Compare commits

..
5 changed files with 132 additions and 196 deletions

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
# 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.
-171
View File
@@ -1,171 +0,0 @@
# Row 267 (C2-15, #1772): hybrid universes, exclusions, pins and the crossover. Build notes
Morgana, 2026-10-25. Brief: `docs/plans/2026-10-20_case2-rows.md` "C2-15 hybrid universes: exclusions,
pins and the end-to-end crossover" (blob 85e6fe2e). Reviewer Filbert, gate owner Sage. Claimed at rev 4087.
Branch `morgana/row267` from refactor 78b69e1e6. Design 1.4 is `agents/gosalyn/work/case2/DESIGN.md`.
Status: draft design. Nothing is built or run. No runs before Sage's CLEAR.
## 1. What the code does today (78b69e1e6)
- `mapping.mjs:119-134` checks only the shape of a constraint. `exclude` is any folder path, and
`pin.entity` is any folder path with a scalar value. Nothing checks either against the sources.
- `query.mjs:52-57` `sees()` reads `exclude` as a path prefix only (`inside(c.exclude, file.path)`). The
type form `second/Characters` (`mapping.test.mjs:203`) passes the validator but hides nothing, because
no file path starts with `second/`.
- `reader.mjs:206-210` gives R6 only for a canon link into a universe that is neither the file's own nor
one of its sources. A hybrid canon file that links into an excluded source path gets no problem.
- `guard.mjs:91-97` lets any mix of one hybrid and its sources pass. It doesn't check exclusions.
- `problems.mjs` has R1-R7 and no R8.
## 2. Design
### 2.1 Validator (`mapping.mjs`)
The validator runs after every universe is read, alongside the existing sources check at `:141-146`,
because it needs each source's books, playground and supporting lists. For each constraint of a hybrid:
- **Type form:** the first segment is one of the hybrid's `sources`, and the rest is exactly one key of
`types`.
- **Path form:** the path equals, or lies inside, a book folder of one of the sources.
- Both forms fit: refuse `library-mapping-invalid` ("... reads as both a source/type and a source path").
- Neither form fits: refuse `library-mapping-invalid` ("... lies inside no book of the hybrid's
sources"). This covers a path in the hybrid's own books, in a non-source universe, or unmapped.
- **Path form under a playground:** a path equal to or inside one of a source's playground prefixes
refuses ("... lies under a playground of universes.X, which the hybrid never reads"). "Under" means the
path equals the prefix without its trailing `/`, or starts with the prefix.
- **`pin.entity`:** the same path-form rules apply, with no type form. An entity is one file.
The stored shape stays `{ exclude }` / `{ pin }`. I don't add a `form` field. `library_universe`
returns `constraints` as they are now, and the index cache digest doesn't change for existing mappings.
`excluded()` works out the form again each time. It's pure and cheap.
### 2.2 `excluded(mapping, hybrid, path)` (`scope.mjs`)
This returns true when any `exclude` of universe `hybrid` covers `path`:
- **Type form `S/T`:** `path` starts with `${book}/${T}/` for some book of source S.
- **Path form P:** `path === P` or `path` starts with `${P}/`.
It returns false when `hybrid` isn't a hybrid. It reads only the path (D2). It has three callers:
- `query.mjs` `sees()` replaces its own `inside(c.exclude, ...)` reading with this helper.
- `reader.mjs` uses it for the excluded-link R6.
- `guard.mjs` uses it for the excluded cite.
`query-fixture.mjs` `EXCLUSION` (`Book One/Research`) is path form and still fits, so the existing
C2-05a query tests keep their constants.
### 2.3 Reader: excluded-link R6 and R8
- **Excluded-link R6:** in the canon branch at `:206`, a hybrid file's link to a target where
`excluded(mapping, file.universe, to.path)` is true gives `problem('R6', file.path, link.line)`, with
the default message. A custom message isn't possible: `mirror.mjs:196-201` `validProblem` accepts a
sync-state problem only when `message === RULES[code]`, so a new message would make every later sync
state read refuse `library-state-invalid`. The link stays in `file.links`, as the other R-rules leave
it. `sees()` already drops it from the hybrid's `links_out`. A link that is both foreign and excluded
can't occur, because an excluded path always lies in a source, so each link gets at most one R6.
- **R8:** a new `RULES.R8` entry reads "pinned value changed". The check runs once per commit, after the
link pass, for each pin of each hybrid:
- **Entity missing** from `byPath`: R8 at `entity`, with no line. This covers a file that is absent,
R1, R2 or R7.
- **Entity present:** R8 at `entity`, on the `field` key's line where there is one, unless the parsed
front matter holds `field` as a scalar (string, number or boolean) and `String(parsed) ===
String(value)`. A list, null, a missing key and unparsed (R3) front matter all count as changed. Under
a plain `String()` comparison, `['grey']` would equal `'grey'`, so this rule closes that hole.
- **Hybrid versions:** each canon file of the hybrid whose `type` (from `classify`) equals the entity's
type, and whose name matches the entity's, is a version of it. Names are the trimmed front matter
`name` on both sides. When either file has no text `name`, the file names (stems) compare instead.
A version that holds `field` with another value gets R8 at its own path, on that key's line. A version
without `field` gets nothing, and neither does any other file that only links to the entity.
- The entity's type and name come from `classify(mapping, entity)` and the entity's parsed file. When the
entity is missing, its stem stands in for the name.
### 2.4 Guard (`guard.mjs`)
After the existing span check at `:91-97`, the guard looks for a hybrid among the cited universes. If
there is one, and some cite lies in one of its sources while `excluded(mapping, hybrid, c.path)` is true,
the guard refuses `citation-cross-universe` ("citation N (path) is excluded from hybrid H"). A reply that
cites only source paths, excluded ones included, has no hybrid among its universes, so it's unchanged.
### 2.5 Setup sheet
`docs/case2/SETUP-SHEET.md` gets one section after "Supporting folders", headed "Making a crossover
(hybrid) universe". It covers `kind`, `name`, `sources`, `books`, `playground`, `constraints`
(`exclude` in both forms, `pin`) and `notes`, and says that the continuity role reads and cites the notes.
It also says that only she edits this entry (C2-04 rule M9).
## 3. Tests and mutants
New files and cases, all on scratch repos under TMPDIR (never /tmp):
- **`packages/library/tests/hybrid.test.mjs` (new):**
- A business-file-shaped mapping (fixture `mapping.json` plus a `cross` hybrid) on a repo in her
layout's shape.
- `find` and `search` in `cross` return its own files plus the sources' canon, each carrying its source
universe. They never return a source playground or an excluded path, and this runs once for each
exclude form.
- `get` keeps `links_out` into the sources and drops excluded targets.
- A source universe's lookups never return a `cross` file.
- `universeInfo` returns the sources, constraints and notes.
- `excluded()` gets unit cases for both forms and for a non-hybrid.
- **`mapping.test.mjs`:** one case per new refusal: both forms fit, neither fits, the hybrid's own book,
an exclude under a source playground, and a pin under a source playground. `second/Characters` at
`:203` still checks.
- **`reader.test.mjs`:**
- R8 cases: entity missing, entity changed, a same-name hybrid version with no link, and a list value.
- Non-R8 cases: a hybrid place file with `age: 300` that links the entity, and a different hybrid
character that links it.
- The excluded-link R6, once for each exclude form.
- **`guard.test.mjs`:** a hybrid cite with source canon passes, and a hybrid cite beside an excluded path
refuses in both forms. A cite of only an excluded source path passes as that universe's item.
- **`packages/bus/tests/citation-guard.test.mjs`:** through `citationWorld` with `mapChange` adding the
hybrid, the excluded-beside-hybrid send refuses and the `messages` count stays the same.
- **`packages/bus/tests/library-lookups.test.mjs`:** one broker case, where `library.find` and
`library.universe` in the hybrid return what the library tests above assert.
- **C2-04 M9 rule (agents can't write `projects`), not a mutant:** a case in `packages/business/tests/library-projects-protected.test.mjs` where an organization
write that adds a new hybrid universe refuses `organization-projects-protected`. This is outside the
brief's "tests in library and bus" list (§4 Q1).
Mutants. Each runs only its own named tests, and the tree is restored after each one.
| # | Mutation | Killed by |
|---|---|---|
| X1 | `excluded()` ignores the type form | hybrid.test lookups, type form |
| X2 | `excluded()` ignores the path form | hybrid.test lookups, path form |
| X3 | `sees()` keeps its old `inside()` reading | hybrid.test type form |
| X4 | the validator accepts "both forms fit" | mapping.test |
| X5 | the validator skips the playground check | mapping.test |
| X6 | R8 compares with plain `String()` (list equals scalar) | reader.test list value |
| X7 | a version is matched by link instead of name | reader.test non-R8 cases |
| X8 | the reader drops the excluded-link R6 | reader.test |
| X9 | the guard skips the excluded check | guard.test "C2-15 a hybrid cite beside an excluded source path refuses citation-cross-universe, both forms" (the mutant run); bus citation-guard "C2-15 excluded cite beside a hybrid refuses and inserts nothing" also fails |
## 4. Open questions for Sage and Filbert
- **Q1 (manifest):** the C2-04 M9 test lives in `packages/business/tests`, which the brief's file list leaves
out. I recommend adding it there, beside the existing C2-04 M9 cases, and putting the business suite on the
gate. The alternative is a bus-level `organization.set` test, which needs more setup and tests the
same writer.
- **Q2 (supporting):** the brief refuses a path under a source's playground. A source's supporting
prefix is never read by the hybrid either (`sees()` passes canon only). I recommend refusing that too,
under the same REQ-LIB-3 reason. That widens the brief by one rule, so I need a yes.
- **Q3 (R8 reaches other readers):** adding `RULES.R8` goes beyond the reader:
- `survey.mjs:70` counts problems per `RULES` key, so the survey line gains `R8 0`.
`survey.test.mjs:75` pins that line and has to change. It's in the library tests, so it's in scope.
- `mirror.mjs` `validProblem` accepts R8 through `RULES`, so a sync state written after this row
carries R8 problems.
- A copy running older code would refuse such a state. That only matters if a re-pin goes backwards.
- I'll grep `docs/case2/SURVEY-RUN.md` and the Console for R-code lists before in-review.
- **Q4 (version scope):** I count only the hybrid's canon files as versions. Playground is "what if" and
is exempt, as it is from R5 and R6. A hybrid with `books: []` therefore never gets a version R8. It
still gets the entity R8.
- **Q5 (`pin.entity` not `.md`):** a non-`.md` entity is never indexed, so R8 would report it missing at
every commit. I recommend refusing it in the validator. The brief leaves existence to the reader, but
this is a shape check, not an existence check.
## 5. Gate
Filbert approves the manifest. The library and bus suites, plus business if Q1 stands, run green on
Sage's gate along with every `scripts/test-*.sh`. Hash pins on the changed files are grepped before
in-review.
@@ -1,22 +0,0 @@
# Row 267: one existing assertion changes (mos-1272)
File: `packages/library/tests/survey.test.mjs:75`, in the test that checks the survey's last three lines
with `--mapping`.
Before:
```
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
```
After:
```
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0, R8 0']);
```
Why: R8 ("pinned value changed") is a new rule in `problems.mjs` `RULES`, and the survey line lists a
count for every `RULES` key (`survey.mjs:70`), so it gains `R8 0`. The fixture has no hybrid and no pin,
so the count is 0 and no other count moves.
No other existing assertion in the row changes.
+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);