Compare commits

...
1 Commits
3 changed files with 101 additions and 3 deletions

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
# 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.
+6 -2
View File
@@ -66,7 +66,7 @@
$('mode').onchange = e => { mode = e.target.value; theme(); };
function seenButton(r) {
if (!['waiting', 'error'].includes(r.state) || !r.lastActivity) return '';
return `<button type="button" class="btn" data-seen="${esc(key(r))}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
return `<button type="button" class="btn" data-seen="${esc(key(r))}" data-intent="${r.seen ? 'unsee' : 'seen'}" aria-label="${r.seen ? 'Unsee' : 'Mark seen'} ${esc(r.agent)}">${r.seen ? 'Unsee' : 'Seen'}</button>`;
}
const historyButton = r => `<button type="button" class="btn conv-open" data-history="${esc(key(r))}" aria-label="History for ${esc(r.agent)}">History</button>`;
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
@@ -415,12 +415,16 @@
if (!seen || writing) return;
// A Board read in flight (reading) holds busy. The click waits for that read and posts on the row it
// drew; dropping it left the click doing nothing (row 172). A second click while one waits or posts is dropped.
// The click means what the button said when clicked (Seen marks, Unsee returns the row to waiting), read before
// the wait. If the fresh row already holds that, another viewer got there first: nothing is posted (row 173).
const mark = seen.dataset.intent === 'seen';
writing = true; seen.disabled = true;
try {
while (reading) await reading;
const r = row(seen.dataset.seen); if (!r) { seen.disabled = false; return; }
if (r.seen === mark) { seen.disabled = false; announce(`${r.agent} already ${mark ? 'seen' : 'waiting'}`); return; }
busy = true;
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); }
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: mark })); announce(`${r.agent} ${mark ? 'marked seen' : 'returned to waiting'}`); }
catch (err) { error(err); seen.disabled = false; }
finally { busy = false; schedule(); }
} finally { writing = false; }
+32 -1
View File
@@ -82,7 +82,7 @@ async function setup(machine, fast = false) {
const polls = () => b.evaluate('window.__polls');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, polls, settle, done };
return { b, base, boardURL: f.boardURL, wait, text, shown, reqs, polls, settle, done };
}
const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2';
@@ -191,6 +191,37 @@ test('a Seen click while a Board read is in flight waits for that read, then pos
} finally { await done(); }
});
// Row 173 (#1671): the click took its meaning from the fresh row, so another viewer's mark landing in the read
// it waited for turned "Seen" into an unsee. The click means what its button said; a fresh row that already
// holds that posts nothing and says so. The other viewer posts straight to the board while the read is held.
test('a Seen or Unsee click that waited out a Board read keeps its meaning: another viewer\'s mark in that read posts nothing', { timeout: 120000 }, async () => {
const { b, base, boardURL, wait, text, reqs, done } = await setup(false);
const posts = async () => (await reqs()).filter(r => r.path === '/api/seen').length;
const other = async seen => {
const r = (await (await fetch(`${boardURL}/api/board`)).json()).sessions.find(s => s.project === 'proj' && s.agent === 'agent1');
const res = await fetch(`${boardURL}/api/seen`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project: 'proj', agent: 'agent1', lastActivity: r.lastActivity, seen }) });
assert.equal(res.status, 200);
};
try {
await b.navigate(base);
await wait(`${SESSIONS} && ${HELD}`);
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
await wait('document.querySelector("#inspection [data-seen]")');
for (const [label, seen, count, said] of [['Seen', true, '1', 'agent1 already seen'], ['Unsee', false, '0', 'agent1 already waiting']]) {
assert.equal(await text('#inspection [data-seen]'), label);
await b.evaluate(`window.__hold = '/api/board', document.querySelector('#refresh').click(), true`);
await wait('window.__held.length === 1');
// The held fetch has not reached the board yet, so its answer carries the other viewer's mark.
await other(seen);
await b.evaluate(`document.querySelector('#inspection [data-seen]').click(), true`);
await b.evaluate('window.__held.splice(0).forEach(r => r()), true');
await wait(`document.querySelector("#announce").textContent === ${JSON.stringify(said)}`);
assert.equal(await text('#seen-count'), count);
assert.equal(await posts(), 0);
}
} finally { await done(); }
});
test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
// The poll timers fire after 150 ms here, so the 600 ms settle below spans several ticks.
const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(true, true);