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/2656.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 1 addition & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
5 changes: 3 additions & 2 deletions core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
101 changes: 101 additions & 0 deletions core/test/inbox-workflow-prefix.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
7 changes: 4 additions & 3 deletions patches/ds-bot/0003-flybot-api-client.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
101 changes: 101 additions & 0 deletions patches/ds-bot/files/test/inbox-workflow-prefix.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
Loading