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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1856-workflow-project.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
We worked a workflow (a workflow now belongs to a Project: its definition carries projectId, the run is bound when it starts, and Settings shows the Project) — [#1856](https://github.com/ThinkFlowLab/FlyBot/issues/1856).
15 changes: 15 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,21 @@ export function install(rt) {
return rt.taskCounters?.(query) ?? { roles: {}, runs: {} }
},
},
// #1856: a workflow run belongs to a Project, and the plugin cannot write host state. A host
// without this seam answers `undefined` and never throws: the run stays unbound and keeps today
// behavior. The write goes through the same `bindProject` the endpoint uses, so a run bound
// from an assign and a run bound from Settings cannot disagree.
projects: {
read(id) {
return typeof rt.readProject === "function" ? rt.readProject(String(id ?? '')) ?? undefined : undefined
},
async bindRun(runId, projectId) {
if (typeof rt.bindProject !== "function") return undefined
const project = rt.bindProject({ kind: 'run', id: String(runId ?? ''), projectId })
await rt.save?.()
return project ?? undefined
},
},
tools,
api,
outcomeOf: exchangeId => outcomes.get(exchangeId),
Expand Down
49 changes: 49 additions & 0 deletions core/test/project-run-seam.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// #1856: a workflow run belongs to a Project, but the plugin cannot write host state. `bindRun`
// is that one seam: it writes `state.projectRuns` through the same `bindProject` the endpoint
// uses, refuses an unknown Project, and a host without it answers `undefined` instead of throwing.
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 seed = () => ({
version: 1,
mainBotIds: ['a'],
bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } },
rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,
})

async function start(t) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-run-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(seed()))
const rt = createRuntime(fakeContext(), { home })
t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 }))
await rt.load()
return rt
}

test('#1856: bindRun writes the run binding the board reads', async (t) => {
const rt = await start(t)
const project = rt.createProject({ name: 'FlyBot 开发' })
const bound = await rt.flybot.projects.bindRun('run-abc', project.id)
assert.equal(bound.id, project.id)
assert.equal(rt.state.projectRuns['run-abc'], project.id)
assert.equal(rt.flybot.projects.read(project.id).name, 'FlyBot 开发')
})

test('#1856: bindRun refuses an unknown Project and writes nothing', async (t) => {
const rt = await start(t)
await assert.rejects(() => rt.flybot.projects.bindRun('run-abc', 'project-missing'), /Unknown Project project-missing/)
assert.equal(rt.state.projectRuns['run-abc'], undefined)
})

test('#1856: a host without the seam answers undefined instead of throwing', async (t) => {
const rt = await start(t)
rt.readProject = undefined
rt.bindProject = undefined
assert.equal(rt.flybot.projects.read('project-1'), undefined)
assert.equal(await rt.flybot.projects.bindRun('run-abc', 'project-1'), undefined)
})
4 changes: 4 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -769,3 +769,7 @@ the host side only, so the tier stays T2 and `core/src/shared/` is untouched.
## Project metrics (#1829)

`core/src/host/project-metrics.js` answers the Project board's seven numbers: PRs opened and merged today, issues opened and closed today, and the seven-day medians of PR merge, issue close and task completion durations. The four day counts compare `created_at` / `merged_at` / `closed_at` against the reader's own time zone (`Intl.DateTimeFormat`), not the host clock, and the local task half reads `createdAt` / `completedAt` from `state.tasks`. The numbers ride on the existing `project-board` endpoint (`store.js` carries the `metrics` section beside the rows), which passes the host secret store's `GITHUB_TOKEN` and the reader's time zone from the request, requires the Project, and answers `ok: false` with `null` values when the credential is missing or a read fails — the client paints `—`, never `0`. One five-minute in-process cache keys on repository, time zone and window, so a hit spends no source call; a failed read is not cached. `runningOf` projects a claimed card into the running table's row (who, claim age, source, last activity).

## The Project run seam (#1856)

`fb.projects` is the one seam a workflow run's Project binding goes through. `read(id)` answers the Project record or `undefined`; `bindRun(runId, projectId)` writes `state.projectRuns[runId]` through `bindProject({ kind: 'run', … })` — the path the `project-bind` endpoint already uses — and saves. An unknown Project throws the store's own refusal, so the caller (the workflow plugin) treats it as a refused binding and keeps the run. A host without either function answers `undefined` and never throws, which is what an older plugin instance sees.
8 changes: 8 additions & 0 deletions docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,11 @@ Core never reads `$DSH_HOME/bot/flybot-workflow`. The plugin registers `flybot.t
A connector a Bot switched off is not reopened by a Workflow, whichever surface started the run. A run records the Bots whose switches cap it, both in `cappedBy`: the Bot whose `run_workflow` call started it, and the Bot that saved the graph (`authoredBy` on the workflow record, set by `define_workflow` and by any save that names a Bot). A run Bot carries no `createdBy` (#1582): the roster reads that field as "a Bot made this Bot" and would start the run Bot on its maker's model, so the caller travels in `cappedBy` only — a ceiling, not a parent. Every node's run Bot carries `cappedBy` — the caller, else the graph's author — and `core/src/host/connector-access.js` intersects the node's `tools` with those switches, so a node that names a connector its caller or author turned off is refused with `turned off for <name>; the step's tools list cannot turn it back on`, and the tool is masked out of the run Bot's list too. A run nobody's tool call started (the board's `ingest`, the canvas, the `workflow/*` API) has no caller and is capped by the graph's author alone; a save with no Bot behind it records no author, which is the owner's own edit and leaves no ceiling; a graph saved before this has none either. `visibleTo` on `run_workflow`/`define_workflow` is unchanged: the ceiling is the fix, not limiting who may define a Workflow.
- **Strict order (#1729).** A graph may carry `strict: true` (normalized to a boolean on the record). A strict graph starts one node at a time in declared order: `startFrontier` keeps only the first ready node running and leaves its ready siblings `queued`, so no parallel fan-out happens even when two nodes share a rank. They start when the node before them finishes, in list order. The default is unchanged — a graph without the flag still fans out — and the release-shaped delivery template passes unchanged. A second start against the persisted status after a crash between a claim and its result starts nothing that is already running and reaches no downstream node early, so no second downstream task is minted. The strict release graph (build → test → publish) and its refusal of a node whose required input was never produced are the regression (#1730). Tests: `plugins/workflow/test/strict-order.test.js`, `plugins/workflow/test/strict-release-dag.test.js`.
- **Lease and delivery reads go through the host (#1729).** The plugin reads a card's lease with `fb.tasks.lease(taskId)` (`{ holder, takenAt, expiresAt, live, heldBy }`, or `undefined`) and the per-role/per-run delivery and resource counts with `fb.tasks.counters({ role, run })`. Both are host reads of the existing task rows (`rt.taskLease`, `rt.taskCounters`); the plugin keeps no copy, so an expired lease looks expired to both sides at once.

## A workflow belongs to a Project (#1856)

A workflow record carries `projectId`. `saveGraph` is the only writer: a non-empty value must name an existing Project (`fb.projects.read`, which reads the host's `readProject`) or the whole save is refused and nothing is written; `null` clears it; omitting the field keeps what is stored.

- The Settings page names the Project on every sidebar row (a workflow with none reads `Unassigned`, zh 未归属), offers one Project filter above the list (`All Projects` plus each Project plus the unassigned rows), and carries a Project picker in the graph editor; the picker saves through the same `workflow/graph` call that carries the nodes and edges, and an empty choice sends `null`. The picker is fed by the host `projects` endpoint, so it lists what the core store holds and never a second copy.

A run is bound at creation, through `fb.projects.bindRun(runId, projectId)` — the seam beside `fb.tasks.onProjectProvider`. `assign()` resolves the Project once (the call's own `projectId` wins over the workflow's) and carries it with the task, so a parked run binds when it actually starts. The binding goes through the host's `bindProject({ kind: 'run', … })`, the same path the `project-bind` endpoint uses, so the Project board and Settings cannot disagree. An unknown Project, or a host without the seam, leaves the run unbound and logs once; it never stops the run from starting.
15 changes: 15 additions & 0 deletions patches/ds-bot/files/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,21 @@ export function install(rt) {
return rt.taskCounters?.(query) ?? { roles: {}, runs: {} }
},
},
// #1856: a workflow run belongs to a Project, and the plugin cannot write host state. A host
// without this seam answers `undefined` and never throws: the run stays unbound and keeps today
// behavior. The write goes through the same `bindProject` the endpoint uses, so a run bound
// from an assign and a run bound from Settings cannot disagree.
projects: {
read(id) {
return typeof rt.readProject === "function" ? rt.readProject(String(id ?? '')) ?? undefined : undefined
},
async bindRun(runId, projectId) {
if (typeof rt.bindProject !== "function") return undefined
const project = rt.bindProject({ kind: 'run', id: String(runId ?? ''), projectId })
await rt.save?.()
return project ?? undefined
},
},
tools,
api,
outcomeOf: exchangeId => outcomes.get(exchangeId),
Expand Down
49 changes: 49 additions & 0 deletions patches/ds-bot/files/test/project-run-seam.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// #1856: a workflow run belongs to a Project, but the plugin cannot write host state. `bindRun`
// is that one seam: it writes `state.projectRuns` through the same `bindProject` the endpoint
// uses, refuses an unknown Project, and a host without it answers `undefined` instead of throwing.
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 seed = () => ({
version: 1,
mainBotIds: ['a'],
bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } },
rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,
})

async function start(t) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-run-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(seed()))
const rt = createRuntime(fakeContext(), { home })
t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 }))
await rt.load()
return rt
}

test('#1856: bindRun writes the run binding the board reads', async (t) => {
const rt = await start(t)
const project = rt.createProject({ name: 'FlyBot 开发' })
const bound = await rt.flybot.projects.bindRun('run-abc', project.id)
assert.equal(bound.id, project.id)
assert.equal(rt.state.projectRuns['run-abc'], project.id)
assert.equal(rt.flybot.projects.read(project.id).name, 'FlyBot 开发')
})

test('#1856: bindRun refuses an unknown Project and writes nothing', async (t) => {
const rt = await start(t)
await assert.rejects(() => rt.flybot.projects.bindRun('run-abc', 'project-missing'), /Unknown Project project-missing/)
assert.equal(rt.state.projectRuns['run-abc'], undefined)
})

test('#1856: a host without the seam answers undefined instead of throwing', async (t) => {
const rt = await start(t)
rt.readProject = undefined
rt.bindProject = undefined
assert.equal(rt.flybot.projects.read('project-1'), undefined)
assert.equal(await rt.flybot.projects.bindRun('run-abc', 'project-1'), undefined)
})
Loading
Loading