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:
1 parent
c3b60e5138
commit
b42d9cf360
13 files changed
+435
-128
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user