Files
stack/agents/dewey/work/queue-54/evidence.md
T

25 KiB
Raw Blame History

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.