Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb66792e1 | ||
|
|
25565fa978 | ||
|
|
02d5bd9b47 | ||
|
|
d388f47ecf | ||
|
|
0bb5f230f6 | ||
|
|
716b46adf5 | ||
|
|
69ac924bc0 | ||
|
|
0bde5c8d7a |
No files matched your search
@@ -0,0 +1,189 @@
|
||||
# Row 198 build: retire the board writer on 7331; the Console serves the board in-process
|
||||
|
||||
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. 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.
|
||||
|
||||
## Ruled: `--board` is dropped for every op (Sage sage-2834 ruling a, sage-2837)
|
||||
|
||||
Design item 8's "keep the flag where the target parses it" part is withdrawn. `webui-service.sh
|
||||
install` has no `--board` branch (exit 4), and `c3b60e513` removed the flag from its parse_flags,
|
||||
so a kept flag fails at install-console. `install_stack` (stack-release.sh:565) runs that install on
|
||||
activate as well as rollback, so a re-pin to a pre-198 copy has the same failure. sage-2837 makes
|
||||
the rule per target, not per op.
|
||||
|
||||
Code: `console_for_target` drops every `--board` pair from `BASE_CONSOLE`, for any op. The reason
|
||||
follows the target's `cli.mjs`:
|
||||
- `parses_flag` true (pre-198 target): `drop --board <url>; the 7331 board is retired, so this
|
||||
Console runs without the board`.
|
||||
- `parses_flag` false (post-198 target): `drop --board <url>; the target Console does not parse it,
|
||||
so the board runs in the Console`.
|
||||
`DROP_BOARD_WHY` carries the reason to both print sites (plan and steps).
|
||||
|
||||
Tests: M12 unchanged (post-198 target, activate --plan). M13 now covers a kept `--board` for a target
|
||||
that parses it, on both `activate --plan` to a pre-198 copy and `rollback --plan`. Each asserts the
|
||||
drop line with the retired-board reason and no `--board` on the `Console:` install line, and that the
|
||||
plan leaves state unchanged. The README rollback note covers any move to a pre-198 copy.
|
||||
|
||||
## 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` and `DROP_BOARD_WHY` (reason by target, see the
|
||||
Ruled section), printed in the plan and the steps. Init 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
|
||||
|
||||
- `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 rollback drop line with the retired-board reason and no `--board`
|
||||
in the `Console:` install line. 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.
|
||||
- The reply join killer (B3, M16) lives in `packages/control-board/tests/serve.test.mjs`, not
|
||||
`board.test.mjs`. `reply()` refuses an unregistered row before any send, so the killer needs a
|
||||
registered fixture (`replyFixture`), which `serve.test.mjs` already builds.
|
||||
- `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 (a real board over a 1 s tmux shim:
|
||||
the loop never stalls and `/healthz` answers 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.
|
||||
|
||||
## Dropped coverage (B4), with reasons
|
||||
|
||||
- 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).
|
||||
|
||||
## 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 | a recorded `--board` is kept for a target that parses it (activate or rollback) | `stack-release.test.mjs` M13 (activate and rollback): plan drop line, then a real run: drop line in the output (rollback: steps.log), no `--board` in the unit or pointer |
|
||||
| M14 | a board read blocks the loop: `tmuxInspectAsync` in `scan.mjs` swapped for `tmuxInspect` (spawnSync) | `board-inprocess.test.mjs` M14: real `createBoard`, tmux shim on PATH sleeps 1 s; the timer gap while `/api/board` is pending and `/healthz` elapsed time |
|
||||
| M15 | `index()` joins a scan that started before the last write | `board.test.mjs` M15 (event log) |
|
||||
| M16 | `reply()` drops the counter bump: `writes++` at `board.mjs:61` (the event at line 62 is a second mutant, caught by the event log) | `serve.test.mjs` M16: event log `["scan","scan","send","bump","scan"]`, then the next `index()` must log `scan` |
|
||||
| M17 | `index()` never clears a settled scan: the `promise.then(clear, clear)` line in `board.mjs` `index()` is dropped (Webby B4, r3) | `board.test.mjs` B4: two `index()` calls with no write log `["scan","scan"]` and the second shows a session line appended between them; `board.test.mjs` B4 rejected scan: the next `index()` runs fresh. M15 and M16 still pass after the clear, so they do not kill M17 |
|
||||
|
||||
### M14: closed by the real-board test (Sage sage-2839, Webby N1)
|
||||
|
||||
The earlier gap came from a stub board: a `spawnSync` in `scan.mjs` never ran under the test. The
|
||||
M14 test now builds a real `createBoard` whose tmux check runs through a shim on PATH. The shim is
|
||||
`#!/bin/sh`, `sleep 1`, `exit 1`. The test measures two things, both by elapsed time:
|
||||
- a 10 ms timer's largest gap while `/api/board` is pending (a blocked loop stalls the timer);
|
||||
- `/healthz` elapsed time, fetched after `/api/board` is sent.
|
||||
The bound is 500 ms for each. The async path (`runAsync`) keeps both near 10 ms. The M14 mutant
|
||||
(`tmuxInspectAsync` calls `spawnSync`) stalls the loop for the shim's full second, so both checks
|
||||
fail. The real-board test is not run yet (gate). Known soft spots, to check on the first run: the
|
||||
shim must win the PATH lookup, and the `specs` entry is minimal (no registration), which may not
|
||||
match a real spec.
|
||||
|
||||
## Checks done without running anything
|
||||
|
||||
- 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.
|
||||
|
||||
## Docs (N2)
|
||||
|
||||
- `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).
|
||||
|
||||
Open, ruled sage-2868: boardSend's `/api/seen` catch returns 400 for a scan failure after writeMark. Left as built: a scan failure is not a client error, so this is a real gap. Named follow-up in Sage's post-253 DEFERRED sweep (N2).
|
||||
|
||||
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 per-target drop (sage-2834 ruling a, sage-2837) is implemented and documented; M13 has its
|
||||
expectation for activate and rollback. Pushed at 2a758a5ba (sage-2852 gap, 17:20Z).
|
||||
- Webby webby-0152/0153 and Sage sage-2839, after 2a758a5ba: B1 and N3 met at 2a758a5ba. B2 (real
|
||||
activate and rollback runs, unit and pointer checked), B3 and M16 (reply() join), N1 and M14 (real
|
||||
board over a tmux shim) are written and uncommitted; the test runs wait for the CLEAR.
|
||||
- N2 is open, not changed: `boardSend`'s `/api/seen` catch answers 400 with `err.message` for a scan
|
||||
that fails after the mark is written. Webby marked it non-blocking; Sage has not ruled on it. A
|
||||
500 for that case would need `ConfigError` to tell refusals from scan failures.
|
||||
- M14: Sage accepted the gap as recorded (sage-2834). The mutant run is the check.
|
||||
- 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.
|
||||
@@ -142,7 +142,7 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
|
||||
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
|
||||
try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);}
|
||||
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}}
|
||||
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
||||
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
|
||||
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
|
||||
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
|
||||
|
||||
@@ -851,6 +851,64 @@ test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-targe
|
||||
});
|
||||
});
|
||||
|
||||
// Row 198 (design item 8, B2; sage-2834 ruling a; sage-2837): --board is dropped for every op, because
|
||||
// webui-service.sh install refuses the flag. The reason follows the target's cli.mjs: a target that
|
||||
// parses --board is pre-198 (7331 retired); a target that names it only in its refusal runs the board
|
||||
// in the Console. The plan names each drop. 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, activate): an activate to a pre-198 copy that parses --board drops the unit\'s --board, and the install and unit have none',async(t)=>{
|
||||
// Sage sage-2837: a target that parses --board is pre-198, and webui-service.sh install still refuses the flag.
|
||||
// The unit carries the flag before, as a pre-198 Console does. The plan is checked first, then a real run.
|
||||
const e=await mk(t);await seed(e);setCli(t,C1,PARSES_BOARD);
|
||||
edit(join(e.units,CON),'--business acme',`--business acme ${BOARD}`);withBoard(e);
|
||||
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
||||
let r=await run(e,'activate',C1,'acme','--plan');
|
||||
assert.equal(r.code,0,r.out+r.err);assert.equal(r.err,'');
|
||||
const lines=r.out.split('\n');
|
||||
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.ok(r.out.split('\n').includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.equal(pointer(e).copy,C1);assert.equal(pointer(e).console.includes('--board'),false);
|
||||
assert.doesNotMatch(consoleText(e),/--board/);
|
||||
});
|
||||
|
||||
test('row 198 item 8 (M13, rollback): a rollback drops the unit\'s --board even for a copy that parses it, and the install and unit have none',async(t)=>{
|
||||
// Sage sage-2834 ruling (a), sage-2837: webui-service.sh install refuses --board, so a rollback drops it and
|
||||
// names the drop with the retired-board reason. The activation runs without --board; the unit then carries it,
|
||||
// and the rollback target B parses it, which must not keep the flag. The plan is checked first, then a real run.
|
||||
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);
|
||||
const before=fp(e);
|
||||
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
|
||||
const lines=r.out.split('\n');
|
||||
assert.ok(lines.includes(`drop ${BOARD}; the 7331 board is retired, so this Console runs without the board`),r.out);
|
||||
assert.doesNotMatch(lines.find((l)=>l.startsWith('Console: ')),/--board/);
|
||||
assert.deepEqual(fp(e),before);
|
||||
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.equal(pointer(e).console.includes('--board'),false);
|
||||
assert.doesNotMatch(consoleText(e),/--board/);
|
||||
assert.match(stepText(e),/drop --board http:\/\/127\.0\.0\.1:9; the 7331 board is retired/);
|
||||
});
|
||||
|
||||
// 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// The control board's routes as plain async functions (row 198). The Console
|
||||
// (packages/webui) calls these in-process behind its own auth. Nothing here
|
||||
// opens a socket or binds a port: the 7331 writer is retired.
|
||||
//
|
||||
// Every method returns a promise and uses the async child-process paths (B1), so
|
||||
// a slow tmux call never blocks the Console's event loop.
|
||||
//
|
||||
// Write counter (decision 10, Webby N1). A scan must not serve a pre-write view.
|
||||
// index() joins an in-flight scan only when that scan started at the current
|
||||
// counter value. markSeen() and reply() bump the counter and never join a scan.
|
||||
|
||||
import { scanAsync, markSeen as writeMark, loadRegistrations, runAsync } from "./scan.mjs";
|
||||
import { replyToRowAsync, conversationResponse, DEFAULT_AGENT_SEND } from "./serve.mjs";
|
||||
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, 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, isPidAlive, exec, boardDir, now, seatsDir, discordDataRoot });
|
||||
};
|
||||
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
|
||||
|
||||
// Joins a scan only when it started after the last write and is still running
|
||||
// (see header). A settled scan, fulfilled or rejected, is cleared so the next
|
||||
// index() starts a fresh one; the clear is on both branches, so a rejection is
|
||||
// not left unhandled.
|
||||
function index() {
|
||||
if (inflight && inflight.generation === writes) return inflight.promise;
|
||||
const generation = writes;
|
||||
const promise = scanFresh();
|
||||
const current = { generation, promise };
|
||||
inflight = current;
|
||||
const clear = () => { if (inflight === current) inflight = null; };
|
||||
promise.then(clear, clear);
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Pinned order (Webby, Mos mos-1052): write, then bump the counter, then scan.
|
||||
// The scan sees the mark, and no scan started before the bump can be joined.
|
||||
async function markSeenRoute(body) {
|
||||
writeMark(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
|
||||
onEvent("write");
|
||||
writes++;
|
||||
onEvent("bump");
|
||||
return scanFresh();
|
||||
}
|
||||
|
||||
// Pinned order: send, then bump the counter, then scan. The planning scan
|
||||
// before the send is a read and is not part of the pinned sequence. A refusal
|
||||
// runs no send, so it writes nothing: no bump and no post-send scan. Only a
|
||||
// send that reached the exec runs the rest. The post-send scan rewrites the
|
||||
// derived board files, so a reply's effects reach them the same way a mark does.
|
||||
async function reply(body) {
|
||||
const planned = await scanFresh();
|
||||
let sent = false;
|
||||
const sendExec = (...args) => {
|
||||
sent = true;
|
||||
return exec(...args);
|
||||
};
|
||||
const out = await replyToRowAsync({ index: planned, key: body.agent, text: body.text, agentSend, exec: sendExec, now, host });
|
||||
if (!sent) return out;
|
||||
onEvent("send");
|
||||
writes++;
|
||||
onEvent("bump");
|
||||
await scanFresh();
|
||||
return out;
|
||||
}
|
||||
|
||||
function conversation(url) {
|
||||
return conversationResponse(reader, url);
|
||||
}
|
||||
|
||||
return { index, markSeen: markSeenRoute, reply, conversation };
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
// Usage:
|
||||
// node packages/control-board/src/cli.mjs scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]
|
||||
// node packages/control-board/src/cli.mjs serve [same flags] [--port N] [--host 127.0.0.1]
|
||||
// `serve` is retired (row 198): the board runs in-process inside the Console.
|
||||
// Exit 0 on success, 2 on a config refusal or bad usage.
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxInspect, ConfigError } from "./scan.mjs";
|
||||
import { startServer } from "./serve.mjs";
|
||||
|
||||
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
|
||||
const USAGE = "usage: mosaic-board scan [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print]";
|
||||
const RETIRED = "the control board no longer serves a port (retired on 7331, row 198). Open the board in the Console (webui, port 7332) instead.";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false, port: 7331, host: "127.0.0.1" };
|
||||
if (argv[0] === "serve") throw new ConfigError(RETIRED);
|
||||
const opts = { command: argv[0], config: defaultConfigPath(), repo: process.cwd(), fleet: join(homedir(), ".mosaic", "fleet", "agents"), liveness: "tmux", print: false };
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
const next = () => {
|
||||
@@ -23,13 +24,9 @@ function parseArgs(argv) {
|
||||
else if (a === "--fleet") opts.fleet = next();
|
||||
else if (a === "--liveness") opts.liveness = next();
|
||||
else if (a === "--print") opts.print = true;
|
||||
else if (a === "--port") {
|
||||
opts.port = Number(next());
|
||||
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) throw new ConfigError("--port must be an integer 0..65535");
|
||||
} else if (a === "--host") opts.host = next();
|
||||
else throw new ConfigError(`unknown argument: ${a}`);
|
||||
}
|
||||
if (opts.command !== "scan" && opts.command !== "serve") throw new ConfigError(USAGE);
|
||||
if (opts.command !== "scan") throw new ConfigError(USAGE);
|
||||
if (!["tmux", "assume-alive"].includes(opts.liveness)) throw new ConfigError(`unknown liveness mode: ${opts.liveness}`);
|
||||
return opts;
|
||||
}
|
||||
@@ -41,15 +38,6 @@ async function main() {
|
||||
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
|
||||
const boardDir = join(dataRoot, "board");
|
||||
const seatsDir = join(dataRoot, "seats");
|
||||
if (opts.command === "serve") {
|
||||
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
const addr = server.address();
|
||||
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
|
||||
const stop = () => server.close(() => process.exit(0));
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
return;
|
||||
}
|
||||
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
|
||||
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
|
||||
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Control board</title>
|
||||
<style>
|
||||
:root{
|
||||
--canvas:#f4f6f8;--surface:#fff;--raised:#e9edf1;--text:#16202a;--muted:#5a6774;
|
||||
--line:#e2e7ec;--action:#1f5f8b;--onAction:#fff;--accent:#c96a2b;
|
||||
--success:#2e7d4f;--warning:#b26a00;--danger:#b3261e;
|
||||
--font:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
||||
--radius:8px;--radius-lg:14px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--canvas:#11161c;--surface:#182029;--raised:#1f2833;--text:#e7edf3;--muted:#94a3b0;
|
||||
--line:#2a343f;--action:#6ba6d6;--onAction:#0b1116;--accent:#e08a4c;
|
||||
--success:#57b586;--warning:#e0a63c;--danger:#e58077;
|
||||
}
|
||||
}
|
||||
*,*::before,*::after{box-sizing:border-box}
|
||||
html{font-family:var(--font);background:var(--canvas);color:var(--text);line-height:1.45}
|
||||
body{margin:0 auto;max-width:1100px;padding:16px}
|
||||
h1,h2,h3{margin:0 0 .4em;line-height:1.2;font-weight:600}
|
||||
h1{font-size:1.5rem}h2{font-size:1.1rem;margin-top:1.4em}h3{font-size:.95rem}
|
||||
:focus-visible{outline:3px solid var(--action);outline-offset:2px;border-radius:4px}
|
||||
.page-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px}
|
||||
.head-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.status-line{margin:0;color:var(--muted);font-size:.9rem}
|
||||
.btn{font:inherit;font-weight:600;font-size:.85rem;min-height:34px;padding:6px 12px;border-radius:var(--radius);
|
||||
border:1px solid var(--line);background:var(--surface);color:var(--text);cursor:pointer}
|
||||
.btn:hover{background:var(--raised)}
|
||||
.error-banner{margin:12px 0;padding:10px 14px;border-radius:var(--radius);border:1px solid var(--danger);
|
||||
background:color-mix(in srgb,var(--danger) 10%,var(--surface));color:var(--danger);font-size:.9rem}
|
||||
.empty{color:var(--muted);font-style:italic}
|
||||
#seenDetails summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:8px}
|
||||
#seenDetails summary::-webkit-details-marker{display:none}
|
||||
#seenDetails summary::before{content:"\25B8";color:var(--muted);font-size:.9em}
|
||||
#seenDetails[open] summary::before{content:"\25BE"}
|
||||
#seenDetails summary h2{display:inline;margin:0}
|
||||
.table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:var(--radius-lg);background:var(--surface)}
|
||||
table{width:100%;border-collapse:collapse;font-size:.88rem}
|
||||
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600;white-space:nowrap}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
tr.is-offline{opacity:.55}
|
||||
.badge{display:inline-flex;align-items:center;gap:5px;font-size:.76rem;font-weight:600;padding:2px 9px;
|
||||
border-radius:99px;border:1px solid transparent;white-space:nowrap}
|
||||
.badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
|
||||
.badge-ok{color:var(--success);background:color-mix(in srgb,var(--success) 12%,var(--surface))}
|
||||
.badge-warn{color:var(--warning);background:color-mix(in srgb,var(--warning) 12%,var(--surface))}
|
||||
.badge-danger{color:var(--danger);background:color-mix(in srgb,var(--danger) 12%,var(--surface))}
|
||||
.badge-muted{color:var(--muted);background:var(--raised)}
|
||||
.badge-accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,var(--surface))}
|
||||
.row-toggle{font:inherit;font-weight:600;background:none;border:0;padding:2px 0;color:var(--action);
|
||||
cursor:pointer;text-align:left}
|
||||
.row-toggle:hover{text-decoration:underline}
|
||||
.seen-toggle{font:inherit;font-size:.76rem;background:none;border:1px solid var(--border);border-radius:6px;
|
||||
padding:1px 8px;color:var(--action);cursor:pointer;margin-left:6px;vertical-align:middle}
|
||||
.seen-toggle:hover{border-color:var(--action)}
|
||||
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
|
||||
.msg-error{color:var(--danger)}
|
||||
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
|
||||
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
|
||||
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.unknown{color:var(--muted);font-style:italic}
|
||||
.reply-form{display:flex;gap:.4em;align-items:center;max-width:60ch}
|
||||
.reply-form input{flex:1;min-width:12ch;font:inherit;padding:.2em .4em}
|
||||
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
|
||||
.reply-ok{color:var(--muted)}
|
||||
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
|
||||
.detail-row td{background:var(--raised)}
|
||||
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
|
||||
.detail-list dt{color:var(--muted);font-family:var(--font)}
|
||||
.detail-list dd{margin:0;overflow-wrap:anywhere}
|
||||
.project-group{margin-bottom:20px}
|
||||
.project-group-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
|
||||
.group-toggles{display:flex;flex-wrap:wrap;gap:14px}
|
||||
.project-group-head label{font-size:.85rem;color:var(--muted);display:flex;align-items:center;gap:6px}
|
||||
.offline-note{margin:6px 0 0;font-size:.82rem;color:var(--muted)}
|
||||
.page-footer{margin-top:24px;padding-top:12px;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-head">
|
||||
<h1>Control board</h1>
|
||||
<div class="head-controls">
|
||||
<p id="headerStatus" class="status-line" aria-live="polite">Updated never · next refresh in 10s</p>
|
||||
<button id="refreshBtn" class="btn" type="button">Refresh</button>
|
||||
<button id="pauseBtn" class="btn" type="button" aria-pressed="false">Pause</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="errorBanner" class="error-banner" role="alert" hidden></div>
|
||||
<main id="main">
|
||||
<section aria-labelledby="waiting-h">
|
||||
<h2 id="waiting-h">Waiting on you</h2>
|
||||
<div id="waitingBody"></div>
|
||||
</section>
|
||||
<section aria-labelledby="seen-h">
|
||||
<details id="seenDetails">
|
||||
<summary><h2 id="seen-h">Seen</h2></summary>
|
||||
<div id="seenBody"></div>
|
||||
</details>
|
||||
</section>
|
||||
<section aria-labelledby="projects-h">
|
||||
<h2 id="projects-h">By project</h2>
|
||||
<div id="projectsBody"></div>
|
||||
</section>
|
||||
</main>
|
||||
<footer id="footer" class="page-footer"></footer>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
var STATES = ["working", "waiting", "error", "offline", "idle", "unknown"];
|
||||
var BADGE_CLASS = { waiting: "badge-warn", error: "badge-danger", working: "badge-ok", offline: "badge-muted", idle: "badge-muted", unknown: "badge-accent" };
|
||||
var REFRESH_MS = 10000;
|
||||
|
||||
var headerStatus = document.getElementById("headerStatus");
|
||||
var refreshBtn = document.getElementById("refreshBtn");
|
||||
var pauseBtn = document.getElementById("pauseBtn");
|
||||
var errorBanner = document.getElementById("errorBanner");
|
||||
var waitingBody = document.getElementById("waitingBody");
|
||||
var seenBody = document.getElementById("seenBody");
|
||||
var seenHeading = document.getElementById("seen-h");
|
||||
var projectsBody = document.getElementById("projectsBody");
|
||||
var footer = document.getElementById("footer");
|
||||
var main = document.getElementById("main");
|
||||
|
||||
var lastData = null;
|
||||
var paused = false;
|
||||
var fetching = false;
|
||||
var timerId = null;
|
||||
var secondsLeft = REFRESH_MS / 1000;
|
||||
var hideOfflineState = {};
|
||||
var hideSeenState = {};
|
||||
// Open detail panels survive a refresh. Keyed by section, project and agent
|
||||
// because the same agent can appear in both the waiting list and its group.
|
||||
var openDetails = {};
|
||||
var rowIdx = 0;
|
||||
|
||||
function esc(v) {
|
||||
if (v === null || v === undefined) return "";
|
||||
return String(v).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function humanAge(s) {
|
||||
if (s === null || s === undefined) return "—";
|
||||
if (s < 60) return s + "s";
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + "m";
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + "h";
|
||||
return Math.floor(h / 24) + "d";
|
||||
}
|
||||
|
||||
function timeAgo(iso) {
|
||||
if (!iso) return "never";
|
||||
var ms = Date.now() - Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return "never";
|
||||
var s = Math.floor(ms / 1000);
|
||||
if (s < 5) return "just now";
|
||||
if (s < 60) return s + "s ago";
|
||||
var m = Math.floor(s / 60);
|
||||
if (m < 60) return m + "m ago";
|
||||
var h = Math.floor(m / 60);
|
||||
if (h < 24) return h + "h ago";
|
||||
return Math.floor(h / 24) + "d ago";
|
||||
}
|
||||
|
||||
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
||||
|
||||
function badge(state) {
|
||||
var cls = BADGE_CLASS[state] || "badge-muted";
|
||||
return '<span class="badge ' + cls + '">' + esc(state) + "</span>";
|
||||
}
|
||||
|
||||
// A "Seen" button on rows that need you; a "seen" tag plus "Unsee" on rows
|
||||
// you have already marked. The mark clears itself when the agent writes again.
|
||||
function seenControl(rec) {
|
||||
var needsYou = rec.state === "waiting" || rec.state === "error";
|
||||
if (!needsYou || !rec.lastActivity) return "";
|
||||
var attrs = ' data-project="' + esc(rec.project) + '" data-agent="' + esc(rec.agent) + '" data-last="' + esc(rec.lastActivity) + '"';
|
||||
if (rec.seen) {
|
||||
return '<span class="seen-tag">seen</span><button type="button" class="seen-toggle" data-seen="false"' + attrs + ' title="Put this row back under Waiting on you">Unsee</button>';
|
||||
}
|
||||
return '<button type="button" class="seen-toggle" data-seen="true"' + attrs + ' title="I have read this; hide it from Waiting on you until the agent writes again">Seen</button>';
|
||||
}
|
||||
|
||||
var SOURCE_LABEL = { "registration": "registered", "first-user-message": "first message", "tmux-pane": "tmux pane", "session-cwd": "session cwd", "workspace-git-root": "git root" };
|
||||
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
|
||||
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
|
||||
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
|
||||
// Who set a registered task (#1511): shown only when the task on the row is
|
||||
// the registered one (the scan gives null otherwise). It is what the
|
||||
// caller claimed when it ran `mosaic seat task`, not a verified identity,
|
||||
// and it never changes what the row lets you do.
|
||||
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
|
||||
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
|
||||
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
|
||||
// hover title. modelText() already escapes, so the title takes it as is.
|
||||
// Empty when the log has not named one yet.
|
||||
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
|
||||
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
|
||||
// Reply-from-board (#1505): a box and Send only where the board has an
|
||||
// address, i.e. a live registration naming a tmux session. Receipts and
|
||||
// unsent drafts are kept per row id so a refresh does not lose them.
|
||||
var replyReceipts = {};
|
||||
var replyDrafts = {};
|
||||
function canReply(rec) {
|
||||
if (rec.connector) return false;
|
||||
var reg = rec.registered;
|
||||
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
|
||||
}
|
||||
function replyReceipt(id) {
|
||||
var r = replyReceipts[id];
|
||||
if (!r) return "";
|
||||
if (r.delivered) return '<span class="reply-receipt reply-ok">delivered ' + esc(r.sentAt) + " to tmux " + esc(r.session) + "</span>";
|
||||
return '<span class="reply-receipt msg-error" style="white-space:normal;max-width:none">failed' + (r.exitCode === null || r.exitCode === undefined ? "" : " (exit " + esc(r.exitCode) + ")") + ": " + esc(r.stderr || r.error || "no output") + "</span>";
|
||||
}
|
||||
function replyControl(rec) {
|
||||
var id = [rec.project, rec.agent].join("/");
|
||||
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
|
||||
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
|
||||
return '<form class="reply-form" data-id="' + esc(id) + '">' +
|
||||
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
|
||||
'<button type="submit">Send</button></form>' + replyReceipt(id);
|
||||
}
|
||||
function registeredText(reg) {
|
||||
if (!reg) return "no (not started through mosaic launch)";
|
||||
var parts = ["started " + esc(reg.startedAt || "—")];
|
||||
if (reg.updatedAt) parts.push("task updated " + esc(reg.updatedAt));
|
||||
if (reg.harness) parts.push("harness " + esc(reg.harness));
|
||||
if (reg.pid) parts.push("pid " + esc(reg.pid));
|
||||
if (reg.tmux && reg.tmux.session) parts.push("tmux " + esc(reg.tmux.session) + (reg.tmux.socket ? " (socket " + esc(reg.tmux.socket) + ")" : ""));
|
||||
if (reg.layout) parts.push(esc(reg.layout) + " layout");
|
||||
if (reg.alive === false) parts.push("stale: pid " + esc(reg.pid) + " is gone, so the derived values are shown");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
|
||||
function buildRowPair(rec, showProject) {
|
||||
var idx = rowIdx++;
|
||||
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
|
||||
var open = !!openDetails[key];
|
||||
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
|
||||
var notice = relaunchNotice(rec);
|
||||
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
|
||||
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
|
||||
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
|
||||
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
|
||||
// Gate A fields: from the seat's registration (mosaic launch), else derived
|
||||
// from the log and tmux, else "unknown". Never guessed. The source tag on
|
||||
// the row says which one is shown.
|
||||
var task = rec.task
|
||||
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
|
||||
: '<span class="unknown">unknown</span>';
|
||||
var activeProject = rec.activeProject
|
||||
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
|
||||
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
|
||||
var main =
|
||||
'<tr class="' + cls + '">' +
|
||||
projectCell +
|
||||
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
|
||||
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
|
||||
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
|
||||
"<td>" + task + "</td>" +
|
||||
"<td>" + activeProject + "</td>" +
|
||||
"<td>" + msg + "</td>" +
|
||||
"</tr>";
|
||||
var span = showProject ? 7 : 6;
|
||||
var tmux = rec.tmux && rec.tmux.session
|
||||
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
|
||||
: "—";
|
||||
var detail =
|
||||
'<tr class="detail-row" id="detail-' + idx + '"' + (open ? "" : " hidden") + '><td colspan="' + span + '">' +
|
||||
'<dl class="detail-list">' +
|
||||
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
|
||||
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
|
||||
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
|
||||
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
|
||||
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
|
||||
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
|
||||
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
|
||||
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
|
||||
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
|
||||
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
|
||||
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
|
||||
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
|
||||
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
|
||||
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
|
||||
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
|
||||
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
|
||||
"</dl></td></tr>";
|
||||
return main + detail;
|
||||
}
|
||||
|
||||
// Rows you marked Seen, in one place, so they can be found and put back.
|
||||
// Collapsed by default; the open/closed choice survives a refresh.
|
||||
function renderSeen(data) {
|
||||
var list = (data.sessions || []).filter(function (r) { return r.seen; });
|
||||
list.sort(function (a, b) {
|
||||
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
||||
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
||||
return aa - bb;
|
||||
});
|
||||
seenHeading.textContent = "Seen (" + list.length + ")";
|
||||
if (list.length === 0) {
|
||||
seenBody.innerHTML = '<p class="empty">Nothing is marked seen. Rows you mark come back here, and return to "Waiting on you" on their own when the agent writes again.</p>';
|
||||
return;
|
||||
}
|
||||
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
||||
seenBody.innerHTML =
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
||||
}
|
||||
|
||||
function renderWaiting(data) {
|
||||
var list = (data.sessions || []).filter(function (r) { return r.waitingOnYou; });
|
||||
list.sort(function (a, b) {
|
||||
var ae = a.state === "error" ? 0 : 1, be = b.state === "error" ? 0 : 1;
|
||||
if (ae !== be) return ae - be;
|
||||
var aa = a.ageSeconds === null || a.ageSeconds === undefined ? Infinity : a.ageSeconds;
|
||||
var bb = b.ageSeconds === null || b.ageSeconds === undefined ? Infinity : b.ageSeconds;
|
||||
return aa - bb;
|
||||
});
|
||||
if (list.length === 0) {
|
||||
waitingBody.innerHTML = '<p class="empty">Nothing is waiting on you.</p>';
|
||||
return;
|
||||
}
|
||||
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
|
||||
waitingBody.innerHTML =
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
|
||||
}
|
||||
|
||||
function projectOrder(a, b) {
|
||||
if (a === "fleet" && b === "fleet") return 0;
|
||||
if (a === "fleet") return 1;
|
||||
if (b === "fleet") return -1;
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
function renderProjects(data) {
|
||||
var sessions = data.sessions || [];
|
||||
var byProject = {};
|
||||
sessions.forEach(function (r) {
|
||||
(byProject[r.project] = byProject[r.project] || []).push(r);
|
||||
});
|
||||
var projects = Object.keys(byProject).sort(projectOrder);
|
||||
if (projects.length === 0) {
|
||||
projectsBody.innerHTML = '<p class="empty">No agents are configured.</p>';
|
||||
return;
|
||||
}
|
||||
projectsBody.innerHTML = projects.map(function (project) {
|
||||
var group = byProject[project].slice().sort(function (a, b) { return a.agent.localeCompare(b.agent); });
|
||||
if (!(project in hideOfflineState)) hideOfflineState[project] = true;
|
||||
if (!(project in hideSeenState)) hideSeenState[project] = true;
|
||||
var hideOffline = hideOfflineState[project];
|
||||
var hideSeen = hideSeenState[project];
|
||||
var offlineHidden = 0, seenHidden = 0;
|
||||
var visible = group.filter(function (r) {
|
||||
if (hideOffline && r.state === "offline") { offlineHidden += 1; return false; }
|
||||
if (hideSeen && r.seen) { seenHidden += 1; return false; }
|
||||
return true;
|
||||
});
|
||||
var rows = visible.map(function (r) { return buildRowPair(r, false); }).join("");
|
||||
var noteParts = [];
|
||||
if (offlineHidden > 0) noteParts.push(offlineHidden + " offline hidden");
|
||||
if (seenHidden > 0) noteParts.push(seenHidden + " seen hidden");
|
||||
var note = noteParts.length > 0 ? '<p class="offline-note">' + noteParts.join(" \u00b7 ") + "</p>" : "";
|
||||
var headCount = visible.length < group.length ? visible.length + " of " + group.length : String(group.length);
|
||||
return (
|
||||
'<div class="project-group">' +
|
||||
'<div class="project-group-head"><h3>' + esc(project) + ' <span>(' + headCount + ")</span></h3>" +
|
||||
'<div class="group-toggles">' +
|
||||
'<label><input type="checkbox" class="hide-offline-toggle" data-project="' + esc(project) + '" ' + (hideOffline ? "checked" : "") + "> Hide offline</label>" +
|
||||
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
|
||||
"</div></div>" +
|
||||
'<div class="table-wrap"><table><thead><tr>' +
|
||||
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
|
||||
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
|
||||
note + "</div>"
|
||||
);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
|
||||
function renderFooter(data) {
|
||||
var counts = data.counts || {};
|
||||
var parts = STATES.map(function (s) { return cap(s) + " " + (counts[s] || 0); }).join(" · ");
|
||||
parts += " · Seen " + ((data.seen || []).length);
|
||||
footer.innerHTML = "<p>" + esc(parts) + " — generated " + esc(timeAgo(data.generatedAt)) + " (" + esc(data.generatedAt || "") + ")</p>";
|
||||
}
|
||||
|
||||
// A refresh rebuilds the tables. If Jason is typing a reply, put the
|
||||
// caret back where it was so the periodic refresh does not steal it.
|
||||
function withReplyFocus(render) {
|
||||
var active = document.activeElement;
|
||||
var focus = active && active.classList && active.classList.contains("reply-text")
|
||||
? { id: active.form.dataset.id, pos: active.selectionStart }
|
||||
: null;
|
||||
render();
|
||||
if (!focus) return;
|
||||
var forms = main.querySelectorAll('.reply-form[data-id="' + focus.id.replace(/"/g, '\\"') + '"]');
|
||||
var input = forms.length ? forms[0].querySelector(".reply-text") : null;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
try { input.setSelectionRange(focus.pos, focus.pos); } catch (e) {}
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
withReplyFocus(function () {
|
||||
rowIdx = 0;
|
||||
renderWaiting(lastData);
|
||||
renderSeen(lastData);
|
||||
renderProjects(lastData);
|
||||
renderFooter(lastData);
|
||||
});
|
||||
}
|
||||
|
||||
function renderHeaderStatus() {
|
||||
var updated = lastData ? timeAgo(lastData.generatedAt) : "never";
|
||||
var refresh = paused ? "auto-refresh paused" : "next refresh in " + secondsLeft + "s";
|
||||
headerStatus.textContent = "Updated " + updated + " · " + refresh;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errorBanner.hidden = false;
|
||||
errorBanner.textContent = "Could not refresh: " + msg + ". Showing the last known data.";
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
errorBanner.hidden = true;
|
||||
errorBanner.textContent = "";
|
||||
}
|
||||
|
||||
function scheduleNextFetch() {
|
||||
clearTimeout(timerId);
|
||||
if (paused) return;
|
||||
secondsLeft = REFRESH_MS / 1000;
|
||||
timerId = setTimeout(runFetch, REFRESH_MS);
|
||||
}
|
||||
|
||||
function runFetch() {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
fetch("/api/board", { cache: "no-store" })
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
lastData = body;
|
||||
hideError();
|
||||
renderAll();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("control board refresh failed", err);
|
||||
showError(err && err.message ? err.message : "unknown error");
|
||||
})
|
||||
.then(function () {
|
||||
fetching = false;
|
||||
renderHeaderStatus();
|
||||
scheduleNextFetch();
|
||||
});
|
||||
}
|
||||
|
||||
function setPaused(p) {
|
||||
paused = p;
|
||||
pauseBtn.textContent = paused ? "Resume" : "Pause";
|
||||
pauseBtn.setAttribute("aria-pressed", String(paused));
|
||||
if (paused) clearTimeout(timerId);
|
||||
else scheduleNextFetch();
|
||||
renderHeaderStatus();
|
||||
}
|
||||
|
||||
function postSeen(btn) {
|
||||
if (fetching) return;
|
||||
fetching = true;
|
||||
btn.disabled = true;
|
||||
fetch("/api/seen", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ project: btn.dataset.project, agent: btn.dataset.agent, lastActivity: btn.dataset.last, seen: btn.dataset.seen === "true" })
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
lastData = body;
|
||||
hideError();
|
||||
renderAll();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("marking seen failed", err);
|
||||
showError(err && err.message ? err.message : "unknown error");
|
||||
btn.disabled = false;
|
||||
})
|
||||
.then(function () {
|
||||
fetching = false;
|
||||
renderHeaderStatus();
|
||||
scheduleNextFetch();
|
||||
});
|
||||
}
|
||||
|
||||
function postReply(form) {
|
||||
var id = form.dataset.id;
|
||||
var input = form.querySelector(".reply-text");
|
||||
var text = input.value;
|
||||
if (!text.trim()) return;
|
||||
var sendBtn = form.querySelector("button");
|
||||
sendBtn.disabled = true;
|
||||
fetch("/api/reply", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ agent: id, text: text })
|
||||
})
|
||||
.then(function (res) {
|
||||
return res.json().catch(function () {
|
||||
throw new Error("the server sent a response that was not valid JSON");
|
||||
}).then(function (body) {
|
||||
if (!res.ok) throw new Error((body && body.error) || "HTTP " + res.status);
|
||||
return body;
|
||||
});
|
||||
})
|
||||
.then(function (body) {
|
||||
replyReceipts[id] = body;
|
||||
if (body.delivered) delete replyDrafts[id];
|
||||
renderAll();
|
||||
// The seat's log shows the effect; fetch now instead of waiting a tick.
|
||||
if (body.delivered && !paused) runFetch();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("reply failed", err);
|
||||
replyReceipts[id] = { delivered: false, exitCode: null, error: err && err.message ? err.message : "unknown error" };
|
||||
renderAll();
|
||||
});
|
||||
}
|
||||
|
||||
main.addEventListener("submit", function (e) {
|
||||
var form = e.target.closest(".reply-form");
|
||||
if (!form) return;
|
||||
e.preventDefault();
|
||||
postReply(form);
|
||||
});
|
||||
|
||||
main.addEventListener("input", function (e) {
|
||||
var input = e.target.closest(".reply-text");
|
||||
if (!input) return;
|
||||
replyDrafts[input.form.dataset.id] = input.value;
|
||||
});
|
||||
|
||||
main.addEventListener("click", function (e) {
|
||||
var seenBtn = e.target.closest(".seen-toggle");
|
||||
if (seenBtn) return postSeen(seenBtn);
|
||||
var btn = e.target.closest(".row-toggle");
|
||||
if (!btn) return;
|
||||
var detail = document.getElementById("detail-" + btn.dataset.idx);
|
||||
var expanded = btn.getAttribute("aria-expanded") === "true";
|
||||
btn.setAttribute("aria-expanded", String(!expanded));
|
||||
detail.hidden = expanded;
|
||||
if (expanded) delete openDetails[btn.dataset.key];
|
||||
else openDetails[btn.dataset.key] = true;
|
||||
});
|
||||
|
||||
main.addEventListener("change", function (e) {
|
||||
var cb = e.target.closest(".hide-offline-toggle, .hide-seen-toggle");
|
||||
if (!cb) return;
|
||||
var stateMap = cb.classList.contains("hide-seen-toggle") ? hideSeenState : hideOfflineState;
|
||||
stateMap[cb.dataset.project] = cb.checked;
|
||||
renderProjects(lastData);
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener("click", runFetch);
|
||||
pauseBtn.addEventListener("click", function () { setPaused(!paused); });
|
||||
|
||||
setInterval(function () {
|
||||
if (!paused && secondsLeft > 0) secondsLeft -= 1;
|
||||
renderHeaderStatus();
|
||||
}, 1000);
|
||||
|
||||
renderHeaderStatus();
|
||||
runFetch();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,7 @@
|
||||
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnSync, execFile } from "node:child_process";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
|
||||
|
||||
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
|
||||
@@ -193,17 +193,46 @@ export function parsePanes(listPanesOutput) {
|
||||
// "unknown", never assumed alive).
|
||||
// workspace the current path of the first pane running pi, else null.
|
||||
// `exec` is injectable for tests.
|
||||
export function tmuxInspect({ socket, session }, { exec = spawnSync } = {}) {
|
||||
export function tmuxInspect(tmux, { exec = spawnSync } = {}) {
|
||||
return inspectFromResult(exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
|
||||
}
|
||||
|
||||
const TMUX_TIMEOUT_MS = 5000;
|
||||
|
||||
function tmuxArgs({ socket, session }) {
|
||||
const args = [];
|
||||
if (socket) args.push("-L", socket);
|
||||
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}");
|
||||
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
|
||||
return args;
|
||||
}
|
||||
|
||||
// Shared by the sync and async paths, so they cannot drift.
|
||||
function inspectFromResult(r) {
|
||||
if (r.error) return { alive: null, workspace: null };
|
||||
if (r.status !== 0) return { alive: false, workspace: null };
|
||||
const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command));
|
||||
return { alive: Boolean(pane), workspace: pane?.path ?? null };
|
||||
}
|
||||
|
||||
// Async child run for the Console's in-process board (B1). Same result shape as
|
||||
// spawnSync: { status, signal, stdout, stderr, error }. A timeout or a failure to
|
||||
// start is an error, never a status. The Console's event loop must not block here.
|
||||
export function runAsync(file, args, { encoding = "utf8", timeout, env } = {}) {
|
||||
return new Promise((resolvePromise) => {
|
||||
execFile(file, args, { encoding, timeout, env, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
|
||||
if (!err) return resolvePromise({ status: 0, signal: null, stdout, stderr, error: undefined });
|
||||
if (err.killed) return resolvePromise({ status: null, signal: err.signal ?? null, stdout, stderr, error: Object.assign(new Error(`timed out after ${timeout} ms`), { code: "ETIMEDOUT" }) });
|
||||
if (typeof err.code === "number") return resolvePromise({ status: err.code, signal: null, stdout, stderr, error: undefined });
|
||||
return resolvePromise({ status: null, signal: null, stdout, stderr, error: err });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Async twin of tmuxInspect. `exec` is injectable for tests.
|
||||
export async function tmuxInspectAsync(tmux, { exec = runAsync } = {}) {
|
||||
return inspectFromResult(await exec("tmux", tmuxArgs(tmux), { encoding: "utf8", timeout: TMUX_TIMEOUT_MS }));
|
||||
}
|
||||
|
||||
// Liveness only, for callers that do not need the pane path.
|
||||
export function tmuxIsAlive(tmux, opts) {
|
||||
return tmuxInspect(tmux, opts).alive;
|
||||
@@ -462,3 +491,16 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
|
||||
writeAtomic(join(boardDir, "index.json"), index);
|
||||
return index;
|
||||
}
|
||||
|
||||
// Async scan for the Console's in-process board (B1). With no isAlive, every tmux
|
||||
// check runs through the async exec first, in parallel, and the sync scan body
|
||||
// then reads those answers. A caller's sync isAlive (the CLI, tests) is used as is.
|
||||
// One scan body serves both paths, so the records cannot drift.
|
||||
export async function scanAsync(specs, { isAlive, exec = runAsync, ...rest } = {}) {
|
||||
if (isAlive) return scan(specs, { ...rest, isAlive });
|
||||
const checks = new Map();
|
||||
const keyOf = (tmux) => JSON.stringify([tmux.socket ?? null, tmux.session]);
|
||||
const pending = [...new Map(specs.filter((s) => !s.connector).map((s) => [keyOf(s.tmux), s.tmux])).values()];
|
||||
await Promise.all(pending.map(async (tmux) => checks.set(keyOf(tmux), await tmuxInspectAsync(tmux, { exec }))));
|
||||
return scan(specs, { ...rest, isAlive: (tmux) => checks.get(keyOf(tmux)) ?? { alive: null, workspace: null } });
|
||||
}
|
||||
@@ -1,55 +1,30 @@
|
||||
// Control board step 2: a tiny local web server. No dependencies, no auth.
|
||||
// It only ever binds to a loopback address (fail closed otherwise).
|
||||
// Control board: the pure board logic the Console calls in-process (row 198).
|
||||
// No listener. The writer on 7331 is retired; the Console serves the board
|
||||
// routes behind its own auth (packages/webui/src/serve.mjs). The board opens no
|
||||
// socket and never binds a port.
|
||||
//
|
||||
// GET / the page (src/page.html)
|
||||
// GET /api/board re-runs the scanner and returns index.json as JSON
|
||||
// POST /api/seen {project, agent, lastActivity, seen?} marks a row as seen
|
||||
// (or clears the mark with seen:false); rescans, returns index
|
||||
// POST /api/reply {agent: "<project>/<agent>", text} delivers text to the
|
||||
// seat's tmux pane through tools/tmux/agent-send.sh (#1505);
|
||||
// answers {delivered, exitCode, stdout, stderr, ...}
|
||||
// GET /healthz {"ok":true}
|
||||
// GET /api/conversations
|
||||
// read-only Pi conversation catalogue (#1507, CHAT-02)
|
||||
// GET /api/conversation?id=<conversation>[&branch=<branch>][&cursor=<cursor>]
|
||||
// one CHAT-01 history page: the first page of a branch (the
|
||||
// default one without branch), or the next page (or a
|
||||
// follow) for a cursor. A cursor call repeats the page's
|
||||
// branch; without it the answer is 400. Refusals carry
|
||||
// {error, refusal: {code, reconcile}}.
|
||||
//
|
||||
// POST requires Content-Type: application/json. A plain form post from another
|
||||
// site in the browser cannot set that header without a CORS preflight, and this
|
||||
// server answers no preflight, so a stray page cannot flip marks.
|
||||
// Route logic lives in src/board.mjs. This module keeps the pure helpers the
|
||||
// Console and the tests share: reply planning and shaping, the Host/Origin check
|
||||
// (foreignRequest), and the conversation query and refusal map.
|
||||
//
|
||||
// Every route first checks Host and Origin (#1507). Binding to loopback does
|
||||
// not stop DNS rebinding: a page whose name now resolves to 127.0.0.1 reaches
|
||||
// this server as its own origin, with its own name as Host, and could read
|
||||
// /api/board or post /api/reply into a live pane. A Host that is not a loopback
|
||||
// name on this server's port, or any Origin other than this server's own, gets
|
||||
// 403 before anything else runs. Same check as packages/webui/src/serve.mjs.
|
||||
// a Console on this host as its own origin, with its own name as Host, and could
|
||||
// read the board or post /api/reply into a live pane. A Host that is not a
|
||||
// loopback name on the listening port, or any Origin other than the server's
|
||||
// own, gets 403 before anything else runs. The Console applies the same check.
|
||||
// No CORS headers are ever sent.
|
||||
//
|
||||
// Every /api/board request rescans, so the page is never staler than its
|
||||
// refresh timer. The scan rewrites the derived board files as a side effect.
|
||||
//
|
||||
// The conversation routes read only: packages/conversation lists the repository
|
||||
// specs' Pi session roots (never fleet or connector ones), opens files
|
||||
// O_NOFOLLOW and writes nothing. Registrations are hints there too. The
|
||||
// conversation id is opaque and no path comes from the request. Cursors live
|
||||
// in this server's memory, bound to the one actor this unauthenticated
|
||||
// loopback route has, local-operator.
|
||||
// conversation id is opaque and no path comes from the request.
|
||||
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import { hostname } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { scan, markSeen, seenKey, ConfigError, loadRegistrations } from "./scan.mjs";
|
||||
import { createReader, rootsFromSpecs } from "../../conversation/src/reader.mjs";
|
||||
|
||||
const MAX_BODY = 4096;
|
||||
import { resolve } from "node:path";
|
||||
import { seenKey, runAsync } from "./scan.mjs";
|
||||
|
||||
// Reply-from-board (#1505). The board owns no transport: a reply is handed to
|
||||
// the repository's own inter-agent channel, tools/tmux/agent-send.sh, which
|
||||
@@ -71,27 +46,48 @@ const REPLY_TIMEOUT_MS = 15000;
|
||||
// Refusals (4xx) happen before the tool runs and carry { error }. Once the
|
||||
// tool has run the answer is 200 with delivered true/false, the exit code and
|
||||
// both output streams verbatim, whatever the code was.
|
||||
export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, now = () => new Date(), host = hostname().split(".")[0] }) {
|
||||
if (typeof key !== "string" || !key) return { status: 400, body: { error: "agent must be the row id <project>/<agent>" } };
|
||||
if (typeof text !== "string" || !text.trim()) return { status: 400, body: { error: "text must be a non-empty string" } };
|
||||
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
|
||||
export function replyToRow(opts) {
|
||||
return replyWith(opts, (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: spawnSync });
|
||||
}
|
||||
|
||||
// Async twin of replyToRow for the in-process board (B1). `exec` is async.
|
||||
export async function replyToRowAsync(opts) {
|
||||
return replyWith(opts, async (exec, args, env, agentSend) => exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env }), { exec: runAsync, async: true });
|
||||
}
|
||||
|
||||
function replyWith({ index, key, text, agentSend = DEFAULT_AGENT_SEND, exec, now = () => new Date(), host = hostname().split(".")[0] }, send, { exec: defaultExec, async = false }) {
|
||||
const plan = planReply({ index, key, text, host });
|
||||
if (plan.refusal) return async ? Promise.resolve(plan.refusal) : plan.refusal;
|
||||
// The registration says which socket the seat is on. A launcher-exported
|
||||
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
|
||||
const env = { ...process.env };
|
||||
delete env.MOSAIC_TMUX_SOCKET;
|
||||
const finish = (r) => replyBody({ r, key, agentSend, session: plan.session, socket: plan.socket, now });
|
||||
const out = send(exec ?? defaultExec, plan.args, env, agentSend);
|
||||
return async ? Promise.resolve(out).then(finish) : finish(out);
|
||||
}
|
||||
|
||||
function planReply({ index, key, text, host }) {
|
||||
const refuse = (status, error) => ({ refusal: { status, body: { error } } });
|
||||
if (typeof key !== "string" || !key) return refuse(400, "agent must be the row id <project>/<agent>");
|
||||
if (typeof text !== "string" || !text.trim()) return refuse(400, "text must be a non-empty string");
|
||||
if (text.length > REPLY_LIMIT) return refuse(400, `text is longer than ${REPLY_LIMIT} characters`);
|
||||
const rec = index.sessions.find((r) => seenKey(r) === key);
|
||||
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
|
||||
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
|
||||
if (!rec) return refuse(404, `unknown row: ${key}`);
|
||||
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return refuse(409, "board replies are disabled for Discord connectors");
|
||||
const reg = rec.registered;
|
||||
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
|
||||
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
|
||||
if (!reg.tmux || !reg.tmux.session) return { status: 409, body: { error: "registration has no tmux session to address" } };
|
||||
if (!reg) return refuse(409, "reply needs a registered seat (start it through scripts/mosaic launch)");
|
||||
if (reg.alive === false) return refuse(409, `registration is stale: pid ${reg.pid} is gone`);
|
||||
if (!reg.tmux || !reg.tmux.session) return refuse(409, "registration has no tmux session to address");
|
||||
const session = reg.tmux.session;
|
||||
const socket = reg.tmux.socket || null;
|
||||
const args = ["-s", session, "-S", `${host}:${REPLY_SENDER}`];
|
||||
if (socket) args.push("-L", socket);
|
||||
args.push("-m", `${text}\n${REPLY_TRAILER}`);
|
||||
// The registration says which socket the seat is on. A launcher-exported
|
||||
// MOSAIC_TMUX_SOCKET in this server's own environment must not override it.
|
||||
const env = { ...process.env };
|
||||
delete env.MOSAIC_TMUX_SOCKET;
|
||||
const r = exec(agentSend, args, { encoding: "utf8", timeout: REPLY_TIMEOUT_MS, env });
|
||||
return { args, session, socket };
|
||||
}
|
||||
|
||||
function replyBody({ r, key, agentSend, session, socket, now }) {
|
||||
if (r.error) return { status: 500, body: { error: `could not run ${agentSend}: ${r.error.message}` } };
|
||||
const exitCode = r.status;
|
||||
return {
|
||||
@@ -110,39 +106,6 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const type = String(req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
||||
if (type !== "application/json") return reject(new Error("Content-Type must be application/json"));
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on("data", (c) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
req.destroy();
|
||||
reject(new Error(`body larger than ${MAX_BODY} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("body must be a JSON object");
|
||||
resolvePromise(parsed);
|
||||
} catch (err) {
|
||||
reject(new Error(`invalid JSON body: ${err.message}`));
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
||||
|
||||
export function isLoopbackHost(host) {
|
||||
@@ -187,11 +150,6 @@ export const REFUSAL_STATUS = {
|
||||
};
|
||||
const QUERY_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function sendConversationJson(res, status, body) {
|
||||
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
||||
res.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
// GET /api/conversation: id is required; branch and cursor are optional; any
|
||||
// other, repeated or malformed parameter is a 400.
|
||||
function conversationQuery(url) {
|
||||
@@ -220,103 +178,3 @@ export function conversationResponse(reader, url) {
|
||||
if (out.ok) return { status: 200, body: out };
|
||||
return { status: REFUSAL_STATUS[out.refusal.code] ?? 422, body: { error: out.refusal.message, refusal: { code: out.refusal.code, reconcile: out.refusal.reconcile } } };
|
||||
}
|
||||
|
||||
export function loadPage(path = join(import.meta.dirname, "page.html")) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
// specs: agent specs to scan on each request. boardDir: where scan writes.
|
||||
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync, conversationReader = null }) {
|
||||
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
|
||||
const reader = conversationReader ?? createReader({ roots: () => rootsFromSpecs(specs, loadRegistrations(seatsDir).registrations) });
|
||||
return createHttpServer((req, res) => {
|
||||
const refused = foreignRequest(req);
|
||||
if (refused) {
|
||||
req.resume();
|
||||
return sendJson(res, 403, { error: refused });
|
||||
}
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (req.method === "POST" && url.pathname === "/api/reply") {
|
||||
return readJsonBody(req)
|
||||
.then((body) => {
|
||||
let index;
|
||||
try {
|
||||
index = rescan();
|
||||
} catch (err) {
|
||||
return sendJson(res, 500, { error: err.message });
|
||||
}
|
||||
const out = replyToRow({ index, key: body.agent, text: body.text, agentSend, exec, now });
|
||||
sendJson(res, out.status, out.body);
|
||||
})
|
||||
.catch((err) => sendJson(res, 400, { error: err.message }));
|
||||
}
|
||||
if (req.method === "POST" && url.pathname === "/api/seen") {
|
||||
return readJsonBody(req)
|
||||
.then((body) => {
|
||||
try {
|
||||
markSeen(boardDir, { project: body.project, agent: body.agent, lastActivity: body.lastActivity, seen: body.seen ?? true });
|
||||
} catch (err) {
|
||||
return sendJson(res, 400, { error: err.message });
|
||||
}
|
||||
try {
|
||||
sendJson(res, 200, rescan());
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: err.message });
|
||||
}
|
||||
})
|
||||
.catch((err) => sendJson(res, 400, { error: err.message }));
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405, { "content-type": "text/plain" });
|
||||
return res.end("method not allowed\n");
|
||||
}
|
||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||
return res.end(page);
|
||||
}
|
||||
if (url.pathname === "/api/board") {
|
||||
let index;
|
||||
try {
|
||||
index = rescan();
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify({ error: err.message }) + "\n");
|
||||
}
|
||||
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
||||
return res.end(JSON.stringify(index) + "\n");
|
||||
}
|
||||
if (url.pathname === "/api/conversations" || url.pathname === "/api/conversation") {
|
||||
let out;
|
||||
try {
|
||||
out = conversationResponse(reader, url);
|
||||
} catch (err) {
|
||||
process.stderr.write(`conversation read failed: ${err.message}\n`);
|
||||
out = { status: 500, body: { error: "conversation read failed" } };
|
||||
}
|
||||
return sendConversationJson(res, out.status, out.body);
|
||||
}
|
||||
if (url.pathname === "/favicon.ico") {
|
||||
res.writeHead(204);
|
||||
return res.end();
|
||||
}
|
||||
if (url.pathname === "/healthz") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end('{"ok":true}\n');
|
||||
}
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("not found\n");
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves to the listening server. Refuses any non-loopback host.
|
||||
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
|
||||
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
|
||||
const server = createServer(rest);
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolvePromise(server);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, appendFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createBoard } from "../src/board.mjs";
|
||||
|
||||
const pkgRoot = resolve(import.meta.dirname, "..");
|
||||
const cli = join(pkgRoot, "src", "cli.mjs");
|
||||
|
||||
// Track every tmpdir we create so a stray failure never leaves fixtures behind.
|
||||
const roots = [];
|
||||
function makeRoot() {
|
||||
const root = mkdtempSync(join(tmpdir(), "control-board-board-test-"));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
after(() => {
|
||||
for (const root of roots) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// One agent whose last message is an assistant reply (waiting on the user), so
|
||||
// a mark on it is visible in the index. No registration; liveness stubbed so no tmux runs.
|
||||
function fixture() {
|
||||
const root = makeRoot();
|
||||
const sessionsDir = join(root, "sessions");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionsDir, "session.jsonl"),
|
||||
[
|
||||
JSON.stringify({ type: "session", id: "s1", timestamp: "2026-10-20T18:59:00Z", cwd: root }),
|
||||
JSON.stringify({ type: "message", timestamp: "2026-10-20T19:00:00Z", message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Input needed: which branch should I use?" }] } }),
|
||||
].join("\n") + "\n",
|
||||
);
|
||||
const boardDir = join(root, "board");
|
||||
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
|
||||
const isAlive = () => ({ alive: true, workspace: null });
|
||||
return { root, boardDir, specs, isAlive };
|
||||
}
|
||||
|
||||
// Pinned order (Webby, Mos mos-1052): markSeen writes, bumps the counter, then scans.
|
||||
test("markSeen runs write, bump, scan in that order", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
|
||||
await board.markSeen({ project: "proj", agent: "agent1", lastActivity: "2026-10-20T19:00:00Z" });
|
||||
assert.deepEqual(events, ["write", "bump", "scan"]);
|
||||
});
|
||||
|
||||
// Mutant target: a scan run before the write, or a join of a pre-write scan,
|
||||
// would let the answer omit the mark it was asked to show.
|
||||
test("markSeen answer includes the mark it just wrote", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const board = createBoard({ specs, boardDir, isAlive });
|
||||
const before = await board.index();
|
||||
assert.deepEqual(before.seen, []);
|
||||
const rec = before.sessions[0];
|
||||
const index = await board.markSeen({ project: "proj", agent: "agent1", lastActivity: rec.lastActivity });
|
||||
assert.deepEqual(index.seen, ["proj/agent1"]);
|
||||
});
|
||||
|
||||
// A reply to a row that is not registered is refused before any send, so the
|
||||
// send and bump events must not appear.
|
||||
test("reply refuses an unregistered row and never sends", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
|
||||
const out = await board.reply({ agent: "proj/agent1", text: "hello" });
|
||||
assert.equal(out.status, 409);
|
||||
assert.equal(events.includes("send"), false);
|
||||
assert.equal(events.includes("bump"), false);
|
||||
});
|
||||
|
||||
// The retired writer refuses to start. Exit 2 with a pointer to the Console.
|
||||
test("cli serve refuses and points to the Console", () => {
|
||||
const r = spawnSync(process.execPath, [cli, "serve"], { encoding: "utf8" });
|
||||
assert.equal(r.status, 2);
|
||||
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"]);
|
||||
});
|
||||
|
||||
// B4 (Webby r3): a settled scan is not in flight. With no write between two index()
|
||||
// calls, the second must start a fresh scan and show activity appended in between.
|
||||
// The mutant: drop the clear on settle in index(); the second call then joins the
|
||||
// settled promise, logs one scan, and shows the first view.
|
||||
test("index after a settled scan starts a fresh scan and shows new activity (B4)", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name) });
|
||||
await board.index();
|
||||
appendFileSync(join(specs[0].sessionsDir, "session.jsonl"), JSON.stringify({ type: "message", timestamp: "2026-10-20T19:05:00Z", message: { role: "user", content: [{ type: "text", text: "new activity" }] } }) + "\n");
|
||||
const second = await board.index();
|
||||
assert.deepEqual(events, ["scan", "scan"]);
|
||||
assert.equal(second.sessions[0].lastActivity, "2026-10-20T19:05:00Z");
|
||||
});
|
||||
|
||||
// B4 (Webby r3): a scan that rejects is cleared too. The next index() runs fresh
|
||||
// and answers, rather than replaying the rejection on every poll.
|
||||
test("index after a rejected scan starts a fresh scan (B4)", async () => {
|
||||
const { boardDir, specs, isAlive } = fixture();
|
||||
const events = [];
|
||||
let fail = true;
|
||||
const flaky = (tmux) => {
|
||||
if (fail) throw new Error("boom");
|
||||
return isAlive(tmux);
|
||||
};
|
||||
const board = createBoard({ specs, boardDir, isAlive: flaky, onEvent: (name) => events.push(name) });
|
||||
await assert.rejects(board.index(), /boom/);
|
||||
fail = false;
|
||||
const answer = await board.index();
|
||||
assert.deepEqual(events, ["scan", "scan"]);
|
||||
assert.equal(answer.sessions[0].agent, "agent1");
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
|
||||
import { scan, scanAgent } from '../src/scan.mjs';
|
||||
import { replyToRow, startServer } from '../src/serve.mjs';
|
||||
import { replyToRow } from '../src/serve.mjs';
|
||||
import { createBoard } from '../src/board.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
import { envelope } from '../../discord/src/context.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
@@ -115,21 +116,20 @@ test('connector reply refusal precedes forged live tmux registration; ordinary a
|
||||
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
|
||||
const f = fixture(); let server;
|
||||
test('board rescans connector discovery and refuses reply without transport', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
|
||||
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
let response = await fetch(base + '/api/board'); const board = await response.json();
|
||||
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
|
||||
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
|
||||
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
|
||||
assert.equal(response.status, 409);
|
||||
const board = createBoard({ specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
|
||||
const payload = await board.index();
|
||||
assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
|
||||
assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
|
||||
const { status } = await board.reply({ agent: 'fleet/researcher (discord: demo)', text: 'hello' });
|
||||
assert.equal(status, 409);
|
||||
rmSync(join(f.root, 'discord', 'demo.json'));
|
||||
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
|
||||
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
|
||||
assert.equal((await board.index()).sessions.length, 0);
|
||||
} finally { f.cleanup(); }
|
||||
});
|
||||
|
||||
test('connector session links and linked directories are not read', () => {
|
||||
|
||||
@@ -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()}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,9 @@ 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.
|
||||
|
||||
A move to a pre-198 copy, by rollback or by activate, has no board until the next re-pin. The plan drops a recorded `--board` (the 7331 board is retired and the install refuses the flag), and that Console runs without a board until a copy that has the in-process board is activated.
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { startServer, previewOptions, proofOptions } from './serve.mjs';
|
||||
import { createBoard } from '../../control-board/src/board.mjs';
|
||||
import { discoverRepoAgents, discoverFleetAgents } from '../../control-board/src/scan.mjs';
|
||||
import { busCaller } from './bus.mjs';
|
||||
import { startTesterSocket } from './tester-socket.mjs';
|
||||
import { TESTER_CLI } from '../../cli/src/transport.mjs';
|
||||
@@ -11,7 +13,8 @@ import { configDir } from '../../business/src/business.mjs';
|
||||
import { consoleReads, redactor } from './reads.mjs';
|
||||
import { createSso, readSlot } from './sso.mjs';
|
||||
import { statSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
|
||||
// agentWrite reaches only these.
|
||||
@@ -22,11 +25,11 @@ const agentCaller = (call, verbs) => async (verb, args) => {
|
||||
return call(verb, args);
|
||||
};
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
||||
const options = { port: 7330 }, preview = {}, proofFlags = {};
|
||||
let sendOn = false, ssoOn = false, testerSocket = false;
|
||||
while (args.length) {
|
||||
const flag = args.shift();
|
||||
@@ -36,12 +39,14 @@ try {
|
||||
if (flag === '--sso') { ssoOn = true; continue; }
|
||||
if (flag === '--tester-socket') { testerSocket = true; continue; }
|
||||
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
|
||||
// Row 198: the board runs inside the Console; there is no board origin to point at.
|
||||
if (flag === '--board') throw new Error('--board retired: the board runs in the Console (row 198)');
|
||||
const value = args.shift();
|
||||
if (!value) throw new Error(`missing value for ${flag}`);
|
||||
if (flag === '--port') {
|
||||
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
|
||||
options.port = Number(value);
|
||||
} else if (flag === '--board') options.board = value;
|
||||
}
|
||||
// Row 77 (#1571): the canonical checkout the queue read runs from, when
|
||||
// serve runs from a pinned copy; everything else loads from the copy.
|
||||
else if (flag === '--checkout') {
|
||||
@@ -99,6 +104,13 @@ try {
|
||||
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
// Row 198: the board runs in-process over the system config's data root and reads the same repository
|
||||
// agents as before, plus the fleet's read-only specs (the old board's default). Without the system
|
||||
// config there is no data root, so the board routes answer not-configured.
|
||||
const board = system ? createBoard({
|
||||
specs: [...discoverRepoAgents(options.checkout ?? REPO), ...discoverFleetAgents(join(homedir(), '.mosaic', 'fleet', 'agents'))],
|
||||
boardDir: join(system.dataRoot, 'board'), seatsDir: join(system.dataRoot, 'seats'), discordDataRoot: system.dataRoot,
|
||||
}) : null;
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
// The sign-in config is checked again here, then each slot file, before anything listens. A
|
||||
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
|
||||
@@ -110,14 +122,14 @@ try {
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
|
||||
server = await startServer({ board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
try {
|
||||
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
|
||||
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board,
|
||||
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
processing: { read: () => call('processing.status') },
|
||||
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
||||
@@ -136,7 +148,7 @@ try {
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${board ? 'in the Console (no port)' : 'not configured (no system config)'}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
|
||||
const awaiting = new Map();
|
||||
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, writing = false, reading = null, timer;
|
||||
let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
|
||||
let board = 'the Console board', palette = 'harbor', mode = 'system', failed = false;
|
||||
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
|
||||
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
|
||||
let machine = false;
|
||||
@@ -476,5 +476,5 @@
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
api('/api/config').then(config => { board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
@@ -11,19 +11,9 @@ import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
|
||||
}
|
||||
export function boardURL(value) {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
||||
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
|
||||
}
|
||||
// Avoid hostname resolution for localhost.
|
||||
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
|
||||
return url.origin;
|
||||
}
|
||||
const root = resolve(import.meta.dirname, 'public');
|
||||
const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
@@ -169,6 +159,20 @@ const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.header
|
||||
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
|
||||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||||
// Row 198: the board's writes, in-process. A body the board refuses is a 400; the reply's own answer
|
||||
// (its status and receipt) comes back as the board gave it, and a failed scan is a 500 with no detail.
|
||||
async function boardSend(req, res, { board, path }) {
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return json(res, 400, { error: err.message }); }
|
||||
try {
|
||||
if (path === '/api/seen') return json(res, 200, await board.markSeen(value));
|
||||
const out = await board.reply(value);
|
||||
return json(res, out.status, out.body);
|
||||
} catch (err) {
|
||||
if (path === '/api/seen') return json(res, 400, { error: err.message });
|
||||
return json(res, 500, { error: 'board read failed' });
|
||||
}
|
||||
}
|
||||
async function busSend(req, res, { send, preview, log }) {
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
let to = null;
|
||||
@@ -579,7 +583,8 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
// `board` is createBoard() from packages/control-board (row 198), run in-process; null answers the board routes 503.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = null, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
@@ -590,13 +595,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
|
||||
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
|
||||
const upstream = boardURL(board);
|
||||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
}
|
||||
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
|
||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
const boardLabel = board ? 'in the Console' : null;
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
@@ -708,30 +713,15 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
// Row 198: the board's two writes run in-process, under --send and the public name only.
|
||||
if (path === '/api/seen' || path === '/api/reply') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!send || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
|
||||
return boardSend(req, res, { board, path });
|
||||
}
|
||||
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
|
||||
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||||
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
||||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||||
if (allowed) {
|
||||
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
|
||||
let bytes;
|
||||
if (allowed === 'POST') {
|
||||
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
||||
}
|
||||
try {
|
||||
const response = await fetch(upstream + path + (conversation ? search : ''), {
|
||||
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
|
||||
redirect: 'error', signal: AbortSignal.timeout(timeout),
|
||||
});
|
||||
const text = await response.text();
|
||||
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
|
||||
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
return res.end(text);
|
||||
} catch {
|
||||
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
|
||||
}
|
||||
}
|
||||
if (path.startsWith('/api/bus/')) {
|
||||
const verb = path.slice('/api/bus/'.length);
|
||||
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
|
||||
@@ -751,7 +741,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings' || path === '/api/bus-status') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
|
||||
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
|
||||
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: boardLabel, address }));
|
||||
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
|
||||
if (path === '/api/business') return consoleRead(res, () => reads.business());
|
||||
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus());
|
||||
@@ -761,9 +751,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return consoleRead(res, () => reads.row(Number(id)));
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// Row 198: the board's reads, in-process. The sign-in gate above has already run for each one.
|
||||
if (path === '/api/board' || path === '/api/conversations' || path === '/api/conversation') {
|
||||
if (!board) return json(res, 503, { error: 'not-configured', message: 'the Console has no board configured' });
|
||||
if (path === '/api/board') return consoleRead(res, () => board.index());
|
||||
let out;
|
||||
try { out = board.conversation(new URL(req.url, 'http://localhost')); } catch { return json(res, 500, { error: 'conversation read failed' }); }
|
||||
return json(res, out.status, out.body);
|
||||
}
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: boardLabel, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -72,7 +72,7 @@ async function setup(machine, fast = false) {
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser({ machine });
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub(fast) });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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 { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { request } from 'node:http';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createBoard } from '../../control-board/src/board.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: a board read never blocks the Console's loop. The board is a real createBoard, and its tmux
|
||||
// check runs through a shim on PATH that sleeps 1 s. A 10 ms timer records the longest stall of the
|
||||
// loop while /api/board is pending; a spawnSync in the read path stalls the loop for the shim's full
|
||||
// second. /healthz is timed by elapsed time from the same moment.
|
||||
test('a board read never blocks the loop: the timer gap and /healthz stay short while /api/board is pending (M14)', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-m14-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const shim = join(root, 'shim'), sessionsDir = join(root, 'sessions'), seatsDir = join(root, 'seats');
|
||||
for (const d of [shim, sessionsDir, seatsDir]) mkdirSync(d);
|
||||
writeFileSync(join(shim, 'tmux'), '#!/bin/sh\nsleep 1\nexit 1\n');
|
||||
chmodSync(join(shim, 'tmux'), 0o755);
|
||||
const PATH = process.env.PATH;
|
||||
process.env.PATH = `${shim}:${PATH}`;
|
||||
t.after(() => { process.env.PATH = PATH; });
|
||||
const board = createBoard({ specs: [{ agent: 'agent1', project: 'proj', sessionsDir, tmux: { socket: null, session: 'agent1' } }], boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, host: 'm14' });
|
||||
const web = await startServer({ port: 0, board });
|
||||
t.after(() => stop(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
let last = Date.now(), worst = 0;
|
||||
const tick = setInterval(() => { const now = Date.now(); worst = Math.max(worst, now - last); last = now; }, 10);
|
||||
t.after(() => clearInterval(tick));
|
||||
const pending = fetch(base + '/api/board');
|
||||
const t0 = Date.now();
|
||||
const health = await fetch(base + '/healthz');
|
||||
const healthMs = Date.now() - t0;
|
||||
assert.equal(health.status, 200);
|
||||
assert.ok(healthMs < 500, `/healthz took ${healthMs} ms while the board read was pending`);
|
||||
assert.equal((await pending).status, 200);
|
||||
clearInterval(tick);
|
||||
assert.ok(worst < 500, `the loop stalled for ${worst} ms during the board read`);
|
||||
});
|
||||
@@ -109,12 +109,12 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
await b.evaluate('document.querySelector("#close").click()');
|
||||
}
|
||||
// Empty and unreachable pages are tested as browser responses, never live data claims.
|
||||
// Stop only the fixture board. The page keeps the last data and reports its URL.
|
||||
await new Promise(r => f.board.close(r));
|
||||
// Fail only the fixture board's reads. The page keeps the last data and names the board it could not reach (row 198: the Console).
|
||||
f.board.setDown(true);
|
||||
await b.evaluate('document.querySelector("#refresh").click()');
|
||||
await wait('!document.querySelector("#error").hidden');
|
||||
const error = await b.evaluate('document.querySelector("#error").textContent');
|
||||
assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
|
||||
assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
|
||||
assert.ok(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length') > 0);
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
await b.navigate(f.base); await wait('!document.querySelector("#error").hidden');
|
||||
|
||||
@@ -98,7 +98,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
|
||||
assert.ok(comments.label.includes('\u001b'), 'the label excerpt keeps the ESC byte');
|
||||
assert.ok(comments.label.includes('\u202e'), 'the label excerpt keeps the bidi character');
|
||||
writeFileSync(fieldsFile, JSON.stringify(hostile));
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const web = await startServer({ port: 0, board: f.board, bus: s.bus, fieldsFile }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
|
||||
@@ -41,7 +41,7 @@ const stub = `(() => {
|
||||
})();`;
|
||||
async function setup(t, suffix = '') {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
|
||||
const server = await startServer({ port: 0, board: f.board }); t.after(() => close(server));
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
|
||||
|
||||
@@ -294,7 +294,7 @@ test('configured Console process injects chat through its existing human transpo
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
|
||||
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
|
||||
const child = spawn(process.execPath, [cli, 'serve', '--port', '0',
|
||||
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(async () => {
|
||||
|
||||
@@ -3,16 +3,15 @@ import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { identityOf } from '../../discord/src/journal.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { close, inProcessBoard } from './fixture.mjs';
|
||||
|
||||
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
|
||||
let board, web, b;
|
||||
let web, b;
|
||||
try {
|
||||
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
|
||||
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
|
||||
@@ -23,8 +22,8 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
|
||||
// #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
|
||||
const seatsDir = join(root, 'seats');
|
||||
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' });
|
||||
board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } });
|
||||
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
|
||||
const board = inProcessBoard({ root, specs: [], seatsDir, exec: () => { throw Error('no transport'); } });
|
||||
web = await startServer({ port: 0, board });
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const payload = await (await fetch(base + '/api/board')).json();
|
||||
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle');
|
||||
@@ -35,7 +34,7 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
|
||||
assert.equal(reply.status, 409);
|
||||
b = await browser(); await b.viewport(320, 1000);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
|
||||
await b.navigate(`http://127.0.0.1:${board.address().port}`);
|
||||
await b.navigate(base);
|
||||
await wait('document.querySelector(".session-row")');
|
||||
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
|
||||
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/);
|
||||
@@ -58,5 +57,5 @@ test('Discord row through real board/WebUI: independent brake/liveness, no Reply
|
||||
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
|
||||
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
|
||||
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -48,7 +48,7 @@ test('the editor: the list, hostile markdown that runs nothing, the draft, and e
|
||||
const f = await fixture(), b = await browser();
|
||||
const reads = { files: [], file: [] };
|
||||
const web = await startServer({
|
||||
port: 0, board: f.boardURL,
|
||||
port: 0, board: f.board,
|
||||
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
|
||||
projectFiles: async args => { reads.files.push(args); return { head: C1, files: Object.keys(files).map(path => ({ path })) }; },
|
||||
projectFile: async args => { reads.file.push(args); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
|
||||
|
||||
@@ -51,7 +51,7 @@ async function session(run) {
|
||||
const reads = [];
|
||||
const f = await fixture(), b = await browser();
|
||||
const web = await startServer({
|
||||
port: 0, board: f.boardURL,
|
||||
port: 0, board: f.board,
|
||||
reads: { business: async () => ({ business: { id: 'demo', human: 'jason', projects: ['stack'] } }) },
|
||||
projectFiles: async () => ({ head: C1, files: Object.keys(files).map(path => ({ path })) }),
|
||||
projectFile: async args => { reads.push(args.path); return { path: args.path, head: C1, commit: C1, content: files[args.path], author: 'jason', at: '2026-10-16T00:00:00Z' }; },
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
// Same session-line fixture shape as control-board/tests/serve.test.mjs.
|
||||
// Run the real board scanner/server against temporary data only.
|
||||
// Run the real board scanner in-process (row 198) against temporary data only.
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { createBoard } from '../../control-board/src/board.mjs';
|
||||
import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
|
||||
// The Console's board: the same createBoard context the CLI builds, over a temporary data root.
|
||||
// setDown(true) makes the next read fail, which is how a test shows the page a board outage.
|
||||
export function inProcessBoard({ root, specs, seatsDir, isAlive, exec }) {
|
||||
const board = createBoard({ specs, boardDir: join(root, 'board'), seatsDir, discordDataRoot: root, isAlive, exec });
|
||||
let down = false;
|
||||
return { ...board, index: () => { if (down) throw new Error('fixture board is down'); return board.index(); }, setDown(value) { down = value; } };
|
||||
}
|
||||
export async function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-fixture-'));
|
||||
const seatsDir = join(root, 'seats'), specs = [];
|
||||
@@ -29,12 +36,11 @@ export async function fixture() {
|
||||
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
|
||||
}
|
||||
}
|
||||
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
|
||||
const board = inProcessBoard({ root, specs, seatsDir, isAlive: tmux => tmux.session !== 'agent4',
|
||||
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
|
||||
});
|
||||
const boardURL = `http://127.0.0.1:${board.address().port}`;
|
||||
const web = await startServer({ port: 0, board: boardURL });
|
||||
return { root, board, web, boardURL, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
|
||||
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
|
||||
const web = await startServer({ port: 0, board });
|
||||
return { root, board, web, base: `http://127.0.0.1:${web.address().port}`, captures, failReply: () => { exitCode = 4; },
|
||||
async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
|
||||
};
|
||||
}
|
||||
@@ -5,10 +5,9 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { close, inProcessBoard } from './fixture.mjs';
|
||||
|
||||
export const at = s => new Date(Date.now() + s * 1000).toISOString();
|
||||
|
||||
@@ -36,11 +35,11 @@ export async function historyFixture({ seat = 'fixture', project = 'repo' } = {}
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const seatsDir = join(root, 'seats'), captures = [];
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat, project, sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: projectRoot }, task: 'Fixed task', tmux: { session: seat, socket: null }, pid: process.pid, now: () => new Date(Date.now() - 3600000) }));
|
||||
const board = await startBoard({ port: 0, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
|
||||
const board = inProcessBoard({ root, specs: [{ agent: seat, project, sessionsDir, tmux: { session: seat } }], seatsDir, isAlive: () => true,
|
||||
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
|
||||
const web = await startServer({ port: 0, board });
|
||||
return { root, projectRoot, sessionsDir, board, web, base: `http://127.0.0.1:${web.address().port}`, captures,
|
||||
async close() { await close(web); await close(board); rmSync(root, { recursive: true, force: true }); },
|
||||
async close() { await close(web); rmSync(root, { recursive: true, force: true }); },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ test('a copy that meets an empty target directory replaces it by rename, not by
|
||||
// serve from a copy: /healthz, the config check, roles and RELEASE from the
|
||||
// copy, and the queue read from --checkout or, without it, from the copy.
|
||||
async function serve(t, cli, args, env) {
|
||||
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => child.kill());
|
||||
let out = '', err = '';
|
||||
child.stderr.on('data', (c) => { err += c; });
|
||||
@@ -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');
|
||||
|
||||
@@ -104,7 +104,7 @@ test('the CLI refuses a partial or invalid preview flag set at start', () => {
|
||||
test('the CLI with all three flags serves the preview mode', async t => {
|
||||
// No system config, so the bus routes answer not-configured; the gate runs before them.
|
||||
const env = { ...process.env, MOSAIC_CONFIG: fileURLToPath(new URL('./no-such-config.json', import.meta.url)) };
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--public-host', NAME, '--auth-header', 'Remote-User', '--allow-user', ID], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => child.kill());
|
||||
let out = '';
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -37,7 +37,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
|
||||
writeFileSync(hook, `import fs from 'node:fs';import {syncBuiltinESMExports} from 'node:module';const original=fs.fstatSync;fs.fstatSync=(...args)=>{const s=original(...args);s.uid=process.getuid()+1;return s;};syncBuiltinESMExports();`);
|
||||
cases.push({ flags: [...PREVIEW, ...proof], env: { ...f.env, NODE_OPTIONS: `--import=${hook}` } });
|
||||
for (const c of cases) {
|
||||
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
|
||||
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...c.flags], { env: c.env ?? f.env, encoding: 'utf8', timeout: 5000 });
|
||||
assert.equal(r.status, 2, 'invalid proof startup must refuse'); assert.match(r.stderr, /proof/i);
|
||||
assert.ok(!r.stderr.includes(VALUE)); assert.ok(!r.stdout.includes(VALUE));
|
||||
if (c.value) assert.ok(!r.stderr.includes(c.value));
|
||||
@@ -46,7 +46,7 @@ test('proof startup refuses partial flags non-preview and invalid fixture file m
|
||||
test('serve argv carries only fixture proof path and header; startup explicitly reports proof on or off', async t => {
|
||||
const f = fixture(t);
|
||||
for (const on of [true, false]) {
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', ...PREVIEW, ...(on ? ['--proof-header', 'x-console-proof', '--proof-file', f.file] : [])], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '', err = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => err += c);
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -66,7 +66,7 @@ test('CLI applies proof on the main listener and rejects that header on its test
|
||||
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
|
||||
writeFileSync(file, VALUE, { mode: 0o600 });
|
||||
await new Promise(r=>f.web.close(r));
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket', '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Row 178: fixture proof values only. No installed proof, proxy or unit is read.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, request } from 'node:http';
|
||||
import { request } from 'node:http';
|
||||
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -24,9 +24,8 @@ async function setup(t) {
|
||||
writeFileSync(file, VALUE + '\n', { mode: 0o600 });
|
||||
const proof = serve.proofOptions({ proofHeader: HEADER, proofFile: file }, preview);
|
||||
const calls = [], logs = [];
|
||||
const board = createServer((q, r) => { calls.push('board'); r.setHeader('content-type', 'application/json'); r.end('{"sessions":[]}'); });
|
||||
await new Promise(r => board.listen(0, '127.0.0.1', r)); t.after(() => close(board));
|
||||
const server = await serve.startServer({ port: 0, preview, proof, board: `http://127.0.0.1:${board.address().port}`,
|
||||
const board = { index: async () => { calls.push('board'); return { sessions: [] }; } };
|
||||
const server = await serve.startServer({ port: 0, preview, proof, board,
|
||||
bus: { tasks: async () => { calls.push('tasks'); return []; } }, send: async () => { calls.push('send'); return { id: 'receipt-1' }; }, log: line => logs.push(line) });
|
||||
t.after(() => close(server));
|
||||
return { port: server.address().port, calls, logs, proof, file };
|
||||
@@ -70,7 +69,7 @@ test('proxy proof header is refused on other Hosts and on the tester listener',
|
||||
assert.equal((await ask(f.port, { host: `127.0.0.1:${f.port}`, headers: { 'x-user': undefined, [HEADER]: '' } })).status, 403);
|
||||
const testerRoot = mkdtempSync(join(tmpdir(),'tester-proof-'));
|
||||
t.after(()=>rmSync(testerRoot,{recursive:true,force:true}));
|
||||
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: 'http://127.0.0.1:9' });
|
||||
const server = await startTesterSocket({ dataRoot: testerRoot, testerProofHeader: HEADER, board: null });
|
||||
t.after(() => close(server));
|
||||
const result = await testerRequest({dataRoot:testerRoot,headers:{[HEADER]:VALUE},path:'/api/config'});
|
||||
assert.equal(result.status, 403); assert.deepEqual(f.calls, []);
|
||||
|
||||
@@ -185,7 +185,7 @@ test('settings: release, board, loopback address, notifier binding name only', a
|
||||
assert.equal(body.launch.instances.pm.provider, 'zai');
|
||||
assert.equal(body.launch.limits.models.zai['claude-opus-5-5'], 2);
|
||||
assert.match(body.launch.revision, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
|
||||
assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: null, address: `${base}/`, launch: undefined, at: undefined });
|
||||
clean(r.text, s.base, repo.base);
|
||||
// No notifier config: still 200, so appearance works; the reason is redacted.
|
||||
const bare = seeded(t, { notify: false });
|
||||
|
||||
@@ -3,18 +3,17 @@ import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { close, inProcessBoard } from './fixture.mjs';
|
||||
|
||||
const oldAt = '2026-09-14T10:00:00.000Z', launchAt = '2026-09-15T10:00:00.000Z', newAt = '2026-09-15T10:01:00.000Z';
|
||||
const notice = `relaunched at ${launchAt}, no messages since`;
|
||||
|
||||
test('both presentations replace old activity with relaunch notice, label retained history, then resume after new activity', { timeout: 60000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-'));
|
||||
let board, web, b;
|
||||
let web, b;
|
||||
try {
|
||||
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 's.jsonl');
|
||||
@@ -23,16 +22,15 @@ test('both presentations replace old activity with relaunch notice, label retain
|
||||
writeFileSync(file, history);
|
||||
const seatsDir = join(root, 'seats');
|
||||
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' });
|
||||
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } });
|
||||
const boardURL = `http://127.0.0.1:${board.address().port}`;
|
||||
web = await startServer({ port: 0, board: boardURL });
|
||||
const board = inProcessBoard({ root, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], seatsDir, isAlive: () => true, exec: () => { throw Error('no transport expected'); } });
|
||||
web = await startServer({ port: 0, board });
|
||||
const webURL = `http://127.0.0.1:${web.address().port}`;
|
||||
const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0];
|
||||
assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt);
|
||||
assert.match(row.lastAssistantText, /OLD_FIXTURE_REPLY/); assert.equal(row.state, 'waiting'); assert.equal(row.taskSetBy, 'fixture-setter');
|
||||
b = await browser(); await b.viewport(320, 1000);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
|
||||
await b.navigate(boardURL); await wait('document.querySelector(".session-row")');
|
||||
await b.navigate(webURL); await wait('document.querySelector(".session-row")');
|
||||
const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent');
|
||||
assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false);
|
||||
await b.evaluate('document.querySelector(".row-toggle").click()');
|
||||
@@ -55,7 +53,7 @@ test('both presentations replace old activity with relaunch notice, label retain
|
||||
await wait('document.querySelector("#inspection").textContent.includes("NEW_FIXTURE_REPLY")');
|
||||
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('relaunched at'), false);
|
||||
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), false);
|
||||
await b.navigate(boardURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
|
||||
await b.navigate(webURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
|
||||
assert.equal((await b.evaluate('document.querySelector(".session-row").textContent')).includes('relaunched at'), false);
|
||||
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
|
||||
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -10,11 +10,10 @@ import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
|
||||
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { close, inProcessBoard } from './fixture.mjs';
|
||||
|
||||
const at = s => new Date(Date.now() + s * 1000).toISOString();
|
||||
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
|
||||
@@ -22,16 +21,16 @@ const assistant = (timestamp, text) => message(timestamp, { role: 'assistant', s
|
||||
|
||||
test('reported return flow and relative Age: reply sent from the inspector, then the new answer appears there without manual refresh', { timeout: 90000 }, async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-return-'));
|
||||
let board, web, b;
|
||||
let web, b;
|
||||
try {
|
||||
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
|
||||
const file = join(sessionsDir, 's.jsonl');
|
||||
writeFileSync(file, message(at(-120), { role: 'user', content: [{ type: 'text', text: 'Start' }] }) + assistant(at(-90), 'Input needed: OLD_ANSWER'));
|
||||
const seatsDir = join(root, 'seats'), captures = [];
|
||||
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(Date.now() - 300000) }));
|
||||
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true,
|
||||
const board = inProcessBoard({ root, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], seatsDir, isAlive: () => true,
|
||||
exec: (f, args) => { captures.push(args); return { status: 0, stdout: '', stderr: '' }; } });
|
||||
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
|
||||
web = await startServer({ port: 0, board });
|
||||
b = await browser(); await b.viewport(1440, 1000);
|
||||
const wait = (expression, ms = 20000) => b.evaluate(`(async()=>{for(const end=Date.now()+${ms};Date.now()<end;){if(${expression})return true;await new Promise(r=>setTimeout(r,100))}throw Error('timeout: '+${JSON.stringify(expression)})})()`);
|
||||
const inspection = () => b.evaluate('document.querySelector("#inspection").textContent');
|
||||
@@ -72,5 +71,5 @@ test('reported return flow and relative Age: reply sent from the inspector, then
|
||||
const later = await inspection();
|
||||
assert.doesNotMatch(later, /Waiting for a reply|before your message/);
|
||||
assert.match(later, /Last assistant text/);
|
||||
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
|
||||
} finally { if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -64,7 +64,7 @@ test('the send box: held roles, the draft and caret across a refresh, each recei
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? null`);
|
||||
@@ -240,7 +240,7 @@ test('a sent receipt lists its replies as the page refreshes, oldest first and c
|
||||
},
|
||||
});
|
||||
const f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const wait = (expr, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -81,7 +83,7 @@ test('the CLI with --send and a configured bus sends through the human transport
|
||||
// No bus host is running, so the send is a definite refusal: no-bus-host, nothing written.
|
||||
const root = tmp(t, 'webui-send-cli-');
|
||||
const { env } = fixture(root);
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => child.kill());
|
||||
let out = '';
|
||||
const port = await new Promise((resolve, reject) => {
|
||||
@@ -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);
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, get } from 'node:http';
|
||||
import { get } from 'node:http';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { startServer, boardURL, isLoopback } from '../src/serve.mjs';
|
||||
import { startServer, isLoopback } from '../src/serve.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const post = (base, path, body, extra = {}) => fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json', ...extra }, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
||||
|
||||
test('loopback host and board origin fail closed', async () => {
|
||||
test('loopback host fails closed', async () => {
|
||||
for (const h of ['127.0.0.1', '127.2.3.4', '::1', 'localhost']) assert.equal(isLoopback(h), true);
|
||||
for (const h of ['0.0.0.0', '192.168.1.1', '::', 'example.com', '']) {
|
||||
assert.equal(isLoopback(h), false);
|
||||
await assert.rejects(startServer({ host: h, port: 0 }), /non-loopback/);
|
||||
}
|
||||
for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
|
||||
assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
|
||||
assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
|
||||
});
|
||||
|
||||
test('real board fixture passes through WebUI; assets and isolated seen/reply work', async () => {
|
||||
@@ -54,59 +49,43 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
test('proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect', async () => {
|
||||
const requests = [];
|
||||
const upstream = createServer(async (req, res) => {
|
||||
let body = ''; for await (const c of req) body += c;
|
||||
requests.push({ url: req.url, method: req.method, body });
|
||||
if (req.url === '/api/board') { res.writeHead(302, { location: 'http://192.0.2.1/' }); return res.end('{}'); }
|
||||
if (req.url.startsWith('/api/conversation')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}'); }
|
||||
res.writeHead(409, { 'content-type': 'application/json' }); res.end('{"error":"fixture refusal <unsafe>"}');
|
||||
});
|
||||
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}` });
|
||||
test('in-process board routes: writes refused without --send, reads pass the board result through, a failing read answers an error', async () => {
|
||||
const f = await fixture();
|
||||
const web = await startServer({ port: 0, board: f.board });
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
try {
|
||||
for (const path of ['/api/reply', '/api/seen']) {
|
||||
const raw = '{ "agent": "proj/agent1", "text": "hello Ω", "seen": false }';
|
||||
const res = await post(base, path, raw);
|
||||
assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
|
||||
assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
|
||||
// Without --send both writes refuse before the board runs.
|
||||
for (const path of ['/api/seen', '/api/reply']) {
|
||||
const res = await post(base, path, { agent: 'proj/agent1', text: 'blocked', seen: true });
|
||||
assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
|
||||
}
|
||||
for (const raw of ['{', 'null', '[]', JSON.stringify({ text: 'x'.repeat(5000) })]) {
|
||||
try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
|
||||
}
|
||||
assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal((await fetch(base + '/api/board')).status, 502);
|
||||
assert.equal(requests.length, 3);
|
||||
// CHAT-02 conversation routes: GET only, the query passes unchanged, the board's status and body come back as sent.
|
||||
for (const path of ['/api/conversations', '/api/conversation?id=pi-0a&branch=b.e5&cursor=c-1', '/api/conversation?id=x&id=y&unknown=%3C']) {
|
||||
assert.equal(f.captures.length, 0, 'the board never ran a write');
|
||||
assert.equal((await fetch(base + '/api/reply')).status, 405);
|
||||
assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
|
||||
// Reads: the board's own status and body come back as the board sent them.
|
||||
for (const path of ['/api/conversations', '/api/conversation?id=x']) {
|
||||
const res = await fetch(base + path);
|
||||
assert.equal(res.status, 404, path);
|
||||
assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
|
||||
assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
|
||||
const want = await f.board.conversation(new URL(path, 'http://localhost'));
|
||||
assert.equal(res.status, want.status, path);
|
||||
assert.deepEqual(await res.json(), want.body, path);
|
||||
}
|
||||
const before = requests.length;
|
||||
assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
|
||||
assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
|
||||
assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
assert.equal(requests.length, before, 'refused before the board');
|
||||
// Only the conversation routes carry a query upstream.
|
||||
await fetch(base + '/api/board?x=1');
|
||||
assert.equal(requests.at(-1).url, '/api/board');
|
||||
} finally { await close(web); await close(upstream); }
|
||||
// A failing board read is an error answer, not a crash.
|
||||
f.board.setDown(true);
|
||||
assert.ok((await fetch(base + '/api/board')).status >= 400);
|
||||
} finally { await close(web); await f.close(); }
|
||||
});
|
||||
|
||||
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
|
||||
const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
const web = await startServer({ port: 0, board: url, timeout: 100 });
|
||||
test('no board configured: the board routes answer 503; CLI rejects retired --board and other bad options', async () => {
|
||||
const web = await startServer({ port: 0, board: null });
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
|
||||
assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
assert.equal((await fetch(base + '/api/board')).status, 503);
|
||||
assert.equal((await fetch(base + '/api/conversations')).status, 503);
|
||||
} finally { await close(web); }
|
||||
for (const args of [[], ['serve', '--port', 'abc'], ['serve', '--host', '0.0.0.0'], ['serve', '--board', 'http://example.com']]) {
|
||||
const result = spawnSync(process.execPath, [fileURLToPath(new URL('../src/cli.mjs', import.meta.url)), ...args], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 2); assert.match(result.stderr, /^refused:/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -217,9 +217,9 @@ test('render prints the unit without the preview flags', async (t) => {
|
||||
|
||||
test('render carries every serve flag, the preview set included', async (t) => {
|
||||
const dir = tmp(t);
|
||||
const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW]);
|
||||
const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', ...PREVIEW]);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
|
||||
assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
|
||||
});
|
||||
|
||||
test('a partial preview set is refused', async (t) => {
|
||||
@@ -240,9 +240,9 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
|
||||
if (i >= 0) { preview[i + 1] = value; return preview; }
|
||||
return [flag, value];
|
||||
};
|
||||
for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) {
|
||||
for (const flag of ['--business', '--public-host', '--auth-header', '--allow-user']) {
|
||||
for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) {
|
||||
const value = flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`;
|
||||
const value = `a${bad}b`;
|
||||
const r = await run(dir, ['render', ...set(flag, value)]);
|
||||
assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`);
|
||||
assert.equal(r.stdout, '', `${flag} ${JSON.stringify(value)}`);
|
||||
@@ -252,6 +252,7 @@ test('a value the unit cannot carry is refused, for every flag', async (t) => {
|
||||
const r = await run(dir, ['render', '--port', port]);
|
||||
assert.equal(r.status, 1, `port ${JSON.stringify(port)}`);
|
||||
}
|
||||
// --board is retired (row 198): the unit has no board flag, so a render carrying one is refused.
|
||||
for (const args of [['--business', 'Mosaic'], ['--business', '-x'], ['--business', 'a'.repeat(65)], ['--board', 'https://127.0.0.1:7331'], ['--board', '127.0.0.1:7331'], set('--public-host', 'console.example.com:443'), set('--public-host', 'https://console.example.com'), set('--auth-header', 'X_uid')]) {
|
||||
const r = await run(dir, ['render', ...args]);
|
||||
assert.equal(r.status, 1, args.join(' '));
|
||||
@@ -386,7 +387,7 @@ test('without --send, render is the template with its placeholders filled, byte
|
||||
[['--port', '7340'], '--port 7340'],
|
||||
[['--business', 'mosaic'], '--port 7332 --business mosaic'],
|
||||
[PREVIEW, `--port 7332 ${PREVIEW_FLAGS}`],
|
||||
[['--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW], `--port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 ${PREVIEW_FLAGS}`],
|
||||
[['--port', '7340', '--business', 'mosaic.dev_1', ...PREVIEW], `--port 7340 --business mosaic.dev_1 ${PREVIEW_FLAGS}`],
|
||||
]) {
|
||||
const r = await run(dir, ['render', ...args]);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
|
||||
@@ -42,7 +42,7 @@ async function setup() {
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser({ machine: true });
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
const web = await startServer({ port: 0, board: f.board, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
|
||||
@@ -36,7 +36,7 @@ test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for
|
||||
const hook = join(f.root, 'spawn-hook.mjs');
|
||||
writeFileSync(log, '');
|
||||
writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`);
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '', error = '';
|
||||
child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c);
|
||||
t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
|
||||
|
||||
@@ -34,6 +34,8 @@ COPY_PATHS=(
|
||||
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
|
||||
packages/processing-control
|
||||
packages/webui/src packages/webui/README.md packages/cli/README.md
|
||||
# Row 198: the board runs in-process inside the Console, and its replies go through tools/tmux/agent-send.sh.
|
||||
packages/control-board/src tools/tmux
|
||||
packages/queue/src packages/library/src packages/library/bin
|
||||
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
|
||||
packages/plugins
|
||||
|
||||
@@ -809,6 +809,21 @@ console_for_target() {
|
||||
local cli="$COPIES/$TO/packages/webui/src/cli.mjs"
|
||||
local -a without_tester=()
|
||||
sso_base
|
||||
# Row 198 (design item 8, Sage sage-2834 ruling a, sage-2837): --board is dropped for every op.
|
||||
# webui-service.sh install refuses the flag, so a kept flag fails at install-console, on a rollback
|
||||
# and on an activate to a pre-198 copy alike. The reason follows the target: a target that parses
|
||||
# --board is pre-198 (7331 is retired); one that does not runs the board in the Console. Each drop
|
||||
# is named in the plan and the steps. The check reads the target's own cli.mjs, as --tester-port does.
|
||||
DROP_BOARD="" DROP_BOARD_WHY=""
|
||||
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))
|
||||
DROP_BOARD="--board ${BASE_CONSOLE[i]:-}"
|
||||
if parses_flag "$cli" --board; then DROP_BOARD_WHY="the 7331 board is retired, so this Console runs without the board"
|
||||
else DROP_BOARD_WHY="the target Console does not parse it, so the board runs in the Console"; 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 +874,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 +1456,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; $DROP_BOARD_WHY"
|
||||
echo "$PROOF_PLAN"
|
||||
echo "$SEND_PLAN"
|
||||
[ -z "$SSO_PLAN" ] || echo "$SSO_PLAN"
|
||||
@@ -1458,6 +1477,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; $DROP_BOARD_WHY"
|
||||
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
|
||||
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
|
||||
[ -z "$SSO_STEP" ] || say "$SSO_STEP"
|
||||
@@ -2311,7 +2331,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="" DROP_BOARD_WHY="" 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
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
|
||||
# Console), --tester-socket (private Unix tester), or legacy --tester-port N
|
||||
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies),
|
||||
# --business ID, --board URL, and the preview set --public-host
|
||||
# --business ID, and the preview set --public-host
|
||||
# NAME --auth-header HEADER --allow-user ID, all three or none, and --send
|
||||
# (row 83), taken only with the preview set: the unit's Console then sends
|
||||
# human messages to roles and needs the system config at start. systemd
|
||||
@@ -93,7 +93,7 @@ instance_select
|
||||
UNIT_NAME="$CONSOLE_UNIT"
|
||||
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
|
||||
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
|
||||
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
|
||||
FLAGS: [--port N] [--business ID] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
|
||||
--tester-port: legacy copies only; 1024..65535, different from --port and never 7332; loopback tester listener.
|
||||
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
|
||||
|
||||
@@ -113,7 +113,6 @@ TESTER_PORT=""
|
||||
TESTER_SET=0
|
||||
TESTER_SOCKET=0
|
||||
BUSINESS=""
|
||||
BOARD=""
|
||||
PUBLIC_HOST=""
|
||||
AUTH_HEADER=""
|
||||
ALLOW_USER=""
|
||||
@@ -136,7 +135,6 @@ parse_flags() {
|
||||
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;;
|
||||
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
|
||||
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
|
||||
--allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
|
||||
@@ -212,12 +210,6 @@ serve_flags() {
|
||||
[ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS"
|
||||
flags="$flags --business $BUSINESS"
|
||||
fi
|
||||
if [ -n "$BOARD" ]; then
|
||||
# serve checks the rest (a loopback origin, no path or credentials).
|
||||
case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac
|
||||
case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac
|
||||
flags="$flags --board $BOARD"
|
||||
fi
|
||||
local preview=0
|
||||
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
||||
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
||||
|
||||
Reference in new issue
Block a user