review(54): Dewey rounds 1 and 2 build packet (candidate afb2ae0e)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-10 17:11:26 -05:00
co-authored by Claude Opus 5.5
parent cbd79cf666
commit 10225d6185
11 changed files with 6373 additions and 0 deletions
@@ -0,0 +1,13 @@
packages/queue/README.md
packages/queue/src/store.mjs
packages/queue/tests/write.test.mjs
packages/webui/README.md
packages/webui/src/cli.mjs
packages/webui/src/public/bus.js
packages/webui/src/queue-read.mjs
packages/webui/src/reads.mjs
packages/webui/src/serve.mjs
packages/webui/tests/reads-fixture.mjs
packages/webui/tests/reads.test.mjs
packages/webui/tests/shell.test.mjs
packages/webui/tests/views.test.mjs
@@ -0,0 +1,14 @@
packages/queue/README.md
packages/queue/src/store.mjs
packages/queue/tests/write.test.mjs
packages/webui/README.md
packages/webui/src/cli.mjs
packages/webui/src/public/app.js
packages/webui/src/public/bus.js
packages/webui/src/queue-read.mjs
packages/webui/src/reads.mjs
packages/webui/src/serve.mjs
packages/webui/tests/reads-fixture.mjs
packages/webui/tests/reads.test.mjs
packages/webui/tests/shell.test.mjs
packages/webui/tests/views.test.mjs
@@ -0,0 +1,13 @@
3db6ee2a4d4b05de6768709da0c21c6815c0d7899fb9389d659444828264275d packages/queue/README.md
352d96c5cc02d01e5d524c1a9106985c0f0a12b7312d7de157d9bf733dead4d5 packages/queue/src/store.mjs
b44f6f8b07cad4f35dfc92bee2c762845052b2b79f990f7cd5f590efd2b05544 packages/queue/tests/write.test.mjs
2cf78273507099675f1f9da2edff7d66fb6bfca8c83d6372283d78a673a4eb29 packages/webui/README.md
07d2c06523dd89f82c9ea14757852b16434f7207df57d7dfcbc58425e6e75c1d packages/webui/src/cli.mjs
47544013a87fce98a112cbfcded0d8cce3b5743ee1cd6edbc6eb837ff13e0508 packages/webui/src/public/bus.js
4b10c9522f7d2061853abbc98b1d8425791ebde0f9a3af2ef7f19119c23c5cb6 packages/webui/src/queue-read.mjs
1e7505a9cb2509d97dd671399aca8bb5f68ea72320c8f6cb0648c8f0eed9db31 packages/webui/src/reads.mjs
8121915343ad5000cf66d474d9e4a994ce6c44ff1977822a067ad5ffd2fbbf29 packages/webui/src/serve.mjs
f1896e52c29ab4d211a946a70c0d94cecedfb4a0099f8dc37b42e10f55377bc8 packages/webui/tests/reads-fixture.mjs
9eaad7bdf3159e68715b2f63ba3a7d03746ea87ff9ae42cb36b2205639b39f44 packages/webui/tests/reads.test.mjs
2f079e90e769054793beeebfce7e18973f7725ef6676d13f99928d3f52acbd6a packages/webui/tests/shell.test.mjs
b1c93811b18abd6843527f7e64a7807a3596e5aacfc7a469157360d0e3cecd64 packages/webui/tests/views.test.mjs
@@ -0,0 +1,14 @@
3db6ee2a4d4b05de6768709da0c21c6815c0d7899fb9389d659444828264275d packages/queue/README.md
352d96c5cc02d01e5d524c1a9106985c0f0a12b7312d7de157d9bf733dead4d5 packages/queue/src/store.mjs
b44f6f8b07cad4f35dfc92bee2c762845052b2b79f990f7cd5f590efd2b05544 packages/queue/tests/write.test.mjs
17b243db3290abe9417b989dfc1f42432eb05468b3a3c4f271e35e13ea05489f packages/webui/README.md
07d2c06523dd89f82c9ea14757852b16434f7207df57d7dfcbc58425e6e75c1d packages/webui/src/cli.mjs
5a611e2339fa9e31c489d24460f82852ca2bc892f07265ffdc5eca03b579541b packages/webui/src/public/app.js
0e1305c1cfb27617c8df66c77a5bf0fca822d65b9da8f75aadc27e11fdf7c176 packages/webui/src/public/bus.js
4b10c9522f7d2061853abbc98b1d8425791ebde0f9a3af2ef7f19119c23c5cb6 packages/webui/src/queue-read.mjs
1e7505a9cb2509d97dd671399aca8bb5f68ea72320c8f6cb0648c8f0eed9db31 packages/webui/src/reads.mjs
8121915343ad5000cf66d474d9e4a994ce6c44ff1977822a067ad5ffd2fbbf29 packages/webui/src/serve.mjs
f1896e52c29ab4d211a946a70c0d94cecedfb4a0099f8dc37b42e10f55377bc8 packages/webui/tests/reads-fixture.mjs
9eaad7bdf3159e68715b2f63ba3a7d03746ea87ff9ae42cb36b2205639b39f44 packages/webui/tests/reads.test.mjs
9f6a52c90168bfdb024111b35c589c46233db51085f6b7d2df6eacb48826ce82 packages/webui/tests/shell.test.mjs
c3b23f81dfc5a88e1a0865dbabfc8168ba4a0749cc171bb21615e2c113b46e57 packages/webui/tests/views.test.mjs
@@ -0,0 +1,14 @@
3db6ee2a4d4b05de6768709da0c21c6815c0d7899fb9389d659444828264275d packages/queue/README.md
352d96c5cc02d01e5d524c1a9106985c0f0a12b7312d7de157d9bf733dead4d5 packages/queue/src/store.mjs
b44f6f8b07cad4f35dfc92bee2c762845052b2b79f990f7cd5f590efd2b05544 packages/queue/tests/write.test.mjs
cd146196c98d8492a2b7f0c9bc046db0a69bd8c4539de8454563bd91cda24d9b packages/webui/README.md
07d2c06523dd89f82c9ea14757852b16434f7207df57d7dfcbc58425e6e75c1d packages/webui/src/cli.mjs
5a611e2339fa9e31c489d24460f82852ca2bc892f07265ffdc5eca03b579541b packages/webui/src/public/app.js
0e1305c1cfb27617c8df66c77a5bf0fca822d65b9da8f75aadc27e11fdf7c176 packages/webui/src/public/bus.js
4b10c9522f7d2061853abbc98b1d8425791ebde0f9a3af2ef7f19119c23c5cb6 packages/webui/src/queue-read.mjs
1e7505a9cb2509d97dd671399aca8bb5f68ea72320c8f6cb0648c8f0eed9db31 packages/webui/src/reads.mjs
8121915343ad5000cf66d474d9e4a994ce6c44ff1977822a067ad5ffd2fbbf29 packages/webui/src/serve.mjs
f1896e52c29ab4d211a946a70c0d94cecedfb4a0099f8dc37b42e10f55377bc8 packages/webui/tests/reads-fixture.mjs
9eaad7bdf3159e68715b2f63ba3a7d03746ea87ff9ae42cb36b2205639b39f44 packages/webui/tests/reads.test.mjs
85f3bd247e6f99da717a51d71db13b65d511fffa8f0232ecbafa6c5000430c4e packages/webui/tests/shell.test.mjs
c3b23f81dfc5a88e1a0865dbabfc8168ba4a0749cc171bb21615e2c113b46e57 packages/webui/tests/views.test.mjs
+393
View File
@@ -0,0 +1,393 @@
# Row 54 (#1543): Read-only console views: Queue, Business and Settings
Dewey, 2026-10-10. Brief: `docs/plans/2026-10-10_design-implementation.md`,
section "Read-only console views: Queue, Business and Settings" (blob
a360554c), with `docs/design/IMPLEMENTING.md` "Strings", "Boundaries" and
"Acceptance" as for row 53. Lead decisions 81 (appearance follows the
system by default) and 83 (52c5a1f8: `rows()` in packages/queue, path
redaction, recorded choices stand). The brief's "No change to
`packages/queue`" is superseded by decision 83 for exactly three paths;
the brief file was left alone because rows 53-61 pin its blob.
## Base
Row 54 is `after: 53 done`. Row 53 landed at d64f434f (feat 1bdb6f9b,
#1542 closed in comment 27175), and this packet is built and gated on
d64f434f. It was first built on row 53's round 2 candidate (row 54
manifest `8258420b`), then rebased onto round 3 (`3347bb61`); round 3's
`round3.patch` applied cleanly. The rebase onto d64f434f changed nothing in
row 54's files: `row54.patch` as built on round 3, applied at d64f434f,
reproduced `3347bb61` (14 OK). Filbert's T8 test was then added (see
"Tests added from review").
Row 54's candidate is 14 files, listed in `candidate-files.txt`, with
sha256 in `candidate-manifest.sha256` (manifest sha256
`dba2429e411ae22b1e939344247e9f3857a48e488dc7da11c578d1092288ea8a`).
`row54.patch` is the delta over d64f434f: applied to a clean d64f434f
export, it reproduces the manifest (14 OK, checked). `index.html` is
unchanged; the views live in `bus.js`, and `app.js` changes only for two
of the fixes to HEAD behaviour.
## What changed
- `packages/queue/src/store.mjs` (decision 83): `export function rows(opts)`,
`{ rows: rowsArray(state), err: notes, code: 0 }`, the same read as `list`
with no write. `packages/queue/README.md` names it;
`packages/queue/tests/write.test.mjs` adds one test: the rows match `list`
and `show` row for row, no queue, view or head file changes, the
reader-between notes match `list`'s, and a disagreement refuses with
exit code 2. Nothing else in packages/queue changes.
- `packages/webui/src/queue-read.mjs` (new): a child process that calls
`rows()` with its cwd at the checkout and prints JSON. A refusal prints
the store's message on stderr and exits with the store's code.
- `packages/webui/src/reads.mjs` (new): `consoleReads` returns `queue`,
`row`, `business` and `settings`. The queue read runs the child with a
timeout and an output cap. Exit 2 is `queue-refused` (fail closed). Any
other failure is `read-failed`, and the view keeps its last read. Business
is `loadBusiness` projected to names, vars on an allowlist,
`resolveInstance`/`classify` authority per action, and credential metadata
(service, account, role, date kind, date, state from the date alone).
Settings is `RELEASE`, the business id and `readNotifyConfig`'s binding
name. `redactor` maps the config dir to `<config>`, dataRoot to
`<dataRoot>`, any other absolute path to `<path>`, a 17 to 20 digit run
to `<id>`, and drops V8's JSON parse quote. `scrub` applies it to every
string in a body.
- `packages/webui/src/serve.mjs`: GET `/api/queue`, `/api/queue/<id>`,
`/api/business`, `/api/settings`. Any other method gets 405, and a bad
row id gets 400 `invalid-request`. Statuses: 503 for a refusal or nothing
to read, 404 not-found, 502 an unusable answer. A non-`ReadError` answers
with its code only. Settings answers 200 without a system config, with
`notifier.refused`. The existing CSP, Host and Origin checks are
unchanged and cover the new paths.
- `packages/webui/src/cli.mjs`: builds `consoleReads` and runs the
"Bus: not configured (…)" startup line through the redactor.
- `packages/webui/src/public/bus.js`: views `#/queue` (state filter),
`#/queue/<id>`, `#/business`, `#/settings`; three section links; queue
rows in the Ctrl+K palette; `queue-refused` is a refusal; the command bar
names the source; a Settings refusal keeps appearance (its selects mirror
the command bar's). It also carries two of the fixes to HEAD behaviour
below.
- `packages/webui/src/public/app.js`: the other two fixes to HEAD behaviour,
three lines. Nothing for the views.
- `packages/webui/README.md`: the row 54 section, data and boundaries,
the verify commands, the two `app.js` fixes, and the failed first read
in the shell tests line.
- Tests: `reads-fixture.mjs` (seeded fixture), `reads.test.mjs` (8),
`views.test.mjs` (3), `shell.test.mjs` (seven sections in the list and
palette, the two `app.js` fixes, and Filbert's T8: a failed first read).
## Acceptance against the brief
| Brief item | Where it is shown |
|---|---|
| GET JSON `/api/queue`, `/api/queue/<id>`, `/api/business`, `/api/settings` | reads.test "queue: rows, one row, ids and methods, notes"; serve routes |
| CSP, Host and Origin checks kept | serve.mjs unchanged around the new paths; serve.test and bus-routes tests green |
| No write, no network, no new dependency | views.test PROBE: every request is GET; `rows()` test: queue, view and head files byte-identical; no package.json change |
| Five states on each view | views.test test 1: loading, normal, stale over kept rows, refusal, empty on `#/queue`, `#/queue/6`, `#/business`, `#/settings` |
| Missing or invalid business file: refusal with the module's text | views.test test 2: "business file not found: `<config>/businesses/acme.json`" and "business file is not valid JSON (`<config>/…`)"; reads.test "business: missing or invalid file" |
| Secrets as metadata only | reads.test "business: … credential metadata only"; views.test test 2 runs `clean()` on every page and every response body |
| No value, token or path in any response or log | `clean()` asserts no tmp base, no `/srv/secret`, no `"file"`, `"env"`, `"tokenFile"`, `CODER_GITEA_SECRET_ENV`, `placeholder-not-a-token`; views.test test 3 asserts the startup log names no config path and no secret |
| Notifier: binding names only, no channel, guild or user id | seeded fixture holds five Discord ids in the binding, the business vars and a queue note; `clean()` asserts none appears; the note shows as `token at <path> for <id>` |
| Settings: appearance, notifications, about | views.test test 1: the settings mirror sets mode dark and localStorage records it; settings dl shows binding name, release 0.0.99, loopback address |
| Queue rows: state, owner, reviewers, brief link | views.test test 1 checks the table cells as text |
| Move only through `queue move`; command, not a button | views.test test 1: the row page's `.s1-cmd code` is `scripts/mosaic queue move 6 <state> --op <op> --by <seat>`; no control on any of the four views but Copy and Read again |
| Tests as in row 53 | 400px viewport, no sideways scroll (`flat`), focus to the H1 on navigation and kept on refresh, hostile text inert (row 6's piece holds markup), palette rows as text, no script errors |
## Tests
Full webui suite 38 pass (row 53: 27, plus 8 in reads.test and 3 in
views.test; the T8 and fix assertions sit inside existing tests). packages/queue node suite 149 pass, test-queue 27 passed.
## Mutations
Each mutant was applied to a scratch copy and run against its test
(`~/dewey-scratch/r54/mutants.py`, outputs in `~/dewey-scratch/r54/out/`).
17 of 17 killed (rerun 2026-10-10T19:54Z on d64f434f, with all four
fixes to HEAD behaviour and the T8 test in the tree;
`out/mutants-run3.txt`).
| Mutant | Killed by |
|---|---|
| startup log without the redactor | "the log names the temporary directory" |
| `queue-refused` not a refusal | no refusal banner (timed out) |
| a Move button on the row page | "#/queue/6: no control but Copy and Read again" |
| credential spreads its ref (file, env) | "response carries CODER_GITEA_SECRET_ENV" |
| Settings refusal drops appearance | "#/settings": 0 mirror selects, expected 2 |
| module refusal keeps data | "#/queue: no kept queue row in the palette after not-configured" (2, expected 0) |
| palette queue rows unescaped | row 6's `<b id="q-b">` appears in the palette (1, expected 0) |
| no Discord id redaction | "response carries id 523456789012345678" |
| no generic path redaction | "response carries /srv/secret" |
| no H1 focus kept on refresh | "#/queue keeps heading focus" |
| no `clearTimeout(timer)` at the top of `render()` | "the navigation cleared the due refresh" |
| no `#conv-pick` clear when a conversation opens | "no stale session in the picker" |
| no focus fallback at the end of `error()` | "focus after a refusal closes the conversation" |
| the fallback without the "had focus" check | "a failure with nothing focused leaves focus alone" |
| empty board only while the refusal state is up (Filbert's `nodata-after-refusal-only`) | "no skeleton after a failed first read"; survives without T8, checked on the pre-T8 tree |
| `rows()` drops its notes | `rows:` test, reader-between notes |
| queue child exits 1 on a refusal | "a refused read fails closed": `read-failed`, expected `queue-refused` |
## Gate
Worktree at d64f434f (row 53 landed), then row 54's 14 files
(`sha256sum -c` of this manifest: 14 OK). node_modules linked from the
checkout, TMPDIR in scratch, Docker available, suites run one after
another, 2026-10-10T19:54:26Z to 19:59:36Z. `core.hooksPath` unset.
Script `~/dewey-scratch/r54/gate.sh`, outputs `~/dewey-scratch/r54/gate/out/`.
| Suite | Result |
|---|---|
| `node --test 'packages/webui/tests/*.test.mjs'` | 38 pass, 0 fail |
| node suites of business, bus, cli, control-board, conversation, discord, ledger, mosaic, queue, runs, seat, tasks | 60, 67, 66, 124, 182, 178, 78, 69, 149, 41, 19, 51 pass; 0 fail in each |
| `node docs/design/tools/build-tokens.mjs --check` | rc 0, "tokens.css is current" |
| test-auth | 15 passed, 0 failed |
| test-conductor | 17 passed, 0 failed |
| test-config | 24 passed, 0 failed |
| test-discord | 66 passed, 0 failed |
| test-extension-package | 18 passed, 0 failed |
| test-foundation | 44 passed, 0 failed |
| test-queue (decision 83) | 27 passed, 0 failed (verify and render --check skip outside the canonical root) |
| test-release | 14 passed, 0 failed |
| test-task | 98 passed, 0 failed |
The secret, id and path assertions are in the webui count: reads.test
(8) and views.test (3). packages/queue is 149 against row 53's 148: the
one `rows()` test.
## Fixes to HEAD behaviour (not row 54 features)
Four defects, none a row 54 feature, all in HEAD at d64f434f. The two in
`bus.js` were there before row 53. Of the two in `app.js` (Darkwing's
non-blocking notes on row 53 round 3, #1542 comment 27171), the picker
was there before row 53 and the focus loss came with row 53 round 3. Sage put all four in this row, each with its own test
(2026-10-10).
### Refresh timer during a navigation
Darkwing's row 53 round 2 note 3 (#1542 comment 27165), moved here by
Sage. `render()` set the 10 s refresh timer after each read but never
cleared it when a new render started. If the timer fired while a
navigation's read was pending, the refresh bumped the render generation,
and the navigation's render returned early without focusing its H1, so
focus fell to `<body>`. The fix is `clearTimeout(timer)` at the top of
`render()`.
views.test test 1 pins it without a real clock. The PROBE wraps
`setTimeout` and `clearTimeout` for the 10 s `render(false)` timer only
(app.js's 10 s board timer is left alone) and exposes `fireRefresh()`. The
test checks that one refresh is due on `#/queue`, holds the reads, and
navigates to `#/business`. It then asserts that `fireRefresh()` finds no
refresh due, releases the reads, and asserts that the Business H1 has
focus. The "no timer clear" mutant is killed at "the navigation cleared the
due refresh". With that count assertion removed, the mutant still fails
at "the navigation keeps heading focus", so the race reproduces every run.
### Heading focus on a same-page refresh
A refresh
of the same page rebuilds the view, and `restore()` put focus back only on
a link, Copy, Retry or a mirror select. If the H1 held focus, as it does
right after navigation, focus fell to `<body>`. With the 10 s refresh this
can fail bus-browser's "activeElement is H1" check under load. One full
webui run here failed it at 40 s, and three runs of that file alone passed.
Sage's row 53 round 2 gate didn't hit it. The fix: `keep()` records an H1
with focus, and `restore()` focuses the new H1. views.test pins it, and the
"no H1 focus kept on refresh" mutant confirms the test.
### Session picker after a failed catalogue read (`app.js`)
`openConversation()` replaced the `#conv-pick` options only after a good
catalogue read. If that read failed, the picker kept the last seat's or
the last read's sessions under "History unavailable". The fix empties the
picker where the view already empties `#conv-meta` and `#conv-log`. It is
not disabled there: a change of session starts from the picker, and
disabling a focused control drops focus to `<body>`.
The shell browser test opens History (one session in the picker), makes
the board answer 500, opens History again, waits for "History
unavailable", and asserts the picker has no option. The "no picker clear"
mutant is killed at "no stale session in the picker".
### Focus after a refusal closes a conversation (`app.js`)
Row 53 round 3's `error()` closes an open conversation on a refusal.
`closeConversation()` gives focus back to the History button, and the
empty board then removes that button, so focus fell to `<body>`. The fix:
`error()` notes whether anything had focus when it started, and if it
ends with focus on `<body>`, focuses `<main>`, the same fallback
`closeConversation()` uses. Darkwing proposed the fallback without the
first check. With nothing focused, that would move focus and scroll to
`<main>` on every failed read, including a failed first load and each
failing ten-second refresh.
The shell browser test asserts that after the refusal closes the
conversation, focus is on `#main`. It also blurs before the 503 that
follows the first refusal and asserts focus stays on `<body>`. Mutants:
"no focus fallback" is killed at "focus after a refusal closes the
conversation", and "fallback without the check" at "a failure with
nothing focused leaves focus alone".
## Tests added from review
Filbert's row 53 round 3 verdict (#1542 comment 27174) named two
non-blocking items. Sage put T8 in this row (2026-10-10).
- T8: round 3 draws the empty board after a failed first read, but no test
asserted it, so `nodata-after-refusal-only` survived. Test only, no code
change. The shell browser test reloads the page with the board answering
503 and asserts the error banner ("No board data loaded."), no
`aria-busy` skeleton, empty `#sessions` and `#waiting`, the three counts
`–`, and "the read failed" in `#status`, the footer, `#fresh-board` and
the tree. It then answers 200 and refreshes back to the empty board. The
reload resets `window.errors`, so the test asserts it is empty before
reloading.
- N8 is Darkwing's note 2, fixed above under "Focus after a refusal closes
a conversation".
## Decisions and deviations
- A refusal from any source (`queue-refused`, `not-configured`,
`no-bus-host`, 403) clears every kept read, as row 53 round 2 does for the
bus, so no kept queue row survives a refusal in the palette.
- The brief link shows as the path and anchor, not a link to the file:
Console serves no repository files.
- The queue read runs as a child process, like the bus read, so a slow read
never holds up the HTTP server. Like `list`, it takes the queue lock only
to recheck a disagreement before reporting it. It writes nothing.
- The business shown is `--business`, else the running bus host's. Without a
system config, Business is `not-configured` and Settings answers with
`notifier.refused`.
- `app.js` carries no view code. The brief expected both rows to change it
for the views, but they fit the bus view's router in `bus.js`. Its only
change is the two fixes to HEAD behaviour.
## Not done here
- `cli.mjs` prints an error from its outer catch unredacted
(`refused: …`). That catch only reaches option, board-origin and bind
errors, none of which carry a path, so it is left as it was.
- Out of scope per the brief: Ledger, runs and releases, bus backend reads.
## Boundaries kept
No change under `packages/business`, `packages/cli`, `packages/bus`,
`packages/tasks`, `scripts/` or `public/shared/`. packages/queue changes only
in decision 83's three paths. No new dependency. No live tracker request,
no Gitea write other than this row's posts, no push.
## Round 2 (answers #1543 comments 27185 and 27186)
Filbert (comment 27185, rev 376) and Darkwing (comment 27186, rev 377)
both asked for changes. Sage combined them into one round 2 list: B1, R1,
Arbiters and the queue-read import leak are required; D29 and the
views.test :167 timeout are optional, with a finding reported either way.
Same base, d64f434f. Candidate manifest `candidate-manifest.sha256`
(sha256 afb2ae0e…) covers the same 14 files. `row54.patch` is against
d64f434f; applied to a `git archive` of d64f434f it reproduces all 14
files (14 OK). `row54-r1-to-r2.patch` is the change from round 1 (7
files). Round 1's packet is kept as `candidate-manifest-r1.sha256` and
`row54-r1.patch`.
| Item | Fix | Test |
|---|---|---|
| B1 (Filbert, blocker) | `afterRef` renders an `{id, when}` entry as a link to the row and its condition, "6 settled", as QUEUE.md writes it | Seeded views test: `#/queue/9` After is `<a href="#/queue/6">6</a> settled`; row 11 carries `{id: 9, when: 'done'}`, the shape of real row 54's `{id: 53, when: 'done'}`, and reads `<a href="#/queue/9">9</a> done`. Both go through the real store. Reads test: `/api/queue/11` keeps `after` as stored |
| R1 (Filbert), with Sage's `file:/x` note | The path rule also matches `~/`, and a path after `:` or `<`. A `:` followed by `//` and a host is a URL and keeps its path; `file:///x` is still a path | Redactor test: row 35's own phrase, row 8's `` `~/.mosaic` ``, `key=~/k`, `file:/x`, `file:///srv/y`, `</srv/z.token>`; three URLs unchanged; `a~/b`, a bare `~` and a relative path unchanged. The seeded note is shaped like row 35's and is asserted redacted in the reads and views tests; `clean()` now also refuses `~/` and `secrets/mosaic-stack` in any body or page |
| Arbiters (Darkwing, required) | A business file's `arbiters` is an object, `{delivery: 'pm', technical: 'cto'}`; `names()` took only arrays, so the view always said "none". `arbiterRefs` renders each pair as "instance (kind)"; an array still goes through `names()` | Seeded views test, through `loadBusiness`: Arbiters reads `pm (delivery), cto (technical)`. Stub views test: `pm (delivery)` |
| Import leak (Darkwing, required) | Both, since both were cheap. `queue-read.mjs` imports the store and its error class with `await import()` inside its `try`, so a store that fails to load prints "the queue read failed" and exits 1. The reader forwards the child's stderr only on exit 2 (the store's refusal, `queue-refused`); any other exit is the fixed `read-failed` "the queue read failed (exit N)" | Reads test, in a `queueRepo` copy: store.mjs with a syntax error, then store.mjs missing. Each asserts the child's exact status, stdout and stderr (`1`, empty, `the queue read failed\n`), then `/api/queue` gives 503 with that fixed body and `clean()` finds no base or repo path. A fake child that prints a `file:///srv/q/x.mjs` stack and exits 3 gives the fixed exit-3 message |
| D29 (Darkwing, optional) | none needed: the code keeps the mirror select's focus | Five-state views test: focus the mirror select on `#/settings`, mark the H1, fire the 10 s refresh, wait for the redraw, assert focus is on the mirror select |
| D20, D21 (Darkwing, optional) | none needed | Reads test, with the reader pointed at a fake child: one that never exits, with `timeoutMs: 300`, gives "the queue read did not finish in time"; one that prints 64 KiB, with `maxBytes: 1024`, gives "the queue read printed too much" |
| :167 timeout (Sage item 5) | Test only. Cause below | Five-state views test |
| N1 (Filbert) | `settings()` refuses `not-configured` in Console's words when there is no data root, before `readNotifyConfig` | Reads settings test: `--business acme` with no system config gives that notifier refusal, not Node's TypeError |
| N2 (Filbert) | Required reads "yes", with "since …" only for a real date | Seeded views test on `#/queue/9` and `#/queue/11` |
| N3 (Filbert), Darkwing note 4 | `const GROUPS = [[` | none needed |
| T1 (Filbert) | none needed: the scrub was already there | Fixture: the reviewer's `model` var (the one allowlisted free-text var) holds `/srv/secret/models/local.gguf`; the business test asserts `{ model: '<path>' }` |
### The :167 timeout
The five-state test's server had reads but no bus. Opening the palette
reads `/api/bus/inbox` and `/api/bus/tasks`, and with no bus those
answer 503 `not-configured`. That is a refusal, so the row 53 rule,
"a refusal forgets everything", cleared the last reads, `api:queue`
included, and the palette rebuilt without the queue rows. The check
`palette includes #/queue/7` passed only when its first poll ran before
those two reads returned. Under load they returned first and the wait
timed out. It was a race, not a timeout set tighter than the work it
waits on, so a wider timeout would not have fixed it.
The fix is in the test. Its server now has a stub bus that answers
empty lists, and the check first waits for the palette's own inbox and
tasks reads to answer, then asserts the queue rows are still listed. The
`views-no-bus` mutant drops the stub bus. It is killed at that check on
every run, which confirms the cause.
Product follow-up, not changed in this row: on a Console with reads and
no bus, each palette open forgets the queue rows. That follows the row 53
refusal rule as written. Whether `not-configured` on a bus route should
forget the reads is a question for row 53's owner and for Sage.
### A second flake, found while testing D29
The new D29 check failed 3 of 8 times under mutant load: focus was on the
H1, not the mirror select. An instrumented run traced the late focus to
the palette dialog's `close` handler (`bus.js`, `cmdk-dialog`'s `close`
listener calls `pkBack.focus()`). Esc closes the dialog at once, but
the `close` event is a later task, and under load it ran after the test
had focused the mirror select. The test's `closePalette()` now waits for
that event before going on. Both Esc sites use it. After the change: 16
of 16 parallel runs of the five-state test passed, and the webui suite
was 39/0 while the mutant set ran beside it.
### Not done, as follow-ups
- D22, host state without `.live`: no test. It needs a live bus host's
state file in a fixture; Darkwing's P5c shows the read works.
- `cli.mjs`'s outer catch prints its error unredacted. Its inputs carry no
path today (Darkwing note 3 agrees).
- Darkwing's P2 redactor gaps, run through the round 2 redactor:
`~/secret/x.token` is `<path>`, `config:/home/u/x.token` is
`config:<path>`, and `path</home/u/x>` is `path<<path>>`, since `<` is
now a path prefix. Still unchanged:
`./secret/x.token`, a Windows path, `id_123456789012345678`, and digit
runs of 16 or 21. The README states the rule.
The queue store refuses `<` in a note, so a note can't carry the `<…>` form;
that case is in the redactor unit test only. Known gap, stated in the
README: a path stops at the first space, so a path containing a space is
redacted only up to that space. A bare `~`, `~user/` and relative paths
are not paths to the redactor.
### Mutations, round 2
`mutants.py`, 33 of 33 killed, each site matched once
(`out/mutants-r2-full.txt` in scratch), run after the `closePalette()`
change. The 17 from round 1 still apply (the `no-path-redaction` site
updated to the new rule), and 16 are new:
| Mutant | Change | Killed by |
|---|---|---|
| qlink-all-text | After maps through `qLink` again (round 1's code) | seeded views, `#/queue/9` After |
| after-no-when | the condition dropped | seeded views |
| required-bare-true | Required shows the boolean again | seeded views |
| path-no-tilde | `~/` not a path | redactor test |
| path-no-colon | `:` not a path prefix | redactor test |
| path-no-lt | `<` not a path prefix | redactor test |
| path-eats-urls | the URL guard dropped | redactor test |
| business-noscrub | `/api/business` not scrubbed (Filbert's) | business test, `/srv/secret` |
| settings-null-dataroot | the N1 refusal dropped | settings test |
| arbiters-names | Arbiters through `names()` again (Darkwing's) | seeded views, Arbiters |
| static-store-import | static imports of the store and its error class | reads test, broken store, child's stderr |
| forward-any-exit | any exit forwards the child's stderr | reads test, broken store |
| no-mirror-keep | `keep()` without the mirror (Darkwing's D29) | five-state views, mirror focus after the refresh |
| no-output-cap | the output cap dropped (Darkwing's D20) | reads test, fake child, 64 KiB |
| timeout-x1000 | the timeout times 1000 (Darkwing's D21) | reads test, fake child, by the test's 60 s timeout |
| views-no-bus | the five-state test's stub bus dropped | five-state views, palette check |
### Gate, round 2
`gate.sh` on a worktree at d64f434f plus the 14 files, manifest 14 OK,
Docker 29.7.2 up, 2026-10-10T21:10:20Z to 21:15:34Z: webui 39/0;
business 60/0, bus 67/0, cli 66/0, control-board 124/0, conversation
182/0, discord 178/0, ledger 78/0, mosaic 69/0, queue 149/0, runs 41/0,
seat 19/0, tasks 51/0; build-tokens --check current; test-auth 15/0,
test-conductor 17/0, test-config 24/0, test-discord 66/0,
test-extension-package 18/0, test-foundation 44/0, test-queue 27/0,
test-release 14/0, test-task 98/0. `core.hooksPath` unset.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,437 @@
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -157,7 +157,8 @@
with a Copy button and has no button that moves a row. The brief is shown
as its path and anchor, because Console serves no repository files.
- `#/business` shows the business named by `--business`, else the running
- bus host's business: the human, arbiters, projects, launch rules, the vars
+ bus host's business: the human, the arbiters as instance and kind ("pm
+ (delivery), cto (technical)"), projects, launch rules, the vars
on an allowlist, each role instance with its holder and harness, the
authority of each instance for each action as the business package
classifies it (`resolveInstance`, `classify`), and credentials as
@@ -173,9 +174,14 @@
response, page or log carries one, a file path or a Discord channel, guild or
user id. The server redacts every message and string field before it answers
(`redactor` in `src/reads.mjs`): the config directory becomes `<config>`, the
-data root `<dataRoot>`, any other absolute path `<path>`, a 17 to 20 digit run
-`<id>`, and a JSON parse error's quote of the file is dropped. The startup
-log goes through the same redactor.
+data root `<dataRoot>`, any other absolute or `~/` path `<path>`, a 17 to 20
+digit run `<id>`, and a JSON parse error's quote of the file is dropped. The
+startup log goes through the same redactor. A path starts at the beginning
+of the text or after a space, an opening bracket, a quote, `=`, `,`, `<` or
+`:`, so `file:/x` and `file:///x` are redacted and `https://host/x` is not.
+Known gap: a path stops at the first space, so a path containing a space is
+redacted only up to that space and the rest shows. A bare `~` or `~user/`
+is not treated as a path, and neither is a relative path.
## Data and boundaries
@@ -211,7 +217,12 @@
400. The queue is read in a child process (`src/queue-read.mjs`) through
`rows()` from `packages/queue/src/store.mjs` (lead decision 83): the same
read as `queue list`, with no write and no network. Like `list`, it takes
-the queue lock only to recheck a disagreement before reporting it. The business
+the queue lock only to recheck a disagreement before reporting it. The child
+imports the store inside its `try`, so a store that fails to load reports
+"the queue read failed". Only the store's exit 2 (invalid data or refused)
+forwards the child's text, as `queue-refused`; any other exit is the fixed
+`read-failed` message "the queue read failed (exit N)", because that stderr
+may be Node's own, with a `file://` path and a stack. The business
read uses `packages/business` in process. No read writes, calls out or adds a
dependency. Answers carry `at`; failures are `{ error, message }` with 503
for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404
@@ -282,16 +293,26 @@
palette's own reads and from the board page, and that palette labels are text.
Row 54's tests share the seeded fixture in `reads-fixture.mjs`: a scratch
-queue whose row note names a token path and an id, and a business, notifier
-config and Discord binding that hold token files, an environment variable
-name, and guild, channel, bot and user ids. `reads.test.mjs` checks each
+queue whose row note names an id and token paths shaped like real notes
+(absolute, `~/` and after `file:`; the store refuses `<` in a note, so the
+`<…>` case is in the redactor test), and a business,
+notifier config and Discord binding that hold token files, an environment
+variable name, an agent `model` var holding a path, and guild, channel, bot
+and user ids. Row 9's After entry has the `{id, when}` shape the real queue
+stores. `reads.test.mjs` checks each
route's body and status, the redactor, the credential state, methods and
-Origin, and that no body carries any seeded value or the temporary directory.
+Origin, and that no body carries any seeded value or the temporary directory;
+that a `store.mjs` with a syntax error, or none, gives the fixed message with
+no path; and that the child's timeout, output cap and non-2 exits hold, over a
+fake child script.
`views.test.mjs` walks Queue, a queue row, Business and Settings through the
five states over a stub read, with each refusal code the view can meet, at
400px; checks that a refusal leaves no queue row in the palette, that no view
has a control but Copy and Read again, and that the row page shows the
-`queue move` command; then, over the seeded fixture, that neither the page nor
+`queue move` command, that the 10 s refresh keeps focus on a Settings mirror
+select, and that the palette keeps its queue rows after its own inbox and tasks
+reads answer; then, over the seeded fixture, that the Arbiters line reads the
+business file's object, that neither the page nor
any response it read carries a seeded value, that a missing or invalid
business file shows the module's text, and that the startup log names no
config path.
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -345,7 +345,11 @@
const QSTATE = { done: 'ok', blocked: 'danger', 'waiting-on-jason': 'attn', 'in-review': 'attn', 'in-progress': 'attn' };
const qState = s => badge(s, QSTATE[s] || 'muted');
const qLink = id => Number.isInteger(id) ? `<a href="#/queue/${esc(id)}">${esc(id)}</a>` : txt(id);
+ // An after entry is {id, when}, as QUEUE.md writes it: "53 done".
+ const afterRef = a => a && typeof a === 'object' ? `${qLink(a.id)}${a.when ? ` ${txt(a.when)}` : ''}` : qLink(a);
const names = list => Array.isArray(list) && list.length ? list.map(txt).join(', ') : none;
+ // The business file's arbiters are {delivery, technical}, each a role instance: "pm (delivery)".
+ const arbiterRefs = a => a && typeof a === 'object' && !Array.isArray(a) ? Object.entries(a).map(([kind, who]) => `${txt(who)} (${txt(kind)})`).join(', ') || none : names(a);
const issueRefs = list => Array.isArray(list) && list.length ? list.map(n => `#${txt(n)}`).join(', ') : none;
// Console serves no repository files, so the brief is its path and anchor, as the row names it.
const briefRef = b => b?.path ? `<code>${txt(b.path)}${b.anchor ? `#${txt(b.anchor)}` : ''}</code>` : none;
@@ -377,7 +381,7 @@
const cmd = `scripts/mosaic queue move ${id} <state> --op <op> --by <seat>`;
return `${head(`Row ${esc(id)}: ${txt(r.piece)}`, `${qState(r.state)}${r.previousState ? ` <span class="small muted">was ${txt(r.previousState)}</span>` : ''} ${readLine()}`)}
${storeNotes(doc.notes)}
- <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(qLink).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>${txt(r.required)}${r.requiredSince ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
+ <section class="panel" aria-labelledby="q-row"><h2 id="q-row">Row</h2><dl class="kv"><dt>Owner</dt><dd>${txt(r.owner) || none}</dd><dt>Reviewers</dt><dd>${names(r.reviewers)}</dd><dt>Gate</dt><dd>${txt(r.gateOwner) || none}</dd><dt>Issues</dt><dd>${issueRefs(r.issues)}</dd><dt>Closes</dt><dd>${issueRefs(r.closes)}</dd><dt>Brief</dt><dd>${briefRef(r.brief)}${r.brief?.blob ? ` <span class="source">blob ${txt(String(r.brief.blob).slice(0, 12))}</span>` : ''}</dd><dt>After</dt><dd>${Array.isArray(r.after) && r.after.length ? r.after.map(afterRef).join(', ') : none}</dd><dt>Claim</dt><dd>${r.claim ? `${txt(r.claim.seat)} <span class="source">${txt(r.claim.op)}</span>` : none}</dd>${r.required ? `<dt>Required</dt><dd>yes${Number.isFinite(Date.parse(r.requiredSince)) ? ` since ${when(r.requiredSince)}` : ''}</dd>` : ''}${r.blockedReason ? `<dt>Blocked</dt><dd>${txt(r.blockedReason)}</dd>` : ''}${r.note ? `<dt>Note</dt><dd class="s1-q">${txt(r.note)}</dd>` : ''}<dt>Created</dt><dd>${when(r.createdAt)}</dd><dt>Updated</dt><dd>${when(r.updatedAt)}${r.updatedBy ? ` by ${txt(r.updatedBy)}` : ''}</dd></dl></section>
<section aria-labelledby="q-review"><h2 id="q-review">Review <span class="count">${rounds.length}</span></h2>${rounds.length ? `<ol class="s1-plain">${rounds.map(round).join('')}</ol>` : '<p class="muted">No review round yet.</p>'}</section>
<section aria-labelledby="q-move"><h2 id="q-move">Moving this row</h2><p class="small muted">${moveHelp} Copy only puts the command on your clipboard; fill in the state, op and seat before you run it.</p>
<div class="s1-cmd"><code>${txt(cmd)}</code><button type="button" class="btn" data-copy="${esc(cmd)}" aria-label="Copy the move command for row ${esc(id)}">Copy</button></div><p class="small muted" id="copy-status"></p>
@@ -401,7 +405,7 @@
const refused = instances.filter(i => i.refused).map(i => `<li>${txt(i.instance)}: ${txt(i.refused)}</li>`).join('');
const credRows = creds.map(c => `<tr><td>${txt(c.service)}</td><td>${txt(c.account) || '<span class="muted">not named in the business file</span>'}</td><td>${txt(c.role)}</td><td>${txt(c.dateKind === 'expires' ? 'expires' : 'rotate by')} <time datetime="${esc(c.date)}">${txt(c.date)}</time></td><td>${badge(c.state, CRED[c.state] || 'muted')}</td></tr>`).join('');
return `${head(`Business ${txt(b.id)}`, `${readLine()} The business file is edited where it lives; Console only reads it.`)}
- <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${names(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
+ <section class="panel" aria-labelledby="bz-about"><h2 id="bz-about">Business</h2><dl class="kv"><dt>Human</dt><dd>${txt(b.human) || none}</dd><dt>Arbiters</dt><dd>${arbiterRefs(b.arbiters)}</dd><dt>Projects</dt><dd>${names(b.projects)}</dd><dt>Launcher</dt><dd>${txt(launch.by) || none}</dd><dt>May launch</dt><dd>${names(launch.instances)}</dd><dt>At most</dt><dd>${Object.entries(launch.max || {}).map(([f, c]) => `${txt(c)} ${txt(f)}`).join(', ') || none}</dd></dl>
<h3>Vars</h3>${kvRows(b.vars, 'No vars Console shows are set.')}</section>
<section aria-labelledby="bz-roles"><h2 id="bz-roles">Role instances <span class="count">${instances.length}</span></h2>${instances.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Role instances, scroll for all columns"><table class="s1-table s1-roles"><thead><tr>${['Instance', 'Definition', 'Holder', 'Harness'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${roleRows}</tbody></table></div>` : empty('This business has no role instances.', 'An instance shows here when the business file names one under roles.')}</section>
${instances.length ? `<section aria-labelledby="bz-auth"><h2 id="bz-auth">Authority</h2><p class="small muted">Within the role, cross-role (needs the other role), or gated (needs the human), as the business package classifies each action.</p>${matrix}${refused ? `<ul class="s1-plain">${refused}</ul>` : ''}</section>` : ''}
@@ -425,7 +429,7 @@
}
// Trail
- const GROUPS =[['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
+ const GROUPS = [['all', 'All'], ['request', 'Requests'], ['decision', 'Decisions'], ['launch', 'Launches'], ['task', 'Task changes'], ['review', 'Review']];
function trailList(rows, filter) {
const shown = filter === 'all' ? rows : rows.filter(r => r.group === filter);
if (!shown.length) return empty('Nothing of this kind in the trail.', 'Every step the stack takes on this subject writes a trail row.');
--- a/packages/webui/src/queue-read.mjs
+++ b/packages/webui/src/queue-read.mjs
@@ -3,14 +3,19 @@
// calls rows() in packages/queue (lead decision 83): the same unlocked read
// as `queue list`, which writes nothing. JSON on stdout; a refusal's message
// on stderr with the store's exit code.
+//
+// The store is imported inside the try, so a store.mjs that fails to load
+// (a half-written file in a shared checkout) prints "the queue read failed",
+// never Node's own message, which names the file:// path and a stack.
-import { rows } from '../../queue/src/store.mjs';
-import { QueueError } from '../../queue/src/errors.mjs';
-
+let QueueError = null;
try {
+ ({ QueueError } = await import('../../queue/src/errors.mjs'));
+ const { rows } = await import('../../queue/src/store.mjs');
const r = rows({});
process.stdout.write(`${JSON.stringify({ rows: r.rows, notes: r.err })}\n`);
} catch (err) {
- process.stderr.write(`${err instanceof QueueError ? err.message : 'the queue read failed'}\n`);
- process.exitCode = err instanceof QueueError && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
+ const refused = QueueError !== null && err instanceof QueueError;
+ process.stderr.write(`${refused ? err.message : 'the queue read failed'}\n`);
+ process.exitCode = refused && Number.isInteger(err.code) && err.code > 0 ? err.code : 1;
}
--- a/packages/webui/src/reads.mjs
+++ b/packages/webui/src/reads.mjs
@@ -10,7 +10,11 @@
//
// Every string a response carries passes through the redactor: the config
// directory and dataRoot become <config> and <dataRoot>, any other absolute
-// path <path>, and a 17 to 20 digit run (a Discord id) <id>.
+// or ~/ path <path>, and a 17 to 20 digit run (a Discord id) <id>. A path
+// starts at the beginning, a space, an opening bracket, a quote, `=`, `,`,
+// `<` or `:`, so `file:/x` and `file:///x` are paths and `https://host/x`
+// is not. A path stops at a space, so one with a space in it is redacted
+// only up to the space.
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
@@ -41,7 +45,7 @@
return text => {
let s = unquote(String(text));
for (const [re, label] of known) s = s.replace(re, label);
- return s.replace(/(^|[\s('"`=,[{])\/[^\s'"`),;\]}]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
+ return s.replace(/(^|[\s('"`=,[{<]|:(?!\/\/[^/]))~?\/[^\s'"`),;\]}>]+/g, '$1<path>').replace(/(?<![\w.-])\d{17,20}(?![\w.-])/g, '<id>');
};
}
// Every string in a value, deep. Keys are left alone: they are fixed names or ids.
@@ -75,10 +79,12 @@
child.on('close', status => {
const said = Buffer.concat(err).toString('utf8').trim().slice(0, 2000);
if (status === null) return done(reject, new ReadError('read-failed', 'the queue read did not finish'));
- // 2 is the store's "invalid data or refused": fail closed. Anything
- // else failed to run; the view keeps its last read.
+ // 2 is the store's "invalid data or refused": fail closed with the
+ // store's text. Anything else failed to run, and its stderr may be
+ // Node's own (a path and a stack), so it is not shown; the view keeps
+ // its last read.
if (status === 2) return done(reject, new ReadError('queue-refused', said || 'the queue refused the read'));
- if (status !== 0) return done(reject, new ReadError('read-failed', said || `the queue read exited ${status}`));
+ if (status !== 0) return done(reject, new ReadError('read-failed', `the queue read failed (exit ${status})`));
try {
const doc = JSON.parse(Buffer.concat(out).toString('utf8'));
if (!Array.isArray(doc?.rows) || !Array.isArray(doc?.notes)) throw new Error('shape');
@@ -193,6 +199,7 @@
let id = null, notifier;
try {
id = businessId({ business, dataRoot });
+ if (!dataRoot) throw new ReadError('not-configured', 'the Console has no system config, so it knows no notifier config');
notifier = { binding: readNotifyConfig(dataRoot, id) };
} catch (err) {
notifier = { refused: err instanceof ReadError ? err.code : 'not-configured', message: err.message };
--- a/packages/webui/tests/reads-fixture.mjs
+++ b/packages/webui/tests/reads-fixture.mjs
@@ -1,9 +1,11 @@
// Row 54 (#1543): the seeded fixture the reads and views tests share. A
// business holds a Discord user id, token files, an environment variable
// name and bot ids; a data root holds a notifier config and a Discord
-// binding with guild, channel and user ids and a token file. A queue row's
-// note names a token path and an id. None may reach a response, the page or
-// a log, nor may the temporary directory.
+// binding with guild, channel and user ids and a token file; an agent's
+// model var holds a path. A queue row's note names an id and token paths in
+// the shapes real notes use (row 35: "~/.config/mosaic-dev/secrets/…").
+// None may reach a response, the page or a log, nor may the temporary
+// directory. Rows 9 and 11 have After entries in the stored {id, when} shape.
import assert from 'node:assert/strict';
import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -15,10 +17,13 @@
export const SNOWFLAKES = ['123456789012345678', '223456789012345678', '323456789012345678', '423456789012345678', '523456789012345678'];
export const day = n => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
+export const NOTE = `token at /srv/secret/x.token for ${SNOWFLAKES[4]}; tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (receipt file:/srv/secret/r.json)`;
+export const NOTE_SHOWN = 'token at <path> for <id>; tokens 0600 under <path> (receipt file:<path>)';
+
// A canonical scratch checkout with a committed queue, the reader script
// beside a copy of packages/queue/src, and a RELEASE file.
export function queueRepo(t) {
- const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: `token at /srv/secret/x.token for ${SNOWFLAKES[4]}` } : r);
+ const rows = MAP_ROWS.map(r => r.id === 6 ? { ...r, note: NOTE } : r.id === 11 ? { ...r, after: [{ id: 9, when: 'done' }] } : r);
const repo = scratchRepo(t, { map: mapText(rows) });
genesisCommitted(repo);
mkdirSync(join(repo.root, 'packages/webui/src'), { recursive: true });
@@ -37,6 +42,7 @@
doc.roles.coder.credentials.gitea = { env: 'CODER_GITEA_SECRET_ENV', rotateBy: day(-1) };
doc.roles.coder.credentials.vikunja.expires = day(3);
doc.roles.reviewer.credentials.vikunja.expires = day(-2);
+ doc.roles.reviewer.vars = { model: '/srv/secret/models/local.gguf' };
if (business) writeJson(join(configDir, 'businesses', 'acme.json'), doc);
if (notify) writeJson(join(dataRoot, 'notify', 'acme', 'notify.json'), { notifyVersion: 1, binding: 'jason-dm' });
writeJson(join(dataRoot, 'discord', 'jason-dm.json'), {
@@ -51,5 +57,5 @@
export function clean(text, ...bases) {
for (const b of bases) assert.equal(text.includes(b), false, `response names ${b}`);
for (const s of SNOWFLAKES) assert.equal(text.includes(s), false, `response carries id ${s}`);
- for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret']) assert.equal(text.includes(s), false, `response carries ${s}`);
+ for (const s of ['placeholder-not-a-token', 'CODER_GITEA_SECRET_ENV', '"file"', '"env"', '"botId"', 'discordUserId', '"tokenFile"', '"guildId"', '"channels"', '/srv/secret', '~/', 'secrets/mosaic-stack']) assert.equal(text.includes(s), false, `response carries ${s}`);
}
--- a/packages/webui/tests/reads.test.mjs
+++ b/packages/webui/tests/reads.test.mjs
@@ -3,11 +3,13 @@
// the temporary directory.
import { test } from 'node:test';
import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
-import { consoleReads, credentialState, redactor } from '../src/reads.mjs';
+import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
-import { SRC, SNOWFLAKES, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
+import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs';
async function serve(t, reads) {
const server = await startServer({ port: 0, reads });
@@ -23,6 +25,12 @@
assert.equal(r('file /run/x.token, see https://git.example/a'), 'file <path>, see https://git.example/a');
assert.equal(r('/home/u/.config/mosaic-devX/y'), '<path>');
assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user <id> ok');
+ // ~/ paths, and a path after `:` or `<` (Filbert R1 and Sage, #1543 comment 27185). A URL keeps its path.
+ assert.equal(r('tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (lead decision 74)'), 'tokens 0600 under <path> (lead decision 74)');
+ assert.equal(r('no `~/.mosaic` changes; key=~/k'), 'no `<path>` changes; key=<path>');
+ assert.equal(r('file:/x, file:///srv/y and </srv/z.token>'), 'file:<path>, file:<path> and <<path>>');
+ assert.equal(r('https://git.example/a, http://127.0.0.1:3456 and http://h:80/p'), 'https://git.example/a, http://127.0.0.1:3456 and http://h:80/p');
+ assert.equal(r('a~/b, ~ and agents/sage/work/'), 'a~/b, ~ and agents/sage/work/');
const quoted = r('business file is not valid JSON (/home/u/.config/mosaic-dev/businesses/a.json): Unexpected token \'s\', "s3cret-value" is not valid JSON');
assert.equal(quoted.includes('s3cret-value'), false);
assert.match(quoted, /^business file is not valid JSON \(<config>\/businesses\/a\.json\): the parser quoted/);
@@ -46,7 +54,7 @@
assert.deepEqual(body.rows.map(r => r.id), [1, 6, 8, 9, 11]);
const six = body.rows.find(r => r.id === 6);
assert.deepEqual([six.state, six.owner, six.reviewers, six.brief], ['in-progress', 'darkwing', ['filbert'], { path: 'docs/plans/brief-a.md', anchor: 'Row six' }]);
- assert.equal(six.note, 'token at <path> for <id>');
+ assert.equal(six.note, NOTE_SHOWN);
assert.equal(Object.hasOwn(six, 'gate'), false);
assert.ok(Array.isArray(body.notes));
clean(list.text, repo.base);
@@ -55,6 +63,7 @@
const nine = JSON.parse(one.text).row;
assert.deepEqual([nine.id, nine.gate, nine.after], [9, 'filbert approves', [{ id: 6, when: 'settled' }]]);
clean(one.text, repo.base);
+ assert.deepEqual(JSON.parse((await get(base, '/api/queue/11')).text).row.after, [{ id: 9, when: 'done' }]);
assert.equal((await get(base, '/api/queue/7')).status, 404);
for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad);
for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) {
@@ -75,6 +84,27 @@
assert.equal((await get(base, '/api/queue/6')).status, 503);
});
+test('queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack', { timeout: 60000 }, async t => {
+ // Darkwing 27186: a half-written store.mjs used to send Node's own error, file:// path and stack included.
+ for (const [name, breakStore] of [['syntax error', p => writeFileSync(p, 'export const rows = (;\n')], ['missing', p => rmSync(p)]]) {
+ const repo = queueRepo(t);
+ breakStore(join(repo.root, 'packages/queue/src/store.mjs'));
+ const child = spawnSync(process.execPath, [join(repo.root, 'packages/webui/src/queue-read.mjs')], { cwd: repo.root, env: repo.env, encoding: 'utf8' });
+ assert.deepEqual([child.status, child.stdout, child.stderr], [1, '', 'the queue read failed\n'], name);
+ const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES }));
+ const r = await get(base, '/api/queue');
+ assert.equal(r.status, 503, name);
+ assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: 'the queue read failed (exit 1)' }, name);
+ clean(r.text, repo.base, repo.root);
+ }
+ // Only exit 2 forwards the child's text; any other exit is a fixed message.
+ const repo = queueRepo(t), script = join(repo.root, 'packages/webui/src/queue-read.mjs');
+ const fake = (body, opts = {}) => { writeFileSync(script, body); return queueReader({ root: repo.root, env: repo.env, ...opts })(); };
+ await assert.rejects(fake('process.stderr.write("Error: boom\\n at main (file:///srv/q/x.mjs:1:1)\\n"); process.exitCode = 3;\n'), { code: 'read-failed', message: 'the queue read failed (exit 3)' });
+ await assert.rejects(fake('setInterval(() => {}, 1000);\n', { timeoutMs: 300 }), { code: 'read-failed', message: 'the queue read did not finish in time' });
+ await assert.rejects(fake('process.stdout.write("x".repeat(65536));\n', { maxBytes: 1024 }), { code: 'invalid-response', message: 'the queue read printed too much' });
+});
+
test('business: names, vars on the allowlist, authority, credential metadata only', async t => {
const s = seeded(t);
const base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
@@ -86,6 +116,8 @@
assert.deepEqual(b.vars, { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'gitea.baseUrl': 'https://git.example', 'tracker.pollSeconds': 60 });
assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]);
assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' });
+ // An allowlisted var is scrubbed too (Filbert T1).
+ assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '<path>' });
for (const i of b.instances) {
assert.deepEqual(Object.keys(i.authority), b.actions);
assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v)));
@@ -158,4 +190,9 @@
r = await get(base, '/api/settings');
assert.equal(r.status, 200);
assert.equal(JSON.parse(r.text).notifier.refused, 'not-configured');
+ // --business with no system config: refused in Console's words, not Node's (Filbert N1).
+ base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: null, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }));
+ r = await get(base, '/api/settings');
+ assert.equal(r.status, 200);
+ assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' });
});
--- a/packages/webui/tests/views.test.mjs
+++ b/packages/webui/tests/views.test.mjs
@@ -15,7 +15,7 @@
import { startServer } from '../src/serve.mjs';
import { consoleReads, ReadError } from '../src/reads.mjs';
import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs';
-import { SRC, queueRepo, seeded, clean } from './reads-fixture.mjs';
+import { SRC, NOTE_SHOWN, queueRepo, seeded, clean } from './reads-fixture.mjs';
const PROBE = 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];window.bodies=[];const f=window.fetch;window.fetch=async(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);const r=await f(u,o);bodies.push(await r.clone().text());return r};'
// The bus view's 10s refresh is recorded so a test can fire it early, unless it was cleared.
@@ -62,11 +62,19 @@
test('Queue, Business and Settings in their five states, with no move button', { timeout: 180000 }, async () => {
const s = stubReads();
- const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', reads: s.reads });
+ // 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 b = await browser();
try {
const { wait, text, count, flat, refresh, go } = await page(b, `http://127.0.0.1:${web.address().port}/`);
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
+ // Esc closes the palette at once, but its close event, which puts focus back, is a later task.
+ // Waiting for that event keeps a late focus from landing on the next step (seen under load).
+ const closePalette = async () => {
+ await b.evaluate('window.paletteClosed=new Promise(r=>document.querySelector("#cmdk-dialog").addEventListener("close",()=>setTimeout(r),{once:true}));true');
+ await b.key('Escape'); await b.evaluate('window.paletteClosed.then(()=>true)');
+ };
for (const [hash, h1, source, rows, refusals, emptied, emptyText] of [
['#/queue', 'Queue', 'Queue', '#bus-view table.s1-queue tbody tr', ['queue-refused', 'not-configured'], () => { s.docs.queue.rows = []; }, 'The queue has no rows.'],
['#/queue/6', 'Row 6', 'Queue', '#bus-view [aria-labelledby=q-row] dl', ['queue-refused'], null, null],
@@ -114,7 +122,7 @@
assert.equal(await count('#bus-view select[data-mirror]'), hash === '#/settings' ? 2 : 0, hash);
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
assert.equal(await b.evaluate(`${palette}.filter(h=>h.startsWith("#/queue/")).length`), 0, `${hash}: no kept queue row in the palette after ${code}`);
- await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
+ await closePalette();
await flat(`${hash} refused`);
s.fail(null); await refresh();
await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
@@ -157,16 +165,23 @@
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view table.s1-queue tbody tr")].map(e=>e.cells[0].textContent)'), ['7 Second row']);
await go('#/queue/6', 'Row 6');
assert.equal(await text('#bus-view .s1-cmd code'), 'scripts/mosaic queue move 6 <state> --op <op> --by <seat>');
+ await go('#/business', 'Business acme');
+ assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery)');
for (const hash of ['#/queue', '#/queue/6', '#/business', '#/settings']) {
await go(hash, { '#/queue': 'Queue', '#/queue/6': 'Row 6', '#/business': 'Business', '#/settings': 'Settings' }[hash]);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#bus-view button, #bus-view form, #bus-view input")].filter(e=>!e.matches("button[data-copy], button[data-retry]")).length'), 0, `${hash}: no control but Copy and Read again`);
assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view button")].some(e=>/move|resolve|approve|save|edit/i.test(e.textContent))'), false, hash);
}
- // The palette lists queue rows from the last queue read, as text.
- await b.key('k', 'KeyK', 2); await wait(`${palette}.includes("#/queue/7")`);
+ // The palette lists queue rows from the last queue read, as text, and still does once its
+ // inbox and tasks reads have answered. Over a server with no bus they were refusals that
+ // forgot the queue rows, and the check passed only when it ran before they returned.
+ const asked = await b.evaluate('window.requests.length');
+ await b.key('k', 'KeyK', 2);
+ await wait(`window.requests.slice(${asked}).filter(([,u])=>/\\/api\\/bus\\/(inbox|tasks)$/.test(u)).length===2 && window.bodies.length===window.requests.length`);
+ await wait(`${palette}.includes("#/queue/7")`);
assert.equal(await count('#cmdk-list #q-b'), 0);
- await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
+ await closePalette();
// Settings: appearance is the one control, mirrored with the command bar and kept in this browser.
await go('#/settings', 'Settings');
@@ -176,6 +191,11 @@
await b.evaluate('{const m=document.querySelector("#bus-view select[data-mirror=mode]");m.value="dark";m.dispatchEvent(new Event("change",{bubbles:true}))}');
await wait('document.querySelector("#mode").value==="dark"');
assert.equal(await b.evaluate('JSON.parse(localStorage.getItem("mosaic-console-appearance")).mode'), 'dark');
+ // The 10 s refresh redraws Settings and keeps focus on the mirror select (Darkwing D29).
+ await b.evaluate('document.querySelector("#bus-view select[data-mirror=mode]").focus(); document.querySelector("#bus-view h1").dataset.old = "1"');
+ assert.equal(await b.evaluate('fireRefresh()'), 1, 'a refresh is due on #/settings');
+ await wait('!document.querySelector("#bus-view h1").dataset.old && !document.querySelector("#bus-view [aria-busy]")');
+ assert.equal(await b.evaluate('document.activeElement?.matches("#bus-view select[data-mirror=mode]") ?? false'), true, 'the refresh keeps focus on the mirror select');
assert.deepEqual(await b.evaluate('window.errors'), []);
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
@@ -191,10 +211,21 @@
const seen = async () => clean(await b.evaluate('document.documentElement.outerHTML + "\\n" + window.bodies.join("\\n")'), s.base, repo.base, repo.root, tmpdir());
await go('#/queue', 'Queue'); await seen();
await go('#/queue/6', 'Row 6');
- assert.equal(await text('#bus-view dd.s1-q'), 'token at <path> for <id>');
+ assert.equal(await text('#bus-view dd.s1-q'), NOTE_SHOWN);
+ await seen();
+ // After as the store keeps it, {id, when}: a link to the row and its condition (Filbert B1).
+ const kv = (dt) => b.evaluate(`[...document.querySelectorAll("#bus-view [aria-labelledby=q-row] dt")].find(e=>e.textContent===${JSON.stringify(dt)})?.nextElementSibling?.innerHTML ?? null`);
+ await go('#/queue/9', 'Row 9');
+ assert.equal(await kv('After'), '<a href="#/queue/6">6</a> settled');
+ assert.match(await kv('Required'), /^yes( since |$)/);
+ await go('#/queue/11', 'Row 11');
+ assert.equal(await kv('After'), '<a href="#/queue/9">9</a> done');
+ assert.equal(await kv('Required'), null);
await seen();
await go('#/business', 'Business acme');
assert.match(await text('#bus-view [aria-labelledby=bz-creds]'), /bot-acme-coder/);
+ // The business file's arbiters object, as loadBusiness gives it (Darkwing 27186).
+ assert.equal(await b.evaluate('[...document.querySelectorAll("#bus-view [aria-labelledby=bz-about] dt")].find(e=>e.textContent==="Arbiters").nextElementSibling.textContent'), 'pm (delivery), cto (technical)');
await seen();
await go('#/settings', 'Settings');
assert.equal(await text('#bus-view [aria-labelledby=st-notify] dd code'), 'jason-dm');
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff