25 KiB
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 aslistwith no write.packages/queue/README.mdnames it;packages/queue/tests/write.test.mjsadds one test: the rows matchlistandshowrow for row, no queue, view or head file changes, the reader-between notes matchlist'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 callsrows()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):consoleReadsreturnsqueue,row,businessandsettings. The queue read runs the child with a timeout and an output cap. Exit 2 isqueue-refused(fail closed). Any other failure isread-failed, and the view keeps its last read. Business isloadBusinessprojected to names, vars on an allowlist,resolveInstance/classifyauthority per action, and credential metadata (service, account, role, date kind, date, state from the date alone). Settings isRELEASE, the business id andreadNotifyConfig's binding name.redactormaps 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.scrubapplies 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 400invalid-request. Statuses: 503 for a refusal or nothing to read, 404 not-found, 502 an unusable answer. A non-ReadErroranswers with its code only. Settings answers 200 without a system config, withnotifier.refused. The existing CSP, Host and Origin checks are unchanged and cover the new paths.packages/webui/src/cli.mjs: buildsconsoleReadsand 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-refusedis 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 twoapp.jsfixes, 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 twoapp.jsfixes, 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-onlysurvived. 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."), noaria-busyskeleton, empty#sessionsand#waiting, the three counts–, and "the read failed" in#status, the footer,#fresh-boardand the tree. It then answers 200 and refreshes back to the empty board. The reload resetswindow.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 isnot-configuredand Settings answers withnotifier.refused. app.jscarries no view code. The brief expected both rows to change it for the views, but they fit the bus view's router inbus.js. Its only change is the two fixes to HEAD behaviour.
Not done here
cli.mjsprints 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.tokenis<path>,config:/home/u/x.tokenisconfig:<path>, andpath</home/u/x>ispath<<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.