| subsystem | workflow | |
|---|---|---|
| owned_paths |
|
A group-shaped chat of local Bots, with a flowchart the owner can drag once Edit is on (#1318, #1378). 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 workflow-definition engine. That engine (#1272) stores workflows/<id>.json and guards artifacts. This plugin does not read or write those files and does not call that engine.
Settings → Workflows (#1364) is this plugin's list. The page lives in the core client and calls workflow/state, workflow/create and workflow/graph. Factory rows are templateCatalog() — id, name, description, triggers, plus the drawable nodes, edges, layers and chain — and the owner's rows are the saved workflows. The settings page does not replace the sidebar chat. A sidebar click opens this plugin's stage and leaves the Workflows section in place.
A host call on that page that throws, rejects, or never answers ends on the page's own .wf-err alert instead of staying on Reading workflows… or looking saved: a failed read offers Try again, and a failed save or model change clears the Changes are saved automatically hint. A successful call clears the alert again.
A confirmation dialog the host refuses — Rename, Delete, Create, or Hand a task to a Workflow — keeps the dialog open and prints the host's own sentence inside the dialog as the only role="alert" for that refusal: while one of those four dialogs holds the sentence the section draws no .wf-err copy of it, because a live region behind the mask is still read out and one refusal would be announced twice (#2193, #2202, #2237). Opening any of the four clears the previous refusal; closing the assign dialog drops its refusal rather than leaving it on the page. A refusal with no dialog open still reports once on the page, and the success path is unchanged.
With no Workflow defined, step 2 of Hand a task to a Workflow has no Workflow to default to and Assign stays disabled until a target is picked (the Bots in the same list are still offered), so a click can no longer post an empty workflowId the host answers with "No such workflow" (#2245).
The Add link row applies the drawer's pair rule before it saves (#2173): a pair the graph already lists is a no-op, another kind over that same pair names the kind already there instead of submitting, and the To list marks the targets the chosen node already reaches. The host still refuses a duplicated pair — the form is what stops producing one.
A Condition (when) or Loop (until) link takes its output name from the From node's declared outputs (#2178): the field is a select over exactly those names, Add link waits for one, and a node that declares none keeps the text box for an older graph. The host still refuses a name it cannot honour — cleanWhen and PORT_RE are unchanged.
Everything in plugins/workflow/: the graph rules, the run store, the matcher, the browser chat and the flowchart canvas.
flybot.api.handle,flybot.tools.register,flybot.tasks.onWriteandflybot.tasks.onProjectProvider→ plugin-runtime.- The board's own state machine (
task-create,task-update, the task drive) → core-host. The plugin hears a saved card and may name a local Bot; the host applies that owner. - Install commands → install.
@flybot/workflowis a default install.@flybot/timelinestays opt-in.
| File | Role |
|---|---|
plugins/workflow/index.js |
Plugin entry (flybot-workflow). Endpoints, run_workflow, define_workflow, the board listener, and the reply middleware that ingests a step result and denies delivery to persistent Bot chats. |
plugins/workflow/src/graph.js |
Nodes, edges, positions, and named inputs and outputs. A node starts when its forward predecessors are settled and a taken edge reaches it. Same-rank nodes run together. A forward cycle is refused. A loop edge is the only cycle for success paths. A when edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. An onFail edge provides failure recovery: when a node fails with recoveries remaining, the workflow resets the body from the upstream target through the failing node and re-runs it in order (tracked by failAttempts per edge, with max counting recoveries, 1–8, default 3). After the recoveries are used, the node fails permanently. |
plugins/workflow/src/service.js |
The store, lanes (active runs, FIFO queue, terminal history), maxConcurrent, rename, delete, saveGraph, define, runNamed, ingest, stepResult. rename updates a workflow's name and optional description; delete removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes and the task claims that named it — a claim is judged on its own record (kind === 'workflow' and that id), not by the route map, which its own cleanup has already emptied (#1632); a run Bot whose release failed is kept in the store's own orphans list — not in a variable — and the load-time reap retries it, once per id, capped, so deleting a workflow whose Bot will not go does not strand that Bot under a name nothing can reach (the #1616 contract). Factory template ids (e.g. delivery) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin ends every live run still marked running (nodes in flight are failed, the run is archived) and then releases every run Bot and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown cannot persist a run as running with no ephemeral map and cannot leave hidden Bots that no state names, for every live run in a lane rather than only the one archived last (#1387, #1414, #1472). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed: it is retried twice with a short backoff, and a step still unanswered after its workflow's stepTimeoutMinutes (45 minutes by default) fails the run and releases its Bots (#1530, #1580). A stored claim keeps a task only while the card names nothing new: when the card's own fields name another live workflow or Bot, the claim and its route are dropped and the card is matched again, and rematch: true on ingest drops that one claim first (#1647). workflow/state carries, per workflow, the ids it holds — heldTasks, a page of 20 sorted ids beside the whole heldTaskCount — and a Bot claim is not listed there (#1647); the read takes a heldLimit — 20 by default and 200 at most, read through Number() and kept only when it is a positive integer, so '10' reads 10 and true reads 1 while 0, -5, 3.5, 'many' and null fall back to 20 — so a reader can ask for the rest of the page without turning the poll into a full read (#2256, #2260). Each public run also carries finishedAt (null while the run is live, else the moment it ended) and currentStep ({ id, name } of the node it walks now, else null) — pure additions for the Inbox, which reads them off workflow/state (#1706); a paused run keeps status: 'running' with paused: true, so it is not finished and still names its node. An assignment carries its instruction: assign reads brief — multi-line, up to 8000 characters, kept whole rather than flattened into the 300-character title — onto task.brief, and resolves it from the card first: a call that omits brief keeps the card's stored facts, a call that passes one stores it on a card that has none, and re-dispatching the same card to another workflow keeps the same facts, records task.handoff {from, to, at, reason} naming the lane it moved from and to, and hands the next node the stored facts (a brief input in resolveInputs falls back to the title only when the card has none; #2468). A call that passes brief is the latest word — brief: '' clears the card's facts instead of falling back to the cached ones — and deleting a lane drops the facts it cached, so a later dispatch records no handoff from a lane that no longer exists (#2475). It, a parked card keeps it in workflow/state and in workflows.json, and stepBody hands the node task.brief, falling back to the title only when there is none; before #1869 the node received the title twice and the brief never. runNamed gives a brief the same meaning and derives the title from its first line, and a field assign does not read comes back in ignored instead of disappearing (#1869). |
plugins/workflow/src/ephemeral.js |
Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. node.tools names the connectors that node may call: spawn passes the cleaned list to fb.bot.create, the host records it as stepTools, and connector-access.js allows only the connectors it names (github covers mcp__github__*, composio covers mcp__composio__*) — a node that names none reaches none. File and shell rights do not come from that list: every node Bot gets the danger-full-access preset a normal sidebar Bot has, and EPHEMERAL_DENIED_TOOLS still refuses the team tools at the guard and approval layers. The list is also stored in fields.tools for the UI. |
plugins/workflow/src/match.js |
Which open card matches a Workflow or a local Bot. |
plugins/workflow/src/template.js |
Factory delivery/weekly graphs plus EARLY_END_GUIDANCE for Claim/Verify/Triage early-end markers; 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. |
plugins/workflow/src/narrate.js |
Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. |
plugins/workflow/client.js |
Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer; it selects the first card once the board loads, keeps Assign disabled until a card is selected, and shows a failed Tasks load as an error with a Retry instead of the empty-board hint, #1457, #1458; the hand-off carries the card's own facts — the assign body sends the card's brief verbatim and an explicit empty one when the card holds none, so a run never starts on the title where the card had a work order and a brief an earlier call cached cannot come back (#2495); both of its choice lists draw twelve at a time and a Show {count} more cards / Show {count} more Bots row says how many they are not showing, expanding to the rest and folding back to Show less, so no card or local Bot is out of reach while the default view keeps its length, and a card picked past the twelfth is drawn first once the list is folded again, so the twelve rows never hide the card the button is about to send (#2257), #2244), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as delivery) support Delete with confirm. A row with live runs draws the number beside the dot and carries the count, with the word running, in a visually hidden .wf-sr span — the dot and the digit sit inside an aria-hidden wrap, so the number alone would be silent to a reader — and its accessible name therefore stays the name its content gives: the lane's name, the Workflow badge, its Project and its preview, plus the count; a row-level aria-label replaced all of that and made two lanes that share a name read alike (#2222, #2230). An idle row draws no dot, no count, no digit and no hidden text, so its name stays its own text. The stage and dialogs portal onto document.body; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers {ok:false} or does not answer within 10 s — ends on the section's .wf-err alert instead of being swallowed, and a success clears the alert. The flowchart's node role sub-label takes its colour from --bt-ink-2 rather than a hardcoded grey, so it stays readable on every theme's node card (#1495). The drawer's styling reuses the core --bt-* tokens and the Bot details drawer's patterns (title, pill tabs, soft pill buttons, bubble-surfaced node cards; accent tints mixed from --bt-accent, #1654). The stage lists the tasks the open workflow holds (heldTasks; when heldTaskCount exceeds that page the rest is a Show {count} more held tasks control — a wf-more button with data-more="held" and aria-expanded, whose count is what pressing it reaches past the rows drawn rather than everything the lane holds — that reads the wider page with heldLimit: 200 and folds back to Show less; that page stops at 200, so an unfolded list still short of heldTaskCount draws a wf-held-note line Showing {shown} of {count} held tasks, and neither the count nor the button promises an id it cannot show (#2256, #2260), each with one Re-match action that drops that task's claim and matches it again through workflow/ingest with rematch: true — one board read to hand over the card's fields and one state read after, never a second polling loop (#1647). The drawer's Edit plane keeps one copy of the row geometry: a graph whose parallel cards still fit is the fitted plane (scaled centres, parallelFrameStyle frame, one-fold wirePath), and one that cannot is laid out in flowLayout's own rows — its cards are those boxes, its dashed frame is that band's frame, and its wires are the flowWires routing around them, so a 3+ way step at a 364px drawer keeps its frame inside the panel and no wire crosses a card (#1693). A two-Bot parallel step the fitted plane would squeeze — the drawer's fit leaves the pair 4px apart, with each card's out-port (5px) and remove button (8px) past its right edge under the neighbour — is drawn at that row's flowLayout column width (154px at a 364px drawer) on its fitted centres and wires, keeping the 12px row gap and a plane whose drags still commit world coordinates; parallelFrameStyle adds the 20px label band above the cards and a 10px pad below them, so the fitted frame encloses every card it names (#1738). |
- Endpoint
workflow/stateandworkflow/templatesare reads. workflow/create,workflow/rename,workflow/delete,workflow/assign,workflow/concurrency,workflow/graph,workflow/advance,workflow/reset,workflow/ingestwrite.workflow/concurrencysets that workflow'smaxConcurrent(an integer from 1 to 8); the same control is in Settings → Workflow graphs.workflow/step-timeoutsets that workflow'sstepTimeoutMinutes(an integer from 1 to 120, ornullto go back to the default), andworkflow/createanddefine_workflowaccept the same field, refused outside 1..120 rather than dropped (#1868). Every write lands throughworkflows.json.<pid>.tmp, and that temp name is asked as a file first (temp-name sweep): a symbolic or hard link left there refuses the write, with the temp name in the answer, instead of putting the workflows outside their folder and renaming the link into place —lstatreports a hard link as a plain file although another name holds the same bytes, so the guard also requiresnlink === 1, while a crashed host's own plain leftover stays replaceable.workflow/renameupdates a workflow's name and optional description; read-only hosts are refused.workflow/deleteremoves a workflow from state, releases its ephemeral Bots if a run exists, and cleans logs/runs/routes and every task claim that named it, so the nextingestof that task matches again instead of askingassignfor a workflow that no longer exists; a load still drops a workflow claim whose workflow is gone, so a store written before this fix heals (#1632).workflow/ingestalso takesrematch: true: that one task's claim and route are dropped before matching, the panel's per-task action (#1647).workflow/assignreadstaskId(orid),title,brief(stored on the card, and the card's own brief wins on a later call that omits it),callerBotId,projectIdandbotId; any other key is named in the response'signoredarray instead of being dropped in silence (#1869).- Tool
run_workflowstarts a named run with a brief. Tooldefine_workflowwrites the same graph the canvas edits, and takes the samestepTimeoutMinutes(1..120) so a lane created that way does not silently inherit the 45-minute default (#1868). flybot.tasks.onWritecallsingest. A new Bot match returns{ ownerBotId }. A Workflow match starts the run inside the plugin and leaves the card's owner to the host. A host that offers notasks.onWritewarns once and reportstasksWired: falseinworkflow/state, and the Workflows panel says that a board card cannot start a run there (#1462).
- Friends' Bots are not nodes. A
remoteBot is refused by create, define and the matcher. - The canvas and the run share one graph: nodes, edges and positions. A drag commits
workflow/graph, but only while Edit is on — a factory preset is copied first (#1378). A wire or a reconnect that would list a pair the graph already carries commits nothing new, becausenormalizeGraphrefuses a graph that lists one pair twice and then saves none of it: unguarded, the plane would show no edge, no change and only a save error (#2163). Status highlighting reads that same graph. Replacing edges does not recompute who is already running. The Settings canvas paints that graph on the same 960px design plane, scaled to the width the settings page gives it, so the canvas is not a second scroll container inside the page's own scroll; its pointer deltas are divided by the scale, and its wires keep a stronger stroke than a plain divider so an edge can be followed at that size (#1483). While another dsh holds the Bot team the host is read-only (roster.readOnly): the page still reads throughworkflow/state, keeps its controls disabled, and offers no Edit, Add link or wire port, so it cannot take a change that core would refuse withbot/read-onlyand then drop (#2187). The same window draws the sidebar's workflow list without New, the row's Rename/Delete or Hand a task to a Workflow: the list is read-only too, and anroster.readOnlythat is not a string, or holds only whitespace, counts as writable — trimmed the waycore/src/client/workflows.js:570trims it (#2200). The Stage a row opens carries the same lock: it prints the host's sentence underrole="status"and draws no Hand a task to a Workflow, no Re-match, no Pause/Resume/Stop, no Edit/drag palette and no Reset, while the row click, Progress, Flow and viewing a run still work; a writable roster draws every control exactly as before (#2213). Mounting the list in a read-only window still readsprojectsso the Project filter fills, but it sends noworkflow/ingestfor the open cards: ingest is a claim/match write the host refuses, so a reader opening the list writes nothing; a writable window still ingests every open card (#2222). The read-only sentence can also land after the list has mounted — a cold window starts on the empty roster,ready: falseand noreadOnly— so the ingest step asks the value the latest render saw rather than the one the mount saw, and it skips the board read and stops between two cards once the window has turned read-only. The Settings page draws no read-only sentence of its own: the host's Settings header already prints one for every page it shows (core/src/client/settings.jsbt-read-only,role="status",hostText(roster.readOnly)), and a second copy inside the page said the same thing twice (#2202). - A live run advances only when its ephemeral Bot replies, following the graph's edges in order. That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The
workflow-stepsend middleware denies those replies afterstepResultruns. Nodes start only when their forward predecessors finish and edges reach them. Early termination (#1537, #1538, #1546, #1570, #1571): When a node completes or fails, the run ends immediately with statusdone, setsrun.earlyEndto the condition name, and does not advance to successors, take a success-pathloop, or burnonFailrecovery attempts. The two markers that report the task itself end it only on a structured signal (#1546):needs-humanandalready-claimedmust be a declared OUTPUT whose value equals the marker, or a whole reply line that is exactlyEARLY-END <reason>in any case. A step that quotes an issue title, negates the label or reports it absent keeps running.tip-unchanged(#1570) andopen-pr(#1571) accept that same structured signal — in a declared OUTPUT the value counts as a whole word, so a brief that namesdata-open-projectis notopen-pr(#1866) — and also honor their prose fallbacks (tip unchanged/unchanged tip,open PR already/already has an open PR/has an open PR/found an open PR), so a Bot that forgets the structured marker cannot soft-lock Claim↔Verify on the same tip. SeecheckEarlyEndin Key files. Factory prompt guidance lives inEARLY_END_GUIDANCE(plugins/workflow/src/template.js) for Settings graphs that wire Triage / Claim / Verify: Verify must escalate once on an unchanged tip, not re-run the full suite. There is no scripted or seeded run path:workflow/stateexposes onlylive === trueruns — the running ones, plus the last run when it is stillliveand endedfailed— a failed sibling stays on the stage beside whatever is running now, withrun.failure({ code, at, nodeName?, text? }) naming what ended it, so the room does not have to open Runs to learn that a run failed (#1493, #1494). The failed sibling is left out when a running run carries the same task id: that is a retry, and the live run keeps the row (#1507). History entries carry the runidand the samefailure, so a Runs row shows when the run ended and why, and a row for a run already on the stage is not listed twice. A run does not create a schedule.scheduleLabelis display text. When the host can create Bots, starting a live run (assign,run_workflow,ingest) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. AfailOnceretry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Plugin unload (dispose) and the load-timereapboth end a live run still markedrunning: in-flight nodes are failed, the run is archived asfailed, and any ephemeral Bot ids are released. A run already written asrunningwith no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present:reapreleases those instances and records the run asfailed. Nothing resumes it. A run that already ended keeps its own status (#1388).workflow/resetstopping one live run ends it the same way before its Bots are released: nodes stillrunningbecomefailed, so the strip cannot keep painting a run that is over (#1424). A graph edit that drops a node that is still running ends that run the way Stop does: the node is failed, the run is archivedfailed, its node Bots are released, and the queue drains (#1549); an id whose release failed stays named on that archived run, and the next load'sreapretries it, instead of the edit forgetting it (the #1616 contract). Every wait is bounded (#1530). A dispatch that does not land is not marked handed: the chat names the code and the attempt, and it is retried after a short backoff at most twice, never twice inside one hand-off, so a 429 is not hammered. When it still does not land, the step and the run fail with the reason written into the chat. Each running live step also holds a deadline — its workflow'sstepTimeoutMinutes,DEFAULT_STEP_TIMEOUT_MINUTES(45 minutes) unless the record sets one, an integer from 1 to 120 given at create, throughdefine_workflow, or throughworkflow/step-timeout(armed even when the host cannot spawn ephemeral Bots; #1530, #1580); when it expires the node and the run fail asfailed, the run's ephemeral Bots are released, the chat andrun.failuresay the step's deadline was reached (step deadline reached (45 min), the number its lane set), andwarnnames it once; the line never reads as a reply that did not arrive, because a step still working is killed by the same timer (#1868). The deadline is cleared as soon as the step moves, and by Pause, Reset, delete,reap,disposeand a Stop that released every Bot; a run the owner paused holds none, and Resume arms it again from that moment. A timer never outlives the run that started it. A reply that matches no running step — its run ended, or the node already moved — is reported throughwarnonce per Bot and run instead of being dropped silently. A reply that sayscould not answer your messageormodel call failedfails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. Stop and Reset (workflow/reset) follow the same order asdispose: they release the run's Bots before the record forgets their ids, and an id whose release failed stays named on the still-live run, so the next Stop, Reset, dispose or load-timereapretries it (#1476). A run left that way is markedpausedandreleasePendingand shows as Paused: it hands off no further node,resumeis refused with an error, and Stop again is the retry. It also keeps the deadline of the node it was on — the one paused state that holds one — and a graph save re-syncs timers without taking it away (a save that cleared it left the run holding its slot with nothing left to free it); the deadline releases again there, ends the run aspaused-expiredand frees the slot, re-arming itself while a Bot still will not go (#2453, P-560). A whole-workflow Reset that could not release every Bot freezes every run it did not reset, because each of them has lost the ids whose release did go. The markers go away when the run ends: Stop again,dispose,reap, or its own completion. - Each workflow keeps a FIFO queue and up to
maxConcurrentlive runs (default 2, an integer from 1 to 8 on the record, viaworkflow/concurrency, or in Settings → Workflow graphs). A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were stillrunningwith ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning{ botId }claims the card for that local Bot and does not enqueue. A remote Bot is refused.activeCountisrunningOf(lane)— live runs withlive === trueandstatus === 'running'. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card with its ownstatus(never a hardcoded Running), the chip count does not exceed that list, and a failed sibling appears with its time and reason. On the Runs list, the clock stays in a non-shrinking column with a gap before the status chip, and a Failed row paints the Failed chip once — a generic Failed with no host detail does not also drawwf-hist-reason(#1618). - A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or
task,title, orbriefon a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carriesInput <name>: <value>. The Bot reportsOUTPUT <name>: <value>; a single declared output may be the reply body. A declared output a gate reads is required (§3): when awhen/elseoruntiledge reads it and the step's reply does not produce it, the step fails withfailure.textMissing OUTPUT <names>instead of settling, so the gate never reads silence as "not this branch" and the run cannot closedoneon it; a delivered value that does not equaleqstill completes the step and skips the untaken branch (#1773). Nodes start in edge order. - Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (
botId) is optional and is not required for the step to run. File and shell rights are the same as a normal sidebar Bot's: every node Bot gets thedanger-full-accesspreset, andEPHEMERAL_DENIED_TOOLSstill refuses the team-admin tools at the guard and approval layers (#1466, 29 names).node.toolsselects connectors only:spawnpasses the node's cleaned list tofb.bot.create, the host records it asstepTools, andconnector-access.jsallowsmcp__github__*forgithubandmcp__composio__*forcomposio— a connector the node did not name is refused, and a node that names none reaches none. The list is also stored infields.toolsfor the UI. A hidden run Bot cannot show an approval card, so the approval seam answers the same way the guard does. The host stores this on the run Bot asstepToolsandpermission. - A loop edge (
loop: true,maxfrom 1 to 8, default 3, optionaluntil: { output, eq }) is the only cycle for success paths. The body runs again untiluntilmatches ormaxruns have finished, and the forward edges wait until then. Restarting the body drops the branch choice made inside it and queues a sibling that the previous pass skipped, so the next pass can take the other side. Awhen: { output, eq }edge is taken only when that output equalseq.when: { else: true }is taken only when no otherwhenmatched. Awhenoruntilthat is present but cannot be honoured — a blank output name, or one outside/^[a-z][a-z0-9-]{0,40}$/such asresult_ok— refuses the save withA condition link needs an output name like "ready"(or the loop's own line) instead of being dropped: before #2155 the refusedwhenwas indistinguishable from an absent one and the edge fired unconditionally, which is the opposite of what the author asked. An absentwhen/untilis still a plain link, no early stop. Settings → Workflow graphs keeps Add link disabled for a Condition with a blank output name, and the refusal shows in the page's.wf-erralert. A node whose forward edges are all closed isskippedand does not block a join. A run cannot spin: every loop has a max. AnonFailedge (onFail: true,maxrecoveries from 1 to 8, default 3) provides failure recovery: when a node fails, if anonFailedge points from that node back to an upstream target and recoveries remain (tracked infailAttemptsper edge), the workflow resets every node from the recovery target through the failing node (the target torunning, the rest toqueued) and re-runs them in order. Aftermaxrecoveries are used, the node fails permanently and the run stops. AnonFailedge does not interfere withfailOnceretry (which restarts the same node once) or success-pathloopedges. Settings → Workflow graphs can add a link, a condition, or a loop; a failure recovery edge is added throughdefine_workflow(onFail: true) and has no Settings control yet. - Auto-assign considers an
opencard only. Every saved board write reaches the hook, including a card a Bot records for itself throughtrack_tasks, so aworkflow:<id>on the card a Bot wrote starts that run (#1462). A saved Bot claim beats a workflow trigger. Otherwise first hit wins: aworkflow:token, thenworkflowId/workflow, then an assignee that names a workflow, then abot:token, then an assignee that names a local Bot, then the longest trigger in the title, note, criteria or tags — an ASCII trigger must match a whole word (noticedoes not matchnoticeable,prdoes not matchImprove), while a non-ASCII trigger matches inside a longer text because CJK has no word boundary. 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. A stored claim yields only to the card's own named target — aworkflow:/bot:token,workflowId/workflow, or an assignee naming a workflow or local Bot — and only when that target is live and different: the claim and its route go, the card is matched again, and exactly one of each remains. A trigger, or a card that names nothing, never invalidates a claim; a claim naming a workflow or Bot that no longer exists keeps today's behaviour, because this invalidates on named intent and does not guess.heldTasks/heldTaskCountonworkflow/stateare that claim map read back per workflow, andrematch: trueis the person's explicit drop for one task (#1647). A claim that yields leaves one line on the source workflow's log —Task re-matched · <task> → <destination>— in the same store write that attaches the new target, so the graph that lost the card says where it went (#1668). - The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup.
enabled: falseregisters no endpoint, tool or listener and writes nothing.- A sidebar click opens the stage or a dialog and leaves the Workflows section in place. The stage and the dialogs portal onto
document.body. When that container is missing they stay in the section tree; the click does not throw. - A node's prompt and the graph are one record. Settings → Workflow graphs (canvas-first with per-node inspector), the flowchart drawer (collapsed by default, inspector on select), and
define_workflowall writeworkflow/graph. Read-only, the drawer's Flow tab lays the graph out from its steps instead (flowLayout, #1652): one row per layer, a parallel layer inside its dashed frame, node boxes sized to the measured panel width, and wires routed orthogonally byflowWiresfrom the drawn, measured boxes — one bus per fork or join, skip and loop edges in the right gutter — so stored editor coordinates never misplace a node or leave a wire hanging. That read-only plane's wire layer is a namedgroup(Connections, zh 连线) rather thanaria-hidden: every route is arole="img"with a<title>, a 10px transparent hit path of its own (pointer-events: stroke), and a name built from both ends, so a fork or join bus and its drops read as the group they carry (Coder → Reviewer + Tester,Reviewer + Tester → Deployer); pointing at one wire marks itdata-activeand dims the restdata-dim, and the pointer writes nothing, so the plane stays read-only (#2431). In Edit, the drawer paints that record fitted to its fixed 400px panel:fitPlanescales node centres (never the node boxes) into the panel width, wires and parallel frames use the fitted positions, and a pointer delta is divided by the fit scale before it is stored, so an edit still commits the same world coordinates. Because only the centres scale, a parallel layer's 168px cards overlap once it holds three or more branches in a 364px drawer, and the later card takes clicks meant for the branch's out-port;editPlanetherefore keeps the fitted plane only while a layer's cards stay apart, and otherwise lays the graph out inflowLayout's rows, column widths and wrap with the drawn coordinates as the panel's own, so there a drag is a plain pointer delta (#1677). A drag or a palette drop in that laid-out drawer plane changes structure, not layout: it commits no coordinates, and a storedx/yis only ever written from the Settings canvas, whose record is where layout lives (#1686). The plane says so in one line,Layout lives in Settings(zh: 布局在 Settings 画布改), when a drag there is refused. The Settings canvas paints the same record scaled to its own width (invariant 2), not as a second scroll container. A run reads that record; it does not define it. After a live run finishes, the stage shows the last finished live run (lane.lastwithlive === true) until a new live run starts. The drawer's Flow tab and Settings' graph paint one node state: the last run'snodeStatus, and Idle when the workflow has no run at all (#1753). - The stage chat follows a new line only while the reader is at the bottom. A line the stage filters out (a workflow node's own reply) changes no pixel, so it moves the chat nowhere; a reader who scrolled up keeps their place until they return to the bottom.
A core board task (state.tasks, core/src/host/tasks.js) is the only task identity. A run that works on a task holds that same id in run.task.id and never mints a second record: the id on the card, the id in run.task and the id in the claim map are one string. The Project board proves it in one read: project-board on api/bot (core/src/host/project-board.js) takes a Project id and answers { tasks: [{ id, source, column, assignee, updatedAt, runId?, nodeId?, title, priority, board }] }, one row per task. A card a run consumed and a card it created are each one row; source is bot or group from the card's own board, and workflow when only the run's Project binding carries the card. title, priority and board (the { kind, id } the card opens, null on a workflow row) travel on the row because the tasks read keeps open cards only: a finished card in the done half has no record to join, and the row is what draws it and lets it be opened.
Core never reads $DSH_HOME/bot/flybot-workflow. The plugin registers flybot.tasks.onProjectProvider(fn); the host calls it with hasTask(taskId) and the plugin answers { taskId, runId, nodeId? } from service.projectTasks() — one entry per live run that holds a card, nodeId being the running node (else the last node handed the card). A run whose card is gone is dropped by the plugin before it can become a row. The aggregate writes nothing: no card, claim or run changes when the board is read, so an install with no Projects, and every caller that never asks, behaves exactly as before.
plugins/workflow/src/pool-contract.js is the typed handoff contract of #1550 Stage 3 P0: one pure
validateHandoff(handoff, ctx) running the seven checks in order — target, owner, evidence,
concurrency, idempotency, cycle, quota — plus escalationTarget(action), which names the Chief for
the two escalating refusals (cycle-budget, quota) and nobody else. It does no I/O and imports no
service.js. Since P1 (#1862) one path calls it, and only through the shadow step below.
plugins/workflow/test/pool-contract.test.js pins the contract itself, and
core/test/task-handoff-route.test.js feeds the host's own board facts to its target and owner
checks so the two policies cannot drift apart silently.
plugins/workflow/src/pool-shadow.js is the P1 shadow step: shadowVerdict(handoff, ctx) runs the
P0 contract and returns one record — { verdict: 'pass', key } or { verdict: 'reject', code?, reason? } — and shadowLine(shadow) turns it into the run's own stage line, shadow: <verdict> · <code> · <reason>. The line never begins Verdict:, so no reader and no gate can take it for a
conclusion about the pull request.
- Two sources of facts, one answer.
runHandoff(item, task, run, facts)builds the handoff the run already holds — its card, the node it hands the card to, that node's producer, the pool the workflow declares (each node role, capped bymaxConcurrent), the exec that owns it, and no previously spent keys — andassign()accepts the card's ownacceptance/evidence, so a real pull request carries a verdict without any caller learning a new contract. A caller that has better facts passeshandoff(card, fromRole, toRole, attempt, acceptance, evidence) andhandoffContext(roles, pools, execOwner, worker, pool, cycleMax, quota, used), which replace the derived pair wholesale; either way one pair, never mixed. - Record only.
beginRun()stores the answer on the run asshadow: { verdict, code?, reason?, key? }— exposed bypublicRun()— and posts the one line. A parked task carries all four fields (carriedTask()), so a queued card is shadowed when it actually starts. Nothing reads the record: run status,nodeStatusand the step count are the same with or without it, the card is never re-queued, and the pull request fast lane still decides. - A dry run. The contract records an accepted key in
ctx.seen; the shadow pass hands it a copy, so a shadowpasscannot make the next real handoff look like a duplicate. A handoff that cannot be stored (not an object, circular, over 64 KB) is dropped, and a shadow that throws records nothing — the shadow pass cannot stop a run. A card with no handoff facts, and no acceptance or evidence, gets no record and no line, which is every run that existed before P1. - The facts travel over the plugin's own
assign/workflow/stateseam only: no host read was needed, socore/src/host/is unchanged. - Tests:
plugins/workflow/test/pool-shadow.test.js— a rejected and an accepted handoff, a card shadowed from its own acceptance gate, a parked card, a dropped (circular) handoff, a run that keeps walking its graph with arejecton the record, and the purity assertion that the caller'sctx.seenis untouched.
plugins/workflow/test/: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), a per-workflow stepTimeoutMinutes that replaces the ten-minute constant and is refused outside 1..120 (#1580), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, define / runNamed, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as delivery), the stage chat's scroll rule (a new visible line follows, a filtered node reply moves nothing), the assign dialog's preselected card and its failed and empty board states (#1457, #1458), a sidebar click that leaves the Workflows section in place, and the claim rules — a re-routed card moves with one claim and one route, the same target writes nothing, a trigger never invalidates, the held list is capped and omits a Bot claim, the held page answering heldLimit up to its 200 cap and the stage unfolding it behind its wf-more row (#2256), rematch: true drops one claim, and the panel's one re-match action goes through ingest (#1647), and the assigned brief reaching the step prompt while a task without one still gets its title, a parked card keeping its brief on disk, and an unknown assign field coming back in ignored (#1869). Run npm test in plugins/workflow. The host seam that applies ownerBotId is core/test/task-route.test.js, which also pins that a card written through track_tasks reaches the listener (#1462). The host omission of run Bots, and the grant of stepTools plus the hidden-approval answer, is core/test/flybot.test.js. Which connectors a run Bot may call is core/test/connector-access.test.js, and the chain from a node's tools through spawn to that guard — the built-in delivery nodes included — is core/test/workflow-node-connectors.test.js.
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:startFrontierkeeps only the first ready node running and leaves its ready siblingsqueued, 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 }, orundefined) and the per-role/per-run delivery and resource counts withfb.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 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 Projectsplus each Project plus the unassigned rows), and carries a Project picker in the graph editor; the picker saves through the sameworkflow/graphcall that carries the nodes and edges, and an empty choice sendsnull. The picker is fed by the hostprojectsendpoint, so it lists what the core store holds and never a second copy. The Hand a task to a Workflow dialog repeats the same badge on every lane target, so two lanes that share a name but belong to different Projects no longer read alike; a lane with no Project draws no badge there, the same way the dialog's own target row stays one line (#2241). The dialog's confirm button repeats the target it is about to use —Assign to <lane> · <Project>, orAssign to <Bot>for a Bot — so two lanes that share a name are still told apart at the moment of the click, while the confirm body stays{ workflowId, taskId, title }and a dialog with no target keeps the plainAssigndisabled (#2254).
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.