row 198 r3: build design item 8; killers for M1-M15; B4 drops; docs; BUILD.md

Rebased onto aca16404c. Item 8 (scripts/stack-release.sh): a re-pin drops
--board for a target cli.mjs that does not parse it (parses_flag ignores
refusal lines); the plan names the drop. The rollback-install conflict
(webui-service.sh has no --board branch) is open for Sage's ruling and
recorded in BUILD.md.

Killers: retired.test.mjs (M1, M2, M9), board.test.mjs (M15),
board-inprocess.test.mjs (M3, M5, M6, M7, M10, M14), pinned-copy M11,
stack-release M12/M13. M14 is a known gap, recorded in BUILD.md.

B4: stale-pid case ported to createBoard via isPidAlive; bad-JSON moved to
send.test.mjs; page.html and tester board-proxy tests dropped (reasons in
BUILD.md). B5: non-numeric --port test removed.

Docs: webui and control-board READMEs drop the 7331 serve flow; CURRENT.md
dated line appended.

UNVERIFIED: no node or tests run (Sage's gate, sage-2821).
This commit is contained in:
jason.woltje committed 2026-10-22 12:09:45 -05:00
1 parent c3b60e5138
commit b42d9cf360
13 files changed
+435 -128

No files matched your search

+145 -87
View File
@@ -1,109 +1,167 @@
# Row 198 build: retire the board writer on 7331; the Console serves the board in-process
Branch `bushroot/row198-r2`, based on `origin/refactor` b79ad5f2c. Issue #1495 family, Gitea #1696.
Ruling source: Sage sage-2803 (Q220 / mos-1301), reviewer Webby.
Branch `bushroot/row198-r3`, rebased onto `aca16404c` (queue revs 3195-3216 on refactor). Supersedes
`bushroot/row198-r2` (base b79ad5f2c), which is kept and not force-pushed.
Issue #1495 family, Gitea #1696. Ruling source: Sage sage-2821 answering Webby webby-0150
(CHANGES B1-B5, N1-N3); earlier Q220 ruling sage-2803 (Mos mos-1301), reviewer Webby.
## Status: UNVERIFIED
No test suite, mutant, or node process has run for this commit. Gander holds the
slot (row 253 run 5, from 16:32Z) and Sage's rule is "no test runs until my CLEAR".
Sage's rule overrides the usual "commit only after green" order in AGENTS.md
invariant 8, so this commit is local, unverified, and must not be pushed until the
gate runs green.
No test suite, mutant, or node process has run for this commit. Sage's gate (sage-2803, sage-2821):
"no node of any kind and no https git; no test runs until my CLEAR." This commit is local.
It must not be pushed until the gate runs green and Sage opens a gap for the push.
## Why the commit holds more than the tests
## Open for Sage's ruling: rollback re-installs `--board` and install refuses it
Every row 198 change sat uncommitted on this branch; `origin/refactor..HEAD` was empty
when the commit was made. The source changes (`packages/control-board/src`,
`packages/webui/src`, `scripts/stack-copy.sh`, `scripts/webui-service.sh`) are in the
same commit as the test rewrite, so the commit cannot be reviewed as tests alone.
Design item 8 (rev 5, checkpoint 3f71b359b) keeps a recorded `--board` on rollback when the
rollback target's `cli.mjs` parses `--board`. But `scripts/webui-service.sh install` has no
`--board` branch: an unknown option hits `*) usage`, exit 4. So a rollback that keeps the flag
would fail at install-console. The M13 test checks only the rollback plan, so it does not
catch this. By reading the script, not by running it, the install fails closed.
## What changed in the source (already on the r2 code, now committed)
Options, for Sage:
- (a) Drop `--board` on rollback as well, named in the plan, the same way re-pin drops it.
Recommended: the retirement removes the flag from the Console's unit.
- (b) Teach `webui-service.sh` to accept `--board`. This contradicts the retirement.
- (c) Keep the rule as ruled. The rollback fails closed at install.
- `packages/control-board/src/board.mjs` (new): `createBoard`, the board context the
Console builds in-process. It owns the write counter (`markSeen` = write, bump, scan;
`reply` bumps only when a send happened, so a refusal does no bump), and it joins an
in-flight scan only when that scan started at the current write count.
- `packages/control-board/src/serve.mjs`, `scan.mjs`, `cli.mjs`: the standalone HTTP
writer is gone. `control-board serve` throws RETIRED and exits 2. `page.html` is
deleted.
- `packages/webui/src/serve.mjs`: board routes answer from the in-process board;
`/api/seen` and `/api/reply` need `--send` and a named human, else 403
`{error:'read-only', sent:false}`. No board configured answers 503.
- `packages/webui/src/cli.mjs`: `--board` throws `--board retired: the board runs in the
Console (row 198)`. The board context is built from the system config's dataRoot.
- `scripts/webui-service.sh`: the unit no longer carries `--board`. `--board` is refused in
render and install.
Implemented as ruled (c) for now. Choose (a) and the change is one line in
`console_for_target` plus the M13 expectation.
## What changed in the source
- `scripts/stack-release.sh` (B2, design item 8): `console_for_target` drops a `--board` pair from
`BASE_CONSOLE` unless the target's `cli.mjs` parses `--board`. `parses_flag` matches
`flag === '--board'` and ignores lines that contain `throw` (N2: the target names `--board` only
in a refusal). A dropped pair sets `DROP_BOARD`, printed as `drop $DROP_BOARD; the target Console
does not parse it, so the board runs in the Console` in the plan and the activate path. Init
`DROP_BOARD=""` at the globals. `bash -n` passes; not run.
- `packages/control-board/src/board.mjs` (B4): `createBoard` takes `isPidAlive` and passes it to
`scanAsync`, so the stale-pid refusal can be tested with an injected liveness check.
- `packages/control-board/src/cli.mjs`: `serve` throws RETIRED, exit 2 (`ConfigError` path).
- `packages/webui/src/serve.mjs`: `/api/seen` and `/api/reply` answer 405 for a non-POST, 403
`{error:'read-only', sent:false}` without `--send` on the public name, 503 with no board, and
otherwise run the in-process board. `boardSend` answers 400 on a body that is not JSON, before
any board call.
- `packages/webui/src/cli.mjs`: `--board` throws `--board retired: the board runs in the Console
(row 198)`. The board is built over `system.dataRoot` (`board/`, `seats/`).
- `scripts/webui-service.sh`: the unit no longer carries `--board`; render and install refuse it.
- `scripts/stack-copy.sh`: COPY_PATHS adds `packages/control-board/src` and `tools/tmux`.
## What changed in the tests
Rewritten against the in-process board, not the proxy:
- `packages/cli/tests/stack-release.test.mjs` (B2): new block before the Row 181 section.
`setCli` rewrites a copy's `cli.mjs` and restores it with mode 0444 in `t.after`; `withBoard`
appends the flags to the pointer console.
- M12: `seed`, `setCli` to a refusal-only `cli.mjs`, `withBoard`, `activate --plan`. Expects the
drop line, no `--board` in the Console line, and no state change.
- M13: `setCli` to a parser, real `activate` without the flag, then the unit gets `--board` and
`rollback --plan`. Expects the flag in the Console line and no `drop --board`. Activation runs
without the flag because `webui-service.sh install` refuses it.
- `packages/control-board/tests/serve.test.mjs` (B4, B5): `replyBoard(f, agentSend, isPidAlive)`
passes `isPidAlive` through. New refusal row: a stale registration (pid 4242, `isPidAlive` false)
answers 409 with `/stale/`. B5: the non-numeric `--port` test is deleted, since `serve` no
longer takes a port.
- `packages/webui/tests/send.test.mjs` (B4): `setup` builds an in-process board object
(`index`, `markSeen`, `reply`, each recording into `boardHits`); the `upstream` server is gone.
New case: a board write with a body that is not JSON answers 400 under `--send`, and the board
runs nothing.
- `packages/control-board/tests/retired.test.mjs` (new, B3): M1 `cli.mjs` starts no server;
M2 no board source imports `node:http`, calls `createServer`/`.listen(`, or exports a server;
M9 live READMEs, AGENTS.md and `scripts/*.sh` do not tell anyone to run `control-board serve`
or open 127.0.0.1:7331, except a line that says it is retired. Append-only logs and plans are
not scanned. Grep check on the current tree: no hits.
- `packages/control-board/tests/board.test.mjs` (B3): new test, M15. After a write, `index()` must
start a fresh scan. The event log must read `write, bump, scan, scan`, and the answer must show
the mark.
- `packages/webui/tests/board-inprocess.test.mjs` (new, B3): M6 and M7 (preview: each board route
answers 401 without the proxy header and 403 with a foreign Host, and the board never runs);
M5 (`/api/seen` without `--send` writes no `seen.json`); M10 (positive control: with `--send` on
the public name, `/api/seen` writes the mark and `/api/reply` runs exec once); M3 (a board read
makes no request to a URL other than the Console's own); M14 (`/healthz` answers 200 within
200 ms while `/api/board` is pending).
- `packages/webui/tests/pinned-copy.test.mjs` (B3, M11): the existing "serve runs from a copy"
test also asserts `/api/board` answers 200 with a `sessions` array from the copy.
- `packages/webui/tests/fixture.mjs`: new `inProcessBoard({root, specs, seatsDir, isAlive,
exec})`. `setDown(true)` makes `index()` throw, to test a failing board read.
`close()` closes the web server and removes the root only.
- `packages/webui/tests/history-fixture.mjs`, `return-flow.test.mjs`, `relaunch.test.mjs`,
`discord.test.mjs`: `inProcessBoard` replaces `startBoard`. No board close in `finally`.
- `board: f.boardURL` becomes `board: f.board` in docs-browser, editor-browser,
sso-browser, chat-browser, send-browser (two sites), bus-browser, board-held.
- `'--board', 'http://127.0.0.1:9'` removed from chat.test, proof-cli (three sites),
pinned-copy, preview, send, tester-cli.
- `browser.test.mjs`: the down case uses `f.board.setDown(true)` and expects the
`in the Console` text.
- `serve.test.mjs` (webui): the proxy-forwarding and unreachable-board tests are removed.
New: in-process routes (writes refused 403 `{sent:false}` without `--send`, with zero
captures; GET `/api/reply` 405; OPTIONS `/api/board` 405; conversation pass-through;
a failing read answers >=400). New: `board: null` answers 503, and the CLI refuses
`--board` and other bad args with exit 2 and `refused:`.
- `service.test.mjs`: the render expectation has no `--board`. The refusal list keeps
`--board` with a row 198 comment. The factory expectation has no `--board`.
- `reads.test.mjs:188`: `board: null`, since that test configures no board.
- `proof.test.mjs`: the fake HTTP board is replaced by an in-process `{index}` object;
the tester socket gets `board: null`. Call-order assertions unchanged.
- `packages/cli/tests/stack-copy.test.mjs:145`: the spawn drops `--board`.
- `packages/control-board/tests/serve.test.mjs`: 216 lines. The HTTP and page.html tests
are removed. The reply tests are ported to `createBoard`, with a `replyBoard` helper
pinned to 2026-09-12T18:00:00Z. Kept: the isLoopbackHost tests, the CLI port test,
two CLI scan tests, the DEFAULT_AGENT_SEND test and the REFUSAL_STATUS test.
- `packages/control-board/tests/discord.test.mjs`: the board test is now
`board rescans connector discovery and refuses reply without transport`, on `createBoard`.
- `packages/control-board/tests/board.test.mjs` (new): 4 tests. These passed 4/4 on the
r2 base earlier; not re-run on this commit.
## Dropped coverage (B4), with reasons
## Dropped coverage (deliberate; Sage to confirm)
- Stale-pid reply case. Not dropped: moved. It runs against `createBoard` with the new
`isPidAlive` passthrough (`serve.test.mjs`, the 409 `/stale/` row).
- Bad-JSON request case. Moved to `packages/webui/tests/send.test.mjs`. The HTTP writer is gone;
body parsing now happens in the Console's `boardSend`.
- `page.html` tests. Dropped: the page is deleted, and the Console serves the board page.
- Tester board-proxy tests. Dropped: there is no proxy. The tester socket is built with the
in-process board (N1 below).
- Stale-pid reply case in control-board serve. It needed `isPidAlive` injection, which
`replyBoard` does not provide.
- Bad-JSON request case. It exercised HTTP body parsing, and the HTTP writer is gone.
- page.html tests. The page is deleted.
- Tester board-proxy tests. There is no proxy any more; the tester socket runs with
`board: null`.
## Corrections to r2 (N1)
The r2 note said the tester socket gets `board: null`. That was wrong. Only
`packages/webui/tests/proof.test.mjs` passes `board: null`. `cli.mjs` passes the in-process board
to `startTesterSocket`.
## Mutant map (M1-M15, design rev 5)
Each mutant runs on a scratch copy with the new tests. Not run yet.
| # | Mutant | Killer |
|---|---|---|
| M1 | `cli.mjs serve` starts a server | `retired.test.mjs` M1; also `board.test.mjs` "cli serve refuses" |
| M2 | serve source restores or exports `createServer`/`startServer` | `retired.test.mjs` M2 |
| M3 | Console proxy fetch to a board URL returns | `board-inprocess.test.mjs` "makes no request to a board URL" |
| M4 | `/api/reply` skips the `send` check | `webui serve.test.mjs` "writes refused without --send" (existing; zero captures) |
| M5 | `/api/seen` skips the `send` check | `board-inprocess.test.mjs` "writes no mark"; `serve.test.mjs` as M4 |
| M6 | board block runs before the preview and header gate | `board-inprocess.test.mjs` preview 401 |
| M7 | board route accepts a foreign Host | `board-inprocess.test.mjs` preview 403 |
| M8 | `webui-service.sh` re-accepts `--board` | `service.test.mjs` render refusal list (status 1) |
| M9 | live doc or script tells people to run `control-board serve` | `retired.test.mjs` M9 |
| M10 | `/api/reply` and `/api/seen` branches move below the read-only refusal | `board-inprocess.test.mjs` positive control (200, exec once) |
| M11 | `COPY_PATHS` drops `packages/control-board/src` or `tools/tmux` | `pinned-copy.test.mjs` "serve runs from a copy": `/api/board` 200 |
| M12 | `console_for_target` keeps `--board` for a target that does not parse it | `stack-release.test.mjs` M12 |
| M13 | rollback drops a recorded `--board` from a target that parses it | `stack-release.test.mjs` M13 (plan only; see the open item above) |
| M14 | a board read blocks the loop (spawnSync or a blocking exec) | `board-inprocess.test.mjs` "healthz within 200 ms" catches only a pending read that blocks; see the gap below |
| M15 | `index()` joins a scan that started before the last write | `board.test.mjs` M15 (event log) |
### Gap: M14
The M14 test checks that `/healthz` answers while a board read is pending. It does not reliably
catch a read that holds the event loop, because the test runs on the same loop. A synchronous
`spawnSync` in the read path would pass it. A busy-wait stub in the test did not help, since the
read and the health fetch share the thread. The real check is the mutant run on a scratch copy,
where a `spawnSync` in `tmuxInspect` makes `/healthz` time out. Whether the mutant run detects it
is open until the gate runs. The fix, if it does not, is an out-of-process check that starts the
Console and times `/healthz` against a board that stalls in a child process.
## Checks done without running anything
- Read whole, every touched test file: `service.test.mjs`, `reads.test.mjs`,
`serve.test.mjs` (webui), `proof.test.mjs`, `fixture.mjs`, control-board
`serve.test.mjs` and `discord.test.mjs`. The three browser spawn files were
re-checked by grep.
- `grep -rn -e '--board' -e boardURL -e startBoard packages/*/tests scripts/test-*.sh`:
remaining hits are all intended refusals or unrelated suites:
- `packages/cli/tests/case2-unit.test.mjs:214, 330, 371`: case2's own `--board`
refusal (`scripts/case2-unit.sh:165`), unchanged.
- `packages/cli/tests/stack-release.test.mjs:57`: a mutation that adds `--board` to a
unit must still be refused. Read, not changed.
- `packages/webui/tests/service.test.mjs:255-256`, `serve.test.mjs:78-85`: refusal checks.
- sha256 pins: the grep for 64-hex literals in changed tests and scripts found one hit,
`service.test.mjs:23`, a fixture Authentik uid (`UID`), not a file hash. No changed file
pins a file sha256.
- `packages/webui/tests/library-cli.test.mjs:16`: a fake serve module exports
`DEFAULT_BOARD`. Harmless; may be dropped later.
- Read whole: `packages/webui/tests/fixture.mjs`, `packages/webui/tests/send.test.mjs` (first
70 lines), `packages/webui/tests/serve.test.mjs` (first 80 lines), `packages/control-board/
tests/board.test.mjs`, `packages/control-board/src/board.mjs`, `packages/webui/tests/pinned-copy.test.mjs`
(the serve-from-copy test).
- Grep, current tree: `--board` and `boardURL` in `packages/*/tests` and `scripts/test-*.sh`
show only intended refusals and the unrelated case2 suite (`case2-unit.test.mjs`, `case2-unit.sh`).
- The M9 and M2 rules run as greps over the live docs and `packages/control-board/src`: no hits.
- sha256 pins: no changed file pins a file sha256. The 64-hex literal at `service.test.mjs:23` is
a fixture uid, not a hash.
- `packages/webui/tests/library-cli.test.mjs:16`: a fake serve module exports `DEFAULT_BOARD`.
Harmless; may be dropped later.
## Not done (open)
## Docs (N2)
- Docs: `packages/webui/README.md` (lines near 10, 15, 766, 930 still mention 7331 or
`--board`), the control-board README serve instructions, and an append-only dated line
in `docs/plans/CURRENT.md` near line 171. Not in this commit.
- Gate: control-board, webui and cli suites; `scripts/test-*.sh`; mutants M1 to M15 from
the earlier plan. All wait for Sage's CLEAR.
- `packages/webui/README.md`: the start block is one command (`node packages/webui/src/cli.mjs
serve`); `--board` is refused; the proxy-origin sentence is gone; the Host and write paragraph
says seen and reply reach the in-process board under `--send` on the public name; rollback has
no 7331 step.
- `packages/control-board/README.md`: `serve` is retired and exits 2; the board runs in the
Console; `scan` is kept; the serve-only flags are removed; the route list is marked as served by
the Console; exit 0 is scan only.
- `docs/plans/CURRENT.md`: one dated line appended at the end (append-only).
Still in the README, on purpose: `packages/webui/README.md` line 826 runs the control-board suite
(`node --test packages/control-board/tests/`). The suite still exists, so the line stays.
## Gate, not run
- control-board, webui and cli suites; `scripts/test-*.sh`; the mutants M1-M15 on a scratch copy.
All wait for Sage's CLEAR.
- The rollback-install open item needs Sage's ruling before the M13 expectation is final.
- Push: not done. Sage's gaps govern it.
+1
View File
@@ -586,3 +586,4 @@ git history + Gitea issues.
- 2026-09-26 (sage, lead): Jason's ruling: Sage leads the project, Darkwing is a collaborating seat, more seats to follow, development stays in T3 for now. The fleet Sage seat that took Invoice Ninja coordination outside Jason's instructions is being decommissioned; moving work onto the new stack is the fix. Suites all green. Rows 24 and 25 pushed (1685deb4..43d7574d). Open: about 11 days of uncommitted agent work in the tree (row 16 launch files, row 22, #1512, WUI evidence), 23 untracked aws-* skill directories in skills/ from 2026-09-21, and Jason's target for the next phase.
- 2026-10-13 (sage, lead): Jason's PM re-test on 664199f7 is finished (Mos; PM run rcc0f72be8b17; Vikunja #228). Paging, the Board's held roles, the Messages page and the #229 loop (coder push 010d30cb, review, approve, PM close) all worked; #230 and #231 are done, #232 stays open for the secrets-path probe. Two faults checked and dropped: the reviewer did run the tests before its verdict, and the PM's clone did hold the row 95 Board code. Two filed: row 104 (#1598) seat git identity, row 105 (#1599) a review request says whether a reviewer is running. Rows 96, 101-105 briefed; row 85 released to build. Row 85's live steps, any bus restart and row 102's public/ change each need Mos's go. Row 100 waits on Jason (Vikunja #257). Formatting, Jason's direction: no formatting row now. Chat output will be formatted in the TUI and WebUI the way chat interfaces usually do it, made our own; T3 Code's chat is his example. Carry it into the TUI and WebUI chat work.
- 2026-10-22 (bushroot, row 198 r3): the board runs in-process in the Console (webui, port 7332); control-board `serve` is retired and exits 2, and the Console refuses `--board`. Docs: packages/webui/README.md and packages/control-board/README.md now give the Console start and drop the 7331 serve text. Built on bushroot/row198-r3 from aca16404c; not run, awaiting Sage's CLEAR.
+31
View File
@@ -851,6 +851,37 @@ test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-targe
});
});
// Row 198 (design item 8, B2): --board stays only where the target's cli.mjs parses it. A target that
// names the flag only in its refusal drops it, and the plan names the drop. A rollback to a copy that
// parses it keeps the pointer's flag. setCli rewrites a shared copy's cli.mjs and restores it after.
const BOARD='--board http://127.0.0.1:9';
const REFUSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nif (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');\n`;
const PARSES_BOARD=`// fixture Console, never run\n// serve accepts '--tester-port'\nelse if (flag === '--board') board = value;\n`;
const setCli=(t,id,text)=>{const f=join(COPIES,id,'packages/webui/src/cli.mjs'),orig=readFileSync(f,'utf8');chmodSync(f,0o644);writeFileSync(f,text);t.after(()=>{chmodSync(f,0o644);writeFileSync(f,orig);chmodSync(f,0o444)})};
const withBoard=(e)=>{const q=pointer(e);q.console=[...q.console,...BOARD.split(' ')];writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
test('row 198 item 8 (M12): a re-pin plan drops a --board the target Console does not parse, and its Console line has none',async(t)=>{
const e=await mk(t);await seed(e);setCli(t,C1,REFUSES_BOARD);withBoard(e);
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
const r=await run(e,'activate',C1,'acme','--plan');
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the target Console does not parse it, so the board runs in the Console`),r.out);
assert.doesNotMatch(r.out.split('\n').find((l)=>l.startsWith('Console: ')),/--board/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
});
test('row 198 item 8 (M13): a rollback keeps the pointer\'s --board for a copy that parses it',async(t)=>{
// The activation runs without --board, since webui-service.sh install has no branch for the flag. The
// pointer and unit then carry it, and the rollback target B parses it, so the rollback plan keeps it.
const e=await mk(t);setCli(t,B,PARSES_BOARD);
await seed(e);
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
assert.ok(r.out.split('\n').find((l)=>l.startsWith('Console: ')).includes(BOARD),r.out);
assert.doesNotMatch(r.out,/drop --board/);
});
// Row 181 (#1679, P19 390): COUNTED and STORE_JS as the script under test has them. The fixture
// tables carry only seq and at, which is all the count reads.
const srText=readFileSync(SR,'utf8');
+17 -12
View File
@@ -100,14 +100,22 @@ service transitions or authorize replacing a running board.
```
node src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
node src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
```
`scan` runs once and writes the status files. `serve` starts a small local
web server: open `http://127.0.0.1:7331/` in a browser. The page fetches
`/api/board` every 10 seconds; each fetch re-runs the scan, so the page is
never staler than that timer. There is no login, so the server refuses to
bind to anything but a loopback address.
`scan` runs once and writes the status files.
`serve` is retired (row 198). It exits 2 with a pointer to the Console. The
board now runs in-process inside the Console (`packages/webui`), behind the
Console's own sign-in. Start it with:
```
node packages/webui/src/cli.mjs serve
```
Then open `http://127.0.0.1:7330/` in a browser. The board's routes are
`/api/board` and the two writes `/api/seen` and `/api/reply`, which need
`--send` on the public name. The page fetches `/api/board` every 10 seconds;
each fetch re-runs the scan, so the page is never staler than that timer.
- `--config PATH` — path to the system config file. Defaults to
`~/.config/mosaic-dev/config.json`. This file must exist and name an
@@ -124,11 +132,8 @@ bind to anything but a loopback address.
first column is `*` for a row waiting on you, `s` for a seen row, or blank
otherwise. The final summary line reads
`board: <dir> (N sessions, N waiting on you, N seen)`.
- `--port N` — (`serve`) port to listen on. Default `7331`; `0` picks a free port.
- `--host ADDR` — (`serve`) loopback address to bind. Default `127.0.0.1`.
Any non-loopback address is refused.
Routes served:
Routes (row 198: served by the Console, `packages/webui`, not by a board port;
the bodies and refusals below are unchanged):
- `GET /` — the page.
- `GET /api/board` — rescan, returns `index.json`.
@@ -170,7 +175,7 @@ so the seat's reaction shows without waiting for the next tick.
## Exit codes
- `0` — scan completed and status files were written, or the server stopped cleanly.
- `0` — scan completed and status files were written.
- `2` — refused: bad config, missing/invalid `dataRoot`, or bad arguments.
The message on stderr says why.
+2 -2
View File
@@ -15,12 +15,12 @@ import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs"
// onEvent(name) reports "write", "bump", "send" and "scan" in order. Production
// passes nothing; the order test (board.test.mjs) reads it to pin the sequence.
export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot = null, isAlive, now = () => new Date(), agentSend = DEFAULT_AGENT_SEND, exec = runAsync, conversationReader = null, host, onEvent = () => {} }) {
export function createBoard({ specs, boardDir, seatsDir = null, discordDataRoot = null, isAlive, isPidAlive, now = () => new Date(), agentSend = DEFAULT_AGENT_SEND, exec = runAsync, conversationReader = null, host, onEvent = () => {} }) {
let writes = 0;
let inflight = null;
const scanFresh = () => {
onEvent("scan");
return scanAsync(specs, { isAlive, exec, boardDir, now, seatsDir, discordDataRoot });
return scanAsync(specs, { isAlive, isPidAlive, exec, boardDir, now, seatsDir, discordDataRoot });
};
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
@@ -79,3 +79,18 @@ test("cli serve refuses and points to the Console", () => {
assert.match(r.stderr, /retired on 7331/);
assert.match(r.stderr, /webui/);
});
// M15 (Webby N1): index() must not join a scan that started before the last write.
// A scan already answered before the mark would hide the mark; the join is what
// this kills, so the event log must show a fresh scan after the write.
test("index after a write starts a fresh scan and sees the mark (M15)", async () => {
const { boardDir, specs, isAlive } = fixture();
const events = [];
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
await board.index();
const before = events.length;
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
const after = await board.index();
assert.deepEqual(events.slice(before), ["write", "bump", "scan", "scan"]);
assert.deepEqual(after.seen, ["proj/agent1"]);
});
@@ -0,0 +1,44 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
// Row 198: the 7331 writer is retired and the board runs in the Console. These
// are source and doc checks; each names the design mutant it kills (M1, M2, M9).
const repo = resolve(import.meta.dirname, "..", "..", "..");
const read = (path) => readFileSync(join(repo, path), "utf8");
const srcDir = join(repo, "packages", "control-board", "src");
const srcFiles = readdirSync(srcDir).filter((f) => f.endsWith(".mjs"));
// M1: cli.mjs starts no server, not even on the retired port.
test("retired: cli.mjs starts no server (M1)", () => {
assert.doesNotMatch(read("packages/control-board/src/cli.mjs"), /startServer|createServer|\.listen\(/);
});
// M2: nothing in the board's source opens a socket or exports a server.
test("retired: no board source imports http or exports a server (M2)", () => {
for (const file of srcFiles) {
const text = read(join("packages", "control-board", "src", file));
assert.doesNotMatch(text, /from ["']node:http["']|createServer\(|\.listen\(/, file);
assert.doesNotMatch(text, /export (async )?function (startServer|createServer)\b/, file);
}
});
// M9: a live doc or script must not tell anyone to run the retired serve or open 7331.
// A line that says it is retired is allowed. Append-only logs and plans are not scanned.
test("retired: live docs and scripts do not tell anyone to run control-board serve (M9)", () => {
const liveDocs = ["README.md", "AGENTS.md"];
for (const pkg of readdirSync(join(repo, "packages"))) {
if (existsSync(join(repo, "packages", pkg, "README.md"))) liveDocs.push(join("packages", pkg, "README.md"));
}
for (const file of readdirSync(join(repo, "scripts"))) {
if (file.endsWith(".sh")) liveDocs.push(join("scripts", file));
}
const retiredUse = /control-board\/src\/cli\.mjs serve|control-board serve\b|127\.0\.0\.1:7331/;
const retiredNote = /retired|no longer|exits 2/i;
for (const file of liveDocs) {
read(file).split("\n").forEach((line, i) => {
if (retiredUse.test(line) && !retiredNote.test(line)) assert.fail(`${file}:${i + 1} tells people to run the retired board: ${line.trim()}`);
});
}
});
+5 -10
View File
@@ -91,12 +91,6 @@ test("isLoopbackHost: rejects non-loopback hosts", () => {
// 2. CLI: the port is refused before anything runs; scan and its --print mark
// ---------------------------------------------------------------------------
test("CLI: serve rejects a non-numeric --port with exit 2", () => {
const r = runCli(["serve", "--port", "abc"]);
assert.equal(r.status, 2);
assert.match(r.stderr, /^refused:/);
});
test("CLI: scan still works after the async cli refactor", () => {
const root = makeRoot();
const dataRoot = join(root, "data");
@@ -203,8 +197,8 @@ test("every refusal code the reader can raise has an HTTP status", () => {
});
// The Console's reply route calls board.reply() with the parsed body, so these pin the policy it inherits.
function replyBoard(f, agentSend = f.agentSend) {
return createBoard({ specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, agentSend, now: () => new Date("2026-09-12T18:00:00Z") });
function replyBoard(f, agentSend = f.agentSend, isPidAlive = undefined) {
return createBoard({ specs: f.specs, boardDir: f.boardDir, seatsDir: f.seatsDir, isAlive: () => true, isPidAlive, agentSend, now: () => new Date("2026-09-12T18:00:00Z") });
}
test("reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams", async () => {
@@ -261,7 +255,7 @@ test("reply: a non-zero tool exit is delivered false, with the exit code and the
}
});
test("reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, missing row id 400, no registration 409, no tmux session 409; the tool is never called", async () => {
test("reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, missing row id 400, no registration 409, stale registration 409, no tmux session 409; the tool is never called", async () => {
const cases = [
{ fixture: replyFixture(), body: { agent: "proj/agent1", text: "" }, status: 400, error: /non-empty/ },
{ fixture: replyFixture(), body: { agent: "proj/agent1", text: " \n" }, status: 400, error: /non-empty/ },
@@ -269,11 +263,12 @@ test("reply: refusals before the tool runs: empty or blank or long text 400, unk
{ fixture: replyFixture(), body: { agent: "proj/nobody", text: "hi" }, status: 404, error: /unknown row/ },
{ fixture: replyFixture(), body: { text: "hi" }, status: 400, error: /row id/ },
{ fixture: replyFixture({ registered: false }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /needs a registered seat/ },
{ fixture: replyFixture({ pid: 4242 }), isPidAlive: () => false, body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /stale/ },
{ fixture: replyFixture({ tmux: null }), body: { agent: "proj/agent1", text: "hi" }, status: 409, error: /no tmux session/ },
];
for (const c of cases) {
const f = c.fixture;
const { status, body } = await replyBoard(f).reply(c.body);
const { status, body } = await replyBoard(f, undefined, c.isPidAlive).reply(c.body);
assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
assert.match(body.error, c.error);
assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
+11 -9
View File
@@ -4,22 +4,22 @@ Piece 4, #1507. The control board is the first and only WebUI screen.
## Run
Start the existing board in one terminal, then the WebUI in another:
The board runs inside the Console (row 198), so there is one server to start:
```sh
node packages/control-board/src/cli.mjs serve
node packages/webui/src/cli.mjs serve
```
Open http://127.0.0.1:7330/. Optional `--port N` and
`--board http://127.0.0.1:7331` select another port or loopback board origin.
Open http://127.0.0.1:7330/. Optional `--port N` selects another port. There
is no separate board server (`control-board serve` is retired and exits 2) and
no `--board` flag (it is refused).
`--business ID` picks the business the bus views read; without it they read
the running bus host's business (`mosaic bus start <business>`).
Port 0 picks a free port. Ctrl-C stops each foreground server. No daemon,
installation, account, login or deployment is added. The preview mode below
checks one header a sign-in proxy sets; it signs nobody in itself.
Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
The WebUI binds to IPv4 loopback
by default, rejects a repeated Host (400), nonlocal Host and cross-origin
requests, sends no CORS
headers, refuses redirects and accepts only JSON object POST bodies up to 4096
@@ -60,9 +60,11 @@ In preview mode:
`Host: NAME` would otherwise skip the sign-in check.
- Every method other than GET and HEAD answers 403 `read-only`, whatever the
Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply
fail in the page with that refusal. The one exception is POST
`/api/bus/message` when the Console was started with `--send` (next
section).
fail in the page with that refusal. The exceptions are POST `/api/bus/message`
when the Console was started with `--send` (next section), and POST
`/api/seen` and `/api/reply` when the Console runs with `--send` on the
public name (row 198: the board runs in-process, so these writes reach it
directly; without `--send` they answer 403 `read-only`).
- The GET routes are unchanged: the bus views, the Queue, Business and
Settings reads, the board panel and the assets. If the board is down, its
panel shows the existing error and the bus views still answer.
@@ -936,7 +938,7 @@ completion Seen and find it again in Seen. Record any reason to open the board's
own page or a repo seat terminal in DEFERRED.md. Pass is Jason's end-of-day
say-so, not a test result. Keep #1507 open pending that ruling.
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
Rollback requires only stopping the WebUI. The board runs inside it, so there is no separate board to keep running.
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
Seen and reply actions already taken belong to the board and are not rolled back.
@@ -0,0 +1,123 @@
// Row 198: the board runs in-process in the Console. These are the Console-side killers:
// M3 (no proxy fetch to a board URL), M5 (no seen write without --send), M6 and M7 (the
// preview gate runs before any board route), M10 (positive control: the write branches
// reach the board under --send) and M14 (a pending board read never blocks /healthz).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { seenPath } from '../../control-board/src/scan.mjs';
import { close, fixture } from './fixture.mjs';
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
const preview = { host: NAME, header: HEADER, user: ID };
const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
const BODY = '{"agent":"proj/agent1","text":"hi"}';
const BOARD_ROUTES = [['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen'], ['POST', '/api/reply']];
// node:http, not fetch: fetch can't set Host.
function call(port, { method = 'POST', path, headers = ok, host = NAME, body = BODY } = {}) {
return new Promise((resolve, reject) => {
const length = method === 'GET' ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, text }));
});
req.on('error', reject);
req.end(method === 'GET' ? undefined : body);
});
}
// A board that counts every call that reaches it.
function countingBoard(hits, index = async () => ({ sessions: [], seen: [] })) {
return {
index: async () => { hits.push('index'); return index(); },
markSeen: async () => { hits.push('markSeen'); return { seen: [] }; },
reply: async () => { hits.push('reply'); return { status: 200, body: { delivered: true } }; },
conversation: async () => { hits.push('conversation'); return { status: 200, body: {} }; },
};
}
function stop(web) {
return new Promise(resolve => { web.closeAllConnections(); web.close(resolve); });
}
// M6, M7: under the preview, every board route refuses a request without the proxy header
// (401) and a request with a foreign Host (403), and the board never runs.
test('preview: every board route refuses without the proxy header (401) or with a foreign Host (403) before the board runs (M6, M7)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits), preview });
t.after(() => stop(web));
const port = web.address().port;
for (const [method, path] of BOARD_ROUTES) {
const bare = await call(port, { method, path, headers: without(HEADER) });
assert.equal(bare.status, 401, `${method} ${path} without the header`);
const foreign = await call(port, { method, path, host: 'evil.example' });
assert.equal(foreign.status, 403, `${method} ${path} with a foreign Host`);
}
assert.deepEqual(hits, [], 'the board ran for a refused request');
});
// M5: without --send the seen write refuses and writes no mark file.
test('seen without --send refuses and writes no mark (M5)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview });
t.after(() => stop(web));
const res = await call(web.address().port, { path: '/api/seen', body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: 'x', seen: true }) });
assert.equal(res.status, 403);
assert.equal(JSON.parse(res.text).sent, false);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), false, 'a mark was written without --send');
});
// M10: positive control. With --send on the public name, both writes reach the in-process
// board. A branch moved below the read-only refusal would answer 403 here and fail.
test('with --send on the public name, seen and reply reach the in-process board (M10)', async t => {
const f = await fixture();
t.after(() => f.close());
const web = await startServer({ port: 0, board: f.board, preview, send: async () => ({ id: 'msg-0001' }) });
t.after(() => stop(web));
const port = web.address().port;
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
const seen = await call(port, { path: '/api/seen', body: JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }) });
assert.equal(seen.status, 200, seen.text);
assert.equal(existsSync(seenPath(join(f.root, 'board'))), true, 'the seen write did not reach the board');
const reply = await call(port, { path: '/api/reply' });
assert.equal(reply.status, 200, reply.text);
assert.equal(JSON.parse(reply.text).delivered, true);
assert.equal(f.captures.length, 1);
assert.match(f.captures[0].args.at(-1), /^hi\n/);
});
// M3: the Console reads its board in-process. No request leaves the Console for a board URL.
test('a board read makes no request to a board URL (M3)', async t => {
const hits = [];
const web = await startServer({ port: 0, board: countingBoard(hits) });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
const urls = [];
const realFetch = globalThis.fetch;
globalThis.fetch = (url, opts) => { urls.push(String(url)); return realFetch(url, opts); };
t.after(() => { globalThis.fetch = realFetch; });
assert.equal((await globalThis.fetch(base + '/api/board')).status, 200);
assert.deepEqual(hits, ['index'], 'the board ran once, in-process');
assert.equal(urls.length, 1, 'the spy saw the test request');
assert.ok(urls.every(u => u.startsWith(base)), `a request left the Console: ${urls.join(', ')}`);
});
// M14: /healthz answers within 200 ms while a board read is pending. This checks the async
// path only. A read that blocks the loop (spawnSync) is not reliably caught in-process, since
// the test shares the loop; BUILD.md records that gap, and the mutant run is the real check.
test('healthz answers within 200 ms while a board read is pending (M14)', async t => {
const web = await startServer({ port: 0, board: countingBoard([], () => new Promise(() => {})) });
t.after(() => stop(web));
const base = `http://127.0.0.1:${web.address().port}`;
// stop() drops the pending socket, so this fetch rejects at teardown; its outcome is unused.
fetch(base + '/api/board').catch(() => {});
const health = await fetch(base + '/healthz', { signal: AbortSignal.timeout(200) });
assert.equal(health.status, 200);
});
@@ -252,6 +252,10 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE
assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy');
assert.match(out, /^Queue: read from the --checkout directory$/m);
assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } });
// Row 198 (M11): the copy carries control-board/src, so the in-process board answers.
const board = await get('/api/board');
assert.equal(board.status, 200, JSON.stringify(board.body));
assert.ok(Array.isArray(board.body.sessions), 'the board answers from the copy');
const settings = await get('/api/settings');
assert.equal(settings.status, 200);
assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy');
+20 -7
View File
@@ -5,7 +5,7 @@
// Every other write is still 403 read-only. Nothing here starts the real human transport.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
@@ -36,18 +36,20 @@ function post(port, { method = 'POST', path = '/api/bus/message', headers = ok,
});
}
// A board that counts what reaches it, a bus that counts its reads, and a send that records
// each call and answers from `answer`.
// A board that counts what reaches it (the in-process board's reads and writes), a bus that counts its
// reads, and a send that records each call and answers from `answer`.
async function setup(t, options = {}) {
const boardHits = [];
const upstream = createServer((req, res) => { boardHits.push(`${req.method} ${req.url}`); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); });
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
t.after(() => close(upstream));
const board = {
index: async () => { boardHits.push('index'); return { sessions: [] }; },
markSeen: async () => { boardHits.push('markSeen'); return { seen: [] }; },
reply: async () => { boardHits.push('reply'); return { status: 200, body: { delivered: true } }; },
};
const busCalls = [], sends = [], lines = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
const state = { answer: async () => ({ id: 'msg-0001' }) };
const send = async args => { sends.push(args); return state.answer(args); };
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, send, log: l => lines.push(l), ...options });
const web = await startServer({ port: 0, board, bus, preview, send, log: l => lines.push(l), ...options });
t.after(() => close(web));
return { port: web.address().port, boardHits, busCalls, sends, lines, state };
}
@@ -118,6 +120,17 @@ test('with --send every other write is still 403 read-only, and nothing reaches
assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]);
});
// Row 198 (B4): the bad-JSON refusal moved here from control-board's HTTP tests. The in-process board
// answers 400 before any write, under --send and the public name.
test('a board write with a body that is not JSON is 400 under --send and the public name, and the board runs nothing', async t => {
const { port, boardHits, sends } = await setup(t);
for (const path of ['/api/seen', '/api/reply']) {
const r = await post(port, { path, body: '{not json' });
assert.equal(r.status, 400, path); assert.match(r.body.error, /JSON/, path);
}
assert.deepEqual([boardHits, sends], [[], []]);
});
test('config says send only on the public name with --send on', async t => {
const { port } = await setup(t);
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined })).body.send, true);
+17 -1
View File
@@ -809,6 +809,17 @@ console_for_target() {
local cli="$COPIES/$TO/packages/webui/src/cli.mjs"
local -a without_tester=()
sso_base
# Row 198 (design item 8): --board stays only where the target's Console parses it, and a drop is
# named in the plan and the steps. The check reads the target's own cli.mjs, as --tester-port does.
DROP_BOARD=""
local -a kept=()
for ((i = 0; i < ${#BASE_CONSOLE[@]}; i++)); do
if [ "${BASE_CONSOLE[i]}" != --board ]; then kept+=("${BASE_CONSOLE[i]}"); continue; fi
i=$((i + 1))
if parses_flag "$cli" --board; then kept+=(--board "${BASE_CONSOLE[i]:-}")
else DROP_BOARD="--board ${BASE_CONSOLE[i]:-}"; fi
done
BASE_CONSOLE=("${kept[@]}")
if grep -q 'MOSAIC_TESTER_SOCKET_V1' "$cli"; then socket_supported=1
elif grep -q -- '--tester-port' "$cli"; then supported=1; fi
for ((i = 0; i < ${#BASE_CONSOLE[@]}; i++)); do
@@ -859,6 +870,9 @@ console_for_target() {
sso_supported() { grep -q -- '--sso' "$COPIES/$TO/packages/webui/src/cli.mjs"; }
has_flag() { local f="$1"; shift; [[ " $* " == *" $f "* ]]; }
# Row 198 (design N2): true when cli $1 holds a parser branch for flag $2 that does not throw. The usage
# text and the refusal name --board too, so a bare grep would count a copy that refuses it as parsing it.
parses_flag() { grep -F -- "flag === '$2'" "$1" | grep -qv throw; }
# Row 200 (#1698, R1): a rollback from an --sso pointer to a copy whose Console has no --sso. The
# pointer no longer holds the preview set that Console ran with, so the flags come from the rollback
@@ -1438,6 +1452,7 @@ plan() {
[ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on"
echo "bus: scripts/bus-service.sh install $flags--copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
[ -z "$DROP_BOARD" ] || echo "drop $DROP_BOARD; the target Console does not parse it, so the board runs in the Console"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
[ -z "$SSO_PLAN" ] || echo "$SSO_PLAN"
@@ -1458,6 +1473,7 @@ run_steps() {
else log_line result started; fi
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$DROP_BOARD" ] || say "drop $DROP_BOARD; the target Console does not parse it, so the board runs in the Console"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
[ -z "$SSO_STEP" ] || say "$SSO_STEP"
@@ -2311,7 +2327,7 @@ STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="${BUS_TEMPLATE%.service}$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" DROP_BOARD="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET=""
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
O_ACT="" FLOOR=0