From f84097914334ccac78bd20c75b1ea34816361dce Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 10:04:28 +0800 Subject: [PATCH] fix(client): read the Inbox workflow pack through the plugin endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell's Inbox wiring passed the bare `call` into readWorkflows, so the host answered `bot/not-found`, the read settled as "The Workflow plugin is not installed…", and a failed workflow run never reached the Needs list or its badge. It now uses the same `flybot/`-prefixed call the Workflows page does (#2656). Fixes #2656 Docs-not-needed: one-line endpoint-prefix fix; docs/subsystems/core-client.md already describes the Inbox read and its non-ready behavior (#2656) --- changelog.d/2656.fixed.md | 1 + core/client.js | 2 +- core/src/client/index.js | 5 +- core/test/inbox-workflow-prefix.test.js | 101 ++++++++++++++++++ patches/ds-bot/0003-flybot-api-client.patch | 7 +- patches/ds-bot/0008-client-bundle.patch | 2 +- .../files/test/inbox-workflow-prefix.test.js | 101 ++++++++++++++++++ 7 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 changelog.d/2656.fixed.md create mode 100644 core/test/inbox-workflow-prefix.test.js create mode 100644 patches/ds-bot/files/test/inbox-workflow-prefix.test.js diff --git a/changelog.d/2656.fixed.md b/changelog.d/2656.fixed.md new file mode 100644 index 000000000..11d45033c --- /dev/null +++ b/changelog.d/2656.fixed.md @@ -0,0 +1 @@ +The Inbox lists failed workflow runs again: the shell now reads the workflow pack through the plugin's `flybot/workflow/state` endpoint instead of the bare `workflow/state` the host refuses, so a failed run reaches the Needs list and its badge ([#2656](https://github.com/ThinkFlowLab/FlyBot/issues/2656)). diff --git a/core/client.js b/core/client.js index aa28327e2..27a56bbed 100644 --- a/core/client.js +++ b/core/client.js @@ -23394,7 +23394,7 @@ function apply(ctx) { actions.repoStars = () => call("repo-stars"); actions.workflows = (payload) => call("workflows", payload); actions.workflowSave = (payload) => call("workflow-save", payload); - actions.workflowState = () => readWorkflows(call); + actions.workflowState = () => readWorkflows(call2); actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; turnClock.set((state) => { diff --git a/core/src/client/index.js b/core/src/client/index.js index 0173e2649..7a7fd8a3b 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -773,8 +773,9 @@ export function apply(ctx) { actions.workflows = (payload) => call('workflows', payload) actions.workflowSave = (payload) => call('workflow-save', payload) // The Inbox lists failed workflow runs (#1706). It reads the same pack the Workflows page does, - // with the same settle, so a missing plugin is "no runs", not an Inbox error line. - actions.workflowState = () => readWorkflows(call) + // with the same settle, so a missing plugin is "no runs", not an Inbox error line. The host + // routes a plugin only under `flybot/` (#2656), so the bare call never reached it. + actions.workflowState = () => readWorkflows(flybotCall) actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === undefined || !Number.isFinite(turn) || !Number.isFinite(time)) return turnClock.set((state) => { diff --git a/core/test/inbox-workflow-prefix.test.js b/core/test/inbox-workflow-prefix.test.js new file mode 100644 index 000000000..72aaeee30 --- /dev/null +++ b/core/test/inbox-workflow-prefix.test.js @@ -0,0 +1,101 @@ +// #2656: the Inbox's workflow read left the shell on the bare endpoint, so the host +// answered `bot/not-found` and readWorkflows settled every poll as "plugin not +// installed" — a failed run never reached the Inbox. The host routes a plugin only +// under `flybot/` (src/host/channel.js), so the shell's action must use the plugin +// call. This test takes the wiring index.js actually ships and drives it through a +// fetch that answers exactly the way the channel does. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const root = new URL('..', import.meta.url) +const indexSource = readFileSync(new URL('src/client/index.js', root), 'utf8') + +const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useEffect: () => {}, useLayoutEffect: () => {}, useRef: current => ({ current }), + useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', + }, + 'react-dom': {}, + '@deepseek-ai/dsh-client-ui-primitives': {}, +} + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { call as bareCall } from './src/client/api.js'", + "export { call as flybotCall } from './src/client/flybot-slots.js'", + "export { readWorkflows } from './src/client/workflows.js'", + ].join('\n'), + resolveDir: fileURLToPath(root), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +// The real client modules, loaded once; each test gets its own host below. +const load = (fetch) => { + const module = { exports: {} } + runInNewContext(outputFiles[0].text, { + module, exports: module.exports, require: name => platform[name], + fetch, localStorage: { getItem: () => null }, window: { addEventListener: () => {}, removeEventListener: () => {} }, + setTimeout, clearTimeout, setInterval, clearInterval, console, + }) + return module.exports +} + +// src/host/channel.js: a `flybot/` endpoint reaches the plugin, anything else is +// refused with bot/not-found. The refusal is what readWorkflows maps to its hint. +const host = (pack) => { + const endpoints = [] + const fetch = async (route, init) => { + assert.equal(route, 'api/bot', 'the shell posts its calls on the shared channel') + const { endpoint } = JSON.parse(init.body) + endpoints.push(endpoint) + const body = endpoint.startsWith('flybot/') + ? { ok: true, value: pack } + : { ok: false, error: { code: 'bot/not-found', message: `Unknown endpoint ${endpoint}` } } + return { ok: true, json: async () => body } + } + return { endpoints, fetch } +} + +const pack = { + templates: [], + workflows: [{ id: 'delivery', name: 'Delivery', runs: [{ taskId: 't1', status: 'failed' }] }], +} + +test('#2656: the shell wires the Inbox workflow read to the plugin call', async () => { + const wired = /actions\.workflowState = \(\) => readWorkflows\(([A-Za-z_$][\w$]*)\)/.exec(indexSource) + assert.ok(wired, 'index.js wires actions.workflowState through readWorkflows') + const { endpoints, fetch } = host(pack) + const { bareCall, flybotCall, readWorkflows } = load(fetch) + const send = { call: bareCall, flybotCall }[wired[1]] + assert.ok(send, `index.js wires workflowState to "${wired[1]}", which is not a call this test can drive`) + + // The shape index.js builds, over the function it names. + const actions = { workflowState: () => readWorkflows(send) } + const settled = await actions.workflowState() + + assert.deepEqual(endpoints, ['flybot/workflow/state'], + `the host saw ${JSON.stringify(endpoints)}; a plugin read must carry the flybot/ prefix`) + assert.equal(settled.phase, 'ready', `the Inbox read settled as ${settled.phase}: ${settled.error}`) + assert.ok(settled.pack.workflows.length > 0, 'the failed run is offered to the Inbox') +}) + +test('#2656: the bare endpoint is what made every poll a missing-plugin error', async () => { + // The contract the fix relies on: channel.js refuses a bare endpoint, and the + // settle reads that refusal as "no plugin". Kept so the test names the bug even + // if the wiring changes shape again. + const { endpoints, fetch } = host(pack) + const { bareCall, readWorkflows } = load(fetch) + const settled = await readWorkflows(bareCall) + assert.deepEqual(endpoints, ['workflow/state']) + assert.equal(settled.phase, 'error') + assert.match(settled.error, /Workflow plugin is not installed/) +}) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 6ae8d764e..17124d93e 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2017,7 +2017,7 @@ diff --git a/src/client/index.js b/src/client/index.js actions.signInEntry = () => ctx.slots.entries?.('settings.models.sign-in')?.[0] ?? null actions.canSignIn = () => Boolean(actions.signInEntry()) actions.signIn = () => ui.set(state => ({ ...state, signIn: (state.signIn ?? 0) + 1 })) -@@ -647,6 +647,13 @@ export function apply(ctx) { +@@ -647,6 +647,14 @@ export function apply(ctx) { wakeUpdate() } actions.repoStars = () => call('repo-stars') @@ -2026,8 +2026,9 @@ diff --git a/src/client/index.js b/src/client/index.js + actions.workflows = (payload) => call('workflows', payload) + actions.workflowSave = (payload) => call('workflow-save', payload) + // The Inbox lists failed workflow runs (#1706). It reads the same pack the Workflows page does, -+ // with the same settle, so a missing plugin is "no runs", not an Inbox error line. -+ actions.workflowState = () => readWorkflows(call) ++ // with the same settle, so a missing plugin is "no runs", not an Inbox error line. The host ++ // routes a plugin only under `flybot/` (#2656), so the bare call never reached it. ++ actions.workflowState = () => readWorkflows(flybotCall) actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === undefined || !Number.isFinite(turn) || !Number.isFinite(time)) return turnClock.set((state) => { diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index add89549b..2dfac50c5 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -26737,7 +26737,7 @@ diff --git a/client.js b/client.js actions.repoStars = () => call("repo-stars"); + actions.workflows = (payload) => call("workflows", payload); + actions.workflowSave = (payload) => call("workflow-save", payload); -+ actions.workflowState = () => readWorkflows(call); ++ actions.workflowState = () => readWorkflows(call2); actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; turnClock.set((state) => { diff --git a/patches/ds-bot/files/test/inbox-workflow-prefix.test.js b/patches/ds-bot/files/test/inbox-workflow-prefix.test.js new file mode 100644 index 000000000..72aaeee30 --- /dev/null +++ b/patches/ds-bot/files/test/inbox-workflow-prefix.test.js @@ -0,0 +1,101 @@ +// #2656: the Inbox's workflow read left the shell on the bare endpoint, so the host +// answered `bot/not-found` and readWorkflows settled every poll as "plugin not +// installed" — a failed run never reached the Inbox. The host routes a plugin only +// under `flybot/` (src/host/channel.js), so the shell's action must use the plugin +// call. This test takes the wiring index.js actually ships and drives it through a +// fetch that answers exactly the way the channel does. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const root = new URL('..', import.meta.url) +const indexSource = readFileSync(new URL('src/client/index.js', root), 'utf8') + +const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useEffect: () => {}, useLayoutEffect: () => {}, useRef: current => ({ current }), + useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', + }, + 'react-dom': {}, + '@deepseek-ai/dsh-client-ui-primitives': {}, +} + +const { outputFiles } = await build({ + stdin: { + contents: [ + "export { call as bareCall } from './src/client/api.js'", + "export { call as flybotCall } from './src/client/flybot-slots.js'", + "export { readWorkflows } from './src/client/workflows.js'", + ].join('\n'), + resolveDir: fileURLToPath(root), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +// The real client modules, loaded once; each test gets its own host below. +const load = (fetch) => { + const module = { exports: {} } + runInNewContext(outputFiles[0].text, { + module, exports: module.exports, require: name => platform[name], + fetch, localStorage: { getItem: () => null }, window: { addEventListener: () => {}, removeEventListener: () => {} }, + setTimeout, clearTimeout, setInterval, clearInterval, console, + }) + return module.exports +} + +// src/host/channel.js: a `flybot/` endpoint reaches the plugin, anything else is +// refused with bot/not-found. The refusal is what readWorkflows maps to its hint. +const host = (pack) => { + const endpoints = [] + const fetch = async (route, init) => { + assert.equal(route, 'api/bot', 'the shell posts its calls on the shared channel') + const { endpoint } = JSON.parse(init.body) + endpoints.push(endpoint) + const body = endpoint.startsWith('flybot/') + ? { ok: true, value: pack } + : { ok: false, error: { code: 'bot/not-found', message: `Unknown endpoint ${endpoint}` } } + return { ok: true, json: async () => body } + } + return { endpoints, fetch } +} + +const pack = { + templates: [], + workflows: [{ id: 'delivery', name: 'Delivery', runs: [{ taskId: 't1', status: 'failed' }] }], +} + +test('#2656: the shell wires the Inbox workflow read to the plugin call', async () => { + const wired = /actions\.workflowState = \(\) => readWorkflows\(([A-Za-z_$][\w$]*)\)/.exec(indexSource) + assert.ok(wired, 'index.js wires actions.workflowState through readWorkflows') + const { endpoints, fetch } = host(pack) + const { bareCall, flybotCall, readWorkflows } = load(fetch) + const send = { call: bareCall, flybotCall }[wired[1]] + assert.ok(send, `index.js wires workflowState to "${wired[1]}", which is not a call this test can drive`) + + // The shape index.js builds, over the function it names. + const actions = { workflowState: () => readWorkflows(send) } + const settled = await actions.workflowState() + + assert.deepEqual(endpoints, ['flybot/workflow/state'], + `the host saw ${JSON.stringify(endpoints)}; a plugin read must carry the flybot/ prefix`) + assert.equal(settled.phase, 'ready', `the Inbox read settled as ${settled.phase}: ${settled.error}`) + assert.ok(settled.pack.workflows.length > 0, 'the failed run is offered to the Inbox') +}) + +test('#2656: the bare endpoint is what made every poll a missing-plugin error', async () => { + // The contract the fix relies on: channel.js refuses a bare endpoint, and the + // settle reads that refusal as "no plugin". Kept so the test names the bug even + // if the wiring changes shape again. + const { endpoints, fetch } = host(pack) + const { bareCall, readWorkflows } = load(fetch) + const settled = await readWorkflows(bareCall) + assert.deepEqual(endpoints, ['workflow/state']) + assert.equal(settled.phase, 'error') + assert.match(settled.error, /Workflow plugin is not installed/) +})