From 221526c9e61a01a1f3bd72ad797c5442cc8e16bd Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Tue, 27 Oct 2026 12:35:45 +0800 Subject: [PATCH] rebase onto c67ed0e4 (main with #1333) + fix workflow plugin packing and step-4 docs Re-homed the branch onto the current main so the workflow-plugin work in #1330 builds on the merged #1333 running layer. Content is unchanged from 66e79223; only the base moved. Co-authored-by: Cursor Agent --- .github/actions/flybot-tests/action.yml | 9 +- .github/scripts/check-pack.mjs | 2 +- .github/scripts/plugin-imports.test.mjs | 8 +- .github/workflows/nightly.yml | 10 + .github/workflows/test.yml | 4 +- AGENTS.md | 4 +- Dockerfile | 2 +- changelog.d/1318-workflow.added.md | 1 + core/scripts/install-doctor.mjs | 5 +- core/scripts/local-ci.sh | 4 + core/src/host/hooks.js | 26 + core/src/host/tasks.js | 22 + core/test/install-doctor.test.js | 11 +- core/test/task-route.test.js | 96 +++ core/types/flybot.d.ts | 8 + docs/architecture.md | 2 + docs/docker.md | 2 +- docs/docker.zh.md | 2 +- docs/integrating.md | 2 +- docs/subsystems/README.md | 1 + docs/subsystems/core-host.md | 2 +- docs/subsystems/install.md | 6 +- docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/workflow.md | 53 ++ docs/testing-layers.json | 11 +- docs/testing-layers.md | 1 + package.json | 2 +- .../ds-bot/files/scripts/install-doctor.mjs | 5 +- patches/ds-bot/files/scripts/local-ci.sh | 4 + patches/ds-bot/files/src/host/hooks.js | 26 + patches/ds-bot/files/src/host/tasks.js | 22 + .../ds-bot/files/test/install-doctor.test.js | 11 +- patches/ds-bot/files/test/task-route.test.js | 96 +++ patches/ds-bot/files/types/flybot.d.ts | 8 + plugins/README.md | 8 +- plugins/workflow/README.md | 46 + plugins/workflow/client.js | 789 ++++++++++++++++++ plugins/workflow/cordis.patch.yml | 5 + plugins/workflow/index.js | 72 ++ plugins/workflow/package.json | 42 + plugins/workflow/src/graph.js | 382 +++++++++ plugins/workflow/src/match.js | 83 ++ plugins/workflow/src/narrate.js | 67 ++ plugins/workflow/src/service.js | 511 ++++++++++++ plugins/workflow/src/template.js | 83 ++ plugins/workflow/test/assign.test.js | 301 +++++++ plugins/workflow/test/client.test.js | 243 ++++++ plugins/workflow/test/graph.test.js | 155 ++++ scripts/lint-plugins.mjs | 3 +- 49 files changed, 3222 insertions(+), 38 deletions(-) create mode 100644 changelog.d/1318-workflow.added.md create mode 100644 core/test/task-route.test.js create mode 100644 docs/subsystems/workflow.md create mode 100644 patches/ds-bot/files/test/task-route.test.js create mode 100644 plugins/workflow/README.md create mode 100644 plugins/workflow/client.js create mode 100644 plugins/workflow/cordis.patch.yml create mode 100644 plugins/workflow/index.js create mode 100644 plugins/workflow/package.json create mode 100644 plugins/workflow/src/graph.js create mode 100644 plugins/workflow/src/match.js create mode 100644 plugins/workflow/src/narrate.js create mode 100644 plugins/workflow/src/service.js create mode 100644 plugins/workflow/src/template.js create mode 100644 plugins/workflow/test/assign.test.js create mode 100644 plugins/workflow/test/client.test.js create mode 100644 plugins/workflow/test/graph.test.js diff --git a/.github/actions/flybot-tests/action.yml b/.github/actions/flybot-tests/action.yml index 9008493f4..1f8cdf7df 100644 --- a/.github/actions/flybot-tests/action.yml +++ b/.github/actions/flybot-tests/action.yml @@ -80,7 +80,7 @@ runs: shell: bash run: npm run lint:interop # Core's suite (incl. bot-tree: the public flybot.sidebar.structure slot), then each peer - # plugin's own suite and lint (plugins/social, plugins/tree, plugins/timeline; each also tests against core/). + # plugin's own suite and lint (plugins/social, plugins/tree, plugins/timeline, plugins/workflow; each also tests against core/). - name: Unit tests if: inputs.coverage != 'true' shell: bash @@ -111,6 +111,13 @@ runs: env: DSH_HARNESS: ${{ github.workspace }}/core/dev/harness run: npm test + - name: Plugin tests (workflow) + if: inputs.plugins != 'false' + shell: bash + working-directory: plugins/workflow + env: + DSH_HARNESS: ${{ github.workspace }}/core/dev/harness + run: npm test # Core's suite as a coverage run (each plugin runs its own suite). - name: Unit tests with coverage if: inputs.coverage == 'true' diff --git a/.github/scripts/check-pack.mjs b/.github/scripts/check-pack.mjs index 4234e10ee..e4b67706e 100644 --- a/.github/scripts/check-pack.mjs +++ b/.github/scripts/check-pack.mjs @@ -6,7 +6,7 @@ import { execFileSync } from 'node:child_process' import { mkdirSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' -const packages = ['core', 'plugins/social', 'plugins/tree', 'plugins/timeline'] +const packages = ['core', 'plugins/social', 'plugins/tree', 'plugins/timeline', 'plugins/workflow'] const out = process.argv[2] ? resolve(process.argv[2]) : undefined if (out) mkdirSync(out, { recursive: true }) let failed = false diff --git a/.github/scripts/plugin-imports.test.mjs b/.github/scripts/plugin-imports.test.mjs index 42dd6c108..049a7f1b0 100644 --- a/.github/scripts/plugin-imports.test.mjs +++ b/.github/scripts/plugin-imports.test.mjs @@ -55,13 +55,14 @@ test('undeclared bare imports fail; declared deps and node builtins pass', () => } }) -test('social, tree and timeline runtime sources stay inside their package', () => { +test('social, tree, timeline and workflow runtime sources stay inside their package', () => { assert.deepEqual(outboundImports(join(repo, 'plugins/social')), []) assert.deepEqual(outboundImports(join(repo, 'plugins/tree')), []) assert.deepEqual(outboundImports(join(repo, 'plugins/timeline')), []) + assert.deepEqual(outboundImports(join(repo, 'plugins/workflow')), []) }) -test('packed @flybot/social, @flybot/tree and @flybot/timeline import', async () => { +test('packed @flybot/social, @flybot/tree, @flybot/timeline and @flybot/workflow import', async () => { const social = await importPackedPlugin(join(repo, 'plugins/social')) assert.equal(social.name, 'flybot-social') assert.equal(typeof social.apply, 'function') @@ -70,4 +71,7 @@ test('packed @flybot/social, @flybot/tree and @flybot/timeline import', async () const timeline = await importPackedPlugin(join(repo, 'plugins/timeline')) assert.equal(timeline.name, 'flybot-timeline') assert.equal(typeof timeline.apply, 'function') + const workflow = await importPackedPlugin(join(repo, 'plugins/workflow')) + assert.equal(workflow.name, 'flybot-workflow') + assert.equal(typeof workflow.apply, 'function') }) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2ddf041ba..ab139d5ca 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -121,6 +121,11 @@ jobs: env: DSH_HARNESS: ${{ github.workspace }}/core/dev/harness run: npm test + - name: Plugin tests (workflow) + working-directory: plugins/workflow + env: + DSH_HARNESS: ${{ github.workspace }}/core/dev/harness + run: npm test weekly: name: compat · ${{ matrix.os }} · Node ${{ matrix.node }} · DSH ${{ matrix.dsh }} @@ -178,3 +183,8 @@ jobs: env: DSH_HARNESS: ${{ github.workspace }}/core/dev/harness run: npm test + - name: Plugin tests (workflow) + working-directory: plugins/workflow + env: + DSH_HARNESS: ${{ github.workspace }}/core/dev/harness + run: npm test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 77cb2b620..70a80011c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,7 +52,7 @@ jobs: strategy: fail-fast: false matrix: - plugin: [tree, social, timeline] + plugin: [tree, social, timeline, workflow] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -100,7 +100,7 @@ jobs: node-version: 24 - name: ds-bot/ matches the pin, patches apply, core/ is ds-bot/ + patches (#34) run: node scripts/ds-bot-patches.mjs check - - name: Pack and check core (ds-bot), plugins/social, plugins/tree, plugins/timeline + - name: Pack and check core (ds-bot), plugins/social, plugins/tree, plugins/timeline, plugins/workflow run: node .github/scripts/check-pack.mjs - name: Typecheck the plugin API (types/*.d.ts, strict) working-directory: core diff --git a/AGENTS.md b/AGENTS.md index 20128b1c3..b2eaad9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,7 +276,7 @@ FlyBot is a team of long-lived Bots, built on DeepSeek Harness (DSH). It has two - `ds-bot/`: pristine upstream ds-bot (Fei2411/DeepSeek-Bot), pinned in `ds-bot.pin.json`. **Never edit it.** - `patches/ds-bot/`: FlyBot's ordered patches on it; see `patches/ds-bot/README.md`. - `core/`: `ds-bot/` + the patches (committed, what ships and what tests run on): the Bot features, written as a DSH plugin. The plugin runs in DSH Desktop, in DSH Web and in the desktop app. -- `plugins/social/`, `plugins/tree/`, `plugins/timeline/`: peer plugins `@flybot/social`, `@flybot/tree` and `@flybot/timeline`. +- `plugins/social/`, `plugins/tree/`, `plugins/timeline/`, `plugins/workflow/`: peer plugins `@flybot/social`, `@flybot/tree`, `@flybot/timeline` and `@flybot/workflow`. The Bot team: @@ -330,7 +330,7 @@ After oxlint, `npm run lint` also runs `npm run arch-docs`: if an owned file (se - `core/src/client/`: the browser side (sidebar, chat, panels, themes, characters, the Bot browser panel). - `core/web-search.js`: the `auto` web-search provider, independent of the Bot team code. - `core/test/`: unit tests and fixtures. `core/dev/`: the DSH packages for the tests (`harness/`), mock model, test bench, smoke test, character generator, Desktop scripts for Linux. -- `plugins/social/`, `plugins/tree/` and `plugins/timeline/`: peer plugins `@flybot/social`, `@flybot/tree` and `@flybot/timeline`, not under `core/`. +- `plugins/social/`, `plugins/tree/`, `plugins/timeline/` and `plugins/workflow/`: peer plugins `@flybot/social`, `@flybot/tree`, `@flybot/timeline` and `@flybot/workflow`, not under `core/`. - Architecture and ownership: [docs/architecture.md](docs/architecture.md) and [docs/subsystems/](docs/subsystems/README.md). ## Style diff --git a/Dockerfile b/Dockerfile index af4d0ca56..29c57a0a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # FlyBot in a container (#352): one person, one instance. `dsh web` with the FlyBot core, -# social, tree and timeline packages of this checkout. Build it yourself; nothing is published. +# social, tree, workflow and timeline packages of this checkout. Build it yourself; nothing is published. # docker compose up -d --build (see docs/docker.md) # No keys or tokens are baked in: DEEPSEEK_API_KEY comes from the environment or from the # credentials file on the data volume. diff --git a/changelog.d/1318-workflow.added.md b/changelog.d/1318-workflow.added.md new file mode 100644 index 000000000..07c0cf07c --- /dev/null +++ b/changelog.d/1318-workflow.added.md @@ -0,0 +1 @@ +A Workflow is a group-shaped chat of local Bots. The sidebar opens the run, the flowchart can be dragged into a new graph, and a board task can start a saved pipeline such as 发布交付流水线. A Bot starts one with `run_workflow` or defines one with `define_workflow`. An open task whose title, tags or assignee names a Workflow or a local Bot is handed to that runner; a task that matches nothing stays for a manual pick (#1318). diff --git a/core/scripts/install-doctor.mjs b/core/scripts/install-doctor.mjs index 4bf370d78..9712e4cfb 100644 --- a/core/scripts/install-doctor.mjs +++ b/core/scripts/install-doctor.mjs @@ -1,4 +1,4 @@ -// Install doctor: checks the Node floor and the three plugin packages a FlyBot install +// Install doctor: checks the Node floor and the plugin packages a FlyBot install // needs before the maintainer runs the install commands. Reads the filesystem only: no // network, no tokens, no ~/.dsh. // @@ -16,6 +16,7 @@ const PACKAGES = [ ['core', 'ds-bot'], ['plugins/social', '@flybot/social'], ['plugins/tree', '@flybot/tree'], + ['plugins/workflow', '@flybot/workflow'], ['plugins/timeline', '@flybot/timeline'], ] @@ -75,7 +76,7 @@ export function buildInstallCommands({ dshPackage = '@deepseek-ai/dsh@0.2.0-rc.2', timeline = false, // @flybot/timeline is opt-in (#281): its install command is printed only on request } = {}) { - return ['/core', '/plugins/social', '/plugins/tree', ...(timeline ? ['/plugins/timeline'] : [])] + return ['/core', '/plugins/social', '/plugins/tree', '/plugins/workflow', ...(timeline ? ['/plugins/timeline'] : [])] .map(path => `npx -y ${dshPackage} plugin --profile ${profile} add "github:${repo}#${ref}&path:${path}"`) } diff --git a/core/scripts/local-ci.sh b/core/scripts/local-ci.sh index a34faa374..2659fa57e 100755 --- a/core/scripts/local-ci.sh +++ b/core/scripts/local-ci.sh @@ -61,3 +61,7 @@ echo "=== $step ===" step="plugin tests (timeline)" echo "=== $step ===" (cd ../plugins/timeline && npm test) + +step="plugin tests (workflow)" +echo "=== $step ===" +(cd ../plugins/workflow && npm test) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index e55026138..9eb33cf66 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -1005,6 +1005,22 @@ export function install(rt) { } } + // Board writes (#1318). Listeners see the saved view. The first object returned + // may name a local Bot; tasks.js applies that owner and does not ask again. + const taskWriteListeners = [] + rt.onTaskWrite = async (task, kind) => { + let route = null + for (const fn of taskWriteListeners.slice()) { + try { + const answer = await fn(task, kind) + if (route === null && answer && typeof answer === 'object') route = answer + } catch (error) { + warn('flybot: task write listener failed: %s', error) + } + } + return route + } + const build = (plugin) => { if (plugin === SHARED && built) throw new Error(`flybot: '${SHARED}' is the unscoped service; scope a plugin by its own name`) const fields = fieldsFor(plugin) @@ -1197,6 +1213,16 @@ export function install(rt) { set: (botId, list) => rt.setGrants?.(botId, list), }, approvals, + tasks: { + onWrite(fn) { + if (typeof fn !== 'function') return () => {} + taskWriteListeners.push(fn) + return () => { + const at = taskWriteListeners.indexOf(fn) + if (at >= 0) taskWriteListeners.splice(at, 1) + } + }, + }, tools, api, outcomeOf: exchangeId => outcomes.get(exchangeId), diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 32fa5ee0f..7be7a6401 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -861,6 +861,26 @@ export function install(rt) { return save() } + // A plugin may claim an open card for a local Bot (#1318). The second save + // does not ask again, so a listener cannot re-enter this write. + async function routeOpenTask(task, kind) { + if (typeof rt.onTaskWrite !== 'function') return + let route + try { + route = await rt.onTaskWrite(view(task), kind) + } catch (error) { + rt.warn?.('ds-bot: task route failed: %s', error) + return + } + const next = typeof route?.ownerBotId === 'string' ? route.ownerBotId : '' + if (next === '' || next === task.ownerBotId) return + const bot = botRecord(next) + if (bot === undefined || bot.remote !== undefined) return + task.ownerBotId = next + record(task, null, { by: 'routed' }) + await save() + } + // ---------------------------------------------------------------- channel API rt.taskEndpoint = async function taskEndpoint(endpoint, body, actor) { switch (endpoint) { @@ -928,6 +948,7 @@ export function install(rt) { store()[task.id] = task prune() await save() + await routeOpenTask(task, 'create') return view(task) } case 'task-update': { @@ -941,6 +962,7 @@ export function install(rt) { if (column === 'done' && progress === undefined) task.progress = 100 if (!moved && task.updatedAt === before) record(task, actor, { by: 'updated' }) await save() + await routeOpenTask(task, 'update') return view(task) } case 'task-accept': { diff --git a/core/test/install-doctor.test.js b/core/test/install-doctor.test.js index 3df568b55..5819b4e1a 100644 --- a/core/test/install-doctor.test.js +++ b/core/test/install-doctor.test.js @@ -97,17 +97,18 @@ test('checkPluginPackage: a numeric dsh.bundle.patch fails without throwing', () } }) -test('buildInstallCommands: three install commands for the core, social and tree; timeline only on request (#281)', () => { +test('buildInstallCommands: core, social, tree and workflow; timeline only on request (#281)', () => { const commands = buildInstallCommands() - assert.equal(commands.length, 3) + assert.equal(commands.length, 4) assert.ok(commands.every(command => command.includes('0.2.0-rc.2'))) assert.ok(commands[0].includes('path:/core')) assert.ok(commands[1].includes('path:/plugins/social')) assert.ok(commands[2].includes('path:/plugins/tree')) + assert.ok(commands[3].includes('path:/plugins/workflow')) assert.equal(commands.some(command => command.includes('timeline')), false) const all = buildInstallCommands({ timeline: true }) - assert.equal(all.length, 4) - assert.ok(all[3].includes('path:/plugins/timeline')) + assert.equal(all.length, 5) + assert.ok(all[4].includes('path:/plugins/timeline')) }) test('runDoctor: the real repository passes on a Node version at the floor', () => { @@ -128,7 +129,7 @@ test('runDoctor: an empty root fails the plugin checks', () => { const report = runDoctor({ root: dir, nodeVersion: 'v22.19.0' }) assert.equal(report.ok, false) const pluginChecks = report.checks.filter(check => check.label.includes('plugin package')) - assert.equal(pluginChecks.length, 4) + assert.equal(pluginChecks.length, 5) assert.ok(pluginChecks.every(check => !check.ok)) } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/core/test/task-route.test.js b/core/test/task-route.test.js new file mode 100644 index 000000000..c3c427379 --- /dev/null +++ b/core/test/task-route.test.js @@ -0,0 +1,96 @@ +// A board write can hand an open card to another local Bot (#1318). The listener +// sees the saved view once; a second save for the move does not ask again. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Lead', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d1: { id: 'd1', name: 'Dev1', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + d2: { id: 'd2', name: 'Dev2', role: 'dev', sessionId: 'd2', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + f1: { id: 'f1', name: 'Friend', sessionId: 'f1', createdAt: 4, ownerId: 'owner-1', remote: { handle: 'friend' } }, + }, + rooms: {}, + grants: {}, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-route-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls: [], status: 'idle', + inbox: { nextStep: [], nextTurn: [], remove() { return false } }, + followup(message) { this.calls.push(message); this.inbox.nextTurn.push(message) }, + steer() {}, inject() {}, cancel() {}, async whenIdle() {}, + }) + } + return agents.get(sessionId) + } + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async () => ({ id: 'w1' }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: 'made' }), + resolveAgent: async id => ({ agent: agentOf(id) }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + return rt +} + +test('task-create and task-update apply a local owner and ignore a remote or a throw', async (t) => { + const rt = await start(t) + const seen = [] + rt.flybot.scope('flybot-workflow').tasks.onWrite((task, kind) => { + seen.push({ title: task.title, kind, owner: task.ownerBotId }) + if (String(task.title).includes('route-me')) return { ownerBotId: 'd2' } + if (String(task.title).includes('remote')) return { ownerBotId: 'f1' } + if (String(task.title).includes('missing')) return { ownerBotId: 'nope' } + if (String(task.title).includes('boom')) throw new Error('listener blew up') + return null + }) + + const routed = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'please route-me' }, null) + assert.equal(routed.ownerBotId, 'd2') + assert.equal(seen.filter(item => item.title === 'please route-me').length, 1) + + const stayed = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'leave this card' }, null) + assert.equal(stayed.ownerBotId, 'd1') + + const remote = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'remote hint' }, null) + assert.equal(remote.ownerBotId, 'd1') + + const missing = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'missing bot' }, null) + assert.equal(missing.ownerBotId, 'd1') + + const blown = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'boom' }, null) + assert.equal(blown.ownerBotId, 'd1') + + const updated = await rt.taskEndpoint('task-update', { id: stayed.id, title: 'now route-me' }, null) + assert.equal(updated.ownerBotId, 'd2') + assert.equal(updated.ownerBotName, 'Dev2') + assert.equal(seen.filter(item => item.kind === 'update').length, 1) +}) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index bff6e10b4..1006a9ac4 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -490,6 +490,14 @@ export interface FlyBot { /** 'retry' re-sends a failed approval; an approve resumes the chain after the holder. */ onChange(listener: (approval: Approval) => void): () => void } + /** + * Board task writes (#1318). `fn` hears the saved view after create and update. + * Return `{ ownerBotId }` to move the card onto a local Bot; the host saves that + * move without calling listeners again. Return nothing to leave the owner. + */ + tasks: { + onWrite(fn: (task: Record, kind: 'create' | 'update') => { ownerBotId?: string } | null | undefined | Promise<{ ownerBotId?: string } | null | undefined>): () => void + } tools: { register(tool: FlyBotTool): () => void } api: { /** UI endpoint: POST api/bot { endpoint: 'flybot/', payload }. */ diff --git a/docs/architecture.md b/docs/architecture.md index 0eff84ffb..a9bdb9ce6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,7 @@ FlyBot is a team of long-lived Bots built on DeepSeek Harness (DSH). Two parts s | `plugins/social/` | `@flybot/social` — social posting plugin. | | `plugins/tree/` | `@flybot/tree` — knowledge-tree plugin. | | `plugins/timeline/` | `@flybot/timeline` — where time goes across the local Bot team (swimlanes, communication map, utilization). | +| `plugins/workflow/` | `@flybot/workflow` — a group-shaped chat of local Bots, with a flowchart and task assignment. | | `app/` | Desktop app (Windows/macOS) for the DSH + plugin. | ## Subsystems @@ -22,6 +23,7 @@ Each subsystem has a detail page under [docs/subsystems/](subsystems/README.md) - [plugin-runtime](subsystems/plugin-runtime.md) — FlyBot plugin hooks - [tree](subsystems/tree.md) — `@flybot/tree` - [timeline](subsystems/timeline.md) — `@flybot/timeline` +- [workflow](subsystems/workflow.md) — `@flybot/workflow` - [social](subsystems/social.md) — `@flybot/social` - [install](subsystems/install.md) — scripts and install doctor - [team](subsystems/team.md) — planned human/Chief/specialist authority contract (not shipped); #47–#50 are data/auth/room-membership foundations only diff --git a/docs/docker.md b/docs/docker.md index 0ddaf610d..ef4c93385 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -2,7 +2,7 @@ [中文](docker.zh.md) -One person runs one FlyBot on a Linux machine that stays on, so Bots and schedules keep working with the laptop closed. The container runs `dsh web` (DSH 0.2.0-rc.2, as in the README) with the FlyBot core and the social, tree and timeline plugins of the checkout you build from. Instances talk to each other only through [Bot Social](https://github.com/Birfy/bot-social), which is not in the image; host it yourself. +One person runs one FlyBot on a Linux machine that stays on, so Bots and schedules keep working with the laptop closed. The container runs `dsh web` (DSH 0.2.0-rc.2, as in the README) with the FlyBot core and the social, tree, workflow and timeline plugins of the checkout you build from. Instances talk to each other only through [Bot Social](https://github.com/Birfy/bot-social), which is not in the image; host it yourself. You build the image yourself. FlyBot does not publish an image. diff --git a/docs/docker.zh.md b/docs/docker.zh.md index 4970fc685..59b530e0c 100644 --- a/docs/docker.zh.md +++ b/docs/docker.zh.md @@ -2,7 +2,7 @@ [English](docker.md) -每人在一台一直开着的 Linux 机器上运行一个 FlyBot,合上笔记本后 Bot 和定时任务照常工作。容器里运行 `dsh web`(DSH 0.2.0-rc.2,与 README 相同),带上你构建所用代码里的 FlyBot 核心以及 social、tree、timeline 插件。实例之间只通过 [Bot Social](https://github.com/Birfy/bot-social) 通信;Bot Social 不在镜像里,需要你自己部署。 +每人在一台一直开着的 Linux 机器上运行一个 FlyBot,合上笔记本后 Bot 和定时任务照常工作。容器里运行 `dsh web`(DSH 0.2.0-rc.2,与 README 相同),带上你构建所用代码里的 FlyBot 核心以及 social、tree、workflow、timeline 插件。实例之间只通过 [Bot Social](https://github.com/Birfy/bot-social) 通信;Bot Social 不在镜像里,需要你自己部署。 镜像由你自己构建,FlyBot 不发布镜像。 diff --git a/docs/integrating.md b/docs/integrating.md index dc465aeb1..16e2dab0f 100644 --- a/docs/integrating.md +++ b/docs/integrating.md @@ -33,5 +33,5 @@ Both seams are metadata-safe: no egress, text only, a short `code` instead of pr | --- | --- | --- | | ds-bot-core, core-host, core-client | `core/index.js`, `core/src/`, `core/client.js`, `patches/ds-bot/`, … | Change `core/`, regenerate patches. | | plugin-runtime | `core/src/host/hooks.js`, slots, registry | FlyBot plugin API surface. | -| tree / social | `plugins/tree/`, `plugins/social/` | Independent plugins. | +| tree / social / timeline / workflow | `plugins/tree/`, `plugins/social/`, `plugins/timeline/`, `plugins/workflow/` | Independent plugins. | | install | `core/scripts/`, arch-doc checks | Tooling only. | diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 43dfbebf5..50340d57a 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -10,6 +10,7 @@ Detail pages for each FlyBot subsystem. Each page lists `owned_paths`, what the | plugin-runtime | [plugin-runtime.md](plugin-runtime.md) | `core/src/host/hooks.js`, `core/src/host/flybot-feed.js`, `core/src/client/flybot-slots.js`, `core/src/client/flybot-registry.js`, `core/types/flybot.d.ts`, flybot tests | | tree | [tree.md](tree.md) | `plugins/tree/` | | timeline | [timeline.md](timeline.md) | `plugins/timeline/` | +| workflow | [workflow.md](workflow.md) | `plugins/workflow/` | | social | [social.md](social.md) | `plugins/social/` | | install | [install.md](install.md) | `core/scripts/`, arch-doc tests, `plugins/README.md`, the container image (`Dockerfile`, `compose.yaml`, `docker/`) | | team | [team.md](team.md) | `docs/subsystems/team.md` — **planned contract only** (no code paths; multi-human is not shipped) | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 92a7a67ed..f64fe38d0 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -243,7 +243,7 @@ Accepted/failed/cancelled tasks stay terminal. This records the requester's task disposition, not proof that an external runtime stopped; no runtime abort or side-effect rollback is implied. -A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId` stamped on the send, matching origin when the Bot has several working tasks; `updatedAt` is strictly increasing, so two starts in one millisecond still order) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled. +A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId` stamped on the send, matching origin when the Bot has several working tasks; `updatedAt` is strictly increasing, so two starts in one millisecond still order) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled. After `task-create` and `task-update` save, the host asks `rt.onTaskWrite` (hooks exposes it as `flybot.tasks.onWrite`, [plugin-runtime](plugin-runtime.md)). A returned `ownerBotId` that names a different local Bot is stored and saved once more; that second save does not ask again. A missing, remote or identical id leaves the owner (`core/test/task-route.test.js`, #1318). ### Task input waits diff --git a/docs/subsystems/install.md b/docs/subsystems/install.md index 75397fa50..a27c6126d 100644 --- a/docs/subsystems/install.md +++ b/docs/subsystems/install.md @@ -35,11 +35,11 @@ Tooling for setup checks, the architecture-docs gate and the container image (#3 | `core/test/check-arch-docs.test.js` | Tests for the gate. | | `core/test/install-doctor.test.js` | Install doctor tests. | | `plugins/README.md` | Plugin list. | -| `core/scripts/install-doctor.mjs` | Install doctor: Node floor, the packages (`core`, `plugins/social`, `plugins/tree`, `plugins/timeline`) and the install commands (core, social, tree; `@flybot/timeline` is opt-in and not in the default commands, #281). | +| `core/scripts/install-doctor.mjs` | Install doctor: Node floor, the packages (`core`, `plugins/social`, `plugins/tree`, `plugins/workflow`, `plugins/timeline`) and the install commands (core, social, tree, workflow; `@flybot/timeline` is opt-in and not in the default commands, #281). | | `core/scripts/local-ci.sh` | Local run of CI's steps, including each plugin's suite. | | `core/scripts/replay.mjs` | Dev-only state-journal replay (#511): rebuilds `state.json` up to an entry from `/journal/state.jsonl`; no network or process calls, output is a new 0600 file or stdout, a bad journal is refused whole. See [core-host](core-host.md). | | `core/scripts/delivery-probe.mjs` | Which GitHub endpoint the delivery board's token cannot read (#1211): calls the host's own `probeGitHub()` — the function behind the header's **Diagnose** button — and prints one line per endpoint (`/repos/{repo}`, `/actions/runs`, `/commits/{sha}/check-runs`, `/issues/{n}/comments`, `/pulls`), status plus the setting to fix. `--repo` (repeatable) and `--token-env` (default `GITHUB_TOKEN`); since #1250/#1265 it resolves its repositories through the host's own `resolveRepos()` (`core/src/host/delivery-repo.js`): every `--repo`, else the `[remote "origin"]` of `--workspace ` (default the current folder), else the package's `repository.url` — it probes each one the board would read and prints that repository and its source next to the name, so the CLI and the page cannot disagree. A thin shell, never a second implementation. | -| `Dockerfile` | Packs `core` and the plugins with `check-pack.mjs` (the release tarballs), then a runtime with DSH (pinned like the README), pnpm and tini, as user `node`, one volume `/data`. | +| `Dockerfile` | Packs `core` and the plugins (social, tree, workflow, timeline) with `check-pack.mjs` (the release tarballs), then a runtime with DSH (pinned like the README), pnpm and tini, as user `node`, one volume `/data`. | | `compose.yaml` | One instance: port on the host's `127.0.0.1`, volume `data`, `DEEPSEEK_API_KEY` passed through only when set. | | `docker/entrypoint.mjs`, `docker/profile.mjs` | Each start links the image's packages into `/data/profiles/web` (other plugins and `cordis.patch.yml` kept), `pnpm install`, then `dsh web` in `/data/workspace`. | | `docker/web.patch.yml` | Container-only overlay: `webserver.host: 0.0.0.0` (the CLI refuses `--host 0.0.0.0`; inside the container it reaches only the container's network). | @@ -53,7 +53,7 @@ Tooling for setup checks, the architecture-docs gate and the container image (#3 1. Owned-file changes require a matching subsystem doc in the same PR (or a `Docs-not-needed:` trailer). 2. Empty diff vs base passes. -3. A new peer plugin is added to the install doctor (and its test), `check-pack.mjs`, the CI plugin matrix, `local-ci.sh` and `plugins/README.md` in the same PR. +3. A new peer plugin is added to the install doctor (and its test), `check-pack.mjs`, the CI plugin matrix, `local-ci.sh` and `plugins/README.md` in the same PR. `@flybot/workflow` is one of the default commands. `@flybot/timeline` stays opt-in. 4. The container image holds no key or token, runs as a normal user, and `compose.yaml` publishes the port on the host's `127.0.0.1` only. CI (`.github/workflows/docker.yml`) builds and start-checks it; nothing is pushed to a registry (owner decision on #352). ## Tests diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index ad9e80a79..6989b04c0 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -60,7 +60,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 8. A user's @ of a friend's Bot in a group is answered in that group (#825): the line reaches the Bot as a room line and its reply comes back as one, which is why `mentionReceipt` (`hooks.js`, relay role `mention`) posts only what went wrong — why the send was denied, held, or failed — and no "sent" line at all. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.room.members` adds rows at the foot of a local group's Members card (not a face or shared room), with `host(endpoint, payload)` for core endpoints (#876). Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). -11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. +11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. `flybot.tasks.onWrite(fn)` (#1318) hears the saved view after `task-create` and `task-update`. The first object returned may name `ownerBotId`. The host applies it when it is a different local Bot, records the move, and saves again without calling listeners. A throw is logged and the next listener still runs. Starting a workflow run stays inside the plugin; this seam only moves the card's owner. 12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. `turn.end` carries `model` (`provider/model`, cleaned) when the turn made a model call (#366): the model of the Session's latest `request/header` (DSH logs one only when it changes, so the last one seen applies; before the feed saw one, the Session's own `requestHeader()`). A turn with no step, and every `'remote'` turn, has none; `turn.start` never has one, since the model is known only once the first request goes out. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, `Helper (Bob)` (`remoteTitle` → `remoteBotTitle`, #384); the approval overlay's recipient uses the same name. One event is not folded from a Session: `decision` (#396) is fired directly by `decisions.js` through `rt.fireFeed` when the host enables `config.decisions`, carrying `{ botId, option, choice, why?, time }` for one considered-and-rejected choice. Every new local Bot is announced with `flybot.on('bot.created', …)`: the first Main Bot onboarding creates (on the first `state` request) and a `duplicate-bot` copy included, so a plugin that adopts new Bots (the tree root) never misses one; `bot.removed` fires after a Bot is deleted. ### Chat-row pending badge diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md new file mode 100644 index 000000000..f9fd69559 --- /dev/null +++ b/docs/subsystems/workflow.md @@ -0,0 +1,53 @@ +--- +subsystem: workflow +owned_paths: + - plugins/workflow/ +--- + +# workflow (`@flybot/workflow`) + +A group-shaped chat of local Bots, with a flowchart the owner can drag (#1318). The plugin stores the graphs under `$DSH_HOME/bot/flybot-workflow` and answers `flybot/workflow/*`. It lives entirely under `plugins/workflow/`. + +This is not the Settings workflow-definition engine. That engine (#1272) stores `workflows/.json` and guards artifacts. This plugin does not read or write those files and does not call that engine. + +## Owns + +Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, the browser chat and the flowchart canvas. + +## Does not own + +- `flybot.api.handle`, `flybot.tools.register` and `flybot.tasks.onWrite` → [plugin-runtime](plugin-runtime.md). +- The board's own state machine (`task-create`, `task-update`, the task drive) → [core-host](core-host.md). The plugin hears a saved card and may name a local Bot; the host applies that owner. +- Install commands → [install](install.md). `@flybot/workflow` is a default install. `@flybot/timeline` stays opt-in. + +## Key files + +| File | Role | +| --- | --- | +| `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, and the board listener. | +| `plugins/workflow/src/graph.js` | Nodes, edges and positions. A node starts when every predecessor is done. Same-rank nodes run together. A cycle is refused. | +| `plugins/workflow/src/service.js` | The store, template copies, `saveGraph`, `define`, `runNamed`, `ingest`. | +| `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | +| `plugins/workflow/src/template.js` | 发布交付流水线 (a parallel branch) and the weekly report. | +| `plugins/workflow/src/narrate.js` | Progress lines for a step. A join line is only for a rank of more than one node. | +| `plugins/workflow/client.js` | Sidebar section, chat stage, assign dialog, and the flowchart editor. | + +## Entry points + +- Endpoint `workflow/state` and `workflow/templates` are reads. +- `workflow/create`, `workflow/assign`, `workflow/graph`, `workflow/advance`, `workflow/demo-step`, `workflow/reset`, `workflow/say`, `workflow/ingest` write. +- Tool `run_workflow` starts a named run with a brief. Tool `define_workflow` writes the same graph the canvas edits. +- `flybot.tasks.onWrite` calls `ingest`. A new Bot match returns `{ ownerBotId }`. A Workflow match starts the run inside the plugin and leaves the card's owner to the host. + +## Invariants + +1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher. +2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`. Status highlighting reads that same graph. Replacing edges does not recompute who is already running. +3. v1 does not call a model and does not create a schedule. Progress lines are scripted. `scheduleLabel` is display text. +4. Auto-assign considers an `open` card only. First hit wins: a `workflow:` token, then `workflowId` / `workflow`, then an assignee that names a workflow, then a `bot:` token, then an assignee that names a local Bot, then the longest trigger contained in the title, note or criteria. A miss returns no route. A second ingest of the same task and target is a no-op, and a live run for that task id is not restarted. +5. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup. +6. `enabled: false` registers no endpoint, tool or listener and writes nothing. + +## Tests + +`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, templates, `define` / `runNamed`, the matcher, and the client canvas. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. diff --git a/docs/testing-layers.json b/docs/testing-layers.json index 04f7f6388..330967c74 100644 --- a/docs/testing-layers.json +++ b/docs/testing-layers.json @@ -8,7 +8,7 @@ "guarantee": "The cheapest layer that can catch a defect: it must be deterministic and hermetic — no network, no model, no wall-clock or calendar dependence, no ordering between files.", "mustNot": "Reach the network, call a model, read the clock, or depend on a machine's home directory.", "budget": "The whole layer under 10 minutes on a cold runner. A suite that cannot fit is either split or moved up, not added.", - "command": "node .github/scripts/testing-layers.mjs --check && npm --prefix core test && npm --prefix plugins/social test && npm --prefix plugins/tree test && npm --prefix plugins/timeline test && node --test \".github/scripts/*.test.mjs\" && npm run test:interop" + "command": "node .github/scripts/testing-layers.mjs --check && npm --prefix core test && npm --prefix plugins/social test && npm --prefix plugins/tree test && npm --prefix plugins/timeline test && npm --prefix plugins/workflow test && node --test \".github/scripts/*.test.mjs\" && npm run test:interop" }, "L2": { "name": "merge", @@ -64,6 +64,15 @@ "command": "npm --prefix plugins/timeline test", "note": "Peer plugin; its CI job also runs the plugin lint and fails a skipped test." }, + { + "id": "plugins/workflow", + "layer": "L1", + "paths": [ + "plugins/workflow/test/**/*.test.js" + ], + "command": "npm --prefix plugins/workflow test", + "note": "Peer plugin; its CI job also runs the plugin lint and fails a skipped test." + }, { "id": "scripts", "layer": "L1", diff --git a/docs/testing-layers.md b/docs/testing-layers.md index fb49475ce..ba5b18ac2 100644 --- a/docs/testing-layers.md +++ b/docs/testing-layers.md @@ -81,6 +81,7 @@ The tables below are generated: edit `docs/testing-layers.json`, then run | `plugins/social` | L1 | `plugins/social/test/**/*.test.js` | `npm --prefix plugins/social test` | | `plugins/tree` | L1 | `plugins/tree/test/**/*.test.js` | `npm --prefix plugins/tree test` | | `plugins/timeline` | L1 | `plugins/timeline/test/**/*.test.js` | `npm --prefix plugins/timeline test` | +| `plugins/workflow` | L1 | `plugins/workflow/test/**/*.test.js` | `npm --prefix plugins/workflow test` | | `scripts` | L1 | `.github/scripts/*.test.mjs` | `node --test ".github/scripts/*.test.mjs"` | | `interop` | L1 | `dev/interop/**/*.test.mjs` | `npm run test:interop` | | `patch-payload` | none (see reason) | `patches/ds-bot/files/**` | — Generated copies of the files the patches carry. They are compared against `core/` by `node scripts/ds-bot-patches.mjs check` (L2) and never run on their own; the code under test is the copy in `core/`. | diff --git a/package.json b/package.json index b4d5a8e6a..9dfba3784 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "node": ">=22" }, "scripts": { - "verify": "npm --prefix core run lint && node scripts/lint-plugins.mjs && node core/scripts/build-client.mjs --check && node scripts/ds-bot-patches.mjs check && npm --prefix core test && npm --prefix plugins/social test && npm --prefix plugins/tree test && npm --prefix plugins/timeline test && node --test \".github/scripts/*.test.mjs\" && npm run lint:interop && npm run test:interop && npm run lint:live-e2e && npm run test:live-e2e", + "verify": "npm --prefix core run lint && node scripts/lint-plugins.mjs && node core/scripts/build-client.mjs --check && node scripts/ds-bot-patches.mjs check && npm --prefix core test && npm --prefix plugins/social test && npm --prefix plugins/tree test && npm --prefix plugins/timeline test && npm --prefix plugins/workflow test && node --test \".github/scripts/*.test.mjs\" && npm run lint:interop && npm run test:interop && npm run lint:live-e2e && npm run test:live-e2e", "lint:interop": "node scripts/lint-interop.mjs", "test:interop": "node --import ./dev/interop/no-network.mjs --test \"dev/interop/test/*.test.mjs\"", "demo:interop": "node --import ./dev/interop/no-network.mjs dev/interop/demo.mjs", diff --git a/patches/ds-bot/files/scripts/install-doctor.mjs b/patches/ds-bot/files/scripts/install-doctor.mjs index 4bf370d78..9712e4cfb 100644 --- a/patches/ds-bot/files/scripts/install-doctor.mjs +++ b/patches/ds-bot/files/scripts/install-doctor.mjs @@ -1,4 +1,4 @@ -// Install doctor: checks the Node floor and the three plugin packages a FlyBot install +// Install doctor: checks the Node floor and the plugin packages a FlyBot install // needs before the maintainer runs the install commands. Reads the filesystem only: no // network, no tokens, no ~/.dsh. // @@ -16,6 +16,7 @@ const PACKAGES = [ ['core', 'ds-bot'], ['plugins/social', '@flybot/social'], ['plugins/tree', '@flybot/tree'], + ['plugins/workflow', '@flybot/workflow'], ['plugins/timeline', '@flybot/timeline'], ] @@ -75,7 +76,7 @@ export function buildInstallCommands({ dshPackage = '@deepseek-ai/dsh@0.2.0-rc.2', timeline = false, // @flybot/timeline is opt-in (#281): its install command is printed only on request } = {}) { - return ['/core', '/plugins/social', '/plugins/tree', ...(timeline ? ['/plugins/timeline'] : [])] + return ['/core', '/plugins/social', '/plugins/tree', '/plugins/workflow', ...(timeline ? ['/plugins/timeline'] : [])] .map(path => `npx -y ${dshPackage} plugin --profile ${profile} add "github:${repo}#${ref}&path:${path}"`) } diff --git a/patches/ds-bot/files/scripts/local-ci.sh b/patches/ds-bot/files/scripts/local-ci.sh index a34faa374..2659fa57e 100755 --- a/patches/ds-bot/files/scripts/local-ci.sh +++ b/patches/ds-bot/files/scripts/local-ci.sh @@ -61,3 +61,7 @@ echo "=== $step ===" step="plugin tests (timeline)" echo "=== $step ===" (cd ../plugins/timeline && npm test) + +step="plugin tests (workflow)" +echo "=== $step ===" +(cd ../plugins/workflow && npm test) diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index e55026138..9eb33cf66 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -1005,6 +1005,22 @@ export function install(rt) { } } + // Board writes (#1318). Listeners see the saved view. The first object returned + // may name a local Bot; tasks.js applies that owner and does not ask again. + const taskWriteListeners = [] + rt.onTaskWrite = async (task, kind) => { + let route = null + for (const fn of taskWriteListeners.slice()) { + try { + const answer = await fn(task, kind) + if (route === null && answer && typeof answer === 'object') route = answer + } catch (error) { + warn('flybot: task write listener failed: %s', error) + } + } + return route + } + const build = (plugin) => { if (plugin === SHARED && built) throw new Error(`flybot: '${SHARED}' is the unscoped service; scope a plugin by its own name`) const fields = fieldsFor(plugin) @@ -1197,6 +1213,16 @@ export function install(rt) { set: (botId, list) => rt.setGrants?.(botId, list), }, approvals, + tasks: { + onWrite(fn) { + if (typeof fn !== 'function') return () => {} + taskWriteListeners.push(fn) + return () => { + const at = taskWriteListeners.indexOf(fn) + if (at >= 0) taskWriteListeners.splice(at, 1) + } + }, + }, tools, api, outcomeOf: exchangeId => outcomes.get(exchangeId), diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index 32fa5ee0f..7be7a6401 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -861,6 +861,26 @@ export function install(rt) { return save() } + // A plugin may claim an open card for a local Bot (#1318). The second save + // does not ask again, so a listener cannot re-enter this write. + async function routeOpenTask(task, kind) { + if (typeof rt.onTaskWrite !== 'function') return + let route + try { + route = await rt.onTaskWrite(view(task), kind) + } catch (error) { + rt.warn?.('ds-bot: task route failed: %s', error) + return + } + const next = typeof route?.ownerBotId === 'string' ? route.ownerBotId : '' + if (next === '' || next === task.ownerBotId) return + const bot = botRecord(next) + if (bot === undefined || bot.remote !== undefined) return + task.ownerBotId = next + record(task, null, { by: 'routed' }) + await save() + } + // ---------------------------------------------------------------- channel API rt.taskEndpoint = async function taskEndpoint(endpoint, body, actor) { switch (endpoint) { @@ -928,6 +948,7 @@ export function install(rt) { store()[task.id] = task prune() await save() + await routeOpenTask(task, 'create') return view(task) } case 'task-update': { @@ -941,6 +962,7 @@ export function install(rt) { if (column === 'done' && progress === undefined) task.progress = 100 if (!moved && task.updatedAt === before) record(task, actor, { by: 'updated' }) await save() + await routeOpenTask(task, 'update') return view(task) } case 'task-accept': { diff --git a/patches/ds-bot/files/test/install-doctor.test.js b/patches/ds-bot/files/test/install-doctor.test.js index 3df568b55..5819b4e1a 100644 --- a/patches/ds-bot/files/test/install-doctor.test.js +++ b/patches/ds-bot/files/test/install-doctor.test.js @@ -97,17 +97,18 @@ test('checkPluginPackage: a numeric dsh.bundle.patch fails without throwing', () } }) -test('buildInstallCommands: three install commands for the core, social and tree; timeline only on request (#281)', () => { +test('buildInstallCommands: core, social, tree and workflow; timeline only on request (#281)', () => { const commands = buildInstallCommands() - assert.equal(commands.length, 3) + assert.equal(commands.length, 4) assert.ok(commands.every(command => command.includes('0.2.0-rc.2'))) assert.ok(commands[0].includes('path:/core')) assert.ok(commands[1].includes('path:/plugins/social')) assert.ok(commands[2].includes('path:/plugins/tree')) + assert.ok(commands[3].includes('path:/plugins/workflow')) assert.equal(commands.some(command => command.includes('timeline')), false) const all = buildInstallCommands({ timeline: true }) - assert.equal(all.length, 4) - assert.ok(all[3].includes('path:/plugins/timeline')) + assert.equal(all.length, 5) + assert.ok(all[4].includes('path:/plugins/timeline')) }) test('runDoctor: the real repository passes on a Node version at the floor', () => { @@ -128,7 +129,7 @@ test('runDoctor: an empty root fails the plugin checks', () => { const report = runDoctor({ root: dir, nodeVersion: 'v22.19.0' }) assert.equal(report.ok, false) const pluginChecks = report.checks.filter(check => check.label.includes('plugin package')) - assert.equal(pluginChecks.length, 4) + assert.equal(pluginChecks.length, 5) assert.ok(pluginChecks.every(check => !check.ok)) } finally { rmSync(dir, { recursive: true, force: true }) diff --git a/patches/ds-bot/files/test/task-route.test.js b/patches/ds-bot/files/test/task-route.test.js new file mode 100644 index 000000000..c3c427379 --- /dev/null +++ b/patches/ds-bot/files/test/task-route.test.js @@ -0,0 +1,96 @@ +// A board write can hand an open card to another local Bot (#1318). The listener +// sees the saved view once; a second save for the move does not ask again. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const team = () => ({ + version: 2, + revision: 1, + mainBotIds: ['a'], + members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } }, + bots: { + a: { id: 'a', name: 'Chief', role: 'Lead', sessionId: 'a', createdAt: 1, ownerId: 'owner-1' }, + d1: { id: 'd1', name: 'Dev1', role: 'Dev', sessionId: 'd1', createdAt: 2, ownerId: 'owner-1', createdBy: 'a' }, + d2: { id: 'd2', name: 'Dev2', role: 'dev', sessionId: 'd2', createdAt: 3, ownerId: 'owner-1', createdBy: 'a' }, + f1: { id: 'f1', name: 'Friend', sessionId: 'f1', createdAt: 4, ownerId: 'owner-1', remote: { handle: 'friend' } }, + }, + rooms: {}, + grants: {}, + agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, +}) + +async function start(t) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-route-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(team())) + const ctx = fakeContext() + const agents = new Map() + const agentOf = (sessionId) => { + if (!agents.has(sessionId)) { + agents.set(sessionId, { + session: { id: sessionId }, id: sessionId, calls: [], status: 'idle', + inbox: { nextStep: [], nextTurn: [], remove() { return false } }, + followup(message) { this.calls.push(message); this.inbox.nextTurn.push(message) }, + steer() {}, inject() {}, cancel() {}, async whenIdle() {}, + }) + } + return agents.get(sessionId) + } + Object.assign(ctx, { + llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, + workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async () => ({ id: 'w1' }), archiveSession: async () => {} }, + sessionController: { + create: async () => ({ sessionId: 'made' }), + resolveAgent: async id => ({ agent: agentOf(id) }), + selectModel: async () => {}, + page: async () => ({ records: [], hasMore: false }), + }, + sessionTitle: { rename: async () => {} }, + sessions: { flush: async () => {} }, + }) + const rt = createRuntime(ctx, { home }) + await rt.load() + t.after(async () => { + await ctx.dispose() + rmSync(home, { recursive: true, force: true, maxRetries: 5 }) + }) + return rt +} + +test('task-create and task-update apply a local owner and ignore a remote or a throw', async (t) => { + const rt = await start(t) + const seen = [] + rt.flybot.scope('flybot-workflow').tasks.onWrite((task, kind) => { + seen.push({ title: task.title, kind, owner: task.ownerBotId }) + if (String(task.title).includes('route-me')) return { ownerBotId: 'd2' } + if (String(task.title).includes('remote')) return { ownerBotId: 'f1' } + if (String(task.title).includes('missing')) return { ownerBotId: 'nope' } + if (String(task.title).includes('boom')) throw new Error('listener blew up') + return null + }) + + const routed = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'please route-me' }, null) + assert.equal(routed.ownerBotId, 'd2') + assert.equal(seen.filter(item => item.title === 'please route-me').length, 1) + + const stayed = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'leave this card' }, null) + assert.equal(stayed.ownerBotId, 'd1') + + const remote = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'remote hint' }, null) + assert.equal(remote.ownerBotId, 'd1') + + const missing = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'missing bot' }, null) + assert.equal(missing.ownerBotId, 'd1') + + const blown = await rt.taskEndpoint('task-create', { botId: 'd1', title: 'boom' }, null) + assert.equal(blown.ownerBotId, 'd1') + + const updated = await rt.taskEndpoint('task-update', { id: stayed.id, title: 'now route-me' }, null) + assert.equal(updated.ownerBotId, 'd2') + assert.equal(updated.ownerBotName, 'Dev2') + assert.equal(seen.filter(item => item.kind === 'update').length, 1) +}) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index bff6e10b4..1006a9ac4 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -490,6 +490,14 @@ export interface FlyBot { /** 'retry' re-sends a failed approval; an approve resumes the chain after the holder. */ onChange(listener: (approval: Approval) => void): () => void } + /** + * Board task writes (#1318). `fn` hears the saved view after create and update. + * Return `{ ownerBotId }` to move the card onto a local Bot; the host saves that + * move without calling listeners again. Return nothing to leave the owner. + */ + tasks: { + onWrite(fn: (task: Record, kind: 'create' | 'update') => { ownerBotId?: string } | null | undefined | Promise<{ ownerBotId?: string } | null | undefined>): () => void + } tools: { register(tool: FlyBotTool): () => void } api: { /** UI endpoint: POST api/bot { endpoint: 'flybot/', payload }. */ diff --git a/plugins/README.md b/plugins/README.md index 5f61d23be..f74b11650 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,7 +1,7 @@ # FlyBot plugins Peer plugin packages at the repository root: `social` (`@flybot/social`), `tree` -(`@flybot/tree`) and `timeline` (`@flybot/timeline`). They are not part of `core/` and are not carried by any +(`@flybot/tree`), `workflow` (`@flybot/workflow`) and `timeline` (`@flybot/timeline`). They are not part of `core/` and are not carried by any `patches/ds-bot/` patch. ## Install doctor @@ -13,8 +13,8 @@ cd core npm run install:doctor ``` -It checks the Node floor and the four packages in the repository -(`core`, `plugins/social`, `plugins/tree`, `plugins/timeline`) and prints the `dsh plugin add` -commands for the clean-profile install: `path:/core`, `path:/plugins/social` and `path:/plugins/tree`. +It checks the Node floor and the packages in the repository +(`core`, `plugins/social`, `plugins/tree`, `plugins/workflow`, `plugins/timeline`) and prints the `dsh plugin add` +commands for the clean-profile install: `path:/core`, `path:/plugins/social`, `path:/plugins/tree` and `path:/plugins/workflow`. `@flybot/timeline` is opt-in (off by default), so its command is not in that list; see the README. It reads the filesystem only: no network, no tokens, no `~/.dsh`. diff --git a/plugins/workflow/README.md b/plugins/workflow/README.md new file mode 100644 index 000000000..d73a0496c --- /dev/null +++ b/plugins/workflow/README.md @@ -0,0 +1,46 @@ +# @flybot/workflow + +A Workflow is a group-shaped chat of local Bots. The sidebar row opens the run: a pill header, progress, bubbles, a composer, and a flowchart. The same graph is what a drag-drop edit saves and what a run highlights. + +This is not the Settings workflow-definition engine (`workflows/.json`, #1272). This plugin does not read or write those files. + +## What you can do + +- Open a template. **发布交付流水线** is Planner → Writer → Coder → (Reviewer ‖ Tester) → Deployer. **每周报告** is a short chain with a Friday label. +- Create or edit a graph in the flowchart. Drag a local Bot onto the canvas, move a node, draw or drop an edge. The host stores nodes, edges and positions. +- Hand a board task to a Workflow, or to one local Bot, from the assign dialog. +- Let an open card assign itself. The matcher is `src/match.js`, first hit, and only while the card is `open`: + 1. a `workflow:` token in the title, note, criteria or tags + 2. `workflowId` or `workflow` + 3. `assignee` naming a workflow + 4. a `bot:` token + 5. `assignee` naming a local Bot (a remote Bot never matches) + 6. a workflow trigger contained in the title, note or criteria (longest trigger wins) +- A miss stays unassigned for the manual picker. A Bot match sets that Bot as the card's owner so the task drive can wake it. A Workflow match starts the scripted run and posts progress in the Workflow chat. + +## Tools + +A local Bot uses the same store: + +- `run_workflow` — `workflow` (id or exact name) and `brief` (the task). Optional `task` id. +- `define_workflow` — `name`, `nodes` (`{ id, name, botId?, x?, y? }`), `edges` (`{ from, to }`). Pass `workflow` to replace one. A friend's Bot is refused. + +v1 does not call each Bot's model. A step posts a progress line and moves the node. + +## Install + +Default, with the core, social and tree: + +```sh +npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab/FlyBot#main&path:/plugins/workflow" +``` + +`enabled: false` registers nothing. + +## Develop + +```sh +npm test +``` + +Endpoints, all under `flybot/workflow/`: `state`, `templates` (read), `create`, `assign`, `advance`, `demo-step`, `reset`, `say`, `graph`, `ingest`. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js new file mode 100644 index 000000000..c44b6ff80 --- /dev/null +++ b/plugins/workflow/client.js @@ -0,0 +1,789 @@ +// Browser half of @flybot/workflow. A workflow is a row in the sidebar and, once +// opened, the main column: pill header, progress strip, bubbles, composer, and +// a flowchart drawer. The host owns the graph; this file only draws it. +// Every string is a React text child. require('react') is the shell's React. +window.__ModuleLoader__.load({ + id: '@flybot/workflow', + factory: (require) => { + const { createElement: h, useEffect, useRef, useState } = require('react') + let portal = (node) => node + try { + const dom = require('react-dom') + if (typeof dom?.createPortal === 'function') portal = dom.createPortal.bind(dom) + } catch (error) { + // The unit renderer supplies react only. The chat then stays in the section tree. + void error + } + + const ZH = { + Workflows: 'Workflows', + New: '新建', + 'Hand a task to a Workflow': '把任务交给 Workflow', + 'A task can go to a Workflow, not only to one Bot. The Workflow then runs each Bot in order, or side by side.': '任务不再只能交给单个 Bot —— 交给 Workflow 后,会按固定流程依次(或并行)交给各个 Bot 执行。', + 'Pick a task card': '从 Tasks 看板选择任务卡片', + 'Pick who runs it': '选择执行者', + Cancel: '取消', + Assign: '确认分配', + Workflow: 'Workflow', + 'One Bot (as before)': '单个 Bot(原有方式)', + 'Run demo': '演示运行', + Pause: '暂停', + Next: '下一步', + Reset: '重置', + Flow: '流程图', + Details: 'Details', + Runs: '运行记录', + 'Running now': '运行中', + Queued: '排队中', + Running: '运行中', + Done: '已完成', + Failed: '失败', + Idle: '空闲', + 'Not started': '待办', + 'In progress': '进行中', + 'Close': '关闭', + 'New workflow': '新建 Workflow', + Name: '名称', + 'Add a Bot': '添加 Bot', + Parallel: '与上一步并行', + Create: '创建', + 'Local Bots': '本机 Bot', + 'No workflows yet.': '还没有 Workflow。', + 'Waiting for a task': '等待任务', + 'Message the workflow': '发消息', + Trigger: '触发方式', + Manual: '手动,从任务看板分配', + Schedule: '定时', + Model: '模型', + 'Each Bot’s own model': '跟随各个 Bot 的模型', + Permissions: '权限', + 'Each Bot’s own permission': '跟随各个 Bot 的权限', + 'On failure': '失败策略', + 'Tester retries once. Any other failed step stops the run.': 'Tester 失败后自动重试 1 次。其他节点失败则停住。', + 'A failed step stops the run.': '失败的节点停住,等你处理。', + Members: '成员', + 'Current run': '当前运行', + 'No runs yet.': '还没有运行记录。', + 'Assign the task': '确认分配', + Edit: '编辑', + 'Finish editing': '完成', + 'Drag a Bot here': '把 Bot 拖到图上', + 'From a template': '从模板新建', + Remove: '移除', + } + + function zhUi() { + const host = globalThis.__flybot__?.t + return typeof host === 'function' && host('Tasks') === '任务' + } + function fill(text, params) { + return params ? text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : text + } + function tr(key, params) { + const host = globalThis.__flybot__?.t + const fromHost = typeof host === 'function' ? host(key, params) : key + if (fromHost !== key) return fromHost + const text = zhUi() && Object.hasOwn(ZH, key) ? ZH[key] : key + return fill(text, params) + } + + const STATUS_KEY = { queued: 'Queued', running: 'Running', done: 'Done', failed: 'Failed', idle: 'Idle' } + const COLUMN_KEY = { 'not-started': 'Not started', 'in-progress': 'In progress', done: 'Done' } + const COLUMN_OF = { open: 'not-started', claimed: 'not-started', working: 'in-progress', blocked: 'in-progress', 'result-ready': 'in-progress', accepted: 'done', cancelled: 'done', failed: 'done' } + const COLORS = { Planner: '#8b5cf6', Writer: '#f0509a', Coder: '#1fbf6a', Reviewer: '#f5a524', Tester: '#14b8a6', Deployer: '#2f7cf6', '研究员': '#a78bfa', '数据分析师': '#ec4899' } + + function columnOf(task) { + if (task.column && COLUMN_KEY[task.column]) return task.column + return COLUMN_OF[task.status] ?? 'not-started' + } + function priorityOf(task) { + if (task.priority === 'urgent') return 'P0' + if (task.priority === 'high' || task.priority === 'P1') return 'P1' + if (task.priority === 'low' || task.priority === 'P3') return 'P3' + return 'P2' + } + function shortId(id) { + const text = String(id ?? '') + if (text.startsWith('#')) return text + if (text.startsWith('task-')) return `#${text.slice(-6)}` + return text + } + const FIXTURE_TASKS = [ + { id: '#1280', title: '缩短交付组群 notice', column: 'in-progress', priority: 'P1' }, + { id: '#1281', title: '发布说明:Workflow Beta 上线', column: 'not-started', priority: 'P1' }, + { id: '#1276', title: '修复群聊 @ 提及跳转错位', column: 'not-started', priority: 'P2' }, + ] + + function glyph() { + return h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', 'aria-hidden': true }, + h('rect', { x: 9, y: 2, width: 6, height: 5, rx: 1.5 }), + h('rect', { x: 2, y: 17, width: 6, height: 5, rx: 1.5 }), + h('rect', { x: 16, y: 17, width: 6, height: 5, rx: 1.5 }), + h('path', { d: 'M12 7v4M5 17v-3h14v3' })) + } + + function Mark({ node, roster, size = 28 }) { + const bot = node?.botId ? roster?.byId?.[node.botId] : null + const Avatar = globalThis.__flybot__?.BotAvatar + if (bot && typeof Avatar === 'function') return h(Avatar, { bot, size, badge: false, live: false }) + const color = COLORS[node?.name] ?? '#4d6bfe' + return h('span', { className: 'wf-av', style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) } }, (node?.name ?? '?').slice(0, 1)) + } + + function nodeOf(workflow, id) { + return workflow?.nodes?.find(node => node.id === id) + } + + function Stage({ workflow, roster, playing, drawer, tab, draft, onDraft, onCloseDrawer, onToggleDrawer, onTab, onPlay, onNext, onReset, onAssign, onSend, onTip, editing, onToggleEdit, onCommit }) { + const chatRef = useRef(null) + const run = workflow.run + const names = workflow.nodes.map(node => node.name).join(', ') + useEffect(() => { + const el = chatRef.current + if (el) el.scrollTop = el.scrollHeight + }, [workflow.messages.length, playing]) + const running = (run?.running ?? []).map(id => nodeOf(workflow, id)).filter(Boolean) + return h('div', { className: 'wf-stage', style: { left: conversationLeft() } }, + h('div', { className: 'wf-main' }, + h('div', { className: 'wf-top' }, + h('button', { type: 'button', className: 'wf-pill', onClick: onToggleDrawer }, + h('span', { className: 'wf-stack' }, workflow.nodes.slice(0, 4).map(node => h(Mark, { key: node.id, node, roster, size: 22 }))), + h('span', { className: 'wf-pill-name' }, workflow.name), + h('span', { className: 'wf-sub' }, `${workflow.nodes.length} Bots`), + h('span', { className: 'wf-chev', 'aria-hidden': true }, '▾')), + h('div', { className: 'wf-actions' }, + h('button', { type: 'button', className: 'wf-btn', onClick: onAssign }, tr('Hand a task to a Workflow')), + h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onPlay }, playing ? tr('Pause') : `▶ ${tr('Run demo')}`))), + run ? h('div', { className: 'wf-strip' }, + h('div', { className: 'wf-strip-id' }, + h('div', { className: 'wf-tk' }, `${run.task?.id ?? ''} ${run.task?.title ?? ''}`), + h('div', { className: 'wf-meta' }, running[0] + ? `${tr('Running now')} · ${running.map(node => node.name).join(' ‖ ')} · ${run.doneCount}/${run.nodeCount}` + : (run.status === 'done' ? tr('Done') : tr('Waiting for a task')))), + h('div', { className: 'wf-segs' }, (run.marks ?? []).map((mark, index) => h('span', { key: index, className: `wf-seg ${mark}` }))), + h('span', { className: `wf-chip ${run.status === 'done' ? 'done' : (running[0] ? 'running' : 'queued')}` }, run.status === 'done' ? tr('Done') : (running[0] ? tr('Running') : tr('Queued'))), + h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onTab('flow') }, tr('Flow'))) : null, + h('div', { className: 'wf-chat', ref: chatRef }, + h('div', { className: 'wf-chat-in' }, + workflow.messages.map(message => h(Bubble, { key: message.id, message, workflow, roster })), + playing ? running.map(node => h('div', { key: `type-${node.id}`, className: 'wf-msg' }, + h(Mark, { node, roster, size: 28 }), + h('div', null, + h('div', { className: 'wf-who' }, node.name, h('span', { className: 'wf-step' }, tr('Running'))), + h('div', { className: 'wf-bub wf-typing' }, h('i'), h('i'), h('i'))))) : null)), + h('div', { className: 'wf-composer-wrap' }, + h('form', { className: 'wf-composer', onSubmit: (event) => { event.preventDefault(); onSend() } }, + h('input', { + value: draft, placeholder: `${tr('Message the workflow')} ${workflow.name} (${names})`, + onChange: (event) => onDraft(event.target.value), + }), + h('button', { type: 'submit', className: 'wf-mic', 'aria-label': tr('Assign') }, '↑')), + h('div', { className: 'wf-under' }, run ? `运行 ${run.steps}/${run.stepBudget} 步 · 已完成 ${run.doneCount} 个节点` : null))), + drawer ? h('aside', { className: 'wf-drawer' }, + h('div', { className: 'wf-dr-head' }, + h('button', { type: 'button', className: 'wf-icon', 'aria-label': tr('Close'), onClick: onCloseDrawer }, '×')), + h(DrawerBody, { workflow, roster, tab, onTab, onPlay, onNext, onReset, playing, onTip, editing, onToggleEdit, onCommit })) : null) + } + + function Bubble({ message, workflow, roster }) { + if (message.kind === 'time') return h('div', { className: 'wf-ts' }, message.text) + if (message.kind === 'system') return h('div', { className: `wf-sys ${message.tone ?? ''}` }, message.text) + if (message.kind === 'user') return h('div', { className: 'wf-user' }, message.text) + const node = nodeOf(workflow, message.botId) ?? { id: message.botId, name: message.botId } + return h('div', { className: 'wf-msg' }, + h(Mark, { node, roster, size: 28 }), + h('div', null, + h('div', { className: 'wf-who' }, node.name, message.step ? h('span', { className: 'wf-step' }, `步骤 ${message.step}`) : null), + h('div', { className: 'wf-bub' }, message.text, + message.attachments?.length ? h('div', { className: 'wf-att' }, message.attachments.map(name => h('span', { key: name }, name))) : null))) + } + + function DrawerBody({ workflow, roster, tab, onTab, onPlay, onNext, onReset, playing, onTip, editing, onToggleEdit, onCommit }) { + const run = workflow.run + return h('div', { className: 'wf-dr-body' }, + h('div', { className: 'wf-dr-title' }, + h('div', { className: 'wf-ico wf-ico-lg' }, glyph()), + h('h2', null, workflow.name), + h('p', null, workflow.description)), + h('div', { className: 'wf-tabs' }, + ['flow', 'details', 'runs'].map(key => h('button', { + key, type: 'button', className: tab === key ? 'on' : '', onClick: () => onTab(key), + }, tr(key === 'flow' ? 'Flow' : key === 'details' ? 'Details' : 'Runs')))), + tab === 'flow' ? h(Flow, { workflow, run, roster, onTip, editing, onCommit }) : null, + tab === 'details' ? h(Details, { workflow }) : null, + tab === 'runs' ? h(Runs, { workflow }) : null, + h('div', { className: 'wf-ctl' }, + h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onPlay }, playing ? tr('Pause') : tr('Run demo')), + h('button', { type: 'button', className: 'wf-btn', onClick: onNext }, `${tr('Next')} ›`), + h('button', { type: 'button', className: 'wf-btn', onClick: onReset }, `↺ ${tr('Reset')}`), + h('button', { type: 'button', className: 'wf-btn', onClick: onToggleEdit }, editing ? tr('Finish editing') : tr('Edit')))) + } + + function nextNodeId(nodes) { + let n = nodes.length + 1 + const ids = new Set(nodes.map(node => node.id)) + while (ids.has(`n${n}`)) n += 1 + return `n${n}` + } + + function hitNode(event) { + const direct = event.target?.dataset?.node ? event.target : null + const nested = event.target?.closest?.('[data-node]') + return (nested ?? direct)?.dataset?.node || '' + } + + function wirePath(from, to) { + const x1 = from.x + const y1 = from.y + 58 + const x2 = to.x + const y2 = to.y + const mid = (y1 + y2) / 2 + return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}` + } + + // The flowchart is the graph: positions and edges are the saved model, and + // the status chip is that node's run state. Edit mode drags nodes, draws + // edges from the bottom port, and drops a local Bot onto the canvas. + function Flow({ workflow, run, roster, onTip, editing, onCommit }) { + const [gesture, setGesture] = useState(null) + const [ghost, setGhost] = useState(null) + const status = run?.nodeStatus ?? {} + const nodes = workflow.nodes.map(node => ghost?.[node.id] ? { ...node, ...ghost[node.id] } : node) + const edges = workflow.edges ?? [] + const height = Math.max(420, ...nodes.map(node => (node.y ?? 0) + 150), 0) + const locals = (roster?.bots ?? []).filter(bot => bot && bot.remote !== true && !nodes.some(node => node.botId === bot.id)) + const commit = (nextNodes, nextEdges) => onCommit?.({ nodes: nextNodes, edges: nextEdges }) + const movePointer = (event) => { + if (!gesture || gesture.kind !== 'move') return + setGhost({ [gesture.id]: { x: Math.round(gesture.x + event.clientX - gesture.ox), y: Math.round(gesture.y + event.clientY - gesture.oy) } }) + } + const endPointer = (event) => { + if (!gesture) return + const hit = hitNode(event) + if (gesture.kind === 'move' && ghost?.[gesture.id]) { + commit(nodes.map(node => node.id === gesture.id ? { ...node, ...ghost[gesture.id] } : node), edges) + } else if (gesture.kind === 'wire' && hit && hit !== gesture.from) { + commit(workflow.nodes, edges.concat([{ from: gesture.from, to: hit }])) + } else if (gesture.kind === 'reconnect') { + const rest = edges.filter(edge => !(edge.from === gesture.from && edge.to === gesture.to)) + if (hit && hit !== gesture.from) commit(workflow.nodes, rest.concat([{ from: gesture.from, to: hit }])) + else if (!hit) commit(workflow.nodes, rest) + } else if (gesture.kind === 'add' && event.target?.dataset?.plane) { + const bot = gesture.bot + const x = Number.isFinite(event.offsetX) ? Math.round(event.offsetX) : 24 + const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY) : 24 + commit(workflow.nodes.concat([{ id: nextNodeId(workflow.nodes), name: bot.name, botId: bot.id, role: bot.role ?? '', x, y }]), edges) + } + setGesture(null) + setGhost(null) + } + return h('div', { className: 'wf-canvas', onPointerMove: movePointer, onPointerUp: endPointer }, + editing ? h('div', { className: 'wf-palette' }, + h('div', { className: 'wf-muted' }, tr('Drag a Bot here')), + locals.map(bot => h('button', { + key: bot.id, type: 'button', className: 'wf-chip', 'data-bot': bot.id, + onPointerDown: (event) => { event.preventDefault?.(); setGesture({ kind: 'add', bot }) }, + }, bot.name))) : null, + h('div', { className: 'wf-term' }, run?.task ? `${run.task.id} ${run.task.title}` : tr('Waiting for a task')), + h('div', { className: 'wf-plane', 'data-plane': '1', style: { height } }, + h('svg', { className: 'wf-wires', 'aria-hidden': true }, + edges.map(edge => { + const from = nodes.find(node => node.id === edge.from) + const to = nodes.find(node => node.id === edge.to) + if (!from || !to) return null + return h('g', { key: `${edge.from}>${edge.to}` }, + h('path', { d: wirePath(from, to) }), + editing ? h('circle', { + cx: to.x, cy: to.y, r: 7, className: 'wf-end', + 'data-port': 'end', 'data-src': edge.from, 'data-dst': edge.to, + onPointerDown: (event) => { event.preventDefault?.(); event.stopPropagation?.(); setGesture({ kind: 'reconnect', from: edge.from, to: edge.to }) }, + }) : null) + })), + (workflow.layers ?? []).filter(layer => layer.length > 1).map(layer => { + const members = layer.map(id => nodes.find(node => node.id === id)).filter(Boolean) + if (members.length < 2) return null + const minX = Math.min(...members.map(node => node.x)) + const maxX = Math.max(...members.map(node => node.x)) + const y = Math.min(...members.map(node => node.y)) + return h('div', { + key: layer.join('+'), className: 'wf-par wf-abs', + style: { left: minX - 108, top: y - 18, width: maxX - minX + 216, height: 92 }, + }, h('div', { className: 'wf-plabel' }, '并行分支 · 全部通过后汇合')) + }), + nodes.map(node => h('div', { + key: node.id, + 'data-node': node.id, + className: `wf-node wf-abs ${status[node.id] ?? 'queued'}`, + style: { left: (node.x ?? 0) - 84, top: node.y ?? 0 }, + onPointerDown: (event) => { + if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return + setGesture({ kind: 'move', id: node.id, ox: event.clientX, oy: event.clientY, x: node.x, y: node.y }) + }, + onMouseEnter: (event) => onTip?.({ x: event.clientX, y: event.clientY, node }), + onMouseLeave: () => onTip?.(null), + }, + editing ? h('button', { + type: 'button', className: 'wf-port out', 'data-port': 'out', 'aria-label': node.name, + onPointerDown: (event) => { event.preventDefault?.(); event.stopPropagation?.(); setGesture({ kind: 'wire', from: node.id }) }, + }) : null, + editing ? h('button', { + type: 'button', className: 'wf-mini', 'data-remove': node.id, 'aria-label': tr('Remove'), + onClick: (event) => { + event.stopPropagation?.() + commit(workflow.nodes.filter(item => item.id !== node.id), edges.filter(edge => edge.from !== node.id && edge.to !== node.id)) + }, + }, '×') : null, + h(FlowNode, { node, status: status[node.id] ?? 'queued', roster })))), + run?.status === 'done' ? h('div', { className: 'wf-term on' }, tr('Done')) : null) + } + + function FlowNode({ node, status, roster }) { + return [ + h('span', { className: `wf-chip ${status}` }, tr(STATUS_KEY[status] ?? 'Idle')), + h(Mark, { node, roster, size: 28 }), + h('span', { className: 'wf-nt' }, + h('span', { className: 'wf-nm' }, node.name), + h('span', { className: 'wf-rl' }, node.role)), + ] + } + + function Details({ workflow }) { + const trigger = workflow.trigger === 'schedule' + ? `${tr('Schedule')} · ${workflow.scheduleLabel}` + : tr('Manual') + const retries = workflow.nodes.some(node => node.failOnce) + return h('div', { className: 'wf-box' }, + h('div', { className: 'wf-kv' }, h('span', null, tr('Trigger')), h('span', null, trigger)), + h('div', { className: 'wf-kv' }, h('span', null, tr('Model')), h('span', null, tr('Each Bot’s own model'))), + h('div', { className: 'wf-kv' }, h('span', null, tr('Permissions')), h('span', null, tr('Each Bot’s own permission'))), + h('div', { className: 'wf-kv' }, h('span', null, tr('On failure')), h('span', null, tr(retries ? 'Tester retries once. Any other failed step stops the run.' : 'A failed step stops the run.'))), + h('div', { className: 'wf-kv' }, h('span', null, tr('Members')), h('span', null, workflow.chain))) + } + + function Runs({ workflow }) { + const history = workflow.run?.history ?? [] + const current = workflow.run?.task + if (!current && history.length === 0) return h('div', { className: 'wf-muted' }, tr('No runs yet.')) + const rows = history.map(item => ({ ...item, current: false })) + if (current) rows.push({ task: current, status: workflow.run.status, current: true }) + return h('div', null, rows.map((item, index) => h('div', { key: index, className: 'wf-hist' }, + h('span', null, `${item.task?.id ?? ''} ${item.task?.title ?? ''}`), + h('span', { className: `wf-chip ${item.status === 'done' ? 'done' : item.status === 'running' ? 'running' : 'queued'}` }, tr(item.status === 'done' ? 'Done' : item.status === 'running' ? 'Running' : 'Queued'))))) + } + + function AssignModal({ workflows, tasks, roster, workflowId, onClose, onConfirm }) { + const [taskId, setTaskId] = useState(tasks[1]?.id ?? tasks[0]?.id ?? '') + const [target, setTarget] = useState(workflowId || workflows[0]?.id || '') + const bots = (roster?.bots ?? []).filter(bot => bot && bot.remote !== true && bot.id !== roster?.mainBotId).slice(0, 1) + return h('div', { className: 'wf-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } }, + h('div', { className: 'wf-modal', role: 'dialog', 'aria-modal': true, 'aria-label': tr('Hand a task to a Workflow') }, + h('h3', null, tr('Hand a task to a Workflow'), + h('button', { type: 'button', className: 'wf-icon', 'aria-label': tr('Close'), onClick: onClose }, '×')), + h('p', { className: 'wf-hint' }, tr('A task can go to a Workflow, not only to one Bot. The Workflow then runs each Bot in order, or side by side.')), + h('div', { className: 'wf-step-h' }, h('b', null, '1'), tr('Pick a task card')), + h('div', { className: 'wf-cards' }, tasks.map(task => h('button', { + key: task.id, type: 'button', className: `wf-kcard ${taskId === task.id ? 'sel' : ''}`, onClick: () => setTaskId(task.id), + }, + h('div', { className: 'wf-id' }, `${shortId(task.id)} · ${priorityOf(task)}`), + h('div', { className: 'wf-tt' }, task.title), + h('div', { className: 'wf-kcol' }, `● ${tr(COLUMN_KEY[columnOf(task)] ?? 'Not started')}`)))), + h('div', { className: 'wf-step-h' }, h('b', null, '2'), tr('Pick who runs it')), + h('div', { className: 'wf-targets' }, + workflows.map(item => h('button', { + key: item.id, type: 'button', className: `wf-tgt ${target === item.id ? 'sel' : ''}`, onClick: () => setTarget(item.id), + }, + h('span', { className: 'wf-ico' }, glyph()), + h('span', null, + h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, tr('Workflow'))), + h('span', { className: 'wf-chain' }, item.chain)), + h('span', { className: 'wf-rad' }))), + bots.map(bot => h('button', { + key: bot.id, type: 'button', className: `wf-tgt ${target === `bot:${bot.id}` ? 'sel' : ''}`, onClick: () => setTarget(`bot:${bot.id}`), + }, + h(Mark, { node: { name: bot.name, botId: bot.id }, roster, size: 30 }), + h('span', null, + h('span', { className: 'wf-t1' }, bot.name), + h('span', { className: 'wf-chain' }, tr('One Bot (as before)'))), + h('span', { className: 'wf-rad' })))), + h('div', { className: 'wf-mfoot' }, + h('button', { type: 'button', className: 'wf-btn', onClick: onClose }, tr('Cancel')), + h('button', { type: 'button', className: 'wf-btn wf-dark', onClick: () => onConfirm({ taskId, target }) }, tr('Assign'))))) + } + + function CreateModal({ roster, templates, onClose, onCreate, onTemplate }) { + const locals = (roster?.bots ?? []).filter(bot => bot && bot.remote !== true) + const [name, setName] = useState('') + const [steps, setSteps] = useState([]) + const [parallel, setParallel] = useState(false) + const [botId, setBotId] = useState(locals[0]?.id ?? '') + const add = () => { + const bot = locals.find(item => item.id === botId) + if (!bot) return + setSteps(list => { + const node = { id: `n${list.length + 1}`, botId: bot.id, name: bot.name, role: bot.role ?? '' } + if (parallel && list.length > 0) { + const next = list.slice() + next[next.length - 1] = [...next[next.length - 1], node] + return next + } + return [...list, [node]] + }) + } + return h('div', { className: 'wf-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } }, + h('div', { className: 'wf-modal', role: 'dialog', 'aria-modal': true, 'aria-label': tr('New workflow') }, + h('h3', null, tr('New workflow'), + h('button', { type: 'button', className: 'wf-icon', 'aria-label': tr('Close'), onClick: onClose }, '×')), + (templates ?? []).length > 0 ? h('div', { className: 'wf-field' }, tr('From a template'), + h('div', { className: 'wf-add' }, (templates ?? []).map(item => h('button', { + key: item.id, type: 'button', className: 'wf-btn', onClick: () => onTemplate?.(item.id), + }, item.name)))) : null, + h('label', { className: 'wf-field' }, tr('Name'), + h('input', { value: name, onChange: (event) => setName(event.target.value) })), + h('div', { className: 'wf-field' }, tr('Local Bots'), + h('div', { className: 'wf-add' }, + h('select', { value: botId, onChange: (event) => setBotId(event.target.value) }, + locals.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name))), + h('label', { className: 'wf-par-toggle' }, + h('input', { type: 'checkbox', checked: parallel, onChange: (event) => setParallel(event.target.checked) }), + tr('Parallel')), + h('button', { type: 'button', className: 'wf-btn', onClick: add }, tr('Add a Bot')))), + h('ol', { className: 'wf-plan' }, steps.map((layer, index) => h('li', { key: index }, layer.map(node => node.name).join(' ‖ ')))), + h('div', { className: 'wf-mfoot' }, + h('button', { type: 'button', className: 'wf-btn', onClick: onClose }, tr('Cancel')), + h('button', { type: 'button', className: 'wf-btn wf-dark', onClick: () => onCreate({ name, steps }) }, tr('Create'))))) + } + + function conversationLeft() { + if (typeof document === 'undefined') return 0 + const side = document.querySelector?.('.bt-side') + const right = side?.getBoundingClientRect?.().right + return right > 40 ? right : 0 + } + + function Section({ roster, call, actions }) { + const [pack, setPack] = useState(null) + const [openId, setOpenId] = useState(null) + const [drawer, setDrawer] = useState(false) + const [tab, setTab] = useState('flow') + const [assignOpen, setAssignOpen] = useState(false) + const [creating, setCreating] = useState(false) + const [playing, setPlaying] = useState(false) + const [draft, setDraft] = useState('') + const [tasks, setTasks] = useState(FIXTURE_TASKS) + const [tip, setTip] = useState(null) + const [error, setError] = useState('') + const [editing, setEditing] = useState(false) + const playingRef = useRef(false) + playingRef.current = playing + + const refresh = async () => { + const value = await call('workflow/state') + if (value && Array.isArray(value.workflows)) setPack(value) + else setError(value?.error ?? 'The workflow host did not answer') + return value + } + useEffect(() => { + if (typeof document === 'undefined') return undefined + if (document.getElementById('flybot-workflow-css')) return undefined + const style = document.createElement('style') + style.id = 'flybot-workflow-css' + style.textContent = CSS + document.head.appendChild(style) + return undefined + }, []) + useEffect(() => { + let gone = false + void refresh().then(async () => { + const host = globalThis.__flybot__?.host + if (gone || typeof host !== 'function') return + try { + const rows = await host('tasks', {}) + if (!Array.isArray(rows) || gone) return + for (const task of rows) { + if (!task || task.status !== 'open') continue + const value = await call('workflow/ingest', task) + if (gone) return + if (value?.workflows) setPack(value) + } + } catch (loadError) { + // The board can be absent. Manual assign still lists the fixture cards. + void loadError + } + }) + return () => { gone = true } + }, []) + useEffect(() => { + const root = globalThis.__flybot__ + if (!root) return undefined + const prev = root.openWorkflowAssign + root.openWorkflowAssign = () => { setAssignOpen(true); setOpenId(id => id) } + return () => { root.openWorkflowAssign = prev } + }, []) + useEffect(() => { + if (!playing || !openId) return undefined + const timer = setInterval(() => { void call('workflow/demo-step', { workflowId: openId }).then(refresh).then(value => { + const run = value?.workflows?.find(item => item.id === openId)?.run + if (!run || run.status === 'done') setPlaying(false) + }) }, 1200) + return () => clearInterval(timer) + }, [playing, openId]) + useEffect(() => { + if (!openId || typeof document === 'undefined') return undefined + const onClick = (event) => { + const row = event.target?.closest?.('.bt-row, .bt-board-row') + if (!row || row.closest('[data-flybot="workflow"]')) return + setOpenId(null) + setPlaying(false) + } + document.addEventListener('click', onClick, true) + return () => document.removeEventListener('click', onClick, true) + }, [openId]) + + const workflows = pack?.workflows ?? [] + const open = workflows.find(item => item.id === openId) ?? null + + const loadTasks = async () => { + const host = globalThis.__flybot__?.host + if (typeof host !== 'function') { setTasks(FIXTURE_TASKS); return } + try { + const rows = await host('tasks', {}) + const real = Array.isArray(rows) ? rows.filter(task => task && task.title && task.status !== 'cancelled').slice(0, 12).map(task => ({ + id: task.id, title: task.title, status: task.status, priority: task.priority, column: columnOf(task), + })) : [] + setTasks(real.length > 0 ? real : FIXTURE_TASKS) + } catch (loadError) { + // The board can be absent on a read-only host. The fixture cards still let the owner try the path. + void loadError + setTasks(FIXTURE_TASKS) + } + } + + const confirmAssign = async ({ taskId, target }) => { + const task = tasks.find(item => item.id === taskId) + if (!task) return + if (String(target).startsWith('bot:')) { + const botId = String(target).slice(4) + setAssignOpen(false) + actions?.openSession?.(botId) + return + } + const value = await call('workflow/assign', { workflowId: target, taskId: task.id, title: task.title }) + if (value?.ok === false) { setError(value.error ?? ''); return } + setPack(value) + setOpenId(target) + setAssignOpen(false) + setDrawer(true) + setTab('flow') + } + const create = async ({ name, steps }) => { + const nodes = steps.flat() + if (!name.trim() || nodes.length === 0) return + const value = await call('workflow/create', { + name: name.trim(), + nodes, + layers: steps.map(layer => layer.map(node => node.id)), + }) + if (value?.ok === false) { setError(value.error ?? ''); return } + setPack(value) + const made = value.workflows.find(item => item.name === name.trim()) + setCreating(false) + if (made) setOpenId(made.id) + } + + return h('div', { 'data-flybot': 'workflow' }, + h('div', { className: 'wf-sec' }, + h('span', null, tr('Workflows')), + h('button', { type: 'button', className: 'wf-new', onClick: () => setCreating(true) }, tr('New'))), + workflows.length === 0 ? h('div', { className: 'wf-muted' }, tr('No workflows yet.')) : null, + workflows.map(item => h('button', { + key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`, + onClick: () => { setOpenId(item.id); setPlaying(false) }, + }, + h('span', { className: 'wf-ico' }, glyph()), + h('span', { className: 'wf-txt' }, + h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, 'Workflow')), + h('span', { className: 'wf-t2' }, item.preview || tr('Waiting for a task'))), + item.run?.status === 'running' ? h('span', { className: 'wf-live', 'aria-hidden': true }) : null)), + h('button', { type: 'button', className: 'wf-row wf-assign-row', onClick: () => { setAssignOpen(true); void loadTasks() } }, + h('span', { className: 'wf-ico' }, glyph()), + h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))), + error ? h('div', { className: 'wf-err', role: 'alert' }, error) : null, + open ? portal(h(Stage, { + workflow: open, roster, playing, drawer, tab, draft, onDraft: setDraft, + editing, + onToggleEdit: () => { setEditing(value => !value); setDrawer(true); setTab('flow') }, + onCommit: (graph) => { + void call('workflow/graph', { workflowId: open.id, nodes: graph.nodes, edges: graph.edges }).then(value => { + if (value?.ok === false) { setError(value.error ?? ''); return } + if (value?.workflows) setPack(value) + }) + }, + onCloseDrawer: () => setDrawer(false), + onToggleDrawer: () => { setDrawer(value => !value); setTab('flow') }, + onTab: (key) => { setTab(key); setDrawer(true) }, + onPlay: () => setPlaying(value => !value), + onNext: () => { setPlaying(false); void call('workflow/demo-step', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) }, + onReset: () => { setPlaying(false); void call('workflow/reset', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) }, + onAssign: () => { setAssignOpen(true); void loadTasks() }, + onSend: () => { + const text = draft.trim() + if (!text) return + setDraft('') + void call('workflow/say', { workflowId: open.id, text }).then(value => { if (value?.workflows) setPack(value) }) + }, + onTip: setTip, + })) : null, + assignOpen ? portal(h(AssignModal, { + workflows, tasks, roster, workflowId: openId || workflows[0]?.id, + onClose: () => setAssignOpen(false), onConfirm: confirmAssign, + })) : null, + creating ? portal(h(CreateModal, { + roster, templates: pack?.templates, onClose: () => setCreating(false), onCreate: create, + onTemplate: (template) => { + void call('workflow/create', { template }).then(value => { + if (value?.ok === false) { setError(value.error ?? ''); return } + setPack(value) + setCreating(false) + const made = value.workflows?.find(item => item.name.endsWith('副本')) ?? value.workflows?.at(-1) + if (made) { setOpenId(made.id); setDrawer(true); setTab('flow'); setEditing(true) } + }) + }, + })) : null, + tip ? h('div', { className: 'wf-tip', style: { left: tip.x + 12, top: tip.y + 12 } }, + h('b', null, tip.node.name), + h('div', null, tip.node.role), + h('div', { className: 'wf-k' }, 'Instructions'), + h('div', null, tip.node.instructions)) : null) + } + + const CSS = ` +.wf-sec{display:flex;align-items:center;justify-content:space-between;padding:12px 8px 4px;font-size:11px;color:var(--bt-ink-3,#9a9a9a)} +.wf-new{color:var(--bt-accent,#4d6bfe);background:none;border:0;cursor:pointer;font:inherit;font-size:11px} +.wf-row{display:flex;align-items:center;gap:10px;width:100%;padding:8px;border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer} +.wf-row.on,.wf-row:hover{background:var(--bt-hover,rgba(119,119,119,.09))} +.wf-txt{min-width:0;flex:1} +.wf-t1{display:flex;align-items:center;gap:6px;font-size:13px} +.wf-t2,.wf-chain{display:block;font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.wf-badge{font-size:9.5px;padding:0 5px;border-radius:4px;background:rgba(77,107,254,.12);color:var(--bt-accent,#4d6bfe);line-height:15px} +.wf-live{width:7px;height:7px;border-radius:50%;background:var(--bt-accent,#4d6bfe);flex:none} +.wf-ico{width:32px;height:32px;border-radius:9px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));display:grid;place-items:center;color:var(--bt-accent,#4d6bfe);flex:none} +.wf-ico-lg{width:42px;height:42px;margin:0 auto} +.wf-av{border-radius:50%;display:inline-grid;place-items:center;color:#fff;font-weight:650;flex:none} +.wf-stage{position:fixed;top:0;right:0;bottom:0;z-index:30;display:flex;background:var(--bt-main,#fcfcfc);color:var(--bt-ink,#141414)} +.wf-main{flex:1;min-width:0;display:flex;flex-direction:column} +.wf-top{height:56px;display:flex;align-items:center;justify-content:center;position:relative;flex:none} +.wf-pill{display:flex;align-items:center;gap:8px;height:34px;padding:0 12px 0 8px;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;background:var(--bt-card,#fff);cursor:pointer;color:inherit;font:inherit} +.wf-stack{display:inline-flex}.wf-stack .wf-av,.wf-stack>*{margin-left:-6px}.wf-stack>*:first-child{margin-left:0} +.wf-pill-name{font-weight:600}.wf-sub{font-size:11px;color:var(--bt-ink-3,#9a9a9a)}.wf-chev{color:var(--bt-ink-3,#9a9a9a)} +.wf-actions{position:absolute;right:14px;top:12px;display:flex;gap:6px} +.wf-btn{height:32px;padding:0 12px;border-radius:16px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);color:inherit;font:inherit;font-size:12.5px;cursor:pointer} +.wf-btn.wf-pri{background:var(--bt-accent,#4d6bfe);border-color:var(--bt-accent,#4d6bfe);color:#fff} +.wf-btn.wf-dark{background:var(--bt-user,#070707);color:var(--bt-user-ink,#fcfcfc);border-color:var(--bt-user,#070707)} +.wf-btn.wf-sm{height:28px} +.wf-strip{margin:0 auto 4px;width:min(720px,calc(100% - 48px));border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:9px 12px;display:flex;align-items:center;gap:12px} +.wf-tk{font-weight:600}.wf-meta{font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6))} +.wf-segs{display:flex;gap:3px;flex:1;min-width:80px} +.wf-seg{height:5px;border-radius:3px;background:var(--bt-active,rgba(119,119,119,.17));flex:1} +.wf-seg.done{background:var(--bt-accent,#4d6bfe)}.wf-seg.failed{background:#e5484d}.wf-seg.running{background:rgba(77,107,254,.35)} +.wf-chip{font-size:10.5px;padding:1px 7px;border-radius:10px;background:var(--bt-active,rgba(119,119,119,.17));color:var(--bt-ink-2,rgba(20,20,20,.6))} +.wf-chip.running{background:rgba(77,107,254,.12);color:var(--bt-accent,#4d6bfe)} +.wf-chip.done{background:rgba(22,163,74,.12);color:#16a34a} +.wf-chip.failed{background:rgba(229,72,77,.12);color:#e5484d} +.wf-chat{flex:1;overflow:auto;padding:10px 0 20px} +.wf-chat-in{width:min(760px,calc(100% - 48px));margin:0 auto;display:flex;flex-direction:column;gap:10px} +.wf-ts{text-align:center;font-size:11px;color:var(--bt-ink-3,#9a9a9a)} +.wf-sys{align-self:center;font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));border:1px dashed var(--bt-line,rgba(20,20,20,.08));padding:4px 10px;border-radius:12px;max-width:90%;text-align:center} +.wf-sys.accent{color:var(--bt-accent,#4d6bfe);background:rgba(77,107,254,.1);border-style:solid} +.wf-sys.ok{color:#16a34a;background:rgba(22,163,74,.1);border-color:transparent} +.wf-sys.bad{color:#e5484d;background:rgba(229,72,77,.1);border-color:transparent} +.wf-msg{display:flex;gap:8px;align-items:flex-end;max-width:78%} +.wf-who{font-size:11px;margin:0 0 3px 2px;display:flex;gap:6px} +.wf-step{color:var(--bt-ink-3,#9a9a9a)} +.wf-bub{background:var(--bt-bubble,#eee);border-radius:12px;padding:8px 11px;line-height:1.6} +.wf-att{margin-top:6px;display:flex;gap:6px;flex-wrap:wrap} +.wf-att span{font-size:11.5px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:8px;padding:3px 8px} +.wf-user{align-self:flex-end;background:var(--bt-user,#070707);color:var(--bt-user-ink,#fcfcfc);border-radius:12px;padding:8px 12px;max-width:70%;line-height:1.6} +.wf-typing{display:flex;gap:4px;padding:11px 12px} +.wf-typing i{width:6px;height:6px;border-radius:50%;background:var(--bt-ink-3,#9a9a9a)} +.wf-composer-wrap{flex:none;padding:0 0 10px} +.wf-composer{width:min(760px,calc(100% - 48px));margin:0 auto;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:14px;background:var(--bt-card,#fff);display:flex;align-items:center;gap:8px;padding:8px} +.wf-composer input{flex:1;border:0;outline:0;background:transparent;font:inherit;color:inherit} +.wf-mic{width:28px;height:28px;border-radius:9px;border:0;background:var(--bt-accent,#4d6bfe);color:#fff;cursor:pointer} +.wf-under{text-align:center;margin-top:6px;font-size:11px;color:var(--bt-ink-2,rgba(20,20,20,.6))} +.wf-drawer{width:400px;flex:none;border-left:1px solid var(--bt-line,rgba(20,20,20,.08));display:flex;flex-direction:column;background:var(--bt-main,#fcfcfc)} +.wf-dr-head{display:flex;justify-content:flex-end;padding:12px} +.wf-icon{width:28px;height:28px;border-radius:8px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);cursor:pointer;color:inherit} +.wf-dr-body{flex:1;overflow:auto;padding:4px 18px 20px} +.wf-dr-title{display:flex;flex-direction:column;align-items:center;gap:4px;margin-bottom:10px} +.wf-dr-title h2{font-size:16px;font-weight:600;margin:0} +.wf-dr-title p{font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));text-align:center;line-height:1.5;margin:0} +.wf-tabs{display:flex;justify-content:center;gap:4px;margin:6px 0 12px} +.wf-tabs button{padding:4px 10px;border-radius:7px;border:0;background:transparent;color:var(--bt-ink-2,rgba(20,20,20,.6));cursor:pointer;font:inherit} +.wf-tabs button.on{background:var(--bt-active,rgba(119,119,119,.17));color:var(--bt-ink,#141414)} +.wf-flow{display:flex;flex-direction:column;align-items:center;gap:0;padding:8px 0} +.wf-term{font-size:11px;color:var(--bt-ink-2,rgba(20,20,20,.6));border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:3px 10px} +.wf-term.on{color:#16a34a;background:rgba(22,163,74,.1);border-color:transparent} +.wf-step{display:flex;flex-direction:column;align-items:center} +.wf-vline{width:1.6px;height:22px;background:var(--bt-line,rgba(20,20,20,.16))} +.wf-frow{display:flex;justify-content:center;gap:14px} +.wf-par{position:relative;border:1px dashed var(--bt-ink-3,#9a9a9a);border-radius:14px;padding:16px 8px 8px} +.wf-plabel{position:absolute;top:-9px;left:50%;transform:translateX(-50%);font-size:10.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));background:var(--bt-card,#fff);padding:0 6px;white-space:nowrap} +.wf-canvas{display:flex;flex-direction:column;align-items:center;gap:8px;min-height:440px} +.wf-palette{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;width:100%} +.wf-plane{position:relative;width:0;margin:8px auto 0} +.wf-wires{position:absolute;left:0;top:0;overflow:visible;pointer-events:none} +.wf-wires path{fill:none;stroke:var(--bt-line,rgba(20,20,20,.35));stroke-width:1.6} +.wf-wires .wf-end{pointer-events:auto;fill:var(--bt-card,#fff);stroke:var(--bt-accent,#4d6bfe);cursor:crosshair} +.wf-abs{position:absolute} +.wf-port{position:absolute;left:50%;bottom:-6px;width:12px;height:12px;margin-left:-6px;border-radius:50%;border:2px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);padding:0;cursor:crosshair} +.wf-mini{position:absolute;top:-8px;right:-8px;width:18px;height:18px;border-radius:50%;border:1px solid var(--bt-line,rgba(20,20,20,.16));background:var(--bt-card,#fff);cursor:pointer;line-height:16px;padding:0} +.wf-node{width:168px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px;position:relative} +.wf-node.running{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 3px rgba(77,107,254,.12)} +.wf-node.done{border-color:rgba(22,163,74,.35)}.wf-node.failed{border-color:#e5484d} +.wf-node .wf-chip{position:absolute;top:-9px;right:8px} +.wf-nt{min-width:0;flex:1;display:flex;flex-direction:column} +.wf-nm{font-weight:600;font-size:12.5px}.wf-rl{font-size:11px;color:var(--bt-ink-3,#9a9a9a);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.wf-box{border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;background:var(--bt-card,#fff);padding:10px 12px} +.wf-kv{display:flex;justify-content:space-between;gap:12px;padding:5px 0;font-size:12px} +.wf-kv span:first-child{color:var(--bt-ink-2,rgba(20,20,20,.6))} +.wf-hist{display:flex;justify-content:space-between;gap:8px;padding:7px 0;border-bottom:1px solid var(--bt-line,rgba(20,20,20,.08));font-size:12px} +.wf-ctl{display:flex;gap:6px;flex-wrap:wrap;margin-top:14px} +.wf-mask{position:fixed;inset:0;background:rgba(0,0,0,.28);display:flex;align-items:center;justify-content:center;z-index:80} +.wf-modal{width:560px;max-width:calc(100% - 32px);background:var(--bt-main,#fcfcfc);color:var(--bt-ink,#141414);border-radius:16px;border:1px solid var(--bt-line,rgba(20,20,20,.08));padding:18px 20px} +.wf-modal h3{font-size:15px;display:flex;justify-content:space-between;align-items:center;margin:0} +.wf-hint{font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));margin:4px 0 0} +.wf-step-h{font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));margin:14px 0 8px;display:flex;gap:6px;align-items:center} +.wf-step-h b{width:18px;height:18px;border-radius:50%;background:var(--bt-user,#070707);color:var(--bt-user-ink,#fcfcfc);display:grid;place-items:center;font-size:11px} +.wf-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:8px} +.wf-kcard{border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:10px;padding:9px 10px;text-align:left;cursor:pointer;color:inherit;font:inherit} +.wf-kcard.sel,.wf-tgt.sel{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 3px rgba(77,107,254,.12)} +.wf-id,.wf-kcol{font-size:11px;color:var(--bt-ink-3,#9a9a9a)}.wf-tt{font-size:12.5px;margin:3px 0 6px;font-weight:500} +.wf-targets{display:flex;flex-direction:column;gap:6px} +.wf-tgt{display:flex;align-items:center;gap:10px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:10px;padding:8px 10px;text-align:left;cursor:pointer;color:inherit;font:inherit;width:100%} +.wf-rad{margin-left:auto;width:16px;height:16px;border-radius:50%;border:1.5px solid var(--bt-ink-3,#9a9a9a);flex:none} +.wf-tgt.sel .wf-rad{border:5px solid var(--bt-accent,#4d6bfe)} +.wf-mfoot{display:flex;justify-content:flex-end;gap:8px;margin-top:16px} +.wf-field{display:flex;flex-direction:column;gap:6px;font-size:12px;margin-top:12px} +.wf-field input,.wf-field select{height:32px;border-radius:8px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);color:inherit;font:inherit;padding:0 8px} +.wf-add{display:flex;gap:8px;align-items:center;flex-wrap:wrap} +.wf-plan{margin:10px 0 0;padding-left:18px;font-size:13px} +.wf-tip{position:fixed;z-index:90;width:250px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.55} +.wf-tip .wf-k{color:var(--bt-ink-3,#9a9a9a);font-size:11px;margin-top:6px} +.wf-muted{font-size:12px;color:var(--bt-ink-3,#9a9a9a);padding:4px 8px} +.wf-err{font-size:12px;color:#e5484d;padding:4px 8px} +.wf-assign-row{opacity:.9} +` + + function apply(ctx) { + if (!ctx?.flybot?.call) throw new Error('@flybot/workflow needs the flybot browser service') + const slots = ctx.flybot.slots + const offs = [] + if (slots?.register) offs.push(slots.register('flybot.sidebar.section', { id: 'workflow', order: 20, component: Section })) + const off = () => { for (const fn of offs.splice(0)) fn() } + ctx.effect?.(() => off) + return off + } + + return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, tr, columnOf, shortId, FIXTURE_TASKS } + }, +}) diff --git a/plugins/workflow/cordis.patch.yml b/plugins/workflow/cordis.patch.yml new file mode 100644 index 000000000..ad2953ab3 --- /dev/null +++ b/plugins/workflow/cordis.patch.yml @@ -0,0 +1,5 @@ +# Mounts flybot-workflow (needs ds-bot, which provides the flybot service). +# Set enabled: false to keep the plugin installed without endpoints or a sidebar section. +- insert: + - id: flybot-workflow + name: '@flybot/workflow' diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js new file mode 100644 index 000000000..5fb2802e1 --- /dev/null +++ b/plugins/workflow/index.js @@ -0,0 +1,72 @@ +// @flybot/workflow host plugin. Stores workflow graphs and runs under +// $DSH_HOME/bot/flybot-workflow and answers the chat surface on workflow/*. +import { join } from 'node:path' +import { createWorkflowService } from './src/service.js' + +export const name = 'flybot-workflow' +export const inject = ['flybot'] + +export function apply(ctx, config = {}) { + if (config?.enabled === false) return { enabled: false, dispose() {} } + if (!ctx?.flybot || typeof ctx.flybot.scope !== 'function') { + throw new Error('@flybot/workflow needs ctx.flybot.scope (flybot API v1)') + } + const fb = ctx.flybot.scope('flybot-workflow') + const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') + const persist = config.persist !== false + const service = createWorkflowService({ + dir: persist ? (config.dir ?? join(dshHome, 'bot', 'flybot-workflow')) : undefined, + persist, + bots: () => fb.bot?.list?.() ?? [], + readOnly: () => fb.readOnly?.() === true, + warn: (...args) => fb.warn?.(...args), + }) + const offs = [ + fb.api.handle('workflow/state', () => service.state(), { read: true }), + fb.api.handle('workflow/create', body => service.create(body)), + fb.api.handle('workflow/assign', body => service.assign(body)), + fb.api.handle('workflow/advance', body => service.advance(body)), + fb.api.handle('workflow/demo-step', body => service.demoStep(body)), + fb.api.handle('workflow/reset', body => service.reset(body)), + fb.api.handle('workflow/say', body => service.say(body)), + fb.api.handle('workflow/graph', body => service.saveGraph(body)), + fb.api.handle('workflow/templates', () => ({ ok: true, templates: service.state().templates }), { read: true }), + fb.api.handle('workflow/ingest', body => service.ingest(body)), + ] + if (typeof fb.tools?.register === 'function') { + offs.push(fb.tools.register({ + name: 'run_workflow', + description: 'Start a saved Workflow by name or id. The brief is the task the run carries. This is the same run the Workflow chat shows.', + parameters: { + workflow: { type: 'string', required: true, description: 'Workflow id or exact name' }, + brief: { type: 'string', required: true, description: 'What the run should do' }, + task: { type: 'string', description: 'Task id, such as #1280. Omitted ids are generated.' }, + }, + execute: args => service.runNamed(args ?? {}), + })) + offs.push(fb.tools.register({ + name: 'define_workflow', + description: 'Create or replace a Workflow graph of local Bots. nodes are { id, name, botId?, x?, y? } and edges are { from, to }. The flowchart canvas edits this same graph.', + parameters: { + name: { type: 'string', required: true, description: 'Workflow name' }, + workflow: { type: 'string', description: 'Existing id or name to replace. Omit to create.' }, + nodes: { type: 'array', required: true, description: 'Local Bots seated on the graph' }, + edges: { type: 'array', required: true, description: 'Hand-offs { from, to }. A fork is two edges out; a join is two edges in.' }, + }, + execute: args => service.define(args ?? {}), + })) + } + if (typeof fb.tasks?.onWrite === 'function') { + offs.push(fb.tasks.onWrite(task => { + const result = service.ingest(task ?? {}) + if (result?.matched === true && result.already !== true && result.decision?.kind === 'bot') { + return { ownerBotId: result.decision.id } + } + return null + })) + } + const dispose = () => { service.dispose(); for (const off of offs.splice(0)) off() } + if (typeof ctx.effect === 'function') ctx.effect(() => dispose, 'flybot-workflow') + else ctx.on?.('dispose', dispose) + return { service, enabled: true, dispose } +} diff --git a/plugins/workflow/package.json b/plugins/workflow/package.json new file mode 100644 index 000000000..1a5a1881b --- /dev/null +++ b/plugins/workflow/package.json @@ -0,0 +1,42 @@ +{ + "name": "@flybot/workflow", + "version": "0.1.0", + "private": true, + "description": "FlyBot workflows: a group-chat shaped pipeline of local Bots, with a flowchart and task assignment", + "keywords": [ + "dsh-plugin", + "deepseek-harness", + "dsh", + "ds-bot", + "flybot" + ], + "license": "Apache-2.0", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json" + }, + "files": [ + "index.js", + "client.js", + "src/", + "README.md", + "cordis.patch.yml" + ], + "scripts": { + "test": "node --test \"test/*.test.js\"" + }, + "peerDependencies": { + "ds-bot": ">=0.1.5-flybot.1" + }, + "dsh": { + "client": { + "platform": "web" + }, + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/plugins/workflow/src/graph.js b/plugins/workflow/src/graph.js new file mode 100644 index 000000000..00b8ee8e1 --- /dev/null +++ b/plugins/workflow/src/graph.js @@ -0,0 +1,382 @@ +// A workflow is a directed graph of local Bots. An edge is a hand-off. +// Nodes that share a rank run together; a node starts only when every Bot +// that points at it is done (a join). A failed Bot holds its successors. +// The same nodes, edges and positions are what the canvas edits and what a +// run paints: x/y are the owner's layout, ranks are derived from the edges. + +const STATUSES = new Set(['queued', 'running', 'done', 'failed']) +const ID_RE = /^[a-z][a-z0-9-]{0,40}$/ +const GAP_X = 188 +const GAP_Y = 108 + +function stripControls(value) { + let out = '' + for (const ch of String(value ?? '')) { + const code = ch.codePointAt(0) + out += code <= 0x1f || code === 0x7f ? ' ' : ch + } + return out +} + +export function cleanLine(value, max = 300) { + return stripControls(value).replace(/\s+/g, ' ').trim().slice(0, max) +} + +function coord(value, fallback) { + const number = Number(value) + if (!Number.isFinite(number)) return fallback + return Math.max(-2000, Math.min(4000, Math.round(number))) +} + +export function edgesFromLayers(layers) { + const edges = [] + for (let index = 0; index < layers.length - 1; index += 1) { + for (const from of layers[index]) { + for (const to of layers[index + 1]) edges.push({ from, to }) + } + } + return edges +} + +// Longest-path ranks. A cycle is refused. Nodes keep their list order inside a rank. +export function ranksOf(nodes, edges) { + const ids = nodes.map(node => node.id) + const known = new Set(ids) + const preds = new Map(ids.map(id => [id, []])) + const succs = new Map(ids.map(id => [id, []])) + for (const edge of edges) { + if (!known.has(edge.from) || !known.has(edge.to)) throw new Error(`Link names an unknown Bot ${edge.from} → ${edge.to}`) + if (edge.from === edge.to) throw new Error('A Bot cannot connect to itself') + preds.get(edge.to).push(edge.from) + succs.get(edge.from).push(edge.to) + } + const indegree = new Map(ids.map(id => [id, preds.get(id).length])) + const queue = ids.filter(id => indegree.get(id) === 0) + const order = [] + while (queue.length > 0) { + const id = queue.shift() + order.push(id) + for (const to of succs.get(id)) { + indegree.set(to, indegree.get(to) - 1) + if (indegree.get(to) === 0) queue.push(to) + } + } + if (order.length !== ids.length) throw new Error('A workflow graph cannot contain a cycle') + const rank = new Map(ids.map(id => [id, 0])) + for (const id of order) { + const incoming = preds.get(id) + rank.set(id, incoming.length === 0 ? 0 : 1 + Math.max(...incoming.map(pred => rank.get(pred)))) + } + const index = new Map(ids.map((id, place) => [id, place])) + const layers = [] + for (const id of order) { + const place = rank.get(id) + if (place > 11) throw new Error('A workflow has at most 12 steps') + layers[place] ??= [] + layers[place].push(id) + } + for (const layer of layers) { + if (layer.length > 6) throw new Error('A parallel step seats at most 6 Bots') + layer.sort((a, b) => index.get(a) - index.get(b)) + } + return { layers, preds } +} + +function layoutNodes(nodes, layers) { + const place = new Map() + layers.forEach((layer, row) => { + layer.forEach((id, column) => { + place.set(id, { + x: Math.round((column - (layer.length - 1) / 2) * GAP_X), + y: row * GAP_Y, + }) + }) + }) + return nodes.map(node => { + const fallback = place.get(node.id) ?? { x: 0, y: 0 } + return { ...node, x: coord(node.x, fallback.x), y: coord(node.y, fallback.y) } + }) +} + +function checkNodes(nodes) { + if (!Array.isArray(nodes) || nodes.length === 0) throw new Error('A workflow needs at least one Bot') + if (nodes.length > 24) throw new Error('A workflow seats at most 24 Bots') + const ids = new Set() + for (const node of nodes) { + if (!node || typeof node.id !== 'string' || !ID_RE.test(node.id)) throw new Error('A node id is a short lowercase name') + if (ids.has(node.id)) throw new Error(`Node ${node.id} is listed twice`) + ids.add(node.id) + if (cleanLine(node.name, 80) === '') throw new Error(`Node ${node.id} needs a name`) + } + return ids +} + +function checkLayers(layers, ids) { + if (!Array.isArray(layers) || layers.length === 0) throw new Error('A workflow needs at least one step') + if (layers.length > 12) throw new Error('A workflow has at most 12 steps') + const seen = new Set() + for (const layer of layers) { + if (!Array.isArray(layer) || layer.length === 0) throw new Error('A step needs at least one Bot') + if (layer.length > 6) throw new Error('A parallel step seats at most 6 Bots') + for (const id of layer) { + if (!ids.has(id)) throw new Error(`Step names an unknown Bot ${id}`) + if (seen.has(id)) throw new Error(`Bot ${id} is in more than one step`) + seen.add(id) + } + } + if (seen.size !== ids.size) throw new Error('Every Bot belongs to one step') +} + +// Edges are the source of truth once they exist. A layers-only graph (the +// templates, and a create call that sends steps) grows the bipartite edges +// between consecutive steps, which is the same join the ranks then recover. +export function normalizeGraph(graph) { + if (!graph || typeof graph !== 'object') throw new Error('A workflow needs a graph') + const ids = checkNodes(graph.nodes) + let edges + if (Array.isArray(graph.edges)) { + if (graph.edges.length > 64) throw new Error('A workflow has at most 64 links') + const seen = new Set() + edges = graph.edges.map(edge => { + const from = String(edge?.from ?? '') + const to = String(edge?.to ?? '') + const key = `${from}>${to}` + if (seen.has(key)) throw new Error(`Link ${from} → ${to} is listed twice`) + seen.add(key) + return { from, to } + }) + } else { + checkLayers(graph.layers, ids) + edges = edgesFromLayers(graph.layers) + } + const { layers } = ranksOf(graph.nodes, edges) + const nodes = layoutNodes(graph.nodes.map(node => ({ ...node })), layers) + return { ...graph, nodes, edges, layers } +} + +export function validateGraph(graph) { + return normalizeGraph(graph) +} + +function ensure(graph) { + if (Array.isArray(graph?.edges) && Array.isArray(graph?.layers) && graph.nodes?.every(node => Number.isFinite(node.x) && Number.isFinite(node.y))) return graph + const next = normalizeGraph(graph) + graph.nodes = next.nodes + graph.edges = next.edges + graph.layers = next.layers + return graph +} + +function cloneGraph(graph) { + const ready = ensure(graph) + return { + ...ready, + nodes: ready.nodes.map(node => ({ ...node })), + edges: ready.edges.map(edge => ({ ...edge })), + layers: ready.layers.map(layer => [...layer]), + } +} + +export function moveNode(graph, nodeId, x, y) { + const next = cloneGraph(graph) + const node = next.nodes.find(item => item.id === nodeId) + if (!node) throw new Error(`Unknown Bot ${nodeId}`) + node.x = coord(x, node.x) + node.y = coord(y, node.y) + return normalizeGraph(next) +} + +export function connectNodes(graph, from, to) { + const next = cloneGraph(graph) + if (!next.nodes.some(node => node.id === from) || !next.nodes.some(node => node.id === to)) throw new Error(`Unknown Bot ${from} → ${to}`) + if (next.edges.some(edge => edge.from === from && edge.to === to)) return next + next.edges.push({ from, to }) + return normalizeGraph(next) +} + +export function reconnectEdge(graph, from, to, newTo) { + const next = cloneGraph(graph) + const index = next.edges.findIndex(edge => edge.from === from && edge.to === to) + if (index < 0) throw new Error('No such link') + next.edges.splice(index, 1) + if (!next.edges.some(edge => edge.from === from && edge.to === newTo)) next.edges.push({ from, to: newTo }) + return normalizeGraph(next) +} + +export function removeEdge(graph, from, to) { + const next = cloneGraph(graph) + const index = next.edges.findIndex(edge => edge.from === from && edge.to === to) + if (index < 0) throw new Error('No such link') + next.edges.splice(index, 1) + return normalizeGraph(next) +} + +export function addNode(graph, node) { + const next = cloneGraph(graph) + const id = cleanLine(node?.id, 40).toLowerCase() + if (!ID_RE.test(id)) throw new Error('A node id is a short lowercase name') + if (next.nodes.some(item => item.id === id)) throw new Error(`Node ${id} is listed twice`) + if (node?.botId && next.nodes.some(item => item.botId === node.botId)) throw new Error('That Bot is already in this workflow') + next.nodes.push({ + id, + name: cleanLine(node.name, 80), + role: cleanLine(node.role, 80), + instructions: cleanLine(node.instructions, 500), + ...(node.botId ? { botId: String(node.botId) } : {}), + ...(node.failOnce === true ? { failOnce: true } : {}), + ...(Number.isFinite(Number(node.x)) ? { x: coord(node.x, 0) } : {}), + ...(Number.isFinite(Number(node.y)) ? { y: coord(node.y, 0) } : {}), + }) + return normalizeGraph(next) +} + +export function removeNode(graph, nodeId) { + const next = cloneGraph(graph) + if (!next.nodes.some(node => node.id === nodeId)) throw new Error(`Unknown Bot ${nodeId}`) + next.nodes = next.nodes.filter(node => node.id !== nodeId) + next.edges = next.edges.filter(edge => edge.from !== nodeId && edge.to !== nodeId) + if (next.nodes.length === 0) throw new Error('A workflow needs at least one Bot') + return normalizeGraph(next) +} + +export function freshStatus(graph) { + ensure(graph) + return Object.fromEntries(graph.nodes.map(node => [node.id, 'queued'])) +} + +export function nodeById(graph, id) { + return graph.nodes.find(node => node.id === id) +} + +export function layerIndex(graph, nodeId) { + ensure(graph) + const index = graph.layers.findIndex(layer => layer.includes(nodeId)) + if (index < 0) throw new Error(`Unknown Bot ${nodeId}`) + return index +} + +function copyStatus(status) { + const next = { ...status } + for (const [id, value] of Object.entries(next)) { + if (!STATUSES.has(value)) throw new Error(`Bad status for ${id}`) + } + return next +} + +function predecessors(graph, nodeId) { + ensure(graph) + return graph.edges.filter(edge => edge.to === nodeId).map(edge => edge.from) +} + +// The first rank that is not finished, or -1 when every rank is done. +export function frontierIndex(graph, status) { + ensure(graph) + for (let index = 0; index < graph.layers.length; index += 1) { + if (graph.layers[index].some(id => status[id] !== 'done')) return index + } + return -1 +} + +// Start every queued Bot whose predecessors are all done. +export function startFrontier(graph, status) { + ensure(graph) + const next = copyStatus(status) + const started = [] + for (const id of orderedIds(graph, next, 'queued')) { + if (!predecessors(graph, id).every(pred => next[pred] === 'done')) continue + next[id] = 'running' + started.push(id) + } + const finished = graph.nodes.every(node => next[node.id] === 'done') + return { status: next, started, finished } +} + +export function completeNode(graph, status, nodeId) { + ensure(graph) + if (!nodeById(graph, nodeId)) throw new Error(`Unknown Bot ${nodeId}`) + if (status[nodeId] !== 'running') throw new Error(`${nodeId} is not running`) + const next = copyStatus(status) + next[nodeId] = 'done' + const index = layerIndex(graph, nodeId) + const layer = graph.layers[index] + const joined = layer.every(id => next[id] === 'done') + const follow = startFrontier(graph, next) + return { status: follow.status, started: follow.started, joined, finished: follow.finished, layerSize: layer.length } +} + +export function failNode(graph, status, nodeId) { + if (!nodeById(graph, nodeId)) throw new Error(`Unknown Bot ${nodeId}`) + if (status[nodeId] !== 'running') throw new Error(`${nodeId} is not running`) + const next = copyStatus(status) + next[nodeId] = 'failed' + return { status: next, started: [], joined: false, finished: false } +} + +export function retryNode(graph, status, nodeId) { + if (!nodeById(graph, nodeId)) throw new Error(`Unknown Bot ${nodeId}`) + if (status[nodeId] !== 'failed') throw new Error(`${nodeId} is not failed`) + const next = copyStatus(status) + next[nodeId] = 'running' + return { status: next, started: [nodeId], joined: false, finished: false } +} + +export function orderedIds(graph, status, want) { + ensure(graph) + const ids = [] + for (const layer of graph.layers) { + for (const id of layer) if (status[id] === want) ids.push(id) + } + return ids +} + +// The demo player (and the "next" button) ask the graph what one step is. +// A node marked failOnce fails the first time it is the running choice, then +// a later step retries it. The join still waits until that node is done. +export function nextDemoOp(graph, status, memory = {}) { + ensure(graph) + const failed = orderedIds(graph, status, 'failed') + if (failed.length > 0) return { type: 'retry', nodeId: failed[0] } + const running = orderedIds(graph, status, 'running') + if (running.length === 0) { + if (frontierIndex(graph, status) < 0) return { type: 'restart' } + return { type: 'start' } + } + const failOnce = running.find(id => nodeById(graph, id)?.failOnce === true && memory.failedOnce?.[id] !== true) + if (failOnce) return { type: 'fail', nodeId: failOnce } + return { type: 'complete', nodeId: running[0] } +} + +export function layerMarks(graph, status) { + ensure(graph) + return graph.layers.map(layer => { + if (layer.some(id => status[id] === 'failed')) return 'failed' + if (layer.some(id => status[id] === 'running')) return 'running' + if (layer.every(id => status[id] === 'done')) return 'done' + return 'queued' + }) +} + +export function doneCount(status) { + return Object.values(status).filter(value => value === 'done').length +} + +export function chainLabel(graph) { + ensure(graph) + return graph.layers.map(layer => layer.map(id => nodeById(graph, id)?.name ?? id).join(' ‖ ')).join(' → ') +} + +export function rowPreview(workflow, run) { + if (run?.status === 'running') { + const id = orderedIds(workflow, run.nodeStatus, 'running')[0] + const name = id ? nodeById(workflow, id)?.name : '' + const task = run.task?.id ?? '' + return name ? `${name} 正在处理 ${task}…` : '运行中' + } + if (workflow.trigger === 'schedule' && workflow.scheduleLabel) return `下次运行:${workflow.scheduleLabel}` + return run?.status === 'done' && run.task?.id ? `✓ ${run.task.id} 已完成` : '等待任务' +} + +export function stepLabel(graph, nodeId) { + ensure(graph) + return `${layerIndex(graph, nodeId) + 1}/${graph.layers.length}` +} diff --git a/plugins/workflow/src/match.js b/plugins/workflow/src/match.js new file mode 100644 index 000000000..409b71c3f --- /dev/null +++ b/plugins/workflow/src/match.js @@ -0,0 +1,83 @@ +// Which open board task should start a Workflow or land on a Bot (#1318). +// First hit wins. Anything that does not hit stays unassigned for the manual picker. +// +// 1. A `workflow:` token in the title, note, criteria or tags. +// 2. `workflowId` / `workflow` naming a workflow. +// 3. `assignee` naming a workflow (id or exact name). +// 4. A `bot:` token. +// 5. `assignee` naming a local Bot (id or exact name). A remote Bot never matches. +// 6. A workflow trigger keyword contained in the title, note or criteria. +// The longest trigger wins. A tie keeps the earlier workflow. +// +// Only an `open` task is considered. Claimed, working and finished cards are left alone. + +function text(value) { + let out = '' + for (const ch of String(value ?? '')) { + const code = ch.codePointAt(0) + out += code <= 0x1f || code === 0x7f ? ' ' : ch + } + return out.replace(/\s+/g, ' ').trim() +} + +function haystack(task) { + const tags = Array.isArray(task.tags) ? task.tags.map(text) : [] + return [task.title, task.note, task.criteria, ...tags].map(text).filter(Boolean).join('\n') +} + +function findWorkflow(workflows, raw) { + const wanted = text(raw).toLowerCase() + if (!wanted) return undefined + return workflows.find(item => item.id.toLowerCase() === wanted || item.name.toLowerCase() === wanted) +} + +function findBot(bots, raw) { + const wanted = text(raw).toLowerCase() + if (!wanted) return undefined + return bots.find(bot => bot.id.toLowerCase() === wanted || bot.name.toLowerCase() === wanted) +} + +function token(hay, kind) { + const match = new RegExp(`(?:^|[\\s#])${kind}:([^\\s#]+)`, 'i').exec(hay) + return match ? match[1] : '' +} + +export function matchTask(task, { workflows = [], bots = [] } = {}) { + if (!task || typeof task !== 'object') return null + if (task.status !== 'open') return null + const graphs = workflows.filter(item => item && item.id && item.name) + const locals = bots.filter(bot => bot && bot.remote !== true && bot.id && bot.name) + const hay = haystack(task) + const lower = hay.toLowerCase() + + const workflowToken = findWorkflow(graphs, token(lower, 'workflow')) + if (workflowToken) return { kind: 'workflow', id: workflowToken.id } + + const named = findWorkflow(graphs, task.workflowId || task.workflow || '') + if (named) return { kind: 'workflow', id: named.id } + + const assignee = text(task.assignee) + const assignedWorkflow = assignee ? findWorkflow(graphs, assignee) : undefined + if (assignedWorkflow) return { kind: 'workflow', id: assignedWorkflow.id } + + const botToken = findBot(locals, token(lower, 'bot')) + if (botToken) return { kind: 'bot', id: botToken.id } + + const assignedBot = assignee ? findBot(locals, assignee) : undefined + if (assignedBot) return { kind: 'bot', id: assignedBot.id } + + let best = null + let bestLength = 0 + for (const item of graphs) { + for (const trigger of item.triggers ?? []) { + const word = text(trigger).toLowerCase() + if (word.length < 2 || !lower.includes(word)) continue + if (word.length > bestLength) { + best = item + bestLength = word.length + } + } + } + if (best) return { kind: 'workflow', id: best.id } + return null +} diff --git a/plugins/workflow/src/narrate.js b/plugins/workflow/src/narrate.js new file mode 100644 index 000000000..4ccd4e2fc --- /dev/null +++ b/plugins/workflow/src/narrate.js @@ -0,0 +1,67 @@ +// Chat lines for one graph step. Delivery-template nodes use the demo's copy; +// any other graph gets a short line that names the Bot and the task. Text is +// plain: the client renders it as a text node. + +import { nodeById, stepLabel } from './graph.js' +import { DELIVERY_ID } from './template.js' + +function names(workflow, ids) { + return ids.map(id => nodeById(workflow, id)?.name ?? id).join(' ‖ ') +} + +function doneText(workflow, nodeId, task) { + if (workflow.id === DELIVERY_ID) { + if (nodeId === 'planner') return `已拆解「${task.title}」:Writer 把 notice 压到 2 行以内,结论先行;Coder 替换文案与折叠逻辑;Reviewer 与 Tester 并行验收。` + if (nodeId === 'writer') return '草稿完成(原 86 字 → 31 字):「交付组已建好,成员会自动同步。点此查看进度 →」' + if (nodeId === 'coder') return '补丁已提交:替换文案,超过 2 行自动折叠。改动 3 个文件。' + if (nodeId === 'reviewer') return 'Review 通过。建议把折叠阈值做成常量 NOTICE_MAX_LINES。' + if (nodeId === 'tester') return '重试通过:单元 128/128,快照 24/24,E2E 6/6。' + if (nodeId === 'deployer') return `已合并并灰度 10% 发布。看板卡片 ${task.id} 已移到「已完成」。` + } + const node = nodeById(workflow, nodeId) + return `${node?.name ?? nodeId} 完成了「${task.title}」的这一步。` +} + +function attachmentsOf(workflow, nodeId) { + if (workflow.id !== DELIVERY_ID) return undefined + if (nodeId === 'writer') return ['notice-copy.md'] + if (nodeId === 'coder') return ['PR #482'] + return undefined +} + +export function linesFor(workflow, task, event) { + const label = `${task?.id ?? ''} ${task?.title ?? ''}`.trim() + if (event.type === 'start') { + return [{ kind: 'system', tone: 'accent', text: `任务 ${label} 已进入 Workflow,${names(workflow, event.started)} 认领` }] + } + if (event.type === 'fail') { + const node = nodeById(workflow, event.nodeId) + const text = workflow.id === DELIVERY_ID && event.nodeId === 'tester' + ? '快照测试 notice.snap 不一致(旧文案快照未更新)。自动重试 1/2,并已通知 Coder 更新快照。' + : `${node?.name ?? event.nodeId} 这一步失败了,停在本节点。` + return [{ kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), text }] + } + if (event.type === 'retry') { + const node = nodeById(workflow, event.nodeId) + const text = workflow.id === DELIVERY_ID && event.nodeId === 'tester' + ? '↻ Tester 失败后自动重试 · Coder 已推送快照更新' + : `↻ ${node?.name ?? event.nodeId} 失败后自动重试` + return [{ kind: 'system', tone: 'bad', text }] + } + if (event.type === 'complete') { + const lines = [{ + kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), + text: doneText(workflow, event.nodeId, task), + ...(attachmentsOf(workflow, event.nodeId) ? { attachments: attachmentsOf(workflow, event.nodeId) } : {}), + }] + if (event.started.length > 1) lines.push({ kind: 'system', text: `∥ 并行分支开始:${names(workflow, event.started)}` }) + if (event.joined && event.layerSize > 1 && event.started.length > 0) { + lines.push({ kind: 'system', text: `⇢ 并行分支汇合(join)→ ${names(workflow, event.started)}` }) + } + if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ Workflow 运行完成 · ${task.id} · ${workflow.nodes.length} 个节点` }) + return lines + } + if (event.type === 'reset') return [{ kind: 'system', text: '↺ 已重置运行' }] + if (event.type === 'note') return [{ kind: 'system', text: event.text }] + return [] +} diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js new file mode 100644 index 000000000..0c4921087 --- /dev/null +++ b/plugins/workflow/src/service.js @@ -0,0 +1,511 @@ +// Workflow records and the run that a board task starts. The graph rules live +// in graph.js; this file stores workflows, appends the chat, and refuses a +// remote Bot or a read-only host. A task id is recorded on the run. v1 does +// not start a model turn: each step posts the progress line and moves the node. + +import { randomBytes } from 'node:crypto' +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { + chainLabel, cleanLine, completeNode, doneCount, failNode, freshStatus, layerMarks, + nextDemoOp, nodeById, normalizeGraph, orderedIds, retryNode, rowPreview, startFrontier, validateGraph, +} from './graph.js' +import { matchTask } from './match.js' +import { linesFor } from './narrate.js' +import { DELIVERY_ID, DELIVERY_STEPS, WEEKLY_ID, deliveryWorkflow, fixtureTask, templateById, templateCatalog, weeklyWorkflow } from './template.js' + +const MAX_WORKFLOWS = 24 +const MAX_MESSAGES = 200 +const FILE = 'workflows.json' + +function idOf(prefix) { + return `${prefix}-${randomBytes(4).toString('hex')}` +} + +function publicNode(node, bots) { + const local = (bots ?? []).filter(bot => bot && bot.remote !== true && bot.id && bot.name) + const seated = node.botId + ? local.find(bot => bot.id === node.botId) + : local.find(bot => bot.name === node.name) + return { + id: node.id, + name: node.name, + role: node.role ?? '', + instructions: node.instructions ?? '', + failOnce: node.failOnce === true, + botId: seated?.id ?? null, + x: node.x, + y: node.y, + } +} + +function publicRun(workflow, run) { + if (!run) return null + return { + id: run.id, + workflowId: run.workflowId, + task: run.task, + status: run.status, + nodeStatus: { ...run.nodeStatus }, + steps: run.steps, + stepBudget: run.stepBudget, + doneCount: doneCount(run.nodeStatus), + nodeCount: workflow.nodes.length, + marks: layerMarks(workflow, run.nodeStatus), + running: orderedIds(workflow, run.nodeStatus, 'running'), + history: run.history ?? [], + startedAt: run.startedAt, + updatedAt: run.updatedAt, + } +} + +function blankRun(workflow, task, now, history = []) { + return { + id: idOf('run'), + workflowId: workflow.id, + task, + status: 'idle', + nodeStatus: freshStatus(workflow), + steps: 0, + stepBudget: workflow.id === DELIVERY_ID ? DELIVERY_STEPS : workflow.nodes.length + 1, + failedOnce: {}, + history, + startedAt: now, + updatedAt: now, + } +} + +export function createWorkflowService({ dir, persist = true, now = () => Date.now(), bots = () => [], readOnly = () => false, warn = () => {} } = {}) { + const file = dir ? join(dir, FILE) : null + let state = load(file, warn) ?? seed(now()) + state.routes ??= {} + let seq = state.seq ?? 1 + + function write() { + if (!persist || !file) return + state.seq = seq + mkdirSync(dir, { recursive: true }) + const tmp = `${file}.${process.pid}.tmp` + writeFileSync(tmp, JSON.stringify(state)) + renameSync(tmp, file) + } + + function workflow(id) { + return state.workflows.find(item => item.id === id) + } + + function readyWorkflow(item) { + if (Array.isArray(item.edges) && Array.isArray(item.layers) && item.nodes.every(node => Number.isFinite(node.x))) return item + const next = normalizeGraph(item) + item.nodes = next.nodes + item.edges = next.edges + item.layers = next.layers + return item + } + + function findWorkflow(name) { + const wanted = cleanLine(name, 80).toLowerCase() + if (!wanted) return undefined + return state.workflows.find(item => item.id.toLowerCase() === wanted || item.name.toLowerCase() === wanted) + } + + function push(workflowId, lines, at) { + const log = state.logs[workflowId] ?? [] + for (const line of lines) { + seq += 1 + log.push({ id: `m${seq}`, at, ...line }) + } + state.logs[workflowId] = log.slice(-MAX_MESSAGES) + } + + function present() { + const roster = bots() ?? [] + return { + ok: true, + version: 1, + templates: templateCatalog(), + workflows: state.workflows.map(raw => { + const item = readyWorkflow(raw) + return ({ + id: item.id, + name: item.name, + description: item.description ?? '', + trigger: item.trigger ?? 'manual', + scheduleLabel: item.scheduleLabel ?? '', + nodes: item.nodes.map(node => publicNode(node, roster)), + edges: (item.edges ?? []).map(edge => ({ from: edge.from, to: edge.to })), + layers: item.layers.map(layer => [...layer]), + triggers: [...(item.triggers ?? [])], + chain: chainLabel(item), + preview: rowPreview(item, state.runs[item.id]), + run: publicRun(item, state.runs[item.id]), + messages: state.logs[item.id] ?? [], + }) }), + } + } + + function refuse(error) { + return { ok: false, error: error?.message ?? String(error) } + } + + function applyOp(item, run, op) { + let result + if (op.type === 'start') result = { ...startFrontier(item, run.nodeStatus), joined: false, layerSize: 0 } + else if (op.type === 'complete') result = completeNode(item, run.nodeStatus, op.nodeId) + else if (op.type === 'fail') result = failNode(item, run.nodeStatus, op.nodeId) + else if (op.type === 'retry') result = retryNode(item, run.nodeStatus, op.nodeId) + else throw new Error(`Unknown step ${op.type}`) + const failedOnce = { ...run.failedOnce } + if (op.type === 'fail') failedOnce[op.nodeId] = true + const finished = result.finished === true + const steps = finished ? run.stepBudget : run.steps + 1 + const history = finished + ? [...(run.history ?? []), { task: run.task, status: 'done', at: now() }].slice(-20) + : (run.history ?? []) + const next = { + ...run, + status: finished ? 'done' : 'running', + nodeStatus: result.status, + steps, + failedOnce, + history, + updatedAt: now(), + } + const event = { + type: op.type, + nodeId: op.nodeId, + started: result.started ?? [], + joined: result.joined === true, + finished, + layerSize: result.layerSize ?? 0, + } + return { run: next, lines: linesFor(item, run.task, event) } + } + + function mutate(workflowId, fn) { + if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + const item = workflow(workflowId) + if (!item) return { ok: false, error: 'No such workflow' } + try { + fn(item) + write() + return present() + } catch (error) { + return refuse(error) + } + } + + return { + state() { return present() }, + create(body = {}) { + if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + try { + if (body.template) { + const template = templateById(body.template) + if (!template) throw new Error('No such template') + const base = cleanLine(body.name || template.name, 80) + const name = state.workflows.some(item => item.name === base) ? cleanLine(`${base} 副本`, 80) : base + body = { + ...body, + name, + description: body.description || template.description, + trigger: body.trigger || template.trigger, + scheduleLabel: body.scheduleLabel || template.scheduleLabel, + nodes: template.nodes, + layers: template.layers, + triggers: template.triggers, + } + } + if (state.workflows.length >= MAX_WORKFLOWS) throw new Error('Too many workflows') + const roster = bots() ?? [] + const nodes = (body.nodes ?? []).map(node => { + const botId = node?.botId ? String(node.botId) : '' + const bot = botId ? roster.find(item => item.id === botId) : undefined + if (botId && !bot) throw new Error('No such Bot') + if (bot?.remote === true) throw new Error('A workflow seats local Bots only') + return { + id: cleanLine(node.id, 40).toLowerCase(), + name: cleanLine(node.name || bot?.name, 80), + role: cleanLine(node.role || bot?.role, 80), + instructions: cleanLine(node.instructions || bot?.instructions, 500), + ...(botId ? { botId } : {}), + ...(node.failOnce === true ? { failOnce: true } : {}), + } + }) + const seenBots = new Set() + for (const node of nodes) { + if (!node.botId) continue + if (seenBots.has(node.botId)) throw new Error('That Bot is already in this workflow') + seenBots.add(node.botId) + } + const item = validateGraph({ + id: idOf('wf'), + name: cleanLine(body.name, 80), + description: cleanLine(body.description, 400), + trigger: body.trigger === 'schedule' ? 'schedule' : 'manual', + ...(body.trigger === 'schedule' || body.scheduleLabel ? { scheduleLabel: cleanLine(body.scheduleLabel, 40) } : {}), + ...(Array.isArray(body.triggers) ? { triggers: body.triggers.map(word => cleanLine(word, 40)).filter(Boolean).slice(0, 8) } : {}), + nodes, + ...(Array.isArray(body.edges) ? { edges: body.edges } : { layers: body.layers }), + }) + if (!item.name) throw new Error('A workflow needs a name') + state.workflows.push(item) + const memberNames = item.nodes.map(node => node.name).join('、') + push(item.id, [{ kind: 'system', text: `Workflow「${item.name}」已创建 · 成员 ${memberNames}` }], now()) + write() + return present() + } catch (error) { + return refuse(error) + } + }, + // A board task starts a run at the first layer and posts the claim line. + assign(body = {}) { + return mutate(String(body.workflowId ?? ''), (item) => { + const task = { id: cleanLine(body.taskId ?? body.id, 80), title: cleanLine(body.title, 300) } + if (!task.id || !task.title) throw new Error('A task needs an id and a title') + const previous = state.runs[item.id] + const history = previous ? [...(previous.history ?? []), { task: previous.task, status: previous.status, at: previous.updatedAt }].slice(-20) : [] + let run = blankRun(item, task, now(), history) + const at = now() + push(item.id, [ + { kind: 'time', text: '刚刚' }, + { kind: 'system', text: `来自 Tasks 看板:${task.id} ${task.title} → 分配给 Workflow「${item.name}」` }, + ], at) + const stepped = applyOp(item, run, { type: 'start' }) + run = stepped.run + push(item.id, stepped.lines, at) + state.runs[item.id] = run + }) + }, + advance(body = {}) { + return mutate(String(body.workflowId ?? ''), (item) => { + const run = state.runs[item.id] + if (!run || run.status === 'idle') throw new Error('This workflow has no running task') + if (run.status === 'done') throw new Error('This run is already finished') + const outcome = body.outcome === 'failed' ? 'fail' : 'complete' + const stepped = applyOp(item, run, { type: outcome, nodeId: String(body.nodeId ?? '') }) + state.runs[item.id] = stepped.run + push(item.id, stepped.lines, now()) + }) + }, + demoStep(body = {}) { + return mutate(String(body.workflowId ?? ''), (item) => { + let run = state.runs[item.id] + if (!run) throw new Error('This workflow has no task') + const op = nextDemoOp(item, run.nodeStatus, { failedOnce: run.failedOnce }) + if (op.type === 'restart') { + run = blankRun(item, run.task, now(), run.history) + push(item.id, [{ kind: 'time', text: '重新演示' }], now()) + } + const stepped = applyOp(item, run, op.type === 'restart' ? { type: 'start' } : op) + state.runs[item.id] = stepped.run + push(item.id, stepped.lines, now()) + }) + }, + reset(body = {}) { + return mutate(String(body.workflowId ?? ''), (item) => { + const run = state.runs[item.id] + if (!run) throw new Error('This workflow has no task') + const kept = { ...blankRun(item, run.task, now(), run.history), id: run.id, startedAt: run.startedAt } + state.runs[item.id] = kept + push(item.id, linesFor(item, run.task, { type: 'reset' }), now()) + }) + }, + say(body = {}) { + return mutate(String(body.workflowId ?? ''), (item) => { + const text = cleanLine(body.text, 2000) + if (!text) throw new Error('A message needs text') + const at = now() + const lines = [{ kind: 'user', text }] + const run = state.runs[item.id] + const running = run ? orderedIds(item, run.nodeStatus, 'running')[0] : undefined + if (running) { + const node = nodeById(item, running) + lines.push({ kind: 'bot', botId: running, step: '进行中', text: `${node?.name ?? running}:收到。我继续处理「${run.task?.title ?? ''}」。` }) + } + push(item.id, lines, at) + }) + }, + // Replace the graph the canvas and the run share. Positions and edges + // persist; a node that remains keeps its run status, a new one waits. + saveGraph(body = {}) { + return mutate(String(body.workflowId ?? ''), (item) => { + const seated = seat(body.nodes, bots() ?? []) + const seenBots = new Set() + for (const node of seated) { + if (!node.botId) continue + if (seenBots.has(node.botId)) throw new Error('That Bot is already in this workflow') + seenBots.add(node.botId) + } + const next = validateGraph({ + ...item, + nodes: seated, + edges: body.edges, + layers: undefined, + }) + item.nodes = next.nodes + item.edges = next.edges + item.layers = next.layers + const run = state.runs[item.id] + if (run) { + const ids = new Set(item.nodes.map(node => node.id)) + const nodeStatus = {} + for (const id of ids) nodeStatus[id] = run.nodeStatus?.[id] ?? 'queued' + run.nodeStatus = nodeStatus + run.updatedAt = now() + } + }) + }, + // A Bot (or the owner) writes a graph. A name that already exists is replaced. + define(body = {}) { + const existing = body.workflow ? findWorkflow(body.workflow) : undefined + if (existing) { + const saved = this.saveGraph({ workflowId: existing.id, nodes: body.nodes, edges: body.edges ?? [] }) + if (saved.ok === false) return saved + const name = cleanLine(body.name, 80) + if (name) { + existing.name = name + write() + } + return present() + } + return this.create({ name: body.name, nodes: body.nodes, edges: body.edges ?? [], description: body.description }) + }, + // Start a named workflow. `brief` is the task title; `task` is its id. + runNamed(body = {}) { + const item = findWorkflow(body.workflow) + if (!item) return { ok: false, error: 'No such workflow' } + const title = cleanLine(body.brief || body.title, 300) + if (!title) return { ok: false, error: 'A run needs a brief' } + const taskId = cleanLine(body.task || body.taskId, 80) || `task-${now().toString(36)}` + return this.assign({ workflowId: item.id, taskId, title }) + }, + // Auto-assign an open board task. A miss leaves the task untouched. + ingest(task = {}) { + if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + const decision = matchTask(task, { workflows: state.workflows, bots: bots() ?? [] }) + if (!decision) return { ok: true, matched: false } + const taskId = cleanLine(task.id, 80) + if (!taskId) return { ok: false, error: 'A task needs an id' } + const key = `${decision.kind}:${decision.id}` + const prev = state.routes[taskId] + if (prev?.key === key) return { ok: true, matched: true, already: true, decision } + if (decision.kind === 'workflow') { + const run = state.runs[decision.id] + const live = run && run.task?.id === taskId && run.status === 'running' + if (live) { + state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } + write() + return { ok: true, matched: true, already: true, decision } + } + const title = cleanLine(task.title, 300) + if (!title) return { ok: false, error: 'A task needs a title' } + const assigned = this.assign({ workflowId: decision.id, taskId, title }) + if (assigned.ok === false) return assigned + state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } + write() + return { ...present(), matched: true, already: false, decision } + } + state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } + write() + return { ok: true, matched: true, already: false, decision } + }, + dispose() {}, + } +} + +function seat(nodes, roster) { + return (nodes ?? []).map(node => { + const botId = node?.botId ? String(node.botId) : '' + const bot = botId ? roster.find(item => item.id === botId) : undefined + if (botId && !bot) throw new Error('No such Bot') + if (bot?.remote === true) throw new Error('A workflow seats local Bots only') + return { + id: cleanLine(node.id, 40).toLowerCase(), + name: cleanLine(node.name || bot?.name, 80), + role: cleanLine(node.role || bot?.role, 80), + instructions: cleanLine(node.instructions || bot?.instructions, 500), + ...(botId ? { botId } : {}), + ...(node.failOnce === true ? { failOnce: true } : {}), + ...(Number.isFinite(Number(node.x)) ? { x: Number(node.x) } : {}), + ...(Number.isFinite(Number(node.y)) ? { y: Number(node.y) } : {}), + } + }) +} + +function seed(now) { + const delivery = normalizeGraph(deliveryWorkflow()) + const weekly = normalizeGraph(weeklyWorkflow()) + const task = fixtureTask() + const logs = { [DELIVERY_ID]: [], [WEEKLY_ID]: [] } + const runs = {} + let seq = 0 + const push = (workflowId, lines) => { + for (const line of lines) { + seq += 1 + logs[workflowId].push({ id: `m${seq}`, at: now, ...line }) + } + } + push(DELIVERY_ID, [ + { kind: 'time', text: 'Today 9:07 PM' }, + { kind: 'system', text: 'Workflow「发布交付流水线」已创建 · 成员 Planner、Writer、Coder、Reviewer、Tester、Deployer' }, + { kind: 'user', text: '@发布交付流水线 #1280 今天要上,notice 太长了,帮我缩短并发布。' }, + ]) + let run = blankRun(delivery, task, now) + for (const op of [{ type: 'start' }, { type: 'complete', nodeId: 'planner' }, { type: 'complete', nodeId: 'writer' }]) { + const result = op.type === 'start' + ? { ...startFrontier(delivery, run.nodeStatus), joined: false, layerSize: 0 } + : completeNode(delivery, run.nodeStatus, op.nodeId) + run = { ...run, status: 'running', nodeStatus: result.status, steps: run.steps + 1, updatedAt: now } + push(DELIVERY_ID, linesFor(delivery, task, { + type: op.type, nodeId: op.nodeId, started: result.started ?? [], + joined: result.joined === true, finished: false, layerSize: result.layerSize ?? 0, + })) + } + runs[DELIVERY_ID] = run + const weeklyTask = { id: 'W42', title: '周报' } + const done = freshStatus(weekly) + for (const id of Object.keys(done)) done[id] = 'done' + runs[WEEKLY_ID] = { + ...blankRun(weekly, weeklyTask, now), + status: 'done', + nodeStatus: done, + steps: 4, + stepBudget: 4, + history: [{ task: weeklyTask, status: 'done', at: now }], + } + push(WEEKLY_ID, [ + { kind: 'time', text: '上周五 17:00' }, + { kind: 'system', tone: 'accent', text: '定时触发:周报 W42' }, + { kind: 'bot', botId: 'researcher', step: '1/3', text: '本周行业动态 12 条已整理。' }, + { kind: 'bot', botId: 'analyst', step: '2/3', text: '核心指标周环比:DAU +4.1%,留存 +0.6pt。' }, + { kind: 'bot', botId: 'writer', step: '3/3', text: '周报已发布。' }, + { kind: 'system', tone: 'ok', text: '✓ 运行完成' }, + ]) + return { version: 1, workflows: [delivery, weekly], runs, logs, seq } +} + +function load(file, warn) { + if (!file) return null + let text + try { + text = readFileSync(file, 'utf8') + } catch (error) { + if (error?.code === 'ENOENT') return null + warn(`flybot-workflow: could not read ${file}`) + return null + } + try { + const parsed = JSON.parse(text) + if (parsed?.version !== 1 || !Array.isArray(parsed.workflows) || typeof parsed.runs !== 'object') { + warn('flybot-workflow: the store is not a workflow file; starting from the fixture') + return null + } + return parsed + } catch (error) { + // A file that does not parse is not a workflow store. Keep the plugin up + // on the fixture instead of failing every endpoint. + warn(`flybot-workflow: the store did not parse (${error?.message ?? error}); starting from the fixture`) + return null + } +} diff --git a/plugins/workflow/src/template.js b/plugins/workflow/src/template.js new file mode 100644 index 000000000..66130d174 --- /dev/null +++ b/plugins/workflow/src/template.js @@ -0,0 +1,83 @@ +// The two workflows the owner-approved demo opens on. Node copy is the demo's +// own fixture (a delivery pipeline with one parallel join, and a Friday digest). + +const planner = { + id: 'planner', name: 'Planner', role: '需求拆解 / 排期', + instructions: '读取任务卡片与上下文,拆成可执行子任务,指定验收标准,并把产出交给下游节点。', +} +const writer = { + id: 'writer', name: 'Writer', role: '文案 / 文档', + instructions: 'Draft posts, docs and release notes, keep them short, and lead with the conclusion.', +} +const coder = { + id: 'coder', name: 'Coder', role: '代码实现', + instructions: '根据 Planner 的子任务和 Writer 的文案修改代码,提交 PR,并在 PR 描述中列出变更。', +} +const reviewer = { + id: 'reviewer', name: 'Reviewer', role: '代码审查', + instructions: '审查 Coder 的 PR:可读性、边界条件与回归风险。不通过则打回 Coder。', +} +const tester = { + id: 'tester', name: 'Tester', role: '自动化测试', failOnce: true, + instructions: '运行单元、快照和 E2E。失败时自动重试 1 次,仍失败则停在本节点。', +} +const deployer = { + id: 'deployer', name: 'Deployer', role: '发布上线', + instructions: '在 Reviewer 与 Tester 都通过后合并,灰度发布,并回填任务卡片状态。', +} + +export const DELIVERY_ID = 'delivery' +export const WEEKLY_ID = 'weekly' +// The demo's progress line reads "3/10" while Coder is in progress, so the +// delivery script is ten steps (open, each hand-off, the one retry, the close). +export const DELIVERY_STEPS = 10 + +export function deliveryWorkflow() { + return { + id: DELIVERY_ID, + name: '发布交付流水线', + description: 'Planner 拆解 → Writer 文案 → Coder 实现 → Reviewer ‖ Tester 并行把关 → Deployer 上线。任务从看板分配进来后按此流程执行。', + trigger: 'manual', + nodes: [planner, writer, coder, reviewer, tester, deployer], + layers: [['planner'], ['writer'], ['coder'], ['reviewer', 'tester'], ['deployer']], + // Title / note keywords that auto-start this template. Longer words win. + triggers: ['notice', '交付'], + } +} + +export function weeklyWorkflow() { + return { + id: WEEKLY_ID, + name: '周报汇总流程', + description: '每周五 17:00 触发:研究员收集,数据分析师汇总,Writer 成稿。', + trigger: 'schedule', + scheduleLabel: '周五 17:00', + nodes: [ + { id: 'researcher', name: '研究员', role: '行业研究', instructions: '收集本周行业动态与竞品更新。' }, + { id: 'analyst', name: '数据分析师', role: '数据分析', instructions: '汇总核心指标,生成周对比。' }, + { id: 'writer', name: 'Writer', role: '成稿', instructions: '把研究数据和指标写成周报。' }, + ], + layers: [['researcher'], ['analyst'], ['writer']], + triggers: ['周报'], + } +} + +export function templateById(id) { + const key = String(id ?? '') + if (key === 'delivery' || key === DELIVERY_ID) return deliveryWorkflow() + if (key === 'weekly' || key === WEEKLY_ID) return weeklyWorkflow() + return null +} + +export function templateCatalog() { + return [deliveryWorkflow(), weeklyWorkflow()].map(item => ({ + id: item.id, + name: item.name, + description: item.description, + triggers: [...item.triggers], + })) +} + +export function fixtureTask() { + return { id: '#1280', title: '缩短交付组群 notice' } +} diff --git a/plugins/workflow/test/assign.test.js b/plugins/workflow/test/assign.test.js new file mode 100644 index 000000000..e043abdaa --- /dev/null +++ b/plugins/workflow/test/assign.test.js @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { createWorkflowService } from '../src/service.js' +import { DELIVERY_ID } from '../src/template.js' +import { matchTask } from '../src/match.js' + +const roster = [ + { id: 'bot-planner', name: 'Planner', role: '计划', remote: false }, + { id: 'bot-coder', name: 'Coder', role: '实现', remote: false }, + { id: 'bot-friend', name: 'Remote', remote: true }, +] + +function service(extra = {}) { + return createWorkflowService({ persist: false, now: () => 1_700_000_000_000, bots: () => roster, ...extra }) +} + +function delivery(state) { + return state.workflows.find(item => item.id === DELIVERY_ID) +} + +test('assigning a board task starts the run at the first Bot and posts the claim', () => { + const svc = service() + const opened = delivery(svc.state()) + assert.equal(opened.preview, 'Coder 正在处理 #1280…') + assert.equal(opened.run.steps, 3) + assert.equal(opened.run.stepBudget, 10) + assert.equal(opened.run.doneCount, 2) + assert.deepEqual(opened.run.running, ['coder']) + assert.equal(opened.run.nodeStatus.deployer, 'queued') + assert.ok(opened.messages.some(message => message.kind === 'user' && message.text.includes('#1280'))) + + const assigned = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1281', title: '发布说明:Workflow Beta 上线' }) + const run = delivery(assigned).run + assert.equal(assigned.ok, true) + assert.equal(run.task.id, '#1281') + assert.equal(run.status, 'running') + assert.equal(run.nodeStatus.planner, 'running') + assert.equal(run.nodeStatus.writer, 'queued') + assert.equal(run.nodeStatus.deployer, 'queued') + const claim = delivery(assigned).messages.filter(message => message.tone === 'accent').at(-1) + assert.match(claim.text, /#1281/) + assert.match(claim.text, /Planner 认领/) + assert.ok(delivery(assigned).messages.some(message => message.text.includes('Tasks 看板'))) + assert.equal(svc.assign({ workflowId: DELIVERY_ID, taskId: '', title: 'x' }).ok, false) +}) + +test('advance walks the parallel join: Deployer stays queued until both branches are done', () => { + const svc = service() + svc.assign({ workflowId: DELIVERY_ID, taskId: 'task-abcdef123456', title: 'Ship the note' }) + const step = (nodeId, outcome) => svc.advance({ workflowId: DELIVERY_ID, nodeId, outcome }) + step('planner', 'done') + step('writer', 'done') + const opened = step('coder', 'done') + const mid = delivery(opened).run + assert.equal(mid.nodeStatus.reviewer, 'running') + assert.equal(mid.nodeStatus.tester, 'running') + assert.equal(mid.nodeStatus.deployer, 'queued') + assert.ok(delivery(opened).messages.some(message => message.text.includes('并行分支开始'))) + + const half = step('reviewer', 'done') + assert.equal(delivery(half).run.nodeStatus.deployer, 'queued') + assert.equal(delivery(half).run.nodeStatus.tester, 'running') + + const joined = step('tester', 'done') + assert.equal(delivery(joined).run.nodeStatus.deployer, 'running') + assert.ok(delivery(joined).messages.some(message => message.text.includes('汇合'))) + + const finished = step('deployer', 'done') + const done = delivery(finished).run + assert.equal(done.status, 'done') + assert.equal(done.doneCount, 6) + assert.ok(delivery(finished).messages.some(message => message.tone === 'ok')) + assert.equal(step('deployer', 'done').ok, false) +}) + +test('the demo step fails Tester once and still joins both branches', () => { + const svc = service() + svc.assign({ workflowId: DELIVERY_ID, taskId: '#1280', title: '缩短交付组群 notice' }) + const ops = [] + for (let i = 0; i < 12 && delivery(svc.state()).run.status !== 'done'; i += 1) { + const before = delivery(svc.state()).run.nodeStatus.tester + svc.demoStep({ workflowId: DELIVERY_ID }) + const after = delivery(svc.state()).run.nodeStatus.tester + if (before !== after) ops.push(`${before}->${after}`) + } + assert.ok(ops.includes('running->failed'), ops.join(',')) + assert.equal(delivery(svc.state()).run.status, 'done') + assert.equal(delivery(svc.state()).run.nodeStatus.deployer, 'done') +}) + +test('create seats a local Bot and refuses a remote one', () => { + const svc = service() + const made = svc.create({ + name: 'Hotfix', + nodes: [ + { id: 'plan', botId: 'bot-planner', name: 'Planner' }, + { id: 'code', botId: 'bot-coder', name: 'Coder' }, + ], + layers: [['plan'], ['code']], + }) + assert.equal(made.ok, true) + const hotfix = made.workflows.find(item => item.name === 'Hotfix') + assert.equal(hotfix.nodes[0].botId, 'bot-planner') + assert.equal(hotfix.nodes[1].botId, 'bot-coder') + assert.match(hotfix.messages[0].text, /已创建/) + + const remote = svc.create({ + name: 'Nope', + nodes: [{ id: 'friend', botId: 'bot-friend', name: 'Remote' }], + layers: [['friend']], + }) + assert.equal(remote.ok, false) + assert.match(remote.error, /local Bots only/) + assert.equal(svc.create({ + name: 'Twice', + nodes: [ + { id: 'a', botId: 'bot-coder', name: 'Coder' }, + { id: 'b', botId: 'bot-coder', name: 'Coder' }, + ], + layers: [['a'], ['b']], + }).ok, false) +}) + +test('a read-only host refuses assign, and a say lands in the workflow chat', () => { + const svc = service({ readOnly: () => true }) + const refused = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1', title: 'No' }) + assert.equal(refused.ok, false) + assert.equal(refused.code, 'read-only') + + const live = service() + const said = live.say({ workflowId: DELIVERY_ID, text: '请把 notice 再短一点' }) + const messages = delivery(said).messages + assert.equal(messages.at(-2).kind, 'user') + assert.match(messages.at(-1).text, /Coder/) + assert.match(messages.at(-1).text, /请把 notice 再短一点|收到/) +}) + +test('the store reloads a workflow file and drops a file that does not parse', () => { + const dir = mkdtempSync(join(tmpdir(), 'flybot-wf-')) + try { + const first = createWorkflowService({ dir, persist: true, now: () => 5, bots: () => roster }) + first.assign({ workflowId: DELIVERY_ID, taskId: '#9', title: 'Keep me' }) + const second = createWorkflowService({ dir, persist: true, now: () => 6, bots: () => roster }) + assert.equal(delivery(second.state()).run.task.title, 'Keep me') + const warnings = [] + const file = join(dir, 'workflows.json') + const raw = readFileSync(file, 'utf8') + assert.equal(JSON.parse(raw).version, 1) + const broken = createWorkflowService({ + dir: join(dir, 'missing'), + persist: true, + now: () => 7, + bots: () => [], + warn: (line) => warnings.push(line), + }) + assert.equal(delivery(broken.state()).id, DELIVERY_ID) + assert.equal(warnings.length, 0) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a dragged graph persists and the reloaded run still highlights those nodes', () => { + const dir = mkdtempSync(join(tmpdir(), 'flybot-wf-edit-')) + try { + const first = createWorkflowService({ dir, persist: true, now: () => 5, bots: () => roster }) + const before = delivery(first.state()) + const coder = before.nodes.find(node => node.id === 'coder') + const saved = first.saveGraph({ + workflowId: DELIVERY_ID, + nodes: before.nodes.map(node => node.id === 'coder' ? { ...node, x: coder.x + 48, y: coder.y } : node), + edges: before.edges.filter(edge => !(edge.from === 'coder' && edge.to === 'tester')).concat([{ from: 'writer', to: 'tester' }]), + }) + assert.equal(saved.ok, true) + const edited = delivery(saved) + assert.equal(edited.nodes.find(node => node.id === 'coder').x, coder.x + 48) + assert.equal(edited.run.nodeStatus.coder, 'running') + assert.ok(edited.edges.some(edge => edge.from === 'writer' && edge.to === 'tester')) + assert.equal(edited.edges.some(edge => edge.from === 'coder' && edge.to === 'tester'), false) + + const second = createWorkflowService({ dir, persist: true, now: () => 6, bots: () => roster }) + const loaded = delivery(second.state()) + assert.equal(loaded.nodes.find(node => node.id === 'coder').x, coder.x + 48) + assert.equal(loaded.run.nodeStatus.coder, 'running') + assert.equal(loaded.run.nodeStatus.planner, 'done') + second.assign({ workflowId: DELIVERY_ID, taskId: '#9', title: 'Ship the note' }) + second.advance({ workflowId: DELIVERY_ID, nodeId: 'planner', outcome: 'done' }) + const opened = second.advance({ workflowId: DELIVERY_ID, nodeId: 'writer', outcome: 'done' }) + const run = delivery(opened).run + assert.equal(run.nodeStatus.coder, 'running') + assert.equal(run.nodeStatus.tester, 'running') + assert.equal(run.nodeStatus.deployer, 'queued') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('templates, a Bot-defined graph, and run_workflow share one store', () => { + const svc = service() + const templates = svc.state().templates.map(item => item.id) + assert.deepEqual(templates, ['delivery', 'weekly']) + const copy = svc.create({ template: 'delivery', name: '发布交付流水线' }) + const made = copy.workflows.find(item => item.name === '发布交付流水线 副本') + assert.ok(made) + assert.ok(made.edges.some(edge => edge.from === 'coder' && edge.to === 'reviewer')) + assert.ok(made.layers.some(layer => layer.includes('reviewer') && layer.includes('tester'))) + + const defined = svc.define({ + name: 'Hotfix lane', + nodes: [ + { id: 'plan', name: 'Planner', botId: 'bot-planner' }, + { id: 'code', name: 'Coder', botId: 'bot-coder' }, + ], + edges: [{ from: 'plan', to: 'code' }], + }) + assert.equal(defined.ok, true) + const lane = defined.workflows.find(item => item.name === 'Hotfix lane') + assert.equal(lane.nodes[0].botId, 'bot-planner') + assert.deepEqual(lane.edges, [{ from: 'plan', to: 'code' }]) + const started = svc.runNamed({ workflow: 'Hotfix lane', task: '#77', brief: 'Ship the note' }) + const running = started.workflows.find(item => item.id === lane.id) + assert.equal(running.run.task.id, '#77') + assert.equal(running.run.nodeStatus.plan, 'running') + const renamed = svc.define({ + workflow: 'Hotfix lane', + name: 'Hotfix lane', + nodes: lane.nodes, + edges: [{ from: 'code', to: 'plan' }], + }) + assert.equal(renamed.workflows.find(item => item.id === lane.id).edges[0].from, 'code') + assert.equal(renamed.workflows.find(item => item.id === lane.id).run.nodeStatus.plan, 'running') + assert.equal(svc.runNamed({ workflow: 'missing', brief: 'x' }).ok, false) + assert.equal(svc.define({ + name: 'Remote', + nodes: [{ id: 'friend', name: 'Remote', botId: 'bot-friend' }], + edges: [], + }).ok, false) +}) + +test('an open task auto-starts the matching workflow, and a miss stays unassigned', () => { + const svc = service() + const bots = [ + { id: 'bot-coder', name: 'Coder', remote: false }, + { id: 'bot-friend', name: 'Remote', remote: true }, + ] + const workflows = svc.state().workflows + assert.equal(matchTask({ id: '#1', title: 'hello', status: 'open' }, { workflows, bots }), null) + assert.equal(matchTask({ id: '#1', title: 'notice', status: 'working' }, { workflows, bots }), null) + assert.deepEqual(matchTask({ id: '#1', title: '写周报', status: 'open' }, { workflows, bots }), { kind: 'workflow', id: 'weekly' }) + assert.deepEqual(matchTask({ id: '#1', title: 'notice 里写 workflow:weekly', status: 'open' }, { workflows, bots }), { kind: 'workflow', id: 'weekly' }) + assert.deepEqual(matchTask({ id: '#1', title: 'x', status: 'open', assignee: 'Coder' }, { workflows, bots }), { kind: 'bot', id: 'bot-coder' }) + assert.equal(matchTask({ id: '#1', title: 'x', status: 'open', assignee: 'Remote' }, { workflows, bots }), null) + + const held = svc.ingest({ id: '#1280', title: '缩短交付组群 notice', status: 'open' }) + assert.equal(held.already, true) + assert.equal(delivery(svc.state()).run.steps, 3) + + const weekly = svc.ingest({ id: '#50', title: '周五周报', status: 'open' }) + assert.equal(weekly.matched, true) + assert.equal(weekly.decision.id, 'weekly') + assert.equal(svc.state().workflows.find(item => item.id === 'weekly').run.status, 'running') + const again = svc.ingest({ id: '#50', title: '周五周报', status: 'open' }) + assert.equal(again.already, true) + + const bot = svc.ingest({ id: '#51', title: '请看一下', status: 'open', assignee: 'Coder' }) + assert.deepEqual(bot.decision, { kind: 'bot', id: 'bot-coder' }) + assert.equal(svc.ingest({ id: '#52', title: 'nothing', status: 'open' }).matched, false) + assert.equal(svc.ingest({ id: '#53', title: 'notice', status: 'accepted' }).matched, false) +}) + +test('apply registers the workflow endpoints and stays quiet when disabled', () => { + const names = [] + const writes = [] + const fb = { + bot: { list: () => roster }, + readOnly: () => false, + api: { handle(name, fn, opts) { names.push([name, opts?.read === true]); return () => {} } }, + tasks: { onWrite(fn) { writes.push(fn); return () => {} } }, + warn() {}, + } + const ctx = { flybot: { scope: () => fb }, effect(fn) { fn() } } + const started = apply(ctx, { persist: false }) + assert.equal(started.enabled, true) + assert.ok(names.some(([name, read]) => name === 'workflow/state' && read)) + assert.ok(names.some(([name]) => name === 'workflow/assign')) + assert.ok(names.some(([name]) => name === 'workflow/graph')) + assert.ok(names.some(([name, read]) => name === 'workflow/templates' && read)) + assert.equal(writes.length, 1) + assert.deepEqual(writes[0]({ id: '#9', title: 'please', status: 'open', assignee: 'Coder' }), { ownerBotId: 'bot-coder' }) + assert.equal(writes[0]({ id: '#10', title: 'nothing', status: 'open' }), null) + const tools = [] + const withTools = { ...fb, tools: { register(spec) { tools.push(spec.name); return () => {} } } } + apply({ flybot: { scope: () => withTools }, effect(fn) { fn() } }, { persist: false }) + assert.deepEqual(tools, ['run_workflow', 'define_workflow']) + const off = apply({ flybot: { scope: () => fb } }, { enabled: false }) + assert.equal(off.enabled, false) +}) diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js new file mode 100644 index 000000000..844ab192d --- /dev/null +++ b/plugins/workflow/test/client.test.js @@ -0,0 +1,243 @@ +// The workflow chat (client.js) through a small hook renderer: the sidebar row, +// the flowchart editor, and the rule that a hostile name stays text. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const live = { inst: null, idx: 0 } +const react = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { + const value = typeof next === 'function' ? next(slot.value) : next + if (!Object.is(value, slot.value)) { slot.value = value; inst.root.schedule() } + }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + const prev = inst.hooks[i] + const changed = !prev || !deps || deps.length !== prev.deps?.length || deps.some((dep, k) => !Object.is(dep, prev.deps[k])) + if (!prev) inst.hooks[i] = { deps } + if (changed) { inst.hooks[i].deps = deps; inst.root.pending.push({ slot: inst.hooks[i], fn }) } + }, + useRef(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { current: init } + return inst.hooks[i] + }, +} + +function mount(Component, props = {}) { + const root = { instances: new Map(), pending: [], rendering: false, dirty: false, tree: undefined } + const expand = (node, path) => { + if (node === null || node === undefined || typeof node === 'boolean') return undefined + if (typeof node !== 'object') return String(node) + if (Array.isArray(node)) return node.map((child, i) => expand(child, `${path}.${i}`)) + if (typeof node.type === 'function') { + let inst = root.instances.get(path) + if (!inst) { inst = { hooks: [], root }; root.instances.set(path, inst) } + inst.used = true + live.inst = inst + live.idx = 0 + return expand(node.type({ ...node.props, children: node.children }), `${path}/c`) + } + return { type: node.type, props: node.props, children: node.children.map((child, i) => expand(child, `${path}.${i}`)).flat(Infinity).filter(child => child !== undefined) } + } + const render = () => { + root.rendering = true + for (const inst of root.instances.values()) inst.used = false + root.tree = expand({ type: Component, props, children: [] }, 'r') + const stale = [] + for (const [path, inst] of root.instances) if (!inst.used) stale.push(path) + for (const path of stale) { + for (const slot of root.instances.get(path).hooks) slot?.cleanup?.() + root.instances.delete(path) + } + const run = root.pending.splice(0) + for (const { slot, fn } of run) { slot.cleanup?.(); const out = fn(); slot.cleanup = typeof out === 'function' ? out : undefined } + root.rendering = false + if (root.dirty || root.pending.length) { root.dirty = false; render() } + } + root.schedule = () => { if (root.rendering) root.dirty = true; else render() } + render() + return { + get tree() { return root.tree }, + unmount() { for (const inst of root.instances.values()) for (const slot of inst.hooks) slot?.cleanup?.(); root.instances.clear() }, + } +} + +const findAll = (node, pred, out = []) => { + if (Array.isArray(node)) { for (const child of node) findAll(child, pred, out); return out } + if (!node || typeof node !== 'object') return out + if (pred(node)) out.push(node) + for (const child of node.children ?? []) findAll(child, pred, out) + return out +} +const textOf = node => (typeof node === 'string' ? node : node ? (node.children ?? []).map(textOf).join('') : '') +const esc = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +const htmlOf = (node) => { + if (Array.isArray(node)) return node.map(htmlOf).join('') + if (typeof node === 'string') return esc(node) + const attrs = Object.entries(node.props ?? {}).filter(([k, v]) => v !== undefined && v !== false && typeof v !== 'function' && k !== 'key' && k !== 'ref').map(([k, v]) => ` ${k}="${esc(typeof v === 'object' ? JSON.stringify(v) : v)}"`).join('') + return `<${node.type}${attrs}>${(node.children ?? []).map(htmlOf).join('')}` +} +const button = (tree, label) => findAll(tree, node => node.type === 'button' && textOf(node) === label)[0] + +const documentStub = { + listeners: [], + getElementById() { return null }, + createElement: () => ({ id: '', textContent: '' }), + head: { appendChild() {} }, + querySelector() { return null }, + addEventListener(name, fn, capture) { this.listeners.push({ name, fn, capture }) }, + removeEventListener(name, fn) { this.listeners = this.listeners.filter(item => item.fn !== fn) }, +} +globalThis.document = documentStub + +function loadClient() { + const modules = new Map() + const window = { + __ModuleLoader__: { + load: ({ id, factory }) => modules.set(id, factory(name => { + if (name === 'react') return react + if (name === 'react-dom') throw new Error('no react-dom in the unit renderer') + return {} + })), + }, + } + new Function('window', readFileSync(new URL('../client.js', import.meta.url), 'utf8'))(window) + return modules.get('@flybot/workflow') +} + +const client = loadClient() +const HOSTILE = '' +const SCRIPT = '' + +function delivery() { + return { + id: 'delivery', + name: '发布交付流水线', + description: 'Ship a notice', + chain: 'Planner → Writer → Coder → Reviewer ‖ Tester → Deployer', + preview: 'Coder 正在处理 #1280…', + trigger: 'manual', + layers: [['planner'], ['writer'], ['coder'], ['reviewer', 'tester'], ['deployer']], + nodes: [ + { id: 'planner', name: 'Planner', role: '计划', x: 200, y: 24 }, + { id: 'writer', name: 'Writer', role: '撰写', x: 200, y: 140 }, + { id: 'coder', name: 'Coder', role: HOSTILE, x: 200, y: 260 }, + { id: 'reviewer', name: 'Reviewer', role: '审阅', x: 80, y: 380 }, + { id: 'tester', name: 'Tester', role: '测试', x: 320, y: 380 }, + { id: 'deployer', name: 'Deployer', role: '发布', x: 200, y: 520 }, + ], + edges: [ + { from: 'planner', to: 'writer' }, + { from: 'writer', to: 'coder' }, + { from: 'coder', to: 'reviewer' }, + { from: 'coder', to: 'tester' }, + { from: 'reviewer', to: 'deployer' }, + { from: 'tester', to: 'deployer' }, + ], + messages: [{ id: 'm1', kind: 'user', text: SCRIPT }], + run: { + status: 'running', + task: { id: '#1280', title: '缩短交付组群 notice' }, + nodeStatus: { planner: 'done', writer: 'done', coder: 'running', reviewer: 'queued', tester: 'queued', deployer: 'queued' }, + running: ['coder'], + doneCount: 2, + nodeCount: 6, + marks: ['done', 'done', 'running', 'queued', 'queued'], + steps: 3, + stepBudget: 10, + }, + } +} + +async function settle() { + for (let i = 0; i < 4; i++) await new Promise(resolve => setImmediate(resolve)) +} + +test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are saved', async () => { + const calls = [] + const graph = delivery() + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph], templates: [{ id: 'delivery', name: '发布交付流水线' }] } + if (name === 'workflow/create') return { ok: true, workflows: [graph, { ...graph, id: 'delivery-copy', name: '发布交付流水线 副本' }] } + return { ok: true, workflows: [graph] } + } + const roster = { bots: [{ id: 'bot-docs', name: 'Docs', role: 'notes', remote: false }, { id: 'bot-friend', name: 'Remote', remote: true }] } + const view = mount(client.Section, { roster, call, actions: {} }) + await settle() + assert.ok(calls.some(item => item.name === 'workflow/state')) + assert.ok(textOf(view.tree).includes('发布交付流水线')) + assert.ok(textOf(view.tree).includes('Coder 正在处理 #1280…')) + + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Flow')[0].props.onClick() + const opened = textOf(view.tree) + assert.ok(opened.includes('并行分支 · 全部通过后汇合')) + assert.ok(opened.includes('Reviewer')) + assert.ok(opened.includes('Tester')) + const html = htmlOf(view.tree) + assert.equal(html.includes(' node.props?.['data-node'] === 'coder')[0] + coder.props.onPointerDown({ clientX: 10, clientY: 20, target: { dataset: {} } }) + let canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerMove({ clientX: 58, clientY: 44 }) + canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerUp({ clientX: 58, clientY: 44, target: { dataset: {} } }) + const moved = calls.find(item => item.name === 'workflow/graph') + assert.equal(moved.body.nodes.find(node => node.id === 'coder').x, 248) + assert.equal(moved.body.nodes.find(node => node.id === 'coder').y, 284) + + const port = findAll(view.tree, node => node.props?.['data-port'] === 'out' && node.props['aria-label'] === 'Writer')[0] + port.props.onPointerDown({ preventDefault() {}, stopPropagation() {}, target: { dataset: { port: 'out' } } }) + canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerUp({ target: { dataset: { node: 'tester' }, closest() { return null } } }) + const wired = calls.filter(item => item.name === 'workflow/graph').at(-1) + assert.ok(wired.body.edges.some(edge => edge.from === 'writer' && edge.to === 'tester')) + + const docs = findAll(view.tree, node => node.props?.['data-bot'] === 'bot-docs')[0] + assert.ok(docs) + assert.equal(findAll(view.tree, node => node.props?.['data-bot'] === 'bot-friend').length, 0) + docs.props.onPointerDown({ preventDefault() {}, target: { dataset: { bot: 'bot-docs' } } }) + canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerUp({ target: { dataset: { plane: '1' } }, offsetX: 30, offsetY: 40 }) + const added = calls.filter(item => item.name === 'workflow/graph').at(-1) + const seat = added.body.nodes.find(node => node.botId === 'bot-docs') + assert.equal(seat.name, 'Docs') + assert.equal(seat.x, 30) + assert.equal(seat.y, 40) + + button(view.tree, 'New').props.onClick() + const template = findAll(view.tree, node => node.type === 'button' && node.props.className === 'wf-btn' && textOf(node) === '发布交付流水线')[0] + template.props.onClick() + await settle() + assert.ok(calls.some(item => item.name === 'workflow/create' && item.body.template === 'delivery')) + view.unmount() +}) + +test('Chinese labels follow the host Tasks translation', () => { + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { t: key => (key === 'Tasks' ? '任务' : key) } + try { + assert.equal(client.tr('Edit'), '编辑') + assert.equal(client.tr('Hand a task to a Workflow'), '把任务交给 Workflow') + assert.equal(client.columnOf({ status: 'working' }), 'in-progress') + assert.equal(client.shortId('task-abcdef123456'), '#123456') + } finally { + globalThis.__flybot__ = prev + } +}) diff --git a/plugins/workflow/test/graph.test.js b/plugins/workflow/test/graph.test.js new file mode 100644 index 000000000..9bfd12c35 --- /dev/null +++ b/plugins/workflow/test/graph.test.js @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + addNode, chainLabel, completeNode, connectNodes, doneCount, failNode, freshStatus, layerMarks, + moveNode, nextDemoOp, normalizeGraph, reconnectEdge, removeEdge, removeNode, retryNode, + rowPreview, startFrontier, validateGraph, +} from '../src/graph.js' +import { deliveryWorkflow, weeklyWorkflow } from '../src/template.js' + +const delivery = deliveryWorkflow() + +function runToCoder() { + let status = freshStatus(delivery) + status = startFrontier(delivery, status).status + status = completeNode(delivery, status, 'planner').status + status = completeNode(delivery, status, 'writer').status + return status +} + +test('a delivery run walks in order and the parallel layer joins before Deployer', () => { + let status = freshStatus(delivery) + assert.deepEqual(layerMarks(delivery, status), ['queued', 'queued', 'queued', 'queued', 'queued']) + + status = startFrontier(delivery, status).status + assert.equal(status.planner, 'running') + assert.equal(status.writer, 'queued') + + status = completeNode(delivery, status, 'planner').status + assert.equal(status.planner, 'done') + assert.equal(status.writer, 'running') + assert.equal(status.coder, 'queued') + + status = completeNode(delivery, status, 'writer').status + assert.equal(status.coder, 'running') + assert.equal(doneCount(status), 2) + + const opened = completeNode(delivery, status, 'coder') + status = opened.status + assert.deepEqual(opened.started, ['reviewer', 'tester']) + assert.equal(status.reviewer, 'running') + assert.equal(status.tester, 'running') + assert.equal(status.deployer, 'queued') + + const half = completeNode(delivery, status, 'reviewer') + status = half.status + assert.equal(half.joined, false) + assert.deepEqual(half.started, []) + assert.equal(status.reviewer, 'done') + assert.equal(status.tester, 'running') + assert.equal(status.deployer, 'queued') + + const joined = completeNode(delivery, status, 'tester') + status = joined.status + assert.equal(joined.joined, true) + assert.deepEqual(joined.started, ['deployer']) + assert.equal(status.deployer, 'running') + + const finished = completeNode(delivery, status, 'deployer') + assert.equal(finished.finished, true) + assert.equal(doneCount(finished.status), 6) + assert.equal(chainLabel(delivery), 'Planner → Writer → Coder → Reviewer ‖ Tester → Deployer') +}) + +test('completing a Bot that is not running is refused, and a failed Bot blocks the join', () => { + const status = runToCoder() + const opened = completeNode(delivery, status, 'coder').status + assert.throws(() => completeNode(delivery, opened, 'deployer'), /not running/) + const failed = failNode(delivery, opened, 'tester').status + assert.equal(failed.tester, 'failed') + const afterReview = completeNode(delivery, failed, 'reviewer') + assert.equal(afterReview.joined, false) + assert.equal(afterReview.status.deployer, 'queued') + const retried = retryNode(delivery, afterReview.status, 'tester').status + assert.equal(retried.tester, 'running') + const joined = completeNode(delivery, completeNode(delivery, retried, 'tester').status, 'deployer') + assert.equal(joined.finished, true) +}) + +test('the demo step fails Tester once, then the join still waits for both branches', () => { + let status = runToCoder() + status = completeNode(delivery, status, 'coder').status + const memory = { failedOnce: {} } + const first = nextDemoOp(delivery, status, memory) + assert.deepEqual(first, { type: 'fail', nodeId: 'tester' }) + status = failNode(delivery, status, 'tester').status + memory.failedOnce.tester = true + assert.deepEqual(nextDemoOp(delivery, status, memory), { type: 'retry', nodeId: 'tester' }) + status = retryNode(delivery, status, 'tester').status + assert.deepEqual(nextDemoOp(delivery, status, memory), { type: 'complete', nodeId: 'reviewer' }) + status = completeNode(delivery, status, 'reviewer').status + assert.equal(status.deployer, 'queued') + assert.deepEqual(nextDemoOp(delivery, status, memory), { type: 'complete', nodeId: 'tester' }) +}) + +test('a weekly graph has no fail-once step, and a node cannot sit in two layers', () => { + const weekly = weeklyWorkflow() + let status = startFrontier(weekly, freshStatus(weekly)).status + assert.deepEqual(nextDemoOp(weekly, status, {}), { type: 'complete', nodeId: 'researcher' }) + assert.throws(() => validateGraph({ + nodes: [{ id: 'planner', name: 'Planner' }], + layers: [['planner'], ['planner']], + }), /more than one step|listed twice|belongs to one step/) +}) + +test('moving a node keeps the edges, and reconnecting a join changes who Deployer waits for', () => { + const graph = normalizeGraph(delivery) + const moved = moveNode(graph, 'coder', 40, 220) + assert.equal(moved.nodes.find(node => node.id === 'coder').x, 40) + assert.equal(moved.nodes.find(node => node.id === 'coder').y, 220) + assert.ok(moved.edges.some(edge => edge.from === 'coder' && edge.to === 'reviewer')) + assert.equal(moved.nodes.find(node => node.id === 'planner').x, graph.nodes.find(node => node.id === 'planner').x) + + const dropped = removeEdge(moved, 'coder', 'tester') + const rejoined = connectNodes(dropped, 'writer', 'tester') + let status = freshStatus(rejoined) + status = startFrontier(rejoined, status).status + status = completeNode(rejoined, status, 'planner').status + const afterWriter = completeNode(rejoined, status, 'writer') + assert.ok(afterWriter.started.includes('tester')) + assert.ok(afterWriter.started.includes('coder')) + status = afterWriter.status + status = completeNode(rejoined, status, 'coder').status + assert.equal(status.deployer, 'queued') + status = completeNode(rejoined, status, 'reviewer').status + assert.equal(status.deployer, 'queued') + status = completeNode(rejoined, status, 'tester').status + assert.equal(status.deployer, 'running') + + assert.throws(() => connectNodes(rejoined, 'deployer', 'planner'), /cycle/) + const swapped = reconnectEdge(rejoined, 'tester', 'deployer', 'reviewer') + assert.ok(swapped.edges.some(edge => edge.from === 'tester' && edge.to === 'reviewer')) + assert.equal(swapped.edges.some(edge => edge.from === 'tester' && edge.to === 'deployer'), false) +}) + +test('a canvas can add and remove a local Bot without losing the other seats', () => { + const graph = normalizeGraph(delivery) + const added = addNode(graph, { id: 'n7', name: 'Docs', botId: 'bot-docs', x: 12, y: 40 }) + assert.equal(added.nodes.at(-1).id, 'n7') + assert.equal(added.nodes.at(-1).x, 12) + const linked = connectNodes(added, 'deployer', 'n7') + assert.equal(linked.layers.at(-1)[0], 'n7') + const removed = removeNode(linked, 'n7') + assert.equal(removed.nodes.some(node => node.id === 'n7'), false) + assert.equal(removed.edges.some(edge => edge.to === 'n7'), false) + assert.equal(removed.nodes.length, 6) + assert.throws(() => addNode(graph, { id: 'planner', name: 'Planner' }), /listed twice/) +}) + +test('the sidebar line names the running Bot, otherwise the schedule', () => { + const status = runToCoder() + const run = { status: 'running', nodeStatus: status, task: { id: '#1280', title: 'notice' } } + assert.equal(rowPreview(delivery, run), 'Coder 正在处理 #1280…') + assert.equal(rowPreview(weeklyWorkflow(), { status: 'done', nodeStatus: {}, task: { id: 'W42' } }), '下次运行:周五 17:00') + assert.equal(rowPreview(delivery, null), '等待任务') +}) diff --git a/scripts/lint-plugins.mjs b/scripts/lint-plugins.mjs index a729882ce..9780db47d 100644 --- a/scripts/lint-plugins.mjs +++ b/scripts/lint-plugins.mjs @@ -84,7 +84,8 @@ export function outboundImports(pluginDir, pkg = JSON.parse(readFileSync(join(pl } const name = packageNameOf(spec) if (declared.has(name)) continue - if (name === 'react' && relFile === 'client.js') continue + // The web shell injects React. A client entry may portal with react-dom; nothing else may. + if ((name === 'react' || name === 'react-dom') && relFile === 'client.js') continue problems.push({ file: relFile, spec }) } }