Files

6.1 KiB

Row 173 (#1671): a Seen click that waited out a Board read keeps the meaning it was clicked with

Brief: docs/plans/2026-10-19_seen-click-intent.md (blob 225ac823b). Branch dewey/row173 off aca16404c. Queue reviewer: Darkwing. Gate (queue row): Darkwing approves on #1671 naming the candidate manifest; webui and every scripts/test-*.sh green on Sage's gate rerun.

Design

  • Intent. seenButton draws data-intent="seen" on a Seen button and data-intent="unsee" on an Unsee button, from the same r.seen that picks the label. The click handler reads it into mark before writing = true and before the wait, so the fresh row can't change it. I chose a data- value over reading the label text so a later relabel can't change what a click posts.
  • The fresh row already holds the intent (r.seen === mark). This happens when another viewer marked or unmarked the row while the read was in flight. Nothing is posted, the button is re-enabled, and the page announces <agent> already seen or <agent> already waiting.
    • Why post nothing. The route takes a repeat without harm: markSeen with seen:true rewrites the same mark, and seen:false deletes a key that is already gone (control-board/src/scan.mjs:255-256). So posting the same value would also be correct. It would cost a write and a rescan, though, and the read that just finished has already drawn the row. The brief allows either.
    • An unseen row can still hold an old mark for an earlier lastActivity. Posting nothing on Unsee leaves that mark, which is harmless because scanAgent only counts a mark equal to the row's lastActivity (scan.mjs:340).
  • Otherwise the click posts seen: mark and announces from mark, not from the fresh row.
  • Unchanged: the row 172 wait, the single-click guard (writing), the no-row branch, and the /api/seen route (out of scope).
  • seen.disabled = false in the new branch matches the no-row branch. After the read, accept redraws the inspector and the cards, so seen is usually a detached button and the line has no visible effect. No test can tell it apart; see M5.

Test

packages/webui/tests/board-held.test.mjs gains one test: "a Seen or Unsee click that waited out a Board read keeps its meaning: another viewer's mark in that read posts nothing". setup returns boardURL: f.boardURL so the test can post to the board as the other viewer.

  • Steps, for each of Seen and Unsee:
    • it holds /api/board with window.__hold and clicks Refresh;
    • while the read is held, it posts the other viewer's mark straight to the board;
    • it clicks the inspector's button and releases the read.
  • Checks: the announcement (agent1 already seen, then agent1 already waiting), #seen-count (1, then 0), and that the page sent no /api/seen at all.
  • The held fetch hasn't reached the board when the other viewer posts (the stub awaits the hold before calling the real fetch), so the released answer carries the other viewer's mark.

Hash pins and readers checked (git grep, with a positive control for 64-hex): nothing pins app.js. shell.test counts existing app.js strings only. browser.test, sso-browser.test and the other board-held tests click [data-seen] buttons the page draws, so they get data-intent with them.

Mutants (run on CLEAR only; each one edit to app.js, then board-held.test.mjs whole)

id edit expected kill
M1 post seen: !r.seen and drop the r.seen === mark branch (the old toggle) new test (a post, and "returned to waiting")
M2 drop the r.seen === mark branch only (post the repeat) new test (a post, and "marked seen")
M3 seenButton swaps the intent (r.seen ? 'seen' : 'unsee') row 172 test (#seen-count never 1) and the new test
M4 the new branch returns without announce new test (announcement wait times out)
M5 drop seen.disabled = false in the new branch equivalent: the button is redrawn by accept; named, not run

Each run: timeout -k 10 180 node --test --test-force-exit packages/webui/tests/board-held.test.mjs; a kill counts only as an assertion or a named wait failing in the named test.

Runs

None yet. Gander holds the gate slot (row 253 run 5) from 16:32Z. The claim, the push and every run wait for Sage's CLEAR.

Round 2: rebase onto 78b69e1e6 (sage-4361)

  • Branch dewey/row173-r2 off refactor 78b69e1e6. The round 1 commit 25ff7cd3f (on aca16404c, 555 commits behind) was cherry-picked as a new commit; dewey/row173 is untouched. No force-push.
  • The pick merged on its own. git range-diff aca16404c..25ff7cd3f 78b69e1e6..<pick> reports the commit equal.
  • Overlap with refactor: one app.js line, the /api/config handler at the end of the file (federation render). It is far from both row 173 hunks and changes no assertion or reviewed line. board-held.test.mjs and the brief (blob 225ac823b) are unchanged on refactor.
  • No test pins app.js by hash (grepped tests for app.js plus 64-hex).

Run list

app.js is a page script, loaded by index.html, not imported by any module. Its test readers:

  • by name: board-held, browser, granted-user, serve, shell, sso-browser, user-option-swap;
  • by page load: every webui test that opens the Console through tests/browser.mjs (31 test files). No test outside packages/webui reads or serves app.js (git grep over packages/*/tests and scripts: none).

Order, each run with TMPDIR from mktemp -d /mnt/storage/scratch/tmp/d173XXXXXX and absolute paths:

  1. M1-M4 below, each with board-held.test.mjs whole.
  2. board-held.test.mjs whole on the restored tree.
  3. packages/webui whole (all 70 test files), which covers both reader groups. case1-accept.test.mjs is not in the list (mos-1612). Runs need Sage's CLEAR.

Mutant procedure

The M1-M5 table above stands. M5 stays equivalent: the new branch is reached only after the waited-out Board read's accept (refresh, app.js:372) has redrawn the row, so seen is the detached old button. Each mutant is one edit to app.js. Before the next mutant, the restored app.js must match the candidate's sha256. Each run is in its own process group so a timeout kill takes its browser with it. Afterwards I sweep for orphaned node/chromium processes in my run directory.