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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/actions/flybot-tests/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/check-pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions .github/scripts/plugin-imports.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
})
10 changes: 10 additions & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1318-workflow.added.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 3 additions & 2 deletions core/scripts/install-doctor.mjs
Original file line number Diff line number Diff line change
@@ -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.
//
Expand All @@ -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'],
]

Expand Down Expand Up @@ -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}"`)
}

Expand Down
4 changes: 4 additions & 0 deletions core/scripts/local-ci.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 26 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down
22 changes: 22 additions & 0 deletions core/src/host/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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': {
Expand All @@ -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': {
Expand Down
11 changes: 6 additions & 5 deletions core/test/install-doctor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 })
Expand Down
96 changes: 96 additions & 0 deletions core/test/task-route.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
8 changes: 8 additions & 0 deletions core/types/flybot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>, 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/<name>', payload }. */
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading