Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1381-workflow-settings-hint.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Settings → Workflows says the Workflow plugin is not installed or not enabled when `@flybot/workflow` is missing, instead of `Unknown FlyBot endpoint workflow/state`, and keeps Retry (#1381).
28 changes: 23 additions & 5 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ var ZH = {
"No workflows yet.": "还没有工作流。",
"Reading workflows…": "正在读取工作流…",
"Could not read workflows": "读不到工作流。",
"The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.": "工作流插件没有安装,或没有启用。默认安装包含 @flybot/workflow。",
"Could not copy this workflow": "这个工作流复制不了。",
"Could not save this workflow": "这个工作流保存不了。",
"Use this": "用这个",
Expand Down Expand Up @@ -14354,10 +14355,23 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }
// src/client/workflows.js
var import_react34 = require("react");
var READ_MS = 8e3;
var WORKFLOW_PLUGIN_HINT = "The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.";
var COLORS = { Planner: "#8b5cf6", Writer: "#f0509a", Coder: "#1fbf6a", Reviewer: "#f5a524", Tester: "#14b8a6", Deployer: "#2f7cf6" };
function workflowCallMissing(failure) {
const code = failure && typeof failure === "object" ? String(failure.code ?? "") : "";
const message = typeof failure === "string" ? failure : String(failure?.message ?? "");
return code === "bot/not-found" || /Unknown FlyBot endpoint workflow\//i.test(message);
}
function workflowCallError(failure, fallback = "Could not read workflows") {
if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT;
if (typeof failure === "string" && failure.trim()) return failure.trim();
const message = typeof failure?.message === "string" ? failure.message.trim() : "";
return message || fallback;
}
function settleWorkflowRead(value, fallback = "Could not read workflows") {
if (!value || value.ok === false || !Array.isArray(value.workflows)) {
const error = typeof value?.error === "string" && value.error.trim() ? value.error : fallback;
const raw = typeof value?.error === "string" && value.error.trim() ? value.error : "";
const error = workflowCallMissing(value) || workflowCallMissing(raw) ? WORKFLOW_PLUGIN_HINT : raw || fallback;
return { phase: "error", error, pack: null };
}
const templates = Array.isArray(value.templates) ? value.templates : [];
Expand All @@ -14376,7 +14390,7 @@ function readWorkflows(call4, { timeoutMs = READ_MS } = {}) {
resolve(value);
};
const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs);
Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: failure?.message || "Could not read workflows", pack: null }));
Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null }));
});
}
function moveNode(nodes, id, x, y) {
Expand Down Expand Up @@ -14585,6 +14599,10 @@ function Row2({ item, source, onOpen }) {
(0, import_react34.createElement)("span", { className: "bt-chevron", "aria-hidden": true }, "›")
);
}
function alertCopy(error) {
if (error === WORKFLOW_PLUGIN_HINT) return t("The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.");
return error || t("Could not read workflows");
}
function WorkflowsView({ phase = "loading", pack = null, error = "", opened = null, editing = false, roster = null, onOpen, onBack, onEditing, onRetry, onUse, onCommit }) {
const graph = phase === "ready" ? openedGraph(pack, opened) : null;
const templates = pack?.templates ?? [];
Expand All @@ -14598,7 +14616,7 @@ function WorkflowsView({ phase = "loading", pack = null, error = "", opened = nu
error ? (0, import_react34.createElement)(
"div",
{ className: "bt-error", role: "alert" },
error || t("Could not read workflows"),
alertCopy(error),
phase === "error" ? (0, import_react34.createElement)("button", { type: "button", onClick: () => onRetry?.() }, t("Retry")) : null
) : null,
phase === "loading" ? (0, import_react34.createElement)("p", { className: "bt-wf-status", role: "status" }, t("Reading workflows…")) : null,
Expand Down Expand Up @@ -14710,7 +14728,7 @@ function WorkflowsPage({ call: call4, roster }) {
setEditing(true);
}
}).catch((failure) => {
setError(failure?.message || t("Could not copy this workflow"));
setError(workflowCallError(failure, t("Could not copy this workflow")));
});
};
const commit = (graph) => {
Expand All @@ -14725,7 +14743,7 @@ function WorkflowsPage({ call: call4, roster }) {
setPhase("ready");
setError("");
}).catch((failure) => {
setError(failure?.message || t("Could not save this workflow"));
setError(workflowCallError(failure, t("Could not save this workflow")));
});
};
return (0, import_react34.createElement)(WorkflowsView, {
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const ZH = {
'No workflows yet.': '还没有工作流。',
'Reading workflows…': '正在读取工作流…',
'Could not read workflows': '读不到工作流。',
'The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.':
'工作流插件没有安装,或没有启用。默认安装包含 @flybot/workflow。',
'Could not copy this workflow': '这个工作流复制不了。',
'Could not save this workflow': '这个工作流保存不了。',
'Use this': '用这个',
Expand Down
36 changes: 31 additions & 5 deletions core/src/client/workflows.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,31 @@ import { t } from './i18n.js'

export const READ_MS = 8000

// Shown when @flybot/workflow is not registered. A default install includes it;
// the page says so instead of the host's "Unknown FlyBot endpoint workflow/…" string.
const WORKFLOW_PLUGIN_HINT = 'The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.'

const COLORS = { Planner: '#8b5cf6', Writer: '#f0509a', Coder: '#1fbf6a', Reviewer: '#f5a524', Tester: '#14b8a6', Deployer: '#2f7cf6' }

// Social maps bot/not-found and "Unknown FlyBot endpoint social/…" the same way.
// A workflow call uses that shape when the plugin is missing or switched off.
function workflowCallMissing(failure) {
const code = failure && typeof failure === 'object' ? String(failure.code ?? '') : ''
const message = typeof failure === 'string' ? failure : String(failure?.message ?? '')
return code === 'bot/not-found' || /Unknown FlyBot endpoint workflow\//i.test(message)
}

function workflowCallError(failure, fallback = 'Could not read workflows') {
if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT
if (typeof failure === 'string' && failure.trim()) return failure.trim()
const message = typeof failure?.message === 'string' ? failure.message.trim() : ''
return message || fallback
}

export function settleWorkflowRead(value, fallback = 'Could not read workflows') {
if (!value || value.ok === false || !Array.isArray(value.workflows)) {
const error = typeof value?.error === 'string' && value.error.trim() ? value.error : fallback
const raw = typeof value?.error === 'string' && value.error.trim() ? value.error : ''
const error = workflowCallMissing(value) || workflowCallMissing(raw) ? WORKFLOW_PLUGIN_HINT : (raw || fallback)
return { phase: 'error', error, pack: null }
}
const templates = Array.isArray(value.templates) ? value.templates : []
Expand All @@ -23,6 +43,7 @@ export function settleWorkflowRead(value, fallback = 'Could not read workflows')

// One read of workflow/state. A call that throws, rejects, or never returns
// settles as an error, so the page cannot stay on "Reading workflows…".
// A missing plugin is bot/not-found, so that failure names the plugin.
export function readWorkflows(call, { timeoutMs = READ_MS } = {}) {
return new Promise((resolve) => {
let settled = false
Expand All @@ -36,7 +57,7 @@ export function readWorkflows(call, { timeoutMs = READ_MS } = {}) {
Promise.resolve()
.then(() => call('workflow/state'))
.then((value) => finish(settleWorkflowRead(value)))
.catch((failure) => finish({ phase: 'error', error: failure?.message || 'Could not read workflows', pack: null }))
.catch((failure) => finish({ phase: 'error', error: workflowCallError(failure), pack: null }))
})
}

Expand Down Expand Up @@ -208,6 +229,11 @@ function Row({ item, source, onOpen }) {
h('span', { className: 'bt-chevron', 'aria-hidden': true }, '›'))
}

function alertCopy(error) {
if (error === WORKFLOW_PLUGIN_HINT) return t('The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.')
return error || t('Could not read workflows')
}

export function WorkflowsView({ phase = 'loading', pack = null, error = '', opened = null, editing = false, roster = null, onOpen, onBack, onEditing, onRetry, onUse, onCommit }) {
const graph = phase === 'ready' ? openedGraph(pack, opened) : null
const templates = pack?.templates ?? []
Expand All @@ -216,7 +242,7 @@ export function WorkflowsView({ phase = 'loading', pack = null, error = '', open
const showUse = opened?.source === 'factory' || opened?.fromFactory === true
return h('div', { className: 'bt-wf-page' },
h('p', { className: 'bt-wf-hint' }, t('A workflow is a group of your Bots. Drag the flowchart to change who hands work to whom.')),
error ? h('div', { className: 'bt-error', role: 'alert' }, error || t('Could not read workflows'),
error ? h('div', { className: 'bt-error', role: 'alert' }, alertCopy(error),
phase === 'error' ? h('button', { type: 'button', onClick: () => onRetry?.() }, t('Retry')) : null) : null,
phase === 'loading' ? h('p', { className: 'bt-wf-status', role: 'status' }, t('Reading workflows…')) : null,
phase === 'empty' ? h('p', { className: 'bt-wf-status' }, t('No workflows yet.')) : null,
Expand Down Expand Up @@ -306,7 +332,7 @@ export function WorkflowsPage({ call, roster }) {
setError('')
if (made) { setOpened({ source: 'own', id: made.id }); setEditing(true) }
})
.catch((failure) => { setError(failure?.message || t('Could not copy this workflow')) })
.catch((failure) => { setError(workflowCallError(failure, t('Could not copy this workflow'))) })
}

const commit = (graph) => {
Expand All @@ -319,7 +345,7 @@ export function WorkflowsPage({ call, roster }) {
setPhase('ready')
setError('')
})
.catch((failure) => { setError(failure?.message || t('Could not save this workflow')) })
.catch((failure) => { setError(workflowCallError(failure, t('Could not save this workflow'))) })
}

return h(WorkflowsView, {
Expand Down
69 changes: 67 additions & 2 deletions core/test/workflows-page.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'
import { DICTIONARY } from '../src/client/i18n.js'

const { outputFiles } = await build({
stdin: {
contents: "export { WorkflowsPage, WorkflowsView, readWorkflows, settleWorkflowRead, moveNode, connectNodes, dropBot, removeNode } from './src/client/workflows.js'",
contents: "export { WorkflowsPage, WorkflowsView, readWorkflows, settleWorkflowRead, moveNode, connectNodes, dropBot, removeNode } from './src/client/workflows.js'\nexport { installLocale } from './src/client/i18n.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
Expand All @@ -32,7 +33,21 @@ runInNewContext(outputFiles[0].text, {
throw new Error(`Unexpected platform module ${name}`)
},
})
const { WorkflowsPage, WorkflowsView, readWorkflows, settleWorkflowRead, moveNode, connectNodes, dropBot, removeNode } = module.exports
const { WorkflowsPage, WorkflowsView, readWorkflows, settleWorkflowRead, moveNode, connectNodes, dropBot, removeNode, installLocale } = module.exports

const PLUGIN_HINT = 'The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.'

function fakeLocale(active) {
const dicts = new Map()
return {
register: (ns, locale, dict) => { dicts.set(`${ns}:${locale}`, dict); return () => dicts.delete(`${ns}:${locale}`) },
bind: ns => (key, params) => {
const text = (active === 'zh' ? dicts.get(`${ns}:zh`)?.[key] : undefined) ?? key
return params ? text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : text
},
getLocale: () => ({ active }),
}
}

const expand = (node) => {
if (node === null || node === undefined || typeof node === 'boolean') return null
Expand Down Expand Up @@ -157,6 +172,56 @@ test('a read that never returns becomes an error, and a good payload opens as re
assert.equal(settleWorkflowRead({ ok: true, templates: [], workflows: [] }).phase, 'empty')
})

test('a missing workflow plugin is an enable hint, not the host endpoint string (#1381)', async () => {
const missing = Object.assign(new Error('Unknown FlyBot endpoint workflow/state'), { code: 'bot/not-found' })
const settled = await readWorkflows(() => { throw missing }, { timeoutMs: 1000 })
assert.equal(settled.phase, 'error')
assert.equal(settled.pack, null)
assert.equal(settled.error, PLUGIN_HINT)
assert.doesNotMatch(settled.error, /Unknown FlyBot endpoint/)

const messageOnly = await readWorkflows(() => { throw new Error('Unknown FlyBot endpoint workflow/create') }, { timeoutMs: 1000 })
assert.equal(messageOnly.error, PLUGIN_HINT)
const codeOnly = await readWorkflows(() => { throw Object.assign(new Error('no such route'), { code: 'bot/not-found' }) }, { timeoutMs: 1000 })
assert.equal(codeOnly.error, PLUGIN_HINT)
const graph = await readWorkflows(() => { throw new Error('Unknown FlyBot endpoint workflow/graph') }, { timeoutMs: 1000 })
assert.equal(graph.error, PLUGIN_HINT)
const returned = settleWorkflowRead({ ok: false, error: 'Unknown FlyBot endpoint workflow/state' })
assert.equal(returned.phase, 'error')
assert.equal(returned.error, PLUGIN_HINT)
assert.equal(settleWorkflowRead({ ok: false, error: 'no' }).error, 'no')

const down = await readWorkflows(() => { throw new Error('workflow host down') }, { timeoutMs: 1000 })
assert.equal(down.error, 'workflow host down')
const ready = settleWorkflowRead(pack)
assert.equal(ready.phase, 'ready')
assert.equal(ready.error, '')

const view = renderView({ phase: 'error', error: PLUGIN_HINT, pack: null })
assert.match(text(view), /not installed or not enabled/)
assert.match(text(view), /@flybot\/workflow/)
assert.match(text(view), /Retry/)
assert.doesNotMatch(text(view), /Unknown FlyBot endpoint/)
assert.doesNotMatch(text(view), /Reading workflows/)
const open = renderView({ phase: 'ready', pack, opened: null, error: '' })
assert.match(text(open), /Factory presets/)
assert.doesNotMatch(text(open), /not installed or not enabled/)

assert.equal(DICTIONARY.zh[PLUGIN_HINT], '工作流插件没有安装,或没有启用。默认安装包含 @flybot/workflow。')
const off = installLocale(fakeLocale('zh'))
try {
const zh = renderView({ phase: 'error', error: PLUGIN_HINT, pack: null })
const body = text(zh)
assert.match(body, /工作流插件没有安装,或没有启用/)
assert.match(body, /@flybot\/workflow/)
assert.match(body, /重试/)
assert.doesNotMatch(body, /Unknown FlyBot endpoint/)
assert.doesNotMatch(body, /Retry/)
} finally {
off()
}
})

test('editing the graph moves a node, draws an edge, drops a local Bot and removes one', () => {
const moved = moveNode(own.nodes, 'coder', 40, 80)
assert.equal(moved.find((node) => node.id === 'coder').x, 40)
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second
### New-chat menu

- The + menu is a popover anchored next to the sidebar **+** (#195, #173): its `.bt-pane` carries `data-menu` and stays see-through, the menu is a 280px card at the top left with no title row, and a press on the pane outside the card (or Escape) closes it. New chat, New group chat, New Bot and plugin rows open their full pane as before.
- Settings → **Workflows** (`src/client/workflows.js`, #1364) sits right after **Scheduled tasks**, beside the other automation page, so the page list's existing order and the plugin-slot rule (a `flybot.settings.page` entry is listed after Connectors) are unchanged. It is not a second row in the account menu: the menu keeps its one name per destination. The page reads `@flybot/workflow` through `call('workflow/state')`: **Factory presets** are the template catalog (a drawable graph, so a pipeline can be opened before it is copied) and **Yours** are the workflows saved on this machine whose ids are not those templates. Opening one draws the flowchart; a saved workflow can be dragged (move a node, draw an edge, drop a local Bot, remove a node) and the picture is saved with `workflow/graph`. **Use this** copies a factory template with `workflow/create`. A read that rejects or never returns settles as an error with **Retry**; an empty store says there are no workflows. The page does not teach `workflows/<id>.json`. The definition engine that still stores those files stays on the host and is not this page. Tests: `core/test/workflows-page.test.js`.
- Settings → **Workflows** (`src/client/workflows.js`, #1364) sits right after **Scheduled tasks**, beside the other automation page, so the page list's existing order and the plugin-slot rule (a `flybot.settings.page` entry is listed after Connectors) are unchanged. It is not a second row in the account menu: the menu keeps its one name per destination. The page reads `@flybot/workflow` through `call('workflow/state')`: **Factory presets** are the template catalog (a drawable graph, so a pipeline can be opened before it is copied) and **Yours** are the workflows saved on this machine whose ids are not those templates. Opening one draws the flowchart; a saved workflow can be dragged (move a node, draw an edge, drop a local Bot, remove a node) and the picture is saved with `workflow/graph`. **Use this** copies a factory template with `workflow/create`. A read that rejects or never returns settles as an error with **Retry**; an empty store says there are no workflows. When that read is `bot/not-found`, or the host says `Unknown FlyBot endpoint workflow/…`, the page says the Workflow plugin is not installed or not enabled (a default install includes `@flybot/workflow`) in the interface language, and **Retry** stays (#1381). Any other failure still shows its own reason. The page does not teach `workflows/<id>.json`. The definition engine that still stores those files stays on the host and is not this page. Tests: `core/test/workflows-page.test.js`.
- The sidebar footer button (`ConnectApps` in `sidebar.js`, label **Connect apps** / 连接应用) opens Settings > Connectors through `actions.openConnectors()`, the same call and the same words as the account menu's **Connect apps** row. The shell's plugin panel stays reachable in the Bot surface through the account menu's **Plugins** row (`actions.selectPanel('plugins')`, 插件), without switching to classic Agent mode.
- One name per destination in the account menu and command palette (#392, #393, #394): **Settings** opens the Settings dialog (General), since **Bot settings** / **Edit Bot** open one Bot; **Scheduled tasks** opens Settings → Scheduled tasks, the name Settings and a Bot's details use (DSH's own bundle stays "Automation tasks" in the Plugins hints); and **Switch to classic Agent mode** is the only way to classic Agent mode, so Settings → General is Appearance only.
- Create Bot starts on the Main Bot's model (#230): `startModel()` (`bot-draft.js`) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then `roster.defaultModel`, then the first served model. Only served models are picked, so a provider without a credential is never preselected.
Expand Down
Loading
Loading