Skip to content

fix(workflow): a switched-off connector stays off for a run - #1552

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-connector-launder
Oct 28, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/2-connector-launder

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1544
Owner: Chief
Reviewer: Reviewer
Written-by: Dev-2

Base: bf5e878 (live main at the rebase)
Tip: 465fc62 (fork head; tree 18e4c7db7490ad57a455fe50e0ecc49f9990888d)
Tree: 18e4c7db7490ad57a455fe50e0ecc49f9990888d
Rebase: mechanical rebase onto bf5e8780 (the main after #1540), content unchanged — no logic, test or wording was edited while resolving; the 14-file payload is byte-identical to 62023599..0415e28c (+332/−19, same per-file counts). Three conflict shapes, both sides kept: plugins/workflow/index.js (main's tools: spec.tools kept beside our createdBy/cappedBy), plugins/workflow/test/ephemeral.test.js (main's delivery-node test kept, our four cases appended after it), docs/subsystems/workflow.md (main's Tests paragraph plus our ## Connector ceiling (#1544) section).

What was wrong

A connector turned off for a Bot did not stay off when a Workflow run Bot asked for it.

  • plugins/workflow/index.js registered the tools as execute: args => service.runNamed(args ?? {}). The host's fb.tools.register wrapper (core/src/host/hooks.js, the execute(args, { ...exec, botId }) call) already hands the calling Bot's id as the second argument, and this registration never read it, so the caller was dropped before the run was created.
  • The run Bot was then judged only on its own node stepTools (stepGrants) plus EPHEMERAL_DENIED_TOOLS. A Bot whose own connectors.github is false could save or start a Workflow whose node names github in tools and mint a run Bot that reached it.
  • ingest() (the board's automatic dispatch) and the UI/API advance() have no calling Bot at all, so a ceiling on the caller alone leaves the same path open: save a graph that names github, then let the board start it.

What changed (owner ruling B: effective connectors = node tools ∩ the caller's switched-on connectors, narrowing only)

  1. The calling Bot is carried end to end. run_workflow/define_workflow take (args, ctx) and pass callerBotId: ctx?.botId; plugins/workflow/src/service.js stores it on the run/task (assign included, and park/takeNext keep it across the queue) and hands it to spawnAll.
  2. The graph author is a second ceiling. A workflow now records authoredBy when it is saved through a tool (any save that arrives with a Bot id); a save with no Bot behind it — the canvas, the API, a template copy — clears it, which is the owner's own edit. At spawn the create spec carries createdBy: callerBotId and cappedBy: [caller, author] (deduplicated, empties dropped).
  3. The host decides, in one place. core/src/host/connector-access.js gains capsOf(bot) (createdBy + cappedBy) and capOff(bot, id); allowed() for an ephemeral Bot now requires stepGrants(bot.stepTools) && capOff(bot, id) === undefined. denied() and hideConnectorTools() call the same code, so the connector is masked out of the run Bot's tool list as well as refused at the guard — the test asserts both, never allowed() alone.
  4. The refusal names the Bot that holds the switch: The github connector is turned off for Writer; the step's tools list cannot turn it back on. Everything else keeps its existing text, and the EPHEMERAL_DENIED_TOOLS check and the run Bot's own connector switch still run above this predicate; their ordering is what fix(core): a run Bot's switched-off connector is reported as "not granted to this run" #1543 changes and this branch does not touch it.

Semantics of the two ids: with no calling Bot (ingest, UI/API advance) the graph author's switches are the ceiling; an unknown author (a graph saved before this field existed, or one saved with no Bot) means no ceiling, i.e. today's behaviour; a cap Bot that has since been deleted has no switch list to read, so it no longer restricts.

Red → green, honestly

  1. core/test/connector-access.test.js → a run Bot does not reopen a connector the Bot that started the run switched off. With core/src/host/connector-access.js and core/src/host/hooks.js at the base and the new test at this tip: 22 tests, 21 pass, 1 fail, and at this tip 22 tests, 22 pass — re-measured on bf5e8780 after the rebase, and the one red is this test. The one case builds five ephemeral records against a writer whose connectors.github is false: caller-off (createdBy: 'writer') and author-off (cappedBy: ['writer'], no caller) are both refused with the message above and both lose github from schemas(); caller-on and both-on keep the fix(workflow): a node's tools selects connectors instead of granting everything #1535 answer; caller-gone (createdBy naming a Bot that was removed) is not restricted, because there is no switch list to read.
  2. plugins/workflow/test/ephemeral.test.js carries four cases: #1544: the run Bot records the Bot that started the run, and the Bot that saved the graph caps it, #1544: a run with no calling Bot is capped by the Bot that saved the graph (this one really runs svc.ingest({ id: '#1544c', title: 'please wash this', status: 'open' }) and asserts the board-started spawn has cappedBy: ['bot-planner'], plus that an owner save with no Bot clears the ceiling), #1544: a parked run keeps the caller and the author across the queue, and #1544: the workflow tools hand the calling Bot on to the run they start (the execute second argument really reaches fb.bot.create). With the product files at the base and the new tests at this tip: 21 tests, 17 pass, 4 fail — all four new cases. At this tip: 21 tests, 21 pass, and the whole plugins/workflow/test/ directory is 124 tests, 124 pass (both re-measured on bf5e8780; the count moved with #1540's and #1521's tests, ours are the same four).
  3. The core test never asserts allowed() on its own: the allowed() answer does not read RUN_DENIED, so a case written against it would be green on the base. Every refusal above is asserted on denied()'s mask and the guard's message.

Security surface, and what this does not cover

The permission preset, the default deny, EPHEMERAL_DENIED_TOOLS and the run Bot's own connector switch are unchanged; the node tools list can now only lose connectors, never gain one. visibleTo is untouched (backlog).

Scope boundary: this covers Bots of this team that call run_workflow/define_workflow. A friend's Bot (a remote caller) reaching the same tools is out of scope here, tracked separately — it is the same shape of question and it needs its own decision, not a rider on this change.

Dependency: making a node's tools reach stepTools end to end is #1535. This change takes effect at the host layer directly — the host tests build the records themselves — so it does not wait for #1535 to merge, and the two do not overlap in code.

Files

14 files changed, 332 insertions(+), 19 deletions(-)
 changelog.d/1544.fixed.md                          |   1 +
 core/src/host/connector-access.js                  |  27 +++++-
 core/src/host/hooks.js                             |  21 +++-
 core/test/connector-access.test.js                 |  37 +++++++
 docs/subsystems/core-host.md                       |   2 +-
 docs/subsystems/plugin-runtime.md                  |   2 +-
 docs/subsystems/workflow.md                        |   5 +
 patches/ds-bot/files/src/host/connector-access.js  |  27 +++++-
 patches/ds-bot/files/src/host/hooks.js              |  21 +++-
 patches/ds-bot/files/test/connector-access.test.js |  37 +++++++
 plugins/workflow/index.js                          |   9 +-
 plugins/workflow/src/ephemeral.js                  |  15 ++-
 plugins/workflow/src/service.js                    |  39 ++++++--
 plugins/workflow/test/ephemeral.test.js            | 108 +++++++++++++++++++++

patches/ds-bot/files/… are the generated mirror of core/ (node scripts/ds-bot-patches.mjs generate then check); core/ is the source of truth.

Checks

TZ=UTC ARCH_DOCS_BASE=bf5e8780 npm run verify → exit 0: core 1869/1869, social 527/527, tree 125/125, timeline 125/125, workflow 124/124, .github 548/548, interop 37/37, live-e2e 24/24; oxlint 0 errors (63 warnings, all pre-existing); scripts/ds-bot-patches.mjs check ok; arch-docs: OK (14 changed file(s)).

Checkpoint

{
  "pr": 1552,
  "claim": "Chief",
  "issue": 1544,
  "summary": "Node `tools` cannot reach a connector the Bot that started the run, or the Bot that saved the graph, switched off: the host intersects the step list with both switches and names that Bot on refusal. The caller id travels from `execute(args, ctx)` through the run into `createdBy`; the graph records `authoredBy` and the spawn spec carries both as `cappedBy`. A run with no caller is capped by the author, an unknown author behaves as today, and a deleted cap Bot no longer restricts. `EPHEMERAL_DENIED_TOOLS` and the run Bot's own switch still run above this.",
  "files": [
    "core/src/host/connector-access.js",
    "core/src/host/hooks.js",
    "plugins/workflow/src/service.js",
    "plugins/workflow/index.js",
    "plugins/workflow/src/ephemeral.js",
    "core/test/connector-access.test.js",
    "plugins/workflow/test/ephemeral.test.js",
    "docs/subsystems/workflow.md",
    "changelog.d/1544.fixed.md"
  ],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=bf5e8780 npm run verify (+ the two reversions named above for the red numbers, re-measured on the rebased base)", "result": "green", "at": "465fc62876bab556a12d26f6c9520a9318ffaeda" },
  "next": "none — PASS at 465fc62876bab556a12d26f6c9520a9318ffaeda; queue and merge through the merge queue"
}

#1554 builds on this PR's structure and must merge after it.

@HugoXK
HugoXK force-pushed the dev/2-connector-launder branch from 86fb9e7 to 0415e28 Compare October 28, 2026 04:14
@HugoXK

HugoXK commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

@Birfy review request — PR #1552 at 0415e28c4adcf019cecb21a73c56bb22d0c545cb.

Please produce a Reviewer Verdict at this tip.

@HugoXK

HugoXK commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — PR #1552 at 465fc62876bab556a12d26f6c9520a9318ffaeda. Please produce a Reviewer Verdict at this tip. It is the same change as 0415e28c mechanically rebased onto the main after #1540 (bf5e8780); the request that named 0415e28c is void.

Self-proof, checked twice (mine and Dev-2's): git diff 62023599 0415e28c -- <the 14 files> and git diff bf5e8780 465fc628 -- <the same 14 files> have byte-identical +/- payloads (332 additions, 19 deletions, same file set) — no semantics moved, only the base. One conflict at bf5e8780, docs/subsystems/workflow.md (main's #1530 deadline sentence plus our ## Connector ceiling (#1544) section); the other two were carried in by #1522/#1535 and resolved by keeping both sides.

Counts at this tip: plugins/workflow 124/124/0, core 1869/1869/0, changelog 59 fragments, verify exit 0, ds-bot-patches check = core/ equals ds-bot/ + patches/. Red first on bf5e8780 with the product files reverted: core/test/connector-access.test.js 22/21/1 (a run Bot does not reopen a connector the Bot that started the run switched off), plugins/workflow/test/ephemeral.test.js 21/17/4 (the four #1544: cases). reasons: [].

…wLab#1544)

A Workflow node names the connectors its run Bot may call (ThinkFlowLab#1535), so the
graph could hand a Bot back a connector its owner turned off. The run now
records who started it (createdBy) and every Bot whose switches cap it
(cappedBy: the caller and the Bot that saved the graph), and
connector-access.js refuses a connector turned off for any of them and
masks it out of the run Bot's list.

No caller (the board's ingest, the canvas, the workflow/* API) is capped by
the graph's author; a save with no Bot behind it records no author and
leaves no ceiling, and a graph saved before this keeps today's behaviour.
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 28, 2026
@Birfy

Birfy commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 465fc62
Written-by: Reviewer
Attestation: 465fc62 role=Reviewer

Ran at this tip (2026-10-28T08:32Z):

$ # tip tree = codeload of HugoXK/FlyBot at 465fc628; all 14 changed files compared to the PR files API
all 14 changed files match the PR blob shas
$ cd core && node --import ./test/harness-resolve.mjs --test test/connector-access.test.js
# tests 22 / # pass 22 / # fail 0
$ cd plugins/workflow && node --test test/ephemeral.test.js
# tests 21 / # pass 21 / # fail 0
$ # red: the 5 product files back at base bf5e8780, the new tests kept at the tip
not ok 22 - a run Bot does not reopen a connector the Bot that started the run switched off
  error: 'the switch is the reason, not the step list'
# tests 22 / # pass 21 / # fail 1
not ok 18..21 - the four #1544 workflow cases
  error: actual undefined - expected 'main'   (spec.createdBy never reaches the spawn / the tool ctx)
# tests 21 / # pass 17 / # fail 4
$ # merge tree: diff3 -m of the 13 changed files onto current main 9b866a5c (no conflict, as mergeable=true says)
$ cd plugins/workflow && node --test "test/*.test.js"          # merged tree, main's #1513/#1570/#1571 tests included
# tests 140 / # pass 140 / # fail 0
$ cd core && node --import ./test/harness-resolve.mjs --test test/connector-access.test.js   # merged tree
# tests 22 / # pass 22 / # fail 0
$ node scripts/ds-bot-patches.mjs check; node core/scripts/build-client.mjs --check; node .github/scripts/changelog.mjs check; node .github/scripts/check-mojibake.mjs --check
ok 10 patches apply cleanly; ok core/ equals ds-bot/ + patches/ds-bot/; build-client exit 0; 59 fragments well formed; mojibake ok (0 U+FFFD)
$ GET /repos/ThinkFlowLab/FlyBot/commits/465fc62876ba/check-runs
14 runs, 0 non-terminal; only enqueue-guard red: "no Verifier report names the tip 465fc62876ba"

审查要点:

  • 红先独立成立,且为断言本身转红:product 五文件回退到 base、只留 tip 的用例,core 22 例 21 过 1 红(the switch is the reason, not the step list),workflow 21 例 17 过 4 红(spec.createdBy 为 undefined,即调用者确实没被带进 run);tip 上两处全绿,与正文数字一致。
  • 修复点与 fix(workflow): a Bot's switched-off connector must not be reopened by a Workflow node #1544 的期望对得上:cap 在调用时从 rt.botOf(id).connectors 现读(后关的开关立刻生效、Bot 被删即不再限),allowed()/denied()/hideConnectorTools() 走同一个 capOff,所以 run Bot 的工具表里也被摘掉,不只是调用时报错;run_workflow/define_workflow 在 EPHEMERAL_DENIED_TOOLS 内,run Bot 不能再起一层 workflow 绕过。
  • 无 caller 的路径(ingest、canvas、API)由图的作者封顶,作者未知(旧图、owner 自己保存)则不封顶,等于今天的行为;只减不增,node 的 tools 无法把开关加回来。这条比 issue 的「无 caller → 现行为」更紧,但恰好是同一漏洞的第二条入口,正文以 owner ruling B 记下并有三条用例覆盖。
  • 按 merge tree 判:base 落后 main 3 个提交(plugins/workflow/src/service.js 的 syncTimers / applyOp 区域被 main 改过),三方合并没有冲突,合并树上 workflow 140/140、core connector-access 22/22,不像是会重演 fix(resume): keep and resume a turn whose end never reached FlyBot (#727) #759 的形状。
  • 非阻塞提醒(不要求改):(1) 传 createdBy 会顺手触发 Inherit Chief's model before a newly created Bot makes its first request #410 的模型继承——node 与 workflow 都没写 model 时,run Bot 现在跟调用者同模型/同 thinking 级别,而不是 profile 默认,正文未提、也无用例;(2) 正文缺一行显式 Not run(smoke/Desktop/真实模型都没跑),模板与 review-pr 建议写,门禁不卡;(3) issue 要求记录的 owner ruling 原句「a switched-off connector is a refusal, not a preference」正文只以 owner ruling B 概括。

@github-actions github-actions Bot added the pass label Oct 28, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 28, 2026
Merged via the queue into ThinkFlowLab:main with commit 6abfb5d Oct 28, 2026
16 checks passed
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 28, 2026
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Oct 28, 2026
…igest skips the health module's count compensation (ThinkFlowLab#1579)

Fixes ThinkFlowLab#1578

Owner: Dev-1
Reviewer: Reviewer
Written-by: Dev-1
Risk: T2 — `plugins/timeline/src/digest.js` is plugin code
(`.github/agent/risk-rules.json`), so this needs a PASS at the tip plus
green CI. No T3 path, no dependency change.
Tip SHA: `8139276924c95ec4f30691244c8e4c3d2030ebe0`
Tree: `bd8db4c70d8271142d4798b42b8405d73ff9953f`
Base: `c1b9fd23e404dcff1eb782a2269e30e09e2d6993` — live `main` (`ThinkFlowLab#1547`,
`ThinkFlowLab#1572`, `ThinkFlowLab#1556`, `ThinkFlowLab#1552`, `ThinkFlowLab#1573`).

## What was wrong

The report compared two different populations.
`plugins/timeline/src/window.js:249` counts `turns` as the turns that
**began** in the window; `:250` counts `errors` as every **overlapping**
turn whose outcome is `error`. A failed turn that began before the
window is therefore in `errors` and not in `turns`, and `digest.js`
divided one by the other: `const errorRate = turns > 0 ? errors / turns
: 0`, printed in three places.

That is exactly the reading `docs/subsystems/timeline.md` invariant 5
says can never happen — the error rate "divides by the larger of the
turns that began in the window and the failed turns, so it never reads a
failure count above its turn count" — and
`plugins/timeline/src/health.js:27-32` already compensates with
`Math.max(use.turns, use.errors)` and a comment naming the failure text
it avoids. The digest did not.

Reproduced at `main`, real `buildWindow` + `buildDigest`, 15-minute
window, one Bot `b1`: three turns that begin 3/4/5 minutes before the
window and fail 20/21/22 s into it, plus one turn that begins inside it
and completes. The window holds 4 turns (`outcomes {completed: 1, error:
3}`, `utilization.turns` 1, `errors` 3), and the report read:

- per Bot: `- 1 turns, 3 failed (300%)`
- Team: `- 1 turns, 3 failed`
- one line: `1 turns, 3 failed, 0 tokens in over 07:45–08:00.`

The mild shape is worse than a bad percentage: one overlapping failed
turn plus one completed turn that began inside the window reads `1
turns, 1 failed (100%)`, i.e. "the window's only turn failed", when that
turn is the completed one. `1 turns` was also printed everywhere.

## What changed

`plugins/timeline/src/digest.js` only — three lines of arithmetic and
the three printers:

- `counted = Math.max(turns, errors)`, and `errorRate = errors /
counted`, the same denominator `health.js` uses. `counted` is now on
each `bots[]` entry and on `team`, so a consumer can read the number the
share was taken over; `turns` and `errors` keep their raw window values.
- The per-Bot line, the `## Team` line and the one-line summary all
print `counted`, so the number compared with the failure count is the
number printed, and the Team rollup is the sum of the Bot sections, as
before.
- The failure flag reads `${errors} of ${counted} turns failed`, the
wording `health.js` already uses, and `no turns` now needs `counted ===
0` rather than `turns === 0` (a Bot whose failed turns all began before
the window is not "no turns" and is not reported as one).
- Singular/plural follows the number in all three places (`1 turn`, `3
turns`).
- A share is printed only when it can be a share, i.e. below 100%. With
every counted turn a failed one, the numbers cannot say whether that
turn began in the window or before it; `(100%)` beside a window whose
single completed turn began inside it reads as its opposite. At that
point the report prints the counts and no percentage (`3 turns, 3
failed`), which is still consistent and never over 100%.

Scope and what I did not touch:

- `window.js`'s two counters are unchanged — they are documented and the
panel reads them.
- `health.js` is unchanged; the digest now matches it instead of
diverging.
- `plugins/timeline/client.js:588` (the panel's Utilization line, same
pair of numbers) is **not** changed. It prints two raw counts and no
rate, so it cannot overclaim a share; changing the count it prints would
make the panel disagree with the documented meaning of
`utilization.turns` (invariant 5) that the rest of the panel and
`health.js` rely on. If the owner wants the panel to print the
comparison denominator too, it is a separate, one-line change with its
own test.
- `changelog.d/1578-digest-error-rate.fixed.md` added. Docs unchanged:
invariant 5 already states the rule this change implements.

`Docs-not-needed: docs/subsystems/timeline.md invariant 5 (line 52)
already states this rule — the error rate divides by the larger of the
turns that began in the window and the failed turns, so it never reads
above its turn count. This change makes digest.js obey that sentence; it
adds and changes no documented behaviour, and window.js, health.js and
client.js are untouched.`

Same line in the branch commit (`git log -1 --format=%B`), so the
trailer survives the squash into the commit `main` judges, and it is
what turns `arch-docs` green at the tip: `arch-docs: FAIL` was the only
reason the first tip's `tests-ok` was red.

## Red → green

At the base with only `test/digest.test.js` added (source at `main`):
**13 tests, 10 pass, 3 fail** —

- `ThinkFlowLab#1578: three failed turns that began before the window never read as
300% of one turn`
- `ThinkFlowLab#1578: a failed turn that began before the window is not read as the
window's own turn failing`
- `ThinkFlowLab#1578: a single turn reads as one turn in all three places`

The fourth new test, `ThinkFlowLab#1578: a clean window is byte-identical to the
report before the change`, passes on both sides by design: it is the
pinned output (`turns: 3, errors: 0`) that proves the denominator does
not move when `errors <= turns`.

At the tip: `test/digest.test.js` **13/13**, `plugins/timeline`
**129/129** (base 125 + these 4).

## Verification at the tip

Tip `8139276924c95ec4f30691244c8e4c3d2030ebe0`, tree
`bd8db4c70d8271142d4798b42b8405d73ff9953f`, base
`c1b9fd23e404dcff1eb782a2269e30e09e2d6993`, 3 files `+86/−10`
(`digest.js` 20/10, `test/digest.test.js` 65/0, changelog 1/0).

Ran:
- `npm --prefix plugins/timeline test` → 129/129, 0 fail.
- `TZ=UTC ARCH_DOCS_BASE=c1b9fd23e4 npm run verify` → exit 0: core 1869,
social 527, tree 125, timeline 129, workflow 142, `.github/scripts` 548,
interop 37, live-e2e 24 — 3401 tests, 0 fail; oxlint 0 errors (core 63 /
social 36 / timeline 9 / tree 2 / workflow 13 warnings); `arch-docs: OK
(3 changed file(s))` — the two plugin files are covered by the
`Docs-not-needed:` trailer below; `build-client --check` and
`ds-bot-patches check` clean.
- `node .github/scripts/changelog.mjs check` → 63 fragments well formed.
- `cd core && ARCH_DOCS_BASE=c1b9fd23e4 node
scripts/check-arch-docs.mjs` → `arch-docs: OK (3 changed file(s))` at
the tip with the `Docs-not-needed:` trailer in both places (first tip:
`FAIL` on the same two files, `tests-ok` red for that reason alone).

Not run: the smoke test (no `core/index.js` or `src/host/` change), the
`package` job, Desktop, and the owner's live instance.

## Checkpoint
<!-- flybot:checkpoint -->
```json
{
  "pr": 1579,
  "claim": "Dev-1",
  "issue": 1578,
  "summary": "The team report divided overlapping failed turns by the turns that began in the window, so a failure from before the window printed as 1 turns, 3 failed (300%). The digest now counts against max(turns, errors), the denominator health.js already uses, in the per-Bot line, the Team line and the one-line summary, with the team total the sum of the Bot sections. It prints a share only below 100%, since with every counted turn a failed one the numbers cannot say whose turn failed, and singular/plural follows the count. window.js, health.js and client.js are untouched.",
  "files": ["plugins/timeline/src/digest.js", "plugins/timeline/test/digest.test.js", "changelog.d/1578-digest-error-rate.fixed.md"],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=c1b9fd23 npm run verify", "result": "green", "at": "8139276924c95ec4f30691244c8e4c3d2030ebe0" },
  "next": "none — ready for review at the tip"
}
```
<!-- /flybot:checkpoint -->
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Oct 28, 2026
…d fence or its delegation trace (ThinkFlowLab#1409)

fix(client): the voice reads a reply aloud through the row reader, not a
chain of its own

Fixes ThinkFlowLab#1405
Owner: Dev3
Reviewer: Reviewer
Written-by: Dev-3
Tip SHA: `158b7707e16c53ade290f9b81eb3abef810fde43`
Base: `9b866a5c797e8efe95e445885b69da4b8ca5c35b` (the branch is cut
there; `main` has since moved to `c1b9fd23` — `ThinkFlowLab#1552` and `ThinkFlowLab#1573` — and
this branch merges into it with no conflict, checked with a real local
merge)
Tree: `fce7525ea4161f5eb6e8f3e1b2add15ac61c6d1e`

The previous tips `150fdbd9` (base `7a4fa61e`) and `fce1d6ca` (base
`4d92f4f7`) are superseded: both had gone `dirty`, and no part of their
result is reused beyond this change. The change was replayed onto live
`main` again after `ThinkFlowLab#1547`/`ThinkFlowLab#1552`/`ThinkFlowLab#1556` landed; the payload is
unchanged (`--numstat` per file identical to `fce1d6ca`), and
`patches/ds-bot/0008-client-bundle.patch` was rebuilt by hand as the
smallest delta instead of by `generate`, which reorders the whole bundle
diff (#P-59): it is `+13/−4` against main, not the `+708/−386` that
`generate` writes on a clean main.

## What changed

`core/src/client/voice.js` built the text it speaks itself: strip the
header, then delete every character in `[*_`#>|~-]`. `<` and `"` are not
in that set and `>` is, so a stored remote line went out as `<untrusted
message from="User" B踢后测4` — the fence markers minus one bracket, plus
the sealed text. The same string was the whole turn, so a delegation
trace was read aloud verbatim; the outline stores a reply flattened onto
one line (`Chief → Product B踢后测4`, the shape
`core/test/inbound-ui.test.js:47` pins), and the voice said the routing
and the words as one sentence.

`core/src/client/text.js` now exports `speechOf(text, names)` beside
`readableLine`. It reads the line the way a row reads it —
`readableLine` drops the header, the untrusted fence and a delegation
trace whose every hop is a known Bot name (ThinkFlowLab#1395, ThinkFlowLab#1408) — and then
removes the Markdown a speech synthesiser would otherwise speak: a code
fence, a link's target, emphasis marks, collapsed whitespace. `voice.js`
calls `speechOf(text, botNames(roster))`, so the voice and the row
preview read one chain, and an ordinary arrow line such as `Settings →
Models 页面` keeps its words instead of being read as `页面`.

Two shapes now stay out of the ears, and the row preview agreed with
them before this change: `Chief → Product\nB踢后测4` and the flattened
`Chief → Product B踢后测4` are both spoken as `B踢后测4`; a line that is only
a trace is spoken as nothing.

Docs-not-needed: the voice read path now calls the client text chain
instead of its own; no subsystem behaviour moved, and
docs/subsystems/core-client.md still describes the text helpers and
voice mode as they are.

`.github/**` is untouched.

## Verification at this tip

Tip SHA: `158b7707e16c53ade290f9b81eb3abef810fde43`
Ran: `TZ=UTC ARCH_DOCS_BASE=9b866a5c npm run verify` at this tip; `node
scripts/ds-bot-patches.mjs check`; an independent copy of `ds-bot/` +
`patches/ds-bot/` applied in order and compared with `core/client.js`;
the new test at this tip and with `text.js`/`voice.js` grafted back to
`main`'s sources; `node core/scripts/build-client.mjs --check` inside
`verify`.
Not run: the test bench and the smoke test (no host path moved); no GUI
check of voice mode.

### The three gates

1. **`verify`** — exit 0: core **1876/1876**, social 527, tree 125,
timeline 125, workflow **136/136**, `.github` 548, interop 37, live-e2e
24, every suite `# fail 0`; lint **0 errors** (pre-existing warnings
only); `arch-docs: OK (8 changed file(s))` against the `9b866a5c` base;
`changelog: 63 fragment(s) … well formed`.
2. **`node scripts/ds-bot-patches.mjs check`** — three `ok` lines:
`ds-bot/ = Fei2411/DeepSeek-Bot@2b10c70 (tree 5834c00, v0.1.5)`, `10
patches apply cleanly in order`, `core/ equals ds-bot/ +
patches/ds-bot/`.
3. **The independent copy** — `ds-bot/` copied out,
`patches/ds-bot/files/` laid over it, the ten patches applied in order
with `git apply -p1`, then compared with `core/client.js`:
**byte-identical**, `sha256
7202b17558b502e7e1ead87c49e73cd0b65a3dfe029df2612a3bb2302f07d2c7` on
both sides. `node core/scripts/build-client.mjs --check` is silent at
this tip.

### Red, then green

```
cd core && node --import ./test/harness-resolve.mjs --test test/voice-speech.test.js
```

Re-run on the new base with `text.js` and `voice.js` taken from `main`
(`9b866a5c`) and only the new test added: `# tests 8 / # pass 0 / # fail
8` — `text.js` exports no `speechOf`, and `voice.js` keeps its own
chain. At this tip: `# tests 8 / # pass 8 / # fail 0`.

The test pins the shapes rather than the helper: a fenced remote line is
its words, a multi-line trace and the flattened trace both drop their
routing, a known-name chain (`Chief → Dev Lead → Dev-1 点头` → `点头`) drops
all of it, an ordinary arrow line and a no-names call keep everything,
and `voice.js` is asserted to hand the read side its names.

### Files — 8, +233 / −10

| file | |
| --- | --- |
| `core/src/client/text.js` | +12 |
| `core/src/client/voice.js` | +2/−3 |
| `core/test/voice-speech.test.js` | +82 (new) |
| `core/client.js` | +4/−2 (generated) |
| `patches/ds-bot/0001-core-fixes.patch` | +37/−1 (generated) |
| `patches/ds-bot/0008-client-bundle.patch` | +13/−4 (generated,
hand-scoped) |
| `patches/ds-bot/files/test/voice-speech.test.js` | +82 (new,
whole-file mirror) |
| `changelog.d/1405.fixed.md` | +1 (new) |

## Checkpoint

<!-- flybot:checkpoint -->
```json
{
 "pr": 1409,
 "claim": "Dev3",
 "issue": 1405,
 "summary": "`voice.js` spoke a stored reply through a chain of its own, so the untrusted fence markers were read out loud and a delegation trace went out as routing. `text.js` now exports `speechOf(text, names)`, which reads the line through ThinkFlowLab#1395's `readableLine` — header, fence and a known-name trace — and then drops the Markdown a synthesiser would otherwise speak. `voice.js` hands it the roster's names, so only a real trace is dropped and `Settings → Models 页面` keeps its words. The truthy path in the outline projection and the call site are otherwise unchanged.",
 "files": [
  "core/src/client/text.js",
  "core/src/client/voice.js",
  "core/test/voice-speech.test.js",
  "core/client.js",
  "patches/ds-bot/0001-core-fixes.patch",
  "patches/ds-bot/0008-client-bundle.patch",
  "patches/ds-bot/files/test/voice-speech.test.js",
  "changelog.d/1405.fixed.md"
 ],
 "checks": {
  "ran": "TZ=UTC ARCH_DOCS_BASE=9b866a5c npm run verify; node scripts/ds-bot-patches.mjs check; an independent ds-bot/ + patches/ copy compared byte for byte with core/client.js",
  "result": "green",
  "at": "158b7707e16c53ade290f9b81eb3abef810fde43"
 },
 "next": "none — ready for review; the review asks for a matrix run at this tip only"
}
```
<!-- /flybot:checkpoint -->
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Oct 28, 2026
…owLab#1495) (ThinkFlowLab#1511)

fix(workflow): keep the node role sub-label on a theme token (ThinkFlowLab#1495)
Fixes ThinkFlowLab#1495
Owner: Dev3
Reviewer: Reviewer
Written-by: Dev-3
Tip SHA: `a9bcd021b24af91c0f477ebc4d316eca13213bb4`
Base: `9b866a5c797e8efe95e445885b69da4b8ca5c35b` (live `main`; this
branch is rebased onto it)
Tree: `e0325ca02a2254acff50d33a8196a0398871189e`
Previous tip `9592d342` (base `4d92f4f7`) went `dirty` when
`ThinkFlowLab#1547`/`ThinkFlowLab#1552`/`ThinkFlowLab#1556` landed on `main`; this tip is the same change
rebased onto `9b866a5c` — mechanical, no semantic edit. Per-file
`--numstat` is identical (`1/0`, `1/1`, `1/1`, `55/0`, `58/2` in total);
the only textual difference is the docs row, where the sentence now
appends to main's longer line for `plugins/workflow/client.js`.

## What changed

One declaration. `.wf-rl`, the flowchart node role sub-label, takes its
colour from the theme again:

```diff
-.wf-rl{font-size:12px;color:rgba(20,20,20,.55);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.wf-rl{font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
```

`ThinkFlowLab#1484` had replaced `var(--bt-ink-3, #9a9a9a)` with the bare
`rgba(20,20,20,.55)`. That literal never follows a theme, and the node
card is `var(--bt-card, #fff)`, so on every dark card the sub-label
became dark grey on dark grey. `--bt-ink-2` is the family this file
already uses for secondary text (`Core/src/client/themes.js` derives it
from `mode.text` at 0.6 alpha, and `body[data-ds-dark-theme]` swaps it).

The `.wf-kv` / `.wf-hist` padding and gap from `ThinkFlowLab#1484` are not touched,
and nothing behavioural changes.

## Contrast, computed per shipped theme mode

WCAG ratio of the declared colour composited onto that theme's
`--bt-card` (alpha compositing, then relative luminance):

| theme | mode | card | base, pre-`ThinkFlowLab#1484` (`--bt-ink-3`) | before
(`ThinkFlowLab#1484` literal `.55`) | after (`--bt-ink-2`) |
| --- | --- | --- | --- | --- | --- |
| deepseek | light | `#ffffff` | 2.31:1 | 4.07:1 | **4.81:1** |
| deepseek | dark | `#1f1f1f` | 3.09:1 | 1.07:1 | **6.12:1** |
| mono | light | `#ffffff` | 2.38:1 | 4.07:1 | **5.11:1** |
| mono | dark | `#1c1c1c` | 3.14:1 | 1.05:1 | **6.33:1** |
| paper | light | `#ffffff` | 2.22:1 | 4.07:1 | **4.42:1** |
| paper | dark | `#2a2925` | 2.97:1 | 1.15:1 | **5.60:1** |
| moonlight | light | `#ffffff` | 2.28:1 | 4.07:1 | **4.66:1** |
| moonlight | dark | `#141c33` | 3.00:1 | 1.05:1 | **5.95:1** |

Read the `before` column as `ThinkFlowLab#1484`'s hardcoded `rgba(20,20,20,.55)`
specifically — the 4.07:1 in light is that literal. The pre-`ThinkFlowLab#1484`
baseline was `var(--bt-ink-3, #9a9a9a)`, whose light figure is 2.31:1
(DeepSeek; 2.2–2.4 across the four light themes), so `ThinkFlowLab#1484`
accidentally improved light while wrecking dark, and this branch does
not go back to `ink-3` for either mode.

Every dark theme clears 4.5:1 (5.60–6.33:1). Light is 4.42:1 at its
worst and 4.81:1 on the default light theme.

### Known limit: Paper light is 0.08 below AA

Paper's light text is `#1f1e1b` rather than DeepSeek's `#141414`, so
`--bt-ink-2` (0.6 alpha) composites to **4.42:1** on its white card —
0.08 short of WCAG AA 4.5:1. That is the token's own ceiling at 0.6
alpha; clearing 4.5 there means raising `themes.js`'s `rgba(mode.text,
0.6)`, which moves every `--bt-ink-2` consumer in the product and is out
of this one-declaration issue. The test therefore asserts **dark ≥ 4.5:1
and light ≥ 4.4:1** — not 4.5 everywhere — and the light floor is still
above both the 4.07:1 of `ThinkFlowLab#1484`'s literal and the 2.2–2.4 of the
`ink-3` base.

## Red first

Re-run on the new base: `git checkout 9b866a5 --
plugins/workflow/client.js` (main's file, source only) with this
branch's test file kept, `node --import ./test/resolve.mjs --test
plugins/workflow/test/client.test.js`:

```
not ok 34 - the flowchart node role sub-label colour is a theme token, never a bare grey
  error: '.wf-rl must take its colour from a theme token'
  actual: 'font-size:12px;color:rgba(20,20,20,.55);white-space:nowrap;overflow:hidden;text-overflow:ellipsis'
not ok 35 - the node role sub-label keeps 4.4:1 in light and 4.5:1 in every dark theme
  error: 'deepseek light: rgba(20,20,20,.55) on #ffffff is 4.07:1, below 4.4:1'
# tests 45 / # pass 43 / # fail 2
```

(On the old base `4d92f4f7` the same two cases were `not ok 26`/`not ok
27` of 27 tests; only the numbering moved with main's added cases.)
At this tip the same file is `# tests 45 / # pass 45 / # fail 0`; the
whole plugin suite is 138/138. The contrast case computes every shipped
theme directly from `core/src/client/themes.js` (the same table the
themes module builds the tokens from), so it does not hardcode a second
palette.

## Knife

- put the bare `rgba(20,20,20,.55)` back → `not ok 26` (`must take its
colour from a theme token`) and `not ok 27` (`4.07:1, below 4.4:1`) → `#
pass 25 / # fail 2`; restored, `sha256` and `git diff` clean.
- `var(--bt-ink-2)` → `var(--bt-ink-3)` (the token `ThinkFlowLab#1484` had started
from) also turns `not ok 27` red at 2.2–3.1:1, which is why this does
not go back to `ink-3`.

## What I ran

At the rebased tree (`e0325ca0`, pushed as
`a9bcd021b24af91c0f477ebc4d316eca13213bb4`), base `9b866a5c`:

- `TZ=UTC ARCH_DOCS_BASE=9b866a5c npm run verify`: **exit 0** — core
**1868/1868**, social 527, tree 125, timeline 125, workflow **138/138**,
`.github/scripts` 548, interop 37, live-e2e 24; oxlint **0 errors**
(pre-existing warnings only); `arch-docs: OK (4 changed file(s))`; the
patch checks inside verify print `ok 10 patches apply cleanly in order`
and `ok core/ equals ds-bot/ + patches/ds-bot/`.
- `node .github/scripts/changelog.mjs check`: `changelog: 63 fragment(s)
in changelog.d/ are well formed`.
- Entry-level checks from the original review still hold: the `.wf-rl`
declaration is the one line changed, and `docs/subsystems/workflow.md`
gains our sentence on main's current row.

## What I did not run

- No dark-theme visual pass in a browser: the numbers above are WCAG
math over the shipped palettes, and the colour is now the theme token
the rest of the UI already uses. No `dsh web` session here.
- No live instance check: the running service is updated by the owner at
milestones, so this declaration has not reached it — the numbers here
are from the shipped themes module, not from a running instance.

## Out of scope

- Raising `--bt-ink-2`'s 0.6 alpha (a core `themes.js` token used
everywhere) to force 4.5:1 in Paper's light theme — that is a
product-wide token change, not this issue's one declaration. Recorded as
the known limit above; no separate issue is filed for it.
- The `.wf-kv` / `.wf-hist` padding and gap from `ThinkFlowLab#1484` (deliberately
untouched).

## Test placement

The two new tests sit at `@@ -1025,6 +1027,59 @@` inside
`test/client.test.js` (the `allThemes` import lands in the file header
hunk `@@ -4,6 +4,8 @@`, next to main's `rectWidth` comment) — they were
first written as an end-of-file append, where `ThinkFlowLab#1505` also appends;
moving them here leaves zero overlap, so the two pull requests merge
cleanly in either order. The move changed no product code and no
assertion.

## Checkpoint
<!-- flybot:checkpoint -->
```json
{
  "pr": 1511,
  "claim": "Dev3",
  "issue": 1495,
  "summary": "ThinkFlowLab#1484 wrote the flowchart node role sub-label's colour as a bare rgba(20,20,20,.55), so on every dark theme's card the sub-label read at 1.05-1.15:1. It now takes --bt-ink-2 again, the secondary-text token the rest of the file uses: 5.60-6.33:1 on the four dark cards and 4.42-5.11:1 in light (4.81:1 on the default light theme). Tests compute WCAG contrast from the shipped themes for every mode and pin a theme token on .wf-rl, so a hardcoded grey fails the suite. Only that declaration changes; ThinkFlowLab#1484's padding and gap are untouched.",
  "files": [
    "plugins/workflow/client.js",
    "plugins/workflow/test/client.test.js",
    "docs/subsystems/workflow.md",
    "changelog.d/1495.fixed.md"
  ],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=9b866a5c npm run verify; node --import ./test/resolve.mjs --test plugins/workflow/test/client.test.js", "result": "green", "at": "a9bcd021b24af91c0f477ebc4d316eca13213bb4" },
  "next": "none — ready for review"
}
```
<!-- /flybot:checkpoint -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(workflow): a Bot's switched-off connector must not be reopened by a Workflow node

2 participants