Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931ca77a71 | ||
|
|
397ef58beb | ||
|
|
460355ccc8 | ||
|
|
b0c79751cf | ||
|
|
ab64174753 | ||
|
|
4d8123b0a5 | ||
|
|
14fe7513ce | ||
|
|
3c57ebb220 | ||
|
|
41b5aae3cc | ||
|
|
f913d0e76a | ||
|
|
f2fd6145ad | ||
|
|
c9c8badc3e | ||
|
|
77c12b5545 | ||
|
|
aad3000fe2 | ||
|
|
4288b7b162 |
No files matched your search
@@ -0,0 +1,514 @@
|
||||
# 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.
|
||||
|
||||
## r4: rebase onto 45dca69f6 (sage-3575 step 1, sage-3586 ruling a)
|
||||
|
||||
Branch `bushroot/row198-r4-45dca69f6`: the eight r3 commits replayed on `origin/refactor` 45dca69f6.
|
||||
`bushroot/row198-r3-253` stays at 1fb66792e and is not touched. Seven commits replayed clean. Only the
|
||||
retire commit (0bde5c8d7, now 4288b7b16) conflicted, in three files and seven hunks.
|
||||
|
||||
Ruling applied: every upstream feature stays (federation view, decisionWrite, userOptions, the
|
||||
`/api/config` fields, granted-user gating). The board runs in-process. The URL proxy to a board origin is
|
||||
removed, because the proxy is the route to the 7331 writer that row 198 retires. This is the one place an
|
||||
upstream behaviour changes. Board routes now answer from the in-process board with the same response
|
||||
shapes. A granted user still gets `owner-only` 403 on board routes, because the granted-user gate runs before
|
||||
them.
|
||||
|
||||
### Conflict hunks
|
||||
|
||||
Each hunk: upstream side, our side, resolution, reason.
|
||||
|
||||
1. `packages/webui/src/serve.mjs`, `startServer` signature. Upstream: `board = DEFAULT_BOARD`, with
|
||||
`decisionWrite`, `userOptions`, `federation`. Ours: `board = null`, none of the three. Resolved: upstream
|
||||
signature with `board = null`, and our doc comment above it. Reason: keeps upstream's parameters; no board
|
||||
is now the default, not port 7331.
|
||||
2. `packages/webui/src/serve.mjs`, body after the tester checks. Upstream: decision check, `confirmations`,
|
||||
`const upstream = boardURL(board)`, `userOptions` check, `owner` with `upstream`, `hub`. Ours: nothing.
|
||||
Resolved: upstream minus `const upstream = boardURL(board);`; `owner` drops `upstream`. Reason:
|
||||
`boardURL` is the proxy's loopback validator; there is no proxy. The `let {...}` destructure and the
|
||||
granted-user `upstream = null` line lose the same name.
|
||||
3. `packages/webui/src/serve.mjs`, the board proxy block (`allowed`, `fetch(upstream + path ...)`, 502 on
|
||||
failure). Upstream: the proxy, after the read-only gate. Ours: nothing here; in-process routes sit at two
|
||||
points. Writes (`/api/seen`, `/api/reply`) answer 405 for a non-POST, 403 `{error:'read-only', sent:false}`
|
||||
without `--send` and a named human, 503 with no board, then `boardSend`. Reads (`/api/board`,
|
||||
`/api/conversations`, `/api/conversation`) answer 405 for a non-GET, 503 with no board, then
|
||||
`consoleRead(board.index())` or `board.conversation`. Resolved: ours, upstream block removed. Reason: this
|
||||
block is the 7331 route. Auth check order: the sign-in gate (401 for an unsigned request under `--sso`)
|
||||
and the granted-user owner-only gate both run before the write routes and the read routes.
|
||||
4. `packages/webui/src/serve.mjs`, `/api/config`. Upstream: `board: upstream`. Ours: `board: boardLabel`.
|
||||
Resolved: `board: granted ? null : boardLabel`, with every upstream field after it unchanged. Reason:
|
||||
upstream sent `null` to a granted user (its `upstream` was null); `boardLabel` is `'in the Console'` for the
|
||||
owner and `null` with no system config.
|
||||
5. `packages/webui/src/cli.mjs`, imports. Upstream: `DEFAULT_BOARD` in the `./serve.mjs` import, plus the bus
|
||||
and console imports. Ours: `createBoard`, the two scan discovery imports, and `busCaller` again. Resolved:
|
||||
upstream imports minus `DEFAULT_BOARD`, plus `createBoard` and `discoverRepoAgents`/`discoverFleetAgents`.
|
||||
Reason: `DEFAULT_BOARD` no longer exists; `busCaller` was already imported by upstream.
|
||||
6. `packages/webui/src/cli.mjs`, the `startServer` call. Upstream: `board: options.board`, with
|
||||
`federation: system?.federation ?? null`. Ours: `board`, without `federation`, `decisionWrite`,
|
||||
`userOptions`. Resolved: upstream call with `board`. Reason: `--board` is refused at `cli.mjs:39`, so
|
||||
`options.board` no longer exists; the new `board` is the in-process object.
|
||||
7. `packages/webui/src/public/app.js`, `/api/config` handler. Upstream: `board = config.board`, the federation
|
||||
render, the sso line. Ours: `board = config.board || 'the Console board'`. Resolved: upstream handler with
|
||||
our fallback label. Reason: `config.board` is `null` for a granted user and with no system config, which
|
||||
would print `Board: null`.
|
||||
|
||||
### Upstream tests changed to match (same commit, 4288b7b16)
|
||||
|
||||
Upstream added tests that pass a URL board string or `f.boardURL`. Each now means "no board" (`board: null`,
|
||||
which answers the board routes 503) or the in-process fixture (`f.board`). `board: 'http://127.0.0.1:9'` and
|
||||
`board:'http://127.0.0.1:9'` became `board: null`; `f.boardURL` became `f.board` in decide-browser,
|
||||
thread-claims-browser, processing-browser and granted-user. No assertion compared the URL string. The fake
|
||||
`DEFAULT_BOARD` export strings in library-cli and thread-claims are left: they are fake module text, not board
|
||||
config.
|
||||
|
||||
### Not ported: fake HTTP board servers (known to fail; port before the gate)
|
||||
|
||||
These still pass a fake HTTP origin as `board`. The in-process board never calls it, so each test's
|
||||
board-route assumptions are false now. Each needs a read and a port to an in-process `board` object:
|
||||
|
||||
- `packages/webui/tests/browser-edge.test.mjs:19` (waits for an HTTP `/api/board` request, lines 15 and 31)
|
||||
- `packages/webui/tests/preview.test.mjs:62` (fake board answers `/api/board`; line 160 asserts 200)
|
||||
- `packages/webui/tests/tester-listener.test.mjs:29` (`hits` counter on the fake board)
|
||||
- `packages/webui/tests/shell.test.mjs:111` and `:427` (fake board with unavailable, empty, one, fail, refuse modes)
|
||||
|
||||
Also a risk, not checked: any test that reaches a board route with no `board` argument. The default moved
|
||||
from 7331 (a 502 on a dead origin) to `null` (503). A test asserting 502 there would now fail.
|
||||
|
||||
### Range-diff
|
||||
|
||||
`git range-diff 48e450ce8..bushroot/row198-r3-253 45dca69f6..HEAD`, markers only:
|
||||
|
||||
1: 0bde5c8d7 ! 1: 4288b7b16 retire the board writer on 7331 (resolved above)
|
||||
2: 69ac924bc = 2: aad3000fe build design item 8; killers for M1-M15; B4 drops; docs; BUILD.md
|
||||
3: 716b46adf = 3: 77c12b554 rollback always drops --board (sage-2834 ruling a)
|
||||
4: 0bb5f230f = 4: c9c8badc3 drop --board for every op, reason by target (sage-2837)
|
||||
5: d388f47ec = 5: f2fd6145a real-run M13 pairs, reply join killer M16, real-board M14 (sage-2839)
|
||||
6: 02d5bd9b4 = 6: f913d0e76 index() clears a settled scan; B4 tests and mutant M17 (webby-0157)
|
||||
7: 25565fa97 = 7: 41b5aae3c BUILD.md places the reply join killer (B3, M16)
|
||||
8: 1fb66792e = 8: 3c57ebb22 BUILD.md records N2 as a named follow-up (sage-2868)
|
||||
|
||||
Seven commits are unchanged by patch-id. Only the retire commit is `!`.
|
||||
|
||||
### Files to rerun (auto-merged or changed on rebase)
|
||||
|
||||
The drift check found nine paths touched by both r3 and upstream. Six auto-merged and need a test rerun:
|
||||
|
||||
- `packages/cli/tests/stack-release.test.mjs`
|
||||
- `packages/webui/README.md`
|
||||
- `packages/webui/tests/browser.test.mjs`
|
||||
- `packages/webui/tests/chat-browser.test.mjs`
|
||||
- `packages/webui/tests/chat.test.mjs`
|
||||
- `scripts/stack-release.sh`
|
||||
|
||||
The three conflicted src files are in the hunk list above. The 21 upstream-stub test files listed above also
|
||||
need a rerun, because the drift check did not cover them: they are upstream-only changes.
|
||||
|
||||
### Gate status
|
||||
|
||||
UNVERIFIED. No node, no test run, and no mutant has run on r4. The gate waits for Sage's CLEAR, after 226,
|
||||
220/216 and 285 in gate order. The four fake-board tests above are known red. The rest are unknown until the
|
||||
run.
|
||||
|
||||
## r4: the four fake-board tests ported to the in-process board (sage-3597, sage-3603)
|
||||
|
||||
Scope: the four tests that read the retired fake HTTP board server, plus one new granted-user test. Numstat is
|
||||
against 14fe7513c, the r4 tip before this entry. Nothing in this section has been run. The gate is still
|
||||
UNVERIFIED, and the tests here were not executed (slot rule). Sage sends the list below to Mos (mos-1272).
|
||||
|
||||
| File | +/- | What changed |
|
||||
|---|---|---|
|
||||
| `packages/webui/tests/browser-edge.test.mjs` | 14/13 | The fake server is gone. `index()` holds while `holdBoard` is set and resolves to the page's board on release. `reply()` holds in place of the `held` response and returns `{status, body}`. The 5 s "made no /api/board request" check still runs: it now watches `index()` being called. Every assertion is kept. |
|
||||
| `packages/webui/tests/preview.test.mjs` | 12/16 | `setup()` builds an in-process board that records `GET /api/board`, `POST /api/seen` and `POST /api/reply` in `boardHits`. The 401 and read-only tests keep their `boardHits` checks. The dead-board case is a board whose `index()` throws, so the answer is 502 `invalid-response`. The upstream origin is no longer in the body (see the deleted assertion below). |
|
||||
| `packages/webui/tests/tester-listener.test.mjs` | 4/4 | The first test's `hits` counter moves into an in-process `index()`. Every bus and board read still counts, and both 403 checks are unchanged. The `createServer` import is removed. |
|
||||
| `packages/webui/tests/shell.test.mjs` | 44/63 | `board()` is an in-process board. Modes: `empty`, `one`, `registered`, `fail` (throws, so 502 `invalid-response`), `unavailable` (throws `ReadError('read-failed')`, so 503). `hold()` gates `index()`. The conversation catalogue and page are sync. Changed expectations (listed below). Refusal-only steps removed (listed below). |
|
||||
| `packages/webui/tests/granted-user.test.mjs` | 32/2 | `setup()` takes `board` and `send` options, defaulting to the old `null` and `false`. New test `R6 G4b`: a granted session gets 403 `owner-only` on all five board paths with no board read, and the owner session gets 200 on each, with the read order checked. |
|
||||
|
||||
### Changed expectations (not loosened; each is a product change, for Mos)
|
||||
|
||||
These expectations now name the in-process contract. The message text came from the fake server before. Each one is
|
||||
an old assertion with a new value.
|
||||
|
||||
- `shell.test.mjs` stale-data check: `/board scan failed.*Showing last known data/` is now `/invalid-response.*Showing last known data/`. A scan throw is mapped by `consoleRead` to `invalid-response` (502). Before, the fake returned 500 with the text.
|
||||
- `shell.test.mjs` unavailable check: `/board unavailable.*No board data loaded/` is now `/read-failed.*No board data loaded/`. `consoleRead` sends the code as `error`, and the page shows `err.message`, which is that code. The page shows `read-failed` now, where it showed `board unavailable` before.
|
||||
- `preview.test.mjs` dead board: the status stays 502, and `body.error` is now `invalid-response`.
|
||||
|
||||
### Deleted assertions (proxy-only, no in-process counterpart)
|
||||
|
||||
- `preview.test.mjs` `assert.equal(board.body.board, dead)`. The in-process board has no origin, so the body
|
||||
can't name one. The status and the bus checks around it are kept.
|
||||
- `shell.test.mjs` the refusal block: the fake's HTTP 403 `board-refused`. `consoleRead` has no 403 code: `STATUS`
|
||||
in `reads.mjs` maps only `not-configured`, `no-bus-host`, `queue-refused`, `read-failed`, `not-found` and
|
||||
`invalid-request`, and any other code is 502. So the in-process board cannot refuse, and `app.js`'s
|
||||
`err.status === 403` branch ("The board refused the read", `#board-refused`, the fail-closed drop of kept
|
||||
rows, the inspector and the counts) has no trigger in these tests. The block that was deleted asserted every
|
||||
one of these. The conversation refusal is deleted too: a 403 from `/api/conversation` closed the open
|
||||
conversation, and the new step closes it with `#conv-back`.
|
||||
This is a coverage loss, and it needs a decision. Either `reads.mjs` maps a board refusal to 403 (a product
|
||||
change, which Mos rules on), or the refusal branch is retired. The granted-user 403 `owner-only` answer goes
|
||||
through the same `err.status === 403` branch, so the page path is still reached by granted users. It is not
|
||||
covered by a shell check.
|
||||
|
||||
### Status change, dead board (Webby's question, webby-0229)
|
||||
|
||||
A board failure used to answer by the origin's state: a dead origin was 502 with the origin named, a board 500
|
||||
was 500, and a 503 passed through. Now:
|
||||
|
||||
- no board configured (`board` null): 503 `not-configured`;
|
||||
- a `ReadError` with a code in `STATUS`: that code's status;
|
||||
- any other throw (a scan failure): 502 `invalid-response` on `/api/board`. The 500 applies only on the
|
||||
conversation routes (`conversation read failed`).
|
||||
|
||||
No test under `packages/webui/tests` asserts 502 on a board path other than the preview dead-board case above.
|
||||
The grep: `usage`, `limits-route`, `routes`, `chat`, `library-status`, `thread-claims`, `send`, `bus`,
|
||||
`project-file`, `tester-listener`, `send-browser`, `instances`, `tester-cli` and `agents`. Those 502s belong to bus,
|
||||
chat, or project-file routes, which this change does not touch.
|
||||
|
||||
### Granted-user gate (sage-3603): the test and its mutant
|
||||
|
||||
The board is a closure that the granted rebind doesn't touch, so `userRoute` is the only gate in front of it
|
||||
for a granted session. Upstream's null-origin backstop is gone. Test `R6 G4b` pins that: the granted session gets
|
||||
403 `owner-only` on `GET /api/board`, `GET /api/conversations`, `GET /api/conversation`, `POST /api/seen` and
|
||||
`POST /api/reply`, and `reads` is still empty after those five requests. The owner session gets 200 on each, and
|
||||
`reads` shows the five calls in order.
|
||||
|
||||
Mutant M18 (not run): in `packages/webui/src/serve.mjs`, change the granted gate
|
||||
`if (!userRoute(path, req.method)) return json(res, 403, ...)` so it passes when `path` is one of the five board
|
||||
paths. Expected: `R6 G4b` fails at the granted 403 assertion, because the granted request reaches the board and
|
||||
`reads` is not empty. Run it with the rest of the granted-user suite once Sage gives CLEAR. The kill is not yet shown.
|
||||
|
||||
### Gate status
|
||||
|
||||
UNVERIFIED. These four files and the granted-user test are edited but not run. The rule "commit only after suites
|
||||
are green" is not yet met, and this commit says so. The coverage loss above needs Sage's ruling before the gate.
|
||||
|
||||
## r4 round 2: mos-1513 (B1 fix), N1 test, route helper (19:23Z 10-23)
|
||||
|
||||
Not run. Node and browser suites wait for Sage's CLEAR.
|
||||
|
||||
### B1: CDP interception instead of a named listener on the old files
|
||||
|
||||
Per mos-1513, the browser tests keep the real in-process board. Each one calls `routeWrites(b, base, board)` from
|
||||
`packages/webui/tests/board-route.mjs` before it navigates. Fetch intercepts the page's `/api/config` (answered
|
||||
with the real body and `send: true`), `/api/reply` and `/api/seen`. Each write is forwarded over node http to a
|
||||
named send listener on the same board, with the preview Host, the `https://` Origin and the signed user header.
|
||||
So the server's own gate, parse and status mapping answer, and the page gets that answer back. Nothing re-implements
|
||||
`boardSend`.
|
||||
|
||||
Shared helper, named for webby-0243: `board-route.mjs` exports `ROUTE`, `namedListener(board)`, `forward(port, path,
|
||||
body)` and `routeWrites(b, base, board)`. `namedListener` and `forward` are the same code `routeWrites` uses.
|
||||
|
||||
### Numstat (git diff --numstat, packages/webui/tests, against 4d8123b0a)
|
||||
|
||||
3 1 board-held.test.mjs
|
||||
7 2 browser-edge.test.mjs
|
||||
6 2 browser.test.mjs
|
||||
6 3 discord.test.mjs
|
||||
4 2 history-return-flow.test.mjs
|
||||
4 2 return-flow.test.mjs
|
||||
|
||||
New files: `board-route.mjs` (helper), `board-route-map.test.mjs` (node, see below), `board-refused.test.mjs` (N1).
|
||||
|
||||
### Line map: every removed line in the six old tests
|
||||
|
||||
No assertion line is changed. Every removed line is one of these, and each is listed by old line:
|
||||
|
||||
- board-held.test.mjs: `done` teardown (adds `stopRoute()` before `b.close()`); the `routeWrites` line is added in
|
||||
`setup`. No assertion.
|
||||
- browser-edge.test.mjs: the `await b.viewport(320); await b.navigate(...)` line is rewritten as the same two
|
||||
statements with the `routeWrites` line added before it; the `finally` adds `stopRoute?.()`. The `board` object
|
||||
is unchanged. `held` and `releaseBoard` are the same. No assertion.
|
||||
- browser.test.mjs: the `finally` adds `stopRoute?.()`; `routeWrites` is added before the `viewport`/`navigate`
|
||||
line, which is byte-identical. No assertion.
|
||||
- discord.test.mjs: the `fetch(base + '/api/reply', ...)` request line becomes `namedListener` + `forward`, the
|
||||
request setup, not the assertion; `assert.equal(reply.status, 409)` is byte-identical. The `finally` closes the
|
||||
named listener.
|
||||
- history-return-flow.test.mjs: `let b` gains `stopRoute`; `finally` adds `stopRoute()`. No assertion.
|
||||
- return-flow.test.mjs: `let web, b` gains `stopRoute`; `routeWrites` added after `browser()`; `finally` adds
|
||||
`stopRoute()`. No assertion.
|
||||
|
||||
### N1 (sage-3618, mos-1506 b): the refusal block, old to new
|
||||
|
||||
Old line numbers are in `shell.test.mjs` at 14fe7513c, the block 178-245. New line numbers are in
|
||||
`board-refused.test.mjs`. Every old assertion is restored with its text unchanged:
|
||||
|
||||
181 -> 74 183 -> 76 184 -> 77 185 -> 78 187 -> 80
|
||||
188 -> 81 189 -> 82 190 -> 83 191 -> 84 192 -> 85
|
||||
193 -> 86 194 -> 87 195 -> 88 196 -> 89 197 -> 90
|
||||
203 -> 97 204 -> 98 205 -> 99 206 -> 100 207 -> 101
|
||||
208 -> 102 209 -> 103 210 -> 104 211 -> 105 212 -> 106
|
||||
215 -> 109 216 -> 110 217 -> 111 218 -> 112 220 -> 114
|
||||
221 -> 115 227 -> 122 229 -> 124 232 -> 127 236 -> 131
|
||||
237 -> 132 238 -> 133 239 -> 134 240 -> 135 241 -> 136
|
||||
243 -> 138
|
||||
|
||||
Changes in the port: the page-level 403 stub (`Fetch` on `*/api/board*` and `*/api/conversation*`) is the one
|
||||
place a page-level stub is used, per N1. The board is the in-process one with `refuse()` set. The fake-board
|
||||
data in `board()` is the old fake board's data, moved.
|
||||
|
||||
Pinned N1 mutant (not run): in `packages/webui/public/app.js` (the `error(err)` branch that sets
|
||||
`refused = err.status === 403`), drop the `#board-refused` show and the `refused` banner. Expected:
|
||||
`board-refused.test.mjs` fails at line 80 (`banner ref`) and line 83 (`#board-refused` hidden). Run after CLEAR.
|
||||
|
||||
### Node coverage for the real route
|
||||
|
||||
`board-route-map.test.mjs` (new) starts the named listener on a fixture board and pins the mapping the browser
|
||||
tests now rely on: seen is 200; a delivered reply is 200 with `delivered: true`; an exit-4 reply is 200 with
|
||||
`delivered: false` and `exitCode: 4` (this is `replyWith` in control-board/src/serve.mjs, which maps a nonzero exit
|
||||
to 200, not a status code). Existing coverage cited, not duplicated: `board-inprocess.test.mjs:84-97` (seen 200 and
|
||||
reply delivered through a named listener); `send.test.mjs:114-119` (loopback Host writes 403 read-only, nothing
|
||||
reaches the board); `preview.test.mjs:185-199` (every write 403 read-only from the public name and loopback; 401
|
||||
unsigned on `/api/reply`).
|
||||
|
||||
The discord 409 is now reached through the named listener, so `discord.test.mjs` pins the connector refusal on the
|
||||
real route.
|
||||
|
||||
### Not done in this round
|
||||
|
||||
- The plain-listener browser check for the reply form and Seen line with send off (the `canSend` gate in app.js).
|
||||
The node and browser coverage for that gate is not written yet.
|
||||
- `send-browser.test.mjs:67,243` is unchanged. Webby to confirm next round.
|
||||
- Mutant M18 and the N1 mutant are not run.
|
||||
|
||||
### Gate status
|
||||
|
||||
UNVERIFIED. Nothing in this round is run. Waiting for Sage's CLEAR before any node or browser run.
|
||||
|
||||
### Correction to the r4 round 2 entry above (19:24Z 10-23)
|
||||
|
||||
- The N1 mutant's file is `packages/webui/src/public/app.js`, not `packages/webui/public/app.js`.
|
||||
- `board-refused.test.mjs` does not use the in-process board. Its `board()` is an inline stub object with a `refuse()`
|
||||
flag; the 403 comes from a page-level Fetch stub on `*/api/board*` and `*/api/conversation*`, as the N1 ruling
|
||||
allows. The "in-process board with refuse() set" sentence above is wrong.
|
||||
|
||||
## r4 round 2 fixes: mos-1522 (19:40Z 10-23)
|
||||
|
||||
Not run. Commit held locally until the suites pass (mos-1522).
|
||||
|
||||
- B1 (discord.test.mjs:43, :50): app.js now checks `r.connector` first, then `!canSend`, then the registered-seat text, in both the inspector reply line (app.js:100) and `convForm` (app.js:200, conv-no-reply). Discord still asserts "Board replies disabled for Discord connectors" with no routeWrites added. Assert lines are unchanged.
|
||||
- B2: `conv-no-reply` with send off reads "Replies are off: this Console is read-only.", asserted in send-off-browser.test.mjs.
|
||||
- routeWrites flips only `send` to true, over the real `/api/config` answer. Nothing else in the config body is changed.
|
||||
- Mutants (not run): (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Expected: send-off-browser fails on `.reply-form` absent (a) and on `[data-seen]` absent (b).
|
||||
|
||||
## r4 round 2 follow-up: Webby CHANGES on ab6417475 (N1, N2, B1 board-refused) (10-23 19:46Z)
|
||||
|
||||
Not run. Gate waits for Sage's CLEAR. Commit A of the follow-up; the old-test setup is commit B, below.
|
||||
|
||||
- N1: `routeWrites` builds the config URL with `new URL('/api/config', base)`, so a trailing-slash base no longer requests `//api/config` (which parses as host `api`). The config body now comes from one new export, `sendConfig(base)` (board-route.mjs), which `routeWrites` and `sendOnConfig` both use.
|
||||
- New export `sendOnConfig(b, base)` (board-route.mjs): Fetch answers the page's `/api/config` with send on and leaves every write alone. It is config only (mos-1524); its callers are in commit B.
|
||||
- board-refused.test.mjs: line 13 imports `sendConfig`; the Fetch pattern list adds `*/api/config` (line 59 onward); the handler answers `/api/config` with `sendConfig(base)` and returns before the refusal branch. The 403 refusal stub is unchanged. The assert that was line 122 (`#conv-form` hidden is false) is now line 128, unchanged; it sees send on.
|
||||
- N2, the "Not done" list above is out of date. Its line "the canSend check is not written" is wrong: the check is in app.js (ab6417475, mos-1522 fixes in b0c79751c). Written as of this follow-up:
|
||||
- app.js:24 `canSend` (set from `/api/config` at app.js:482); app.js:51 `canReply` gated on `canSend`; app.js:71 `seenButton` guard.
|
||||
- app.js:100 inspector reply line and app.js:200 `convForm` (conv-no-reply): connector first, then the read-only line, then the registered-seat line.
|
||||
- New user-visible strings: "Replies are off: this Console is read-only." and "Seen is off: this Console is read-only.".
|
||||
- New test files: board-route.mjs (helper), board-route-map.test.mjs (node), board-refused.test.mjs, send-off-browser.test.mjs.
|
||||
- Mutants planned, not run: (a) drop `canSend &&` from `canReply` (app.js:51); (b) drop the `seenButton` guard (app.js:71). Both must fail in send-off-browser.test.mjs.
|
||||
|
||||
## r4 round 2 follow-up, commit B: old-test setup for the canSend gate (Webby B1, mos-1524) (10-23 19:46Z)
|
||||
|
||||
Not run. Setup only: no assert line changed. Approved on the mos-1522 terms (mos-1524: config-only stub, not routeWrites). Gate waits for Sage's CLEAR.
|
||||
|
||||
Numstat for this commit (`git diff --numstat`):
|
||||
|
||||
| file | added | removed | change |
|
||||
| --- | --- | --- | --- |
|
||||
| packages/webui/tests/relaunch.test.mjs | 2 | 1 | line 10 imports `sendOnConfig`; line 32 `await sendOnConfig(b, webURL)` after `browser()` |
|
||||
| packages/webui/tests/shell.test.mjs | 2 | 0 | line 17 imports `sendOnConfig`; line 124 `await sendOnConfig(b, base)`, first test only |
|
||||
| packages/webui/tests/sso-browser.test.mjs | 1 | 1 | line 33 page stub: `/api/config` gets `send: true` on every page; `sso: true` still only with `sso` in the URL |
|
||||
|
||||
- relaunch.test.mjs:49 (`#reply` present, "reply eligibility unchanged") is unchanged and now sees send on. No teardown is needed: `sendOnConfig` starts no server; `b.close()` in the finally ends the Fetch domain.
|
||||
- shell.test.mjs:217 (`#conv-form` hidden is false) is unchanged and now sees send on. The five-states test (from about line 410) has no reply, Seen or conv-form assertion and is not changed.
|
||||
- sso-browser.test.mjs:91-92 (`[data-seen]` wait and click) is unchanged. Its `/api/seen` write is still answered 401 `sign-in required` by the page stub before it reaches the server, so the sign-in path is what runs. The sign-in lines and the `__signIn` list are unchanged.
|
||||
- board-refused.test.mjs:128 (`#conv-form` hidden is false) is in commit A, with its config stub; it needs no change here.
|
||||
- Totals for this commit: 5 added, 2 removed, 3 files. Assert lines changed: 0.
|
||||
|
||||
### Correction to "r4 round 2 fixes: mos-1522" (10-23 19:52Z)
|
||||
|
||||
- The line "Commit held locally until the suites pass (mos-1522)" is stale. The round 2 fixes were pushed as 397ef58be (after b0c79751c, 460355ccc and 397ef58be) once Mos ruled on the old-test setup (mos-1524). The suites have still not run.
|
||||
- Webby round 3 on 397ef58be: APPROVE, static only (webby-0262; review file on webby/reviews 9ffd417c7). Nothing run.
|
||||
@@ -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.
|
||||
@@ -951,7 +953,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,12 +1,14 @@
|
||||
#!/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 { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
import { accessFilePath, loadAccess } from '../../business/src/access.mjs';
|
||||
import { createBoard } from '../../control-board/src/board.mjs';
|
||||
import { discoverRepoAgents, discoverFleetAgents } from '../../control-board/src/scan.mjs';
|
||||
import { startTesterSocket } from './tester-socket.mjs';
|
||||
import { TESTER_CLI } from '../../cli/src/transport.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
@@ -15,14 +17,15 @@ 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';
|
||||
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();
|
||||
@@ -32,12 +35,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') {
|
||||
@@ -87,6 +92,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.
|
||||
@@ -107,14 +119,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, decisionWrite, userOptions, federation: system?.federation ?? null });
|
||||
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, decisionWrite, userOptions, federation: system?.federation ?? null });
|
||||
} 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),
|
||||
@@ -133,7 +145,7 @@ try {
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : '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;
|
||||
@@ -20,6 +20,8 @@
|
||||
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
|
||||
// Any other 401 stays an error. `sso` comes from /api/config.
|
||||
let sso = false;
|
||||
// Row 198 (mos-1512): the reply form and the Seen button draw only when /api/config says send; otherwise a line says so.
|
||||
let canSend = false;
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
@@ -46,7 +48,7 @@
|
||||
const s = Date.parse(sent), a = Date.parse(r.lastActivity);
|
||||
return Number.isFinite(s) && Number.isFinite(a) && a < s;
|
||||
}
|
||||
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const canReply = r => canSend && !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
|
||||
const row = id => data?.sessions.find(r => key(r) === id);
|
||||
const announce = text => { $('announce').textContent = text; };
|
||||
// Colours come from tokens.css by data-palette and data-mode. System removes data-mode, so the OS setting picks light or dark.
|
||||
@@ -66,6 +68,7 @@
|
||||
$('mode').onchange = e => { mode = e.target.value; theme(); };
|
||||
function seenButton(r) {
|
||||
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
|
||||
if (!canSend) return '<span class="small muted">Seen is off: this Console is read-only.</span>';
|
||||
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
|
||||
}
|
||||
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
|
||||
@@ -94,7 +97,7 @@
|
||||
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
|
||||
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
|
||||
const receipt = receipts.get(selected);
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}${pending(r) ? `<dt>Reply</dt><dd>Waiting for a reply to your message sent ${esc(awaiting.get(selected))}. The page checks every 10 seconds${paused ? ' once you resume' : ''}.</dd><dt>${r.relaunchedAt ? 'Historical assistant text' : 'Previous assistant text'}, before your message</dt>` : `<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt>`}<dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${historyButton(r)}${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
|
||||
}
|
||||
// Conversation view (#1507, CHAT-02): the full read-only history from the board's
|
||||
// /api/conversations and /api/conversation routes. Session content is set with
|
||||
@@ -194,7 +197,7 @@
|
||||
if (!conv) return;
|
||||
const r = row(conv.row), can = !!r && !!canReply(r), receipt = receipts.get(conv.row);
|
||||
$('conv-form').hidden = !can; $('conv-no-reply').hidden = can;
|
||||
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat';
|
||||
$('conv-no-reply').textContent = !r ? 'This session is no longer in the board scan.' : r.connector ? 'Board replies disabled for Discord connectors' : !canSend ? 'Replies are off: this Console is read-only.' : 'reply needs a registered seat';
|
||||
if (r) $('conv-reply-label').textContent = `Reply to ${r.agent}`;
|
||||
$('conv-send').disabled = sending.has(conv.row); $('conv-send').textContent = sending.has(conv.row) ? 'Sending…' : 'Send';
|
||||
$('conv-receipt').hidden = !receipt;
|
||||
@@ -476,5 +479,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}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
api('/api/config').then(config => { canSend = config.send === true; board = config.board || 'the Console board'; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
@@ -13,19 +13,9 @@ import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-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']],
|
||||
@@ -191,6 +181,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;
|
||||
@@ -601,7 +605,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, decisionWrite = null, userOptions = null, federation = 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, decisionWrite = null, userOptions = null, federation = 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');
|
||||
@@ -615,11 +620,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
|
||||
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
|
||||
const confirmations = createConfirmations();
|
||||
const upstream = boardURL(board);
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files };
|
||||
const hub = federationView(federation);
|
||||
// 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 }))) {
|
||||
@@ -627,9 +631,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
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) => {
|
||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, files } = owner, granted = false;
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
@@ -696,7 +701,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
reads = null; upstream = null; files = USER_FILES; granted = true;
|
||||
reads = null; files = USER_FILES; granted = true;
|
||||
}
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
@@ -779,31 +784,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' });
|
||||
if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' });
|
||||
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' });
|
||||
@@ -823,7 +812,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());
|
||||
@@ -833,10 +822,18 @@ 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.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: granted ? null : boardLabel, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && 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);
|
||||
|
||||
@@ -17,7 +17,7 @@ const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired
|
||||
|
||||
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
try{
|
||||
// POSTs answer from `replies`, request reads from `polls`; both record what was asked.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
|
||||
@@ -131,7 +131,7 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
|
||||
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
|
||||
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
|
||||
const port=web.address().port;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
|
||||
const businesses = {
|
||||
demo: {
|
||||
@@ -72,7 +73,8 @@ 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}/`;
|
||||
const stopRoute = await routeWrites(b, base, f.board);
|
||||
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)})})()`);
|
||||
@@ -81,7 +83,7 @@ async function setup(machine, fast = false) {
|
||||
const reqs = () => b.evaluate('window.__reqs');
|
||||
const polls = () => b.evaluate('window.__polls');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
const done = async () => { await stopRoute(); await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, polls, settle, done };
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// Row 198 r4 (sage-3618, mos-1506): the board refusal. The in-process board never answers 403 (consoleRead
|
||||
// has no 403 code), so the browser stubs the answer: while the board is refused, Fetch fulfils /api/board
|
||||
// and /api/conversation(s) with 403 board-refused, as the old fake board server did. The assertions are the
|
||||
// refusal block that 4d8123b0a deleted from shell.test.mjs, ported one for one (BUILD.md, r4 mapping).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { ReadError } from '../src/reads.mjs';
|
||||
import { sendConfig } from './board-route.mjs';
|
||||
|
||||
// The old fake board's data: agent1 waiting on you in proj, plus agent2 and agent3, one conversation.
|
||||
function board() {
|
||||
const state = { refused: false, down: false, mode: 'ok' };
|
||||
let index = { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
|
||||
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
|
||||
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
|
||||
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
|
||||
const set = (sessions, counts) => { index = { sessions, counts, generatedAt: new Date().toISOString() }; state.mode = 'ok'; };
|
||||
return {
|
||||
state,
|
||||
board: {
|
||||
index: () => {
|
||||
if (state.down) throw new ReadError('read-failed', 'board unavailable');
|
||||
if (state.mode === 'fail') throw new Error('board scan failed');
|
||||
return index;
|
||||
},
|
||||
conversation: url => {
|
||||
if (state.mode === 'fail') throw new Error('catalogue read failed');
|
||||
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
|
||||
},
|
||||
markSeen: () => ({ status: 200, body: { ok: true } }),
|
||||
reply: () => ({ status: 200, body: { delivered: true } }),
|
||||
},
|
||||
one: () => { state.refused = false; state.down = false; set([one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], { waiting: 1, working: 1 }); },
|
||||
registered: () => { state.refused = false; state.down = false; set([{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], { waiting: 1 }); },
|
||||
fail: () => { state.refused = false; state.mode = 'fail'; },
|
||||
unavailable: () => { state.refused = false; state.down = true; },
|
||||
refuse: () => { state.refused = true; },
|
||||
};
|
||||
}
|
||||
|
||||
test('a board refusal fails closed: the page hides, the banner says refused, the conversation closes and clears', { timeout: 180000 }, async () => {
|
||||
const brd = board(); brd.one();
|
||||
const web = await startServer({ port: 0, board: brd.board });
|
||||
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
|
||||
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 count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
|
||||
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
|
||||
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
|
||||
// The stub: while refused, the board's reads get the 403 the old fake server gave; otherwise they go on to the Console.
|
||||
// /api/config answers with send on (mos-1524: config only), so the conversation's reply form is drawn.
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/board*', requestStage: 'Request' }, { urlPattern: '*/api/conversation*', requestStage: 'Request' }, { urlPattern: '*/api/config', requestStage: 'Request' }] });
|
||||
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
|
||||
if (new URL(req.url).pathname === '/api/config') {
|
||||
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(await sendConfig(base)).toString('base64') }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const answer = brd.state.refused
|
||||
? b.call('Fetch.fulfillRequest', { requestId, responseCode: 403, responseHeaders: [{ name: 'content-type', value: 'application/json' }], body: Buffer.from(JSON.stringify({ error: 'board-refused' })).toString('base64') })
|
||||
: b.call('Fetch.continueRequest', { requestId });
|
||||
answer.catch(() => {});
|
||||
});
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
|
||||
await b.viewport(400, 900);
|
||||
await b.navigate(base); await wait('!document.querySelector("#sessions [aria-busy]")');
|
||||
await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
|
||||
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
||||
// them: the open inspector, the project filter, the project tree's counts and the footer.
|
||||
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
|
||||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
|
||||
assert.equal(await count('#seen .wait-item'), 1);
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
|
||||
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
|
||||
assert.equal(await text('#footer'), 'Board not read: refused');
|
||||
assert.equal(await text('#fresh-board'), 'Board not read: refused');
|
||||
assert.doesNotMatch(await text('#status'), /Scanned/);
|
||||
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
await flat(); await shot('board-refused-400');
|
||||
|
||||
// A failed read after the refusal shows the board again, empty: no row, card or count from
|
||||
// the refused scan, and the footer says what the banner says. With nothing focused, focus stays put.
|
||||
await b.evaluate('document.activeElement.blur()');
|
||||
brd.unavailable(); await refresh();
|
||||
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
|
||||
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
assert.equal(await text('#footer'), 'Board not read: the read failed');
|
||||
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
|
||||
assert.equal(await text('#status'), 'Not read: the read failed');
|
||||
assert.equal(await text('#projects'), 'Not read: the read failed.');
|
||||
await flat(); await shot('board-refused-then-failed-400');
|
||||
brd.one(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
// A good read after a failed one drops the stale words.
|
||||
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
||||
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
|
||||
|
||||
// A refusal with a conversation open closes it and clears its history and reply form: the
|
||||
// session is in no scan the page holds.
|
||||
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), false);
|
||||
// Fix to HEAD behaviour: a failed catalogue read leaves no session from the last one in the picker.
|
||||
assert.equal(await count('#conv-pick option'), 1);
|
||||
brd.fail(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-status").textContent.startsWith("History unavailable")');
|
||||
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
|
||||
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
|
||||
assert.equal(await count('#conv-log > *'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
|
||||
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await close(web); }
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Row 198 (mos-1513): the write mapping the browser tests reach through board-route.mjs, pinned in node
|
||||
// against the named send listener itself. The Console's own gate, parse and status mapping answer here;
|
||||
// the browser tests only get back what this listener returns.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { close, fixture } from './fixture.mjs';
|
||||
import { forward, namedListener } from './board-route.mjs';
|
||||
|
||||
test('named send listener maps the board writes: seen is 200, a delivered reply is 200 delivered, an exit-4 reply is 200 not delivered', { timeout: 60000 }, async t => {
|
||||
const f = await fixture();
|
||||
const named = await namedListener(f.board);
|
||||
t.after(async () => { await close(named); await f.close(); });
|
||||
const port = named.address().port;
|
||||
const rec = (await f.board.index()).sessions.find(r => r.agent === 'agent1');
|
||||
const row = `${rec.project}/${rec.agent}`;
|
||||
|
||||
const seen = await forward(port, '/api/seen', JSON.stringify({ project: rec.project, agent: rec.agent, lastActivity: rec.lastActivity, seen: true }));
|
||||
assert.equal(seen.status, 200, seen.text);
|
||||
|
||||
const delivered = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
|
||||
assert.equal(delivered.status, 200, delivered.text);
|
||||
assert.equal(JSON.parse(delivered.text).delivered, true);
|
||||
assert.equal(f.captures.length, 1);
|
||||
|
||||
f.failReply();
|
||||
const refused = await forward(port, '/api/reply', JSON.stringify({ agent: row, text: 'hi' }));
|
||||
assert.equal(refused.status, 200, refused.text);
|
||||
const body = JSON.parse(refused.text);
|
||||
assert.equal(body.delivered, false);
|
||||
assert.equal(body.exitCode, 4);
|
||||
assert.equal(f.captures.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Row 198 (mos-1513): the browser tests reach the real board routes. Fetch intercepts the page's
|
||||
// /api/config (send on) and its /api/reply and /api/seen writes. Node forwards each write to a named
|
||||
// send listener on the same board, with the preview Host, the https Origin and the signed user header,
|
||||
// so the server's own gate, parse and status mapping answer and the page gets that answer back.
|
||||
// Nothing here re-implements boardSend; the node route tests pin what the listener returns.
|
||||
import { request } from 'node:http';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
export const ROUTE = { host: 'board-route.example', header: 'X-authentik-uid', user: 'board-route-user' };
|
||||
const JSON_HEADER = [{ name: 'content-type', value: 'application/json' }];
|
||||
|
||||
// The named send listener on the board: the preview Host, so the server's own gate and status mapping answer.
|
||||
export function namedListener(board) {
|
||||
return startServer({ port: 0, board, preview: ROUTE, send: async () => ({ id: 'board-route' }) });
|
||||
}
|
||||
|
||||
// One POST to the named listener, as the browser's forwarded write would arrive. Resolves { status, text }.
|
||||
export function forward(port, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { host: ROUTE.host, origin: `https://${ROUTE.host}`, [ROUTE.header]: ROUTE.user, 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers }, res => {
|
||||
let text = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => { text += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
// The page's /api/config body with send on and everything else as the Console answers it. The base URL may end in a slash.
|
||||
export async function sendConfig(base) {
|
||||
return JSON.stringify({ ...(await (await fetch(new URL('/api/config', base))).json()), send: true });
|
||||
}
|
||||
|
||||
// Config only (mos-1524): Fetch answers the page's /api/config with send on and leaves every write alone, so the
|
||||
// writes go to the page's own listener on its existing path. The Fetch domain stays on until the browser closes.
|
||||
export async function sendOnConfig(b, base) {
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*/api/config', requestStage: 'Request' }] });
|
||||
b.on('Fetch.requestPaused', async ({ requestId }) => {
|
||||
try {
|
||||
await b.call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: JSON_HEADER, body: Buffer.from(await sendConfig(base)).toString('base64') });
|
||||
} catch {
|
||||
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Returns a function that closes the named listener. Call it in the test's finally, before the browser closes.
|
||||
export async function routeWrites(b, base, board) {
|
||||
const listener = await namedListener(board);
|
||||
const port = listener.address().port;
|
||||
await b.call('Fetch.enable', { patterns: [
|
||||
{ urlPattern: '*/api/config', requestStage: 'Request' },
|
||||
{ urlPattern: '*/api/reply', requestStage: 'Request' },
|
||||
{ urlPattern: '*/api/seen', requestStage: 'Request' },
|
||||
] });
|
||||
b.on('Fetch.requestPaused', async ({ requestId, request: req }) => {
|
||||
try {
|
||||
const path = new URL(req.url).pathname;
|
||||
let answer;
|
||||
if (path === '/api/config') {
|
||||
answer = { status: 200, text: await sendConfig(base) };
|
||||
} else {
|
||||
answer = await forward(port, path, req.postData ?? '');
|
||||
}
|
||||
await b.call('Fetch.fulfillRequest', { requestId, responseCode: answer.status, responseHeaders: JSON_HEADER, body: Buffer.from(answer.text).toString('base64') });
|
||||
} catch {
|
||||
await b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => close(listener);
|
||||
}
|
||||
@@ -1,26 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
|
||||
test('browser edge states: loading, empty, malformed, stale, hostile/long values, in-flight reply and appearance fallback', { timeout: 120000 }, async () => {
|
||||
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, boardResponse;
|
||||
let index = { sessions: [], counts: {}, generatedAt: 'fixture time' }, held = null, sends = 0, holdBoard = true, releaseBoard;
|
||||
let resolveBoardRequest;
|
||||
const boardRequested = new Promise(resolve => { resolveBoardRequest = resolve; });
|
||||
const server = createServer(async (req, res) => {
|
||||
for await (const chunk of req) { void chunk; }
|
||||
res.setHeader('content-type', 'application/json');
|
||||
if (req.url === '/api/board') { if (holdBoard) { boardResponse = res; resolveBoardRequest(); return; } return res.end(JSON.stringify(index)); }
|
||||
sends++; held = res;
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${server.address().port}` });
|
||||
// The board runs in-process (row 198): index() is the page's /api/board read, reply() its reply.
|
||||
const board = {
|
||||
index: () => {
|
||||
if (!holdBoard) return index;
|
||||
resolveBoardRequest();
|
||||
return new Promise(resolve => { releaseBoard = resolve; });
|
||||
},
|
||||
reply: () => { sends++; return new Promise(resolve => { held = resolve; }); },
|
||||
};
|
||||
const web = await startServer({ port: 0, board });
|
||||
const b = await browser();
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
let stopRoute;
|
||||
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')})()`);
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'Object.defineProperty(window,"localStorage",{get(){throw new Error("storage denied")}});window.errors=[];addEventListener("error",e=>errors.push(e.message));' });
|
||||
stopRoute = await routeWrites(b, base, board);
|
||||
await b.viewport(320); await b.navigate(`http://127.0.0.1:${web.address().port}`);
|
||||
// Row 95: with storage denied the toggle starts off and still turns on; the page throws nothing.
|
||||
assert.match(await b.evaluate('document.querySelector("#status").textContent'), /Machine sessions hidden/);
|
||||
@@ -30,7 +35,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
let timer;
|
||||
try { await Promise.race([boardRequested, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Show machine sessions made no /api/board request in 5 s')), 5000); })]); }
|
||||
finally { clearTimeout(timer); }
|
||||
holdBoard = false; boardResponse.end(JSON.stringify(index));
|
||||
holdBoard = false; releaseBoard(index);
|
||||
await wait('document.querySelector("#sessions").textContent.includes("No sessions")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
|
||||
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
|
||||
@@ -52,7 +57,7 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
|
||||
assert.equal(await b.evaluate('document.querySelector(".reply-form button").disabled'), true);
|
||||
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'newer ' });
|
||||
held.end(JSON.stringify({ delivered: true, sentAt: 'fixture', session: agent }));
|
||||
held({ status: 200, body: { delivered: true, sentAt: 'fixture', session: agent } });
|
||||
await wait('document.querySelector(".receipt")?.textContent.startsWith("delivered")');
|
||||
assert.equal(sends, 1); assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
|
||||
// A stale registration removes the form, but does not destroy its draft.
|
||||
@@ -92,5 +97,5 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
|
||||
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /Invalid board session data/);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions [data-open]").length'), 1);
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { boardResponse?.end(); held?.end(); await b.close(); await close(web); await close(server); }
|
||||
} finally { releaseBoard?.(index); held?.({ status: 200, body: {} }); await stopRoute?.(); await b.close(); await close(web); }
|
||||
});
|
||||
@@ -4,14 +4,17 @@ import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture } from './fixture.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
|
||||
test('served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures', { timeout: 120000 }, async () => {
|
||||
const f = await fixture(), b = await browser();
|
||||
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')})()`);
|
||||
let stopRoute;
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
|
||||
stopRoute = await routeWrites(b, f.base, f.board);
|
||||
await b.viewport(1440); await b.navigate(f.base); await wait('document.querySelectorAll("table.sessions tbody tr").length===3');
|
||||
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '2');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects [data-project]").length'), 3);
|
||||
@@ -109,15 +112,15 @@ 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');
|
||||
assert.match(await b.evaluate('document.querySelector("#error").textContent'), /No board data loaded/);
|
||||
} finally { await b.close(); await f.close(); }
|
||||
} finally { await stopRoute?.(); await b.close(); await f.close(); }
|
||||
});
|
||||
@@ -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)})})()`);
|
||||
|
||||
@@ -48,7 +48,7 @@ function seeded(t) {
|
||||
|
||||
test('bus routes serve the four reads from the reader view, unescaped JSON for the page to escape', async t => {
|
||||
const { decision, bus, calls } = seeded(t);
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const web = await startServer({ port: 0, board: null, bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
|
||||
@@ -106,7 +106,7 @@ test('decisions route serves the roles and recent lists from the reader view', a
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const web = await startServer({ port: 0, board: null, bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
|
||||
@@ -140,7 +140,7 @@ test('credentials route serves the broker credential status from the reader view
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const calls = [];
|
||||
const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const web = await startServer({ port: 0, board: null, bus });
|
||||
t.after(() => close(web));
|
||||
const r = await fetch(`http://127.0.0.1:${web.address().port}/api/bus/credentials`);
|
||||
assert.equal(r.status, 200); assert.equal(r.headers.get('cache-control'), 'no-store');
|
||||
@@ -170,7 +170,7 @@ test('replies route serves the replies to one message from the reader view', asy
|
||||
try { return structuredClone(b.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
|
||||
};
|
||||
const bus = views({ call });
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const web = await startServer({ port: 0, board: null, bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
|
||||
@@ -197,7 +197,7 @@ test('replies route serves the replies to one message from the reader view', asy
|
||||
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
|
||||
const calls = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'].map(v => [v, async () => { calls.push(v); return []; }]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const web = await startServer({ port: 0, board: null, bus });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) {
|
||||
@@ -218,8 +218,8 @@ test('bus routes are GET-only, have no write verb, and keep the same-origin chec
|
||||
test('bus refusals map to statuses and never carry a path or a stack', async t => {
|
||||
let fail;
|
||||
const bus = { inbox: async () => { throw fail; }, tasks: async () => ({ not: 'rows' }), agents: async () => [], trail: async () => [] };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const none = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
const web = await startServer({ port: 0, board: null, bus });
|
||||
const none = await startServer({ port: 0, board: null });
|
||||
t.after(() => Promise.all([close(web), close(none)]));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const inbox = async () => { const r = await fetch(base + '/api/bus/inbox'); return [r.status, (await r.json())]; };
|
||||
@@ -275,7 +275,7 @@ test('humanCall speaks the human transport protocol without blocking the server'
|
||||
assert.equal(await humanCall({ socket: 's', business: 'demo', cli: join(tmpdir(), 'no-such-dir-x', 'cli.mjs') })('inbox').catch(e => e.code), 'invalid-response');
|
||||
|
||||
// A slow read stays slow on its own: the server keeps answering meanwhile.
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
|
||||
const web = await startServer({ port: 0, board: null, bus: views({ call: (verb) => call(verb, { mode: 'slow' }) }) });
|
||||
t.after(() => close(web));
|
||||
const base = `http://127.0.0.1:${web.address().port}`;
|
||||
const started = Date.now();
|
||||
|
||||
@@ -47,7 +47,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`);
|
||||
|
||||
@@ -53,7 +53,7 @@ test('C2-09a metadata uses the human listener bus, projects labels only and refu
|
||||
test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
let reads = 0;
|
||||
const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
|
||||
const server = await startTesterSocket({ dataRoot: root, board: null,
|
||||
bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
|
||||
t.after(() => close(server));
|
||||
const answer = await get(server);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -74,7 +74,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();
|
||||
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)})})()`);
|
||||
|
||||
@@ -56,7 +56,7 @@ async function setup(t, server = {}, access = null) {
|
||||
};
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
|
||||
const userOptions = access && { userOptions: userOptionsFor({ owner: async () => ({}), send: true, transport: () => async () => [] }) };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...userOptions, ...server });
|
||||
const web = await startServer({ port: 0, board: null, bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...userOptions, ...server });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port;
|
||||
const signIn = async (sub = undefined) => {
|
||||
@@ -134,7 +134,7 @@ test('J16: without a decision transport the routes are read-only and /api/config
|
||||
const write = async () => ({});
|
||||
await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/);
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
const plain = await startServer({ port: 0, board: null });
|
||||
t.after(() => close(plain));
|
||||
const port = plain.address().port, host = `127.0.0.1:${port}`;
|
||||
const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
|
||||
|
||||
@@ -3,16 +3,16 @@ 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';
|
||||
import { forward, namedListener } from './board-route.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, named;
|
||||
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,19 +23,21 @@ 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');
|
||||
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
|
||||
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
|
||||
assert.equal(JSON.stringify(payload).includes('forged'), false);
|
||||
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
|
||||
// The 409 is the board's refusal, reached through the named send listener; the loopback listener has no send and answers 403 first.
|
||||
named = await namedListener(board);
|
||||
const reply = await forward(named.address().port, '/api/reply', JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }));
|
||||
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 +60,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 (named) await close(named); 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 }); },
|
||||
};
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'al
|
||||
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
|
||||
// human listener. The owner's options are counting stubs; each user session's options are
|
||||
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
|
||||
async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
|
||||
async function setup(t, { users = [ALICE], grants = [grant()], board = null, send = false } = {}) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = op.issuer('a');
|
||||
@@ -103,7 +103,7 @@ async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
|
||||
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])),
|
||||
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])),
|
||||
};
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log, ...owner, userOptions, federation: FEDERATION });
|
||||
const web = await startServer({ port: 0, board, send, sso, log, ...owner, userOptions, federation: FEDERATION });
|
||||
t.after(() => close(web));
|
||||
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
|
||||
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id;
|
||||
@@ -246,6 +246,36 @@ test('R6 G4 every owner route answers 403 owner-only before any bus call', async
|
||||
assert.equal(events(env), before, 'something was written');
|
||||
});
|
||||
|
||||
// Row 198 (sage-3603): the board runs in-process behind userRoute alone; upstream's null-origin backstop
|
||||
// is gone. A granted session is refused on all five board paths before any board read, and the owner's
|
||||
// session reaches each one. The mutant that drops the userRoute check for these paths must fail here.
|
||||
test('R6 G4b the five board paths: a granted session is refused before any board read, the owner reaches all five', async t => {
|
||||
const reads = [];
|
||||
const board = {
|
||||
index: async () => { reads.push('index'); return { sessions: [], counts: {}, generatedAt: 'fixture' }; },
|
||||
conversation: url => { reads.push(url.pathname); return { status: 200, body: { conversations: [] } }; },
|
||||
markSeen: async () => { reads.push('markSeen'); return { sessions: [] }; },
|
||||
reply: async () => { reads.push('reply'); return { status: 200, body: { delivered: true } }; },
|
||||
};
|
||||
const env = await setup(t, { board, send: true });
|
||||
const { session: granted } = await signIn(env, 'alice-sub');
|
||||
const { session: owner } = await signIn(env, 'owner-sub');
|
||||
const seen = JSON.stringify({ project: 'p', agent: 'a', lastActivity: 'x', seen: true }), reply = JSON.stringify({ agent: 'p/a', text: 'hi' });
|
||||
const paths = [['GET', '/api/board'], ['GET', '/api/conversations'], ['GET', '/api/conversation?conversation=c1'], ['POST', '/api/seen', seen], ['POST', '/api/reply', reply]];
|
||||
const headers = (method, cookie) => (method === 'GET' ? { cookie } : writeHeaders(cookie));
|
||||
for (const [method, path, body] of paths) {
|
||||
const r = await call(env.port, { method, path, headers: headers(method, granted), body });
|
||||
assert.equal(r.status, 403, `granted ${method} ${path}`);
|
||||
assert.equal(r.body.error, 'owner-only', `granted ${method} ${path}`);
|
||||
}
|
||||
assert.deepEqual(reads, [], 'a granted session reached the board');
|
||||
for (const [method, path, body] of paths) {
|
||||
const r = await call(env.port, { method, path, headers: headers(method, owner), body });
|
||||
assert.equal(r.status, 200, `owner ${method} ${path}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
assert.deepEqual(reads, ['index', '/api/conversations', '/api/conversation', 'markSeen', 'reply']);
|
||||
});
|
||||
|
||||
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
|
||||
const env = await setup(t);
|
||||
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
|
||||
@@ -336,7 +366,7 @@ test('R6 G7 the hub label and plain peer links render from config on both pages'
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
// The owner page, over the real server.
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const owner = await startServer({ port: 0, board: f.boardURL, federation: FEDERATION }); t.after(() => close(owner));
|
||||
const owner = await startServer({ port: 0, board: f.board, federation: FEDERATION }); t.after(() => close(owner));
|
||||
await b.navigate(`http://127.0.0.1:${owner.address().port}/`);
|
||||
await wait(b, '!document.querySelector("#federation").hidden');
|
||||
assert.deepEqual(JSON.parse(await b.evaluate(links)), expected);
|
||||
|
||||
@@ -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 }); },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
import { historyFixture, session, user, assistant, toolResult, at, waiter, turns } from './history-fixture.mjs';
|
||||
|
||||
const PEER = '[from: darkwing (7525c8e4) -> to: fixture (00000000)] peer note that lands mid-turn';
|
||||
@@ -16,7 +17,7 @@ const BIG = 'BIG_START ' + Array.from({ length: 900000 }, (_, i) => (i % 1679616
|
||||
|
||||
test('return flow through the conversation view: send, tool call, delayed result, peer message, exact long answers, relaunch', { timeout: 240000 }, async () => {
|
||||
const f = await historyFixture();
|
||||
let b;
|
||||
let b, stopRoute;
|
||||
try {
|
||||
const file = join(f.sessionsDir, '2026-09-26T10-00-00_s1.jsonl');
|
||||
const log = session(file, f.projectRoot);
|
||||
@@ -24,6 +25,7 @@ test('return flow through the conversation view: send, tool call, delayed result
|
||||
log.add(assistant('Input needed: OLD_ANSWER'), at(-90));
|
||||
|
||||
b = await browser(); await b.viewport(1440, 1000);
|
||||
stopRoute = await routeWrites(b, f.base, f.board);
|
||||
const wait = waiter(b);
|
||||
const draft = () => b.evaluate('(() => { const t = document.querySelector("#conv-reply"); return [t.value, t.selectionStart, document.activeElement === t]; })()');
|
||||
// The seat is waiting on Jason, so the view opens from its card.
|
||||
@@ -95,5 +97,5 @@ test('return flow through the conversation view: send, tool call, delayed result
|
||||
assert.notEqual(await b.evaluate('document.querySelector("#conv-pick").value'), opened);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-marker=newer]")'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-reply").value'), 'NEXT_DRAFT');
|
||||
} finally { if (b) await b.close(); await f.close(); }
|
||||
} finally { if (stopRoute) await stopRoute(); if (b) await b.close(); await f.close(); }
|
||||
});
|
||||
@@ -18,7 +18,7 @@ test('row 165 Settings: role instances read, add with a restart named, change wi
|
||||
// The server's read follows each write, so a poll between steps shows what the stub answered.
|
||||
let current=view;
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>current};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
try{
|
||||
// The stub answers each POST with the next queued reply and records the body.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/instances'&&options.method==='POST'){posts.push(JSON.parse(options.body));const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
|
||||
|
||||
@@ -21,7 +21,7 @@ const TOKENS=[
|
||||
test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced and check failed; Reset posts one path; a save keeps the token limits',{timeout:90000},async()=>{
|
||||
let answer={revision:launch.revision,limits:launch.limits,tokens:TOKENS};
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>answer};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'b'.repeat(64)}));}return original(url,options);};`});
|
||||
const port=web.address().port;
|
||||
@@ -74,7 +74,7 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced
|
||||
test('Row 167 with an assistant draft open, a token limit reset gives the assistant submit its revision',{timeout:90000},async()=>{
|
||||
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-opus-5-5'},tracker:null}}};
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},limits:async()=>({revision:launch.revision,limits:launch.limits,tokens:TOKENS})};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{},launch:withAssistant})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:true}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));return new Response(JSON.stringify({...fixture,revision:'bc'[posts.length-1].repeat(64)}));}return original(url,options);};`});
|
||||
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
|
||||
|
||||
@@ -15,7 +15,7 @@ function seed(t){
|
||||
}
|
||||
test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{
|
||||
const s=seed(t),a=s.send('first'),b=s.send('second');
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});t.after(()=>close(web));
|
||||
const web=await startServer({port:0,board:null,bus:s.bus});t.after(()=>close(web));
|
||||
const url=`http://127.0.0.1:${web.address().port}/api/bus/`;
|
||||
const get=async path=>{const res=await fetch(url+path);return {status:res.status,body:await res.json()}};
|
||||
const page=await get('messages?limit=1');assert.equal(page.status,200);
|
||||
@@ -34,7 +34,7 @@ test('messages HTTP read pages and preserves metadata; bad arguments refuse befo
|
||||
});
|
||||
test('Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver', {timeout:45000},async t=>{
|
||||
const s=seed(t),early=s.send('early');
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});
|
||||
const web=await startServer({port:0,board:null,bus:s.bus});
|
||||
const b=await browser({machine:false});
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out')})()`);
|
||||
try{
|
||||
@@ -78,7 +78,7 @@ test('Messages nav count polls on Tasks and Board and clears on a Board refusal;
|
||||
business:async()=>{localReads.push('business');return {business:{id:'demo',human:'jason',arbiters:{},projects:[],vars:{},instances:[],actions:[],credentials:[]},at:at()};},
|
||||
settings:async()=>{localReads.push('settings');return {release:'0.0.1',business:'demo',notifier:{binding:null},at:at()};},
|
||||
};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads});
|
||||
const web=await startServer({port:0,board:null,bus,reads});
|
||||
const b=await browser({machine:false});
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const badge=`document.querySelector('#sections a[href="#/messages"] .count')`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
||||
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
||||
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
|
||||
@@ -7,7 +5,7 @@ import { after as laneAfter } from 'node:test';
|
||||
// fails closed instead of reaching the loopback path.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, request } from 'node:http';
|
||||
import { request } from 'node:http';
|
||||
import { connect } from 'node:net';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -46,20 +44,17 @@ function raw(port, lines) {
|
||||
const signedIn = { [HEADER]: ID };
|
||||
const json = { 'content-type': 'application/json' };
|
||||
|
||||
// A board that counts what reaches it, and a bus that counts its reads.
|
||||
// A board that counts what reaches it (in-process, row 198), and a bus that counts its reads.
|
||||
async function setup(t, options = {}) {
|
||||
const boardHits = [];
|
||||
const upstream = createServer((req, res) => {
|
||||
boardHits.push(`${req.method} ${req.url}`);
|
||||
res.writeHead(200, json);
|
||||
res.end(req.url === '/api/board' ? '{"sessions":[]}' : '{"ok":true}');
|
||||
});
|
||||
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
|
||||
// Closed first and on its own, so a refused startServer doesn't leave it holding the process open.
|
||||
t.after(() => close(upstream));
|
||||
const board = {
|
||||
index: async () => { boardHits.push('GET /api/board'); return { sessions: [] }; },
|
||||
markSeen: async () => { boardHits.push('POST /api/seen'); return { sessions: [] }; },
|
||||
reply: async () => { boardHits.push('POST /api/reply'); return { status: 200, body: { delivered: true } }; },
|
||||
};
|
||||
const busCalls = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, ...options });
|
||||
const web = await startServer({ port: 0, board, bus, preview, ...options });
|
||||
t.after(() => close(web));
|
||||
return { port: web.address().port, boardHits, busCalls };
|
||||
}
|
||||
@@ -104,7 +99,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) => {
|
||||
@@ -207,8 +202,9 @@ test('in preview mode every write answers 403 read-only, from the public name an
|
||||
});
|
||||
|
||||
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
||||
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
|
||||
// The board is down: every read throws, which the Console answers as invalid-response (502).
|
||||
const down = { index: async () => { throw new Error('board down'); } };
|
||||
const { port, busCalls } = await setup(t, { board: down });
|
||||
const loopback = { host: `127.0.0.1:${port}` };
|
||||
const inbox = await send(port, { headers: loopback });
|
||||
assert.equal(inbox.status, 200); assert.deepEqual(inbox.body.rows, [{ verb: 'inbox' }]);
|
||||
@@ -217,7 +213,7 @@ test('loopback requests are unchanged in preview mode, and the bus views work wi
|
||||
assert.equal((await send(port, { headers: { host: `127.0.0.1:${port + 1}` } })).status, 403);
|
||||
// The board panel's existing error; the bus views still answer.
|
||||
const board = await send(port, { path: '/api/board', headers: signedIn });
|
||||
assert.equal(board.status, 502); assert.equal(board.body.board, dead);
|
||||
assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
|
||||
assert.equal((await send(port, { path: '/api/bus/agents', headers: signedIn })).status, 200);
|
||||
assert.deepEqual(busCalls.map(c => c[0]), ['inbox', 'agents']);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { browser } from './browser.mjs';
|
||||
const GEN = '12345678-1234-4234-8234-123456789abc';
|
||||
async function setup(t, readonly = false) {
|
||||
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: `(() => {
|
||||
const real=fetch.bind(window);window.writes=[];window.failWrite=false;window.confirmResume=true;window.confirmed=0;
|
||||
|
||||
@@ -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,18 @@ 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';
|
||||
import { sendOnConfig } from './board-route.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 +23,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);
|
||||
b = await browser(); await sendOnConfig(b, webURL); 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 +54,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,11 @@ 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';
|
||||
import { routeWrites } from './board-route.mjs';
|
||||
|
||||
const at = s => new Date(Date.now() + s * 1000).toISOString();
|
||||
const message = (timestamp, m) => JSON.stringify({ type: 'message', timestamp, message: m }) + '\n';
|
||||
@@ -22,17 +22,18 @@ 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, stopRoute;
|
||||
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);
|
||||
stopRoute = await routeWrites(b, `http://127.0.0.1:${web.address().port}`, board);
|
||||
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');
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}`); await wait('document.querySelector("table.sessions [data-open]")');
|
||||
@@ -72,5 +73,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 (stopRoute) await stopRoute(); if (b) await b.close(); if (web) await close(web); rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -41,7 +41,7 @@ test('row 65 Settings: the routing table lists every instance, saves a harness,
|
||||
try{return structuredClone(host.broker.request(human,{verb,args}));}catch(e){if(e instanceof BusError)throw new BusReadError(e.code);throw e;}
|
||||
};
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},routes:()=>call('routes')};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})},log:()=>{}});
|
||||
const lines=[];
|
||||
const writer=await startServer({port:0,preview,send:async()=>({}),routesWrite:args=>call('routes.set',args),log:line=>lines.push(line)});
|
||||
const b=await browser();
|
||||
|
||||
@@ -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})()`);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Row 198 (mos-1512): with send off, the Console is read-only in the page. The reply form and the Seen
|
||||
// button are not drawn, and the inspector says so in words. The Console here is a plain listener with no
|
||||
// send, so /api/config answers send: false and the page's gate is what is under test.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture } from './fixture.mjs';
|
||||
|
||||
test('send off: the reply form and the Seen button are not drawn, and the inspector says the Console is read-only', { timeout: 60000 }, async () => {
|
||||
const f = await fixture(), b = await browser();
|
||||
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)})})()`);
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));' });
|
||||
await b.viewport(1440); await b.navigate(f.base);
|
||||
await wait('document.querySelector("table.sessions [data-open=\'proj/agent1\']")');
|
||||
assert.equal(await b.evaluate('(await fetch("/api/config").then(r => r.json())).send'), false);
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open=\'proj/agent1\']").click()');
|
||||
await wait('!document.querySelector("#inspector").hidden');
|
||||
await wait('document.querySelector("#inspection").textContent.includes("Replies are off: this Console is read-only.")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#inspection [data-seen]")'), false);
|
||||
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Seen is off: this Console is read-only\./);
|
||||
// The conversation view's reply line says the same thing, with no form under it.
|
||||
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('!document.querySelector("#conversation").hidden');
|
||||
await wait('document.querySelector("#conv-no-reply").textContent === "Replies are off: this Console is read-only."');
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-form").hidden'), true);
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await f.close(); }
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {startServer} from '../src/serve.mjs';
|
||||
import {close} from './fixture.mjs';
|
||||
const launch={business:'acme',revision:'a'.repeat(64),models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}},instances:{pm:{modelKey:'opus'},cto:{modelKey:'opus'},coder:{modelKey:'sonnet'},reviewer:{modelKey:'opus'}},usage:{total:1,providers:{anthropic:1},models:{anthropic:{'claude-opus-5-5':1}}}};
|
||||
test('Settings form shows every scope and held role; gated save, exact payload, retained draft and no automatic retry', {timeout:90000},async()=>{
|
||||
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0})};const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch})}}),b=await browser();
|
||||
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0})};const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(launch)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/settings'&&options.method==='POST'){posts.push(JSON.parse(options.body));if(mode==='unknown')throw Error('fixture lost answer');if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});return new Response(JSON.stringify({...fixture,...posts.at(-1),revision:'b'.repeat(64)}));}return original(url,options);};`});
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings`);
|
||||
@@ -28,7 +28,7 @@ test('Settings form shows every scope and held role; gated save, exact payload,
|
||||
test('Q169 Assistant section: gated, changed fields only, name and stop posts, retained draft and no automatic retry',{timeout:90000},async()=>{
|
||||
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
|
||||
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'){const body=JSON.parse(options.body);posts.push([url,body]);if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});if(url==='/api/name')return new Response(JSON.stringify({...body,at:'2026-10-18T01:00:00.000Z'}));if(url==='/api/assistant-stop')return new Response(JSON.stringify({stopped:[{thread:'t-1',run:'a-run'},{thread:'t-2',run:'b-run'}]}));const a=body.assistants.assistant;window.saved={...fixture,revision:'b'.repeat(64),assistants:{assistant:{...fixture.assistants.assistant,...a}}};return new Response(JSON.stringify(window.saved));}if(url==='/api/settings'&&window.saved){const read=await (await original(url,options)).json();return new Response(JSON.stringify({...read,launch:window.saved}));}return original(url,options);};`});
|
||||
const port=web.address().port, q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
|
||||
@@ -56,7 +56,7 @@ test('Q169 Assistant section: gated, changed fields only, name and stop posts, r
|
||||
test('Row 167 a refused names read gives the refused usage page; another names failure keeps the Assistant section',{timeout:90000},async()=>{
|
||||
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
|
||||
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const query=new URL(location.href).searchParams,refuse=query.get('refuse'),fail=query.get('fail'),original=window.fetch;window.fetch=async(url,options={})=>{if(refuse&&url.startsWith('/api/bus/'+refuse))return new Response(JSON.stringify({error:'human-required',message:'fixture refusal'}),{status:403});if(fail&&url.startsWith('/api/bus/'+fail))return new Response(JSON.stringify({error:'bus-unreachable',message:'fixture failure'}),{status:503});return original(url,options);};`});
|
||||
const port=web.address().port;
|
||||
@@ -79,7 +79,7 @@ const oneSnapshot=`window.posts=[];window.saved=null;const original=window.fetch
|
||||
async function snapshotPage(){
|
||||
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
|
||||
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
||||
const web=await startServer({port:0,board:null,bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.fixture=${JSON.stringify(withAssistant)};${oneSnapshot}`});
|
||||
const q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { ReadError } from '../src/reads.mjs';
|
||||
import { sendOnConfig } from './board-route.mjs';
|
||||
|
||||
const pub = new URL('../src/public/', import.meta.url), design = new URL('../../../docs/design/', import.meta.url);
|
||||
const read = (name, base = pub) => readFileSync(new URL(name, base));
|
||||
@@ -68,31 +69,41 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
assert.equal(read('icons.svg').toString().includes('<script'), false);
|
||||
});
|
||||
|
||||
// The Console's board, in-process (row 198). Each mode sets what the next read answers. A thrown
|
||||
// ReadError answers its own code; any other throw answers invalid-response (consoleRead's mapping).
|
||||
// A scan that fails answers the same as a board that is down: the page keeps or drops data as before.
|
||||
function board() {
|
||||
let reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } };
|
||||
let mode = 'empty', gate = null;
|
||||
const one = { agent: 'agent1', project: 'proj', state: 'waiting', waitingOnYou: true, task: 'Fixture task', lastActivity: '2026-10-10T00:00:00Z', lastAssistantText: 'Input needed: fixture', registered: null };
|
||||
// While the board answers, agent1 has one conversation with one message. A failure or refusal answers every path.
|
||||
// While the board answers, agent1 has one conversation with one message. A failure answers every path.
|
||||
const catalogue = { conversations: [{ project: 'proj', seat: 'agent1', conversation: 'c1', availability: 'available', title: 'Fixture', conversationCreatedAt: '2026-10-10T00:00:00Z', lastActivityAt: '2026-10-10T00:00:00Z' }] };
|
||||
const page = { page: { branch: 'b1', entries: [{ message: 'm1', role: 'assistant', createdAt: '2026-10-10T00:00:00Z', content: [{ block: 0, type: 'text', text: 'Fixture history line' }] }] }, view: { incomplete: false, defaultBranch: 'b1', branch: 'b1' }, cursor: null, follow: null };
|
||||
let gate = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
for await (const chunk of req) { void chunk; }
|
||||
await gate;
|
||||
const path = new URL(req.url, 'http://localhost').pathname;
|
||||
const body = reply.status !== 200 ? reply.body : path === '/api/conversations' ? catalogue : path === '/api/conversation' ? page : reply.body;
|
||||
res.writeHead(reply.status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body));
|
||||
});
|
||||
const answer = () => {
|
||||
if (mode === 'fail') throw new Error('board scan failed');
|
||||
if (mode === 'unavailable') throw new ReadError('read-failed', 'board unavailable');
|
||||
if (mode === 'one') return { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() };
|
||||
if (mode === 'registered') return { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() };
|
||||
return { sessions: [], counts: {}, generatedAt: new Date().toISOString() };
|
||||
};
|
||||
const set = next => () => { mode = next; };
|
||||
return {
|
||||
server,
|
||||
// Holds every reply until the returned function is called.
|
||||
board: {
|
||||
// Waits while held, so the page shows its loading state.
|
||||
index: async () => { await gate; return answer(); },
|
||||
// The catalogue and the page are sync, as the Console calls them; a failing mode throws, which the route answers 500.
|
||||
conversation: url => {
|
||||
if (mode === 'fail' || mode === 'unavailable') throw new Error('conversation read failed');
|
||||
return { status: 200, body: url.pathname === '/api/conversations' ? catalogue : page };
|
||||
},
|
||||
},
|
||||
// Holds every board read until the returned function is called.
|
||||
hold: () => { let go; gate = new Promise(r => { go = r; }); return () => { gate = null; go(); }; },
|
||||
empty: () => { reply = { status: 200, body: { sessions: [], counts: {}, generatedAt: new Date().toISOString() } }; },
|
||||
one: () => { reply = { status: 200, body: { sessions: [one, { ...one, agent: 'agent2', state: 'working', waitingOnYou: false }, { ...one, agent: 'agent3', state: 'idle', waitingOnYou: false, seen: true }], counts: { waiting: 1, working: 1 }, generatedAt: new Date().toISOString() } }; },
|
||||
fail: () => { reply = { status: 500, body: { error: 'board scan failed' } }; },
|
||||
refuse: () => { reply = { status: 403, body: { error: 'board-refused' } }; },
|
||||
empty: set('empty'),
|
||||
one: set('one'),
|
||||
fail: set('fail'),
|
||||
// One seat a reply can reach: registered, alive, with a tmux session.
|
||||
registered: () => { reply = { status: 200, body: { sessions: [{ ...one, registered: { alive: true, tmux: { session: 'fixture' } } }], counts: { waiting: 1 }, generatedAt: new Date().toISOString() } }; },
|
||||
unavailable: () => { reply = { status: 503, body: { error: 'board unavailable' } }; },
|
||||
registered: set('registered'),
|
||||
unavailable: set('unavailable'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,10 +117,11 @@ function stubBus() {
|
||||
}
|
||||
|
||||
test('shell in a browser: System default, seeded states, 400px, focus and the Ctrl+K palette', { timeout: 180000 }, async () => {
|
||||
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
|
||||
const brd = board();
|
||||
const s = stubBus();
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
|
||||
const web = await startServer({ port: 0, board: brd.board, bus: s.bus });
|
||||
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
||||
await sendOnConfig(b, base);
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
const shot = async name => { if (out) await b.screenshot(join(out, `shell-${name}.png`)); };
|
||||
@@ -170,39 +182,18 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("table.sessions .st-waiting"),"::before").content'), '"◐" / ""');
|
||||
brd.fail(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||
assert.match(await text('#error'), /board scan failed.*Showing last known data\. Scanned /);
|
||||
assert.match(await text('#error'), /invalid-response.*Showing last known data\. Scanned /);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 2);
|
||||
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
|
||||
await flat(); await shot('board-stale-400');
|
||||
|
||||
// Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
||||
// them: the open inspector, the project filter, the project tree's counts and the footer.
|
||||
await b.evaluate('document.querySelector("#projects [data-project=proj]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=proj]").getAttribute("aria-pressed")'), 'true');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
|
||||
assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
|
||||
assert.equal(await count('#seen .wait-item'), 1);
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
|
||||
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
|
||||
assert.equal(await text('#footer'), 'Board not read: refused');
|
||||
assert.equal(await text('#fresh-board'), 'Board not read: refused');
|
||||
assert.doesNotMatch(await text('#status'), /Scanned/);
|
||||
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
await flat(); await shot('board-refused-400');
|
||||
// A failed read after the refusal shows the board again, empty: no row, card or count from
|
||||
// the refused scan, and the footer says what the banner says. With nothing focused, focus stays put.
|
||||
// A failed first read on a fresh load: the board is drawn empty and the footer says what the banner says.
|
||||
// With nothing focused, focus stays put. (Before row 198 this followed a refusal; the refusal is gone.)
|
||||
brd.unavailable(); await b.navigate(base); await wait('!document.querySelector("#error").hidden');
|
||||
await b.evaluate('document.activeElement.blur()');
|
||||
brd.unavailable(); await refresh();
|
||||
assert.equal(await b.evaluate('document.activeElement === document.body'), true, 'a failure with nothing focused leaves focus alone');
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner err');
|
||||
assert.match(await text('#error'), /board unavailable.*No board data loaded\./);
|
||||
assert.match(await text('#error'), /read-failed.*No board data loaded\./);
|
||||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await count('#sessions tr, #sessions [data-open], #waiting .wait-item, #seen .wait-item, #board-view [data-history]'), 0);
|
||||
for (const id of ['#waiting-count', '#seen-count', '#session-count']) assert.equal(await text(id), '–', id);
|
||||
@@ -210,7 +201,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
assert.equal(await text('#fresh-board'), 'Board not read: the read failed');
|
||||
assert.equal(await text('#status'), 'Not read: the read failed');
|
||||
assert.equal(await text('#projects'), 'Not read: the read failed.');
|
||||
await flat(); await shot('board-refused-then-failed-400');
|
||||
await flat(); await shot('board-unavailable-400');
|
||||
brd.one(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#projects [data-project=\'\']").getAttribute("aria-pressed")'), 'true');
|
||||
@@ -219,8 +210,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
// A good read after a failed one drops the stale words.
|
||||
brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
||||
brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
|
||||
// A refusal with a conversation open closes it and clears its history and reply form: the
|
||||
// session is in no scan the page holds.
|
||||
// A conversation is opened from a registered session, and a failed catalogue read clears the picker.
|
||||
brd.registered(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===1');
|
||||
await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
@@ -232,15 +222,8 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
assert.equal(await count('#conv-pick option'), 0, 'no stale session in the picker');
|
||||
brd.registered(); await b.evaluate('document.querySelector("table.sessions [data-history]").click()');
|
||||
await wait('document.querySelector("#conv-log").textContent.includes("Fixture history line")');
|
||||
brd.refuse(); await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#conversation").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("has-conversation")'), false);
|
||||
assert.equal(await count('#conv-log > *'), 0);
|
||||
assert.equal(await b.evaluate('document.querySelector("#conv-form").checkVisibility()'), false);
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||||
// Fix to HEAD behaviour: focus went back to History, which the refusal removed; it lands on the page, not <body>.
|
||||
assert.equal(await b.evaluate('document.activeElement.id'), 'main', 'focus after a refusal closes the conversation');
|
||||
// Back to the board, closed as the refusal used to close it.
|
||||
await b.evaluate('document.getElementById("conv-back").click()'); await wait('document.querySelector("#conversation").hidden');
|
||||
brd.one(); await refresh(); await wait('document.querySelectorAll("table.sessions tbody tr").length===2');
|
||||
|
||||
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
|
||||
@@ -410,21 +393,21 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
assert.ok(scrollH > innerH, `the board page is taller than ${innerH}px`);
|
||||
assert.deepEqual([tree[0], tree[1]], [bar[1], foot[0]]);
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
} finally { await b.close(); await close(web); await close(brd.server); }
|
||||
} finally { await b.close(); await close(web); }
|
||||
});
|
||||
|
||||
// Every restyled view in its five states: loading (a held read), normal, empty, a failed read
|
||||
// over kept data, and a refusal. Empty is seeded per view above; here each view is read once
|
||||
// with rows, then made to fail, then refused, at 400px.
|
||||
test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }, async () => {
|
||||
const brd = board(); await new Promise(r => brd.server.listen(0, '127.0.0.1', r));
|
||||
const brd = board();
|
||||
const s = stubBus();
|
||||
const at = new Date().toISOString(), id = 'd'.repeat(32);
|
||||
s.rows.inbox = [{ id, question: 'Push it?', class: 'gated', action: 'git.push.protected', target: 'refactor', blocking: true, options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', at, raised_by: 'coder' }];
|
||||
s.rows.trail = [{ table: 'decisions', seq: 1, at, id, class: 'gated', action: 'git.push.protected', route_to: 'human', question: 'Push it?', options: [{ key: 'yes', text: 'Yes' }], recommendation: 'yes', blocking: 1, raised_by_role: 'coder', raised_by_run: 'coder-run' }];
|
||||
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
|
||||
s.rows.agents = [{ role: 'coder', holder_run: 'coder-run', harness: 'pi', address: 'coder@bus', at }];
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${brd.server.address().port}`, bus: s.bus });
|
||||
const web = await startServer({ port: 0, board: brd.board, bus: s.bus });
|
||||
const base = `http://127.0.0.1:${web.address().port}/`, b = await browser();
|
||||
const out = process.env.WEBUI_EVIDENCE;
|
||||
if (out) mkdirSync(out, { recursive: true });
|
||||
@@ -525,5 +508,5 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
||||
|
||||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||||
} finally { await b.close(); await close(web); await close(brd.server); }
|
||||
} finally { await b.close(); await close(web); }
|
||||
});
|
||||
@@ -30,7 +30,7 @@ const stub = `(() => {
|
||||
window.__reqs.push(path);
|
||||
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
|
||||
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
|
||||
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
|
||||
if (path === '/api/config') return answer(200, { ...(await (await real(url, init)).json()), send: true, ...(/sso/.test(location.search) ? { sso: true } : {}) });
|
||||
return real(url, init);
|
||||
};
|
||||
})();`;
|
||||
@@ -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)})})()`);
|
||||
|
||||
@@ -60,7 +60,7 @@ async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = nul
|
||||
t.after(() => sso.close());
|
||||
const sends = [];
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
|
||||
const web = await startServer({ port: 0, board: null, bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
|
||||
t.after(() => close(web));
|
||||
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
|
||||
}
|
||||
@@ -389,7 +389,7 @@ test('12: under --sso the tester listener refuses each kind of sign-in, has no /
|
||||
const env = await setup(t);
|
||||
const dataRoot = mkdtempSync(join(tmpdir(),'g196-sso-'));
|
||||
t.after(()=>rmSync(dataRoot,{recursive:true,force:true}));
|
||||
const tester = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: env.bus, log: () => {} });
|
||||
const tester = await startTesterSocket({ dataRoot, board: null, bus: env.bus, log: () => {} });
|
||||
t.after(() => close(tester));
|
||||
const callTester = options => testerRequest({dataRoot,...options});
|
||||
const { session, g, begun } = await signIn(env);
|
||||
@@ -455,7 +455,7 @@ test('13: the CSP lets the Sign out form reach the provider\'s end-session origi
|
||||
const env = await setup(t);
|
||||
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
|
||||
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
const plain = await startServer({ port: 0, board: null });
|
||||
t.after(() => close(plain));
|
||||
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
|
||||
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
|
||||
|
||||
@@ -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'); } });
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function testerFixture(t, { enabled = true } = {}) {
|
||||
};
|
||||
const bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
const lines = [];
|
||||
const web = await startTesterSocket({ dataRoot: f.dataRoot, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', bus,
|
||||
const web = await startTesterSocket({ dataRoot: f.dataRoot, board: null, tester: true, testerAuthHeader: 'x-user', bus,
|
||||
reads: { settings: async () => ({ business: 'acme', release: 'fixture', notifier: {}, launch: await call('limits') }) },
|
||||
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
||||
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
|
||||
@@ -90,7 +90,7 @@ export async function testerChat(t, { enabled = true } = {}) {
|
||||
} catch (err) { if (err instanceof BusError) throw new BusReadError(err.code); throw err; }
|
||||
} });
|
||||
const lines = [];
|
||||
const web = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9', tester: true, testerAuthHeader: 'x-user', chat, log: line => lines.push(line) });
|
||||
const web = await startTesterSocket({ dataRoot: root, board: null, tester: true, testerAuthHeader: 'x-user', chat, log: line => lines.push(line) });
|
||||
t.after(() => close(web));
|
||||
return { store, broker, human, calls, web, lines };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request, createServer } from 'node:http';
|
||||
import { request } from 'node:http';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
import { tmp } from '../../cli/tests/helpers.mjs';
|
||||
@@ -24,9 +24,9 @@ export const post = (web, path, data, headers = {}) => http(web, path, { method:
|
||||
|
||||
test('main listener refuses x-mosaic-tester before any board or bus read for every Host', async t => {
|
||||
let hits = 0;
|
||||
const upstream = createServer((q, r) => { hits++; r.end('{}'); });
|
||||
await new Promise(r => upstream.listen(0, '127.0.0.1', r)); t.after(() => close(upstream));
|
||||
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } });
|
||||
// The board runs in-process (row 198): a read that reaches it counts as a hit.
|
||||
const board = { index: async () => { hits++; return { sessions: [] }; } };
|
||||
const web = await startServer({ port: 0, board, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, bus: { agents: async () => { hits++; return []; } } });
|
||||
t.after(() => close(web));
|
||||
for (const host of [`127.0.0.1:${web.address().port}`, 'console.example', 'other.example'])
|
||||
for (const path of ['/api/board', '/api/bus/agents']) {
|
||||
@@ -91,7 +91,7 @@ test('tester-off and tester-required reads return HTTP 403', async t => {
|
||||
const s = await testerFixture(t, { enabled: false });
|
||||
const r = await http(s.web, '/api/bus/names');
|
||||
assert.equal(r.status, 403); assert.equal(r.body.error, 'tester-off');
|
||||
const required = await startTesterSocket({ dataRoot: tmp(t,'tester-required-'), board: 'http://127.0.0.1:9', bus: { names: async () => { throw new BusReadError('tester-required'); } } });
|
||||
const required = await startTesterSocket({ dataRoot: tmp(t,'tester-required-'), board: null, bus: { names: async () => { throw new BusReadError('tester-required'); } } });
|
||||
t.after(() => close(required));
|
||||
const missing = await http(required, '/api/bus/names');
|
||||
assert.equal(missing.status, 403); assert.equal(missing.body.error, 'tester-required');
|
||||
@@ -172,7 +172,7 @@ test('row 177 W3: the human thread through the tester chat is thread-not-owned,
|
||||
test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and writes before the chat', async t => {
|
||||
const calls = [];
|
||||
const chat = Object.fromEntries(['projects', 'createProject', 'threads', 'createThread', 'messages', 'send', 'open'].map(k => [k, async () => { calls.push(k); return []; }]));
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', preview: { host: 'console.example', header: 'x-user', user: 'jason' }, send: async () => ({}), chat, log: () => {} });
|
||||
const web = await startServer({ port: 0, board: null, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, send: async () => ({}), chat, log: () => {} });
|
||||
t.after(() => close(web));
|
||||
const signed = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
for (const [method, path, data] of [['GET', '/api/chat/threads'], ['POST', '/api/chat/threads/20000000-0000-4000-8000-000000000002/messages', { body: 'x' }]]) {
|
||||
|
||||
@@ -12,7 +12,7 @@ async function refused(options, pattern) {
|
||||
}
|
||||
test('private tester HTTP serves on 0600 socket in 0700 parent and fixed origin',async t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'g196-w-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const web=await startTesterSocket({dataRoot:root,board:'http://127.0.0.1:9'});t.after(()=>new Promise(r=>web.close(r)));
|
||||
const web=await startTesterSocket({dataRoot:root,board:null});t.after(()=>new Promise(r=>web.close(r)));
|
||||
assert.equal(web.address(),testerSocketPath(root));
|
||||
assert.equal(lstatSync(join(root,'console')).mode&0o777,0o700);
|
||||
assert.equal(lstatSync(web.address()).mode&0o777,0o600);
|
||||
|
||||
@@ -26,7 +26,7 @@ async function setup(t) {
|
||||
return typeof answer === 'function' ? answer() : answer;
|
||||
};
|
||||
const bus = { agents: read('agents', [role]), inbox: read('inbox', []), messages: read('messages', { messages: [], unread: 0 }), 'thread.claims': read('thread.claims', () => state.rows) };
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }); t.after(() => close(web));
|
||||
const web = await startServer({ port: 0, board: f.board, bus }); t.after(() => close(web));
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}throw Error(${JSON.stringify(expression)})})()`);
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/agents`);
|
||||
@@ -100,7 +100,7 @@ test('row278 browser sign-out removes claims and prevents reuse of cached answer
|
||||
|
||||
test('row278 tester browser keeps role rows without requesting the owner claims section', async t => {
|
||||
const dataRoot = tmp(t, 'claims-view-'), calls = [], reads = [];
|
||||
const web = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: {
|
||||
const web = await startTesterSocket({ dataRoot, board: null, bus: {
|
||||
agents: async () => [{ role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT }],
|
||||
messages: async () => ({ messages: [], unread: 0 }),
|
||||
'thread.claims': async () => { calls.push('thread.claims'); return [claim]; },
|
||||
|
||||
@@ -17,7 +17,7 @@ const rows = [{ thread: '11111111-1111-4111-8111-111111111111', instance: 'assis
|
||||
test('row278 HTTP read returns held claims without send and refuses arguments before the bus', async t => {
|
||||
const calls = [];
|
||||
const bus = { 'thread.claims': async arg => { calls.push(arg); return rows; } };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
|
||||
const web = await startServer({ port: 0, board: null, bus });
|
||||
t.after(() => close(web));
|
||||
const url = `http://127.0.0.1:${web.address().port}/api/bus/thread.claims`;
|
||||
const result = await fetch(url);
|
||||
|
||||
@@ -8,7 +8,7 @@ test('REQ-LIMIT-4 Settings shows all token kinds and unknown turns beside every
|
||||
const hostile = '<img src=x onerror="window.injected=true">';
|
||||
const usage = { total: count, byRun: [{ run: 'run1', ...count }], byInstance: [{ instance: hostile, ...count }],
|
||||
byModel: [{ provider: 'provider', model: 'model', ...count }], byProvider: [{ provider: 'provider', ...count }], byAccount: [{ account: null, ...count }] };
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9',
|
||||
const server = await startServer({ port: 0, board: null,
|
||||
bus: { messages: async () => ({ messages: [], unread: 0 }), usage: async () => usage },
|
||||
reads: { settings: async () => ({ business: 'demo', notifier: {}, launch: { refused: 'not-configured' } }) },
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ async function setup(t) {
|
||||
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(`reads.${v}`)])),
|
||||
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(`chat.${v}`)])),
|
||||
};
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log: l => lines.push(l), ...options, userOptions });
|
||||
const web = await startServer({ port: 0, board: null, sso, log: l => lines.push(l), ...options, userOptions });
|
||||
t.after(() => close(web));
|
||||
return { op, iss, owner, user, opened, lines, port: web.address().port };
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ test('Queue, Business and Settings in their five states, with no move button', {
|
||||
const s = stubReads();
|
||||
// A bus that answers, so the palette's own inbox and tasks reads succeed and forget nothing.
|
||||
const bus = { inbox: async () => [], tasks: async () => [], agents: async () => [], trail: async () => [] };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads, bus });
|
||||
const web = await startServer({ port: 0, board: null, reads: s.reads, bus });
|
||||
const b = await browser();
|
||||
try {
|
||||
const { wait, text, count, flat, refresh, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
|
||||
@@ -204,7 +204,7 @@ test('Queue, Business and Settings in their five states, with no move button', {
|
||||
|
||||
test('seeded secrets, Discord ids and paths reach no response and no page; business refusals in the module\'s words', { timeout: 180000 }, async t => {
|
||||
const s = seeded(t), repo = queueRepo(t);
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }) });
|
||||
const web = await startServer({ port: 0, board: null, reads: consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }) });
|
||||
const b = await browser();
|
||||
try {
|
||||
const { wait, text, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
|
||||
|
||||
@@ -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
|
||||
@@ -1448,6 +1466,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"
|
||||
@@ -1468,6 +1487,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"
|
||||
@@ -2321,7 +2341,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