Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b23783f395 | ||
|
|
e18ad27115 | ||
|
|
12f8c3089c | ||
|
|
253346e3c3 | ||
|
|
7328da8ca4 | ||
|
|
84002f960a | ||
|
|
f89aaf57fe | ||
|
|
d3a49f301a | ||
|
|
562b05b43c | ||
|
|
8c28ee7315 | ||
|
|
bce4917154 | ||
|
|
01b57f48bb | ||
|
|
54ec0005e0 | ||
|
|
61e262b7c0 | ||
|
|
540b6f16ba | ||
|
|
e6a675ee83 | ||
|
|
e77635cebb | ||
|
|
3fc8ccef1a | ||
|
|
27d3266fff | ||
|
|
6bd67225b5 | ||
|
|
14715d644a | ||
|
|
f9a9c9b5a0 | ||
|
|
34388223d8 | ||
|
|
3adb391b57 | ||
|
|
4da907cd5e | ||
|
|
aa8fc490f5 | ||
|
|
a9884dc75c | ||
|
|
25c2c1a560 | ||
|
|
4f827a5e81 | ||
|
|
7dc8ff1bfa | ||
|
|
61df3061ed | ||
|
|
b212182ed1 | ||
|
|
dac64ff311 | ||
|
|
b329ad69f5 | ||
|
|
8d4d4ec598 | ||
|
|
5ddc36ca97 | ||
|
|
f510d73676 | ||
|
|
767bb62c7d | ||
|
|
6345aae3d8 | ||
|
|
ee5f7b9ef8 | ||
|
|
0b923a2759 | ||
|
|
b0a6838ca6 | ||
|
|
0626464874 | ||
|
|
66299eeef7 | ||
|
|
569bb6b177 | ||
|
|
fb404c909d | ||
|
|
4c0a2c4fd4 | ||
|
|
2f06aad3f0 | ||
|
|
6863d4c852 | ||
|
|
9ef5798acb | ||
|
|
4897520c31 | ||
|
|
4ca2703079 | ||
|
|
25c09f19fe | ||
|
|
a6da8718c4 | ||
|
|
ee26cfbada | ||
|
|
a4da4e6df4 | ||
|
|
7309260aeb | ||
|
|
7c543fd4a1 | ||
|
|
284d55cf2c | ||
|
|
93a491d732 | ||
|
|
c7b754d7b1 | ||
|
|
a94c95522f | ||
|
|
c4f77373f9 | ||
|
|
c7e77202d6 | ||
|
|
a0094633f6 | ||
|
|
721268b084 | ||
|
|
f0e3dcdf41 |
No files matched your search
@@ -5344,3 +5344,39 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
|
||||
2026-10-22T18:11:57Z | Sage | Row 275 (#1780, Morgana, Duckworth) LANDED 3410df7c3. Before: refactor at 48e450ce8; viewers had no verb list of their own and board reads were not refused to users. After: a fast-forward of 3 commits; manifest 39847b15 (4 paths) blobs match; gate 17:52:12Z-18:05:00Z green (whole files 7/22/6, bus 834, cli 861, harness 151, business 231, seat 167, 10 scripts with 3 named skips), M6 and M10 killed; Duckworth APPROVE rev 3260 (comment 30717); note and done by sage-2897; #1780 comment 30728 and closed. N1 (board refusal test as a loop over BOARD_VERBS) is non-blocking.
|
||||
|
||||
2026-10-22 18:11:58 UTC | Gander row276 #1781 prepared, gate pending | Before: row253 left5legacy Case1 AS_USER calls unsupervised across sudo pty sessions. After: candidate uses its unchanged supervisor with per-call inner/outer budgets, separate streams, strict display-only failure trailer, fake-only paired process-model cases and15unique mutants. Duckworth0182 B1 corrected the plan before product code;0183 cleared build. Parse-only Bash checks pass; no Node, behavioral test, host, unit or config action. Runtime gate and publication await Sage.
|
||||
|
||||
2026-10-22T22:51:32Z | Megavolt | Row 207 (193c, #1705): rebased onto 01983cd3f as 712f79c0a; gate green under sage-3041 (access.test 31/31, M1-M8 killed on named tests, cli 894/894, ten scripts under mos-1275 limits). Correction: the first run's kill check grepped TAP "not ok" against node's spec output and was stopped and rerun. Runs note in 9bb406c6c.
|
||||
|
||||
2026-10-22T22:51:32Z | Sage | Row 207 (#1705, Megavolt, Duckworth) LANDED c4f77373f. Before: refactor at f0e3dcdf4; mosaic access had no user verbs. After: fast-forward of 4 commits, a cherry-pick of the gated 01983cd3f..9bb406c6c (range-diff 4/4 equal, patch-ids equal, the 4 manifest blobs equal; the base moved only by the #1797 brief, which no test reads; no open branch touches the manifest). Duckworth APPROVE c30849 rev 3356; done rev after the note. N1 (any-business refusal untested) DEFERRED. No live change. Correction: my first 207 review request ran under timeout 120, which killed it while it wrote the outcome; the comment (c30841) had posted, so I removed the dead lock and resolved it at rev 3350.
|
||||
|
||||
2026-10-22T23:03:39Z | Gyro Q195 O2 rewrite, #1752 / Vikunja #448, mos-1356 | Before: landed O3 design 95e3f7166/blob 4cb59999. After: Q231/Q233 full WordCraft O2 design on gyro/editor-o2 from refactor c4f77373f; canonical git DOCX, original Markdown/wiki preservation, human cadence default 0 with instant accepted-save commits, full rich-feature first slice. Ten briefs include independent source/dependency security clearance before any build queue entry, exact editor-only CSP approval gate, restricted source/build/bridge, separate reviewed DOCX citation gate and flags-only agent access. Existing Case 2 semantics unchanged; any integration requirement change goes through Mos to Jason. Static checks: ten briefs/seven fields, independent reviewers, unique anchors, current source/base/blob pins, docs scope and append-only logs pass. No builds, browser/worker runs, code, queue or live changes. Fenton review pending; CSP and implementation gates remain closed.
|
||||
|
||||
2026-10-22T23:03:53Z | Gyro Q195 O2 push guard | CI queue guard identity gyro / purpose push returned exit 0, documented degraded-pass: CANNOT_ASSERT credential-unresolvable. Audit: /mnt/storage/scratch/gyro-q195-ci-queue-audit.jsonl. No credential fallback or CI-green claim.
|
||||
|
||||
2026-10-22T23:09:38Z | Gyro Q195 O2 fenton-0006 fixes | Before: e012bab0b left stale-source ambiguity, incomplete migration/shipping ownership, undefined typing transactions and an unmeasured CSP gate. After: D12 explicitly routes superseded-source/C2 reply notices to Mos/Jason and refuses adoption before W4b/notice path; Q2 remains open. F2-F6 add schema-edge placeholders and restore-accounting files, shipped runtime data/writing closure with no sandbox store mount, per-chapter DOCX and 2s-pause/15s-span save groups, volume/latency/growth gates, split-run clickable wikilinks and historical Obsidian graph warning, and S0 isolated existing-artifact CSP measurement before exact-header approval. Static ten-brief/ownership/settings/checklist checks pass. No build/browser run, product/queue/live change. Independent re-check pending.
|
||||
|
||||
2026-10-22T23:09:50Z | Gyro fenton-0006 push guard | identity gyro, purpose push, exit 0 documented degraded-pass CANNOT_ASSERT credential-unresolvable; audit gyro-q195-ci-queue-audit.jsonl. No fallback credentials or CI-green claim.
|
||||
|
||||
2026-10-22T23:10:14Z | Gyro mos-1358 ruling recorded | Q36 / Vikunja 562 keeps C2 superseded notices OPEN with Jason. Markdown adoption refusal until W4b PASS is now a separate settled Mos build-order ruling, not a Jason question. D12 and Q2 distinguish them; no notice build authorized.
|
||||
|
||||
2026-10-22T23:13:27Z | Gyro Q195 O2 fenton-0007 F7/F8 | Named human-only editor.saveGroupPauseMs=2000 and editor.saveGroupMaxMs=15000 with integer/range/cross-field validation and W3a UI/CLI refusal tests. W3b owns production adoption refusal until W4b; isolated fixture conversion remains testable without request-flag bypass. W4b owns adoption module/API tests and reviewed lifting only with committed-reader/citation readiness and, if Q36 approves, the separate notice row PASS. Q36 unresolved stays closed. Static ten-brief, setting-key/bounds, refusal/lift ownership and two-question checks pass. Docs only; no build, browser/worker run, product/queue/live change. Fenton checks F7/F8 on next push.
|
||||
|
||||
2026-10-22T23:13:48Z | Gyro mos-1359 / r3 push guard | D13 records save-group timing as Mos's accepted reading of Q226 F41, not Jason's ruling; both defaults remain human-only settings. Push guard identity gyro/purpose push: exit 0 documented degraded-pass, CANNOT_ASSERT credential-unresolvable; audit gyro-q195-ci-queue-audit.jsonl, no credential fallback or CI-green claim.
|
||||
|
||||
2026-10-22T23:23:42Z | Rocko, W4 #1799 | Before, D24-D26 had no separate exact DOCX citation/access contract. After, 15e91538f proposed a versioned schema, parser/story/view/refusal rules, live authorization/sharing and generic/git/session bypass closure, compatibility gates and 22 acceptance rows. Two JSON examples parse, diff check clean; no code or implementation acceptance. Megavolt review pending.
|
||||
|
||||
2026-10-22T23:55:11Z | Sage, W4 #1799 docs landing | Megavolt reviewed four rounds (CHANGES 15e91538f, 019a0ea5b, 54246cb32; APPROVE 994c3f7fb, #1799 c30868/c30870/c30876/c30884). Landed here as cherry-picks: docs/plans/2026-10-22_q195-docx-citations.md blob 2af460ab7 and docs/reviews/2026-10-22_q195-docx-citations.md blob bedde1716, both new files. 32 acceptance rows go to W4b; R4-N1 (re-migration after a pre-W4b restore keeps the rotated fence and needs owner confirmation) goes into W4b's brief and A32. W4 is design-approved only: its gate still needs Sage's W4b implementation brief, which waits for S0 (#1798) PASS. Q36 open, adoption closed. Docs only, no test reads these files.
|
||||
|
||||
2026-10-23T00:52:58Z | Gyro Q235-Q237 policy rewrite started, Vikunja 571, mos-1366/1367 | Before: approved O2 e36bce352 keeps Markdown originals at their paths and Q36 notices open; landed W4 2af460ab7 has the same boundary. Imported that byte-identical W4 baseline as 1ac76a417, from refactor 062646487, to make the policy-only revision diff explicit. Prepared P1-P6/B1-B4: human one-way per-story import, human-set source-repo archive, DOCX current reader, scoped format map/archived non-canon reads, W7 conversion publication after W4b/approved PRD/10-27 and Jason first-real-book go. Q234 exact CSP, mos-1363 and S0 M1-M14 retained. W4 locator/extractor, shares and restore-fence blocks compare byte-identical to the approved baseline. Scope/source review ongoing; Mos impact map must be folded before Fenton handoff. No build/browser/worker run, code, queue, private-book or live action.
|
||||
|
||||
2026-10-23T01:37:07Z | Gyro Vikunja571 final impact-map / mos-1372/1375 fold | Prepared parent/W4 rewrite against brain-redux d183246 impact map at stack 062646487. P1-P6/B1-B4 and engineering Q1-Q16 are mapped to eleven briefs. D9 replaces the earlier private O2-only context/per-document/share model: normal Case 2 access in any authorized thread/room, supporting writes intact, changed policy awaits PRD 1.17 approval. New DOCX story creation has the same opening gate as human per-story import. Host-only push to human-set off-host remote replaces the no-remote ban; remoteURL defaults unset and same-host bare is disallowed. W7 gates on Hermes #423 step5, W4b, PRD approval, post-10-27 and first-real-book go; owns conversion events COUNTED/--events/pre-conversion restore reconciliation, manifest order/next_chapter, creative role cite wording, archive/old-path edits and format/link/citation routing. Store+off-host acknowledgment precedes archive commit/push, then catalogue publication; post-push failures remain resumable pending with pinned source. Current checks pass: complete brief fields, policy/map coverage, exact Q234 CSP, locator/projection mechanics byte-equivalent except explicit authority wording, A01-A35 IDs, immutable map pin, four-doc scope and append-only logs. W4 authority/remote/recovery sections reopened for Fenton; older APPROVE not reused. No product/build/browser/worker/private-book/queue/live action. Earlier entry describing preserved shares/fence applies only to the pre-D9 intermediate draft; D9 now withdraws those unimplemented structures.
|
||||
|
||||
2026-10-23T01:37:55Z | Gyro 571 push guard | identity gyro/purpose push: exit 0 documented degraded-pass CANNOT_ASSERT credential-unresolvable, scratch audit gyro-q195-ci-queue-audit.jsonl; no credential fallback or CI-green claim.
|
||||
|
||||
2026-10-23T01:41:59Z | Gyro sage-3121 A1-A3 PRD alignment | Before: 8d209016b adds a current Connections graph, excludes new Markdown stories in converted books and defaults archiveFolder. After: parent/W4 align with PRD1.17 d6 sha256 886b93c571f87bfbb2f27ee3113b6800a32d8f89196d95ace33566031bee380a: clickable editor links only; graph/tests later pending J-b; new different-path Markdown in a converted book remains canon until import, only recreated converted old paths are unmapped; archive folder is human-set in library mapping with no default, missing folder refuses conversion. Static A1-A3/11-brief checks pass; W4 locator/extractor/access/remote/recovery blocks unchanged. Local commit only, push held for Sage's named gap while Gizmoduck holds 270+271 run5. No product/build/browser/worker/queue/live action. Fenton gets local SHA through shared object store.
|
||||
|
||||
2026-10-23T01:49:08Z | Gyro fenton-0012 B1/B2 and N1-N6, mos-1379 | B1: off-host remote must deny the real shared jason.woltje seat-helper credential as well as seat identities until #431; private visibility/dummy-token denial is insufficient; W3b/A16/A26 require that future fixture proof. B2 follows mos-1379: human-only editor.agentReadComments and editor.agentReadTrackedDeletions both default false, comment/original/markup reads/resolves/index/context refuse when off; final access remains D9 and no role allowlist/per-document grant. N1 staging-ref allowlist and retained private refused/published audit refs; N2 URLs literal with fidelity-report loss and confirmation; N3 future paths marked new or predecessor row; N4 rename/sharing wording fixed; N5 historical thread text not refreshed; N6 protected VM1012 backup cited in P5. Static checks pass, locator JSON/package/projection mechanics retained. Local commit only; HTTPS push still held for Sage's named gap. No credentials tested/read/printed, no product/build/browser/worker/queue/live action. Fenton gets exact local SHA/blobs.
|
||||
|
||||
2026-10-23T01:53:25Z | Gyro fenton-0013 N7 mixed-format chapter order | Fenton approved local 0f4fa4f93. D10 now explicitly orders every chapter ID regardless of Markdown/DOCX format, inserts new canon Markdown by row216 or human committed order, and preserves ID/position at import. W7 tests cover mixed traversal/insertion/import stability. Static diff/append-only checks and docs hook pass. Local commit only, narrow re-check requested, HTTPS push still held for Sage gap. No runtime, queue or live change.
|
||||
|
||||
2026-10-23T02:21:06Z | Sage, rows 270+271 landing, 571 landing, MVP rows | Before: 270 (#1775) and 271 (#1776) were approved on gizmoduck/row270-271-r4 49cee846e and 2b964a082 after combined gate C2 run 5 GREEN on 53406f3e4 (bus 873, seat 202, cli 905, ten scripts, 38/38 mutants). After: 33 commits cherry-picked onto 767bb62c7 as 7328da8ca, range-diff all "=", 40 paths identical to the candidate; 270 manifest f4174458 matches, 271 manifest 3ab1f5c8 matches on 29 paths, the other 4 moved under 270 and match its manifest. Done revs 3396/3398, #1775 c30962 and #1776 c30964 closed, P19 463 c861. 571 (Gyro, Fenton APPROVE) picked as ee5f7b9ef, 6345aae3d, 767bb62c7 (blobs 9382f85b, 7ec0897b), 1ac76a417 left out; N7 911dad2aa (Fenton-0014) picked after. Rows 293-299 added (574, FED R6, R4, R7, GitHub grant, Q228, 573), queue commit 253346e3c. Correction: the push sync step checked out O..S paths over the canonical worktree without testing for uncommitted edits, so any pending SESSIONS or BUILD-LOG line in the canonical checkout at 02:06Z would have been overwritten; the sync now skips edited paths. Schema 3n is not live (LIVE 0cbe2e9b3 at 3m).
|
||||
@@ -0,0 +1,669 @@
|
||||
# Row 270 build notes (Gizmoduck)
|
||||
|
||||
264c, T1 item 7: the compaction count, exact for Pi. Gitea #1775. Brief:
|
||||
docs/plans/2026-10-21_t1-sessions.md section 4 (D6), with RULINGS Q6 (Pi exact, Claude `unknown` with
|
||||
the Vikunja 466 reason). Reviewer Duckworth, gate owner Sage. Branch gizmoduck/row270 from
|
||||
gizmoduck/row269 2d0d407ba (sage-2797, Q220). Row 269 lands first, and this branch rebases with it.
|
||||
|
||||
## Contract for 264e (pinned for Huey, row 272)
|
||||
|
||||
The surface reads the count from `session.list`. Each row gains one key, `compactions`:
|
||||
|
||||
```json
|
||||
{ "id": "...", "at": "...", "name": "...", "predecessor": null, "default": true,
|
||||
"compactions": { "count": 3, "known": true, "reason": null, "at": "2026-10-22T16:40:00.000Z" } }
|
||||
```
|
||||
|
||||
- `known: true`: `count` is the exact number of completed compactions over the session's lifetime,
|
||||
an integer of 0 or more. `reason` is null.
|
||||
- `known: false`: `count` is a lower bound (an integer) or null when there is no bound. Show
|
||||
`unknown` with the reason, never a bare 0. `reason` is one of:
|
||||
- `harness-unsupported`: Claude Code, until Vikunja 466 pins its format.
|
||||
- `not-collected`: no collection has run for this session yet.
|
||||
- `store-unreadable`: a store file couldn't be read, or isn't a Pi session.
|
||||
- `store-incomplete`: a stream file got shorter, was replaced, or has unreadable lines, so
|
||||
entries may be missing.
|
||||
- `at`: ISO time of the last collection, or null if none has run.
|
||||
- A clear successor (264d) starts at `known: true, count: 0` once its first collection runs. The
|
||||
retired session keeps its row and its count.
|
||||
- On a base without 264c, the key is absent. The surface shows the count as unavailable, which is
|
||||
what Huey's plan does (Sage Q1).
|
||||
|
||||
No new verb and no new input key. Access is `session.list`'s: the thread owner, or the tester.
|
||||
|
||||
## Pi source contract (pinned offline at 0.85.1)
|
||||
|
||||
`@earendil-works/pi-coding-agent` 0.85.1 (package.json pin), read in node_modules:
|
||||
|
||||
- One line per completed compaction: `{"type":"compaction","id":...,"parentId":...,"timestamp":...,
|
||||
"summary":...,"firstKeptEntryId":...,"tokensBefore":...}` from `appendCompaction`
|
||||
(dist/core/session-manager.js:818-833). The two callers write it after the summary exists
|
||||
(dist/core/agent-session.js:1539, :1826). A start or an aborted compaction writes no entry.
|
||||
- What doesn't count: a `message` entry with role `compactionSummary` (a rendered summary,
|
||||
docs/session-format.md:154), any message inside a compaction's `retainedTail`, and
|
||||
`branch_summary` entries.
|
||||
- Identity: a compaction is its (entry `id`, entry `timestamp`) across the whole store, not per
|
||||
stream. Pi's ids are unique only within a file (`generateId(this.byId)`, session-manager.js:21-29),
|
||||
and `createBranchedSession` (:1093-1140) and `forkFrom` (:1264-1300) copy entries into a new
|
||||
`*.jsonl` in the same directory with their id and timestamp unchanged. A (stream, id) key would
|
||||
count a compaction made before a branch or fork once per copy. Reported to Sage in
|
||||
gizmoduck-0262; accepted in sage-2814. The ledger line records the stream it was first seen in.
|
||||
- Residual (sage-2814): two distinct compactions that share an id and a timestamp count once. That
|
||||
needs an 8-hex collision between two files at the same millisecond. It is the one undercount
|
||||
this key allows, and it never overcounts.
|
||||
- Partial trailing line: wait. Only lines that end with a newline are read, as
|
||||
packages/conversation/src/pi.mjs:35-38 does.
|
||||
|
||||
## Storage: A, a host ledger (sage-2806)
|
||||
|
||||
Sage ruled A over B. A keeps the count in `<store dir>/compactions.jsonl`, a file beside the
|
||||
mounted `session/` and `claude-projects/`, in the store directory no bind mounts
|
||||
(sandbox.mjs:122-127). B would have been a bus table. The tradeoff: A needs no schema step, so 271's
|
||||
clear journal stays the only new schema step in the 264 rows, and the count lives with the native
|
||||
store it is collected from. The cost is that the bus reads a file on each `session.list`, through a
|
||||
reader runtime.mjs injects, because broker.mjs is a RUNNER_FILES file and can't import seat code.
|
||||
|
||||
### Ledger
|
||||
|
||||
`packages/seat/src/compactions.mjs`. One JSON object per line, `v: 1`:
|
||||
|
||||
- `{kind: "compaction", entry, completedAt, stream, at}`: once per (entry, completedAt) key.
|
||||
- `{kind: "cursor", stream, bytes, tail, at}`: the stream was read through `bytes`. `tail` is the
|
||||
sha256 of the up to 4096 bytes ending there. Written only when it moved.
|
||||
- `{kind: "collected", at, known, reason, harness, version}`: one per collection.
|
||||
|
||||
Conditions (sage-2806):
|
||||
|
||||
1. Append-only, the launcher its only writer, a key never written twice, nothing rewrites or
|
||||
truncates. The key is the one above, not (stream, id).
|
||||
2. Mode 0600, opened `O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW`. **Lock used: the session's workspace
|
||||
lock** (`lockSessionWorkspace`, taken in launcher.mjs `start()` and released in the run's exit
|
||||
`finally` or on a start failure). Collection runs at run start, after `check()` gives the
|
||||
harness and before the spawn, and at run end, before the lock is released. Each collection is
|
||||
one `writeSync` loop on one O_APPEND descriptor. A collection error is logged and recorded as a
|
||||
`compactions` launch-log event and never stops or fails the run.
|
||||
3. `compactionCount(dir)`:
|
||||
|
||||
| Ledger | Answer |
|
||||
| --- | --- |
|
||||
| missing | `not-collected`, count null |
|
||||
| unreadable, not a regular file, a symlink | `store-unreadable`, count null |
|
||||
| any bad line, a torn last line included | `store-incomplete`, count a lower bound or null |
|
||||
| any collection under a harness other than pi | `harness-unsupported`, lower bound or null |
|
||||
| any collection that found a stream shrunk, changed under its cursor or gone, or a bad stream line | `store-incomplete`, for good |
|
||||
| the last collection hit an unreadable stream or a non-session first line | `store-unreadable` until a clean collection |
|
||||
| otherwise | `known: true`, the exact count |
|
||||
|
||||
Never count 0 when unknown. After a torn line, the next write starts on a fresh line, so the torn
|
||||
line stays one bad line and the new lines stay whole.
|
||||
4. `packages/seat/tests/compactions.test.mjs` "the ledger is under no sandbox mount": for a legacy
|
||||
and a non-legacy store, no `binds()` source contains the ledger path.
|
||||
5. This section.
|
||||
|
||||
### Wiring
|
||||
|
||||
- launcher.mjs: `compactions(store, model.harness, ...)` at start (after `newRun`) and in the exit
|
||||
`finally` before `lock?.release()`. Thread sessions only.
|
||||
- broker.mjs: a `compactions` constructor option, TypeError-checked like `access`. `session.list`
|
||||
selects `op` to tell a legacy session (`legacy-3l`) and adds `compactions` to each row; `op` is
|
||||
not returned. No reader gives `not-collected`; a reader that throws gives `store-unreadable`.
|
||||
No import added.
|
||||
- runtime.mjs: passes `({business, thread, session, legacy}) => compactionCount(storeDir(...))`.
|
||||
- packages/bus/README.md: the `session.list` row.
|
||||
|
||||
Huey's 272 broker hunk (reply citation predicate, near broker.mjs:5167 on huey/row272) doesn't
|
||||
touch `session.list`.
|
||||
|
||||
## Tests
|
||||
|
||||
New files only, so no pre-existing test changes:
|
||||
|
||||
- packages/seat/tests/compactions.test.mjs: zero, a failed start and non-compaction entries; one and
|
||||
three; a compaction copied into a branched stream counts once (sage-2814); one copy in two
|
||||
streams in one collection; the same id at another time counts twice; resume with cursors and
|
||||
keys written once; ledger mode 0600; a partial trailing stream line waits; a missing, a directory
|
||||
and a symlinked ledger; a torn and a bad ledger line; shrink, rewrite and remove sticky; a
|
||||
symlinked stream and a non-header first line; Claude harness-unsupported and sticky; `storeDir`
|
||||
against `sessionStore`; the mount test.
|
||||
- packages/bus/tests/session-compactions.test.mjs: the key per row with the store identity
|
||||
asked for, legacy true for `legacy-3l`, no reader and a throwing reader, the TypeError.
|
||||
|
||||
Run limits (mos-1275): each file whole with `timeout -k 10 120`, TMPDIR from
|
||||
`mktemp -d /mnt/storage/scratch/tmp/gXXX`. Then the touched files' suites: seat session.test,
|
||||
sandbox.test; cli thread-launch, session-launch; bus sessions, project-runtime, tester-writes.
|
||||
|
||||
## Mutants
|
||||
|
||||
Each must turn a new test red:
|
||||
|
||||
- M1 key per stream at write: `keyOf(f.entry, f.completedAt)` → `keyOf(f.stream + f.entry, ...)` in
|
||||
`collectCompactions`. The reader still dedupes, so the count holds; the kill is the
|
||||
branched-copy test's ledger lines (`[["c1","a.jsonl"],["c2","b.jsonl"]]`) and the two-stream
|
||||
test's one line.
|
||||
- M2 key without the timestamp: `keyOf(entry, null)` everywhere. Kill: same-id-another-time test.
|
||||
- M3 count `compactionSummary` messages: count `e.message?.role === "compactionSummary"` too. Kill:
|
||||
the zero test.
|
||||
- M4 ignore the cursor tail: `digest(tail) === c.tail` → `true`. Kill: rewrite subtest.
|
||||
- M5 torn line skipped: `bad = torn = true` → `torn = true`. Kill: torn-line test.
|
||||
- M6 no newline after a torn line: drop the `ledger.torn ? "\n"` prefix. Kill: torn-line test's bad
|
||||
lines.
|
||||
- M7 incomplete not sticky: drop `if (ledger.incomplete)`. Kill: the "later clean collection"
|
||||
assertion.
|
||||
- M8 harness not sticky: drop `if (ledger.unsupported)`. Kill: Claude then Pi test.
|
||||
- M9 0 when unknown: `count: count || null` → `count`. Kill: torn-line test's null.
|
||||
- M10 removed stream ignored: drop the missing-cursor loop. Kill: remove subtest.
|
||||
- M11 broker legacy flag: `op === 'legacy-3l'` → `false`. Kill: bus legacy test.
|
||||
- M12 broker default 0: no reader → `{count: 0, known: true, ...}`. Kill: bus no-reader test.
|
||||
|
||||
## Round 1 for review (sage-3024)
|
||||
|
||||
270's code reached the 271 branch in 09b20784f and 48c34539c and has no verdict and no mutant run.
|
||||
This round submits it on its own: the manifest is the paths 270's commits change, hashed at
|
||||
7d199f033 (the 271 r3 tip). queue-271/BUILD.md, which 8365e4e5b also touched, stays with 271.
|
||||
broker.mjs, runtime.mjs and launcher.mjs carry 271's changes too, so their blobs match the 271 round.
|
||||
|
||||
Mutants: `mutants.py` beside this file, anchors at 7d199f033, each matching once (`check`, python
|
||||
only). Each kill below is read from the test, not run yet.
|
||||
|
||||
| Mutant | Edit | Kill: first failing assertion |
|
||||
| --- | --- | --- |
|
||||
| M1 | collect key `keyOf(f.stream + f.entry, ...)` | seat compactions.test.mjs:64, ledger lines gain `[c1,b.jsonl]` (counts hold, readLedger dedupes) |
|
||||
| M2 | `keyOf` drops the timestamp | :81, count 1 not 2 |
|
||||
| M3 | count `compactionSummary` messages | :40, m2 counts 1 |
|
||||
| M4 | tail digest check `true` | :170 in `rewrite`, known true |
|
||||
| M5 | `bad = torn = true` to `torn = true` | :131, the torn line is popped unparsed, count reads known 0 |
|
||||
| M6 | no newline after a torn line | :133, c1 joins the torn line, count null not 1 |
|
||||
| M7 | drop `if (ledger.incomplete)` | :174 in `shrink` (`remove` stays incomplete by its missing cursor) |
|
||||
| M8 | drop `if (ledger.unsupported)` | :201, Pi run reads known |
|
||||
| M9 | `count \|\| null` to `count` | :131, count 0 not null |
|
||||
| M10 | drop the missing-cursor loop | :170 in `remove` |
|
||||
| M11 | broker `op === 'legacy-3l'` to `false` | bus session-compactions.test.mjs:76 |
|
||||
| M12 | broker no reader gives `{count: 0, known: true}` | bus session-compactions.test.mjs:81 |
|
||||
|
||||
Run plan, on a CLEAR only: each mutant runs its test file whole under `timeout -k 10 120` (M1-M10
|
||||
seat compactions.test.mjs, M11-M12 bus session-compactions.test.mjs), TMPDIR from
|
||||
`mktemp -d /mnt/storage/scratch/tmp/gXXX`, restore with `git checkout` and a clean-tree check after
|
||||
each. A kill is an AssertionError at the named line. M11 and M12 edit broker.mjs, which 271 also
|
||||
changed, so after their restore bus session-clear.test.mjs and sessions.test.mjs run whole, each
|
||||
under `timeout -k 10 300`. compactions.mjs has no 271 change.
|
||||
|
||||
Not covered: launcher.mjs's two `compactions(store, ...)` calls (run start, run end) and runtime.mjs's
|
||||
reader closure have no test in either file, and no test elsewhere names compactions. A mutant
|
||||
dropping either launcher call or making the closure answer a constant would survive. This is the
|
||||
T3 Pi half as built; the Tests section above never claimed the wiring. Adding a cli and a runtime
|
||||
test is a scope call for Sage.
|
||||
|
||||
Prior coverage: none. 271's M1-M14 edit no 270 line, and no past verdict names 270.
|
||||
|
||||
## Round 2 (duckworth-0223 B1-B3, sage-3030, sage-3031, mos-1352)
|
||||
|
||||
New commits on 8a5eb3c8b (round 1, on 7d199f033). Nothing here has run: no test, no mutant, no node.
|
||||
Each kill below is read from the test.
|
||||
|
||||
### B1: the read budget, a human-only setting
|
||||
|
||||
`scanPi` reads a stream a CHUNK (1 MiB) at a time from its cursor, carries a line split across
|
||||
reads, and stops when the collection's budget runs out, over all streams together. The whole-range
|
||||
`Buffer.alloc(to - from)` that compactions.mjs:57 and :101 had is gone from the stream read;
|
||||
`readRange` now serves only the tail digest reads, at most 4096 bytes. A collection the budget stops
|
||||
short records `known: false`, reason `not-collected`, and cursors at the last whole line it read. The
|
||||
reason isn't sticky: `compactionCount` takes `last.reason`, so a later collection that reads to the
|
||||
end reads known again. When a collection has more than one problem the order is store-incomplete,
|
||||
store-unreadable, then not-collected.
|
||||
|
||||
The setting (mos-1352, DECISIONS Q222). It's `compactionReadBytes`, a top-level key of the instance's
|
||||
system config, the file `MOSAIC_CONFIG` names (`~/.config/mosaic-NAME/config.json`). Under invariant 2
|
||||
a human writes that file and nothing auto-writes it. The launcher reads it once, at host start,
|
||||
through `loadSystem`; nothing reads it from the session mount, the workspace or an environment
|
||||
variable an agent's process sets. `readBudget` (compactions.mjs) takes a whole number of bytes from
|
||||
1 MiB to 1 GiB and gives the default for anything else, missing included. The launcher logs a bad
|
||||
value once at start, naming the default. A change takes effect at the next host start.
|
||||
|
||||
The schema change, for review. `scripts/mosaic-config.mjs` refused any top-level key outside
|
||||
`configVersion, environment, dataRoot, execution, console` (`unsupported configuration key`). It now
|
||||
also takes `compactionReadBytes`, and passes it on as written, a bad value included, because the
|
||||
fallback is the launcher's and a bad value must not refuse the config. The output carries the key
|
||||
only when the file has it, so a config without it prints what it always did (test-config.sh's
|
||||
"config without console" case still pins that). The old list literal stays as it was and the key is
|
||||
added by `.concat`, because packages/cli/tests/stack-release-fixture.mjs:300 edits that literal to
|
||||
make an older loader.
|
||||
|
||||
A consequence for Duckworth to rule on. A loader from before this round refuses a config that sets
|
||||
the key. scripts/stack-release.sh `config_checks` (:1079-1097) runs the target copy's loader only when
|
||||
the config has a `console` key. So a human who sets `compactionReadBytes` and then rolls back to a
|
||||
copy from before 270 passes that check, and the bus fails at its next config read. Nobody has to set
|
||||
the key, since the default needs none. I've left stack-release.sh alone: it's outside this row's
|
||||
manifest. The bounded follow-up is to make `config_checks` run the target loader whenever the config
|
||||
has any key the target might refuse, not just `console`.
|
||||
|
||||
The default, 64 MiB, from sizes measured 2026-10-22 by stat and line length only (no content printed,
|
||||
nothing under the live stack read):
|
||||
|
||||
| Store | Files | p50 | p90 | p99 | max |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Pi sessions, `~/.pi/agent/sessions` | 224 | 298 KB | 1.1 MB | 4.7 MB | 11.6 MB |
|
||||
| Claude Code transcripts, `~/.claude/projects` (not read by the scanner) | 549 | 607 KB | 6.6 MB | 120 MB | 603 MB |
|
||||
|
||||
The longest Pi line is 0.84 MB, and no Pi line is over 1 MiB. 64 MiB is more than five times the
|
||||
largest Pi stream, so a session's first collection reads it whole, and later ones read only what
|
||||
was appended. Memory per collection is one 1 MiB chunk plus the carry of one unfinished line, and
|
||||
the budget bounds both. The Claude numbers show how far a long agent session can grow. A Pi session
|
||||
that size would take several collections to read through, each one reading unknown until the last.
|
||||
I haven't measured how long a 64 MiB collection takes; the sparse test reads 64 MiB of a hole, and its
|
||||
wall time in the slot run will be the first number.
|
||||
|
||||
Known limits:
|
||||
- A single line longer than the budget is never read past. The count stays not-collected until a
|
||||
human raises the setting. The longest Pi line measured is 0.84 MB.
|
||||
- `readLedger` still reads the ledger whole; B1 named the stream reads. Ledger lines are 120 bytes
|
||||
(collected), 226 (cursor) and 199 (compaction), so a run with two collections, one changed stream
|
||||
and one compaction adds about 0.9 KB. One session reaches 64 MiB of ledger after about 75,000 runs.
|
||||
|
||||
### B3: Claude history from before the ledger
|
||||
|
||||
When a Pi collection finds no ledger and the store's `claude-projects/` (`store.projects`) isn't
|
||||
empty, it skips the scan and writes one collected record: `known: false`, reason
|
||||
`harness-unsupported`, harness `pi`. `readLedger` now sets `unsupported` for a record whose harness
|
||||
isn't `pi` or whose reason is `harness-unsupported`, so the count stays unknown for good, as after a
|
||||
Claude run. A `claude-projects/` that can't be read throws, so nothing is recorded and the next
|
||||
collection checks again. An empty one is no history. Once a ledger exists the check doesn't run: a
|
||||
later Claude run collects for itself.
|
||||
|
||||
A Pi run mounts `claude-projects/` read-write (sandbox.mjs:125). The run-start collection comes before
|
||||
the agent's first turn, though, so an agent can't put a file there before a fresh session's first
|
||||
collection. The exception is a run whose start collection failed. Even then, the agent can only
|
||||
make the count unknown, never wrong.
|
||||
|
||||
The Vikunja 466 reason. For a Claude run, seat compactions.test.mjs:199 asserts `harness-unsupported`,
|
||||
and :201 asserts that it stays after a Pi run. For history from before the ledger,
|
||||
compactions-budget.test.mjs:88 and :89 assert it (sage-3030 item 2).
|
||||
|
||||
### B2: the run wiring, the reader and a clear
|
||||
|
||||
The new tests live in files 271 doesn't touch:
|
||||
- `packages/seat/tests/compactions-budget.test.mjs`, B1 and B3.
|
||||
- `packages/cli/tests/compactions-launch.test.mjs`, with its stub adapter
|
||||
`packages/cli/tests/fixtures/compaction-adapter.mjs`, real runners in bwrap.
|
||||
|
||||
compactions-launch tests:
|
||||
1. A run's start and end collections, across a resume and two host restarts. session.list reads the
|
||||
ledger through the runtime's reader (:166-198).
|
||||
2. A collection that fails, with the ledger's path made a directory. Each failure is logged as
|
||||
`run X: compactions: ...` and recorded as `store-unreadable`. The run answers, and it ends with the
|
||||
same reason, exit code and signal as the next run, whose collections count (:200-224).
|
||||
3. The setting. At 1 MiB, a 2 MiB hole is not-collected at both collections. With `"x"`, the host
|
||||
logs the fallback once, and the default reads the hole through (:226-261).
|
||||
4. A clear. The retired session keeps its count, 1, and the successor reads `{count: 0, known: true}`
|
||||
after its first collection. A legacy session (op legacy-3l, seeded with the SQL from
|
||||
bus session-compactions.test.mjs:72-74) reads the thread store's ledger through the runtime's reader
|
||||
after a restart (:263-308).
|
||||
|
||||
### Mutants M13-M21
|
||||
|
||||
`mutants.py`, with all 21 anchors matching once at this round's tip (`check`, python only).
|
||||
|
||||
| Mutant | Edit | Kill: first failing assertion |
|
||||
| --- | --- | --- |
|
||||
| M13 | drop the run-start `compactions(...)` call (launcher.mjs:501) | cli compactions-launch.test.mjs:182, run 1 logs only `[D,2,true,null]` |
|
||||
| M14 | drop the run-end call (launcher.mjs:642) | :182, run 1 logs only `[D,1,true,null]` |
|
||||
| M15 | the runtime's reader returns `null` (runtime.mjs:268) | :197, compactions null; :286 and :306 fail the same way |
|
||||
| M16 | no budget, `stop = size` | seat compactions-budget.test.mjs:48, the hole is read whole: known true |
|
||||
| M17 | `readBudget` returns `value ?? READ_BYTES` | :37, `"8388608"` comes back as the string |
|
||||
| M18 | the launcher ignores the setting, `budget = READ_BYTES` | cli :248, the 2 MiB hole reads known twice |
|
||||
| M19 | no carry, `take(view.subarray(i, nl))` | seat :79, c1's halves fail to parse: store-incomplete |
|
||||
| M20 | no Claude history check | :88, c1 counts known |
|
||||
| M21 | `harness-unsupported` not sticky on a `pi` record | :89, the second collection reads known |
|
||||
|
||||
The files they touch, sha256 at this round's tip, to check each restore against (sage-3030 item 3):
|
||||
|
||||
```
|
||||
45a3744598cfb8aa1cfcffd8a0ee2dd2bb631061efd192d8461f1901141993ee packages/seat/src/compactions.mjs
|
||||
d7cdc8a17e337e00dfe2d1bf6249cc2c2e696b16c5b426b2fe616089e5559509 packages/bus/src/broker.mjs
|
||||
5d36cc0fa19ab8e478ecf9a807e3afab8181cb650778e80b13efe785e6c44995 packages/cli/src/launcher.mjs
|
||||
5ae423438a178b1c8eac3b8ce0e0c22f5bb7caebb9011ce7098a36d95b53f1ca packages/bus/src/runtime.mjs
|
||||
```
|
||||
|
||||
broker.mjs and runtime.mjs are byte-identical to 7d199f033.
|
||||
|
||||
### Run plan, on a CLEAR only
|
||||
|
||||
Every run uses TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gXXX` and `timeout -k 10`. A first-run
|
||||
red in a new test is placed and reported after the run (mos-1272). A red in an old test, a product
|
||||
fix or a second red goes to Sage and Mos first.
|
||||
|
||||
1. Each mutant runs its kill file whole: M1-M10 seat compactions.test.mjs, M16, M17 and M19-M21 seat
|
||||
compactions-budget.test.mjs (120 s each), M11-M12 bus session-compactions.test.mjs (120 s), and
|
||||
M13-M15 and M18 cli compactions-launch.test.mjs (600 s). After each one: `git checkout` the file,
|
||||
check its sha256 against the table, and confirm the tree is clean. A byte-identical restore needs
|
||||
no other rerun.
|
||||
2. The new test files, whole: seat compactions-budget.test.mjs and cli compactions-launch.test.mjs.
|
||||
3. Every file that reads a path this round touches, whole:
|
||||
- compactions.mjs: seat compactions.test.mjs, bus session-compactions.test.mjs.
|
||||
- mosaic-config.mjs: scripts/test-config.sh, cli stack-instances.test.mjs, cli stack-release.test.mjs
|
||||
(through stack-release-fixture.mjs), webui pinned-copy.test.mjs, and the business suite (it
|
||||
writes configs through helpers.mjs:79).
|
||||
- launcher.mjs: the cli files that import it, all in the cli suite.
|
||||
- README.md: no test reads it.
|
||||
4. The gate (sage-3031) runs on the combined 270+271 tip in one slot after Gander's 276 run5. It
|
||||
covers bus, seat and cli whole, the shell suites, M1-M21 and 271's mutants. compactions.mjs is in
|
||||
271's manifest, so 271's earlier gate evidence is stale. Webui pinned-copy and the business suite
|
||||
join it because the loader changed.
|
||||
|
||||
### Known residuals (duckworth-0223, sage-3031)
|
||||
|
||||
- N1. A session.list read can briefly see a half-written line. The bus reads without the workspace
|
||||
lock, so a read during an append sees a torn last line. That call alone reports store-incomplete,
|
||||
and nothing is persisted. Accepted.
|
||||
- N2 (README). A launcher killed mid-write leaves a torn ledger line, and the count stays unknown
|
||||
(store-incomplete) for good, by design (sage-2806 condition 3). This is now a line in the bus
|
||||
README's `session.list` row.
|
||||
- N3. Pi 0.85.1 rewrites a session file only when it's empty (session-manager.js:625-632), on a
|
||||
version migration (:675-677), or for a new branched file (:1173). None of these moves bytes under a
|
||||
0.85.1 cursor, so the 4096-byte tail check is enough. A non-Pi writer that rewrites earlier bytes and
|
||||
keeps the tail goes unseen. That residual is acceptable.
|
||||
|
||||
## Round 3 (duckworth-0226 B4, sage-3038)
|
||||
|
||||
Round 2 at 62a279cf3 got CHANGES on B4. B1-B3 were met, and Duckworth accepted the schema change.
|
||||
|
||||
### B4: a cap on the streams one collection opens
|
||||
|
||||
Before this round, one collection opened every `*.jsonl` in the session directory. It also checked
|
||||
each ledger cursor with `names.includes`, which is O(cursors × names), and wrote a cursor line for
|
||||
every stream, empty ones included. The agent can write its rw session directory (sandbox.mjs:124),
|
||||
and nothing limits its file count. So 100,000 empty files meant about 22 MB of cursor lines at one
|
||||
run end, then about 10^10 string compares on the launcher's loop at the next.
|
||||
|
||||
Now:
|
||||
|
||||
- scanPi (compactions.mjs:123-125) sorts the `*.jsonl` names and opens at most `streams` of them.
|
||||
When there are more, the collection adds not-collected. The streams it didn't open get no cursor
|
||||
line. A stream that already has a cursor keeps its old one.
|
||||
- The missing-stream check uses a Set (compactions.mjs:121-122).
|
||||
- The cap is a setting, per sage-3038 (the same reason as mos-1352 and Q222): top-level
|
||||
`compactionStreams` in the instance system config. It follows compactionReadBytes. It is
|
||||
human-only. `readStreams()` accepts a safe integer from 32 to 65536 and returns the default, 1024,
|
||||
for anything else. The launcher reads it once and logs one `system config:` line for a bad value.
|
||||
mosaic-config.mjs adds the key to the same `.concat` and passes it on verbatim. The fixture literal
|
||||
at stack-release-fixture.mjs:300 still matches.
|
||||
- Name order stays sorted, so a collection under a larger cap, or with fewer files, catches up.
|
||||
Under the same cap and the same files, each collection opens the same streams and adds no cursor
|
||||
lines for streams that haven't moved.
|
||||
|
||||
Default, from stat only (`find ~/.pi/agent/sessions -name '*.jsonl'`, counted per directory, no
|
||||
content read). Each of these directories holds every session for one cwd, so a single session's
|
||||
count is at most these:
|
||||
|
||||
| directories | p50 | p90 | p99 | max |
|
||||
|---|---|---|---|---|
|
||||
| 40 | 3 | 19 | 27 | 27 |
|
||||
|
||||
1024 is about 38 times the max. The cursor lines one collection writes are bounded by 1024 × 226 B,
|
||||
about 231 KB. At the setting's 65536 maximum the bound is about 14.8 MB.
|
||||
|
||||
### N4: docs/TOOLS.md
|
||||
|
||||
docs/TOOLS.md has a paragraph on both keys, after the `config-console-key` paragraph. It gives the
|
||||
range, the default, that only a human writes them, the fallback with its log line, not-collected,
|
||||
and C1. No test reads TOOLS.md. stack-release.test.mjs:78 only names it inside a message string.
|
||||
|
||||
### C1 (duckworth-0226, sage-3038)
|
||||
|
||||
Any re-pin or rollback plan whose OLD copy is before row 270 removes both `compactionReadBytes` and
|
||||
`compactionStreams` from config.json first. A pre-270 loader refuses either key. stack-release.sh
|
||||
config_checks (:1079-1097) runs the target loader only when there is a `console` key, so the release
|
||||
doesn't catch this. Sage is drafting the config_checks follow-up row.
|
||||
|
||||
### Tests
|
||||
|
||||
- seat compactions-budget.test.mjs:114-120: readStreams over good and bad values.
|
||||
- :122-134: four streams under a cap of 3. Both collections are not-collected, and the cursors are
|
||||
only a, b and c, with no new cursor lines on the second. A cap of 4 then reads count 4, known.
|
||||
- :136-143: 1025 empty streams under the default cap. The result is not-collected with 1024 cursor
|
||||
lines, the last one e1023.
|
||||
- cli compactions-launch.test.mjs:264-298: compactionStreams 32 with 34 streams. pi.jsonl sorts
|
||||
last, so its c0 isn't opened and both collections are `[D,null,false,not-collected]`. Then `"x"`
|
||||
logs the fallback, and the next run's collections read `[D,1,true,null]`.
|
||||
- scripts/test-config.sh: 2048 and `"x"` load and pass through, as for compactionReadBytes.
|
||||
|
||||
### Mutants
|
||||
|
||||
M10's anchor moved to the Set line. The mutant is the same, removing the missing-stream check. Every
|
||||
anchor in mutants.py matches once (`check`). Round 3 shifted round 2's kill lines: seat budget
|
||||
:37/:48/:79/:88/:89 are now :39/:50/:81/:90/:91, and cli launch :182/:197/:248 are now
|
||||
:183/:198/:249. mutants.py K has the current lines. The launcher call sites are now
|
||||
launcher.mjs:507 and :648.
|
||||
|
||||
| id | change | kill |
|
||||
|---|---|---|
|
||||
| M22 | no cap, `for (const name of visit)` | seat compactions-budget.test.mjs:127, all four are opened: count 4 known; :141 fails the same way |
|
||||
| M23 | `readStreams` returns `value ?? STREAMS` | :118, `"2048"` comes back as the string |
|
||||
| M24 | the launcher ignores the setting, `streams = STREAMS` | cli compactions-launch.test.mjs:285, all 34 open: `[D,1,true,null]` twice |
|
||||
|
||||
sha256 at this round's tip, to check each restore against. These replace round 2's table:
|
||||
|
||||
```
|
||||
b5a078a096e447c4afe15812696d1f2f8c3f2b7665868c02e61a4e1edc8cd874 packages/seat/src/compactions.mjs
|
||||
d7cdc8a17e337e00dfe2d1bf6249cc2c2e696b16c5b426b2fe616089e5559509 packages/bus/src/broker.mjs
|
||||
21a7e910d81848aeb99036028d5b87354a2a515f3642779105c881b43a8b6e05 packages/cli/src/launcher.mjs
|
||||
5ae423438a178b1c8eac3b8ce0e0c22f5bb7caebb9011ce7098a36d95b53f1ca packages/bus/src/runtime.mjs
|
||||
```
|
||||
|
||||
### Run plan
|
||||
|
||||
This is round 2's plan with M1-M24. M22 and M23 run seat compactions-budget.test.mjs (120 s). M24
|
||||
runs cli compactions-launch.test.mjs (600 s). Per sage-3038, the slot goes to Megavolt's 207, then
|
||||
203, then this row's combined 270+271 gate after Duckworth approves r3.
|
||||
|
||||
### Residuals
|
||||
|
||||
- N5. readdirSync still lists the whole session directory, and the names are sorted before the
|
||||
cap. Both are O(files) in memory and time on the launcher. The cap bounds opens, reads and ledger
|
||||
lines, not the listing. Bounding the listing needs opendirSync with a stop. That would lose name
|
||||
order, and name order is what lets a later collection catch up. Accepted for this row
|
||||
(duckworth-0226).
|
||||
- The ledger's cursor map still grows when the agent deletes and recreates streams, by at most the
|
||||
cap per collection. Each deletion of a stream with a cursor is store-incomplete for good. This is
|
||||
the known limit that the bus reads the ledger whole (round 2).
|
||||
|
||||
## Round 4: rebase onto row 203 (sage-3073)
|
||||
|
||||
Rows 270 and 271 are rebased onto origin/refactor 25c09f19f (sage-3078): row 203 (FED R2) at
|
||||
a4da4e6df, plus three docs-only commits for the O2 design (BUILD-LOG.md, docs/SESSIONS.md and
|
||||
docs/plans/2026-10-21_q195-writing-editor.md, none of them in either manifest). The new branch is
|
||||
gizmoduck/row270-271-r4. gizmoduck/row271-r2 stays at b9da7697f. The old
|
||||
merge-base was 0cbe2e9b3, and the rebase moved 28 commits.
|
||||
|
||||
`git range-diff 0cbe2e9b3..b9da7697f 25c09f19f..HEAD~1` shows 26 commits
|
||||
unchanged. Round 2 and round 3 differ, and only in scripts/mosaic-config.mjs:
|
||||
|
||||
- The allowlist keeps 203's literal and adds both keys after it:
|
||||
`["configVersion", "environment", "dataRoot", "execution", "console", "federation"].concat(["compactionReadBytes", "compactionStreams"])`.
|
||||
203 changed packages/cli/tests/stack-release-fixture.mjs, and its LOADER_OLD replace is now at
|
||||
:301 (rounds 2 and 3 say :300). It matches the literal with `"federation"`, which is still
|
||||
intact before the `.concat`.
|
||||
- The output has 203's `federation` line, then the two `Object.hasOwn` lines.
|
||||
|
||||
Rows 203, 270 and 271 also all touch scripts/test-config.sh, scripts/stack-release.sh and
|
||||
packages/cli/tests/stack-release.test.mjs. Those merged without conflict, and the hunks are far
|
||||
apart:
|
||||
|
||||
- test-config.sh: 203's federation cases start at :194. Mine are at :266-:281, after the
|
||||
no-console case.
|
||||
- stack-release.sh: 203 changed config_checks at :1078-:1097. Row 271's change is COUNTED at
|
||||
:420, still the line 271's BUILD.md cites.
|
||||
- stack-release.test.mjs: 203 changed :1468 and :1582. Row 271's LISTED is still at :861 and T46
|
||||
at :913.
|
||||
|
||||
203 widens config_checks to run the target loader for a `federation` key as well as `console`.
|
||||
It still does not run the loader for compactionReadBytes or compactionStreams, so C1 stands as
|
||||
written.
|
||||
|
||||
`mutants.py check` passes: every anchor matches once. The restore hashes in round 3's table are
|
||||
unchanged, because 203 touches none of those four files. No test pins a sha256 of mosaic-config.mjs,
|
||||
test-config.sh, stack-release.sh or stack-release.test.mjs.
|
||||
|
||||
Nothing has run on this base yet. The combined gate is sage-3073 item 4, on Sage's CLEAR.
|
||||
|
||||
## Combined 270+271 gate (C2), written before the first run (sage-3073 item 4, sage-3086, mos-1351)
|
||||
|
||||
The gate runs on the commit that adds this section. Its code is ac44d0de9's: `git diff --name-only
|
||||
ac44d0de9 HEAD` is this file only. The review keys stay manifest-270-r4 4f5e7041… and manifest-271-r4
|
||||
3ab1f5c8…, Duckworth approves at revs 3379 and 3380. Worktree /mnt/storage/scratch/gizmo-270, with
|
||||
node_modules linked to the canonical checkout. Base 25c09f19f. Script
|
||||
/mnt/storage/scratch/gizmo-271-out/run-r4.sh, run with pipefail and tee, logs in
|
||||
/mnt/storage/scratch/gizmo-271-out/r4/. TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`.
|
||||
|
||||
### Blob table
|
||||
|
||||
sha256 of every source file a mutant edits, at the gate commit. Each restore is `git checkout --` the
|
||||
file, then this hash, then a clean tree.
|
||||
|
||||
```
|
||||
b5a078a096e447c4afe15812696d1f2f8c3f2b7665868c02e61a4e1edc8cd874 packages/seat/src/compactions.mjs (270)
|
||||
d7cdc8a17e337e00dfe2d1bf6249cc2c2e696b16c5b426b2fe616089e5559509 packages/bus/src/broker.mjs (270, 271)
|
||||
21a7e910d81848aeb99036028d5b87354a2a515f3642779105c881b43a8b6e05 packages/cli/src/launcher.mjs (270, 271)
|
||||
5ae423438a178b1c8eac3b8ce0e0c22f5bb7caebb9011ce7098a36d95b53f1ca packages/bus/src/runtime.mjs (270)
|
||||
```
|
||||
|
||||
### Timeout table (mos-1275)
|
||||
|
||||
Every run is `timeout -k 10 LIMIT`. The longest records are 271's gate on e8af389e6 (BUILD.md queue-271
|
||||
"Gate on e8af389e6") and 271's "Run limits" table.
|
||||
|
||||
| Run | Longest recorded | LIMIT |
|
||||
| --- | --- | --- |
|
||||
| stack-release.test alone | 575 s | 1800 s |
|
||||
| each other touched test file alone, stack-instances.test, webui pinned-copy.test | 15 s | 300 s |
|
||||
| business suite | none recorded | 900 s |
|
||||
| bus suite | 24 s | 900 s |
|
||||
| seat suite | 6 s | 600 s |
|
||||
| cli suite | 591 s | 1800 s |
|
||||
| each scripts/test-*.sh | 42 s (test-foundation; 39 s at e8af389e6) | 900 s |
|
||||
| 270 mutant, seat or bus kill file | none recorded | 120 s |
|
||||
| 270 mutant, cli compactions-launch.test | none recorded | 600 s |
|
||||
| 271 mutant, each session-clear.test file | 57 s for both files (e8af389e6) | 300 s |
|
||||
|
||||
A shell-phase timeout goes to Mos unplaced. A timeout with no failed test is a hang and goes to Sage.
|
||||
|
||||
### GREEN and RED
|
||||
|
||||
- GREEN for a test run: rc 0, `fail 0`, and at least one pass for a single file. A skip is named in the
|
||||
report.
|
||||
- RED: any other rc, a failed test, or zero passes.
|
||||
- A mutant is killed when its kill file is RED with a `not ok` on the test K names. 270's K is in
|
||||
queue-270/mutants.py. 271's kills are the bus or cli session-clear.test failures in queue-271/BUILD.md.
|
||||
A mutant whose kill file stays GREEN survived.
|
||||
- A first-run red in a new test (seat compactions-budget.test, cli compactions-launch.test, 271's new
|
||||
tests) is placed and reported after (mos-1272). A red in an old test, a product change, a looser
|
||||
assertion or a second red on a row stops the run and goes to Sage before any change (sage-3086).
|
||||
- Mutants run only on a GREEN base.
|
||||
|
||||
### Order
|
||||
|
||||
1. The 20 touched test files whole, then cli stack-instances.test, webui pinned-copy.test and the
|
||||
business suite (the config loader's readers, round 2 run plan item 3).
|
||||
2. bus, seat and cli suites whole.
|
||||
3. The ten scripts/test-*.sh, test-config.sh among them, with skips named.
|
||||
4. 270 M1-M24, each on its K file whole. Then 271 M1-M14, each on bus and cli session-clear.test whole.
|
||||
|
||||
### Run 1: a first-run red, placed (mos-1272)
|
||||
|
||||
Run 1 started 00:06:57Z on 0f5e5d62d. In phase 1, the 15 bus files and session-compactions passed.
|
||||
Then cli compactions-launch.test.mjs, new in this row and on its first run, went RED: 4 of 5 pass. The
|
||||
failure was in the clear test, "REQ-THREAD-2 T3 T4 a clear leaves the retired session's count...", at
|
||||
:319 (now :324):
|
||||
`[done?.state, done?.start]` was `[undefined, undefined]`, not `["retired", "started"]`, after the
|
||||
30 s settle (31433 ms).
|
||||
|
||||
The cause is in the test, not the product. The test's host `ops` table had no entries for row 271's
|
||||
five clear ops: sessionClears, sessionClearExited, sessionClearUncertain, sessionClearRetire and
|
||||
sessionClearStart. The launcher's clear calls through host.op were refused as invalid-host-request,
|
||||
so the successor never started. cli session-clear.test.mjs:96-100 wires the same five, and its clear
|
||||
tests passed in this run (5 of 5).
|
||||
|
||||
The placement adds those five lines, copied from session-clear.test.mjs, after landRelease. No assertion
|
||||
changed, and no product file changed. The file alone then passed 5 of 5
|
||||
(/mnt/storage/scratch/gizmo-271-out/place-compactions-launch.log). The five lines move 270's
|
||||
compactions-launch kill lines by +5 in queue-270/mutants.py K: :188, :203, :254 and :290. Round 2
|
||||
and round 3 above cite the old lines.
|
||||
|
||||
Run 1 stopped at 00:07:59Z (the log's last write), after phase 1's cli session-clear.test, so that the gate runs on one
|
||||
tree. No node process was left, and the tree was clean. Its log is
|
||||
/mnt/storage/scratch/gizmo-271-out/r4-run1/. This is the row's first red. Run 2 is the whole gate
|
||||
again on the commit that adds the placement. That commit changes the 270 manifest (compactions-launch.test.mjs,
|
||||
mutants.py and this file), so Sage and Duckworth see it before landing.
|
||||
|
||||
### Run 2: a second red, two test defects (mos-1364, sage-3093)
|
||||
|
||||
Run 2 started 00:09:50Z on 5f697db99. Phase 1 passed except one file: the 15 bus files, session-compactions
|
||||
4/4, compactions-launch 5/5, cli session-clear 5/5, stack-release 219/219, seat compactions 20/20,
|
||||
stack-instances 24/24, webui pinned-copy 10/10 and business 231/231. seat compactions-budget.test.mjs
|
||||
went RED: 8 of 10 pass. The file is new in round 2, and this was its first run. Rounds 2 and 3 were not
|
||||
run. This is the row's second red, so I stopped the run (r4.out's last write 00:20:12Z) and changed
|
||||
nothing until Mos ruled yes on both fixes (mos-1364, passed on in sage-3093). Phase 2 did not start. Its
|
||||
log is /mnt/storage/scratch/gizmo-271-out/r4-run2/.
|
||||
|
||||
Both failures are defects in the test, not in compactions.mjs.
|
||||
|
||||
F1, :39 in "the budget is the setting's whole number of bytes...". `readBudget(8.5 * MiB)` returned
|
||||
8912896, not the default 67108864. 8.5 MiB is 8912896 bytes: a whole number, in range. readBudget
|
||||
(compactions.mjs:80-82, Number.isSafeInteger and the 1 MiB to 1 GiB bounds) is right to take it. The case
|
||||
meant a value that is not a whole number of bytes. The fix changes only that input, to `MiB + 0.5`. The
|
||||
other twelve bad values stay. M17's kill line is this :39, so M17 had no valid kill before the fix.
|
||||
|
||||
F2, :107 in "a claude-projects/ that can't be read...". The hook `t.after(() => chmodSync(projects,
|
||||
0o700))` threw ENOENT. A test's after hooks run in the order they were registered. setup()'s rmSync of the
|
||||
root (:20) was registered first, so the tree was gone when this hook ran. The test's assertions had passed.
|
||||
The fix puts the 0o000 window in try/finally, which restores 0o700 before the rm even when an assertion
|
||||
fails, and drops the hook and the inline chmod. It keeps the same assertions in the same order, and none
|
||||
is removed. Net +2 lines, so in mutants.py K, M23 moves :118 to :120 and M22 :127 to :129. M22's note
|
||||
cites the default-cap test's :141, now :143. M15's note cites the clear test's :323 and the legacy :343,
|
||||
which are :328 and :348 since run 1. That change is to note text only. `mutants.py check` shows all 24
|
||||
anchors matching once.
|
||||
|
||||
The file alone then passed 10 of 10 (/mnt/storage/scratch/gizmo-271-out/place-compactions-budget.log).
|
||||
Run 3 is the whole combined gate again, on one tree, on the commit with F1 and F2. A third red on 270
|
||||
stops it and goes to Sage and Mos before any change.
|
||||
|
||||
### Runs 3 and 4: two old-test races, not 270 or 271 (mos-1367, mos-1369)
|
||||
|
||||
Run 3 started 00:23:35Z on 53406f3e4. Phase 1 passed whole (business 231/231), bus 873/873 and seat
|
||||
202/202. cli went RED, 904 of 905: case1-accept.test.mjs:368, the P10 inner hop probe took 1.08 s, under
|
||||
its 1.25 s floor. The file is old and unchanged in 25c09f19f..53406f3e4. I stopped the run during phase 3
|
||||
(r4.out's last write 00:46:24Z) and changed nothing. Mos ruled (mos-1367, passed on in sage-3097): A1 run
|
||||
case1-accept alone, which passed 50/50 (00:50:01Z to 00:54:17Z, load 3.19 3.58 3.71); A2 run 4 on the
|
||||
same tip. Vikunja 573 (owner gander) owns the P10 band fix. Log: /mnt/storage/scratch/gizmo-271-out/r4-run3/.
|
||||
|
||||
Run 4 started 00:54:25Z on 53406f3e4 (load 4.45 4.20 3.94). Phase 1 passed 23/23 files and bus 873/873.
|
||||
seat went RED, 201 of 202: checkout.test.mjs:239 "a dirty clone whose fetch fails is kept, fetch-failed,
|
||||
exactly as it was" failed :91 "the root is unchanged" at its first once(w) (:241). The file is old and
|
||||
unchanged in 25c09f19f..53406f3e4, and it passed in run 3. Load was 8.19 at 01:06Z. I stopped the run
|
||||
(r4.out's last write 01:06:31Z) and changed nothing. The cause is the fixture, not 270 or 271: its git
|
||||
helper (checkout.test.mjs:35) runs with gitEnv() (checkout.mjs:80-90), which does not set
|
||||
maintenance.auto=false, so its commit can leave a detached `git maintenance run --auto` writing in the
|
||||
root that once() fingerprints. Mos watched objects/maintenance.lock vanish after commit returned in 1 of
|
||||
30 fresh repos (mos-1369, passed on in sage-3106). Vikunja 574 (owner gander) owns the fixture fix; no test
|
||||
changed in this slot. Log: /mnt/storage/scratch/gizmo-271-out/r4-run4/.
|
||||
|
||||
### Run 5: GREEN (mos-1369, sage-3106)
|
||||
|
||||
Run 5 waited until the one-minute load was under 4, then started 01:17:11Z on 53406f3e4, unchanged since
|
||||
run 3 (loadavg 3.86 4.91 4.69, up 9 days 11:37; origin/refactor 062646487, no drift from 25c09f19f in
|
||||
packages or scripts). It ended 01:49:41Z, wall 1950 s, exit 0.
|
||||
|
||||
| Phase | Result | Wall |
|
||||
| --- | --- | --- |
|
||||
| 1, touched test files whole and the loader's readers | 23/23 files pass; stack-release 219/219, compactions-budget 10/10, business 231/231 | 673 s |
|
||||
| 2, bus suite | 873/873 | 25 s |
|
||||
| 2, seat suite | 202/202 | 7 s |
|
||||
| 2, cli suite | 905/905; P10 inner hop probe 2.31 s, outer 4.14 s | 682 s |
|
||||
| 3, scripts/test-*.sh | all ten pass; tree clean after | |
|
||||
| 4, 270 M1-M24 | 24/24 killed, each on its K file, each restore matched | |
|
||||
| 4, 271 M1-M14 | 14/14 killed in the file the 271 table names (271 BUILD.md:713), each restore matched | |
|
||||
|
||||
No zero-pass file. Three skips, the same as before: test-queue's queue verify and render --check (this
|
||||
checkout is not the queue's canonical root) and test-task's live provider cases, twice (row 244). No node
|
||||
process was left with its cwd in the worktree. Sage ran one `git fetch` from the canonical checkout at
|
||||
about 01:38:10Z, inside the cli suite (sage-3122); the suite had no red. Log:
|
||||
/mnt/storage/scratch/gizmo-271-out/r4/ and r4.out.
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 270 mutants. M1-M12 are round 1's (anchors at 7d199f033); M13-M21 are round 2's
|
||||
# (duckworth-0223 B1-B3, sage-3030, sage-3031, mos-1352); M22-M24 are round 3's (duckworth-0226
|
||||
# B4, sage-3038). Round 3's kill lines are at round 3's tests. `apply <id>` edits the worktree and
|
||||
# prints the touched path; each edit asserts its anchor appears exactly once. `check` prints each
|
||||
# anchor's count and kill. K names the test and the first assertion each mutant should fail
|
||||
# (checked by reading, not run). The base blob of every touched file is in BUILD.md; a restore is
|
||||
# `git checkout -- <path>`, then sha256 against it (sage-3030 item 3).
|
||||
import sys
|
||||
WT = "/mnt/storage/scratch/gizmo-270/"
|
||||
C = "packages/seat/src/compactions.mjs"
|
||||
B = "packages/bus/src/broker.mjs"
|
||||
L = "packages/cli/src/launcher.mjs"
|
||||
R = "packages/bus/src/runtime.mjs"
|
||||
ST = "packages/seat/tests/compactions.test.mjs"
|
||||
SB = "packages/seat/tests/compactions-budget.test.mjs"
|
||||
BT = "packages/bus/tests/session-compactions.test.mjs"
|
||||
CT = "packages/cli/tests/compactions-launch.test.mjs"
|
||||
M = {
|
||||
"M1": (C, " const key = keyOf(f.entry, f.completedAt);\n if (ledger.events.has(key)) continue;",
|
||||
" const key = keyOf(f.stream + f.entry, f.completedAt);\n if (ledger.events.has(key)) continue;"),
|
||||
"M2": (C, "const keyOf = (entry, completedAt) => JSON.stringify([entry, completedAt]);",
|
||||
"const keyOf = (entry, completedAt) => JSON.stringify([entry, null]);"),
|
||||
"M3": (C, 'if (e.type === "compaction" && typeof e.id === "string" && e.id) {',
|
||||
'if ((e.type === "compaction" || e.message?.role === "compactionSummary") && typeof e.id === "string" && e.id) {'),
|
||||
"M4": (C, "digest(tail) === c.tail) from = c.bytes;", "true) from = c.bytes;"),
|
||||
"M5": (C, 'if (lines.pop() !== "") bad = torn = true;', 'if (lines.pop() !== "") torn = true;'),
|
||||
"M6": (C, '(ledger.torn ? "\\n" : "") + out.map', 'out.map'),
|
||||
"M7": (C, ' if (ledger.incomplete) return unknown("store-incomplete", count, at);\n', ""),
|
||||
"M8": (C, ' if (ledger.unsupported) return unknown("harness-unsupported", count, at);\n', ""),
|
||||
"M9": (C, "({ count: count || null, known: false, reason, at })", "({ count, known: false, reason, at })"),
|
||||
# Round 3 moved M10's anchor to the Set (duckworth-0226 B4); the mutant is the same.
|
||||
"M10": (C, ' for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");\n', ""),
|
||||
"M11": (B, "op === 'legacy-3l')", "false)"),
|
||||
"M12": (B, "if (!this.#compactions) return unknown('not-collected');",
|
||||
"if (!this.#compactions) return { count: 0, known: true, reason: null, at: null };"),
|
||||
# B2: the launcher's two collections and the runtime's reader.
|
||||
"M13": (L, " if (store) compactions(store, model.harness, { instance, thread, session, run });\n const toolSocket",
|
||||
" const toolSocket"),
|
||||
"M14": (L, " if (store) compactions(store, model.harness, { instance, thread, session, run });\n try { lock?.release(); }",
|
||||
" try { lock?.release(); }"),
|
||||
"M15": (R, " return dir ? compactionCount(dir) : { count: null, known: false, reason: 'store-unreadable', at: null };",
|
||||
" return null;"),
|
||||
# B1: the budget, its setting and the carry across reads.
|
||||
"M16": (C, " const stop = Math.min(size, from + left);", " const stop = size;"),
|
||||
"M17": (C, " return Number.isSafeInteger(value) && value >= READ_MIN && value <= READ_MAX ? value : READ_BYTES;",
|
||||
" return value ?? READ_BYTES;"),
|
||||
"M18": (L, " const budget = readBudget(system?.compactionReadBytes);", " const budget = READ_BYTES;"),
|
||||
"M19": (C, " take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));",
|
||||
" take(view.subarray(i, nl));"),
|
||||
# B3: Claude history from before the ledger, and its stickiness.
|
||||
"M20": (C, ' if (harness === "pi" && !prior && claudeHistory(store)) {', ' if (harness === "pi" && !prior && false) {'),
|
||||
"M21": (C, 'if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;',
|
||||
'if (r.harness !== "pi") unsupported = true;'),
|
||||
# B4: the stream cap and its setting.
|
||||
"M22": (C, ' if (visit.length > streams) problems.add("not-collected");\n for (const name of visit.slice(0, streams)) {',
|
||||
' for (const name of visit) {'),
|
||||
"M23": (C, " return Number.isSafeInteger(value) && value >= STREAMS_MIN && value <= STREAMS_MAX ? value : STREAMS;",
|
||||
" return value ?? STREAMS;"),
|
||||
"M24": (L, " const streams = readStreams(system?.compactionStreams);", " const streams = STREAMS;"),
|
||||
}
|
||||
K = {
|
||||
"M1": (ST, "a compaction copied into a branched stream counts once, under the stream it was first seen in", 64,
|
||||
"c1 is written again under b.jsonl: [[c1,a],[c1,b],[c2,b]]; the count holds at 1 and 2 because readLedger dedupes"),
|
||||
"M2": (ST, "the same entry id at another time in another stream is another compaction", 81, "count 1, not 2"),
|
||||
"M3": (ST, "an empty store, a store with no compaction and a failed start count 0, known", 40,
|
||||
"the compactionSummary message m2 counts: count 1, not 0"),
|
||||
"M4": (ST, "a stream that shrank, changed under its cursor or went away is store-incomplete for good > rewrite", 170,
|
||||
"same length, other bytes, read from the old cursor: known true"),
|
||||
"M5": (ST, "a torn last ledger line makes the count unknown, a lower bound, and stays so", 131,
|
||||
"the torn line is popped unparsed, so nothing sets bad: {count 0, known true}"),
|
||||
"M6": (ST, "a torn last ledger line makes the count unknown, a lower bound, and stays so", 133,
|
||||
"the c1 record joins the torn line and is lost: count null, not 1"),
|
||||
"M7": (ST, "a stream that shrank, changed under its cursor or went away is store-incomplete for good > shrink", 174,
|
||||
"the later clean collection reads known; remove would pass, its cursor stays missing"),
|
||||
"M8": (ST, "a Claude run is harness-unsupported, and stays so after a Pi run", 201, "the Pi run reads known true"),
|
||||
"M9": (ST, "a torn last ledger line makes the count unknown, a lower bound, and stays so", 131,
|
||||
"count 0, not null; the symlinked-stream test's :184 fails the same way"),
|
||||
"M10": (ST, "a stream that shrank, changed under its cursor or went away is store-incomplete for good > remove", 170,
|
||||
"a removed stream raises no problem: known true"),
|
||||
"M11": (BT, "a legacy session is asked for with legacy true", 76, "asked with legacy false"),
|
||||
"M12": (BT, "without a reader the count is unknown, never 0; a reader that throws is store-unreadable", 81,
|
||||
"{count 0, known true} for no reader"),
|
||||
"M13": (CT, "a run collects compactions at its start and its end, and the count holds across a resume and a host restart", 188,
|
||||
"no start collection: run 1 logs only [D,2,true,null]; settle returns after 5 s with one event"),
|
||||
"M14": (CT, "a run collects compactions at its start and its end, and the count holds across a resume and a host restart", 188,
|
||||
"no end collection: run 1 logs only [D,1,true,null]"),
|
||||
"M15": (CT, "a run collects compactions at its start and its end, and the count holds across a resume and a host restart", 203,
|
||||
"session.list's compactions is null: {at: 'undefined'} and no count; the clear test's :328 and the legacy :348 fail the same way"),
|
||||
"M16": (SB, "a stream past the default budget is not-collected with a lower bound, and a later collection catches up", 50,
|
||||
"the whole 66 MiB is read: {count 1, known true, reason null}"),
|
||||
"M17": (SB, "the budget is the setting's whole number of bytes from 1 MiB to 1 GiB, and the default for anything else", 39,
|
||||
"readBudget(null) is the default, but readBudget('8388608') is the string"),
|
||||
"M18": (CT, "the read budget is the system config's compactionReadBytes, and a bad value takes the default", 254,
|
||||
"the 1 MiB setting is ignored, the 2 MiB hole is read whole: [D,1,true,null] twice"),
|
||||
"M19": (SB, "a line split across reads counts once", 81,
|
||||
"c1's two halves parse alone and fail: {count null, known false, reason store-incomplete}"),
|
||||
"M20": (SB, "Claude history from before the ledger makes the first Pi collection harness-unsupported, for good", 90,
|
||||
"the first collection counts c1: {count 1, known true}"),
|
||||
"M21": (SB, "Claude history from before the ledger makes the first Pi collection harness-unsupported, for good", 91,
|
||||
"a Pi collected record isn't sticky: the second reads {count 1, known true}"),
|
||||
"M22": (SB, "more streams than the cap is not-collected, with cursors only for the streams opened, and a larger cap catches up", 129,
|
||||
"all four streams are opened: {count 4, known true, reason null}; the default-cap test's :143 fails the same way (1025 cursors)"),
|
||||
"M23": (SB, "the stream cap is the setting's whole number from 32 to 65536, and the default for anything else", 120,
|
||||
"readStreams(null) is the default, but readStreams('2048') is the string"),
|
||||
"M24": (CT, "the stream cap is the system config's compactionStreams, and a bad value takes the default", 290,
|
||||
"the cap of 32 is ignored, all 34 streams are opened: [D,1,true,null] twice"),
|
||||
}
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "list":
|
||||
print(" ".join(M)); sys.exit(0)
|
||||
if cmd == "check":
|
||||
for k, (f, a, b) in M.items():
|
||||
n = open(WT + f).read().count(a)
|
||||
tf, name, line, why = K[k]
|
||||
print(k, f, n, "kill", f"{tf}:{line}", name)
|
||||
sys.exit(0)
|
||||
f, a, b = M[sys.argv[2]]
|
||||
s = open(WT + f).read()
|
||||
assert s.count(a) == 1, (sys.argv[2], s.count(a))
|
||||
open(WT + f, "w").write(s.replace(a, b))
|
||||
print(f)
|
||||
@@ -0,0 +1,790 @@
|
||||
# Row 271 build notes (Gizmoduck)
|
||||
|
||||
264d, T1 item 7: clear context through row 194's lifecycle stop. Gitea #1776. Brief:
|
||||
docs/plans/2026-10-21_t1-sessions.md section 5 (D7, D8), with RULINGS Q4 (rebase onto 194's
|
||||
landing, no 194 code copied). Reviewer Duckworth, gate owner Sage. Built after row 270 (sage-2797).
|
||||
|
||||
## Contract for 264e (pinned for Huey, row 272)
|
||||
|
||||
Proposed, pending Duckworth's review. Any change goes to Huey before it's pushed.
|
||||
|
||||
### session.clear
|
||||
|
||||
Input, every key required, with no other key (`keys()` as `session.create` does):
|
||||
|
||||
```json
|
||||
{ "thread": "<thread id>", "session": "<session id>", "revision": 0, "confirm": true, "op": "<op key>" }
|
||||
```
|
||||
|
||||
- `revision`: the integer from the session's `session.list` row (below). It names the state the
|
||||
human confirmed.
|
||||
- `confirm`: the literal `true`. Anything else refuses `confirm-required`.
|
||||
- `op`: the caller's operation key, an identifier, unique per thread. It is also the idempotency
|
||||
key.
|
||||
|
||||
Answer, the operation envelope:
|
||||
|
||||
```json
|
||||
{ "op": "...", "thread": "...", "session": "<old id>", "successor": "<new id>",
|
||||
"state": "clearing", "start": null, "refusal": null, "at": "..." }
|
||||
```
|
||||
|
||||
- `successor` is bound when the request is accepted, before any asynchronous work. It is a
|
||||
lowercase UUID, `xxxxxxxx-xxxx-8xxx-Vxxx-xxxxxxxxxxxx` with V one of `8`, `9`, `a`, `b` (RFC 9562
|
||||
version 8, variant 10). It's the first 32 hex digits of sha256(business, thread, old session, op)
|
||||
with those two nibbles set. It matches the UUID test every session selector uses
|
||||
(cli/src/thread.mjs:41), so 264a's selectors and deep links take it unchanged (Huey's question,
|
||||
gizmoduck-0274).
|
||||
- `state`:
|
||||
- `clearing`: accepted. Sends and launches to the old session are fenced, and the drain or stop
|
||||
is in progress.
|
||||
- `uncertain`: stop proof isn't available. Capacity stays reserved and nothing is forced. It
|
||||
resumes when proof arrives.
|
||||
- `retired`: the old session is retired and the successor exists, in one bus transaction.
|
||||
- `failed`: no retirement happened, and `refusal` names why. The old session stays as it was.
|
||||
- `cancelled`: a human cancelled the clear (session.cancel below, sage-2828). `retired` is
|
||||
null if it was cancelled before retirement and true if after. `start` stays as it was.
|
||||
- `start` is null until `state` is `retired`. After that it is `held` (waiting for capacity),
|
||||
`started`, or `refused` with `refusal` set.
|
||||
- `at`: the time of the latest state change.
|
||||
|
||||
Repeats and conflicts:
|
||||
|
||||
- The same `op` with the same payload returns the current envelope and starts nothing new.
|
||||
- The same `op` with any other payload refuses `session-op-conflict`.
|
||||
- A new `op` on a session with an open clear refuses `session-clearing`.
|
||||
- A `revision` that isn't current refuses `session-revision-stale`.
|
||||
- Other refusals: `session-not-found`, `session-retired`, `processing-halted` (D8: a halt refuses
|
||||
and changes nothing), and the owner refusals `session.create` already gives.
|
||||
|
||||
### session.cancel (sage-2828)
|
||||
|
||||
Input, every key required, with no other key:
|
||||
|
||||
```json
|
||||
{ "thread": "<thread id>", "op": "<the clear's op>", "confirm": true }
|
||||
```
|
||||
|
||||
This is a human write with the same owner or write-grant check as session.clear, rechecked under
|
||||
the session fence. An agent caller refuses as session.clear does. `confirm` must be the literal
|
||||
`true`, or it refuses `confirm-required`. The answer is the operation envelope with `state`
|
||||
`cancelled`.
|
||||
|
||||
- Before retirement, cancel stops the replacement:
|
||||
- The successor id is never created or started.
|
||||
- Messages already cancelled stay cancelled and are never replayed.
|
||||
- The old session is `active` again.
|
||||
- After retirement, cancel stops the startup only:
|
||||
- No further start attempts are made.
|
||||
- Both sessions stay as they are. Nothing un-retires.
|
||||
- Repeats and refusals:
|
||||
- A repeat with the same payload returns the original cancelled envelope.
|
||||
- The payload digest covers `{thread, op}` and the calling principal. A cancel from a
|
||||
different principal, or a payload that otherwise differs, refuses `session-op-conflict`.
|
||||
- An op that isn't a clear refuses `operation-not-found`.
|
||||
- A cancel of a clear whose successor has started refuses `session-clear-complete`.
|
||||
|
||||
### session.operation
|
||||
|
||||
Input `{ "thread": "...", "op": "..." }`. Answer: the same envelope. Unknown op refuses
|
||||
`operation-not-found`. A CLI that waits 30 s and then reports pending uses this to poll. A retry
|
||||
queries the op here and never resends the clear.
|
||||
|
||||
### session.list additions
|
||||
|
||||
Each row gains:
|
||||
|
||||
- `state`: `active`, `clearing`, `uncertain` or `retired`. A cancelled clear leaves the session
|
||||
`active` if cancelled before retirement, and `retired` if after.
|
||||
- `revision`: an integer. 0 at creation, plus 1 for each lifecycle event on that session.
|
||||
- `successor`: the successor's id once retired, otherwise null.
|
||||
|
||||
A successor row has `predecessor` set (264a's column). When the cleared session was the thread's
|
||||
default, `default` moves to the successor at retirement. Clearing any other session leaves the
|
||||
default where it is.
|
||||
|
||||
On a base without 264d, `session.clear`, `session.cancel` and `session.operation` refuse
|
||||
`unknown-verb`, and the rows have no `state`,
|
||||
`revision` or `successor`.
|
||||
|
||||
## Design (draft 2: Sage's sage-2826 rulings and Mos's mos-1315 folded in)
|
||||
|
||||
Measured on 2d0d407ba, which already holds row 194 (ab7377bcf). Nothing has been run. Draft 1
|
||||
was 3105de889. Draft 2 changes where the drained acknowledgement comes from (stage 2): threadIdle
|
||||
alone can't show that no child is running.
|
||||
|
||||
### What exists and what doesn't
|
||||
|
||||
- No per-session lifecycle state. `sessions` is append-only (schema.sql:370-372). The nearest
|
||||
per-session state is the latest `thread_claims` row (op and generation). Row 194's lifecycle
|
||||
is per instance, as `action.allowed` events keyed by a random request id (broker.mjs:622-666).
|
||||
- No cancelled state for an undelivered message. `deliveries.op` is a CHECK list on an
|
||||
append-only table.
|
||||
- The runner calls `message.receive` only when no harness child exists:
|
||||
- `runTurn`'s close handler SIGKILLs the turn's whole process group and clears `child`
|
||||
(harness/src/runner.mjs:363-366).
|
||||
- The loop asks receive only after the landing turns (:493-517) and after each message's
|
||||
RESULT and `message.read` (:524-580).
|
||||
- So a receive from the run is a completed turn boundary, made by the runner itself.
|
||||
- `threadIdle` (broker.mjs:865-877) is not enough. A landing turn reads no message, so `unread`
|
||||
is 0 while its child runs. That is why landRelease keeps a run until it has asked receive
|
||||
(`#received`, broker.mjs:363-365, :3815-3829).
|
||||
- There is precedent for acting at receive. `#limitStop` (broker.mjs:4147-4148, :4499-4520)
|
||||
holds a stop until the run's next `message.receive` or `state`, so the turn's RESULT and read
|
||||
land first.
|
||||
- The runner's SIGTERM handler escalates to KILL after 5 s only when `child` is set
|
||||
(runner.mjs:405-423). With no child it sets `stopping`, the loop ends, and it exits 0.
|
||||
- The launcher's idle stop (launcher.mjs:683-701) sends a SIGTERM after a start-time check. The
|
||||
exit proof is the run's `close` handler, which runs endRun, removes the registry entry and
|
||||
releases the workspace lock (:600-641).
|
||||
|
||||
### Clear, stage by stage
|
||||
|
||||
1. **Accept** (`session.clear`, the human's write). One transaction:
|
||||
- Check owner and grant (the `session.create` path), the revision, and that processing isn't
|
||||
halted.
|
||||
- Bind the successor before any async work:
|
||||
- The id is deterministic, from a sha256 of (business, thread, old session, op), shaped as a
|
||||
lowercase version 8 UUID (the contract above). 264a's ids are UUIDs, and every selector
|
||||
checks the UUID shape, so draft 2's `ID_PATTERN` shape is replaced.
|
||||
- The name is `<old name>-N`, with N the next free number in that thread. If a user already
|
||||
made a session called `<old>-N`, the next free N is taken.
|
||||
- Both go on the `requested` row, so a repeat of the op returns the same id and name.
|
||||
- Append journal stage `requested`, with a payload digest for the repeat and conflict checks.
|
||||
- The answer is `clearing`. From here:
|
||||
- `thread.send` and `thread.open` naming the session refuse `session-clearing`. So does a
|
||||
thread-only send whose default is that session.
|
||||
- `threadsState` reports the session as `clearing`, so `threadPass` never starts it.
|
||||
- Undelivered messages to it are cancelled with a receipt each (`message_cancels`), in the
|
||||
same transaction. Old messages, results and run evidence don't change.
|
||||
2. **Drain, acknowledged by the runner.** Once the session is clearing, the run's
|
||||
`message.receive` (the runner's call, at a turn boundary with no child) takes one transaction:
|
||||
- It appends `drained` with the run and its claim generation, once.
|
||||
- It delivers nothing and answers `[]`.
|
||||
- The claim stays held. That holds the dequeue fence, so no turn and no child can start
|
||||
before the stop, and the old capacity stays reserved.
|
||||
Later receives keep answering `[]`. `state` needs no fence, because the runner asks it only
|
||||
for a message it received. A clear on a session with no holder appends `drained` with run
|
||||
null at accept.
|
||||
3. **Stop and proof** (launcher). For a `drained` op whose run and generation are still the
|
||||
holder's, the launcher sends the idle stop's start-time-checked SIGTERM, the same reviewed
|
||||
path. The runner has no child, so its handler never reaches KILL.
|
||||
- The exit proof is the run's own `close` handler (endRun, registry remove, lock release),
|
||||
recorded with `sessionClearExited {business, op, run}`. A signal, a revoked claim or a dead
|
||||
socket is not proof.
|
||||
- Under the host, the runner is pid 1 of its own pid namespace (`--unshare-pid --as-pid-1`),
|
||||
so its exit ends anything a turn left outside its process group. **This is a build check**,
|
||||
tested against bwrap, not assumed.
|
||||
- With no run, the proof is that the launcher takes `lockSessionWorkspace` for the session and
|
||||
the registry has no run for it.
|
||||
- If no `drained` arrives (a turn still running, or a stuck child), the session stays
|
||||
`clearing`. Capacity stays reserved, the successor stays unstarted, the status stays
|
||||
actionable, and no signal is sent.
|
||||
- Thirty seconds after the SIGTERM with no proof, `uncertain` is appended once. The launcher
|
||||
keeps waiting and sends no second signal. A launcher restart finds the run among its row 269
|
||||
survivors and waits on its identity.
|
||||
4. **Retire** (trusted op `sessionClearRetire {business, op}`), one transaction:
|
||||
- Check the exit proof (the run is in `#ended`), or that there was no run.
|
||||
- Append `retired`, and end the old claim.
|
||||
- Insert the successor `sessions` row with the bound id and name, `predecessor` set to the old
|
||||
id and `op` set to the clear op.
|
||||
- Add a `session_defaults` row only if the old session was the default.
|
||||
- Add a `thread_opens` row for the successor.
|
||||
The old workspace stays at its path with no new writer mount. The successor gets its own store,
|
||||
HOME and workspace from row 269's per-session layout, and a fresh checkout of the approved
|
||||
revision. Nothing is copied.
|
||||
5. **Start** (D8). `threadPass` starts the successor from its `thread_opens` row through the same
|
||||
`check()`/`start()` as any session. The launcher reports the outcome with `sessionClearStart
|
||||
{business, op, start, refusal}`, which appends `start-held`, `start-started` or
|
||||
`start-refused`, each with the next report counter. A held start keeps its op and spends no
|
||||
work attempt.
|
||||
|
||||
`failed`: a refusal at retire (for example, the owner lost the grant) appends `failed` with the
|
||||
code. Nothing is retired, and the old session goes back to `active` with its messages still
|
||||
cancelled.
|
||||
|
||||
**Cancel** (sage-2828, re-ruling sage-2826 Q3, D7:263-264). `session.cancel` checks the owner or
|
||||
write grant under the fence and appends `cancelled`, attempt 0, in one transaction:
|
||||
- Before `retired` it releases the fence:
|
||||
- If no `drained` was recorded, the drain is still waiting. Nothing is signalled, the clear
|
||||
ends, and the run's next receive delivers again.
|
||||
- If `drained` was recorded with the claim still held and no SIGTERM sent, the launcher skips
|
||||
the stop because the op is no longer open. The run keeps its claim, and its next receive
|
||||
delivers again.
|
||||
- If the SIGTERM was already sent or the run has exited, the old session is `active` with no
|
||||
runner. The next send launches it through D8 like any idle session.
|
||||
- The successor row is never inserted, and the cancelled messages stay cancelled.
|
||||
- The launcher reads the op state right before its SIGTERM, in the same tick as its start-time
|
||||
check. A cancel after that point still finds a stopping runner, which takes the "active with
|
||||
no runner" path above. This is a residual: a cancel can't take back a SIGTERM already sent.
|
||||
- A pre-retire cancel doesn't touch the claim (duckworth-0171 C2):
|
||||
- A run that still holds it keeps it, generation and all. Releasing the fence only stops its
|
||||
receives answering `[]`.
|
||||
- A run already stopped ended its own claim through endRun, so there's nothing to change.
|
||||
- Messages already cancelled keep their `message_cancels` rows and are never delivered.
|
||||
- After `retired` and before `start-started`, no further start attempts are made.
|
||||
- The successor row and the default move stay. Nothing un-retires the old session.
|
||||
- threadPass doesn't start the successor from the clear's `thread_opens` row: a cancelled op
|
||||
suppresses that open.
|
||||
- **My reading, stated for Sage:** the successor is then an ordinary idle session. A later new
|
||||
send to it, including a thread-only send while it's the default, launches it through D8 like
|
||||
any idle session. That's new input, not the clear's startup. The other reading leaves the
|
||||
thread's default pointing at a session that can never start, because there is no verb to
|
||||
move a default (the session verbs are list and create).
|
||||
- The race with the drained receive: `drained` and `cancelled` are each one synchronous
|
||||
transaction on the open op. Whichever commits second sees the first. A receive after the
|
||||
cancel finds no open op and delivers normally. A cancel after `drained` takes the "drained, no
|
||||
SIGTERM yet" path above.
|
||||
- After `start-started` it refuses `session-clear-complete`.
|
||||
|
||||
### Early look d2 folds (duckworth-0170, 98dc69f0c)
|
||||
|
||||
- **B1, messages routed by `#sessionFor` (broker.mjs:722-748).** A role reply chain or a spec
|
||||
notice can resolve to a clearing or retired session with no state check. Rule: **cancel with a
|
||||
receipt at arrival.** The message is inserted (old results stay as evidence, D7 step 4) and its
|
||||
`message_cancels` row goes in the same transaction, so it is never delivered and never pending.
|
||||
The send's answer gains `cancelled: "session-clearing"` or `"session-retired"`, an additive key.
|
||||
- Refusing was the other option. It would lose a teammate's RESULT outright: the runner only
|
||||
logs a failed reply (harness/src/runner.mjs:575-580).
|
||||
- Routing to the successor would replay old input, which D7 forbids.
|
||||
- A spec notice resolves through `#defaultSession`. During a clear of the default session that
|
||||
is still the old session, so the notice is cancelled. After retirement the default is the
|
||||
successor, and a new notice is new input that goes there.
|
||||
- `threadsState` reports `retired`, and `threadPass` starts neither a `clearing` nor a
|
||||
`retired` session.
|
||||
- **B2, a claim racing a no-holder clear.** role.claim's thread path (broker.mjs:4827-4845)
|
||||
refuses `session-clearing` for a session with an open op and `session-retired` for a retired
|
||||
one. The runner gets refused and exits 21. Retire rechecks in its own transaction that no
|
||||
`claim` is current for the session, unless it's the proven-ended run's. The no-run proof also
|
||||
takes `lockSessionWorkspace`, which a spawned run holds before it claims.
|
||||
- **B3, op namespace.** `sessions` has `UNIQUE(thread, op)` (schema.sql:263), and the successor
|
||||
takes the clear op. Both directions refuse `session-op-conflict` at accept, before any stop:
|
||||
- `session.clear` refuses an op already in `sessions` or `session_ops` for the thread.
|
||||
- `session.create` (broker.mjs:5859-5872) refuses an op already in `session_ops`, instead of
|
||||
answering the successor row.
|
||||
- **B4** is in the schema step below (the `attempt` key).
|
||||
- **N2, name cap.** `name()` caps names at 200 characters (broker.mjs:323). When `<old>-N` would
|
||||
be longer than that, session.clear refuses `session-name-too-long` at accept and writes nothing
|
||||
(sage-2828). Test: a 199-character name clears to a 201-character `<old>-1` and is refused, and
|
||||
a 198-character name clears.
|
||||
- **N3, batch.** Drain waits for the runner's whole receive batch. Messages already delivered
|
||||
are a turn in progress, and the runner runs each of them before its next receive (:543-590).
|
||||
Only undelivered messages are cancelled. This is a residual: a batch of k delivered messages
|
||||
delays the drain by k turns.
|
||||
- **N4, D7 step 1 rechecks.** Retire rechecks owner, write grant and halt in its transaction, and
|
||||
refuses to `failed` with the code. External effects: the old run's endRun record is the stop
|
||||
evidence the op joins (its run and generation are on `drained`). No other effect is reconciled
|
||||
in 271.
|
||||
- **N5, claim end.** Retire ends the old claim only if that run's `claim` is still the latest
|
||||
`thread_claims` row for the session. If endRun already ended it, nothing is written twice. Test:
|
||||
retire after endRun, with exactly one end row.
|
||||
- **N6, pid 1.** `--as-pid-1` is in `NAMESPACES` (seat/src/sandbox.mjs:68), and `sandboxArgv`
|
||||
uses it (:168) for the runner command that session.mjs:302 builds. At build I'll confirm that
|
||||
thread runs reach session.mjs:302. Teardown on exit is kernel behaviour, and the test stays.
|
||||
- **N7**: Duckworth checks the mos-1315 list line by line at the code round.
|
||||
- **N8**: row 206 is approved at 3l→3m, so if it lands first, 271 is 3m→3n (as mos-1315 item 4
|
||||
says).
|
||||
|
||||
Cancel tests (sage-2828, duckworth-0171 C2):
|
||||
- before retirement (no drained yet; and drained with the claim held)
|
||||
- a cancel racing the drained receive, each order
|
||||
- a cancel after `start-held`: no further report, and threadPass doesn't start the successor
|
||||
- held, held, then started on one op gives three start rows with reports 1-3 and one work
|
||||
attempt (C1)
|
||||
- after retirement, with no further start
|
||||
- an idempotent repeat
|
||||
- a changed payload refused
|
||||
- after completion: `session-clear-complete`
|
||||
- an agent caller refused
|
||||
|
||||
Mutant: drop the fence release in cancel's pre-retire path. The kill is the pre-retire test's next
|
||||
send/receive.
|
||||
|
||||
Tests gained: B1 a cancel at arrival (reply chain and spec notice) for clearing and retired; B2
|
||||
a claim refused while clearing, and retire refusing a live claim; B3 both op directions; B4 a
|
||||
second `start-held` appended; N2; N5.
|
||||
|
||||
duckworth-0171 N1: the `cancelled` key on message.send is additive. At build I'll grep the
|
||||
whole-answer `deepEqual` pins on message.send in `packages/*/tests/*`. A pin breaks only where a
|
||||
test's routed message lands on a clearing or retired session, which no test does before 271.
|
||||
Any hit goes into the mos-1315 list first.
|
||||
|
||||
### Schema step (label by landing order, mos-1315 item 4)
|
||||
|
||||
- `session_ops(seq, at, business, thread, session, op, stage, attempt, successor, successor_name,
|
||||
run, generation, digest, refusal)`:
|
||||
- append-only, with `UNIQUE(thread, op, stage, attempt)` and a CHECK on `stage`
|
||||
- the stages are `requested`, `drained`, `uncertain`, `exited`, `retired`, `failed`,
|
||||
`cancelled`, `start-held`, `start-started` and `start-refused`
|
||||
- `attempt` is 0 for every stage up to `retired`, and for `failed` and `cancelled`. For the
|
||||
three start stages it is a report counter: 1 plus the number of start rows the op already
|
||||
has. Every `sessionClearStart` report bumps it, a held report included. This keys D7's
|
||||
retryable start (duckworth-0170 B4, duckworth-0171 C1).
|
||||
- The launcher's work attempt goes in its own `work_attempt` column, null outside the start
|
||||
stages. A held start spends none (D8, t1-sessions.md:274), so held, held, started share one
|
||||
work attempt and take reports 1, 2 and 3.
|
||||
- The stage CHECK pins `attempt = 0` for every non-start stage, `cancelled` included.
|
||||
- `session_ops_one_open` treats an op as open while its latest stage is `requested`, `drained`,
|
||||
`uncertain` or `exited`. `retired`, `failed` and `cancelled` close it, so a new clear on the
|
||||
same session can follow a cancelled or failed one. A cancelled op stays in the namespace (B3).
|
||||
- a trigger refuses a second open op per session
|
||||
- the envelope comes from the latest stage, and `revision` is the count of this session's rows
|
||||
- `message_cancels(seq, at, message UNIQUE, session, op)`, append-only.
|
||||
- `threadRows`'s `pending` gains `AND NOT EXISTS (cancel)`, and each row gains `state`.
|
||||
- Label: 3m if 271 lands before row 206. If 206 lands first (3l to 3m at Launchpad's 631a256ba),
|
||||
271 rebases on it and is 3m to 3n. Sage sets the order.
|
||||
|
||||
### Pre-existing test changes (mos-1315)
|
||||
|
||||
These are allowed:
|
||||
- the current schema label
|
||||
- the fabricated future step in comment-migration.test.mjs, moved up one label
|
||||
- the migration file lists, which gain the new step's file
|
||||
|
||||
No assertion is removed or loosened, and no other expected value changes. This list was measured
|
||||
on 2d0d407ba with `git grep` for `'3l'`, `3k-3l` and `'3m'` in `packages/*/tests/*`.
|
||||
|
||||
- Current label (`'3l'` becomes the new label):
|
||||
- account-migration.test.mjs:94, :144, :163, plus the rewrite source :156
|
||||
- chief-of-staff.test.mjs:641, plus the rewrite source :617
|
||||
- comment-migration.test.mjs:36
|
||||
- gitea-migration.test.mjs:98, :124, :142, plus the rewrite source :135
|
||||
- grant-migration.test.mjs:103, :128, :146, plus the rewrite source :139
|
||||
- name-migration.test.mjs:68
|
||||
- sessions.test.mjs:377, :450, plus the rewrite source :433
|
||||
- spec-migration.test.mjs:66
|
||||
- thread-migration.test.mjs:104, plus the rewrite source :52
|
||||
- usage-migration.test.mjs:71
|
||||
- A rewrite source is the `.replace("export const SCHEMA_VERSION = '3l';", ...)` string. It has
|
||||
to match store.mjs:17's new text, or the rewrite silently does nothing.
|
||||
- Fabricated future step: comment-migration.test.mjs:78-79, :86 (`3l`→`3m` becomes one label up).
|
||||
- Migration file lists:
|
||||
- comment-migration.test.mjs:82
|
||||
- the `copyFileSync` lists that copy `3k-3l.sql`: account-migration.test.mjs:86,
|
||||
gitea-migration.test.mjs:76, grant-migration.test.mjs:79
|
||||
- plus each one's sibling copy loop, read whole at the edit
|
||||
- sessions.test.mjs:435 asserts which step names the edited source holds. If its expected list
|
||||
needs the new step's file, that falls under the migration-file-list allowance. I'll read it
|
||||
whole at the edit.
|
||||
- **Restore lists (mos-1318 item 1).** Both new tables have an `at` column, so both join
|
||||
`COUNTED` in scripts/stack-release.sh:420. That is product scope for 271: a rollback with
|
||||
`--restore` counts their rows written after the backup and refuses on them, as it does for
|
||||
`sessions` and `thread_opens`. T46 (stack-release.test.mjs:913) then passes unchanged. Neither
|
||||
table goes in `untimed`, and nothing is removed from any list.
|
||||
- `session_ops`: COUNTED. Every write is a clear stage, and a restore that dropped one uncounted
|
||||
could leave a retired session's successor without its journal.
|
||||
- `message_cancels`: COUNTED. A dropped cancel would put a cancelled message back in `pending`.
|
||||
- T45's `LISTED` (stack-release.test.mjs:858) is a hand copy of COUNTED, so that a table dropped
|
||||
from the script fails T45. **Both names are added** (mos-1319). Mos counts this as the
|
||||
stricter change mos-1285 allows, because T45's loop then checks each new table on its own.
|
||||
The reviewer checks that LISTED matches COUNTED name for name: 15 names after 271, where
|
||||
there were 13. On this base COUNTED is at stack-release.sh:420. Mos measured it at :418 on
|
||||
aca16404c.
|
||||
- **Count pins (mos-1318 item 2).** schema.test.mjs:525-527 (table, trigger and view counts):
|
||||
- tables 22 to 24: `session_ops`, `message_cancels`
|
||||
- triggers 86 to 93:
|
||||
- `session_ops_no_update`, `session_ops_no_delete`, `session_ops_no_replace`
|
||||
- `session_ops_one_open` (refuses a second open op on a session)
|
||||
- `message_cancels_no_update`, `message_cancels_no_delete`, `message_cancels_no_replace`
|
||||
- views: 5, unchanged
|
||||
- The comment block above the pins gains a row 271 line, as rows 124-264a have.
|
||||
- If the build adds or drops an object, this list changes first and the reviewer checks it.
|
||||
- If row 206 (631a256ba) lands first, 271 re-adds its own objects on top of 206's counts.
|
||||
- Any other count pin found at build goes here before it's edited.
|
||||
- After the move, grep `packages/*/tests` for the old label. Only history fixtures and migration
|
||||
chains may still name it. The result is recorded here (mos-1315 item 3).
|
||||
- **TESTER_VERBS pin (mos-1325 item 1).** tester-writes.test.mjs:28-29 gains one comment line and
|
||||
the three names after 264a's `session.list`, `session.create`. The deepEqual stays exact.
|
||||
- **Engines that load the whole current store** need the new step's file. That's the
|
||||
migration-file-list allowance. account-, gitea- and grant-migration.test.mjs each gain a
|
||||
`copyFileSync` of `3l-3m.sql` after their `3k-3l.sql` copy, for their `engine-failing`, which
|
||||
keeps every step. The engines that truncate the step list (engine-3h, -3j, -3i, -3c, -3d, -3k)
|
||||
need nothing.
|
||||
- Applied on gizmoduck/row271 (edit script gizmo-271-edits/mos1315.py). The grep for `'3l'` in
|
||||
`packages/*/tests/*` afterwards has no hit. `3k-3l` stays in the migration copy lists and in
|
||||
sessions.test.mjs:435's "no 3k→3l step" assertion, both history.
|
||||
- Comment-only mentions (`3k-3l migration` in end-launch, message-filter, thread-claims, threads,
|
||||
session-launch, thread-cli, thread-launch, chat, session-compactions) are history and stay.
|
||||
|
||||
### Tests (T4, to write)
|
||||
|
||||
- clear on a held session:
|
||||
- accept fences the session
|
||||
- the run's next receive appends `drained` and answers `[]`
|
||||
- SIGTERM, then exit proof, `retired`, the successor row and the default move
|
||||
- a landing-turn run (no receive yet) is never drained or signalled
|
||||
- a run whose turn is running (delivered, unread) stays `clearing` with no signal, past 30 s
|
||||
- a non-default session clears with the default unchanged
|
||||
- `<old>-N` collision: a user session already named `<old>-1` gives `<old>-2`
|
||||
- repeat with the same payload gives the same envelope, id and name
|
||||
- conflict: a changed payload refuses `session-op-conflict`
|
||||
- a second op on a clearing session refuses `session-clearing`
|
||||
- stale revision, halted processing, retired session
|
||||
- undelivered messages are cancelled with receipts, not delivered to the successor
|
||||
- sends to a clearing session refuse
|
||||
- `uncertain` after 30 s with no proof, and no second signal
|
||||
- the successor start is held, started or refused, through check()/start()
|
||||
- the pid-namespace build check
|
||||
|
||||
## Build round 1 (code on gizmoduck/row271, not run: the slot was held)
|
||||
|
||||
Where the code departs from the design above. Each one is for Duckworth at the code round.
|
||||
|
||||
- **No `state` key in `threadsState`.** end-launch.test.mjs:193 pins a whole threadsState row, and
|
||||
a new key would be an old-test change. Instead:
|
||||
- `threadRows` drops retired sessions (broker.mjs `#threadRows`), and a cancelled message isn't
|
||||
`pending`.
|
||||
- A new trusted op, `sessionClears {business}` (broker.mjs:988), lists each open or starting
|
||||
clear: thread, instance, session, op, stage, run, generation, successor, refusal. The
|
||||
launcher reads it once per tick in `clearPass` (launcher.mjs:742), before its threads loop.
|
||||
Any session a listed clear owns (the old session while stopping, the successor while
|
||||
starting) is skipped by the normal start and idle code, so threadPass never starts or idles
|
||||
it.
|
||||
- **No `thread_opens` row at retire.** The successor's start comes from `sessionClears` (stages
|
||||
`retired`, `start-held`, `start-refused`), not from an open. A cancelled op isn't listed, so
|
||||
nothing starts its successor. A later new send gives the successor a `pending` message, and
|
||||
threadPass starts it like any idle session (sage-2830's reading).
|
||||
- **The run's end is a drain too.** `endLaunch` appends `drained` (run, generation) when the
|
||||
ending run's session has a `requested` op. A run that exits before its next receive needs no
|
||||
signal.
|
||||
- **Exit proof.** `sessionClearExited {business, thread, op, run}` refuses `run-not-ended` unless
|
||||
the run has ended. With run null, the launcher first takes and releases the session's workspace
|
||||
lock. A busy lock (a spawning run) throws and the clear waits a tick.
|
||||
- Side effect: `sessionWorkspace` creates the session's workspace directory, so a session that
|
||||
never ran gets an empty one at its clear.
|
||||
- **Restart.** `recover()` SIGKILLs an earlier host's runs and ends them `host-lost`. A run that
|
||||
outlived the kill is a row 269 survivor and is ended too. `clearStop` waits while a survivor
|
||||
with the clear's run is live (`liveRun`), and sends it nothing. When the old run is gone, the
|
||||
ended run is the proof.
|
||||
- `clearStops` is in memory, so a restarted launcher has no stop times. That's safe because
|
||||
recover() has already killed or listed every run from the earlier host, so none is in the new
|
||||
registry to signal. An `uncertain` op it finds is never signalled.
|
||||
- **Halt at retire waits.** `sessionClearRetire` throws `processing-halted` while processing is
|
||||
halted, and the op stays `exited` until it lifts. Only an owner or grant refusal appends
|
||||
`failed`.
|
||||
- **Held codes.** A successor start refused `capacity-full` or `limit-reached` reports `held`.
|
||||
Every other refusal reports `refused`, and any non-Refusal throw reports `launch-failed`. Each
|
||||
code is logged and reported once per op, not once per tick.
|
||||
- **Names stay bound.** `#nameTaken` counts any op's `successor_name`, a cancelled or failed one
|
||||
included. So a session.create of `<old>-1` after a cancelled clear refuses `session-exists`, and
|
||||
the next clear takes `<old>-2`. That's stricter than needed. Freeing a name would let a repeat
|
||||
of the cancelled op's envelope name a session that isn't its successor.
|
||||
- **Cancel after `failed`** refuses `session-clear-complete`, like cancel after `start-started`.
|
||||
- **message.send** answers `cancelled: "session-clearing"` or `"session-retired"` only when its
|
||||
route lands on such a session (B1). N1 grep of whole-answer pins is still to do.
|
||||
|
||||
### Tests added to T4 (sage-2830)
|
||||
|
||||
- After a post-retire cancel, a thread-only send to the thread (the successor is the default)
|
||||
launches the successor through D8 as new input. The clear's latest row stays `cancelled`, and
|
||||
no `start-*` row is appended.
|
||||
- After a restart, a live survivor of the cleared run holds the clear at `drained` with no
|
||||
signal. Its exit lets the clear retire.
|
||||
|
||||
### Tester grant (mos-1325)
|
||||
|
||||
The brief's section 3 says tester writes aren't granted by default and use fixtures only. 271
|
||||
grants the tester `session.clear`, `session.cancel` and `session.operation` because the Q173 live
|
||||
check has to clear a tester thread through the tester socket. Without the grant, D7 can't be
|
||||
proved live by an agent tester.
|
||||
- The three verbs act only on the tester's own threads. Each resolves its thread with `#thread`
|
||||
(broker.mjs:871-877), which refuses `thread-not-owned` unless the thread's owner is the caller's
|
||||
`#owner`, and a tester's owner is `tester` (:879-880). So a tester can't reach a human's or a
|
||||
user's thread.
|
||||
- Test: a tester's `session.clear`, `session.cancel` and `session.operation` on a thread a human
|
||||
owns each refuse `thread-not-owned` and write nothing (no `session_ops` or `message_cancels`
|
||||
row).
|
||||
- The R3 user gate refuses all three for a user (user-principal.test.mjs:133 spreads
|
||||
TESTER_VERBS). Those refusals are this row's own checks (mos-1325 item 3).
|
||||
|
||||
### N1 and N6 at build
|
||||
|
||||
- **N1.** message.send adds `cancelled` only when `#route` returns a code (broker.mjs:5505,
|
||||
`if (cancelled) return { id: mid, request, cancelled }`). Every other send answers exactly what
|
||||
it did before. `#route` returns a code only for a session with an open or retired clear, and
|
||||
before 271 no test can make one. So no whole-answer pin on message.send can change, and nothing
|
||||
joins the mos-1315 list.
|
||||
- **N6.** The launcher's only spawn is `spawnSession` at launcher.mjs:574, inside `start()`
|
||||
(:422), which runs every session, thread runs included. spawnSession (seat/src/session.mjs:300-302)
|
||||
builds the runner argv through `sandboxArgv`, whose argv starts with `NAMESPACES` (sandbox.mjs:168),
|
||||
and `NAMESPACES` holds `--unshare-pid` and `--as-pid-1` (:68). The bwrap test stays in T4.
|
||||
|
||||
### Tests written (round 1, not run: the slot was held)
|
||||
|
||||
New files only. No old test changes beyond the mos-1315 list above.
|
||||
|
||||
- `packages/bus/tests/session-clear.test.mjs` (15 tests), the bus half:
|
||||
- held default: accept fences send, open and claim; the receive drains once; no proof before the
|
||||
run's end; retire, successor row, default move; threadsState drops the retired session
|
||||
- unheld session: drained at accept, proof with run null; a non-default clear keeps the default
|
||||
- a run that ends before its next receive drains at its end, with its generation
|
||||
- delivered and unread: stays `clearing`, `not-drained` for uncertain and proof; uncertain once;
|
||||
later proof resumes
|
||||
- B1: undelivered inputs cancelled at accept with receipts; a routed reply answers `cancelled`
|
||||
`session-clearing`, later `session-retired`; the successor gets none
|
||||
- repeat, conflict, second op, stale revision, confirm, halt (writes nothing), B3 both ways
|
||||
- Q4 `<old>-N` with a taken `default-1`, and a cancelled op's name staying bound
|
||||
- D8 start counters: held, refused, held, started, with attempt and work_attempt; complete after
|
||||
- halt at retirement waits and writes nothing
|
||||
- cancel before retirement (sage-2828); cancel after retirement and the thread-only send to the
|
||||
successor (sage-2830); cancel after started
|
||||
- B2 claim refusal and the claim after a cancel
|
||||
- mos-1325 tester: own thread clears; clear, cancel and operation on a human thread refuse
|
||||
`thread-not-owned` and write nothing; agents refuse `human-required`
|
||||
- append-only triggers, `session_ops_one_open`, CHECKs and UNIQUEs below the broker
|
||||
- `packages/cli/tests/session-clear.test.mjs` (3 tests, bwrap), the launcher half, with
|
||||
`fixtures/clear-adapter.mjs` (session-adapter's turn plus /proc pids and pid 1's argv):
|
||||
- a running session: one `session-clear-stop`, end, retired, successor launch in that order; the
|
||||
successor starts fresh in its own workspace; N6 pid namespace check on the first turn
|
||||
- held: capacity-full reported `held` and logged once, started when the side session idles out
|
||||
- restart (sage-2830, Q2): a live survivor holds the clear at `drained` with no signal; its exit
|
||||
lets the clear retire and start
|
||||
|
||||
Open, still to write before the code round:
|
||||
|
||||
- A `failed` clear (N4: a user's thread whose grant is revoked before retirement) and cancel after
|
||||
`failed` refusing `session-clear-complete`.
|
||||
- The launcher's `uncertain` path (a run that ignores SIGTERM past `stopTimeoutMs`). It needs an
|
||||
adapter that traps TERM, and a short stopTimeoutMs.
|
||||
- Mutants, at the run: the fence (`#sessionFence` in role.claim, thread.send, thread.open), the
|
||||
drain at receive, the survivor wait in clearStop, CLEAR_HELD, and the `cancelled` filter in
|
||||
`#threadRows`.
|
||||
|
||||
### N4 failed clear (round 1, not run)
|
||||
|
||||
Test "T4 N4: a user's thread whose grant is gone by retirement..." in packages/bus/tests/session-clear.test.mjs.
|
||||
Finding for Duckworth: the `failed` path in sessionClearRetire (broker.mjs:1137, #ownerRefusal at broker.mjs:1191)
|
||||
has no verb route in 271. A user can't call session.clear (USER_VERBS, broker.mjs:138, gate at broker.mjs:4452
|
||||
gives access-refused), and the human or tester gets thread-not-owned on a user's thread. Tester and human threads
|
||||
always pass #ownerRefusal while the business names them. So the test writes the op's requested and drained rows
|
||||
directly, as the accept would, then runs exited and retire with the grant removed. The path becomes reachable
|
||||
when a user verb for clear lands (264e or later). Question for review: keep the path as a guard, or drop it until then.
|
||||
|
||||
### Launcher uncertain path (round 1, not run)
|
||||
|
||||
Test "T4 D7: a drained run that outlives the stop timeout..." in packages/cli/tests/session-clear.test.mjs. The test
|
||||
drives the ticks (tickMs one hour, stopTimeoutMs 1000) and SIGSTOPs the runner after the drain and before the tick
|
||||
that sends the TERM, so the TERM stays pending. It checks one stop, one uncertain, no force, then SIGCONT and
|
||||
retired plus started. Open: mutants (fence in claim/send/open, drain at receive, survivor wait, CLEAR_HELD,
|
||||
the cancelled filter in #threadRows, the uncertain once-only branch at launcher.mjs:788).
|
||||
|
||||
### Mutants (round 1, defined, not run)
|
||||
|
||||
Each mutant is one edit on the 271 tree, and each kill is an assertion failure in a named test, not a timeout
|
||||
(sage-2707). Lines are on gizmoduck/row271-r2 at e567f949c, after the rebase onto 0cbe2e9b3 and the B1
|
||||
fix, which put one comment line above broker.mjs:1194 (launcher.mjs didn't move).
|
||||
|
||||
| # | File:line | Edit | Expected kill |
|
||||
| --- | --- | --- | --- |
|
||||
| M1 | broker.mjs:5158 | drop `#sessionFence(s.session)` in role.claim | bus T4 B2 (claim on a clearing session gives session-clearing) |
|
||||
| M2 | broker.mjs:6146 | drop the fence in thread.send | bus T4 D7 held default (send during the clear refuses session-clearing) |
|
||||
| M3 | broker.mjs:6134 | drop the fence in thread.open | bus T4 D7 held default (open refuses) |
|
||||
| M4 | broker.mjs:6287 | drop the fence in session.clear | bus T4 D7 repeat/conflict: a second op refuses session-clearing. The session_ops trigger (one open clear) is a second fence and would refuse with its own text, so the kill is the code check, not "refused at all" (mutant-masked-by-second-fence) |
|
||||
| M5 | broker.mjs:5599 | receive returns [] without writing drained | bus T4 D7 held default (stages after receive) |
|
||||
| M6 | broker.mjs:1319 | drop the drain at endLaunch | bus T4 D7 run ends before its next receive |
|
||||
| M7 | broker.mjs:6298 | always skip the drain at accept | bus T4 D7 unheld session |
|
||||
| M8 | launcher.mjs:798 | drop the survivor wait | cli T4 D7 Q2 survivor (stage stays drained, no stop) |
|
||||
| M9 | launcher.mjs:815 | CLEAR_HELD empty | cli T4 D8 held (start held, one log line) |
|
||||
| M10 | broker.mjs:1209 | drop the message_cancels filter in #threadRows | cli T4 D7 running session (cancelled input would launch D again) or bus B1; the run decides which, both are named |
|
||||
| M11 | launcher.mjs:788 | `stage === "drained"` becomes `true` | cli T4 D7 uncertain (one uncertain event); bus T4 D7 delivered-unread keeps its broker-side once |
|
||||
| M12 | broker.mjs:1137 | `#ownerRefusal` returns null | bus T4 N4 failed clear |
|
||||
| M13 | broker.mjs:1195 | drop the `'session.clear'` verb arg from `#ownerRefusal`'s `#granted` call (the r2 defect, duckworth-0201 B1) | bus T4 N4 live grant (refusal access-refused, state failed) |
|
||||
|
||||
duckworth-0201 B1 (early look on 911b90903): `#ownerRefusal` called `#granted(s, project, instance)`
|
||||
against the signature `#granted(s, verb, project, instance)` at broker.mjs:919, so the project sat in
|
||||
the verb slot and the instance in the project slot, and a user's thread with a live grant failed
|
||||
retirement with access-refused. The N4 test revoked the grant first, so it could not tell the two
|
||||
apart. The call now passes `'session.clear'`, which is not in VIEWER_VERBS, so the recheck is at
|
||||
collaborator. The N4 setup is a shared helper, the revoked case is unchanged, and a second test keeps
|
||||
the grant and expects retired plus the successor. M13 restores the defect. Both N4 tests are new 271
|
||||
tests, so mos-1292 doesn't apply.
|
||||
|
||||
M10's kill is a guess until it runs; if neither test fails, I add a pending-count assertion and report it as a
|
||||
first-run change under mos-1272.
|
||||
|
||||
## Rebase onto row 206: schema label 3m to 3n (sage-2968)
|
||||
|
||||
Row 206 landed 0cbe2e9b3 on refactor at 19:47Z with schema step 3l to 3m (thread_shares), and
|
||||
origin/gizmoduck/row271 at 74b5e3638 also set 3m (Mos found the collision in mos-1335). The branch is
|
||||
rebased on 0cbe2e9b3 as gizmoduck/row271-r2, a new name, so nothing is force-pushed. The rebase took
|
||||
`git rebase --onto 0cbe2e9b3 2d0d407ba`, since the 269 commits under 271 landed as 269 r3. Row 271
|
||||
becomes step 3m to 3n:
|
||||
|
||||
- `packages/bus/src/migrations/3m-3n.sql` is the old 271 `3l-3m.sql`, byte for byte. `3l-3m.sql` is
|
||||
206's.
|
||||
- store.mjs: `SCHEMA_VERSION = '3n'`, the 3m to 3n step after 206's, `session_ops` and
|
||||
`message_cancels` stay last in `timedTables`, and `SCHEMA_SHA256` is the sha256 of the new
|
||||
schema.sql (a2ba3a46...).
|
||||
- schema.sql is refactor's with the 271 objects appended after 206's, unchanged.
|
||||
- schema.test.mjs:526-529: 25 tables and 96 triggers, 206's 23 and 89 plus 2 and 7. `grep -c` in
|
||||
schema.sql gives 25 `CREATE TABLE` and 97 `CREATE TRIGGER`; at 0cbe2e9b3 it gives 23 and 90 against
|
||||
the pinned 89, so the extra hit is in the base file and the step adds 7.
|
||||
- `packages/bus/tests/fixtures/schema-3m.sql` equals refactor's schema.sql at 0cbe2e9b3 (sha256
|
||||
d5ca9bb9...).
|
||||
- stack-release.sh:420 COUNTED and stack-release.test.mjs:861 LISTED: both tables after 206's
|
||||
`thread_shares`, at the end of the list as before.
|
||||
|
||||
New tests in packages/bus/tests/session-clear.test.mjs, written like 206's in thread-shares.test.mjs:
|
||||
- "T4 schema: a 3m store migrates to 3n": a 3m store from the fixture, with a row in each thread table
|
||||
and a share, migrates. Both tables are new and empty, every other row stays, the migrated
|
||||
sqlite_master equals a fresh store's, both tables refuse update, delete and replace, the one-open
|
||||
trigger refuses a second open clear, and a cancel naming no message refuses on its foreign key.
|
||||
- "T4 schema: the 3m engine refuses a 3n store": the 3m engine refuses with and without
|
||||
migrateSchema and leaves the store as it was, and it opens a fresh 3m store.
|
||||
|
||||
### Pre-existing test changes on the rebase
|
||||
|
||||
The mos-1315 kinds, one label up:
|
||||
- Current label `'3m'` becomes `'3n'`: account-migration.test.mjs:98, :148, :167 and rewrite source
|
||||
:160; gitea-migration.test.mjs:102, :128, :146 and :139; grant-migration.test.mjs:107, :132, :150
|
||||
and :143; chief-of-staff.test.mjs:641 and :617; comment-migration.test.mjs:36; name-migration :68;
|
||||
spec-migration :66; usage-migration :71; thread-migration :104 and :52; sessions.test.mjs:377,
|
||||
:450 and :433; thread-shares.test.mjs:406, :459 and the engine3l rewrite source :442.
|
||||
- The offline command's ready line, `bus schema 3m ready` becomes `3n`: comment-migration.test.mjs:112,
|
||||
thread-migration.test.mjs:164. My first build missed these two (`3l ready` then), so the first run
|
||||
would have shown them red.
|
||||
- The future step in comment-migration.test.mjs:78-79 moves to 3n to 3o, :86 expects `'3o'`, and the
|
||||
:82 file list gains `3m-3n.sql`.
|
||||
- Engine copies: account- :90, gitea- :80 and grant-migration.test.mjs :83 copy `3m-3n.sql` after
|
||||
206's `3l-3m.sql`.
|
||||
|
||||
Changes outside the mos-1315 list, the same kind 206 made for thread_shares. Mos approved all three
|
||||
under mos-1292 in mos-1336 (19:58Z). Conditions: neither name list grows past these two names without
|
||||
Mos, and a gate red in these files outside the listed lines goes to Mos:
|
||||
- `later3l` in account- :21-22, gitea- :38-39 and grant-migration.test.mjs :24-25 gains
|
||||
`session_ops` and `message_cancels`, with a comment line. These tests compare sqlite_master before
|
||||
and after older steps, and the 3m to 3n objects are new there just as thread_shares is. My first
|
||||
build missed this too.
|
||||
- `rows()` in gitea- :43 and grant-migration.test.mjs :45 leaves out both tables, as it leaves out
|
||||
thread_shares.
|
||||
- thread-shares.test.mjs:410-414: 206's 3l migration test now ends at 3n, so after it asserts
|
||||
thread_shares empty it asserts session_ops and message_cancels empty and drops them before the
|
||||
whole-table compare. Nothing it checked is removed.
|
||||
|
||||
A grep for `'3m'` in these files afterwards has no hit. session-clear.test.mjs's 3m engine is new. This is a rebase,
|
||||
so the next run reruns every touched file whole.
|
||||
|
||||
## Run limits (mos-1275, sage-2991)
|
||||
|
||||
The gate on e567f949c (sage-2991). Each node --test file and suite runs under `timeout -k 10 LIMIT`, and
|
||||
each scripts/test-*.sh under at least 3x its longest recorded run. The records are from
|
||||
agents/gizmoduck/work/queue-269/BUILD.md "Run limits", plus row 269's r3 gate.
|
||||
|
||||
| Run | Longest recorded | LIMIT |
|
||||
| --- | --- | --- |
|
||||
| stack-release.test alone | 575 s (264a r12 survey) | 1800 s |
|
||||
| each other touched file alone | 15 s (thread-launch.test) | 300 s |
|
||||
| bus suite | 23 s | 900 s |
|
||||
| seat suite | none recorded | 600 s |
|
||||
| cli suite | 591 s (row 281 gate) | 1800 s |
|
||||
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
|
||||
| each mutant run (one session-clear file whole) | none recorded | 300 s |
|
||||
|
||||
A timeout with no failed test is a hang and goes to Sage. A timeout in the shell phase goes to Mos
|
||||
unplaced (mos-1275). A first red in a new 271 test is placed and reported after (mos-1272). A second
|
||||
red, a red in an old test outside the mos-1336 lines, or a fix that needs a product change outside the
|
||||
manifest or a looser assertion stops the run and goes to Mos and Sage (sage-2991). Mutants run only on
|
||||
a green base.
|
||||
|
||||
## Gate on e8af389e6 (sage-2991)
|
||||
|
||||
The code on e8af389e6 is the same as on e567f949c. The only difference is the BUILD.md "Run limits"
|
||||
section. The run went from 20:31:19Z to 20:56:32Z (1513 s wall), on base 0cbe2e9b3 with origin/refactor
|
||||
at f2735bb6a, whose drift touches only docs/plans/QUEUE.md and queue.json. The logs are in
|
||||
/mnt/storage/scratch/gizmo-271-out/gate/.
|
||||
|
||||
- The 18 touched test files, each run whole, all pass with 0 fail and 0 skip. Among them, bus
|
||||
session-clear 19/19, cli session-clear 4/4, stack-release.test 218/218, thread-shares 16/16 and seat
|
||||
compactions 20/20. Phase wall 596 s.
|
||||
- Suites: bus 873/873 (24 s), seat 192/192 (6 s), cli 873/873 (588 s).
|
||||
- scripts/test-*.sh: all 10 pass. auth 15, conductor 17, config 31, discord 81, extension-package 18,
|
||||
foundation 44, land-work 86, queue 156 node tests plus 28, release 14, task 91. The skips are
|
||||
test-queue's verify/render --check (this worktree isn't the canonical root) and test-task's live
|
||||
provider cases (row 244). test-task has no Docker skip.
|
||||
- The tree was clean after the tests and after each mutant restore. No node process was left with its
|
||||
cwd in the worktree.
|
||||
- First-run reds: none, so nothing was placed under mos-1272.
|
||||
|
||||
Each mutant ran with bus and cli session-clear.test whole. Every kill is an AssertionError in the
|
||||
test this section named, never a timeout:
|
||||
|
||||
| # | Killed by |
|
||||
| --- | --- |
|
||||
| M1 | bus T4 B2 and T4 D7 held default |
|
||||
| M2 | bus T4 D7 held default and T4 D7 delivered-unread |
|
||||
| M3 | bus T4 D7 held default |
|
||||
| M4 | bus T4 D7 repeat/conflict: `storage-refused` where `session-clearing` was expected, so the code check failed and the trigger was the only fence left. Also T4 D7 held default (`session-retired` expected, got null) |
|
||||
| M5 | bus T4 D7 held default and delivered-unread; cli T4 D7 uncertain, D7 D8 N6 running session, and D7 Q2 survivor |
|
||||
| M6 | bus T4 D7 run ends before its next receive |
|
||||
| M7 | bus T4 D7 unheld session (stages `['requested']` where `['requested','drained']` was expected), and 7 later bus tests, 5 of them on `not-drained` or `session-revision-stale` refusals that follow from the missing drain; cli T4 D8 capacity-full |
|
||||
| M8 | cli T4 D7 Q2 survivor |
|
||||
| M9 | cli T4 D8 capacity-full held |
|
||||
| M10 | bus T4 D7 B1 (cancelled inputs never reach the successor). The cli running-session test passes, so no pending-count assertion was added |
|
||||
| M11 | cli T4 D7 uncertain |
|
||||
| M12 | bus T4 N4 grant gone |
|
||||
| M13 | bus T4 N4 grant live |
|
||||
|
||||
Open: the row's gate names tests T4 and T6 (queue row 271). This build maps T4. Of T6, these are
|
||||
covered: clear under a kill or restart (cli D7 Q2 survivor, D7 uncertain), grant revocation (bus N4),
|
||||
limits (cli D8 capacity-full held) and a halt at retirement (bus D7 N4 halt). These are not: a Q198
|
||||
hold, a halt between reservation, spawn and bind, and a manager configured but unavailable. That goes
|
||||
to Sage before the in-review move.
|
||||
|
||||
## Round 3: T6 (sage-3001, duckworth-0209, duckworth-0210)
|
||||
|
||||
Sage ruled the three T6 items left open above. Written with no runs; the r3 gate runs on a CLEAR.
|
||||
|
||||
- Halt between reservation, spawn and bind: covered by citation. session-launch.test.mjs:372 (the
|
||||
halt fence, C2) halts once after check() and once inside its recheck, and neither attempt spawns.
|
||||
A successor goes through the same path: clearStart at launcher.mjs:824 calls `check()` and
|
||||
`start()` unchanged, so the fence that test pins is the successor's fence too.
|
||||
- Q198 hold: binds row 257 (Morgana). Row 271 has no Q198 code to test: 257 hasn't landed, and
|
||||
packages/*/src holds no pressure or Q198 code on this branch or on origin/refactor f2735bb6a.
|
||||
Carry for 257 (duckworth-0210): 257 either puts its hold code in CLEAR_HELD (launcher.mjs:815) or
|
||||
rules that a Q198 hold counts as a refusal, and either way it adds a clear-path test.
|
||||
- Manager configured but unavailable: a new cli test, "T4 T6: a bus unavailable at the exit proof
|
||||
and then at retirement" (packages/cli/tests/session-clear.test.mjs). The test's host op rejects
|
||||
with `broker-exited`, the code host.mjs:303 gives when the broker exits before it replies. It is
|
||||
down first at `sessionClearExited`, then only at `sessionClearRetire`. While it is down the test
|
||||
expects no retired or failed event, no launch but D's, nothing in the session registry (the
|
||||
successor takes no slot) and D still the thread's only and default session. The stage holds at
|
||||
drained, then at exited. The failure is logged once in all, and no `thread tick:` line appears.
|
||||
Once the bus answers, the clear retires and the successor starts, in the same event order as the
|
||||
running-session test.
|
||||
|
||||
Reading of "capacity release": the launcher counts capacity from its registry of running sessions
|
||||
(`limitHit`, launcher.mjs:403). D's run really ends, so its slot is free on the host either way;
|
||||
what an unproven stop must not do is hand that slot to the successor. The test pins that.
|
||||
|
||||
| # | Where | Mutant | Killed by |
|
||||
| --- | --- | --- | --- |
|
||||
| M14 | launcher.mjs:755-759 | drop clearPass's per-clear try/catch, so a failed op leaves the pass and tick() | cli T4 T6 bus unavailable: the first `lines()` assertion. The clear's line is never logged; the ticker's catch logs `thread tick:` instead |
|
||||
|
||||
The harness the gate applies, M1-M14 with their exact anchors, is committed beside this file as
|
||||
mutants.py (duckworth-0212 C1). `check` counts each anchor; `apply <id>` edits the worktree.
|
||||
|
||||
r3 gate, on a CLEAR (sage-3001): the touched files run whole, the cli suite runs whole, and M14.
|
||||
No src file changed in r3, so the full gate isn't due again. A first-run red in the new test is
|
||||
placed under mos-1272 and reported after; a fix that needs a product change goes to Sage and Mos
|
||||
before any edit.
|
||||
|
||||
## r3 gate on 9e18a9aaf (sage-3017)
|
||||
|
||||
Run 21:16:37Z to 21:27:04Z (627 s wall), TMPDIR /mnt/storage/scratch/tmp/gJmI, limits as in "Run
|
||||
limits" above (300 s per file and per mutant run, 1800 s for the cli suite). origin/refactor was
|
||||
f2735bb6a. No src file differs from e567f949c, so the full gate wasn't due (sage-3001).
|
||||
|
||||
| Run | Result | Wall |
|
||||
| --- | --- | --- |
|
||||
| cli session-clear.test whole | 5/5 pass | 12 s for both files |
|
||||
| bus session-clear.test whole (r3 touched no other test; the bus half of the clear) | 19/19 pass | |
|
||||
| cli suite whole | 874/874 pass, 0 skipped or todo | 585 s |
|
||||
| M14, bus session-clear.test | 19/19 pass (M14 is launcher-only) | 30 s for both |
|
||||
| M14, cli session-clear.test | 1 fail: the T6 test | |
|
||||
|
||||
- First-run reds: none. The new T6 test passed on its first run, so nothing was placed under mos-1272.
|
||||
- M14 kill: an AssertionError at session-clear.test.mjs:365, the T6 test's first `lines()`
|
||||
assertion, as named above. The ticker's catch logged `thread tick: broker exited before it
|
||||
replied` once per tick and the clear's own line never appeared.
|
||||
- The tree was clean after the tests and after the M14 restore; launcher.mjs matched HEAD. No node
|
||||
process was left with its cwd in the worktree.
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 271 mutants M1-M14 (BUILD.md "Mutants", lines at e567f949c; M14 is r3's). `apply <id>` edits the
|
||||
# worktree and prints the touched path; each edit asserts its anchor appears exactly once.
|
||||
import sys
|
||||
WT = "/mnt/storage/scratch/gizmo-270/"
|
||||
B = "packages/bus/src/broker.mjs"
|
||||
L = "packages/cli/src/launcher.mjs"
|
||||
M = {
|
||||
"M1": (B, " this.#sessionFence(s.session);\n if (this.#sessionHolder(s.session)?.op === 'claim') fail('thread-already-held');",
|
||||
" if (this.#sessionHolder(s.session)?.op === 'claim') fail('thread-already-held');"),
|
||||
"M2": (B, " // Row 271 (264d, D7 step 1): nor sent to, by name or as the default.\n this.#sessionFence(session);\n",
|
||||
" // Row 271 (264d, D7 step 1): nor sent to, by name or as the default.\n"),
|
||||
"M3": (B, " // Row 271 (264d, D7 step 1): a session being cleared, or a retired one, isn't opened.\n this.#sessionFence(session);\n",
|
||||
" // Row 271 (264d, D7 step 1): a session being cleared, or a retired one, isn't opened.\n"),
|
||||
"M4": (B, " this.#processingWork();\n this.#sessionFence(session);\n if (this.#revision(session) !== a.revision)",
|
||||
" this.#processingWork();\n if (this.#revision(session) !== a.revision)"),
|
||||
"M5": (B, " if (!this.#opRow(open.thread, open.op, 'drained')) this.#appendOp(open, 'drained', { run: s.run, generation: this.#runGeneration(s) });\n return [];",
|
||||
" return [];"),
|
||||
"M6": (B, " if (open?.stage === 'requested') this.#appendOp(open, 'drained', { run: session.run, generation: h.generation });\n", ""),
|
||||
"M7": (B, " if (this.#sessionHolder(session)?.op !== 'claim') this.#appendOp(op, 'drained');",
|
||||
" if (false && this.#sessionHolder(session)?.op !== 'claim') this.#appendOp(op, 'drained');"),
|
||||
"M8": (L, " if (survivors.some((x) => x.business === b.id && x.run === c.run && liveRun(x))) return;\n", ""),
|
||||
"M9": (L, 'const CLEAR_HELD = new Set(["capacity-full", "limit-reached"]);', 'const CLEAR_HELD = new Set([]);'),
|
||||
"M10": (B, "\n AND NOT EXISTS(SELECT 1 FROM message_cancels x WHERE x.message=m.id)) AS pending,", ") AS pending,"),
|
||||
"M11": (L, '} else if (stage === "drained" && now - clearStops', '} else if (true && now - clearStops'),
|
||||
"M12": (B, " const refusal = this.#ownerRefusal(t);", " const refusal = null;"),
|
||||
"M13": (B, "this.#granted({ ...s, grants: this.#userGrants(s) }, 'session.clear', t.project, t.instance)",
|
||||
"this.#granted({ ...s, grants: this.#userGrants(s) }, t.project, t.instance)"),
|
||||
"M14": (L, " try {\n await (start ? clearStart(c, legacyThreads) : clearStop(c, now));\n } catch (e) {\n clearLog(c.op, e.code ?? e.message, `clear ${c.op} (session ${c.session}): ${e.code ?? e.message}`);\n }\n",
|
||||
" await (start ? clearStart(c, legacyThreads) : clearStop(c, now));\n"),
|
||||
}
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "list":
|
||||
print(" ".join(M)); sys.exit(0)
|
||||
if cmd == "check":
|
||||
for k, (f, a, b) in M.items():
|
||||
n = open(WT + f).read().count(a)
|
||||
print(k, f, n)
|
||||
sys.exit(0)
|
||||
f, a, b = M[sys.argv[2]]
|
||||
s = open(WT + f).read()
|
||||
assert s.count(a) == 1, (sys.argv[2], s.count(a))
|
||||
open(WT + f, "w").write(s.replace(a, b))
|
||||
print(f)
|
||||
@@ -0,0 +1,310 @@
|
||||
# Row 203, FED R2: the federation key in the system config (#1701)
|
||||
|
||||
Owner Quackerjack, reviewer Webby, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md,
|
||||
"Brief: FED R2". Design: DESIGN.md section 3.1 (D1), section 4 (the key shape), section 7 row R2, at
|
||||
gizmoduck/federation-design b93f098f0.
|
||||
|
||||
Base: aca16404c, rebased onto f2735bb6a (origin/refactor, shared object store). Rebased onto 253's tip
|
||||
only if 253 lands first, under a new branch name.
|
||||
|
||||
## Commits
|
||||
|
||||
- 0eb4b6f1f, rebased to e704a18ae: the loader, the first FED R2 tests, and these notes.
|
||||
- Second commit on top of e704a18ae (sage-2998): B1 and B2 below, the fixture fix, and the notes
|
||||
rewrite. Its sha goes in the status line once committed.
|
||||
|
||||
## Shape
|
||||
|
||||
```json
|
||||
"federation": {
|
||||
"self": "hub",
|
||||
"peers": [{ "id": "factory", "origin": "https://mosaic.woltje.com", "label": "Software factory" }]
|
||||
}
|
||||
```
|
||||
|
||||
- Optional. Absent: the config loads and prints what it printed before (no `federation` in the output).
|
||||
- `self` and each `peers[].id` match `^[a-z][a-z0-9-]{0,31}$`. A peer id may not equal `self` or another peer's id.
|
||||
- `peers[].origin` must be an https origin exactly as `new URL(origin).origin` returns it: no path, no query,
|
||||
no userinfo, no trailing slash.
|
||||
- `peers[].label` is a non-empty string of at most 80 characters.
|
||||
- Labels and URLs only. Nothing here grants trust to a peer (section 3.1 D1, section 3.2 W1).
|
||||
|
||||
## Readers of the key
|
||||
|
||||
Two readers read the raw key. Nothing else reads it (`git grep "federation"` at e704a18ae):
|
||||
|
||||
1. The loader, `scripts/mosaic-config.mjs` (`validate`, the `federation` branch). This is the only reader of
|
||||
the validated value. Its output is read by the CLI and business code, which read named fields only.
|
||||
The output is checked in `scripts/test-config.sh`.
|
||||
2. The stack-release probe, `config_checks` in `scripts/stack-release.sh` (B1, below). It reads the config
|
||||
file itself, before a copy is activated or rolled back to, and asks whether the target copy's loader
|
||||
refuses a key the config holds.
|
||||
|
||||
Stale wording, NOT changed in this row (Sage rulings sage-2808 and sage-2809):
|
||||
`packages/cli/src/access.mjs` is out of row 203. Megavolt's row 207 changes that file. Follow-up, to be
|
||||
carried to DEFERRED by Sage:
|
||||
- access.mjs lines 26-27 (`ACCESS_HELP` comment): "Peer homes are empty until the federation key lands
|
||||
(row 203)". Still partly true after this row, since the access context does not read the key.
|
||||
- access.mjs line 36 (inside the `ACCESS_HELP` string, user-visible help): "Peer homes are not read yet
|
||||
(row 203 adds the federation key)". The same claim, in help text. No test in `packages/cli/tests` pins it
|
||||
(`git grep` for "row 203" and "Peer homes" finds nothing in tests).
|
||||
- access.mjs line 44 comment: "peers is empty until row 203".
|
||||
- `packages/cli/README.md` line 1107: "until row 203 adds the". Outside the manifest; flagged in the
|
||||
same follow-up.
|
||||
|
||||
Behavior after this row: `accessContextFor` still returns `peers: []` (access.mjs line 58). Row 203 adds the
|
||||
config key only, so grants naming a peer home still refuse. Wiring the key into the access context is not in
|
||||
the FED R2 brief.
|
||||
|
||||
## B1: the stack-release probe (sage-2998, in scope)
|
||||
|
||||
Webby's early look (webby-0189): a config holding `federation` and no `console` passed `config_checks`, because
|
||||
the probe looked only for `console`. A rollback or activate to a pre-203 copy would then pass its checks, and
|
||||
that copy's loader refuses `unsupported configuration key: "federation"` on the next read.
|
||||
|
||||
Fix (scripts/stack-release.sh, `config_checks`):
|
||||
- The probe's node check now reads `console` or `federation` (`Object.hasOwn(d,"console")||Object.hasOwn(d,"federation")`).
|
||||
- The refusal keeps the check name `config-console-key`, so existing callers and tests do not change. The
|
||||
message now reads "holds a console or federation key". This is a wording change to an existing refusal,
|
||||
and `KEY_MSG` in `packages/cli/tests/stack-release.test.mjs` moves with it.
|
||||
- The new test is `row 203: a config whose federation key the target loader refuses ...` in
|
||||
`packages/cli/tests/stack-release.test.mjs`, after the row 200 console test. Target B's loader has no
|
||||
federation key in its list, so it refuses the key. Activate to B and rollback to B are both refused, and
|
||||
a rollback succeeds once the key is removed.
|
||||
|
||||
Fixture (packages/cli/tests/stack-release-fixture.mjs, line 300): `LOADER_OLD` builds B's loader by a string
|
||||
replace on the `rejectUnknownKeys` list. Adding `federation` to that list in this row left the replace with no
|
||||
match, and the fixture's `assert.notEqual(LOADER_OLD, LOADER)` would fail at load. The replace now targets
|
||||
`"execution", "console", "federation"]`, so B's loader refuses both keys. This regression came from my
|
||||
e704a18ae, and the fix is in this commit.
|
||||
|
||||
## B2: a case and a mutant for each refusal (sage-2998: "one mutant per refusal")
|
||||
|
||||
Webby's list is covered below. Each refusal has a case in `scripts/test-config.sh` (FED R2 block) and a
|
||||
mutant. M5 is killed by the self case; M13 covers the duplicate half of the id check that M5 did not reach.
|
||||
|
||||
## Refusals and their tests
|
||||
|
||||
`scripts/test-config.sh`, FED R2 block:
|
||||
|
||||
| Case | Refusal (exit 2) | Mutant (killed by) |
|
||||
|---|---|---|
|
||||
| valid key loads and is in the output | none | base |
|
||||
| unknown top-level sub-key | `unsupported federation key: "extra"` | M1 |
|
||||
| unknown peer sub-key | `unsupported federation.peers[0] key: "trust"` | M2 |
|
||||
| origin over http | `federation.peers[0].origin must be an https origin ...` | M3 |
|
||||
| origin with a path | same | M4 |
|
||||
| origin with userinfo | same | M4 |
|
||||
| origin with a trailing slash | same | M4 |
|
||||
| peer id equal to self | `federation.peers[0].id ... is already named` | M5 |
|
||||
| duplicate peer id | `federation.peers[1].id "factory" is already named` | M13 |
|
||||
| peer label empty | `federation.peers[0].label must be a non-empty string ...` | M6 |
|
||||
| peer label over 80 characters | `federation.peers[0].label must be ...` | M12 |
|
||||
| federation not an object | `"federation" must be a JSON object` | M7 |
|
||||
| self does not match the pattern | `federation.self must match ...` | M8 |
|
||||
| peers not an array | `federation.peers must be an array` | M9 |
|
||||
| peer not an object | `federation.peers[0] must be a JSON object` | M10 |
|
||||
| peer id does not match the pattern | `federation.peers[0].id must match ...` | M11 |
|
||||
| self missing | `federation.self must match ...` | M15 |
|
||||
| peer id missing | `federation.peers[0].id must match ...` | M16 |
|
||||
| peer label missing | `federation.peers[0].label must be ...` | M17 |
|
||||
| no federation key | loads, no federation output | base |
|
||||
| B1: target loader refuses federation | `config-console-key` refusal (stack-release.test) | M14 |
|
||||
|
||||
Mutant plan (run on Sage's gate only, one edit each, a named test must fail). Before the gate each edit string
|
||||
must occur exactly once in its file. I checked all of M1 to M17 with grep -cF: each is 1. The
|
||||
gate's mutant runner checks the count again before it applies each edit:
|
||||
|
||||
- M1: `rejectUnknownKeys(value, ["self", "peers"], "federation");` removed. Killed by "federation unknown sub-key".
|
||||
- M2: `rejectUnknownKeys(peer, ["id", "origin", "label"], where);` removed. Killed by "federation peer unknown sub-key".
|
||||
- M3: `url.protocol !== "https:" ||` removed. Killed by "origin over http".
|
||||
- M4: `|| url.origin !== value` removed. Killed by "origin with a path", "origin with userinfo" and
|
||||
"origin with a trailing slash".
|
||||
- M5: `if (ids.has(peer.id)) {` changed to `if (false) {`. Killed by "peer id equal to self".
|
||||
- M6: `peer.label.trim() === "" ||` removed. Killed by "peer label empty".
|
||||
- M7: `if (!isPlainObject(value)) {` changed to `if (false) {`. Killed by "federation not an object".
|
||||
- M8: `|| !FEDERATION_ID_PATTERN.test(value.self)` removed. Killed by "self does not match the pattern".
|
||||
- M9: `if (!Array.isArray(value.peers)) {` changed to `if (false) {`. Killed by "peers not an array".
|
||||
- M10: `if (!isPlainObject(peer)) {` changed to `if (false) {`. Killed by "peer not an object".
|
||||
- M11: `|| !FEDERATION_ID_PATTERN.test(peer.id)` removed. Killed by "peer id does not match the pattern".
|
||||
- M12: ` || peer.label.length > FEDERATION_LABEL_MAX` removed. Killed by "peer label over 80 characters".
|
||||
- M13: ` ids.add(peer.id);` removed. Killed by "duplicate peer id".
|
||||
- M14 (stack-release.sh): `||Object.hasOwn(d,"federation")` removed. Killed by the row 203 federation test in
|
||||
`stack-release.test.mjs`.
|
||||
- M15: `typeof value.self !== "string" || ` removed. Killed by "federation self missing". Without the typeof
|
||||
half, an absent self passes the pattern (`/^[a-z]…$/.test(undefined)` is true).
|
||||
- M16: `typeof peer.id !== "string" || ` removed. Killed by "federation peer id missing". Same reason.
|
||||
- M17: `typeof peer.label !== "string" || ` removed. Killed by "federation peer label missing". Without it, a
|
||||
missing label throws a TypeError at `.trim()` (exit 1), not the exit 2 refusal.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No config is written. `~/.config/mosaic-dev/config.json` is not touched. Tests use `$SANDBOX` only.
|
||||
- No change to `packages/cli/src`, `scripts/bootstrap.sh`, or any reader beyond the stack-release probe.
|
||||
|
||||
## Manifest (sha256 of committed blobs; the list is the review scope)
|
||||
|
||||
- scripts/mosaic-config.mjs
|
||||
- scripts/test-config.sh
|
||||
- scripts/stack-release.sh (B1)
|
||||
- packages/cli/tests/stack-release.test.mjs (B1 test, KEY_MSG)
|
||||
- packages/cli/tests/stack-release-fixture.mjs (B's loader fixture; without this, the suite fails to load)
|
||||
- agents/quackerjack/work/queue-203/BUILD.md
|
||||
|
||||
## Slot estimate (for Sage's gap)
|
||||
|
||||
- test-config.sh: about 5 min, padded.
|
||||
- The cli suite: about 5 min, padded.
|
||||
- stack-release.test.mjs: up to 1800 s, per Sage. It copies the loader, so it runs whole at the gate (Webby N3).
|
||||
- Mutants M1–M13 and M15–M17 each re-run the FED block of test-config.sh. M14 re-runs stack-release.test.mjs.
|
||||
The per-mutant cost is not measured: this run has not been timed.
|
||||
|
||||
## Status
|
||||
|
||||
- e704a18ae: the loader, first tests and notes, rebased onto f2735bb6a.
|
||||
- 8d34a9e94: B1, B2, the fixture fix and the notes rewrite (Mos approved the two old-test edits, mos-1346).
|
||||
- Third commit on top: B3 cases M15–M17 and these notes (sha in the message to Sage and Webby).
|
||||
- NOT YET RUN: no node during Gander's slot. The test-config.sh, cli and stack-release gates run when Sage
|
||||
announces a CLEAR. `bash -n` passes on both shell scripts.
|
||||
- Not pushed, not claimed. Per invariant 8 the gate runs before any commit is called green.
|
||||
|
||||
## Review notes (Webby, webby-0189, answered here)
|
||||
|
||||
- B1: fixed as above (scope per sage-2998). The probe's check name `config-console-key` is kept on purpose (Webby
|
||||
N5, Sage): renaming it would change callers and tests for no gain in this row. The message says "console or
|
||||
federation key"; the name is the only old word left.
|
||||
- B3 (webby-0191): missing self, missing peer id, and missing peer label each have a case and a mutant (M15–M17).
|
||||
The typeof halves are the only thing refusing an absent value, since the pattern test coerces undefined.
|
||||
- B2: a case and a mutant for each uncovered refusal, plus the trailing-slash case under M4 (N4) and the
|
||||
duplicate-peer case (M13).
|
||||
- N1: this file is rewritten with the current base and status.
|
||||
- N2: consumers checked by `git grep "mosaic-config"`. The loader's output is read by `packages/cli/src/config.mjs`,
|
||||
`packages/business/src/*.mjs`, `packages/webui/src/sso-config.mjs`, `packages/seat/src/seat.mjs`,
|
||||
`packages/discord/src/binding.mjs`, `packages/conversation/src/guard.mjs`, and the shell scripts that call
|
||||
`validate` (bootstrap.sh, common.sh, instance.sh, land-work.sh, stack-copy.sh, stack-release.sh,
|
||||
conductor-apply.sh). Checked by `git grep "federation"` over packages and scripts: no reader outside the
|
||||
loader and the stack-release probe mentions `federation`. I did NOT read each call site for which fields it
|
||||
uses; that is still open for Webby's static read. Not run.
|
||||
- N3: the cli stack-release tests copy the loader; they run whole at the gate. Listed in the manifest and
|
||||
the slot estimate.
|
||||
- N4: trailing-slash case added (see table; M4).
|
||||
|
||||
## Gate on the rebased tip (Sage sage-3055, sage-3057; claim rev 3361)
|
||||
|
||||
Brief pin: docs/plans/2026-10-20_federation-r1-r2-r5.md re-pinned to 8adfb261f (sage-203-repin). "Brief: FED R2"
|
||||
is byte-identical between b624ddd49 and 8adfb261f (Sage's diff).
|
||||
|
||||
Rebase: onto origin/refactor c7b754d7b (row 207 landed at c4f77373f on that tip). The three commits are now
|
||||
93a491d73 (loader), 284d55cf2 (stack-release probe and fixture), 7c543fd4a (B3 cases). Blob check against the
|
||||
pre-rebase candidate d5428c1a8 (`git rev-parse <commit>:<path>`), all six identical:
|
||||
|
||||
| blob | path |
|
||||
|---|---|
|
||||
| 4c26453a0 | scripts/mosaic-config.mjs |
|
||||
| 06b9d8ea2 | scripts/test-config.sh |
|
||||
| 92bd6f7d0 | scripts/stack-release.sh |
|
||||
| beb8da252 | packages/cli/tests/stack-release.test.mjs |
|
||||
| f080bf2f5 | packages/cli/tests/stack-release-fixture.mjs |
|
||||
| 6f29dbbde | agents/quackerjack/work/queue-203/BUILD.md (pre-gate-commit blob) |
|
||||
|
||||
Timeouts (Mos mos-1275: `timeout -k 10 <S>` per script, at least 3x its longest run; written here before the
|
||||
first run). Basis: row 202's gate on this machine (scratch g202gatelBKGE0): cli suite 620.3 s (duration_ms
|
||||
620297.68, EXIT=0); the ten scripts together finished in about 2.5 min, so no single script was measured on its own.
|
||||
Figures are set above 3x of those, and the stack-release.test run is set from Sage's up-to-1800 s estimate.
|
||||
They are estimates, not measured. A timeout hit is a red and goes to Mos first. Mos rules on these figures.
|
||||
|
||||
| target | timeout S (seconds) | basis |
|
||||
|---|---|---|
|
||||
| scripts/test-auth.sh | 900 | 3x of a row 202 script, padded |
|
||||
| scripts/test-conductor.sh | 900 | same |
|
||||
| scripts/test-config.sh | 900 | same; also each mutant run of the FED block |
|
||||
| scripts/test-discord.sh | 900 | same |
|
||||
| scripts/test-extension-package.sh | 900 | same |
|
||||
| scripts/test-foundation.sh | 900 | same |
|
||||
| scripts/test-land-work.sh | 900 | same |
|
||||
| scripts/test-queue.sh | 900 | same |
|
||||
| scripts/test-release.sh | 900 | same |
|
||||
| scripts/test-task.sh | 900 | same |
|
||||
| packages/cli suite | 2400 | 3x of 620 s is 1861 s |
|
||||
| stack-release.test.mjs (whole) | 5400 | 3x of Sage's 1800 s estimate |
|
||||
|
||||
Run command shape: `timeout -k 10 <S> <cmd>`. Queue verbs are never wrapped in a timeout.
|
||||
|
||||
Mutant kill rule: each of M1-M17 must fail on an assertion, not on a crash. The mutant run records the failing
|
||||
test name. Skips are named in the gate record.
|
||||
|
||||
## Gate results on 7309260ae (23:00-23:16Z)
|
||||
|
||||
- stack-release.test.mjs, whole: 219 pass, 0 fail, 0 skipped, 583.5 s (timeout 5400 s).
|
||||
- test-config.sh: selftest 51 pass, 0 fail, EXIT 0, 2.4 s (timeout 900 s).
|
||||
- Mutants, one run each, file restored from git after each. Each run of M1-M13 and M15-M17 is 2.4 s
|
||||
(the first run is the timing: 2.4 s, so the 900 s timeout is far above it).
|
||||
|
||||
| mutant | result | killed by (FAIL line in test-config.sh, or stack-release.test) |
|
||||
|---|---|---|
|
||||
| M1 | killed | federation unknown sub-key |
|
||||
| M2 | killed | federation peer unknown sub-key |
|
||||
| M3 | killed | federation peer origin over http |
|
||||
| M4 | killed | federation peer origin with a path (also userinfo, trailing slash) |
|
||||
| M5 | killed | federation peer id equal to self (also duplicate peer id) |
|
||||
| M6 | killed | federation peer label empty |
|
||||
| M7 | killed | federation not an object |
|
||||
| M8 | killed | federation self pattern |
|
||||
| M9 | killed | federation peers not an array |
|
||||
| M10 | killed | federation peer not an object |
|
||||
| M11 | killed | federation peer id pattern |
|
||||
| M12 | killed | federation peer label over 80 characters |
|
||||
| M13 | killed | duplicate peer id |
|
||||
| M14 | killed | row 203 subtest of T57 in stack-release.test.mjs (parent T57 also fails) |
|
||||
| M15 | killed | federation self missing |
|
||||
| M16 | killed | federation peer id missing |
|
||||
| M17 | killed | federation peer label missing |
|
||||
|
||||
Correction (new entry; the kill-text column in the mutant plan above is kept as written): the plan named
|
||||
the kill labels for M8 and M11 as "self does not match the pattern" and "peer id does not match the pattern".
|
||||
Those labels are not in the test file. The assertions that fail are "federation self pattern" and
|
||||
"federation peer id pattern". Both mutants are killed; only the label text in the plan was wrong.
|
||||
|
||||
M14 note: `node --test --test-name-pattern="row 203"` runs zero inner tests, because the row 203 case is a
|
||||
t.test subtest of T57 and the filter skips the parent. The first automated M14 run reported SURVIVED for
|
||||
that reason (rc 0 in 0.9 s, no FAIL line). It was re-run with `--test-name-pattern="T57|row 203"`: the row
|
||||
203 subtest fails (rc 1), so M14 is killed. The automated result is the wrong one and is not the gate record.
|
||||
|
||||
## cli suite (23:20-23:37Z)
|
||||
|
||||
- First run (timeout 2400 s, 627.6 s): 895 tests, 816 pass, 77 fail, 2 cancelled, EXIT 1. Cause: this worktree
|
||||
had no node_modules, so scripts/case2-persona-import.mjs (and the other suites that import it) could not
|
||||
resolve package 'yaml' (declared at 2.9.0 in package.json). Not a federation failure. Not in the gate record.
|
||||
- Fix in the environment only: node_modules symlinked to /mnt/storage/src/mosaic-stack/node_modules (the row 202
|
||||
recipe). The symlink is excluded through .git/info/exclude and is not committed.
|
||||
- Rerun (timeout 2400 s, 621.9 s): 895 tests, 895 pass, 0 fail, 0 cancelled, 0 skipped, EXIT 0.
|
||||
|
||||
## Gate summary (23:00-23:40Z, rebased tip c7b754d7b, code tree 7309260ae)
|
||||
|
||||
| gate item | result | timeout S | measured |
|
||||
|---|---|---|---|
|
||||
| stack-release.test.mjs, whole | 219 pass, 0 fail, 0 skipped | 5400 | 583.5 s |
|
||||
| test-config.sh | 51 pass, 0 fail | 900 | 2.4 s |
|
||||
| mutants M1-M17 (named assertion) | 17 of 17 killed | 900 (per run) | 2.4 s per config run; M14 by row 203 subtest |
|
||||
| cli suite (npm test) | 895 pass, 0 fail, 0 skipped | 2400 | 621.9 s |
|
||||
| test-auth.sh | 15 pass, 0 fail | 900 | 1 s |
|
||||
| test-conductor.sh | 17 pass, 0 fail | 900 | 6 s |
|
||||
| test-config.sh | 51 pass, 0 fail | 900 | 2 s |
|
||||
| test-discord.sh | 81 pass, 0 fail | 900 | 8 s |
|
||||
| test-extension-package.sh | 18 pass, 0 fail | 900 | 1 s |
|
||||
| test-foundation.sh | 44 pass, 0 fail | 900 | 41 s |
|
||||
| test-land-work.sh | 86 pass, 0 fail | 900 | 12 s |
|
||||
| test-queue.sh | 28 pass, 0 fail, 1 skip | 900 | 21 s |
|
||||
| test-release.sh | 14 pass, 0 fail | 900 | 2 s |
|
||||
| test-task.sh | 91 pass, 0 fail, 2 skip | 900 | 14 s |
|
||||
|
||||
Named skips:
|
||||
- test-queue.sh: `queue verify and render --check` skipped. This checkout is not the queue's canonical root
|
||||
(/mnt/storage/src/mosaic-stack). The check runs on the canonical checkout only.
|
||||
- test-task.sh: two live-provider cases skipped by design (row 244: no provider call in the gate).
|
||||
|
||||
Timeouts: the ten per-script figures above were written before the first run and were not changed. Every script
|
||||
finished far inside its timeout. No timeout was hit, and none was placed around a queue verb.
|
||||
|
||||
Environment: the first cli run failed for a missing node_modules in the worktree; see the cli section above.
|
||||
The rerun is the gate record.
|
||||
@@ -1549,3 +1549,19 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-22 | Della | Row258 E01 3094e3bfa focused manifest review | APPROVED 11-path manifest/static F1/F2/N1 corrections; file and full-blob digest recipes verified. M1-M11 fixed; all execution/landing evidence still required in CLEAR. No shared-log edit, Node, tests, HTTPS Git or live action.
|
||||
2026-10-22T17:55:40Z | Sage (Opus 5.5, 1ef1e4f8) | Row 253 landing; post-253 doc sweep | 253 LANDED 48e450ce8. Sweep on sage/sweep-253: Gosalyn Case 2 design and rows, 229 design, Gyro 0acbdb0d8, Della 179391055, 268 docs, seat dirs for Della, Scrooge, Huey, Louie and Ludwig, human-users-build:70 under Q173, DEFERRED 253 N1, 198 N2, row 203 text, 224 residual and 206 sharee list, and seat log lines.
|
||||
2026-10-22 14:48 UTC | Researcher (Sonnet 5.5, 9bac0794) | Rows 220 and 216 resumed (sage-2708, sage-2716, sage-2719); local commits only | 220 e4a204836 on researcher/row220-r2 (on ea6dc9eff): C1 run tests, C2 default-reader M7 test, configDirectory fixture scope, BUILD.md round 2b. 216 4f3fbc25a on researcher/row216-r2 (on aca16404c): index cache, find/get/search/manuscript/universeInfo, PRD 1.14 levels. Nothing pushed, claimed or run; node --check not run. Waits for Sage's CLEAR and go; rebase onto 253 on a new branch before any gate.
|
||||
2026-10-22 | Megavolt | row 207 (193c, #1705) mosaic access user add/remove | gate green on 712f79c0a (31/31, M1-M8 killed, cli 894/894, ten scripts); pushed megavolt/row207-r3 9bb406c6c; to Duckworth.
|
||||
2026-10-22T22:51:32Z | Sage (Opus 5.5, 1ef1e4f8) | Post-207 gap: brief push, row 292 add, stuck-request repair, 207 landing | #1797 brief f0e3dcdf4 pushed; row 292 (#1797, Gander) briefed after 270/271/203. Dead queue locks removed twice; requests for 207/270 abandoned and reposted as jarvis (c30841, c30845), 291 requested (c30846); verdicts posted for Duckworth (c30847, c30849) and Webby (c30848). 207 LANDED c4f77373f, #1705 closed c30855, P19 269 c808. DEFERRED: 207 N1, 270 N6.
|
||||
2026-10-22T23:03:39Z | Gyro (Codex gpt-6.1-sol, mos-1356) | Q195/Q231/Q233 O2 design rewrite, gyro/editor-o2 from c4f77373f | Canonical queue next gyro: nothing. Ten docs-only briefs; full-feature WordCraft, independent 15-crate/199-package security review, editor-only CSP candidate, instant git DOCX saves and retained Markdown/wiki sources, committed-only DOCX citation gate, no agent story prose. Static structure/source-anchor/scope checks only; no code, build/run, queue or live change. Fenton review requested after push.
|
||||
2026-10-22T23:09:38Z | Gyro (Codex gpt-6.1-sol, fenton-0006) | Q195 O2 design corrections on gyro/editor-o2 | F1 requirement boundary sent to Mos, adoption gated; F2-F6 source-checked docs fixes prepared. Ten briefs and two open owner items checked statically. No implementation, build/browser/worker run, queue or live action; Fenton re-check after push.
|
||||
2026-10-22T23:13:27Z | Gyro (Codex gpt-6.1-sol, fenton-0007) | Q195 O2 r3 F7/F8 | Save-group keys/defaults/validation and W3b refusal/W4b lift ownership explicit. Static docs checks only. Q36 remains open; no implementation or gate lift. Direct Fenton/Mos handoff after push.
|
||||
2026-10-22 23:23:42Z | rocko | Q195 O2 W4 #1799 committed DOCX citation/access design | pushed rocko/q195-w4-design-20261022 at 15e91538f; blob 662abdb7; direct handoff to Megavolt; static docs checks only, review pending; Q36 open/adoption closed.
|
||||
2026-10-22 | Megavolt (Claude Opus 5.5) | Q195 O2 W4 DOCX citation/access design review (#1799, sage-3061/3067) | four rounds: CHANGES 15e91538f, 019a0ea5b, 54246cb32; APPROVE 994c3f7fb (verdict 67f82a12d, unpushed pending CLEAR); M1 closed with no ruling; R4-N1 to W4b
|
||||
2026-10-22 23:48Z | rocko | Q195 O2 W4 #1799 design approved | W4 #1799 design approved at 994c3f7fb (Megavolt 67f82a12d), 32 acceptance rows assigned to W4b; static docs/public-fixture inspection only, no implementation PASS. Q36 remains OPEN and adoption CLOSED. Sage's accepted implementation brief remains required. Row219 remains separately held pending your next direction.
|
||||
2026-10-22T23:55:11Z | Sage (Opus 5.5, 1ef1e4f8) | Row 203 landing, O2 design landing, W4 docs landing | 203 LANDED a4da4e6df (Webby c30877, rev 3366), #1701 closed c30882, P19 269 c835. O2 design (mos-1360/1361) pushed 25c09f19f, blob 322ec50f matches fenton-0008. W4 #1799 APPROVE round 4 posted c30884; design 2af460ab7 and review bedde1716 land here. Queue revs 3360-3368 in 4ca270307. 270+271 r4 rebased by Gizmoduck onto 25c09f19f for new Duckworth rounds.
|
||||
2026-10-23T00:05:06Z | Filbert (Opus 5.5, sage-3059/3064) | Q195 O2 S0 (#1798) static review of Fenton's fenton/q195-s0 85ae2be77 (security + CSP reports, receipts in s0-20261022) | CONCUR CONDITIONAL PASS, c30914; A1 M1 needs WOFF2 and summed-size tests, A2 F2/D7 misstate the ctrl+click gate, M2 retargets to DOCX save; N1-N4 non-blocking; D8 hash question stays with Mos; no queue row (mos-1361), no slot, harness not rerun
|
||||
2026-10-23T00:52:58Z | Gyro (Codex gpt-6.1-sol, mos-1366/1367) | Vikunja571 Q235-Q237 O2 parent/W4 format-policy rewrite on gyro/editor-o2 | Canonical queue next gyro: nothing. Prepared P1-P6 and B1-B4 docs, baseline W4 import 1ac76a417; no product/build/browser/worker/private-file/queue/live action. Mos impact map fold precedes review/push handoff.
|
||||
2026-10-23T01:37:07Z | Gyro (Codex gpt-6.1-sol, mos-1368/1372/1375) | Vikunja571 O2 parent/W4 revision complete for review | P1-P6/B1-B4 and A17-A41/Q1-Q16 folded; same-thread Case 2 lookups/support writes, human-set host-pushed off-host remote, Hermes/restore/counting/order/role gates. Static docs checks pass; old locator/projection mechanics retained, authority/access review reopened. No code, build/browser/worker run, private conversion, queue or live action. Direct Fenton/Mos/Sage handoff after push.
|
||||
2026-10-23T01:41:59Z | Gyro (Codex gpt-6.1-sol, sage-3121) | 571 PRD d6 A1-A3 alignment | Both designs corrected and statically checked; local commit for Fenton in shared repo object store. HTTPS push held for Sage gap, independent review pending. No product, test-slot, queue or live change.
|
||||
2026-10-23T01:49:08Z | Gyro (Codex gpt-6.1-sol, fenton-0012/mos-1379) | 571 B1/B2 and N1-N6 docs corrections | Real-helper denial gate and two off-by-default human review settings; static acceptance/brief/immutable-mechanics checks pass. Local candidate for Fenton via shared Git objects; no HTTPS push before Sage gap, no runtime or credential experiment.
|
||||
2026-10-23T01:53:25Z | Gyro (Codex gpt-6.1-sol, fenton-0013) | 571 N7 chapter-order clarification | D10 and W7 explicitly cover mixed formats and stable chapter identity/position. Docs checks pass; local candidate sent for narrow re-check, push held for Sage gap. No runtime or live change.
|
||||
2026-10-23T02:21:06Z | Sage (Opus 5.5, 1ef1e4f8) | Gap after 270+271 run 5: landings and MVP rows | 270+271 LANDED 7328da8ca (mos-1351), 571 landed 767bb62c7 plus N7, rows 293-299 added and committed 253346e3c, 270 request c30929 and Duckworth verdict c30930 posted, 219 rebase conflict ruled (rocko/row219 fd17203ed), 574 CLEAR to Gander 02:19Z.
|
||||
@@ -638,6 +638,18 @@ before row 192 refuses the key). The message says to remove the key first. So a
|
||||
window re-pins to row 192 or later before it writes the key, and a rollback past
|
||||
row 192 removes the key first.
|
||||
|
||||
Two more optional system config keys bound the host's compaction collections
|
||||
(row 270, #1775, DECISIONS Q222). Only a human writes them, like `console`.
|
||||
`compactionReadBytes` is the stream bytes one collection reads, a whole number
|
||||
from 1048576 to 1073741824, default 67108864 (64 MiB). `compactionStreams` is the
|
||||
stream files one collection opens, a whole number from 32 to 65536, default 1024.
|
||||
The loader passes either on as written. The launcher takes the default for a
|
||||
missing or bad value and logs one `system config:` line. A collection that hits
|
||||
either bound counts as `not-collected`, and the next one reads on. A copy before
|
||||
row 270 refuses both keys, and the release's config check doesn't catch that
|
||||
(it runs the target's loader only for a `console` key). So any re-pin or
|
||||
rollback plan whose OLD copy is before row 270 removes both keys first.
|
||||
|
||||
#### A target past c216f47db: the PM after the harness kind
|
||||
|
||||
From c216f47db on, the bus launches the PM only with a `harness` kind in the
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,58 @@
|
||||
# Brief: stack-release.sh config_checks runs the target loader on any config
|
||||
|
||||
Sage, 2026-10-22. Builder per mos-1353. Follows `docs/plans/BRIEF-TEMPLATE.md`. Raised by Duckworth's row 270 round 2 review
|
||||
(duckworth-0226, rollback hazard), ruled a follow-up row by Sage (sage-3038).
|
||||
|
||||
## stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard)
|
||||
|
||||
### Problem
|
||||
|
||||
`config_checks` in `scripts/stack-release.sh` (the row 200 item 3 check, about :1079-1097 at 62a279cf3)
|
||||
runs the target copy's `scripts/mosaic-config.mjs validate` only when config.json holds a `console`
|
||||
key. Row 270 (264c, #1775) adds two human-only system config keys, `compactionReadBytes` and
|
||||
`compactionStreams` (mos-1352, sage-3038), and `mosaic-config.mjs` rejects unknown keys. A re-pin or
|
||||
rollback to a copy from before 270, with either key set and no `console` key, passes `config_checks`.
|
||||
The bus, the Console and the seats then fail closed on their next config read. The same holds for any
|
||||
later key an older loader doesn't know.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Gander (mos-1353).
|
||||
- Reviewer: Duckworth (found the hazard, duckworth-0226; mos-1353).
|
||||
- After: rows 270, 271 and 203. All three change `scripts/stack-release.sh` or the config loader.
|
||||
|
||||
### Files owned
|
||||
|
||||
`scripts/stack-release.sh` (`config_checks` and its comment only), `packages/cli/tests/stack-release.test.mjs`,
|
||||
`docs/TOOLS.md` (the release check line, if it names the console key) and the packet
|
||||
`agents/gander/work/queue-<row>/`.
|
||||
|
||||
### What ships
|
||||
|
||||
- `config_checks` runs the target's loader `validate` whenever the config file exists and parses, not
|
||||
only when it holds a `console` key. A file that doesn't parse still refuses as it does today. A
|
||||
missing file still passes. A target with no `scripts/mosaic-config.mjs` refuses only when the config
|
||||
holds any key beyond what the oldest copies accepted. Name that set from the copies, or refuse and
|
||||
say so in BUILD.md.
|
||||
- The refusal text names the key the loader refused and keeps the "restore the config.json backup"
|
||||
instruction.
|
||||
- Tests: a fixture target loader that rejects a non-console key refuses activate and rollback; a
|
||||
loader that accepts it passes; the console-key cases keep passing unchanged; a mutant that restores
|
||||
the console-only skip is killed.
|
||||
- `stack-release.test.mjs` whole, the cli suite and every `scripts/test-*.sh` are green.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The row 270 keys themselves (row 270). Writing or removing config keys in any window (windows remove
|
||||
keys by hand, per Duckworth's row 270 C1, until this lands). Console flags and pointer handling.
|
||||
|
||||
### Gate
|
||||
|
||||
Duckworth approves the manifest. The touched test files run whole, the cli suite and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
|
||||
### Handoffs
|
||||
|
||||
Every seat hands off directly (DECISIONS Q210, mos-1221). When the owner pushes a build or a new
|
||||
round, or the reviewer posts a verdict, that seat messages the next seat's T3 thread and Sage's with
|
||||
`mcp__t3__t3_send_message`. Copy Mos's thread only when Mos has a step.
|
||||
@@ -0,0 +1,671 @@
|
||||
# Q195 O2 W4: committed DOCX citations and Case 2 access
|
||||
|
||||
Policy rewrite for Vikunja 571, mos-1366/1367/1368/1372/1375, Q234–Q237.
|
||||
Normative parent: `docs/plans/2026-10-21_q195-writing-editor.md` in the same revision.
|
||||
Original W4: Rocko/Megavolt, #1799, refactor 062646487, blob
|
||||
`2af460ab772f8d204598dc8cb79b3c161f311623`. Gyro writes this assigned revision; Fenton
|
||||
independently reviews it. The reviewer-owned verdict file is untouched.
|
||||
Impact map: brain-redux d183246, `projects/mosaic-mvp/case2-docx-impact-map.md`, stack pin
|
||||
062646487. Sections 1/2/6/7 and A19/A20/A28 are reopened, plus part/view authorization
|
||||
wording. Exact locator, package-validation and projection mechanics in sections 3–5 remain
|
||||
from the reviewed baseline except their replaced per-document-scope wording.
|
||||
|
||||
## 1. Confirmed format policy and approval gates
|
||||
|
||||
Q235–Q237 P1–P6 and mos-1372 D1/D2 settle the design basis: canon and playground story
|
||||
files become DOCX, retaining universe/book and source scope. Type-folder notes, agent files,
|
||||
instructions, PRDs, character sheets, summaries and supporting documentation stay Markdown.
|
||||
An unmarked-folder story is eligible; the human selects one story file at a time. New
|
||||
stories start directly as DOCX in the human-selected universe/book or playground, without a
|
||||
Markdown precursor, under the same production-opening gate as import.
|
||||
|
||||
The October 27 release stays Markdown. Production import/new-story opening waits for W4b,
|
||||
W7, the post-10-27 release, the approved Case 2 PRD/brief amendment, Hermes retirement
|
||||
(#423 step 5) and configured off-host durability. The first real use on Melanie's books
|
||||
also waits for Jason's scoped go and mos-1374's protected manual VM1012 backup before the
|
||||
first real import, handled by its authorized human operator, not this design seat. All starts are human-only; `library.convert` has no role
|
||||
grant or agent request path and stays a hard wait unless an approved PRD changes it.
|
||||
Nothing in this document authorizes conversion, new private story creation or deployment.
|
||||
Q234's exact frame CSP, mos-1363 and S0 M1–M14 remain unchanged implementation gates.
|
||||
|
||||
mos-1372 D9 replaces the prior W4 O2-only-conversation, per-document-grant and per-send-share
|
||||
requirements for Case 2 lookups. Agents read converted stories through the normal Case 2
|
||||
lookups in any thread or room under the same live project/universe/source-scope checks as
|
||||
Markdown canon/playground. Final accepted text follows that access. Under mos-1379,
|
||||
editor.agentReadComments and editor.agentReadTrackedDeletions are human-only booleans,
|
||||
both false by default. The former gates comment stories; the latter gates original/markup
|
||||
views. This is two settings, not a role-level allowlist or per-document grant. They can then use existing supporting-Markdown write tools under
|
||||
Q223. The agent never writes a story/DOCX/archive file. D9 is marked as a choice for Jason
|
||||
in PRD 1.17 approval; it is design text, not an already approved role/policy expansion.
|
||||
No code may rely on that changed requirement before the approved PRD and independent review.
|
||||
|
||||
Human import commits the verified DOCX to a private staging ref first and obtains its
|
||||
off-host push acknowledgment. It then creates/pushes the source archive move+mark through
|
||||
new human-only bus plumbing on the C2-02/C2-03b writer, not two single-path saves. Finally
|
||||
one host publication protocol advances writing-published and the catalogue under the
|
||||
publication/read lock. No staged ID is reader-visible before catalogue CAS. Before that
|
||||
switch, agents read the source at its pinned commit; sync cannot advance it early. After
|
||||
it, only the current DOCX is a chapter. Pre-push failure leaves source Markdown unmoved;
|
||||
after archive push, failure is a visible resumable pending operation, never an assertion
|
||||
that nothing moved, a rollback push or Markdown revival. Old bytes/history remain.
|
||||
|
||||
The archive is in the source-repository folder set by a human in the library mapping.
|
||||
There is no default; missing/invalid folder keeps conversion closed. It is recorded with
|
||||
converted date, source commit/blob/path and replacement ID in the operation record. It is
|
||||
read-only in the Console. Explicit `archived` scope returns only requested authorized
|
||||
records, always marked and non-canon; default/current queries never opt into it. An old
|
||||
source lookup returns a typed conversion record and routes an authorized DOCX read, never
|
||||
stale Markdown or fake DOCX lines. Historical line cites still verify at commit/blob and
|
||||
show converted/historical state; archive access alone is not current story authorization.
|
||||
Pre-conversion text already in thread history is not rewritten/refreshed; its cites verify
|
||||
against their original commit/blob and show historical/converted state, not the current DOCX.
|
||||
A Markdown file recreated at a converted old path is unmapped, neither canon nor playground,
|
||||
excluded from lookups and flagged to the human. A new Markdown story at a different path in
|
||||
a converted book stays canon until import, flagged and offered for human import; no automatic
|
||||
conversion. Existing playground scope rules remain. Clickable DOCX editor links stay; no
|
||||
DOCX graph exists yet. A Connections graph and its tests wait for Jason's J-b answer.
|
||||
|
||||
Every conversion runs the pinned extractor before archiving and refuses unsupported output.
|
||||
An independently reviewed importer preserves every wiki token and front-matter key.
|
||||
The v1 extractor does not accept OOXML hyperlinks: emit Markdown label/URL as literal text
|
||||
with a fidelity-report entry for lost clickable behavior, never w:hyperlink/r:id. Bare URLs
|
||||
stay literal; wiki links stay exact and host-clickable. Unsafe/unrepresentable URLs refuse.
|
||||
This handled literal mode is explicitly confirmed before archive push; metadata
|
||||
moves to the canonical manifest (type/name/appears_in/relationships feed the index). Links
|
||||
resolve across Markdown/DOCX through owned aliases, not host paths. A lost link fails the
|
||||
fidelity report. Plain links/front-matter values resolve or raise R4; unresolved chapter
|
||||
embeds must not take the old silent attachment-drop path. Images/.canvas/.base remain attachments.
|
||||
|
||||
## 2. Document identity, order and freshness
|
||||
|
||||
Host code alone opens `<dataRoot>/writing/<business>/<project>/`. Opaque document IDs resolve
|
||||
through the canonical manifest, never caller paths. Conversion preserves book/chapter identity
|
||||
and scope, binding the original path/name as an alias to the DOCX ID. New stories get a fresh
|
||||
ID and the human-selected universe/book or playground. No format-based canon promotion.
|
||||
The map tells authorized agents format, source scope, owned location and current version;
|
||||
archived entries are available only in explicit archived requests. No host paths/credentials.
|
||||
|
||||
A lookup snapshots the reachable committed manifest and DOCX blob from one publication.
|
||||
No worktree, draft, archive or engine-memory fallback. `refs/heads/writing-published` is the
|
||||
canonical served ref. A document's publications are the first-parent commits adding or
|
||||
changing its blob/manifest entry; sibling-only saves do not change its currentCommit.
|
||||
History locators name only such reachable publications, with current project/universe checks.
|
||||
Deleted entries refuse until explicitly reinstated by an authorized human. New local saves
|
||||
publish before acknowledgment. A lookup starting after an acknowledged save returns that
|
||||
save or a later one and names it. A lookup already in flight stays on its captured version;
|
||||
never label an observed older snapshot latest. Recheck live access before emitting text.
|
||||
|
||||
D10's ordered chapter-ID manifest drives manuscript paging and next_chapter after conversion,
|
||||
replacing row216's initial path-order rule (RULINGS.md:32–34). Before the first conversion,
|
||||
seed the order from that existing rule; replace each source entry with its DOCX identity in
|
||||
place. A human reorder is a manifest commit. next_chapter never names an archive. Pending
|
||||
unconverted files retain their legacy identity/scope until their individual publication
|
||||
switch; no file has two working formats and new stories start as DOCX. Book-null notes
|
||||
stay out of manuscript; hybrid exclusions and source-universe boundaries still apply.
|
||||
|
||||
## 3. Exact wire schema, version 1
|
||||
|
||||
All objects reject unknown keys. IDs are nonempty ASCII `[A-Za-z0-9_-]{1,128}`;
|
||||
commits/blobs are lowercase 40-hex Git object IDs in the repository's current
|
||||
SHA-1 object format. A future object format requires a new schema revision.
|
||||
Digest values are lowercase 64-hex SHA-256. Integers are safe nonnegative
|
||||
JSON integers. No floats, coercion, duplicate JSON keys or implicit defaults
|
||||
except the explicitly listed request defaults below. These are proposed DOCX records for the Case 2 lookup/citation union.
|
||||
|
||||
A locator is exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "docx-range", "version": 1,
|
||||
"business": "acme", "project": "book", "document": "chapter_1",
|
||||
"commit": "<40 lowercase hex>", "blob": "<40 lowercase hex>",
|
||||
"extractor": {"id": "mosaic-docx-text-v1", "revision": "<64 lowercase hex>"},
|
||||
"part": {"class": "body", "name": "word/document.xml", "story": "main"},
|
||||
"paragraph": {"ordinal": 0, "xmlId": null},
|
||||
"start": 0, "end": 5, "textView": "final", "quote": "Hello",
|
||||
"quoteDigest": "<sha256 of UTF-8 quote>"
|
||||
}
|
||||
```
|
||||
|
||||
Part triples are exact: body/main uses the body part; footnote/endnote/comment
|
||||
uses its class-matching part and a canonical nonnegative decimal story ID
|
||||
(`0` or `[1-9][0-9]*`), never a user path. Paragraph `xmlId` is JSON null
|
||||
when absent. A textView is always recorded in every locator; there is no
|
||||
resolver default. quote is a nonempty valid Unicode string bounded to 16,384
|
||||
code points and 64 KiB UTF-8. Unknown enum values or unexpected types refuse.
|
||||
|
||||
The ordinal-primary rule below deliberately refines D25's wording ("IDs
|
||||
when present, otherwise ordinal"): a possibly duplicated paraId is a required
|
||||
cross-check, never the lookup key. This is an O2-only design choice for Sage's
|
||||
implementation brief, not an amendment to Case 2.
|
||||
|
||||
`paragraph.ordinal` is the zero-based paragraph index within the selected
|
||||
story, in deterministic XML document order, including empty paragraphs and
|
||||
table-cell paragraphs. `xmlId` is null exactly when the selected node has no w14:paraId, otherwise the literal eight-hex `w14:paraId`,
|
||||
canonicalized uppercase. Ordinal is always present: paraId alone is not
|
||||
unique or stable enough. The key is mandatory: passing null for a node that has the attribute refuses.
|
||||
The non-null value must equal that node's attribute; duplicated IDs do not
|
||||
trigger a search, since ordinal selects the node. Mismatches refuse.
|
||||
Thus identical paragraphs remain distinct. `start < end <= codePointLength`
|
||||
selects one paragraph, end exclusive. A multi-paragraph selection has the exact wrapper below, not a bare array:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind":"docx-selection", "version":1,
|
||||
"business":"acme", "project":"book", "document":"chapter_1",
|
||||
"commit":"<40 lowercase hex>", "blob":"<40 lowercase hex>",
|
||||
"extractor":{"id":"mosaic-docx-text-v1","revision":"<64 lowercase hex>"},
|
||||
"part":{"class":"body","name":"word/document.xml","story":"main"},
|
||||
"textView":"final",
|
||||
"ranges":[
|
||||
{"paragraph":{"ordinal":0,"xmlId":null},"start":1,"end":5,"quote":"ello","quoteDigest":"<64 lowercase hex>"},
|
||||
{"paragraph":{"ordinal":1,"xmlId":null},"start":0,"end":5,"quote":"World","quoteDigest":"<64 lowercase hex>"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
It has 2..32 ranges, all sharing the wrapper's exact document, publication,
|
||||
blob, extractor, view and part. Ordinals are consecutive and increasing.
|
||||
First range ends at its paragraph length, last starts at zero; intermediate
|
||||
ranges cover whole paragraphs. Each range verifies its own quote/digest;
|
||||
no inserted newline is included in any digest. Empty intermediate paragraphs
|
||||
are represented by start=end=0, quote="" and SHA-256 of empty UTF-8; the first
|
||||
and last are nonempty. Total quote budget is 16,384 code points and 64 KiB
|
||||
UTF-8 for the entire selection. A one-paragraph selection uses docx-range
|
||||
only: there is one canonical representation. No auto-merging, cross-story
|
||||
selection or clipping. Resolver's locator union and a range chip accept
|
||||
either exact kind. An atomic selection fails entirely if any range fails;
|
||||
a response never returns only the valid fragments.
|
||||
|
||||
`extractor.revision` is the digest of a canonical build manifest containing
|
||||
extractor source blob IDs, projection rules revision and locked parser/ZIP
|
||||
library versions. The release pins that manifest and independently verifies
|
||||
its digest. It is not caller-supplied executable code or a mutable version
|
||||
label. Its canonical manifest is exactly `{manifestVersion:1,id,rulesRevision,
|
||||
sources,dependencies,formattingProperties}`. id is mosaic-docx-text-v1,
|
||||
rulesRevision is integer 1; sources is a path-sorted array of `{path,blob}`
|
||||
for every extractor/transitive source input; dependencies is a name-sorted
|
||||
array of `{name,version,integrity}` from the lock, including ZIP/XML parsers;
|
||||
formattingProperties is a sorted unique array of expanded XML names.
|
||||
Duplicate keys/entries refuse. Canonical bytes are compact JSON, UTF-8,
|
||||
no BOM/newline, recursively lexicographic ASCII object-key order; strings
|
||||
escape only quote/backslash and U+0000..001F (lowercase four-hex \u escapes),
|
||||
with other scalar values literal. Arrays preserve the specified sorted order,
|
||||
integers are decimal without leading zeroes. revision is SHA-256 of those
|
||||
bytes. Independent tooling must reproduce it. Server readiness is derived
|
||||
from the verified release artifact containing that manifest and its reviewed
|
||||
formatting list, not any human-authored config field.
|
||||
Resolver requires that exact available revision; changed extraction
|
||||
rules create a new revision, never silently reinterpret an old locator.
|
||||
|
||||
A paragraph extraction record is exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"part": {"class": "footnote", "name": "word/footnotes.xml", "story": "7"},
|
||||
"paragraph": {"ordinal": 0, "xmlId": null},
|
||||
"textView": "final", "text": "Note text", "length": 9,
|
||||
"textDigest": "<sha256 UTF-8 text>",
|
||||
"anchors": [], "wikiTokens": []
|
||||
}
|
||||
```
|
||||
|
||||
Anchors have the exact shape `{kind,story,offset}`; allowed
|
||||
kinds are `footnote-reference`, `endnote-reference`, `comment-start`,
|
||||
`comment-end`, `comment-reference`; story is a canonical decimal nonnegative XML
|
||||
ID string (`0` or `[1-9][0-9]*`, the same as note/comment locator stories),
|
||||
offset a code-point boundary in this record. They never grant
|
||||
access to their target. wikiTokens have exactly `{start,end,literal,form,target,label,heading}`.
|
||||
form is link/label/heading/embed; label and heading are null except in their
|
||||
corresponding form. target is 1..256 code points without `[`, `]`, `|`, `#`,
|
||||
control characters or newline; a label/heading is 1..256 code points without
|
||||
brackets, `|`, `#`, controls or newline. literal is respectively `[[target]]`,
|
||||
`[[target|label]]`, `[[target#heading]]` or `![[target]]`. start/end are exact
|
||||
paragraph code-point bounds, including the exclamation for embed. No nested
|
||||
forms or combined label+heading/embed variants are interpreted in v1; they
|
||||
remain text. Tokens are maximal nonoverlapping complete forms in left-to-right
|
||||
order, with their literal equal to the text slice. They are not instructions
|
||||
or external links. All text
|
||||
and metadata are untrusted document data. Part/story IDs, excerpts and
|
||||
comment content never enter system prompts as instructions.
|
||||
|
||||
## 4. Story classes and package validation
|
||||
|
||||
Version 1 accepts transitional WordprocessingML namespace
|
||||
`http://schemas.openxmlformats.org/wordprocessingml/2006/main`, relationships
|
||||
`http://schemas.openxmlformats.org/package/2006/relationships`, office
|
||||
relationship types rooted at
|
||||
`http://schemas.openxmlformats.org/officeDocument/2006/relationships/`, and
|
||||
`w14:paraId` from `http://schemas.microsoft.com/office/word/2010/wordml`.
|
||||
Prefixes are irrelevant; expanded namespace/local names decide. Strict OOXML
|
||||
and unknown alternate-content choices refuse until a separately pinned
|
||||
extractor supports them; never guess equivalent namespaces.
|
||||
|
||||
| Class | Exact part names | Story selector | Agent exposure |
|
||||
| --- | --- | --- | --- |
|
||||
| body | word/document.xml | main | final by default; existing project/universe access |
|
||||
| footnote | word/footnotes.xml | w:footnote/@w:id | own story; same project/universe access as its document |
|
||||
| endnote | word/endnotes.xml | w:endnote/@w:id | own story; same project/universe access as its document |
|
||||
| comment | word/comments.xml | w:comment/@w:id | explicit part request plus human comments setting; same project/universe access |
|
||||
| layout | word/headerN.xml, word/footerN.xml (N positive decimal) | none | denied in v1 |
|
||||
| supporting | separately declared non-story document | separate identity | C2 tools/scopes only; no DOCX scope inheritance |
|
||||
| opaque | media, styles, numbering, theme, fonts, custom XML, embedded objects | none | no raw bytes through O2 reader |
|
||||
|
||||
Body/note/comment ownership comes solely from relationships in that committed
|
||||
package, not filenames supplied by a request. A note/comment belongs to one
|
||||
DOCX, never the book or an adjacent chapter. Classify reserved notes by
|
||||
w:type in {separator, continuationSeparator, continuationNotice}, with any
|
||||
canonical signed integer ID (`0` or `-?[1-9][0-9]*`, no plus/leading zeroes),
|
||||
not by sign. A normal note has absent type or type normal and a canonical
|
||||
nonnegative ID; unknown types refuse. Reserved notes are structural and never
|
||||
readable stories. Any document reference to a reserved note refuses, including
|
||||
an id-0 continuation separator; the reference must resolve to a normal note. Duplicate story IDs, dangling references or overlapping
|
||||
ambiguous comment ownership refuse extraction. Unreferenced comment/note
|
||||
stories are not agent-readable. Cross-paragraph comment anchors may be
|
||||
recorded, but never concatenate their text with body text.
|
||||
|
||||
The root `_rels/.rels` must contain exactly one internal officeDocument
|
||||
relationship to `word/document.xml`. Its matching content type is
|
||||
`application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml`.
|
||||
Optional footnotes/endnotes/comments each have at most one matching internal
|
||||
relationship and their matching `...wordprocessingml.{footnotes,endnotes,comments}+xml`
|
||||
content type. Their relationship targets must be the exact part names above.
|
||||
Reject normalized-path aliases, absolute paths, traversal, encoded separators,
|
||||
backslashes, duplicate ZIP names, symlink entries, encryption and overlapping
|
||||
ZIP data. Never extract ZIP entries onto the filesystem.
|
||||
|
||||
Only internal relationships of types officeDocument, styles, numbering,
|
||||
settings, webSettings, fontTable, theme, image, header, footer, footnotes,
|
||||
endnotes and comments are accepted. Resolve internal targets relative to the
|
||||
owning part within the package, reject escape and validate existence/type.
|
||||
Reject every external relationship, hyperlink relationship, altChunk,
|
||||
attachedTemplate, OLE, package embedding, VBA/macro content type or executable
|
||||
part. Textual literal wiki links need no relationship. Unsupported rich
|
||||
constructs refuse the agent projection, not the human save: the human can
|
||||
retain a full document without it becoming readable to agents.
|
||||
|
||||
Parse XML without DTDs, external entities, XInclude or network/file resolution.
|
||||
Reject DOCTYPE/entity declarations. Bound before allocation: compressed blob
|
||||
32 MiB, total expanded bytes 128 MiB, each entry 16 MiB, 2048 entries,
|
||||
expansion ratio 100:1 both per entry and for the total (nonempty expanded data with zero compressed bytes refuses), XML depth 128, 1,000,000 nodes, 100,000 paragraphs,
|
||||
1,000,000 code points per paragraph and 10,000,000 per document. Extraction
|
||||
has a 5 s host deadline and bounded worker memory of 256 MiB. Exceeding any
|
||||
bound yields `editor-document-unsupported`, no truncated success. These are
|
||||
initial server safety ceilings, not grants or business quota settings;
|
||||
changing them requires reviewed parser evidence. CRC/size mismatches refuse.
|
||||
|
||||
## 5. Deterministic reading views
|
||||
|
||||
`textView` enum is `final`, `original`, `markup`. Default requests use final.
|
||||
Final is the accepted-reading projection, not an accept-changes operation.
|
||||
A text run is visible in final iff no ancestor is w:del or w:moveFrom; in
|
||||
original iff no ancestor is w:ins or w:moveTo. This ancestor veto applies to
|
||||
nested revisions, so an insertion inside a deletion is absent in both final
|
||||
and original. Markup includes both in source order with revision spans for
|
||||
every revision container (nested spans may overlap). w:t and w:delText use
|
||||
the same ancestor rule, never special substring searches. Revision IDs are
|
||||
canonical nonnegative decimals; malformed or unmatched move ranges refuse.
|
||||
|
||||
Paragraph-mark revisions under pPr/rPr mark only the paragraph boundary,
|
||||
not the runs' text. A mark-only w:ins or w:del changes **no projected text**
|
||||
in final or original: run content follows the ancestor rule alone. Although
|
||||
accepting a mark deletion in Word joins paragraphs, this locator projection
|
||||
never merges, splits or renumbers XML paragraphs in any view. It preserves
|
||||
the text on both sides as separate ordinal records. In markup a paragraph
|
||||
mark contributes a zero-width span at that paragraph's code-point length,
|
||||
kind paragraph-mark-insert or paragraph-mark-delete with its revision ID;
|
||||
it never becomes a whole-paragraph deletion span. A mark-only insertion's
|
||||
text stays in original; a mark-only deletion's text stays in final. Inline
|
||||
run revisions remain independent. No view edits or accepts/rejects changes.
|
||||
Original/markup require editor.agentReadTrackedDeletions=true; comment stories require
|
||||
editor.agentReadComments=true. Both are human-only and false by default (mos-1379); a comment
|
||||
in original/markup needs both. Check them in addition to ordinary project/universe access,
|
||||
refusing with editor-access-refused and no text when off. No role-level allowlist or
|
||||
per-document grant is added. A final-body read stays available and never silently includes
|
||||
deleted revisions/comments. Human editor review/save remains full-featured and unaffected. Revision author/date metadata
|
||||
is omitted from agent responses in v1.
|
||||
|
||||
Closed story/run leaf table (all names in w namespace unless stated):
|
||||
|
||||
| Leaves | Text/anchor contribution |
|
||||
| --- | --- |
|
||||
| t, delText | decoded text subject to view/ancestor rule |
|
||||
| tab | U+0009 |
|
||||
| br, cr | U+000A, regardless of page/column type; layout kind does not affect offsets |
|
||||
| noBreakHyphen | U+2011 |
|
||||
| softHyphen | U+00AD |
|
||||
| annotationRef | zero text; only allowed in comment story |
|
||||
| separator, continuationSeparator, continuationNotice | structural only, allowed only inside reserved footnote/endnote stories; never projected |
|
||||
| footnoteRef, endnoteRef | zero text; only in matching note story |
|
||||
| footnoteReference, endnoteReference | zero text; matching anchor at current offset with canonical story ID |
|
||||
| commentRangeStart, commentRangeEnd, commentReference | zero text; matching comment-start/end/reference anchor |
|
||||
| moveFromRangeStart, moveFromRangeEnd, moveToRangeStart, moveToRangeEnd | zero text; validate paired IDs, do not duplicate revision text |
|
||||
| bookmarkStart, bookmarkEnd, permStart, permEnd, proofErr, lastRenderedPageBreak | zero text; structural only, never access grants |
|
||||
| sym, ptab | refuse requested story (font/position dependent) |
|
||||
| fldChar, instrText, delInstrText, fldSimple | refuse requested story, including field results |
|
||||
| drawing, pict, object, txbxContent, subDoc, altChunk | refuse requested story |
|
||||
|
||||
Leaf validation covers **the entire selected XML part**, including all its
|
||||
normal and reserved stories, before any requested story is projected. Reserved
|
||||
separator/continuation notes are structurally validated against this same
|
||||
closed vocabulary, with separator/continuationSeparator/continuationNotice
|
||||
permitted only there, never a readable locator story. Each reserved ID/type
|
||||
must be internally consistent; duplicate IDs within a part refuse. A separator
|
||||
leaf in a normal note/body/comment refuses. A valid separator in a reserved
|
||||
note does not make readable footnotes/endnotes unsupported.
|
||||
|
||||
The table is exhaustive for story/run leaves; unknown leaves refuse, including
|
||||
nontext leaves not explicitly named. Formatting properties belong only to
|
||||
the separately closed manifest list. Anchors/range marks under invisible
|
||||
revision ancestors are omitted in that view but still structurally validated.
|
||||
Comment author data and ACL-like Word permission markers never supply Mosaic
|
||||
authority. Unknown extension/AlternateContent text is unsupported, not ignored.
|
||||
|
||||
Walk direct story paragraphs and table cells in XML order, including nested
|
||||
tables in their containing cell order. Paragraph ordinals do not change with
|
||||
view. Concatenate visible text across adjacent runs with no invented spaces.
|
||||
Preserve decoded Unicode as stored: no NFC, case folding or whitespace
|
||||
collapse. xml:space text remains
|
||||
literal. Offsets count Unicode scalar values, not UTF-16 units or graphemes;
|
||||
unpaired surrogates/invalid XML characters refuse. Field instructions,
|
||||
hidden/vanished text and text boxes are not silently omitted: their presence
|
||||
in a requested story makes v1 projection unsupported. Field results and
|
||||
unknown semantic text-bearing elements likewise refuse until specified.
|
||||
The projection traverses only these containers in the w namespace:
|
||||
body, footnote, endnote, comment, tbl, tr, tc, p, r, ins, del, moveFrom,
|
||||
moveTo, smartTag and sdtContent. A document root wraps body; note/comment
|
||||
part roots wrap their corresponding stories. sdt may contain sdtPr (ignored
|
||||
properties) and sdtContent only. Hyperlink containers are unsupported even
|
||||
without a relationship. Unknown text-bearing containers refuse. Nontext
|
||||
subtrees pPr, rPr, tblPr, tblGrid, trPr, tcPr and sdtPr may be ignored only
|
||||
if they contain no story text elements, field codes, external references,
|
||||
objects or unknown namespaces. Structural-leaf behavior is defined only by
|
||||
the closed table above. w:sectPr is layout-only, with relationship targets
|
||||
validated normally.
|
||||
Formatting-property descendants are a closed W4b release artifact: its full
|
||||
expanded-name list is included in the extractor manifest and independently
|
||||
reviewed before enablement; any name absent from that list refuses. This
|
||||
artifact can narrow the allowed property subtrees, never add a text-bearing
|
||||
container to this v1 contract. Missing artifact is no readiness, not a
|
||||
permissive descendant-text scraper.
|
||||
|
||||
Reference marks contribute anchors at the current text boundary, not invented
|
||||
note numbers or inline comment bodies. Each note/comment is separately paged.
|
||||
A single range cannot straddle a view, paragraph or story; the selection wrapper
|
||||
may cover contiguous paragraphs only under section 3. Extract literal
|
||||
`[[target]]`, `[[target|label]]`, `[[target#heading]]` and `![[target]]`
|
||||
after concatenating visible runs within one paragraph, so split-run tokens
|
||||
survive formatting changes. Preserve complete literal bytes as UTF-8 after
|
||||
XML decoding and code-point start/end; never follow OS paths or external URLs.
|
||||
Targets resolve only via the host's owned-document resolver, with independent
|
||||
authorization; unresolved targets stay literal and marked unresolved in human
|
||||
UI. No link parsing grants reads. Nested/unclosed bracket sequences remain
|
||||
plain text, and tokens cannot cross paragraph/view boundaries.
|
||||
|
||||
## 6. Reading APIs, verification, paging and staleness
|
||||
|
||||
These are proposed typed tool schemas over the existing runner socket, not
|
||||
native WordCraft MCP. The same reader backs Case 2 lookups in any authorized thread or
|
||||
room under mos-1372 D9. Caller/session/business come from the bound broker.
|
||||
Agents cannot override them using object fields.
|
||||
|
||||
- `editor_read_committed({project,document,version,textView,part,cursor,limit})`:
|
||||
project, document, version and part are required; only textView, cursor and
|
||||
limit may be omitted. version is exactly `{mode:"latest"}` or
|
||||
`{mode:"commit",commit}`; part is
|
||||
one class/name/story triple above; textView defaults final; cursor is null
|
||||
on first page, limit 1..100 (default 20). No path, blob or draft argument.
|
||||
- `editor_resolve_citation({locator})`: verify the exact immutable range;
|
||||
it does not search for a nearby quote. Return exact quote and locator plus
|
||||
`currentCommit` and `stale` after current authorization.
|
||||
- `editor_flag` is W5's metadata-only write; W5 must validate the same
|
||||
locator before storing a flag and may not write into the document.
|
||||
|
||||
Read response is exactly `{version:1, business,project,document,commit,blob,
|
||||
extractor,textView,part,rows,nextCursor,currentCommit,stale}`. Each row has the
|
||||
extraction record above. The public envelope contains no absolute paths or
|
||||
raw package attributes. `nextCursor` is null at end; `currentCommit` is the
|
||||
current published document commit; stale is exactly commit != currentCommit.
|
||||
A resolver response is exactly `{version:1,locator,currentCommit,stale}`:
|
||||
the verified quote is the locator's quote, with no separate unverified text.
|
||||
There is no default "first part" or automatic descent into notes/comments. The broker-bound business must equal
|
||||
locator.business; a mismatch is editor-access-refused before object lookup.
|
||||
Markup additionally requires `revisionSpans` on each
|
||||
row: array of `{kind,id,start,end}`, kind insert/delete/move-from/move-to/
|
||||
paragraph-mark-insert/paragraph-mark-delete (mark spans have start=end),
|
||||
with null-free canonical decimal IDs and valid boundaries; other views omit
|
||||
that key. Limits cap output at 64 KiB UTF-8 JSON per response, including envelope and cursor. End a page before the first complete
|
||||
row that would overflow, and return a cursor for that row. This is normal
|
||||
paging, not partial-row success. If that first row cannot fit even on an
|
||||
otherwise empty page, return editor-range-too-large with no text. Such a
|
||||
paragraph is unsupported for agent reading in v1; no subrange read exists.
|
||||
A human-visible "paragraph exceeds agent reading limit" reason appears only
|
||||
after authorization. The human editor/save is unaffected. All empty paragraphs
|
||||
still produce rows and advance the cursor. No page can return the same cursor
|
||||
without progress.
|
||||
|
||||
Cursor is a host-authenticated opaque token binding principal, session,
|
||||
project/document, commit/blob, extractor, view, part, next ordinal, the complete
|
||||
project/universe authorization and format-publication revision vector defined in section 7 and expiration (5 minutes). It carries no plaintext content.
|
||||
Tampering, reuse by another session, wrong request parameters or a revoked
|
||||
scope refuses. A latest cursor continues its captured snapshot with stale
|
||||
metadata if a save intervenes; starting again with null cursor returns the
|
||||
new publication. Cursor is not a capability and grants are rechecked every
|
||||
page. Repeated ordinal ranges are stable within one committed projection.
|
||||
|
||||
Resolver steps: authorize the current principal, live project/universe/source scope and publication membership; load
|
||||
that commit's manifest/blob; validate package; run the exact pinned extractor;
|
||||
select exact part/story/ordinal and mandatory XML-ID value; select code-point range;
|
||||
require literal quote equality and SHA-256(UTF-8 quote) equality. Require both
|
||||
blob and commit membership, not digest alone. Any mismatch refuses
|
||||
`editor-citation-invalid` without guessed text. After newer publication the
|
||||
old valid locator returns `stale:true` with old text; it never auto-relocates
|
||||
to identical paragraphs or another note. Jump opens that committed view with
|
||||
an explicit old-version label. If current grant no longer allows it, even
|
||||
old quote resolution refuses and the UI hides cached content.
|
||||
|
||||
Errors: `editor-context-unavailable` for missing readiness; `editor-access-refused`
|
||||
for absent live project/universe authorization (same response for nonexistent or foreign IDs);
|
||||
`editor-document-unsupported` for parser limits or unsupported features after
|
||||
authorization; `editor-version-unavailable` for authorized missing history or
|
||||
extractor; `editor-citation-invalid` for locator/quote mismatch;
|
||||
`editor-cursor-invalid` for invalid paging; `editor-range-too-large` for size.
|
||||
The exact refusal envelope is `{ok:false,error:{code,message}}`; code is one
|
||||
of those enum strings or editor-policy-conflict (409, stale catalogue/settings revision), and message is fixed server text for that code (no user
|
||||
input). Both missing/foreign and ungranted IDs use the same code/message/status
|
||||
403. Syntax errors before identity lookup use editor-citation-invalid, 400;
|
||||
context unavailable is 503, cursor-invalid 400, version unavailable 409,
|
||||
unsupported/range-too-large 422. Successful tools return `{ok:true,result}`
|
||||
where result is the defined response; errors never include result/partial
|
||||
rows. Return no source text, XML, path or untrusted parser error in refusals.
|
||||
|
||||
|
||||
### 6.1 Case 2 query and citation adapters
|
||||
|
||||
Keep `library_find`, `library_get`, `library_search`, `library_manuscript` and the universe
|
||||
lookup. Host adapters join Markdown metadata/read results and the committed DOCX extractor
|
||||
into one typed, scope-filtered index; only current identities enter normal lookups. A request
|
||||
on a converted alias returns its conversion record and the authorized DOCX result. Every
|
||||
answer names format, store/owned ID and captured commit. No loss of chapter search/manuscript
|
||||
or supporting-doc workflow is accepted. Same project/universe/source-scope rules apply in
|
||||
all threads/rooms; archive/draft scope never becomes canon because its format is DOCX.
|
||||
|
||||
DOCX pages use paragraph locators and opaque cursors, not invented line equivalents.
|
||||
Retain section 6's 64-KiB/1..100 paragraph bounds (default20); human reply limits still apply.
|
||||
A manuscript cursor binds ordered-manifest commit, the selected document publication vector,
|
||||
principal/request/scope revisions and expiry. Resume only that snapshot with current access;
|
||||
changed/foreign cursor refuses. next_chapter uses the next visible current manifest ID,
|
||||
never an archive or a guessed sort of DOCX names. Index/cache keys include local publication
|
||||
and extractor revisions; no minute-old Markdown refresh window is copied to DOCX.
|
||||
|
||||
C2-06's checked cite union preserves legacy `{path,commit,lines,quote,as?}` for Markdown and
|
||||
adds `{kind:"docx",locator,as?}` where locator is section3's exact range/selection. Never infer
|
||||
a cite kind from a .docx suffix or parse DOCX as lines. The host checks project/universe,
|
||||
source canon/playground/archive status, fetched provenance, quote and commit before message
|
||||
storage. requireCitation and as:canon apply to both; historical converted/archived sources
|
||||
cannot assert current canon. The host reads Mosaic-Agent trailers from the correct source
|
||||
repository or writing-store publication, never trusts an agent author field. Console and
|
||||
the shared reply renderer show format, locator/lines, version, staleness and converted state.
|
||||
C2-18 Discord remains a separate ruled delivery task, not enabled by a shared renderer.
|
||||
|
||||
## 7. Existing Case 2 authority, remote durability and restore
|
||||
|
||||
### 7.1 Access and supporting writes (reopened M1)
|
||||
|
||||
Reuse existing bound business/project/universe access, live holder/role and source scope.
|
||||
No private O2 conversation ID, per-document access table or send/share token gates a Case 2
|
||||
lookup. Any existing authorized thread/room may use the checked reader and normal library
|
||||
tools. No arbitrary native WordCraft MCP, execute, open/save or raw filesystem/git tool is
|
||||
added. The host store is never mounted in a worker. Rendered/extracted content travels over
|
||||
the ordinary authorized Case 2 tool/message path and existing transcript/run-record policy;
|
||||
the withdrawn private-only transcript/output rule cannot forbid that documented workflow.
|
||||
Grant loss blocks later lookups through existing checks; do not promise erasure of earlier
|
||||
delivered text or rewrite immutable run evidence. This is the D9 choice for PRD approval.
|
||||
|
||||
The existing `library_doc_write`/`library_doc_delete` still require library.write plus the
|
||||
live supporting-repo Gitea grant and supportingPath before git. They operate only on human-
|
||||
marked supporting Markdown, with the existing size/base-CAS/seat-author/push/notice/revert
|
||||
rules. An agent can read a DOCX story and update its character sheet in the same authorized
|
||||
thread. There is no agent write to DOCX, original story, archive or conversion manifest.
|
||||
The archive prefix wins over supporting globs; conflicts refuse mapping. Format/status/type
|
||||
notes in the six creative contracts replace stale 'every item cites lines' wording with
|
||||
free chat plus optional checked line/DOCX citations under requireCitation. No per-document
|
||||
export grant or O2-only session is a hidden prerequisite.
|
||||
|
||||
The cursor authority revision is the server's current project grant, universe/mapping/scope
|
||||
revision, the two human review-settings revisions and format-publication generation for
|
||||
the bound caller/session. It contains no
|
||||
sharing human or writing_access_events/writing_shares sequence. Check the review settings before every part/view/citation/search/index/context attachment,
|
||||
not only on first open. No request boolean can enable them. Recheck before every page,
|
||||
resolution, index hit and message insertion. Scope revocation and format changes invalidate
|
||||
cached authorization; metadata for missing/foreign IDs uses the same refusal as before.
|
||||
|
||||
### 7.2 Selection convenience and ordinary messages
|
||||
|
||||
Keep Q233's human-only setting, default a visible removable chip on each send. A chip may
|
||||
attach only a verified committed locator/version, never unsaved selection text. It helps the
|
||||
writer point at a passage; it neither grants nor limits the agent's ordinary project/universe
|
||||
lookup rights and does not freeze a later latest lookup to that older commit. The sent cite
|
||||
remains immutable historical evidence. Multi-human/thread/room behavior follows existing Case 2
|
||||
permission checks, not a new one-human conversation restriction. Saved-context/pin records
|
||||
remain on the bus through their reviewed schema, with no share-token authority layer.
|
||||
|
||||
### 7.3 Host-only off-host remote (replaces the ban)
|
||||
|
||||
`editor.remoteURL` is a human-only setting, default unset. No same-host bare/filesystem/loopback
|
||||
remote is the default or qualifies as off-host durability. The human selects an off-stack-host
|
||||
private destination (for example private Gitea) and separately configures host-only auth.
|
||||
Until #431 corrects the real seat helper's shared jason.woltje credential route, the remote
|
||||
must be on a host/account that principal and all seat credentials cannot reach; private
|
||||
visibility alone proves nothing. Prefer a separate off-host instance. An existing-instance
|
||||
owner/org qualifies only after denial through the actual helper credential path proves no
|
||||
jason.woltje access. W3b tests that real helper route and seat identities against an authorized
|
||||
empty fixture remote, without printing/logging credential values; no dummy-token substitute.
|
||||
Only the host process pushes. No seat token can list/fetch/clone/fork/push the writing repo,
|
||||
no seat gets its credential, and no raw Gitea grant or store mount is created. Validate the
|
||||
owned remote, TLS/host policy and off-host storage identity; reject URL userinfo, unexpected
|
||||
remotes/object alternates and non-fast-forward updates. Allow host pushes only to refs/heads/writing-published and
|
||||
refs/heads/import-staging/<operation-id>. A refused candidate retains a private non-reader
|
||||
staging ref and journal status; after publication the staging ref stays as an audit link.
|
||||
No automatic deletion or force-update of staging refs. No silent fallback or force push.
|
||||
|
||||
Every writing-store commit pushes there. Agents read the local publication immediately;
|
||||
receipts/UI distinguish local commit and remote pending/failed/synced. Missing remote blocks
|
||||
production opening. Before source archive push, the staged DOCX must have remote acknowledgment.
|
||||
A failed later push keeps local commits, queues bounded host retry/readback and shows failure;
|
||||
it never rolls back the local story. Source archives push through their separate human
|
||||
library writer. The configured retry deadline is human-only (default30s, 1s..300s).
|
||||
Remote/provisioning changes require the normal owner authority, never this design execution.
|
||||
The remote exception is for story git history/manifest; generic stack copies/releases are
|
||||
not story backups and contain no host credentials. Normal Case 2 output/notes follow the
|
||||
existing project's approved storage/sharing policy; the former blanket no-backup/no-remote
|
||||
rule is withdrawn. PRD 1.17 explicitly presents this D7/C4 change to Jason.
|
||||
|
||||
### 7.4 Conversion events, rollback --restore and irreversible recovery
|
||||
|
||||
W7 owns `writing_conversion_events`: a STRICT append-only table with seq INTEGER PRIMARY KEY,
|
||||
at TEXT, business/project/document/operation_id/stage TEXT, record_ref/record_digest TEXT;
|
||||
All fields except seq are NOT NULL; operation_id+stage is unique, no update/delete. The private host journal/record contains source
|
||||
commit/blob/path, archive path/commit, DOCX commit and conversion date; the row is metadata
|
||||
and never prose/credentials. Records are flushed before their bus references. Table rows
|
||||
join stack-release COUNTED and T46 exactly once, so post-backup conversion events contribute
|
||||
to --events and the existing restore approval/counting behavior. No new private conversation,
|
||||
per-document grant or per-send share tables are created for D9.
|
||||
|
||||
Three recovery cases are required: (1) crash after durable DOCX staging but before archive
|
||||
push, (2) crash after archive push before publication, and (3) rollback --restore from a
|
||||
pre-conversion bus backup while archive commits and O2 documents remain. The source is pinned
|
||||
until final publication in cases1/2; after archive push state is visibly pending, never
|
||||
'original untouched'. Case3 drops conversion rows only through the counted/approved restore
|
||||
path. The durable host conversion journal/format registry is not restored from that older bus
|
||||
backup. A compatible controller compares journal, both git histories, remote acknowledgment
|
||||
and restored metadata before lookups/writes resume; it reconstructs missing metadata through
|
||||
new recovery events without reviving Markdown or any old access grant. If reconciliation
|
||||
cannot prove a single current identity/version, refuse that project's lookups and surface the
|
||||
pending operation to the human. Do not replay chapter text into an old index or hide event loss.
|
||||
Incompatible old binaries cannot activate converted projects through the supported controller;
|
||||
raw privileged rollback tampering is outside the guarantee. Never rewrite git/run history.
|
||||
|
||||
Hermes retirement (#423 step5) is a production-opening prerequisite, with old story-write and
|
||||
store-read access removed before any conversion/new-story operation. W7 checks its retirement
|
||||
receipt and host capability preflight; no design seat retires services or accesses Melanie's
|
||||
host. Independent tests use fixtures only. Preserve the existing stack grant/restore behavior;
|
||||
the previous unimplemented W4 private-share restore-fence/table design is withdrawn with M1,
|
||||
not backfilled into legacy rows. Conversion's durable registry is not an authorization token.
|
||||
## 8. Acceptance matrix and release evidence
|
||||
|
||||
These are required future fixture gates, not implementation results.
|
||||
|
||||
| ID | Evidence required | Negative control / bypass |
|
||||
| --- | --- | --- |
|
||||
| A01 | Exact body quote independently recomputed from commit/blob/revision | change quote, digest, blob, commit or extractor; each refuses |
|
||||
| A02 | Two identical paragraphs and notes have different locators | no content-search relocation; swapped ordinal/story refuses |
|
||||
| A03 | XML ID absent/duplicate and ordinal behavior | missing/mismatched paraId cannot silently choose another node |
|
||||
| A04 | Emoji, combining characters, tabs, breaks and split runs | UTF-16 offsets, normalization and whitespace collapse fail |
|
||||
| A05 | Literal split-run wiki forms retained | no path/URL fetch; malformed/cross-paragraph tokens stay text |
|
||||
| A06 | Final/original/markup independently verified with moves/deletions; review settings off by default | final read succeeds; original/markup or comment request/resolve refuses until matching human setting, both needed for original comment; no document rewrite |
|
||||
| A07 | Body/note/endnote/comment exact ownership | orphan/dangling/cross-document references and duplicate IDs refuse |
|
||||
| A08 | Package allowlist, aliases, CRC, XML/ZIP limits | external rel, macro, traversal, symlink, entity, ZIP bomb and timeout refuse |
|
||||
| A09 | Commit snapshot and publication ordering | next latest after Saved gets new commit; no worktree/draft fallback |
|
||||
| A10 | Historical resolve stale label | old quote stays old; removed grant/history refuses without text |
|
||||
| A11 | Pagination complete/deterministic and bounded | tampered/foreign/revoked cursor and oversized row refuse |
|
||||
| A12 | Cross-user/business/project/universe denial | foreign project/universe IDs, same blob elsewhere and guessed unpublished objects grant nothing |
|
||||
| A13 | Selection chip points only at committed text; ordinary lookup authority independent | draft mapping refuses; missing chip does not deny an authorized lookup |
|
||||
| A14 | Normal project/universe/source-scope checks in every thread/room | pin, guessed ID, foreign universe and revoked holder cannot grant access |
|
||||
| A15 | Latest after acknowledged save; explicit historical cite stays immutable | chip/per-send snapshot cannot force stale latest results |
|
||||
| A16 | Host-only store and remote credentials; checked reader routes only | no worker store mount, arbitrary git/engine access or seat remote token; actual seat helper credential denied, not just a dummy token |
|
||||
| A17 | Existing Case 2 message/transcript policy | no O2-only/raw-session backdoor that skips existing thread/project checks; no false erasure promise |
|
||||
| A18 | Reader/resolve/flag plus existing Case 2 lookups/supporting writer after gates | native MCP, execute, save, replacement and story comment writes refuse |
|
||||
| A19 | Unconverted/support Markdown unchanged; converted path returns record and DOCX | no stale archive as chapter or DOCX-as-lines; historical line cite verifies by old commit/blob |
|
||||
| A20 | Conversion/new story gates W4b/W7/PRD/post-10-27/Hermes/owner go | agent start, missing readiness, early release, fixture flag and missing first-real-book go refuse |
|
||||
| A21 | Human full-feature save remains separate | unsupported projection yields no agent context, never lossy human save |
|
||||
| A22 | Scope revocation during extraction | final pre-emission check prevents async stale-authority leak |
|
||||
| A23 | Sibling chapter save | currentCommit/stale/cursor for untouched document remain unchanged |
|
||||
| A24 | Full page and oversized paragraph | end before overflow; first oversized row refuses without text; no unreachable subrange claim |
|
||||
| A25 | Every read/write/tool surface uses existing Case 2 isolation | same document ID across business/universe or replayed cursor refuses |
|
||||
| A26 | Host-only off-host remote and per-save push | unset/local-only/helper-readable/unexpected remote refuses opening; real helper denial tested; push outage visible, local next lookup still fresh |
|
||||
| A27 | Conversion restore accounting and durable journal reconciliation | pre-conversion --restore loses counted row but cannot revive Markdown; no lost-event concealment |
|
||||
| A28 | Reopened M1 supports same-thread DOCX-read then supporting-note write | any room/project-authorized caller works; no per-document/share token; story/archive write refuses |
|
||||
| A29 | Readiness is reviewed runtime/extractor plus approved PRD, not caller fields | no request/go/setting can assert release or Hermes retirement |
|
||||
| A30 | Canonical schemas and complete selections | unknown keys, inconsistent ranges, xmlId null mismatch and noncanonical story IDs refuse |
|
||||
| A31 | Closed leaf/revision table | every leaf/nested rule; mark-only deletion keeps text in final, mark-only insertion keeps text in original; markup mark spans zero-width; Word -1 separator/0 continuation-separator and older 0/1 reserved layouts validate, references to them refuse |
|
||||
| A32 | Existing grant/restore controls and conversion metadata COUNTED | restore cannot revive grant, current Markdown or uncounted conversion event |
|
||||
| A33 | Staging/archive/publish journal and operation readback | crash/sync at every boundary; pinned source until publish; post-push failure pending, not undone |
|
||||
| A34 | Manifest order, typed index/links and format map | next_chapter never archive; draft scope retained; cross-universe/hybrid exclusions and R4/R5/R6 |
|
||||
| A35 | Per-file fidelity, source history, human controls and missing-folder refusal | all constructs/wiki/front matter preserved; no agent conversion; new DOCX has no Markdown precursor |
|
||||
|
||||
Implementation acceptance includes exact schemas/parser fixtures, live project/universe
|
||||
revocation probes, same-thread supporting writes, remote denial/failure, counted-event restore
|
||||
cases, Hermes gate, pinned-copy/sandbox closure and G1. All skips are named; an unexercised
|
||||
guard blocks enablement. Old O2-only/private-share acceptance wording is replaced, not claimed
|
||||
passed. No test, conversion or runtime experiment ran for this rewrite.
|
||||
|
||||
## 9. Reopened review and handoff
|
||||
|
||||
Fenton reviews this policy rewrite against mos-1368's A17–A41/Q1–Q16 and mos-1372 D1–D9,
|
||||
including reopened M1 and A19/A20/A28, host-only remote and C5 restoration. Megavolt's old
|
||||
APPROVE stays historical at the original blob; no author writes its verdict file. The
|
||||
locator/projection facts remain source-backed, but the new authority/tool/runtime contract
|
||||
requires its own independent approval and the Case 2 PRD approval, not a silent addendum.
|
||||
Owner sends both design blobs/commit to Fenton, Mos and Sage. No code, private document,
|
||||
conversion, source build/browser test, queue entry or live change is authorized here.
|
||||
@@ -0,0 +1,182 @@
|
||||
# MVP readiness rows: FED R6, R4, R7, the GitHub grant method, the Q228 grant scope, and 574/573
|
||||
|
||||
Sage, 2026-10-23. Mos filed these under mos-1371 (readiness at-risk) and accepted the owners, reviewers and
|
||||
slot order in mos-1372, from sage-3112. Row ids come from the queue CLI at add. Every row below has gate owner Sage.
|
||||
Slot order per sage-3112, with R6 first among the FED rows because it blocks 208, R7 and R8.
|
||||
|
||||
The federation rows follow Gizmoduck's design `agents/gizmoduck/work/federation/DESIGN.md` at b93f098f0 on
|
||||
`gizmoduck/federation-design` (r4.11, sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6),
|
||||
sections 3.1, 3.7 and 7, under Vikunja P19 #414. The R3 brief (`docs/plans/2026-10-22_federation-r3.md`) is the
|
||||
template.
|
||||
|
||||
Common boundaries for every row here: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, any
|
||||
live access.json or any live credential. Tests use scratch roots under `/mnt/storage/scratch/tmp`, never `/tmp`.
|
||||
Single test files run freely, and suites and mutants need Sage's slot. Every old-test change follows mos-1272.
|
||||
|
||||
## Brief: FED R6, Console granted-user sign-in
|
||||
|
||||
Issue #1800.
|
||||
|
||||
Owner Launchpad (built row 192). Reviewer Duckworth. Parents done: 192 (#412 phase 1), R1 (201), R2, R3 (274).
|
||||
Row 208 gets an after on this row.
|
||||
|
||||
What to build, in `packages/webui/src/` and `packages/cli/src/access.mjs`:
|
||||
1. Granted-user sign-in. The Console verifies the OIDC token itself (Q175) and matches `(provider, sub)` to a
|
||||
live access.json grant through R1's loader. An ungranted `sub` is refused.
|
||||
2. A `user-cli` transport per session, R3's path-bound `packages/bus/src/user-cli.mjs`. The Console never
|
||||
speaks for a granted user through `human-cli`.
|
||||
3. The grant is re-read on every request. A user revoked mid-session gets 401 on the next call.
|
||||
4. Owner-only controls are hidden from a granted user. The bus still refuses them (R3), and the mutant proves
|
||||
the bus refusal stands on its own.
|
||||
5. The notice on the user's thread page, row 193's sentence word for word (B9, N21): "The business owner sees
|
||||
your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages."
|
||||
6. Peer links and the hub label (design 3.1 D1). The Console shows which instance it is from
|
||||
`federation.self` and links each `federation.peers` entry by label and origin. A peer link is a plain link.
|
||||
7. The F8 peers wiring. `accessContextFor` at `packages/cli/src/access.mjs:62` returns `peers: []`, so an
|
||||
access.json that names a peer can't validate. It must pass the configured peer ids from
|
||||
`system.federation.peers`.
|
||||
|
||||
Tests, webui tests on Sage's fake OP plus the cli access tests: design section 7's R6 gate item by item
|
||||
(granted user signs in, ungranted `sub` refused, revoked mid-session gets 401, the owner-verb mutant, the
|
||||
notice equals row 193's sentence exactly), one test that the hub label and peer links render from config,
|
||||
and one cli test that a configured peer id validates and an unconfigured one refuses. Mutants: the UI check
|
||||
removed (owner verb still refused by the bus), the per-request re-read skipped, the notice text changed by
|
||||
one word, `peers` back to `[]`.
|
||||
|
||||
Gate: Duckworth approves the manifest. The webui, cli and bus suites and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed. The live provider client and the hub proxy route are R8's.
|
||||
|
||||
## Brief: FED R4, revocation
|
||||
|
||||
Issue #1801.
|
||||
|
||||
Owner Morgana (built R3, 205 and 275). Reviewer Dewey. Parents done: R3 (274), R5 (202), 193a (204). Design
|
||||
section 7 also names row 193's viewer level (mos-1019, N17). If 193 hasn't landed that level when R4 starts,
|
||||
R4 builds on 193's candidate, and the downgrade test and its mutant run on that base (Q177). Row 275's
|
||||
"after FED R4" note becomes an after on this row.
|
||||
|
||||
What to build, in `packages/bus/src/broker.mjs` and `packages/cli/src/launcher.mjs`, per design section 3.7:
|
||||
1. A broker timer every 30 s that re-reads access.json and re-evaluates expiry.
|
||||
2. `access.reload`, a human-only verb. R5's `mosaic access grant` and `revoke` call it after the write.
|
||||
3. On a loss, one transaction ends the claims of every `user:<id>` thread no live collaborator grant covers
|
||||
(`#threadHolders`, `#claimEnd`). The run's next bus call is refused.
|
||||
4. `threadsState` marks such a thread `revoked: true`, computed from the file on each call. A downgrade to
|
||||
viewer counts as a loss (`covers` with `level: 'collaborator'`).
|
||||
5. The launcher never starts a revoked thread, SIGTERMs a running one at once without waiting for
|
||||
`threadIdle`, and its launch check refuses a revoked thread.
|
||||
6. R5's help text keeps the provider-removal order (access.json first).
|
||||
|
||||
Tests, bus and launcher tests, each with a mutant (design section 7): a hand edit removing the grant ends the
|
||||
run within one timer period, an expiry with no request ends it, a pending message followed by a launcher
|
||||
restart starts no run, a running revoked thread gets SIGTERM, a downgrade to viewer counts as a loss, and
|
||||
another user's runs stay. Timer tests drive the clock or the timer callback. No test sleeps 30 s.
|
||||
|
||||
Gate: Dewey approves the manifest. The bus, cli and business suites and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
|
||||
## Brief: FED R7, scratch two-instance acceptance
|
||||
|
||||
Issue #1802.
|
||||
|
||||
Owner Quackerjack (built R5 and R2). Reviewer Filbert. After R4 and R6, and row 190 (several instances on one
|
||||
uid). Briefed now and claimable once R4 and R6 both have candidates. It builds on their combined candidate.
|
||||
|
||||
What to build: one script test, under `scripts/` with its harness in `packages/*/tests/` as row 190's tests are.
|
||||
It sets up two configs, two data roots, two Consoles and one fake OP. The Consoles start outside agent ancestry
|
||||
in a transient user unit, as the user proof requires. They never use the live stack's units, ports or
|
||||
sockets, and the script removes its units on exit (trap).
|
||||
|
||||
Test, design section 7's R7 gate: Jason's test identity, granted collaborator on one hub project, signs in to
|
||||
the hub, makes a thread, sends to the granted instance, gets the instance's reply in the thread, is refused
|
||||
on another project and on board reads, then is revoked, is refused, and has the run ended. The factory shows
|
||||
nothing of the hub. Mutants: the grant's project list widened to `*`, the revocation's claim end skipped.
|
||||
|
||||
Gate: Filbert approves the manifest. The script test and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
no unit, port or socket of the script's outlives the run. R8 is the live window and stays row 209.
|
||||
|
||||
## Brief: the GitHub grant method (Bushroot's half of #436b)
|
||||
|
||||
Issue #1803.
|
||||
|
||||
Owner Bushroot. Reviewer Dewey. After row 198, so Bushroot isn't on two rows. Q188 item 3: on Approve,
|
||||
protected stack code creates the grant on GitHub as it does on Gitea. The Gitea half is row 236 (landed
|
||||
bd5f11ded) under #436b design r5 (`agents/megavolt/work/436b/DESIGN.md` at 1be535aca).
|
||||
|
||||
Step 1, before any code: Q-B3. Bushroot answers design section 10's shared items S1-S6 in the packet, and
|
||||
megavolt-0036 is waiting on that answer. Where GitHub doesn't fit (S4, because App installation tokens expire
|
||||
within an hour and may not fit "mint once and store"), say so and propose the per-method S4. The rest stays
|
||||
shared. Dewey and Megavolt read the answer before the build starts. A change to a shared item that 236 already
|
||||
built goes to Sage first.
|
||||
|
||||
Step 2, the build: a `github` method behind the same grant body (S1), admin slot (S2), `grant.call` and
|
||||
`grant.result` records (S3), executor steps (S4 or its per-method form), revoke order (S5) and capability
|
||||
floor (S6). Scopes are fixed by code per method.
|
||||
|
||||
Tests: a fake GitHub API only. Mirror 236's test and mutant list for the GitHub method, plus the S4 expiry case
|
||||
if the method refreshes tokens. No call reaches github.com. Live use waits for Jason's admin credential step,
|
||||
as it did for Gitea, and no agent sets the admin slot.
|
||||
|
||||
Gate: Dewey approves the manifest. The touched suites and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed.
|
||||
|
||||
## Brief: the chief-of-staff grant scope (Q228)
|
||||
|
||||
Issue #1804.
|
||||
|
||||
Owner Megavolt (built 236 and 281). Reviewer Dewey. After row 252, because the inbox Approve path is the one
|
||||
this row lets in-scope grants skip.
|
||||
|
||||
What to build, per DECISIONS Q228 (Jason, "Q31: no"):
|
||||
1. A grant-scope setting for the chief of staff that only a human changes (Q222). A missing or bad value falls
|
||||
back to the default, which is an empty scope, and every grant then needs Jason's Approve as before.
|
||||
2. A chief-of-staff grant request that is inside the scope is issued by the protected grant code without a
|
||||
per-grant Approve. A request outside it goes to the inbox as today.
|
||||
3. Every grant is logged and revocable, in-scope ones included, with the same records 236 writes.
|
||||
4. Jason's Approve stays for the chief of staff's enrollment and for any change to the scope.
|
||||
5. Gosalyn's Case 2 write path keeps its grant step. Only the approver changes.
|
||||
|
||||
Tests: an agent or tester caller can't change the scope, a bad value falls back to the default, an in-scope
|
||||
request issues with no inbox item, an out-of-scope one waits for Approve, a scope change needs Approve, an
|
||||
in-scope grant is logged and revocable. Mutants: the scope check skipped, the fallback skipped, the human
|
||||
check on the setting removed.
|
||||
|
||||
Gate: Dewey approves the manifest. The touched suites and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed. The live scope value is Jason's to set.
|
||||
|
||||
## Brief: 574, maintenance-free test fixture commits (Vikunja 574)
|
||||
|
||||
Issue #1805.
|
||||
|
||||
Owner Gander. Reviewer Filbert. Gate owner Sage. Ruled in mos-1369 A4 and mos-1370 under mos-1272. Slot: ahead
|
||||
of the next row after 270+271 (A5).
|
||||
|
||||
Fixture git helpers commit through `gitEnv()`, which has no maintenance config. On git 2.55.0 a commit spawns a
|
||||
detached `git maintenance run --auto` that can write and delete `objects/maintenance.lock` after commit
|
||||
returns, while a test fingerprints the whole root, `.git` included. This turned 270+271 run 4 red at
|
||||
`packages/seat/tests/checkout.test.mjs:239`.
|
||||
|
||||
Change: only `-c maintenance.auto=false -c gc.auto=0` added to the fixture git helpers, with no assertion
|
||||
change, and the fingerprints still covering `.git`. Candidate `gander/v574` 7da382244 (source 65e919e5c) on
|
||||
062646487, four files: `packages/seat/tests/checkout.test.mjs`, `packages/seat/tests/working.test.mjs`,
|
||||
`packages/cli/tests/git-socket.test.mjs`, `packages/cli/tests/lifecycle.test.mjs` (the last approved in
|
||||
mos-1370).
|
||||
|
||||
Proof: a GIT_TRACE2 run through a scratch PATH shim shows no maintenance child for the flagged helpers, and an
|
||||
unflagged positive control shows one. Gate: Filbert approves. The four whole files, then the seat and cli
|
||||
suites, are green on Sage's gate. Filbert's static APPROVE (filbert-v574-7da38224-sage, 01:23Z) adds proof
|
||||
folds P1-P5: per-process GIT_TRACE2_EVENT files, the positive control in the gitEnv-shaped environment, a wait
|
||||
for detached git before counting, the commit count beside the zero child count, and checkout.test's wrappers
|
||||
through the shim. Residual outside 574: `packages/queue/src/store.mjs`, `packages/discord/src/git.mjs` and
|
||||
`packages/harness/src/tools.mjs` commit without the maintenance flags. No whole-root test covers them today,
|
||||
so that's a bounded follow-up note, not part of this row.
|
||||
|
||||
## Brief: 573, the P10 hop band (Vikunja 573)
|
||||
|
||||
Issue #1806.
|
||||
|
||||
Owner Gander, after 574. Reviewer Filbert. Gate owner Sage. Ruled in mos-1367 A5. 270+271 run 3 went red on
|
||||
`packages/cli/tests/case1-accept.test.mjs:368/:390`, the P10 inner hop band (probe 1.08 under 1.25), at load.
|
||||
|
||||
Gander's packet states the band's source and a fix that measures the same property under load. Changing an
|
||||
old test, or anything that loosens its assertion, goes to Mos before the build (mos-1272). Gate: Filbert
|
||||
approves. The whole file under load at least 3 times, then the cli suite, are green on Sage's gate.
|
||||
@@ -624,3 +624,13 @@ Open follow-up: scripts/case1-accept.sh:413 (at candidate3c4c463da) uses `tr < "
|
||||
|
||||
|
||||
Row276 mos-1345 R3 residual remains open: a same-uid process that escapes the session, or a stalled supervisor in that session, can retain descriptors to the private stdout/stderr spool after root unlinks it and keep growing the unlinked file. The outer bound releases the caller because no residual holds its capture pipe. Row276 adds no disk cap and no cross-session kill; this is not a cleanup or closure claim.
|
||||
|
||||
|
||||
## Row207 N1: "refuses at any business" has no test
|
||||
|
||||
Open follow-up (duckworth-0230 N1, sage-3043). Row 207 (#1705) lands on 9bb406c6c without a test that `mosaic access user remove` refuses while the user holds a grant at any business, not only the named one. A mutant adding `&& g.business === business` survives T4: checkAccess still refuses at exit 3, so nothing is written, but the reason and exit code are wrong. The next row that touches packages/cli/tests/access.test.mjs adds one T4 case with the grant at a second business and a mutant that the case kills. N2 (parse ignores stray flags) is pre-existing in grant and revoke and stays out of scope.
|
||||
|
||||
|
||||
## Row270 N6: ledger growth and the whole-file read on session.list
|
||||
|
||||
Open follow-up (duckworth-0228 N6), bound to row 272 (264e, #1777). Row 270 caps what the bus reads per compaction collection (compactionReadBytes, default 64 MiB) but an agent can still grow a session's compaction ledger by about 231 KB per collection, and session.list reads the whole file. Row 272 bounds the ledger or the read on session.list, with a test and a mutant, or records why the cap is enough.
|
||||
+15
-7
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 3338 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3406 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -239,12 +239,12 @@ Generated from `docs/plans/queue.json` revision 3338 by `queue render`. Do not e
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | Landed afad4993f on refactor (squash of gizmoduck ae3c36f24..44e36fc4c, manifest 225597146daf...0c520). patch-id ba604ad5e matches the reviewed diff on all 3 paths. Gate on 44e36fc4c in the slot (gizmoduck-0060): business 173/0, 10 scripts 422/0, mutants 44/44. Duckworth approve 29581. |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | done | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Landed a4da4e6df on refactor 23:47Z 10-22, fast-forward from c7b754d7b, 5 commits. Webby APPROVE c30877 rev 3366 on candidate a4da4e6df. Gate on Sage's slot: stack-release.test 219/0, test-config 51/0, M1-M17 killed, cli 895/0, ten scripts green, named skips only. Brief re-pinned 8adfb261f (Webby N2). 270+271 rebase onto this (mosaic-config.mjs conflict) as a new round. |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | done | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | Landed ce6a96ad6 fast-forward on 0fa7bf421 (3 commits, 4 paths, manifest 858463b1). Duckworth APPROVE r1 c30375 (signed :124 test diff, mos-1262). Gate: Morgana slot205 10 files, bus 652/0, M1 M11-M14 killed, 0 skips; Sage test-*.sh 10/10, 3 by-design skips. N1/N3 to 205b or R3, N2 to 193b2 brief, N4 = #1782 before any user thread. Rebase next: 268 r4, 194, 236, 253, 274. |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | launchpad; reviewers duckworth | #1704 | done | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | Landed 0cbe2e9b3 fast-forward on 17f415792 (10 commits, 22 paths), pushed 19:47Z. Duckworth APPROVE rev 3322 c30795 on 0cbe2e9b3; request rev 3320/3321 c30793 posted as jarvis. Gate (Launchpad, 9538e0fc8, tip differs in BUILD.md only): bus 850/0, cli 869/0, ten scripts/test-*.sh rc=0, touched files whole green, M2-M4 M7-M9 and Q2 killed. One placed fixture red (mos-1272). Schema 3l to 3m; live --migrate is Mos's go under Q173. |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | megavolt; reviewers duckworth | #1705 | in-progress | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | megavolt; reviewers duckworth | #1705 | done | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | LANDED c4f77373f 22:49Z 10-22 (fast-forward from f0e3dcdf4). Rebuild of gated 9bb406c6c: range-diff 4/4 =, patch-ids equal, 4 manifest blobs equal. Duckworth APPROVE c30849 rev 3356. Gate on 712f79c0a under sage-3041: access.test 31/31, M1-M8 killed, cli 894/894, ten scripts green. N1 DEFERRED (next access.test row). No live change. |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done, 294 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | done | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Landed f5753c0e8 on refactor (base 9d165be8c), pushed. Patch-id 723b1c83 matches git diff ad95c888c..0485384e9 on the 23 manifest paths; landed files pass r5 manifest 688d38b9. Duckworth approve r2 rev 2592, r3 29851. Mutants 52/53, M22b equivalent (rev 2584). Gate on ad95c888c+patch: packages 2796/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | darkwing; reviewers dewey | #1709 | in-progress | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
@@ -306,8 +306,8 @@ Generated from `docs/plans/queue.json` revision 3338 by `queue render`. Do not e
|
||||
| 267 | Case 2 C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | morgana; reviewers filbert | #1772 | queued | 216 done, 217 done, 224 done | Filbert approves the manifest. The brief's tests and the end-to-end crossover run whole plus every touched suite on Sage's slot. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-15 hybrid universes: exclusions, pins and the end-to-end crossover | R8 accepted (sage-2218). Webby's R8 noise note (gosalyn-0127) is for the builder. |
|
||||
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | done | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | Landed c27207f6e fast-forward on ce6a96ad6 (1 commit, 9 paths, digest caa0fa4e). r4 is a carry of r3 aca53c4f0: range-diff =, patch-id 2942acc8 equal, blobs equal. Webby APPROVE round 2 on ecd9b35eb stands; carries webby-0112 (r3) and webby-0113 (r4, notes 34e1ab2bf). Gate: Gizmoduck r4 rerun brief 14/14, library 83/83, business 209/209, 0 skips; gate grep empty. 194 rebases next (business.test.mjs). |
|
||||
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | done | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Landed efcc9ea8d fast-forward on 94c33a43c (13 commits, 12 paths, manifest 455c12ee). r3 is a carry of r2 aafcc857c, range-diff = (Duckworth 0194, c30756, rev 3307). Gate r3 18:37-18:50Z all green, 0 skips: bus 834, seat 172, cli 869, case2-unit 43, singles 38. M1-M12 kills carry. Re-pin needs the Console restart by Jason or Mos right after the host. A 264a rollback refuses a v2 sessions.json, see BUILD.md Rollback. |
|
||||
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | in-review, round 1, request posted | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | done | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 7328da8ca (with 271, mos-1351) 02:13Z 10-23 on 767bb62c7: 33 commits cherry-picked from gizmoduck/row270-271-r4 49cee846e (base 25c09f19f), range-diff all '=', 40 paths identical to 49cee846e. Manifest f4174458 (15 paths) matches the tip. Duckworth APPROVE c30930 rev 3389. C2 run 5 GREEN on 53406f3e4: bus 873, seat 202, cli 905, ten scripts, 38/38 mutants killed. #1775 closes. |
|
||||
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | done | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 7328da8ca (with 270, mos-1351) 02:13Z 10-23: tip 2b964a082 is in the 33-commit pick, range-diff all '='. Manifest 3ab1f5c8 (33 paths): 29 match the tip; BUILD.md(270), bus README, launcher.mjs and compactions.mjs moved under 270 and match 270's f4174458. Duckworth APPROVE rev 3380. Combined C2 run 5 GREEN, 271 M1-M14 killed. #1776 closes. |
|
||||
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | huey; reviewers scrooge | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | done | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | landed 0bbf0f6d1 03:35:55Z, FF of 378a76b85; rebase of cac7d0f67, 6 range-diff pairs =, patch-ids equal, manifest e1031023 unchanged. Gate by Darkwing on 0bbf0f6d1: stack-release.test 218/218, external 25/25, test-release.sh rc 0, T58 c0 pass c1 fail, M1-M7 killed. Filbert APPROVE rev 3111 c30308, carry c30319. Docs 5365adaef (TOOLS.md N1 + BUILD-LOG). #1778. |
|
||||
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | done | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | LANDED 4c485c63b ff from 47a35bf43 (6 Morgana commits). Dewey APPROVE c30487 on 4c485c63b (manifest 00047b52, 13 paths). Gate 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, 10 scripts rc=0 (skips test-queue 1, test-task 2), 22/22 mutants. M3x wall code not-holder (kit comment said human-required). r5 f58cfc361 red, void. Overlap 13/257 clean. |
|
||||
@@ -327,7 +327,15 @@ Generated from `docs/plans/queue.json` revision 3338 by `queue render`. Do not e
|
||||
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | honker; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | — |
|
||||
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
|
||||
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | briefed | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | — |
|
||||
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | briefed | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | — |
|
||||
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | in-review, round 1, request posted | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | — |
|
||||
| 292 | stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | gander; reviewers duckworth | #1797 | briefed | 203 done, 270 done, 271 done | Duckworth approves the manifest. The touched test files run whole, the cli suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_config-checks-any-key-brief.md` § stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | — |
|
||||
| 293 | 574: maintenance-free test fixture commits (Vikunja 574) | gander; reviewers filbert | #1805 | in-progress | — | Filbert approves. The four whole files, then the seat and cli suites, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 574, maintenance-free test fixture commits (Vikunja 574) | — |
|
||||
| 294 | FED R6: Console granted-user sign-in, user-cli transport, peer links | launchpad; reviewers duckworth | #1800 | briefed | — | Duckworth approves the manifest. The webui, cli and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R6, Console granted-user sign-in | — |
|
||||
| 295 | FED R4: revocation | morgana; reviewers dewey | #1801 | briefed | — | Dewey approves the manifest. The bus, cli and business suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R4, revocation | — |
|
||||
| 296 | FED R7: scratch two-instance acceptance | quackerjack; reviewers filbert | #1802 | briefed | 190 done, 294 done, 295 done | Filbert approves the manifest. The script test and every scripts/test-*.sh are green on Sage's gate, and no unit, port or socket of the script's outlives the run. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R7, scratch two-instance acceptance | — |
|
||||
| 297 | GitHub grant method (Bushroot's half of #436b) | bushroot; reviewers dewey | #1803 | briefed | 198 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the GitHub grant method (Bushroot's half of #436b) | — |
|
||||
| 298 | Q228: chief-of-staff grant scope | megavolt; reviewers dewey | #1804 | briefed | 252 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the chief-of-staff grant scope (Q228) | — |
|
||||
| 299 | 573: the P10 hop band (Vikunja 573) | gander; reviewers filbert | #1806 | briefed | 293 done | Filbert approves. The whole file under load at least 3 times, then the cli suite, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 573, the P10 hop band (Vikunja 573) | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+2541
-30
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,528 @@
|
||||
# Q195 O2 W4 review: committed DOCX citations and access
|
||||
|
||||
Reviewer Megavolt (Opus 5.5), assigned by Sage (sage-3061, mos-1360), Gitea #1799. Owner Rocko.
|
||||
Reviewed: `docs/plans/2026-10-22_q195-docx-citations.md`, blob 662abdb7c, commit 15e91538f (one
|
||||
docs-only commit on e36bce352). Against: parent blob 322ec50f2 (`docs/plans/2026-10-21_q195-writing-editor.md`)
|
||||
D11, D12, D24-D26, W4 and W4b. Static review only: no tests, parser runs or live files.
|
||||
|
||||
## Verdict: CHANGES
|
||||
|
||||
The W4 Gate asks for "no unresolved locator or permission ambiguity" and for every bypass closed.
|
||||
The design gets the core right, but nine items are still open against that bar (B1-B9). One part of
|
||||
the design changes Case 2 behaviour and goes to Mos and Jason (M1). None of these needs a new
|
||||
architecture. Each one needs a definition, a rule or an acceptance row.
|
||||
|
||||
## What holds
|
||||
|
||||
- Snapshot: manifest and DOCX come from one reachable commit. There is no worktree, export or
|
||||
engine-memory fallback, and commit/blob is a locator, not an authority token (section 2).
|
||||
- Identity: a copy or new import gets a new document ID with no inherited grant, share, pin or cache.
|
||||
The same blob in another document is not selectable.
|
||||
- Quote verification: exact part, story and ordinal, a code-point range, literal quote equality,
|
||||
SHA-256 equality, blob and commit membership, and a pinned extractor revision. A stale locator
|
||||
returns old text labelled stale and never relocates.
|
||||
- Write paths: none. There is no native MCP, execute, save, replace, tracked-change or comment write,
|
||||
and flags belong to W5 as metadata.
|
||||
- Case 2: the line-cite schema, library grants and supporting-doc writer are unchanged, and no DOCX
|
||||
paragraph is called a line. Q36 stays open and adoption stays closed (mos-1358). A19 and A20 test
|
||||
this.
|
||||
- Parser: transitional namespace only, closed relationship types, no DTD, entities or external
|
||||
relationships, ZIP bounds before allocation, and refusal rather than a truncated success.
|
||||
- Document text is data: comments, deletions and wiki tokens never become instructions.
|
||||
- Text kept out of generic stores: the design keeps O2 text out of generic raw stores and logs,
|
||||
allows audit events to carry only IDs and digests, and does not let a shared extraction cache act
|
||||
as an authorization cache.
|
||||
|
||||
## Blocking findings
|
||||
|
||||
### B1. Several named records have no exact shape
|
||||
|
||||
Section 3 says "all objects reject unknown keys", but these objects are never specified:
|
||||
|
||||
- **The multi-paragraph citation.** Line 100-101 calls it "an ordered nonempty list of these
|
||||
ranges", but `editor_resolve_citation` takes one `{locator}`. The design needs to give:
|
||||
- the wrapper and its kind;
|
||||
- whether each range carries its own quote and digest;
|
||||
- whether every range must share commit, blob, extractor, view and part;
|
||||
- whether the paragraphs must be contiguous;
|
||||
- the maximum count;
|
||||
- how the resolver and the share chip accept the list.
|
||||
- **wikiTokens entries** (line 120 and section 5). The field names, offsets and forms (`link`,
|
||||
`label`, `heading`, `embed`) are not given.
|
||||
- **Anchor story.** Anchors are "a canonical decimal signed XML ID string" (line 127), but locator
|
||||
stories are nonnegative (line 88). Use one grammar for both.
|
||||
- **`paragraph.xmlId`.** It is "JSON null when absent" (line 89), but also "if provided" (line 97).
|
||||
Two locators for the same range must not both be valid. State that null means "this node has no
|
||||
`w14:paraId`" and that a node with one must carry it, or make it always null.
|
||||
- **Departure from D25.** D25 says "resolve paragraph IDs when present, otherwise an ordinal". The
|
||||
design makes the ordinal primary and the paraId only a check. I agree with that on the merits,
|
||||
since paraId is not unique, but the design should name it as a deliberate departure from D25's
|
||||
wording so that Sage's brief carries it. This is O2-only, not a Case 2 item.
|
||||
- **The share record, the chip request and the refusal envelope.** Section 7 needs:
|
||||
- the fields the WebUI sends to have a selection mapped;
|
||||
- the fields of the stored share record;
|
||||
- the error object's keys (section 6 gives only the codes).
|
||||
|
||||
### B2. Extraction is not closed enough to be deterministic
|
||||
|
||||
Section 5 lists containers and a few leaves, then refuses "unknown text-bearing elements". Offsets
|
||||
depend on how each run-level leaf counts, so two conforming extractors can disagree. The design
|
||||
needs a closed leaf table that gives each leaf's text contribution or refusal. At least these are
|
||||
missing:
|
||||
|
||||
- `w:annotationRef`. Word starts every comment with it, so whether comments are readable at all
|
||||
depends on an unstated choice.
|
||||
- `w:footnoteRef` and `w:endnoteRef`, the number mark inside the note's own story.
|
||||
- `w:noBreakHyphen`, `w:softHyphen`, `w:sym` and `w:ptab`.
|
||||
- `w:moveFromRangeStart/End` and `w:moveToRangeStart/End`, which always come with the moves the
|
||||
design supports.
|
||||
- `w:commentRangeStart/End`, as anchors.
|
||||
- `w:permStart/End`.
|
||||
- `w:fldChar`, `w:instrText` and `w:fldSimple`, as refusals.
|
||||
|
||||
Two revision rules are also unstated:
|
||||
|
||||
- **Nested revisions.** A run is in `final` if and only if no ancestor is `del` or `moveFrom`, and in
|
||||
`original` if and only if no ancestor is `ins` or `moveTo`. The design should say so.
|
||||
- **Paragraph-mark revisions.** For `w:pPr/w:rPr/w:ins|w:del`, state that views never merge or split
|
||||
paragraphs, so that an inserted paragraph shows as empty in `original`. That follows from "ordinals
|
||||
do not change with view" but should be explicit.
|
||||
|
||||
### B3. `currentCommit` and staleness are ambiguous for multi-chapter projects
|
||||
|
||||
D10 puts one DOCX per chapter under one book manifest, and the store is per project
|
||||
(`<dataRoot>/writing/<business>/<project>/`). `stale` is "exactly commit != currentCommit"
|
||||
(line 268). If `currentCommit` is the project's newest commit, then saving chapter 2 marks every
|
||||
chapter 1 citation stale and breaks chapter 1's latest cursors, even though chapter 1's blob did not
|
||||
change.
|
||||
|
||||
Two things need a definition:
|
||||
|
||||
- `currentCommit`: for example, the newest published commit that changed this document's DOCX blob
|
||||
or its manifest entry. Alternatively, define `stale` by blob plus manifest entry.
|
||||
- The document's "published history" (line 49): which ref, and which commits count.
|
||||
|
||||
Add an acceptance row: a save to a sibling document leaves `stale:false`.
|
||||
|
||||
### B4. The permission model is prose, not data
|
||||
|
||||
Section 7 intersects six inputs, but the grant side has no schema. Before W4b can implement this
|
||||
the same way twice, the design must answer:
|
||||
|
||||
- Where do document, part, view and comment scopes live: R5's access.json, a new bus table or the
|
||||
role files? Who writes them, through which human-only verb, with what proof, receipt and revision?
|
||||
Rows 207 and R5 already provide that pattern for business grants.
|
||||
- Who is "the principal" in an agent turn: the human who sent the chip, the seat, or both?
|
||||
- Can a `viewer`-level human share? Can a share ever expose a part or view that the sharing human's
|
||||
own grant does not cover?
|
||||
- In a thread with two humans, whose grants does `conversation-current` intersect?
|
||||
- "Historical-view scope" (line 290) appears once and is undefined. Is it a separate scope, or the
|
||||
live grant plus an exact share?
|
||||
- The cursor binds a "grant revision" (line 282). Which revision counts?
|
||||
|
||||
### B5. Share semantics contradict each other
|
||||
|
||||
- Line 329-331 says a share "does not widen it or authorize a later version". Line 340 says "latest
|
||||
whole-document reads require a whole-document share", and A15 says "whole-document latest share
|
||||
explicit". The design must say what a whole-document chip binds:
|
||||
- one commit or latest;
|
||||
- whether it includes notes, comments and the `original`/`markup` views.
|
||||
- Range shares: rows are whole paragraphs, so `editor_read_committed` must refuse under a range-only
|
||||
share. Only `editor_resolve_citation` works with one. State this.
|
||||
- `off` "revokes future reads". Does it also stop resolution of exact shares already sent?
|
||||
- `conversation-current` lasts "until human revocation/session end". Which session is that: the bus
|
||||
session, the thread session (row 269) or the Console login?
|
||||
|
||||
### B6. Paging rules contradict each other
|
||||
|
||||
- Overflow. The design says "response overflow never returns partial data with success", but paging
|
||||
must end a page before a row that would overflow. State that the page ends before the first row
|
||||
that would exceed 64 KiB, and that only a single row too large on its own refuses.
|
||||
- Paragraphs over 64 KiB. The design says a caller "may request a checked subrange through a
|
||||
separately bounded citation", but a citation needs the quote and its digest first, so such a
|
||||
paragraph is unreachable. Either define a bounded subrange read (ordinal plus a code-point window,
|
||||
returning text and a server-made locator) or declare these paragraphs unsupported in v1.
|
||||
|
||||
### B7. Git grants: the design does not forbid a remote
|
||||
|
||||
Case 2's library has a Gitea remote. Agent supporting-doc writes push through each seat's own Gitea
|
||||
account (Q226, case2-rows.md:1634-1641), and seat tokens carry `write:repository` and can fork
|
||||
(packages/bus/src/gitea-grant.mjs:29 and :297). The design says "no raw O2 git clone/read grant", but
|
||||
nothing stops an O2 project repo from getting a remote, mirror or backup push that any seat account
|
||||
can then list, fork or clone.
|
||||
|
||||
The design must state that O2 store repositories have no remote, and that no mirror, backup or push
|
||||
reaches a host an agent account can access. It also needs a stack-copy/release rule: copies never
|
||||
carry the store. Add an A16 probe: a seat Gitea token cannot list, fork or clone an O2 project.
|
||||
|
||||
### B8. The raw-session surfaces are deferred, not closed
|
||||
|
||||
Line 437-438 hands the surface inventory to the W4b brief. The W4 Gate wants raw sessions closed
|
||||
by this design. The stores already exist and can be named now, each with a rule (no O2 text,
|
||||
opaque reference only, or enforced envelope):
|
||||
|
||||
- seat session stores `threads/<business>/<thread>/session/` and `claude-projects/`,
|
||||
`threads/<business>/<thread>/sessions/<session>/` and `session-workspaces/...`
|
||||
(packages/seat/src/session.mjs:23-29);
|
||||
- the bus store's event and thread-message tables, and row 264's sessions;
|
||||
- the Console thread view;
|
||||
- the CLI's `thread`, talk and follow;
|
||||
- the Discord connector (packages/discord, including journal.mjs and context.mjs) and the
|
||||
control-board Discord path;
|
||||
- `<dataRoot>/runs/` run records.
|
||||
|
||||
Run records need a rule of their own. Invariant 5 makes them write-once, so O2 text written there
|
||||
can't be withdrawn on revocation, only pruned. State that O2 content never enters a run record.
|
||||
|
||||
### B9. The migration and compatibility plan is too thin to review
|
||||
|
||||
W4 asks for "a reviewed migration/compatibility plan". Lines 33-35 say only "new O2 records, a schema
|
||||
edge from Sage, no reinterpretation". The plan needs:
|
||||
|
||||
- **New tables and columns.** Name them, and state whether any existing table (thread messages,
|
||||
events) gains a provenance column.
|
||||
- **Rollback.** After `release.sh rollback` to a pre-W4b copy, the old code does not enforce the
|
||||
envelope. Any O2-derived text stored in an existing table becomes readable through the old thread
|
||||
views. Require that O2-derived content is stored only where pre-W4b code can't render it (a new
|
||||
table, or opaque references in existing tables), and add a rollback probe.
|
||||
- **Extractor revision retention.** Old locators need their old revision. Say whether activating a
|
||||
release that drops a revision is allowed, which turns those citations into
|
||||
`editor-version-unavailable`, or whether every published revision is retained.
|
||||
|
||||
## Mos/Jason item
|
||||
|
||||
**M1. O2-context turns narrow Case 2 behaviour.**
|
||||
|
||||
Section 7, lines 362-371, does two things in any turn that received O2 context:
|
||||
|
||||
- It refuses "generic supporting-doc writes, git writes, exports and arbitrary outgoing tools".
|
||||
- It classifies every output of that turn, and of any resumed or forked descendant, with the O2
|
||||
document scopes.
|
||||
|
||||
In a Case 2 thread, that means:
|
||||
|
||||
- C2-02's supporting-doc write, from the Q226 rows (mos-1309), stops working once the human shares
|
||||
a chip;
|
||||
- the agent's C2-06 replies become invisible to thread members without the O2 grant.
|
||||
|
||||
That changes a Case 2 requirement. Under the W4 Gate it goes to Jason through Mos before code, not
|
||||
as a design choice.
|
||||
|
||||
My recommendation: confine O2 context to O2-dedicated threads or sessions. Case 2 semantics then
|
||||
stay untouched, and the narrowing applies only where it was opted into. Otherwise Jason has to
|
||||
approve the narrowing for mixed threads.
|
||||
|
||||
The rest of the design changes no Case 2 requirement. Q36 and D12 stay open with Jason as before,
|
||||
and the design builds no notice.
|
||||
|
||||
## Non-blocking notes
|
||||
|
||||
- N1. Give the canonical serialization of the extractor build manifest that `extractor.revision`
|
||||
digests, so that the digest can be recomputed independently.
|
||||
- N2. A locator's `business` field must equal the broker-bound business. Otherwise refuse with
|
||||
`editor-access-refused`, the same response as for a foreign ID.
|
||||
- N3. The 100:1 expansion ratio: say whether it is per entry, total, or both.
|
||||
- N4. Line 68-69 says "no implicit defaults except the reading view", but `limit` defaults to 20 and
|
||||
`cursor` to null. Reword.
|
||||
- N5. Hyperlinks, fields and text boxes refuse the projection. Many real chapters will then be
|
||||
agent-unreadable. That is acceptable for v1, but the W4b brief and the human UI should say why a
|
||||
chapter is unavailable.
|
||||
- N6. Readiness should come from the verified release artifact (extractor manifest plus the
|
||||
formatting-property list), not a system-config field, since configs are human-authored
|
||||
(invariant 2).
|
||||
- N7. Add acceptance rows for B3 (sibling save), B6 (oversized paragraph), B7 (seat token against an
|
||||
O2 repo), B8 (each named store, including run records) and B9 (rollback).
|
||||
|
||||
## Next round
|
||||
|
||||
Revise the design for B1-B9 and send the new commit and blob to me and Sage. I re-review only the
|
||||
changed sections and the items above. M1 waits on Mos and Jason. If the design takes the
|
||||
O2-only-thread recommendation, M1 closes without a ruling.
|
||||
|
||||
## Round 2: 019a0ea5b (blob 14dfe92d9)
|
||||
|
||||
Reviewed: Rocko's round 2 commit 019a0ea5b, a fast-forward on 15e91538f, design blob 14dfe92d9.
|
||||
I read the whole file again, not only the changed lines, because the change was large (+453/-120).
|
||||
Static review only.
|
||||
|
||||
### Verdict: CHANGES
|
||||
|
||||
The design now closes B1-B9 and M1 as they were written. Two problems remain. R2-B1 is a
|
||||
projection rule that contradicts D25's "final accepted reading". R2-B2 is an existing backup and
|
||||
restore path that section 7 does not cover. Each needs a rule and an acceptance row, not a new
|
||||
design.
|
||||
|
||||
### Round 1 items
|
||||
|
||||
- **B1, closed.**
|
||||
- The `docx-selection` wrapper: 2..32 consecutive ranges, a shared snapshot, empty intermediate
|
||||
paragraphs, one budget for the whole selection, atomic failure and one canonical form.
|
||||
- `xmlId` is null exactly when the node has no paraId.
|
||||
- The wikiToken and anchor grammars now match the locator.
|
||||
- The chip, share and refusal envelopes are exact.
|
||||
- The departure from D25 is named (lines 108-111).
|
||||
- **B2, closed except for R2-B1.** The closed leaf table and the ancestor veto for nested revisions
|
||||
are in (lines 268-310).
|
||||
- **B3, closed.**
|
||||
- `refs/heads/writing-published` advances first-parent with no force.
|
||||
- Publication commits are defined per document, and `currentCommit` and stale are per document.
|
||||
- A23 covers a sibling save.
|
||||
- **B4, closed.**
|
||||
- The principal is a tuple.
|
||||
- Scope lives in `writing_access_events`, written by the business owner's existing human proof
|
||||
only.
|
||||
- Agent `mayShare` is false, and viewers cannot share.
|
||||
- The authority vector names its sources.
|
||||
- Two-human threads are refused (`editor-sharing-unsupported`).
|
||||
- **B5, closed.**
|
||||
- A range share allows resolve only.
|
||||
- A document chip binds one exact publication.
|
||||
- A latest share exists only under `conversation-current` with a whole-document chip.
|
||||
- `off` and any policy change invalidate all earlier shares.
|
||||
- The share's session is the new O2 execution session.
|
||||
- **B6, closed.** A page ends before the first row that would overflow. A row too large for an
|
||||
empty page is unsupported in v1, and A24 covers it.
|
||||
- **B7, closed.**
|
||||
- O2 repositories have no remotes, mirrors or forks, and the store refuses one.
|
||||
- Copies exclude `writing/` and `writing-context/`.
|
||||
- A25 includes a seat-token probe.
|
||||
- **B8, closed except for R2-B2.** Section 7.4 names every store and renderer I listed, plus
|
||||
`launches/` and `bus-host/sessions.json`. The run-records rule is explicit, and A26 probes each
|
||||
store.
|
||||
- **B9, closed except for R2-B2.**
|
||||
- New tables are named, append-only, with no change to existing columns.
|
||||
- Old renderers never query the new tables.
|
||||
- A rollback probe and an extractor-retention rule are in.
|
||||
- **M1, closed. No Mos/Jason item.** O2 context exists only in new, dedicated O2 conversations and
|
||||
execution sessions. Existing C2 threads cannot be converted, attached, resumed or forked into O2
|
||||
mode. C2-02 supporting-doc writes and C2-06 replies keep their meaning (lines 433-441), and A28
|
||||
tests that. This adds new O2 behaviour and changes no Case 2 requirement.
|
||||
- **N1-N7, addressed.** N1 is the canonical manifest bytes (lines 155-168). N2 is the business
|
||||
check (lines 378-379). N3 is the ratio rule at line 257. N4 is the defaults wording at line 84.
|
||||
N5 is the UI reason (lines 688-690). N6 is release-derived readiness. N7 is A23-A31.
|
||||
|
||||
### R2-B1. Paragraph-mark revisions drop accepted text, and reserved notes would refuse
|
||||
|
||||
**What the design says.** Lines 276-281: "an inserted paragraph mark makes that paragraph's
|
||||
projected text empty in original; a deleted paragraph mark makes it empty in final."
|
||||
|
||||
**What the markup means.** In WordprocessingML, `w:pPr/w:rPr/w:del` marks only the paragraph mark
|
||||
as deleted. Accepting the change merges this paragraph's text into the next one. The text is gone
|
||||
only if its own runs sit under `w:del`, and the ancestor veto already handles that. `w:ins` on the
|
||||
mark works the same way in `original`.
|
||||
|
||||
**Why that breaks the projection.** When a human joins two paragraphs with tracking on, the first
|
||||
paragraph's live text disappears from `final`. A quote taken from the accepted reading then can't be
|
||||
cited. That contradicts D25's "default final accepted reading" and the design's own definition at
|
||||
line 267.
|
||||
|
||||
**Fix.** Paragraph-mark revisions never change text. Runs follow the ancestor rule alone. Since
|
||||
views never merge or split paragraphs, a mark-only revision shows up only as a zero-width
|
||||
whole-paragraph-mark span in `markup`. Extend A31 with a mark-only deletion whose text stays in
|
||||
`final`, and a mark-only insertion whose text stays in `original`.
|
||||
|
||||
**Reserved notes.** The leaf table "is exhaustive; unknown leaves refuse" (line 305). Word writes a
|
||||
separator note and a continuation-separator note into every `footnotes.xml` and `endnotes.xml`,
|
||||
holding `w:separator` and `w:continuationSeparator` (and sometimes `w:continuationNotice`). Line
|
||||
227-229 calls these notes structural, but the table doesn't name those leaves. Read literally, every
|
||||
document that has footnotes refuses. Name the three leaves as allowed only inside reserved notes,
|
||||
structurally validated and never projected. Say whether leaf validation covers the whole part or
|
||||
only the requested story.
|
||||
|
||||
### R2-B2. The release backup and restore path keeps O2 text and can revive revoked access
|
||||
|
||||
**What the design says.**
|
||||
- Section 7.4 forbids "plaintext backup" of O2 material (lines 625-626).
|
||||
- Section 7.5 puts O2 message bodies, share selections (which hold quotes) and every grant and
|
||||
policy event into new tables in the bus store (lines 638-663).
|
||||
|
||||
**What the release script does.** `scripts/stack-release.sh` copies that store, in plaintext, on
|
||||
every activate:
|
||||
- `backup_store` writes `$DATA/backups/stack/<ACT>/bus/bus.sqlite` (lines 474-493).
|
||||
- It never prunes backups (line 18).
|
||||
- `rehearse` reads them (line 22).
|
||||
- `restore_store` moves the current `bus/` into a post-attempt directory and deletes nothing
|
||||
(line 496).
|
||||
|
||||
So O2 text persists in backups and post-attempt directories that section 7.4 does not name.
|
||||
|
||||
**The restore gap.** `rollback --restore` puts back an older store. The guard counts `events` plus
|
||||
the fixed `COUNTED` list (lines 16-17 and 418-420). The design keeps O2 writes out of generic
|
||||
events (lines 659-661), and the new tables are not in `COUNTED`. A restore over revocations made
|
||||
after the backup therefore passes the guard without Jason's `--events N`. It also silently brings
|
||||
back the revoked `writing_access_events`, `writing_policy_events` and session-end rows. That
|
||||
revives revoked grants and shares, against section 7.3's "a scope decrease/revocation denies even
|
||||
historical shares".
|
||||
|
||||
**What the design needs to state.**
|
||||
1. **Backups.** Either O2 bodies and quotes live outside `bus.sqlite` (for example under
|
||||
`writing-context/`) so that release backups hold opaque rows only, or release backups and
|
||||
post-attempt directories are named O2-classified stores in section 7.4, with mode, access and a
|
||||
prune rule.
|
||||
2. **The guard.** Every `writing_*` table joins the restore guard's counted list. The T46 guard
|
||||
already requires every schema table to be classified.
|
||||
3. **After a restore.** A restore that drops any `writing_*` row disables O2 readiness until the
|
||||
owner reconfirms access. It never brings back an older grant, share or policy.
|
||||
4. **Acceptance.** Add a row: a revocation after the backup, then a restore, must leave the revoked
|
||||
share refused. A secret marker must stay absent from every backup and post-attempt path except
|
||||
the named O2-classified ones.
|
||||
|
||||
### Non-blocking
|
||||
|
||||
- R2-N1. Define the agent `subject.id` for `editor.access.set` as the bus agent instance ID (the
|
||||
principal tuple's `agentInstance`), so a grant can't name a role or a seat display name.
|
||||
- R2-N2. Lines 330-332 restate the structural leaves already in the table. Drop the sentence or
|
||||
point it to the table, so there is one list.
|
||||
|
||||
### Next round
|
||||
|
||||
Fix R2-B1 and R2-B2 and send the commit and blob to me and Sage. I'll re-check those two items and
|
||||
any lines they touch.
|
||||
|
||||
## Round 3: 54246cb32 (blob 0c4f55826)
|
||||
|
||||
Reviewed: Rocko's round 3 commit 54246cb32, a fast-forward on 019a0ea5b, design blob 0c4f55826.
|
||||
I read the whole delta and every definition it touches. I checked its citations against the same
|
||||
commit: `COUNTED` is at `scripts/stack-release.sh:420`, T46 is at
|
||||
`packages/cli/tests/stack-release.test.mjs:913`, and the guard counts `WHERE at > STAMP`, which
|
||||
works with the `at` column every `writing_*` table has. Static review only.
|
||||
|
||||
### Verdict: CHANGES
|
||||
|
||||
R2-B1's paragraph-mark rule and all of R2-B2 are closed. One gap from R2-B1 is still open, and the
|
||||
new fence adds a second. Each needs a sentence or two, not a redesign.
|
||||
|
||||
### Round 2 items
|
||||
|
||||
- **R2-B1, paragraph marks: closed.**
|
||||
- A mark-only `w:ins`/`w:del` changes no projected text in any view.
|
||||
- Paragraphs are never merged or split.
|
||||
- Markup records a zero-width `paragraph-mark-insert`/`paragraph-mark-delete` span, and
|
||||
`revisionSpans` allows those kinds with start=end.
|
||||
- A31 covers both cases.
|
||||
- **R2-B1, reserved-note leaves: open.** The table rows and whole-part validation are in (lines
|
||||
301 and 311-318). How a reserved note is recognized is still wrong; see R3-B1.
|
||||
- **R2-B2: closed.**
|
||||
- Message bodies and share payloads leave SQLite for 0600 content objects under
|
||||
`writing-context/objects/`. The bus tables hold only refs and digests.
|
||||
- Backup and post-attempt trees are named O2-classified metadata stores, with modes, link checks,
|
||||
rehearse limits and a receipt-based prune that only a human can run.
|
||||
- All eight `writing_*` tables join `COUNTED` and T46, and row identities are compared.
|
||||
- An independent fence epoch, set before every store swap, stops restored rows from granting
|
||||
access, and owner confirmation can't revive them.
|
||||
- A32 covers the revocation, restore, crash and marker sweep.
|
||||
- **R2-N1: closed.** The agent `subject.id` is the agent instance ID.
|
||||
- **R2-N2: closed.** The structural-leaf list now points to the table.
|
||||
|
||||
### R3-B1. Reserved notes are recognized by negative ID
|
||||
|
||||
Line 228 defines reserved notes as "negative IDs and type separator/continuationSeparator". Word
|
||||
normally writes the separator as `w:id="-1"` and the continuation separator as `w:id="0"`, so the
|
||||
continuation separator is not negative. A `w:type="continuationNotice"` note is a third reserved
|
||||
type, and line 228 leaves it out, although the leaf table names it.
|
||||
|
||||
Read with lines 315-317 ("A separator leaf in a normal note/body/comment refuses"), the id-0
|
||||
continuation separator counts as a normal note. Its `continuationSeparator` leaf then refuses, and
|
||||
every footnoted document Word writes is refused again. ECMA-376 identifies these notes by `w:type`;
|
||||
the ID is only a number. I'm working from Word's usual output here, so a fixture from real Word
|
||||
should confirm it.
|
||||
|
||||
Fix:
|
||||
- Classify a note as reserved by `w:type` in {separator, continuationSeparator,
|
||||
continuationNotice}, with any integer ID.
|
||||
- A normal note has no `w:type` or `w:type="normal"` and a canonical nonnegative ID.
|
||||
- A reference to a reserved note refuses.
|
||||
- Add Word's -1/0 layout to A31.
|
||||
|
||||
### R3-B2. The fence's first state and when confirmation is needed are undefined
|
||||
|
||||
Line 791 says a missing fence denies O2. Only release and the owner-proof handler may write it
|
||||
(lines 762-763), and only "before any store swap" is a write moment defined (line 767). Nothing says
|
||||
who creates the fence when W4b's tables are first created, or with what epoch.
|
||||
|
||||
It is also unclear whether `confirmedBusinesses` is consulted on a store that has never been
|
||||
restored. Read literally, a fresh fence has no confirmed business, so O2 stays off until an
|
||||
`editor.restore.confirm` that nothing calls for. The other reading, confirmation only after a
|
||||
restore, needs a rule saying so.
|
||||
|
||||
Either way this is a gap in an authorization check: it fails closed, but an implementer has to
|
||||
invent the enable path. Fix:
|
||||
- State that the compatible controller creates the fence durably with the W4b migration (or at
|
||||
first O2 enable), with a fresh epoch, `restoreOperation` null and the empty dropped-rows digest.
|
||||
- State whether `confirmedBusinesses` gates O2 always or only after a fence whose
|
||||
`restoreOperation` is non-null.
|
||||
- Add both states to A32.
|
||||
|
||||
### Non-blocking
|
||||
|
||||
- R3-N1. Content objects are named by the SHA-256 of `{body,provenance}`. Two equal objects in
|
||||
different businesses share one file, so a per-business prune or retention action can delete
|
||||
another business's message. The digest kept in metadata backups also lets anyone confirm a
|
||||
guessed short body ("ok"). Put the row `id` (or a random nonce) in the canonical object so each
|
||||
digest is unique and can't be guessed.
|
||||
- R3-N2. Line 791 says rolled-back fence state denies O2, but the fence keeps only its current epoch,
|
||||
so nothing can detect a rollback. Either define the check (for example, a monotonic fence
|
||||
generation that every new row also records, and deny when the bus holds a newer generation than
|
||||
the fence) or drop the claim.
|
||||
- R3-N3. `restore_store` begins at `scripts/stack-release.sh:498`; line 727 cites ":496ff", as my
|
||||
round 2 did. Cosmetic.
|
||||
|
||||
### Next round
|
||||
|
||||
Fix R3-B1 and R3-B2 and send me and Sage the commit and blob. I'll re-check those two items and the
|
||||
lines they touch.
|
||||
|
||||
## Round 4: 994c3f7fb (blob 2af460ab7)
|
||||
|
||||
Reviewed: Rocko's round 4 commit 994c3f7fb, a fast-forward on 54246cb32, design blob 2af460ab7.
|
||||
I read the whole delta and the definitions it touches. Static review only. I did not download the
|
||||
two public fixtures Rocko cites, so their sizes and hashes are Rocko's; what they report (-1/0 in
|
||||
Word 15, 0/1 in Word 12) matches my round 3 claim and also shows that an ID can't identify a
|
||||
reserved note.
|
||||
|
||||
### Verdict: APPROVE
|
||||
|
||||
Every blocker from rounds 1-3 is closed, and M1 closed in round 2 with no Mos/Jason item. The
|
||||
design meets the W4 Gate (spec blob 322ec50f2, lines 806-850):
|
||||
- no locator or permission ambiguity is left;
|
||||
- new O2 copies, generic reads, git grants and raw sessions are closed as bypasses;
|
||||
- quotes verify against a committed blob and an extractor revision;
|
||||
- there is no unsaved-context or agent write path;
|
||||
- Case 2 Markdown authorization is unchanged;
|
||||
- a migration and compatibility plan is present.
|
||||
|
||||
W4b owns the implementation and the A1-A32 evidence. This approval covers the design, not passing
|
||||
tests.
|
||||
|
||||
### Round 3 items
|
||||
|
||||
- **R3-B1: closed.**
|
||||
- Reserved notes are classified by `w:type` in {separator, continuationSeparator,
|
||||
continuationNotice}, with any canonical signed ID (line 227).
|
||||
- A normal note has no type or type normal, plus a nonnegative ID; unknown types refuse.
|
||||
- Any reference to a reserved note refuses, including an id-0 continuation separator.
|
||||
- Duplicate IDs within a part refuse.
|
||||
- A31 covers both the -1/0 and 0/1 layouts.
|
||||
- **R3-B2: closed.**
|
||||
- The first schema migration creates the fence durably before the tables commit, with a fresh
|
||||
epoch, `restoreOperation` null, the `[]` digest and no confirmed businesses (line 781).
|
||||
- Confirmation applies only when `restoreOperation` is non-null (line 819).
|
||||
- A missing or malformed fence over existing `writing_*` tables or history fails closed and
|
||||
never reinitializes (line 790), and a crash resumes on the same epoch.
|
||||
- A32 covers both states.
|
||||
- **R3-N1: closed.**
|
||||
- Each object holds business, project, row and a private 32-byte nonce, and lives under
|
||||
`objects/<business>/<row>/`.
|
||||
- There's no cross-row deduplication, and retention is per owning row.
|
||||
- **R3-N2: closed.** The rollback-detection claim is replaced by an honest limit: a same-uid
|
||||
operator bypassing the protocol is outside what the file can prove.
|
||||
- **R3-N3: closed.** The `:498` citation is fixed.
|
||||
|
||||
### Non-blocking, for W4b
|
||||
|
||||
- R4-N1. Line 790 allows initialization only with no `writing_*` tables and no fence. One case is
|
||||
unnamed: a compatible-controller restore of a backup taken before W4b, then a re-migration. The
|
||||
tables are then absent while a rotated fence with a non-null `restoreOperation` exists. Neither
|
||||
reading opens a bypass, because the restore dropped every `writing_*` row. W4b should still say
|
||||
that re-migration keeps the existing rotated epoch and its confirmation requirement, rather than
|
||||
refusing forever or writing a new null fence. Add that case to A32.
|
||||
@@ -246,7 +246,7 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
|
||||
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
|
||||
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
|
||||
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
|
||||
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
|
||||
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default,compactions}]`. `compactions` is `{count,known,reason,at}` from the host's ledger (row 270, #1775): `known` true gives the exact count of completed Pi compactions; otherwise `count` is a lower bound or null and `reason` is `harness-unsupported`, `not-collected`, `store-unreadable` or `store-incomplete`. `not-collected` passes: the next collection reads on. A launcher killed mid-write leaves a torn ledger line, and the count stays unknown (`store-incomplete`) for good, by design (sage-2806 condition 3). Writes nothing. |
|
||||
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
|
||||
|
||||
### Projects and threads (row 121, #1615)
|
||||
|
||||
@@ -493,3 +493,40 @@ CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
|
||||
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TABLE session_ops (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
op TEXT NOT NULL,
|
||||
stage TEXT NOT NULL CHECK (stage IN ('requested','drained','uncertain','exited','retired','failed','cancelled','start-held','start-started','start-refused')),
|
||||
attempt INTEGER NOT NULL CHECK (CASE WHEN stage GLOB 'start-*' THEN attempt >= 1 ELSE attempt = 0 END),
|
||||
work_attempt INTEGER CHECK (work_attempt IS NULL OR (stage GLOB 'start-*' AND work_attempt >= 1)),
|
||||
successor TEXT NOT NULL,
|
||||
successor_name TEXT NOT NULL,
|
||||
run TEXT,
|
||||
generation INTEGER CHECK (generation IS NULL OR generation >= 1),
|
||||
digest TEXT,
|
||||
refusal TEXT,
|
||||
UNIQUE (thread, op, stage, attempt)
|
||||
) STRICT;
|
||||
CREATE TABLE message_cancels (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
message TEXT NOT NULL UNIQUE REFERENCES messages(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
op TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX session_ops_session ON session_ops (session, seq);
|
||||
CREATE TRIGGER session_ops_no_update BEFORE UPDATE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
|
||||
CREATE TRIGGER session_ops_no_delete BEFORE DELETE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
|
||||
CREATE TRIGGER session_ops_no_replace BEFORE INSERT ON session_ops WHEN EXISTS (SELECT 1 FROM session_ops WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
|
||||
CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops
|
||||
WHEN NEW.stage = 'requested' AND EXISTS (SELECT 1 FROM session_ops o WHERE o.session = NEW.session
|
||||
AND o.seq = (SELECT max(seq) FROM session_ops WHERE thread = o.thread AND op = o.op)
|
||||
AND o.stage IN ('requested','drained','uncertain','exited'))
|
||||
BEGIN SELECT RAISE(ABORT, 'session has an open clear'); END;
|
||||
CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
|
||||
CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
|
||||
CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
|
||||
+382
-11
@@ -127,7 +127,9 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
|
||||
'instance.launch', 'instance.end', 'instance.request',
|
||||
// Row 264a (#1769): the sessions of the tester's own threads.
|
||||
'session.list', 'session.create']);
|
||||
'session.list', 'session.create',
|
||||
// Row 271 (264d, #1776): clear context in the tester's own threads.
|
||||
'session.clear', 'session.cancel', 'session.operation']);
|
||||
// FED R3 (#414, federation design 3.5): the collaborator level is this exact list. Every other verb
|
||||
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
|
||||
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
|
||||
@@ -394,6 +396,7 @@ export class Broker {
|
||||
// FED R3: () => the checked access document (R1's loadAccess), read once per user request. Null,
|
||||
// a throw or no document leaves every user with no grant (design 3.5, fail closed).
|
||||
#access;
|
||||
#compactions;
|
||||
// r6 X3: interrupted host grants whose remote undo waits for reconcileHosts, each with its claim.
|
||||
#hostUndo = [];
|
||||
// r6 section 6 (N4): revokes a restart cut short, whose step 3 waits for reconcileHosts.
|
||||
@@ -419,13 +422,14 @@ export class Broker {
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, compactions = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
|
||||
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
|
||||
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
|
||||
if (compactions !== null && typeof compactions !== 'function') throw new TypeError('compactions must be a function');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
|
||||
@@ -445,6 +449,7 @@ export class Broker {
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#access = access;
|
||||
this.#compactions = compactions;
|
||||
this.#project = project;
|
||||
this.#library = library;
|
||||
this.#usage = usage;
|
||||
@@ -610,6 +615,108 @@ export class Broker {
|
||||
if (!this.#store.get('SELECT 1 FROM sessions WHERE id=? AND thread=?', identifier(session), t.id)) fail('session-not-found');
|
||||
return session;
|
||||
}
|
||||
// Row 271 (264d, #1776, D7): a clear of a session is one op, its rows in session_ops. The op is
|
||||
// open while its latest row is requested, drained, uncertain or exited (session_ops_one_open).
|
||||
#openOp(session) {
|
||||
return this.#store.get(
|
||||
`SELECT o.* FROM session_ops o WHERE o.session=? AND o.seq=(SELECT max(seq) FROM session_ops WHERE thread=o.thread AND op=o.op)
|
||||
AND o.stage IN ('requested','drained','uncertain','exited')`,
|
||||
session,
|
||||
);
|
||||
}
|
||||
// active, clearing, uncertain or retired, with the op that made it so.
|
||||
#sessionState(session) {
|
||||
const retired = this.#store.get("SELECT op, successor FROM session_ops WHERE session=? AND stage='retired'", session);
|
||||
if (retired) return { state: 'retired', op: retired.op, successor: retired.successor };
|
||||
const open = this.#openOp(session);
|
||||
if (!open) return { state: 'active', op: null, successor: null };
|
||||
return { state: open.stage === 'uncertain' ? 'uncertain' : 'clearing', op: open.op, successor: null, open };
|
||||
}
|
||||
// The fence: no send, open or claim reaches a session being cleared, or a retired one.
|
||||
#sessionFence(session) {
|
||||
const { state } = this.#sessionState(session);
|
||||
if (state === 'retired') fail('session-retired');
|
||||
if (state !== 'active') fail('session-clearing');
|
||||
}
|
||||
// A session's revision: 0 at creation, plus one for each row of its clears.
|
||||
#revision(session) {
|
||||
return this.#store.get('SELECT count(*) AS n FROM session_ops WHERE session=?', session).n;
|
||||
}
|
||||
// A name a session has, or one a clear has bound for its successor.
|
||||
#nameTaken(thread, sessionName) {
|
||||
return Boolean(
|
||||
this.#store.get('SELECT 1 FROM sessions WHERE thread=? AND name=?', thread, sessionName) ??
|
||||
this.#store.get('SELECT 1 FROM session_ops WHERE thread=? AND successor_name=?', thread, sessionName),
|
||||
);
|
||||
}
|
||||
// The successor's id, bound at accept so a repeat finds it: a lowercase RFC 9562 version 8 UUID
|
||||
// from a sha256 of the op, which every session selector takes (cli/src/thread.mjs:41, gizmoduck-0274).
|
||||
#successorId(business, thread, session, op) {
|
||||
const h = createHash('sha256').update(JSON.stringify([business, thread, session, op])).digest('hex').split('');
|
||||
h[12] = '8';
|
||||
h[16] = '89ab'[parseInt(h[16], 16) & 3];
|
||||
const x = h.join('');
|
||||
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
|
||||
}
|
||||
// A clear verb's payload with its caller, for the repeat and conflict checks.
|
||||
#opDigest(s, ...payload) {
|
||||
return createHash('sha256').update(JSON.stringify([this.#owner(s), ...payload])).digest('hex');
|
||||
}
|
||||
// One session_ops row. `op` is the op's requested row, or the fields it is made from.
|
||||
#appendOp(op, stage, { attempt = 0, workAttempt = null, run = null, generation = null, digest = null, refusal = null } = {}) {
|
||||
this.#store.run(
|
||||
'INSERT INTO session_ops(at,business,thread,session,op,stage,attempt,work_attempt,successor,successor_name,run,generation,digest,refusal) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
this.#now(),
|
||||
op.business,
|
||||
op.thread,
|
||||
op.session,
|
||||
op.op,
|
||||
stage,
|
||||
attempt,
|
||||
workAttempt,
|
||||
op.successor,
|
||||
op.successor_name,
|
||||
run,
|
||||
generation,
|
||||
digest,
|
||||
refusal,
|
||||
);
|
||||
}
|
||||
#opRow(thread, op, stage) {
|
||||
return this.#store.get('SELECT * FROM session_ops WHERE thread=? AND op=? AND stage=? ORDER BY seq LIMIT 1', thread, op, stage);
|
||||
}
|
||||
// The answer to every clear verb, from the op's rows (BUILD.md's contract for 264e).
|
||||
#clearEnvelope(thread, op) {
|
||||
const rows = this.#store.all('SELECT * FROM session_ops WHERE thread=? AND op=? ORDER BY seq', thread, op);
|
||||
if (!rows.length) fail('operation-not-found');
|
||||
const has = (stage) => rows.some((r) => r.stage === stage);
|
||||
const last = rows.at(-1);
|
||||
const start = rows.filter((r) => r.stage.startsWith('start-')).at(-1) ?? null;
|
||||
const state = has('cancelled') ? 'cancelled' : has('failed') ? 'failed' : has('retired') ? 'retired' : last.stage === 'uncertain' ? 'uncertain' : 'clearing';
|
||||
const refusal = state === 'failed' ? rows.find((r) => r.stage === 'failed').refusal : start?.stage === 'start-refused' ? start.refusal : null;
|
||||
return {
|
||||
op,
|
||||
thread,
|
||||
session: rows[0].session,
|
||||
successor: rows[0].successor,
|
||||
state,
|
||||
start: start ? start.stage.slice('start-'.length) : null,
|
||||
refusal,
|
||||
...(state === 'cancelled' ? { retired: has('retired') ? true : null } : {}),
|
||||
at: last.at,
|
||||
};
|
||||
}
|
||||
// D7 step 1: the session's undelivered inputs are cancelled, each with a receipt, never replayed.
|
||||
#cancelInputs(op, instance) {
|
||||
const rows = this.#store.all(
|
||||
`SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.to_role=?
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))
|
||||
AND NOT EXISTS(SELECT 1 FROM message_cancels c WHERE c.message=m.id) ORDER BY m.seq`,
|
||||
op.session,
|
||||
instance,
|
||||
);
|
||||
for (const m of rows) this.#store.run('INSERT INTO message_cancels(at,message,session,op) VALUES(?,?,?,?)', this.#now(), m.id, op.session, op.op);
|
||||
}
|
||||
// Row 155 (#1649): the held claims of every session of a thread instance, oldest thread first, or
|
||||
// only the threads `owner` made. An end of the instance ends each of them.
|
||||
#threadHolders(business, instance, owner = null) {
|
||||
@@ -763,6 +870,15 @@ export class Broker {
|
||||
if (s.tester && this.#store.get('SELECT owner FROM threads WHERE id=?', thread).owner !== this.#owner(s)) fail('thread-not-owned');
|
||||
return thread;
|
||||
}
|
||||
#compactionCount(t, session, legacy) {
|
||||
const unknown = (reason) => ({ count: null, known: false, reason, at: null });
|
||||
if (!this.#compactions) return unknown('not-collected');
|
||||
try {
|
||||
return this.#compactions({ business: t.business, thread: t.id, session, legacy });
|
||||
} catch {
|
||||
return unknown('store-unreadable');
|
||||
}
|
||||
}
|
||||
#thread(s, id) {
|
||||
const t = this.#threadRow(s, id);
|
||||
// REQ-USER-2: a user reaches only their own threads.
|
||||
@@ -939,15 +1055,158 @@ export class Broker {
|
||||
this.#store.transaction(() => this.#claimEnd(session, this.#own(session), 'release', null));
|
||||
return { released: true };
|
||||
}
|
||||
// Row 271 (264d, #1776, D7 steps 3-5): the business's clears still in the launcher's hands, as of
|
||||
// each op's latest row. An open op waits for its drained run's stop and exit proof, then its
|
||||
// retirement. A retired op whose successor hasn't started, and wasn't cancelled, waits for its
|
||||
// start through D8. `run` and `generation` are the drained row's. Trusted launcher API, not a
|
||||
// socket verb.
|
||||
sessionClears(business) {
|
||||
this.#business(identifier(business));
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT o.*, t.instance FROM session_ops o JOIN threads t ON t.id=o.thread
|
||||
WHERE o.business=? AND o.seq=(SELECT max(seq) FROM session_ops WHERE thread=o.thread AND op=o.op)
|
||||
AND o.stage IN ('requested','drained','uncertain','exited','retired','start-held','start-refused')
|
||||
ORDER BY o.seq`,
|
||||
business,
|
||||
)
|
||||
.map((o) => {
|
||||
const drained = this.#opRow(o.thread, o.op, 'drained');
|
||||
return {
|
||||
thread: o.thread,
|
||||
instance: o.instance,
|
||||
session: o.session,
|
||||
op: o.op,
|
||||
stage: o.stage,
|
||||
run: drained?.run ?? null,
|
||||
generation: drained?.generation ?? null,
|
||||
successor: o.successor,
|
||||
refusal: o.refusal,
|
||||
};
|
||||
});
|
||||
}
|
||||
// The requested row of a clear the launcher names, in the business it names.
|
||||
#launcherOp(record, extra = []) {
|
||||
keys(record, ['business', 'thread', 'op', ...extra], ['business', 'thread', 'op', ...extra]);
|
||||
this.#business(identifier(record.business));
|
||||
const first = this.#opRow(identifier(record.thread), identifier(record.op), 'requested');
|
||||
if (!first || first.business !== record.business) fail('operation-not-found');
|
||||
return first;
|
||||
}
|
||||
// D7 step 3: the exit proof, from the launcher. The drained run's own close handler wrote its
|
||||
// session.ended (endLaunch), which is checked here; a signal, a revoked claim or a dead socket
|
||||
// is not proof. With no run, the proof is the launcher's: it took the session's workspace lock,
|
||||
// and its registry holds no run of the session. Trusted launcher API.
|
||||
sessionClearExited(record) {
|
||||
const first = this.#launcherOp(record, ['run']);
|
||||
if (record.run !== null) identifier(record.run);
|
||||
return this.#store.transaction(() => {
|
||||
const open = this.#openOp(first.session);
|
||||
if (open?.op !== first.op) fail('operation-not-open');
|
||||
if (open.stage === 'exited') return this.#clearEnvelope(first.thread, first.op);
|
||||
const drained = this.#opRow(first.thread, first.op, 'drained') ?? fail('not-drained');
|
||||
if (record.run !== drained.run) fail('not-drained-run');
|
||||
if (drained.run !== null && !this.#store.get("SELECT 1 FROM events WHERE business=? AND kind='session.ended' AND subject=?", first.business, drained.run))
|
||||
fail('run-not-ended');
|
||||
this.#appendOp(first, 'exited', { run: drained.run, generation: drained.generation });
|
||||
return this.#clearEnvelope(first.thread, first.op);
|
||||
});
|
||||
}
|
||||
// D7 step 3: 30 s after the stop with no proof, once. Nothing else is signalled or forced.
|
||||
sessionClearUncertain(record) {
|
||||
const first = this.#launcherOp(record);
|
||||
return this.#store.transaction(() => {
|
||||
const open = this.#openOp(first.session);
|
||||
if (open?.op !== first.op) fail('operation-not-open');
|
||||
if (open.stage === 'drained') this.#appendOp(first, 'uncertain');
|
||||
else if (open.stage !== 'uncertain') fail('not-drained');
|
||||
return this.#clearEnvelope(first.thread, first.op);
|
||||
});
|
||||
}
|
||||
// D7 step 4: retirement and the successor, in one transaction, after the exit proof. Owner, grant
|
||||
// and halt are rechecked here (N4). A halt refuses and writes nothing, so the launcher asks again
|
||||
// once processing resumes; a lost grant ends the clear `failed`. Only the proven run may still hold
|
||||
// the claim, and its claim ends here only if endRun hasn't ended it (N5).
|
||||
sessionClearRetire(record) {
|
||||
const first = this.#launcherOp(record);
|
||||
return this.#store.transaction(() => {
|
||||
const open = this.#openOp(first.session);
|
||||
if (open?.op !== first.op || open.stage !== 'exited') fail('not-exited');
|
||||
this.#processingWork();
|
||||
const t = this.#store.get('SELECT * FROM threads WHERE id=?', first.thread);
|
||||
const refusal = this.#ownerRefusal(t);
|
||||
if (refusal) {
|
||||
this.#appendOp(first, 'failed', { refusal });
|
||||
return this.#clearEnvelope(first.thread, first.op);
|
||||
}
|
||||
const drained = this.#opRow(first.thread, first.op, 'drained');
|
||||
const h = this.#sessionHolder(first.session);
|
||||
if (h?.op === 'claim') {
|
||||
if (h.holder_run !== drained.run) fail('thread-already-held');
|
||||
this.#claimEnd({ business: t.business }, h, 'release', null, null, 'session-clear');
|
||||
}
|
||||
const wasDefault = this.#defaultSession(t.id) === first.session;
|
||||
this.#appendOp(first, 'retired');
|
||||
const at = this.#now();
|
||||
this.#store.run(
|
||||
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
first.successor,
|
||||
at,
|
||||
t.business,
|
||||
t.id,
|
||||
t.instance,
|
||||
t.owner,
|
||||
first.successor_name,
|
||||
first.op,
|
||||
first.session,
|
||||
);
|
||||
// D7 step 5: the default moves only off the session that was the default.
|
||||
if (wasDefault) this.#store.run('INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,?)', at, t.id, first.successor, t.owner);
|
||||
return this.#clearEnvelope(first.thread, first.op);
|
||||
});
|
||||
}
|
||||
// D7 step 5, D8: the successor's start, as the launcher's check()/start() decided it. Each report
|
||||
// takes the next counter (C1). A held start spends no work attempt; a refused one spends one.
|
||||
sessionClearStart(record) {
|
||||
keys(record, ['business', 'thread', 'op', 'start', 'refusal'], ['business', 'thread', 'op', 'start']);
|
||||
const { start, refusal = null } = record;
|
||||
const first = this.#launcherOp({ business: record.business, thread: record.thread, op: record.op });
|
||||
if (!['held', 'started', 'refused'].includes(start)) fail('invalid-request');
|
||||
if (start === 'started' ? refusal !== null : !id(refusal)) fail('invalid-request');
|
||||
return this.#store.transaction(() => {
|
||||
if (!this.#opRow(first.thread, first.op, 'retired')) fail('not-retired');
|
||||
if (this.#opRow(first.thread, first.op, 'cancelled')) fail('session-clear-cancelled');
|
||||
if (this.#opRow(first.thread, first.op, 'start-started')) fail('session-clear-complete');
|
||||
const { reports, refused } = this.#store.get(
|
||||
"SELECT count(*) AS reports, count(*) FILTER (WHERE stage='start-refused') AS refused FROM session_ops WHERE thread=? AND op=? AND stage GLOB 'start-*'",
|
||||
first.thread,
|
||||
first.op,
|
||||
);
|
||||
this.#appendOp(first, 'start-' + start, { attempt: reports + 1, workAttempt: refused + 1, refusal });
|
||||
return this.#clearEnvelope(first.thread, first.op);
|
||||
});
|
||||
}
|
||||
// N4: why the thread's owner may no longer clear it, or null. A user's grants are read again at
|
||||
// session.clear's level (collaborator, not a viewer verb); the business human and the tester own
|
||||
// their threads for as long as the business names them.
|
||||
#ownerRefusal(t) {
|
||||
if (t.owner.startsWith('user:')) {
|
||||
const s = { business: t.business, user: t.owner.slice('user:'.length) };
|
||||
return this.#granted({ ...s, grants: this.#userGrants(s) }, 'session.clear', t.project, t.instance) ? null : 'access-refused';
|
||||
}
|
||||
return t.owner === 'tester' || t.owner === this.#business(t.business).human ? null : 'thread-not-owned';
|
||||
}
|
||||
// Row 269 (264b, #1774): one row per session of each thread, oldest thread first, then oldest
|
||||
// session. `legacy` is a session the 3k-3l migration made (op legacy-3l), whose store and
|
||||
// in-sandbox workspace path are the thread's old ones.
|
||||
// in-sandbox workspace path are the thread's old ones. Row 271 (264d): a retired session has
|
||||
// none, so nothing starts or idles it, and a cancelled message isn't pending.
|
||||
#threadRows(where, ...params) {
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT t.id, t.instance, se.id AS session, se.op AS session_op,
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) AS pending,
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))
|
||||
AND NOT EXISTS(SELECT 1 FROM message_cancels x WHERE x.message=m.id)) AS pending,
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=se.id AND m.to_role=t.instance
|
||||
AND c.op='claim' AND EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='delivered' AND d.holder_run=c.holder_run)
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')) AS unread,
|
||||
@@ -956,7 +1215,7 @@ export class Broker {
|
||||
c.at AS claim_at, c.op AS claim_op, c.holder_run AS holder_run
|
||||
FROM threads t JOIN sessions se ON se.thread=t.id
|
||||
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id)
|
||||
WHERE ${where} ORDER BY t.seq, se.seq`,
|
||||
WHERE ${where} AND NOT EXISTS(SELECT 1 FROM session_ops o WHERE o.session=se.id AND o.stage='retired') ORDER BY t.seq, se.seq`,
|
||||
...params,
|
||||
)
|
||||
.map((r) => ({
|
||||
@@ -1054,6 +1313,10 @@ export class Broker {
|
||||
const h = this.#own(session);
|
||||
const released = h?.op === 'claim' && h.holder_run === session.run;
|
||||
if (released) this.#claimEnd(session, h, 'release', null);
|
||||
// Row 271 (264d, D7 step 2): the holder of a session being cleared that ends before its next
|
||||
// receive leaves no turn running either. Its exit is the drain, recorded with its generation.
|
||||
const open = released && session.thread ? this.#openOp(session.session) : null;
|
||||
if (open?.stage === 'requested') this.#appendOp(open, 'drained', { run: session.run, generation: h.generation });
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stopped carries the claim row's reason.
|
||||
const limit = this.#store.get(
|
||||
`SELECT reason FROM ${session.thread ? 'thread_claims' : 'role_claims'} WHERE business=? AND holder_run=? AND by='limit-check' ORDER BY seq DESC LIMIT 1`,
|
||||
@@ -4891,6 +5154,8 @@ export class Broker {
|
||||
if (s.thread) {
|
||||
if (this.#store.get("SELECT 1 FROM thread_claims WHERE session=? AND holder_run=? AND op='revoke' LIMIT 1", s.session, s.run))
|
||||
fail('run-revoked');
|
||||
// Row 271 (264d, B2): no run takes a session being cleared, or a retired one.
|
||||
this.#sessionFence(s.session);
|
||||
if (this.#sessionHolder(s.session)?.op === 'claim') fail('thread-already-held');
|
||||
this.#store.run(
|
||||
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,address,by) VALUES(?,?,?,?,1+(SELECT count(*) FROM thread_claims WHERE session=? AND op='claim'),?,?,?,?,?)",
|
||||
@@ -5309,10 +5574,12 @@ export class Broker {
|
||||
a.corrects ?? null,
|
||||
a.body,
|
||||
);
|
||||
this.#route(s.business, mid, to, thread);
|
||||
const cancelled = this.#route(s.business, mid, to, thread);
|
||||
const request = s.human
|
||||
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
|
||||
: null;
|
||||
// Row 271 (264d, B1): kept as evidence and cancelled at arrival, so it waits for nobody.
|
||||
if (cancelled) return { id: mid, request, cancelled };
|
||||
const waiting = this.#waiting(s, mid, to, thread);
|
||||
return waiting ? { id: mid, request, waiting } : { id: mid, request };
|
||||
}
|
||||
@@ -5323,10 +5590,21 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
this.#received.add(s.business + ':' + s.run);
|
||||
}
|
||||
// Row 271 (264d, D7 step 2): a thread run's receive comes at a completed turn with no child
|
||||
// (the runner asks it only then). At a clear of its session it records the drain once and
|
||||
// delivers nothing. The run keeps its claim, so no turn starts before the launcher's stop.
|
||||
if (s.thread) {
|
||||
const open = this.#openOp(s.session);
|
||||
if (open) {
|
||||
if (!this.#opRow(open.thread, open.op, 'drained')) this.#appendOp(open, 'drained', { run: s.run, generation: this.#runGeneration(s) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const role = s.human ? 'human' : s.role;
|
||||
// A thread run receives its own session's messages only (REQ-THREAD-1, row 264a).
|
||||
const rows = this.#store.all(
|
||||
"SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))" +
|
||||
' AND NOT EXISTS(SELECT 1 FROM message_cancels c WHERE c.message=m.id)' +
|
||||
(s.thread ? ' AND EXISTS(SELECT 1 FROM message_threads mt WHERE mt.message=m.id AND mt.session=?)' : '') +
|
||||
' ORDER BY seq LIMIT 100',
|
||||
s.business,
|
||||
@@ -5852,6 +6130,8 @@ export class Broker {
|
||||
keys(a, ['thread', 'session'], ['thread']);
|
||||
const t = this.#grantedThread(s, 'thread.open', this.#thread(s, a.thread));
|
||||
const session = this.#threadSession(t, a.session);
|
||||
// Row 271 (264d, D7 step 1): a session being cleared, or a retired one, isn't opened.
|
||||
this.#sessionFence(session);
|
||||
this.#store.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', this.#now(), t.id, session, this.#owner(s));
|
||||
return { opened: t.id };
|
||||
}
|
||||
@@ -5862,6 +6142,8 @@ export class Broker {
|
||||
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
|
||||
const t = this.#grantedThread(s, 'thread.send', this.#thread(s, a.thread));
|
||||
const session = this.#threadSession(t, a.session);
|
||||
// Row 271 (264d, D7 step 1): nor sent to, by name or as the default.
|
||||
this.#sessionFence(session);
|
||||
// Row 120's lists hold here as on message.send: the instance must hear from the human.
|
||||
this.#checkMessaging(s.business, 'human', t.instance);
|
||||
string(a.body, 32768);
|
||||
@@ -5939,14 +6221,28 @@ export class Broker {
|
||||
}
|
||||
// Row 264a (#1769, T1 item 7): a thread's sessions, oldest first, and which one is the default.
|
||||
// Each user reaches only their own threads' sessions (REQ-USER-2); there are no shares (Q7).
|
||||
// Row 270 (264c, D6): each row's compaction count, from the host's ledger reader (the bus
|
||||
// host's seat compactions.mjs; this file is mounted in the sandbox and can't import it).
|
||||
// Without a reader the count is unknown, never 0.
|
||||
case 'session.list': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread'], ['thread']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const fallback = this.#defaultSession(t.id);
|
||||
// Row 271 (264d): each row's clear state, revision and, once retired, successor.
|
||||
return this.#store
|
||||
.all('SELECT id,at,name,predecessor FROM sessions WHERE thread=? ORDER BY seq', t.id)
|
||||
.map((r) => ({ ...r, default: r.id === fallback }));
|
||||
.all('SELECT id,at,name,predecessor,op FROM sessions WHERE thread=? ORDER BY seq', t.id)
|
||||
.map(({ op, ...r }) => {
|
||||
const { state, successor } = this.#sessionState(r.id);
|
||||
return {
|
||||
...r,
|
||||
default: r.id === fallback,
|
||||
compactions: this.#compactionCount(t, r.id, op === 'legacy-3l'),
|
||||
state,
|
||||
revision: this.#revision(r.id),
|
||||
successor,
|
||||
};
|
||||
});
|
||||
}
|
||||
// D8: a new session is metadata only. Its run starts when the launcher starts it (264b).
|
||||
// The op key makes a retry find the first row: the same op and name answer it, another name
|
||||
@@ -5957,14 +6253,80 @@ export class Broker {
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
identifier(a.op);
|
||||
const t = this.#thread(s, a.thread);
|
||||
// Row 271 (264d, B3): a clear's op is the clear's, and its successor takes it.
|
||||
if (this.#store.get('SELECT 1 FROM session_ops WHERE thread=? AND op=?', t.id, a.op)) fail('session-op-conflict');
|
||||
const old = this.#store.get('SELECT id,name FROM sessions WHERE thread=? AND op=?', t.id, a.op);
|
||||
if (old) {
|
||||
if (old.name !== a.name) fail('session-op-conflict');
|
||||
return { id: old.id, thread: t.id, name: old.name };
|
||||
}
|
||||
if (this.#store.get('SELECT 1 FROM sessions WHERE thread=? AND name=?', t.id, a.name)) fail('session-exists');
|
||||
if (this.#nameTaken(t.id, a.name)) fail('session-exists');
|
||||
return { id: this.#newSession(s, t, a.name, a.op, false), thread: t.id, name: a.name };
|
||||
}
|
||||
// Row 271 (264d, #1776, D7): clear context. This accept binds the successor, fences the session
|
||||
// and cancels its undelivered inputs in one transaction. The drain (message.receive or
|
||||
// endLaunch), the stop and proof (the launcher), retirement and the start follow. The answer is
|
||||
// the op's envelope, and a repeat of the same payload answers it again.
|
||||
case 'session.clear': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread', 'session', 'revision', 'confirm', 'op'], ['thread', 'session', 'revision', 'confirm', 'op']);
|
||||
identifier(a.op);
|
||||
if (!Number.isSafeInteger(a.revision) || a.revision < 0) fail('invalid-request');
|
||||
const t = this.#thread(s, a.thread);
|
||||
const session = this.#threadSession(t, a.session);
|
||||
if (a.confirm !== true) fail('confirm-required');
|
||||
const digest = this.#opDigest(s, 'session.clear', t.id, session, a.revision, a.op);
|
||||
const first = this.#opRow(t.id, a.op, 'requested');
|
||||
if (first) {
|
||||
if (first.digest !== digest) fail('session-op-conflict');
|
||||
return this.#clearEnvelope(t.id, a.op);
|
||||
}
|
||||
// B3: an op names one thing in its thread, a session or a clear.
|
||||
if (this.#store.get('SELECT 1 FROM sessions WHERE thread=? AND op=?', t.id, a.op)) fail('session-op-conflict');
|
||||
this.#processingWork();
|
||||
this.#sessionFence(session);
|
||||
if (this.#revision(session) !== a.revision) fail('session-revision-stale');
|
||||
// Q4 (sage-2826): `<old>-N`, the next N free in the thread.
|
||||
const base = this.#store.get('SELECT name FROM sessions WHERE id=?', session).name;
|
||||
let n = 1;
|
||||
while (this.#nameTaken(t.id, `${base}-${n}`)) n++;
|
||||
if (!name(`${base}-${n}`)) fail('session-name-too-long');
|
||||
const op = { business: s.business, thread: t.id, session, op: a.op, successor: this.#successorId(s.business, t.id, session, a.op), successor_name: `${base}-${n}` };
|
||||
this.#appendOp(op, 'requested', { digest });
|
||||
this.#cancelInputs(op, t.instance);
|
||||
// With no holder there is no turn to wait for, and B2 refuses a claim from here on.
|
||||
if (this.#sessionHolder(session)?.op !== 'claim') this.#appendOp(op, 'drained');
|
||||
return this.#clearEnvelope(t.id, a.op);
|
||||
}
|
||||
// Row 271 (sage-2828, D7:263-264): a human's cancel of a clear. Before retirement it closes the
|
||||
// op, which releases the fence: the claim and the cancelled messages stay as they are, and no
|
||||
// successor is made. After retirement it ends the startup only, and both sessions stay. After
|
||||
// the successor started, or a failed clear, there is nothing to cancel.
|
||||
case 'session.cancel': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread', 'op', 'confirm'], ['thread', 'op', 'confirm']);
|
||||
identifier(a.op);
|
||||
const t = this.#thread(s, a.thread);
|
||||
if (a.confirm !== true) fail('confirm-required');
|
||||
const first = this.#opRow(t.id, a.op, 'requested') ?? fail('operation-not-found');
|
||||
const digest = this.#opDigest(s, 'session.cancel', t.id, a.op);
|
||||
const done = this.#opRow(t.id, a.op, 'cancelled');
|
||||
if (done) {
|
||||
if (done.digest !== digest) fail('session-op-conflict');
|
||||
return this.#clearEnvelope(t.id, a.op);
|
||||
}
|
||||
if (this.#opRow(t.id, a.op, 'start-started') || this.#opRow(t.id, a.op, 'failed')) fail('session-clear-complete');
|
||||
this.#appendOp(first, 'cancelled', { digest });
|
||||
return this.#clearEnvelope(t.id, a.op);
|
||||
}
|
||||
// Row 271: a clear's envelope, for a caller that waited and reports pending. Reading writes
|
||||
// nothing, and a retry asks here rather than sending the clear again.
|
||||
case 'session.operation': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread', 'op'], ['thread', 'op']);
|
||||
identifier(a.op);
|
||||
return this.#clearEnvelope(this.#thread(s, a.thread).id, a.op);
|
||||
}
|
||||
case 'replies': {
|
||||
if (!s.human && !s.reader) fail('human-required');
|
||||
keys(a, ['id'], ['id']);
|
||||
@@ -6093,12 +6455,21 @@ export class Broker {
|
||||
}
|
||||
// Row 121: a message names its thread (REQ-MSG-1) and goes to the holder that may have it.
|
||||
// Row 264a (#1769): it names one session of the thread, and the session's holder may have it.
|
||||
// Row 271 (264d, B1): a message to the instance in a session being cleared, or a retired one, is
|
||||
// kept and cancelled at arrival with a receipt. It is never delivered there or to the successor,
|
||||
// and the answer is why; otherwise null.
|
||||
#route(business, mid, to, session) {
|
||||
if (session) {
|
||||
const { thread } = this.#store.get('SELECT thread FROM sessions WHERE id=?', session) ?? fail('session-not-found');
|
||||
const { thread, instance } = this.#store.get('SELECT thread, instance FROM sessions WHERE id=?', session) ?? fail('session-not-found');
|
||||
this.#store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', mid, thread, session);
|
||||
const { state, op } = this.#sessionState(session);
|
||||
if (to === instance && state !== 'active') {
|
||||
this.#store.run('INSERT INTO message_cancels(at,message,session,op) VALUES(?,?,?,?)', this.#now(), mid, session, op);
|
||||
return state === 'retired' ? 'session-retired' : 'session-clearing';
|
||||
}
|
||||
}
|
||||
this.#delivery(mid, 'routed', this.#recipient(business, to, session));
|
||||
return null;
|
||||
}
|
||||
// Row 264a (#1769): a session row and, for a new thread, its default. The 3k-3l migration made the
|
||||
// legacy defaults, whose id is the thread id; a new session's id is random, and a trigger refuses
|
||||
@@ -6251,7 +6622,7 @@ export class Broker {
|
||||
#routeSession(session) {
|
||||
const h = this.#sessionHolder(session);
|
||||
const rows = this.#store.all(
|
||||
"SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE mt.session=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read'))",
|
||||
"SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE mt.session=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read')) AND NOT EXISTS(SELECT 1 FROM message_cancels WHERE message=m.id)",
|
||||
session,
|
||||
);
|
||||
for (const m of rows) this.#delivery(m.id, 'routed', h);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Row 271 (264d, #1776, T1 item 7 D7/D8): clear context. session_ops is the clear journal, one row
|
||||
-- per stage, and message_cancels holds a receipt for each message a clear cancelled. Only new
|
||||
-- tables, an index and their triggers.
|
||||
CREATE TABLE session_ops (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
op TEXT NOT NULL,
|
||||
stage TEXT NOT NULL CHECK (stage IN ('requested','drained','uncertain','exited','retired','failed','cancelled','start-held','start-started','start-refused')),
|
||||
attempt INTEGER NOT NULL CHECK (CASE WHEN stage GLOB 'start-*' THEN attempt >= 1 ELSE attempt = 0 END),
|
||||
work_attempt INTEGER CHECK (work_attempt IS NULL OR (stage GLOB 'start-*' AND work_attempt >= 1)),
|
||||
successor TEXT NOT NULL,
|
||||
successor_name TEXT NOT NULL,
|
||||
run TEXT,
|
||||
generation INTEGER CHECK (generation IS NULL OR generation >= 1),
|
||||
digest TEXT,
|
||||
refusal TEXT,
|
||||
UNIQUE (thread, op, stage, attempt)
|
||||
) STRICT;
|
||||
CREATE TABLE message_cancels (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
message TEXT NOT NULL UNIQUE REFERENCES messages(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
op TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX session_ops_session ON session_ops (session, seq);
|
||||
CREATE TRIGGER session_ops_no_update BEFORE UPDATE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
|
||||
CREATE TRIGGER session_ops_no_delete BEFORE DELETE ON session_ops BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
|
||||
CREATE TRIGGER session_ops_no_replace BEFORE INSERT ON session_ops WHEN EXISTS (SELECT 1 FROM session_ops WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_ops is append-only'); END;
|
||||
CREATE TRIGGER session_ops_one_open BEFORE INSERT ON session_ops
|
||||
WHEN NEW.stage = 'requested' AND EXISTS (SELECT 1 FROM session_ops o WHERE o.session = NEW.session
|
||||
AND o.seq = (SELECT max(seq) FROM session_ops WHERE thread = o.thread AND op = o.op)
|
||||
AND o.stage IN ('requested','drained','uncertain','exited'))
|
||||
BEGIN SELECT RAISE(ABORT, 'session has an open clear'); END;
|
||||
CREATE TRIGGER message_cancels_no_update BEFORE UPDATE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
|
||||
CREATE TRIGGER message_cancels_no_delete BEFORE DELETE ON message_cancels BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
|
||||
CREATE TRIGGER message_cancels_no_replace BEFORE INSERT ON message_cancels WHEN EXISTS (SELECT 1 FROM message_cancels WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'message_cancels is append-only'); END;
|
||||
@@ -29,6 +29,11 @@ const LAUNCH_OPS = new Set([
|
||||
'lifecycleTake',
|
||||
'lifecycleDelete',
|
||||
'lifecycleDone',
|
||||
'sessionClears',
|
||||
'sessionClearExited',
|
||||
'sessionClearUncertain',
|
||||
'sessionClearRetire',
|
||||
'sessionClearStart',
|
||||
]);
|
||||
function launchOp(m) {
|
||||
if (m.op === 'identity') return runtime.identity(m.cap);
|
||||
@@ -64,6 +69,12 @@ function launchOp(m) {
|
||||
if (m.op === 'lifecycleTake') return runtime.lifecycleTake(m.record);
|
||||
if (m.op === 'lifecycleDelete') return runtime.lifecycleDelete(m.record);
|
||||
if (m.op === 'lifecycleDone') return runtime.lifecycleDone(m.record);
|
||||
// Row 271 (264d, #1776, D7 steps 3-5): the launcher's clear pass.
|
||||
if (m.op === 'sessionClears') return runtime.sessionClears(m.business);
|
||||
if (m.op === 'sessionClearExited') return runtime.sessionClearExited(m.record);
|
||||
if (m.op === 'sessionClearUncertain') return runtime.sessionClearUncertain(m.record);
|
||||
if (m.op === 'sessionClearRetire') return runtime.sessionClearRetire(m.record);
|
||||
if (m.op === 'sessionClearStart') return runtime.sessionClearStart(m.record);
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
|
||||
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
||||
import { readSessions } from '../../seat/src/session.mjs';
|
||||
import { compactionCount, storeDir } from '../../seat/src/compactions.mjs';
|
||||
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
@@ -261,6 +262,11 @@ export async function startBroker({
|
||||
broker = new Broker({
|
||||
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
|
||||
access: access ? () => loadAccess(access.file, access.context) : null,
|
||||
// Row 270 (264c): session.list's compaction count, read from the session store's ledger.
|
||||
compactions: ({ business, thread, session, legacy }) => {
|
||||
const dir = storeDir(dataRoot, business, thread, session, { legacy });
|
||||
return dir ? compactionCount(dir) : { count: null, known: false, reason: 'store-unreadable', at: null };
|
||||
},
|
||||
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
|
||||
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
|
||||
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
|
||||
@@ -588,6 +594,12 @@ export async function startBroker({
|
||||
lifecycleTake: (r) => broker.lifecycleTake(r),
|
||||
lifecycleDelete: (r) => broker.lifecycleDelete(r),
|
||||
lifecycleDone: (r) => broker.lifecycleDone(r),
|
||||
// Row 271 (264d, #1776): the launcher's clear pass.
|
||||
sessionClears: (business) => broker.sessionClears(business),
|
||||
sessionClearExited: (r) => broker.sessionClearExited(r),
|
||||
sessionClearUncertain: (r) => broker.sessionClearUncertain(r),
|
||||
sessionClearRetire: (r) => broker.sessionClearRetire(r),
|
||||
sessionClearStart: (r) => broker.sessionClearStart(r),
|
||||
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
|
||||
credentialStatus,
|
||||
launches: bound,
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
|
||||
export const SCHEMA_VERSION = '3m';
|
||||
export const SCHEMA_SHA256 = 'a2ba3a46f12e56139ecae9e55227d00847842aedec50c1d92aaae28844ca48e7';
|
||||
export const SCHEMA_VERSION = '3n';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -77,6 +77,11 @@ const migrations = [{
|
||||
from: '3l',
|
||||
to: '3m',
|
||||
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 271 (#1776): clear context. The clear journal and the cancelled-message receipts. Only new tables.
|
||||
from: '3m',
|
||||
to: '3n',
|
||||
sql: readFileSync(new URL('./migrations/3m-3n.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -95,6 +100,8 @@ const timedTables = [
|
||||
'thread_shares',
|
||||
'instance_names',
|
||||
'usage_records',
|
||||
'session_ops',
|
||||
'message_cancels',
|
||||
];
|
||||
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
||||
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
||||
|
||||
@@ -18,7 +18,8 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3h(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -86,6 +87,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
|
||||
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
|
||||
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
|
||||
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -143,7 +145,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
});
|
||||
|
||||
@@ -155,14 +157,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -614,7 +614,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3o';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3n', to: '3o', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3o');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3m ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3n ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'session.launched',
|
||||
'session.ended',
|
||||
'action.allowed',
|
||||
'action.refused',
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.state',
|
||||
'task.closed',
|
||||
'task.changed.external',
|
||||
'task.conflict',
|
||||
'task.missing',
|
||||
'review.requested',
|
||||
'review.verdict',
|
||||
'human.input',
|
||||
'config.refused',
|
||||
'credential.expiring',
|
||||
'credential.expired',
|
||||
'credential.changed',
|
||||
'launch.revoked',
|
||||
'launch.restored',
|
||||
'digest.sent')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
subject TEXT,
|
||||
corrects TEXT REFERENCES events(id),
|
||||
body TEXT NOT NULL CHECK (json_valid(body))
|
||||
) STRICT;
|
||||
CREATE TABLE role_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, role TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE decisions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, project TEXT,
|
||||
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
||||
action TEXT NOT NULL,
|
||||
route_to TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
||||
recommendation TEXT NOT NULL,
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
, spec_path TEXT, spec_commit TEXT) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
||||
by TEXT NOT NULL,
|
||||
choice TEXT, note TEXT, via TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
||||
to_role TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
in_reply_to TEXT REFERENCES messages(id),
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
, to_name TEXT) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
||||
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE task_snapshots (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||
updated TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||
read_at TEXT,
|
||||
role TEXT, run TEXT,
|
||||
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||
) STRICT;
|
||||
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
||||
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
||||
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
||||
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
||||
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
||||
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
||||
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
||||
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.missing' AND (
|
||||
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.created' AND (
|
||||
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||
AND kind = 'human.input' AND business = NEW.business)
|
||||
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||
CREATE VIEW launch_state AS
|
||||
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||
CREATE VIEW task_external_changes AS
|
||||
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||
FROM task_snapshots p
|
||||
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||
WHERE p.source = 'poll'
|
||||
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||
CREATE VIEW task_current AS
|
||||
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
||||
FROM task_snapshots s
|
||||
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
||||
WHERE c.business = s.business AND c.task_ref = s.task_ref
|
||||
AND (c.source = 'self'
|
||||
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
||||
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
||||
ORDER BY ls.seq DESC LIMIT 1), '')));
|
||||
CREATE VIEW tasks_open AS
|
||||
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||
FROM task_current s
|
||||
WHERE json_type(s.fields, '$.gone') IS NULL
|
||||
AND json_extract(s.fields, '$.done') = 0;
|
||||
CREATE VIEW urgent_inbox AS
|
||||
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||
FROM decisions d
|
||||
WHERE d.class = 'gated' AND d.blocking = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||
CREATE TABLE task_comment_cursors (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL CHECK (task_id > 0),
|
||||
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
||||
) STRICT;
|
||||
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
||||
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
||||
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
||||
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
||||
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
||||
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
||||
CREATE TABLE projects (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE (business, name)
|
||||
) STRICT;
|
||||
CREATE TABLE threads (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE sessions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
predecessor TEXT REFERENCES sessions(id),
|
||||
UNIQUE (thread, name),
|
||||
UNIQUE (thread, op)
|
||||
) STRICT;
|
||||
CREATE TABLE session_defaults (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
generation INTEGER NOT NULL CHECK (generation >= 1),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE INDEX message_threads_session ON message_threads (session);
|
||||
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
|
||||
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
|
||||
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
|
||||
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
|
||||
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
|
||||
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
|
||||
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
|
||||
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
|
||||
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
|
||||
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
|
||||
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
|
||||
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
|
||||
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
|
||||
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TABLE instance_names (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
|
||||
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
|
||||
AND n.instance <> NEW.instance
|
||||
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
|
||||
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
|
||||
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
|
||||
CREATE TABLE usage_records (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
run TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
account TEXT,
|
||||
turn TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
|
||||
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
|
||||
code TEXT,
|
||||
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
|
||||
input INTEGER,
|
||||
output INTEGER,
|
||||
cache_write INTEGER,
|
||||
cache_read INTEGER,
|
||||
UNIQUE (business,run,turn,source,phase),
|
||||
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
|
||||
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
|
||||
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TABLE hosts (
|
||||
business TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
user TEXT NOT NULL,
|
||||
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
|
||||
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
|
||||
installer_fingerprint TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
checked_at TEXT,
|
||||
PRIMARY KEY (business, host),
|
||||
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TABLE host_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent, host),
|
||||
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
|
||||
) STRICT;
|
||||
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'action.allowed'
|
||||
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
|
||||
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
|
||||
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
|
||||
CREATE TABLE gitea_users (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
id INTEGER NOT NULL CHECK (id > 0),
|
||||
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent)
|
||||
) STRICT;
|
||||
CREATE TABLE gitea_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
token_id INTEGER NOT NULL CHECK (token_id > 0),
|
||||
token_name TEXT NOT NULL,
|
||||
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent),
|
||||
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
|
||||
) STRICT;
|
||||
-- Row 206 (193b2, #1704, row 193 design 5.2): a thread owner's read-only shares. The latest row per
|
||||
-- (thread, target) is the share's state. Append-only.
|
||||
CREATE TABLE thread_shares (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
|
||||
target TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
|
||||
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
@@ -35,11 +35,12 @@ function source3j(t) {
|
||||
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -76,6 +77,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
|
||||
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
|
||||
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
|
||||
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -97,7 +99,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -123,7 +125,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -134,14 +136,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3j,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,8 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1',
|
||||
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
|
||||
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
// Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -41,7 +42,7 @@ function source3i(t) {
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -79,6 +80,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
|
||||
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
|
||||
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
|
||||
copyFileSync(here('../src/migrations/3m-3n.sql'), join(moduleRoot, 'src/migrations/3m-3n.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -102,7 +104,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
@@ -127,7 +129,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -138,14 +140,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
|
||||
schema: ddl3i,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -523,7 +523,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
|
||||
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
|
||||
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 23);
|
||||
assert.equal(count('trigger'), 89);
|
||||
// Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 25);
|
||||
assert.equal(count('trigger'), 96);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
// Row 271 (264d, #1776, T4): clear context, the bus half. D7 and D8 of
|
||||
// docs/plans/2026-10-21_t1-sessions.md, the contract in agents/gizmoduck/work/queue-271/BUILD.md.
|
||||
// The launcher's stop, proof and start are in packages/cli/tests/session-clear.test.mjs.
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const roles = {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
};
|
||||
const GENERATION = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69';
|
||||
const UUID8 = /^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function setup(t, { access = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-session-clear-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
vars: { 'bus.tester': true },
|
||||
roles,
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
projects: { stack: { root: '/nonexistent' } },
|
||||
},
|
||||
},
|
||||
processing: { read: () => sw.state },
|
||||
...(access ? { access: () => access.doc } : {}),
|
||||
});
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
};
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const code = (fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
return e.code ?? e.message;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const launch = (name, thread, session) => {
|
||||
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread, ...(session ? { session } : {}) });
|
||||
return call(name, 'role.claim');
|
||||
};
|
||||
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
call('chief', 'role.claim');
|
||||
const project = call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
const A = call('human', 'thread.create', { project, name: 'alpha' }).id;
|
||||
const list = (thread = A) => call('human', 'session.list', { thread });
|
||||
const D = list()[0].id;
|
||||
const stages = (op, thread = A) => store.all('SELECT stage FROM session_ops WHERE thread=? AND op=? ORDER BY seq', thread, op).map((r) => r.stage);
|
||||
const cancels = (session) => store.all('SELECT message FROM message_cancels WHERE session=? ORDER BY seq', session).map((r) => r.message);
|
||||
const count = (table) => store.get(`SELECT count(*) AS n FROM ${table}`).n;
|
||||
const clear = (who, args) => call(who, 'session.clear', { thread: A, confirm: true, ...args });
|
||||
const where = (op, thread = A) => ({ business: 'demo', thread, op });
|
||||
return { b, store, sw, caps, call, code, launch, project, A, D, list, stages, cancels, count, clear, where };
|
||||
}
|
||||
|
||||
const successorOf = (thread, session, op) => {
|
||||
const h = createHash('sha256').update(JSON.stringify(['demo', thread, session, op])).digest('hex').split('');
|
||||
h[12] = '8';
|
||||
h[16] = '89ab'[parseInt(h[16], 16) & 3];
|
||||
const x = h.join('');
|
||||
return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;
|
||||
};
|
||||
|
||||
test('T4 D7: a clear of a held default session fences it, drains at the next receive, retires at the exit proof and moves the default', (t) => {
|
||||
const { b, call, code, launch, A, D, list, stages, clear, where, store } = setup(t);
|
||||
launch('a1', A);
|
||||
call('human', 'thread.send', { thread: A, body: 'first turn' });
|
||||
assert.deepEqual(call('a1', 'message.receive').map((m) => m.body), ['first turn']);
|
||||
assert.deepEqual(list().map((r) => [r.id, r.state, r.revision, r.successor, r.default]), [[D, 'active', 0, null, true]]);
|
||||
const accepted = clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
const S = successorOf(A, D, 'clear-1');
|
||||
assert.match(S, UUID8);
|
||||
assert.deepEqual({ ...accepted, at: null }, { op: 'clear-1', thread: A, session: D, successor: S, state: 'clearing', start: null, refusal: null, at: null });
|
||||
// Accept fences the session: no send, open or new claim reaches it. The holder keeps its claim.
|
||||
assert.equal(code(() => call('human', 'thread.send', { thread: A, body: 'late' })), 'session-clearing');
|
||||
assert.equal(code(() => call('human', 'thread.open', { thread: A })), 'session-clearing');
|
||||
assert.equal(code(() => launch('a2', A)), 'session-clearing');
|
||||
assert.deepEqual(stages('clear-1'), ['requested']);
|
||||
assert.deepEqual(list().map((r) => [r.state, r.revision]), [['clearing', 1]]);
|
||||
// The run's next receive, at its completed turn, records the drain once and delivers nothing.
|
||||
assert.deepEqual(call('a1', 'message.receive'), []);
|
||||
assert.deepEqual(call('a1', 'message.receive'), []);
|
||||
assert.deepEqual(stages('clear-1'), ['requested', 'drained']);
|
||||
assert.deepEqual(
|
||||
{ ...store.get("SELECT run, generation FROM session_ops WHERE op='clear-1' AND stage='drained'") },
|
||||
{ run: 'a1-run', generation: 1 },
|
||||
);
|
||||
assert.deepEqual(b.sessionClears('demo'), [
|
||||
{ thread: A, instance: 'assistant', session: D, op: 'clear-1', stage: 'drained', run: 'a1-run', generation: 1, successor: S, refusal: null },
|
||||
]);
|
||||
// No proof until the run itself has ended: a signal or a dead socket isn't one.
|
||||
assert.equal(code(() => b.sessionClearExited({ ...where('clear-1'), run: 'a1-run' })), 'run-not-ended');
|
||||
assert.equal(code(() => b.sessionClearExited({ ...where('clear-1'), run: 'other-run' })), 'not-drained-run');
|
||||
assert.equal(code(() => b.sessionClearRetire(where('clear-1'))), 'not-exited');
|
||||
b.endLaunch({ business: 'demo', run: 'a1-run', reason: 'stopped', exitCode: 0 });
|
||||
assert.equal(b.sessionClearExited({ ...where('clear-1'), run: 'a1-run' }).state, 'clearing');
|
||||
// A repeat of the proof answers the envelope and appends nothing.
|
||||
b.sessionClearExited({ ...where('clear-1'), run: 'a1-run' });
|
||||
assert.deepEqual(stages('clear-1'), ['requested', 'drained', 'exited']);
|
||||
const retired = b.sessionClearRetire(where('clear-1'));
|
||||
assert.deepEqual([retired.state, retired.start, retired.successor], ['retired', null, S]);
|
||||
assert.deepEqual(list().map((r) => [r.id, r.name, r.predecessor, r.state, r.successor, r.default]), [
|
||||
[D, 'default', null, 'retired', S, false],
|
||||
[S, 'default-1', D, 'active', null, true],
|
||||
]);
|
||||
// A retired session has no threadsState row; the successor has one and no message.
|
||||
assert.deepEqual(b.threadsState('demo').map((r) => [r.session, r.pending, r.holder]), [[S, 0, null]]);
|
||||
assert.equal(code(() => call('human', 'thread.send', { thread: A, session: D, body: 'to old' })), 'session-retired');
|
||||
assert.equal(code(() => clear('human', { session: D, revision: 4, op: 'clear-2' })), 'session-retired');
|
||||
// A thread-only send now goes to the successor, the default.
|
||||
const m = call('human', 'thread.send', { thread: A, body: 'fresh' });
|
||||
assert.equal(store.get('SELECT session FROM message_threads WHERE message=?', m.id).session, S);
|
||||
});
|
||||
|
||||
test('T4 D7: an unheld session drains at accept, its proof is run null, and a non-default clear leaves the default', (t) => {
|
||||
const { b, call, code, A, D, list, stages, clear, where } = setup(t);
|
||||
const N = call('human', 'session.create', { thread: A, name: 'side', op: 'op-side' }).id;
|
||||
clear('human', { session: N, revision: 0, op: 'clear-side' });
|
||||
assert.deepEqual(stages('clear-side'), ['requested', 'drained']);
|
||||
assert.deepEqual(b.sessionClears('demo').map((c) => [c.session, c.stage, c.run, c.generation]), [[N, 'drained', null, null]]);
|
||||
assert.equal(code(() => b.sessionClearExited({ ...where('clear-side'), run: 'x-run' })), 'not-drained-run');
|
||||
b.sessionClearExited({ ...where('clear-side'), run: null });
|
||||
b.sessionClearRetire(where('clear-side'));
|
||||
const S = successorOf(A, N, 'clear-side');
|
||||
assert.deepEqual(list().map((r) => [r.id, r.name, r.default, r.state]), [
|
||||
[D, 'default', true, 'active'],
|
||||
[N, 'side', false, 'retired'],
|
||||
[S, 'side-1', false, 'active'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('T4 D7: a run that ends before its next receive drains at its end, with its generation', (t) => {
|
||||
const { b, call, launch, A, D, stages, clear, store } = setup(t);
|
||||
launch('a1', A);
|
||||
clear('human', { session: D, revision: 0, op: 'clear-end' });
|
||||
assert.deepEqual(stages('clear-end'), ['requested']);
|
||||
b.endLaunch({ business: 'demo', run: 'a1-run', reason: 'exited', exitCode: 0 });
|
||||
assert.deepEqual(stages('clear-end'), ['requested', 'drained']);
|
||||
assert.deepEqual({ ...store.get("SELECT run, generation FROM session_ops WHERE op='clear-end' AND stage='drained'") }, { run: 'a1-run', generation: 1 });
|
||||
});
|
||||
|
||||
test('T4 D7: a run whose turn is running (delivered, unread) stays clearing; uncertain is recorded once and nothing more', (t) => {
|
||||
const { b, call, code, launch, A, D, stages, clear, where, list } = setup(t);
|
||||
launch('a1', A);
|
||||
call('human', 'thread.send', { thread: A, body: 'long turn' });
|
||||
call('a1', 'message.receive');
|
||||
clear('human', { session: D, revision: 0, op: 'clear-busy' });
|
||||
// Without a receive or an end, it isn't drained, so the launcher has nothing to stop.
|
||||
assert.deepEqual(b.sessionClears('demo').map((c) => c.stage), ['requested']);
|
||||
assert.equal(code(() => b.sessionClearUncertain(where('clear-busy'))), 'not-drained');
|
||||
assert.equal(code(() => b.sessionClearExited({ ...where('clear-busy'), run: 'a1-run' })), 'not-drained');
|
||||
assert.deepEqual(call('a1', 'message.receive'), []);
|
||||
assert.equal(b.sessionClearUncertain(where('clear-busy')).state, 'uncertain');
|
||||
b.sessionClearUncertain(where('clear-busy'));
|
||||
assert.deepEqual(stages('clear-busy'), ['requested', 'drained', 'uncertain']);
|
||||
assert.deepEqual(list().map((r) => r.state), ['uncertain']);
|
||||
assert.equal(code(() => call('human', 'thread.send', { thread: A, body: 'x' })), 'session-clearing');
|
||||
// Proof arriving later resumes the clear.
|
||||
b.endLaunch({ business: 'demo', run: 'a1-run', reason: 'stopped', exitCode: 0 });
|
||||
b.sessionClearExited({ ...where('clear-busy'), run: 'a1-run' });
|
||||
assert.equal(b.sessionClearRetire(where('clear-busy')).state, 'retired');
|
||||
});
|
||||
|
||||
test('T4 D7 B1: undelivered inputs are cancelled with receipts at accept, never delivered to the successor, and a routed reply is cancelled at arrival', (t) => {
|
||||
const { b, call, launch, A, D, stages, cancels, clear, where, store } = setup(t);
|
||||
launch('a1', A);
|
||||
const ask = call('a1', 'message.send', { to: 'chief', body: 'need help', class: 'REQUEST' });
|
||||
const m1 = call('human', 'thread.send', { thread: A, body: 'waiting one' });
|
||||
const m2 = call('human', 'thread.send', { thread: A, body: 'waiting two' });
|
||||
clear('human', { session: D, revision: 0, op: 'clear-msgs' });
|
||||
assert.deepEqual(cancels(D), [m1.id, m2.id]);
|
||||
assert.deepEqual(b.threadsState('demo').map((r) => r.pending), [0]);
|
||||
// The chief's reply routes to the session being cleared: kept, cancelled, and the answer says why.
|
||||
const back = call('chief', 'message.send', { to: 'assistant', body: 'here', in_reply_to: ask.id });
|
||||
assert.equal(back.cancelled, 'session-clearing');
|
||||
assert.equal(back.waiting, undefined);
|
||||
assert.deepEqual(cancels(D), [m1.id, m2.id, back.id]);
|
||||
assert.deepEqual({ ...store.get('SELECT op FROM message_cancels WHERE message=?', back.id) }, { op: 'clear-msgs' });
|
||||
assert.deepEqual(call('a1', 'message.receive'), []);
|
||||
b.endLaunch({ business: 'demo', run: 'a1-run', reason: 'stopped', exitCode: 0 });
|
||||
b.sessionClearExited({ ...where('clear-msgs'), run: 'a1-run' });
|
||||
b.sessionClearRetire(where('clear-msgs'));
|
||||
const S = successorOf(A, D, 'clear-msgs');
|
||||
launch('s1', A, S);
|
||||
assert.deepEqual(call('s1', 'message.receive'), []);
|
||||
// A reply to the retired session's message is cancelled as retired.
|
||||
const late = call('chief', 'message.send', { to: 'assistant', body: 'later', in_reply_to: ask.id });
|
||||
assert.equal(late.cancelled, 'session-retired');
|
||||
assert.deepEqual(call('s1', 'message.receive'), []);
|
||||
assert.deepEqual(stages('clear-msgs'), ['requested', 'drained', 'exited', 'retired']);
|
||||
});
|
||||
|
||||
test('T4 D7: repeat, conflict, a second op, a stale revision, confirm and a halt', (t) => {
|
||||
const { sw, call, code, A, D, stages, clear, count } = setup(t);
|
||||
// A halt refuses and writes nothing.
|
||||
sw.state = { version: 1, halted: true, generation: GENERATION };
|
||||
assert.equal(code(() => clear('human', { session: D, revision: 0, op: 'clear-1' })), 'processing-halted');
|
||||
assert.equal(count('session_ops'), 0);
|
||||
sw.state = { version: 1, halted: false, generation: GENERATION };
|
||||
assert.equal(code(() => clear('human', { session: D, revision: 1, op: 'clear-1' })), 'session-revision-stale');
|
||||
assert.equal(code(() => call('human', 'session.clear', { thread: A, session: D, revision: 0, op: 'clear-1' })), 'invalid-request');
|
||||
assert.equal(code(() => call('human', 'session.clear', { thread: A, session: D, revision: 0, confirm: 'yes', op: 'clear-1' })), 'confirm-required');
|
||||
assert.equal(code(() => clear('human', { session: D, revision: -1, op: 'clear-1' })), 'invalid-request');
|
||||
assert.equal(code(() => clear('human', { session: 'no-such-session', revision: 0, op: 'clear-1' })), 'session-not-found');
|
||||
assert.equal(count('session_ops'), 0);
|
||||
const first = clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
// The same op and payload answers the same envelope, id and name, and appends nothing.
|
||||
assert.deepEqual(clear('human', { session: D, revision: 0, op: 'clear-1' }), first);
|
||||
assert.deepEqual(stages('clear-1'), ['requested', 'drained']);
|
||||
assert.equal(code(() => clear('human', { session: D, revision: 1, op: 'clear-1' })), 'session-op-conflict');
|
||||
assert.equal(code(() => clear('human', { session: D, revision: 2, op: 'clear-2' })), 'session-clearing');
|
||||
// B3: an op names a session or a clear in its thread, never both.
|
||||
assert.equal(code(() => call('human', 'session.create', { thread: A, name: 'other', op: 'clear-1' })), 'session-op-conflict');
|
||||
call('human', 'session.create', { thread: A, name: 'side', op: 'op-side' });
|
||||
const side = call('human', 'session.list', { thread: A }).find((r) => r.name === 'side').id;
|
||||
assert.equal(code(() => clear('human', { session: side, revision: 0, op: 'op-side' })), 'session-op-conflict');
|
||||
assert.equal(count('session_ops'), 2);
|
||||
});
|
||||
|
||||
test('T4 Q4: the successor takes <old>-N, the next N free, and a bound name stays taken', (t) => {
|
||||
const { b, call, code, A, D, list, clear, where } = setup(t);
|
||||
call('human', 'session.create', { thread: A, name: 'default-1', op: 'op-d1' });
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
assert.equal(code(() => call('human', 'session.create', { thread: A, name: 'default-2', op: 'op-d2' })), 'session-exists');
|
||||
// Cancelled before retirement: the name stays bound to the op.
|
||||
call('human', 'session.cancel', { thread: A, op: 'clear-1', confirm: true });
|
||||
assert.equal(code(() => call('human', 'session.create', { thread: A, name: 'default-2', op: 'op-d2' })), 'session-exists');
|
||||
clear('human', { session: D, revision: 3, op: 'clear-2' });
|
||||
b.sessionClearExited({ ...where('clear-2'), run: null });
|
||||
b.sessionClearRetire(where('clear-2'));
|
||||
assert.deepEqual(list().map((r) => [r.name, r.state]), [
|
||||
['default', 'retired'],
|
||||
['default-1', 'active'],
|
||||
['default-3', 'active'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('T4 D8: the successor start is held, refused or started, each report on its own counter', (t) => {
|
||||
const { b, code, A, D, stages, clear, where, store } = setup(t);
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
assert.equal(code(() => b.sessionClearStart({ ...where('clear-1'), start: 'held', refusal: 'capacity-full' })), 'not-retired');
|
||||
b.sessionClearExited({ ...where('clear-1'), run: null });
|
||||
b.sessionClearRetire(where('clear-1'));
|
||||
assert.deepEqual(b.sessionClears('demo').map((c) => c.stage), ['retired']);
|
||||
assert.equal(code(() => b.sessionClearStart({ ...where('clear-1'), start: 'held' })), 'invalid-request');
|
||||
assert.equal(code(() => b.sessionClearStart({ ...where('clear-1'), start: 'started', refusal: 'x' })), 'invalid-request');
|
||||
assert.equal(code(() => b.sessionClearStart({ ...where('clear-1'), start: 'begun', refusal: null })), 'invalid-request');
|
||||
const held = b.sessionClearStart({ ...where('clear-1'), start: 'held', refusal: 'capacity-full' });
|
||||
assert.deepEqual([held.state, held.start, held.refusal], ['retired', 'held', null]);
|
||||
const refused = b.sessionClearStart({ ...where('clear-1'), start: 'refused', refusal: 'launch-failed' });
|
||||
assert.deepEqual([refused.start, refused.refusal], ['refused', 'launch-failed']);
|
||||
assert.deepEqual(b.sessionClears('demo').map((c) => [c.stage, c.refusal]), [['start-refused', 'launch-failed']]);
|
||||
b.sessionClearStart({ ...where('clear-1'), start: 'held', refusal: 'limit-reached' });
|
||||
const started = b.sessionClearStart({ ...where('clear-1'), start: 'started' });
|
||||
assert.deepEqual([started.state, started.start, started.refusal], ['retired', 'started', null]);
|
||||
assert.deepEqual(
|
||||
store.all("SELECT stage, attempt, work_attempt FROM session_ops WHERE op='clear-1' AND stage GLOB 'start-*' ORDER BY seq").map((r) => ({ ...r })),
|
||||
[
|
||||
{ stage: 'start-held', attempt: 1, work_attempt: 1 },
|
||||
{ stage: 'start-refused', attempt: 2, work_attempt: 1 },
|
||||
{ stage: 'start-held', attempt: 3, work_attempt: 2 },
|
||||
{ stage: 'start-started', attempt: 4, work_attempt: 2 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(b.sessionClears('demo'), []);
|
||||
assert.equal(code(() => b.sessionClearStart({ ...where('clear-1'), start: 'started' })), 'session-clear-complete');
|
||||
assert.equal(stages('clear-1').length, 8);
|
||||
});
|
||||
|
||||
test('T4 D7 N4: a halt at retirement waits and writes nothing; retirement goes once processing resumes', (t) => {
|
||||
const { b, sw, code, D, stages, clear, where } = setup(t);
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
b.sessionClearExited({ ...where('clear-1'), run: null });
|
||||
sw.state = { version: 1, halted: true, generation: GENERATION };
|
||||
assert.equal(code(() => b.sessionClearRetire(where('clear-1'))), 'processing-halted');
|
||||
assert.deepEqual(stages('clear-1'), ['requested', 'drained', 'exited']);
|
||||
sw.state = { version: 1, halted: false, generation: GENERATION };
|
||||
assert.equal(b.sessionClearRetire(where('clear-1')).state, 'retired');
|
||||
});
|
||||
|
||||
test('T4 sage-2828: a cancel before retirement makes the session active again, keeps the claim and the cancelled messages, and never makes the successor', (t) => {
|
||||
const { b, call, code, launch, A, D, list, stages, cancels, clear, where, store } = setup(t);
|
||||
launch('a1', A);
|
||||
// Routed to a1's claim but not delivered: the accept cancels it.
|
||||
const m = call('human', 'thread.send', { thread: A, body: 'waiting' });
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
const S = successorOf(A, D, 'clear-1');
|
||||
assert.equal(code(() => call('human', 'session.cancel', { thread: A, op: 'clear-1' })), 'invalid-request');
|
||||
assert.equal(code(() => call('human', 'session.cancel', { thread: A, op: 'clear-1', confirm: false })), 'confirm-required');
|
||||
assert.equal(code(() => call('human', 'session.cancel', { thread: A, op: 'no-such-op', confirm: true })), 'operation-not-found');
|
||||
const cancelled = call('human', 'session.cancel', { thread: A, op: 'clear-1', confirm: true });
|
||||
assert.deepEqual([cancelled.state, cancelled.retired, cancelled.start, cancelled.successor], ['cancelled', null, null, S]);
|
||||
assert.deepEqual(call('human', 'session.cancel', { thread: A, op: 'clear-1', confirm: true }), cancelled);
|
||||
assert.deepEqual(call('human', 'session.operation', { thread: A, op: 'clear-1' }), cancelled);
|
||||
assert.deepEqual(stages('clear-1'), ['requested', 'cancelled']);
|
||||
assert.deepEqual(list().map((r) => [r.id, r.state, r.successor]), [[D, 'active', null]]);
|
||||
assert.equal(store.get('SELECT 1 AS x FROM sessions WHERE id=?', S), undefined);
|
||||
assert.deepEqual(b.sessionClears('demo'), []);
|
||||
assert.deepEqual(cancels(D), [m.id]);
|
||||
// The holder kept its claim: a new send reaches it, and the cancelled message never does.
|
||||
call('human', 'thread.send', { thread: A, body: 'after cancel' });
|
||||
assert.deepEqual(call('a1', 'message.receive').map((r) => r.body), ['after cancel']);
|
||||
// The launcher's late reports refuse.
|
||||
assert.equal(code(() => b.sessionClearExited({ ...where('clear-1'), run: null })), 'operation-not-open');
|
||||
assert.equal(code(() => b.sessionClearUncertain(where('clear-1'))), 'operation-not-open');
|
||||
assert.equal(code(() => b.sessionClearRetire(where('clear-1'))), 'not-exited');
|
||||
// A new clear may follow; its revision counts the cancelled op's rows.
|
||||
assert.deepEqual(list().map((r) => r.revision), [2]);
|
||||
assert.equal(clear('human', { session: D, revision: 2, op: 'clear-2' }).state, 'clearing');
|
||||
});
|
||||
|
||||
test('T4 sage-2828 sage-2830: a cancel after retirement ends the startup only, and a later thread-only send makes the successor pending as new input', (t) => {
|
||||
const { b, call, code, A, D, list, stages, clear, where } = setup(t);
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
b.sessionClearExited({ ...where('clear-1'), run: null });
|
||||
b.sessionClearRetire(where('clear-1'));
|
||||
b.sessionClearStart({ ...where('clear-1'), start: 'held', refusal: 'capacity-full' });
|
||||
const S = successorOf(A, D, 'clear-1');
|
||||
const cancelled = call('human', 'session.cancel', { thread: A, op: 'clear-1', confirm: true });
|
||||
assert.deepEqual([cancelled.state, cancelled.retired, cancelled.start], ['cancelled', true, 'held']);
|
||||
assert.deepEqual(b.sessionClears('demo'), []);
|
||||
assert.equal(code(() => b.sessionClearStart({ ...where('clear-1'), start: 'started' })), 'session-clear-cancelled');
|
||||
assert.deepEqual(list().map((r) => [r.id, r.state, r.default]), [
|
||||
[D, 'retired', false],
|
||||
[S, 'active', true],
|
||||
]);
|
||||
call('human', 'thread.send', { thread: A, body: 'new input' });
|
||||
assert.deepEqual(b.threadsState('demo').map((r) => [r.session, r.pending]), [[S, 1]]);
|
||||
assert.deepEqual(stages('clear-1'), ['requested', 'drained', 'exited', 'retired', 'start-held', 'cancelled']);
|
||||
});
|
||||
|
||||
// N4: retirement rechecks the owner. Only a user's thread can fail it (a lost grant), and no verb
|
||||
// clears a user's thread in 271: the R3 gate refuses all three clear verbs to a user, and #thread
|
||||
// refuses them to anyone else. So the op's first two rows are written here as the accept writes
|
||||
// them, and the launcher's ops run as they would. Row 264e carry (duckworth-0201): once a user
|
||||
// clear verb lands, these tests take the verb path instead of the raw session_ops inserts.
|
||||
const userClearExited = (t) => {
|
||||
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] } };
|
||||
const ctx = setup(t, { access });
|
||||
const { b, call, code, project, store, where } = ctx;
|
||||
access.doc.grants = [{ id: 'g-1', user: 'alice', business: 'demo', projects: [project], instances: ['assistant'], level: 'collaborator' }];
|
||||
const alice = b.bindUser({ business: 'demo', user: 'alice' });
|
||||
const U = b.request(alice, { verb: 'thread.create', args: { project, name: 'alice work' } }).id;
|
||||
const UD = store.get('SELECT id FROM sessions WHERE thread=?', U).id;
|
||||
assert.equal(code(() => b.request(alice, { verb: 'session.clear', args: { thread: U, session: UD, revision: 0, confirm: true, op: 'u1' } })), 'access-refused');
|
||||
assert.equal(code(() => call('human', 'session.clear', { thread: U, session: UD, revision: 0, confirm: true, op: 'u1' })), 'thread-not-owned');
|
||||
const S = successorOf(U, UD, 'u1');
|
||||
for (const stage of ['requested', 'drained'])
|
||||
store.run(
|
||||
"INSERT INTO session_ops(at,business,thread,session,op,stage,attempt,successor,successor_name) VALUES(?,'demo',?,?,'u1',?,0,?,'default-1')",
|
||||
new Date().toISOString(),
|
||||
U,
|
||||
UD,
|
||||
stage,
|
||||
S,
|
||||
);
|
||||
b.sessionClearExited({ ...where('u1', U), run: null });
|
||||
return { ...ctx, access, U, UD, S };
|
||||
};
|
||||
|
||||
test('T4 N4: a user\'s thread whose grant is gone by retirement ends the clear failed, writes no successor, and leaves the session as it was', (t) => {
|
||||
const { b, code, stages, store, where, access, U, UD, S } = userClearExited(t);
|
||||
access.doc.grants = [];
|
||||
const failed = b.sessionClearRetire(where('u1', U));
|
||||
assert.deepEqual([failed.state, failed.refusal, failed.start], ['failed', 'access-refused', null]);
|
||||
assert.deepEqual(stages('u1', U), ['requested', 'drained', 'exited', 'failed']);
|
||||
assert.equal(store.get('SELECT 1 AS x FROM sessions WHERE id=?', S), undefined);
|
||||
assert.deepEqual(b.sessionClears('demo'), []);
|
||||
// The session is active again, with no successor; the default stays.
|
||||
assert.deepEqual(
|
||||
store.all('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq', U).map((r) => r.session),
|
||||
[UD],
|
||||
);
|
||||
assert.equal(code(() => b.sessionClearStart({ ...where('u1', U), start: 'started' })), 'not-retired');
|
||||
});
|
||||
|
||||
// duckworth-0201 B1: the recheck passes while the user's collaborator grant is live.
|
||||
test('T4 N4: a user\'s thread whose grant is live at retirement retires the session and makes its successor', (t) => {
|
||||
const { b, stages, store, where, U, UD, S } = userClearExited(t);
|
||||
const retired = b.sessionClearRetire(where('u1', U));
|
||||
assert.deepEqual([retired.state, retired.refusal ?? null, retired.start, retired.successor], ['retired', null, null, S]);
|
||||
assert.deepEqual(stages('u1', U), ['requested', 'drained', 'exited', 'retired']);
|
||||
assert.equal(store.get('SELECT predecessor FROM sessions WHERE id=?', S).predecessor, UD);
|
||||
assert.deepEqual(
|
||||
store.all('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq', U).map((r) => r.session),
|
||||
[UD, S],
|
||||
);
|
||||
});
|
||||
|
||||
// This one covers the started successor.
|
||||
test('T4 sage-2828: a cancel after the successor started refuses session-clear-complete', (t) => {
|
||||
const { b, call, code, A, D, clear, where } = setup(t);
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
b.sessionClearExited({ ...where('clear-1'), run: null });
|
||||
b.sessionClearRetire(where('clear-1'));
|
||||
b.sessionClearStart({ ...where('clear-1'), start: 'started' });
|
||||
assert.equal(code(() => call('human', 'session.cancel', { thread: A, op: 'clear-1', confirm: true })), 'session-clear-complete');
|
||||
});
|
||||
|
||||
test('T4 B2: a run bound to a clearing session refuses its claim, and claims again once the clear is cancelled', (t) => {
|
||||
const { call, code, launch, A, D, clear } = setup(t);
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
assert.equal(code(() => launch('a1', A)), 'session-clearing');
|
||||
assert.equal(code(() => launch('a2', A, D)), 'session-clearing');
|
||||
call('human', 'session.cancel', { thread: A, op: 'clear-1', confirm: true });
|
||||
assert.deepEqual(launch('a3', A), { role: 'assistant', run: 'a3-run', thread: A });
|
||||
});
|
||||
|
||||
test('T4 mos-1325: the tester clears its own thread; its clear, cancel and operation on a human-owned thread refuse thread-not-owned and write nothing', (t) => {
|
||||
const { b, call, code, project, A, D, count } = setup(t);
|
||||
const T = call('tester', 'thread.create', { project, name: 'tester work' }).id;
|
||||
const [{ id: TD }] = call('tester', 'session.list', { thread: T });
|
||||
const own = call('tester', 'session.clear', { thread: T, session: TD, revision: 0, confirm: true, op: 'tclear' });
|
||||
assert.equal(own.state, 'clearing');
|
||||
assert.deepEqual(call('tester', 'session.operation', { thread: T, op: 'tclear' }), own);
|
||||
call('human', 'thread.send', { thread: A, body: 'human waiting' });
|
||||
call('human', 'session.clear', { thread: A, session: D, revision: 0, confirm: true, op: 'hclear' });
|
||||
const before = [count('session_ops'), count('message_cancels')];
|
||||
assert.equal(code(() => call('tester', 'session.clear', { thread: A, session: D, revision: 2, confirm: true, op: 'tx' })), 'thread-not-owned');
|
||||
assert.equal(code(() => call('tester', 'session.cancel', { thread: A, op: 'hclear', confirm: true })), 'thread-not-owned');
|
||||
assert.equal(code(() => call('tester', 'session.operation', { thread: A, op: 'hclear' })), 'thread-not-owned');
|
||||
assert.deepEqual([count('session_ops'), count('message_cancels')], before);
|
||||
// And the human can't reach the tester's.
|
||||
assert.equal(code(() => call('human', 'session.cancel', { thread: T, op: 'tclear', confirm: true })), 'thread-not-owned');
|
||||
// An agent refuses before any check.
|
||||
assert.equal(code(() => call('chief', 'session.clear', { thread: A, session: D, revision: 2, confirm: true, op: 'ax' })), 'human-required');
|
||||
assert.equal(code(() => call('chief', 'session.operation', { thread: A, op: 'hclear' })), 'human-required');
|
||||
assert.deepEqual(b.sessionClears('demo').map((c) => [c.thread, c.op]), [
|
||||
[T, 'tclear'],
|
||||
[A, 'hclear'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('T4 schema: session_ops and message_cancels are append-only, and a second open clear of a session is refused below the broker', (t) => {
|
||||
const { call, A, D, clear, store } = setup(t);
|
||||
const m = call('human', 'thread.send', { thread: A, body: 'w' });
|
||||
clear('human', { session: D, revision: 0, op: 'clear-1' });
|
||||
assert.throws(() => store.run("UPDATE session_ops SET stage='retired'"), /session_ops is append-only/);
|
||||
assert.throws(() => store.run('DELETE FROM session_ops'), /session_ops is append-only/);
|
||||
assert.throws(() => store.run("UPDATE message_cancels SET op='x'"), /message_cancels is append-only/);
|
||||
assert.throws(() => store.run('DELETE FROM message_cancels'), /message_cancels is append-only/);
|
||||
const raw = (op, stage, attempt = 0) =>
|
||||
store.run(
|
||||
"INSERT INTO session_ops(at,business,thread,session,op,stage,attempt,successor,successor_name) VALUES(?,'demo',?,?,?,?,?,'s','n')",
|
||||
new Date().toISOString(),
|
||||
A,
|
||||
D,
|
||||
op,
|
||||
stage,
|
||||
attempt,
|
||||
);
|
||||
assert.throws(() => raw('clear-2', 'requested'), /session has an open clear/);
|
||||
assert.throws(() => raw('clear-1', 'start-held', 0), /CHECK constraint failed/);
|
||||
assert.throws(() => raw('clear-1', 'retired', 1), /CHECK constraint failed/);
|
||||
assert.throws(() => raw('clear-1', 'bogus'), /CHECK constraint failed/);
|
||||
assert.throws(() => raw('clear-1', 'drained'), /UNIQUE constraint failed/);
|
||||
assert.throws(
|
||||
() => store.run("INSERT INTO message_cancels(at,message,session,op) VALUES(?,?,?,'again')", new Date().toISOString(), m.id, D),
|
||||
/UNIQUE constraint failed/,
|
||||
);
|
||||
});
|
||||
|
||||
// The 3m→3n step (sage-2968: row 206 holds 3l→3m). A 3m store from fixtures/schema-3m.sql, which is
|
||||
// refactor's schema.sql at 0cbe2e9b3, with one row in each thread table and a share.
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const ddl3m = readFileSync(here('./fixtures/schema-3m.sql'), 'utf8');
|
||||
function source3m(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'session-clear-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
||||
assert.equal(createHash('sha256').update(ddl3m).digest('hex'), 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9');
|
||||
db.exec(ddl3m);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3m');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
|
||||
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
|
||||
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
|
||||
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
|
||||
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
|
||||
}
|
||||
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
|
||||
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
|
||||
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
|
||||
run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
|
||||
db.close();
|
||||
chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
const open = (path, fn) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try { return fn(db); } finally { db.close(); }
|
||||
};
|
||||
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
|
||||
// Every table's rows but meta's, whose version and digest the step changes.
|
||||
const tables = (path) => open(path, (db) => Object.fromEntries(
|
||||
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
|
||||
));
|
||||
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
|
||||
|
||||
test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are new and empty, and every other row stays as it was', (t) => {
|
||||
const { root, path } = source3m(t);
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3n');
|
||||
const after = tables(path);
|
||||
for (const name of ['session_ops', 'message_cancels']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
delete after[name];
|
||||
}
|
||||
assert.deepEqual(after, before);
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'session-clear-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
new Store(freshRoot).close();
|
||||
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
||||
// The new tables are append-only on the migrated store, and a session holds one open clear.
|
||||
const store = new Store(root);
|
||||
t.after(() => store.close());
|
||||
const op = (stage, over = {}) => ['INSERT INTO session_ops(at,business,thread,session,op,stage,attempt,successor,successor_name) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
AT, 'demo', 't2', 't2', over.op ?? 'c-1', stage, 0, over.successor ?? 's-1', 'default-1'];
|
||||
const [insert, ...args] = op('requested');
|
||||
store.run(insert, ...args);
|
||||
const [again, ...againArgs] = op('requested', { op: 'c-2', successor: 's-2' });
|
||||
assert.throws(() => store.run(again, ...againArgs), /session has an open clear/);
|
||||
store.run('INSERT INTO message_cancels(at,message,session,op) VALUES(?,?,?,?)', AT, 'm-1', 't2', 'c-1');
|
||||
for (const [table, sql, sqlArgs] of [
|
||||
['session_ops', "UPDATE session_ops SET stage='retired'", []],
|
||||
['session_ops', 'DELETE FROM session_ops', []],
|
||||
['session_ops', 'INSERT OR REPLACE INTO session_ops(seq,at,business,thread,session,op,stage,attempt,successor,successor_name) VALUES(?,?,?,?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 't2', 'c-1', 'drained', 0, 's-1', 'default-1']],
|
||||
['message_cancels', "UPDATE message_cancels SET op='c-9'", []],
|
||||
['message_cancels', 'DELETE FROM message_cancels', []],
|
||||
['message_cancels', 'INSERT OR REPLACE INTO message_cancels(seq,at,message,session,op) VALUES(?,?,?,?,?)', [1, AT, 'm-1', 't2', 'c-9']],
|
||||
]) assert.throws(() => store.run(sql, ...sqlArgs), new RegExp(`${table} is append-only`), sql);
|
||||
assert.throws(() => store.run('INSERT INTO message_cancels(at,message,session,op) VALUES(?,?,?,?)', AT, 'no-such', 't2', 'c-1'), /FOREIGN KEY/);
|
||||
assert.deepEqual(store.all('SELECT seq,thread,session,op,stage FROM session_ops').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', session: 't2', op: 'c-1', stage: 'requested' }]);
|
||||
});
|
||||
|
||||
// The 3m engine is store.mjs at this change with the 3m schema and version and no 3m→3n step.
|
||||
async function engine3m(root) {
|
||||
const moduleRoot = join(root, 'engine-3m');
|
||||
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3m).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3m);
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves it as it was', async (t) => {
|
||||
const { root, path } = source3m(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const was = master(path), before = tables(path);
|
||||
const Store3m = await engine3m(root);
|
||||
assert.throws(() => new Store3m(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
|
||||
const { root: old } = source3m(t);
|
||||
new Store3m(old).close();
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
// Row 270 (264c, #1775, T1 item 7 D6): session.list's `compactions` key, from the host's ledger
|
||||
// reader (runtime.mjs gives the seat's compactionCount). The reader itself is
|
||||
// packages/seat/tests/compactions.test.mjs's.
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
|
||||
projects: { stack: { root: '/nonexistent' } },
|
||||
},
|
||||
};
|
||||
|
||||
function setup(t, options = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-compactions-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const b = new Broker({ store, businesses, ...options });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args = {}) => b.request(human, { verb, args });
|
||||
const project = call('project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
const A = call('thread.create', { project, name: 'alpha' }).id;
|
||||
return { store, call, project, A };
|
||||
}
|
||||
|
||||
test('session.list rows carry the reader count for each session, with its store identity', (t) => {
|
||||
const asked = [];
|
||||
const f = setup(t, {
|
||||
compactions: (q) => {
|
||||
asked.push(q);
|
||||
return { count: asked.length, known: true, reason: null, at: '2026-10-22T12:00:00.000Z' };
|
||||
},
|
||||
});
|
||||
const S = f.call('session.create', { thread: f.A, name: 'second', op: 'op1' }).id;
|
||||
const rows = f.call('session.list', { thread: f.A });
|
||||
assert.deepEqual(rows.map((r) => [r.id, r.compactions]), [
|
||||
[rows[0].id, { count: 1, known: true, reason: null, at: '2026-10-22T12:00:00.000Z' }],
|
||||
[S, { count: 2, known: true, reason: null, at: '2026-10-22T12:00:00.000Z' }],
|
||||
]);
|
||||
assert.deepEqual(asked, rows.map((r) => ({ business: 'demo', thread: f.A, session: r.id, legacy: false })));
|
||||
assert.ok(rows.every((r) => !('op' in r)));
|
||||
});
|
||||
|
||||
// A legacy session (op legacy-3l, from the 3k-3l migration, its id the thread's) keeps the
|
||||
// thread's store. The thread is made by hand with the session the migration gives it, as
|
||||
// message-filter.test.mjs does; sessions is append-only.
|
||||
test('a legacy session is asked for with legacy true', (t) => {
|
||||
const asked = [];
|
||||
const f = setup(t, { compactions: (q) => (asked.push(q), { count: 0, known: true, reason: null, at: null }) });
|
||||
const L = randomUUID();
|
||||
const at = '2026-10-21T00:00:00.000Z';
|
||||
f.store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', L, at, 'demo', f.project, 'assistant', 'jason', 'legacy');
|
||||
f.store.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?", L);
|
||||
f.store.run("INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?", L);
|
||||
f.call('session.list', { thread: L });
|
||||
assert.deepEqual(asked, [{ business: 'demo', thread: L, session: L, legacy: true }]);
|
||||
});
|
||||
|
||||
test('without a reader the count is unknown, never 0; a reader that throws is store-unreadable', (t) => {
|
||||
const f = setup(t);
|
||||
assert.deepEqual(f.call('session.list', { thread: f.A }).map((r) => r.compactions), [{ count: null, known: false, reason: 'not-collected', at: null }]);
|
||||
const g = setup(t, {
|
||||
compactions: () => {
|
||||
throw new Error('EACCES');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(g.call('session.list', { thread: g.A }).map((r) => r.compactions), [{ count: null, known: false, reason: 'store-unreadable', at: null }]);
|
||||
});
|
||||
|
||||
test('compactions must be a function', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-compactions-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
assert.throws(() => new Broker({ store, businesses, compactions: {} }), { name: 'TypeError', message: 'compactions must be a function' });
|
||||
});
|
||||
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
|
||||
const { root, path } = source3k(t);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
@@ -430,7 +430,7 @@ async function engine3k(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
|
||||
const Store3k = await engine3k(root);
|
||||
assert.throws(() => new Store3k(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -25,6 +25,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
|
||||
'instance.launch', 'instance.end', 'instance.request',
|
||||
// Row 264a (#1769): the sessions of the tester's own threads.
|
||||
'session.list', 'session.create',
|
||||
// Row 271 (264d, #1776): clear context in the tester's own threads (mos-1325).
|
||||
'session.clear', 'session.cancel', 'session.operation',
|
||||
]);
|
||||
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
|
||||
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3n ready', ''].join('\n'));
|
||||
});
|
||||
@@ -403,10 +403,15 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
const after = tables(path);
|
||||
assert.deepEqual(after.thread_shares, []);
|
||||
delete after.thread_shares;
|
||||
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too.
|
||||
for (const name of ['session_ops', 'message_cancels']) {
|
||||
assert.deepEqual(after[name], [], name);
|
||||
delete after[name];
|
||||
}
|
||||
assert.deepEqual(after, before);
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
|
||||
@@ -434,7 +439,7 @@ async function engine3l(root) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const edited = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace("export const SCHEMA_VERSION = '3n';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
@@ -451,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa
|
||||
const Store3l = await engine3l(root);
|
||||
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3m');
|
||||
assert.equal(version(path), '3n');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
|
||||
@@ -162,6 +162,7 @@ import {
|
||||
writeSessionFile,
|
||||
} from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { collectCompactions, READ_BYTES, readBudget, readStreams, STREAMS } from "../../seat/src/compactions.mjs";
|
||||
import { CheckoutError, ensureCheckouts } from "../../seat/src/checkout.mjs";
|
||||
import { createGitSocket } from "./git-socket.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
@@ -262,6 +263,9 @@ export function createLauncher({
|
||||
const stopping = new Set();
|
||||
const refusedThreads = new Map();
|
||||
const startedFor = new Map();
|
||||
// Row 271 (264d): each clear's stop time (by op), and each clear refusal logged once (by op and code).
|
||||
const clearStops = new Map();
|
||||
const clearLogged = new Map();
|
||||
// Row 194 N6: run -> when its process was spawned, so an end signals only the runs spawned by its verb.
|
||||
const spawnedAt = new Map();
|
||||
// Row 156: run -> the decisions its landing names; decision -> started; decision -> last
|
||||
@@ -283,6 +287,34 @@ export function createLauncher({
|
||||
}
|
||||
};
|
||||
|
||||
// Row 270 (264c, D6): count a thread session's completed compactions into its
|
||||
// store's ledger, at run start (what earlier runs and a resume left) and at
|
||||
// run end. The caller holds the session's workspace lock. A failure is
|
||||
// logged and recorded, and never stops or fails the run: the count then
|
||||
// reads unknown with its reason. Row 270 r2 (B1, mos-1352): each collection
|
||||
// reads at most the system config's compactionReadBytes, a human-only
|
||||
// setting; a missing or bad value takes the default.
|
||||
const budget = readBudget(system?.compactionReadBytes);
|
||||
if (system?.compactionReadBytes !== undefined && budget !== system.compactionReadBytes) {
|
||||
log(`system config: compactionReadBytes ${JSON.stringify(system.compactionReadBytes)} isn't a whole number of bytes from 1048576 to 1073741824; compaction collections read up to the default ${READ_BYTES}`);
|
||||
}
|
||||
// Row 270 r3 (B4, sage-3038): and opens at most compactionStreams stream
|
||||
// files, the same kind of setting.
|
||||
const streams = readStreams(system?.compactionStreams);
|
||||
if (system?.compactionStreams !== undefined && streams !== system.compactionStreams) {
|
||||
log(`system config: compactionStreams ${JSON.stringify(system.compactionStreams)} isn't a whole number from 32 to 65536; compaction collections open up to the default ${STREAMS} streams`);
|
||||
}
|
||||
const compactions = (store, harness, { instance, thread, session, run }) => {
|
||||
let c;
|
||||
try {
|
||||
c = collectCompactions(store, harness, { budget, streams });
|
||||
} catch (e) {
|
||||
log(`run ${run}: compactions: ${e.message}`);
|
||||
c = { count: null, known: false, reason: "store-unreadable" };
|
||||
}
|
||||
record({ event: "compactions", instance, thread, session, run, count: c.count, known: c.known, reason: c.reason });
|
||||
};
|
||||
|
||||
function serialize(fn) {
|
||||
const run = chain.then(fn);
|
||||
chain = run.catch(() => {});
|
||||
@@ -472,6 +504,7 @@ export function createLauncher({
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
if (store) compactions(store, model.harness, { instance, thread, session, run });
|
||||
const toolSocket = join(runDir, "tools.sock");
|
||||
const turnMarker = join(runDir, "turn.done");
|
||||
const version = harnessVersion !== undefined ? harnessVersion : call(model.harness, "version", () => harness.version({ env }));
|
||||
@@ -612,6 +645,7 @@ export function createLauncher({
|
||||
} finally {
|
||||
try { unregisterRuntime(dataRoot, managed.id); }
|
||||
catch { log(`run ${run}: processing receipt cleanup refused`); }
|
||||
if (store) compactions(store, model.harness, { instance, thread, session, run });
|
||||
try { lock?.release(); }
|
||||
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
|
||||
children.delete(run);
|
||||
@@ -657,9 +691,12 @@ export function createLauncher({
|
||||
const ms = (at) => (at ? Date.parse(at) : 0);
|
||||
// Row 269: a thread with a legacy session keeps its old store for that session only.
|
||||
const legacyThreads = new Set(threads.filter((t) => t.legacy).map((t) => t.thread));
|
||||
// Row 271 (264d): a session in a clear is the clear pass's, and nothing below starts or idles it.
|
||||
const clearing = await clearPass(now, legacyThreads);
|
||||
for (const t of threads) {
|
||||
if (closed) return;
|
||||
const key = `${t.thread}/${t.session}`;
|
||||
if (clearing.has(t.session)) continue;
|
||||
const s = registry.running(b.id, t.instance, t.thread, t.session);
|
||||
if (s) {
|
||||
const last = Math.max(ms(s.startedAt), ms(t.lastMessage), ms(t.lastOpen));
|
||||
@@ -707,6 +744,112 @@ export function createLauncher({
|
||||
}
|
||||
}
|
||||
|
||||
// Row 271 (264d, #1776, D7 steps 3-5): one step of each clear the broker lists. The drain comes
|
||||
// from the run itself (its receive, or its end), so a requested clear waits here. A drained run
|
||||
// gets the idle stop's start-time-checked SIGTERM once, and 30 s without its exit proof records
|
||||
// uncertain once; nothing is signalled again or forced. The proof is the run's own close handler,
|
||||
// whose endRun the broker checks, or for no run, the session's workspace lock. Then retirement,
|
||||
// and the successor's start through the same check()/start() as any session. Returns the
|
||||
// sessions those steps own this tick.
|
||||
async function clearPass(now, legacyThreads) {
|
||||
const owned = new Set();
|
||||
let clears;
|
||||
try {
|
||||
clears = await host.op({ op: "sessionClears", business: b.id });
|
||||
} catch (e) {
|
||||
clearLog("sessionClears", e.code ?? e.message, `sessionClears refused: ${e.code ?? e.message}`);
|
||||
return owned;
|
||||
}
|
||||
for (const c of clears) {
|
||||
if (closed) break;
|
||||
const start = ["retired", "start-held", "start-refused"].includes(c.stage);
|
||||
owned.add(start ? c.successor : c.session);
|
||||
try {
|
||||
await (start ? clearStart(c, legacyThreads) : clearStop(c, now));
|
||||
} catch (e) {
|
||||
clearLog(c.op, e.code ?? e.message, `clear ${c.op} (session ${c.session}): ${e.code ?? e.message}`);
|
||||
}
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
function clearLog(op, code, line) {
|
||||
if (clearLogged.get(op) === code) return;
|
||||
clearLogged.set(op, code);
|
||||
log(line);
|
||||
}
|
||||
|
||||
async function clearStop(c, now) {
|
||||
const where = { business: b.id, thread: c.thread, op: c.op };
|
||||
let stage = c.stage;
|
||||
if (stage === "drained" || stage === "uncertain") {
|
||||
const s = registry.running(b.id, c.instance, c.thread, c.session);
|
||||
if (s && s.run === c.run) {
|
||||
if (!clearStops.has(c.op)) {
|
||||
// An uncertain clear was signalled before this launcher started, and isn't signalled again.
|
||||
if (stage === "uncertain") return void clearStops.set(c.op, now);
|
||||
// A cancel since the snapshot leaves the run alone; one after this read is a residual.
|
||||
const fresh = (await host.op({ op: "sessionClears", business: b.id })).find((x) => x.thread === c.thread && x.op === c.op);
|
||||
if (fresh?.stage !== "drained") return;
|
||||
clearStops.set(c.op, now);
|
||||
stopping.add(s.run);
|
||||
record({ event: "session-clear-stop", instance: c.instance, thread: c.thread, session: c.session, run: s.run, op: c.op });
|
||||
try {
|
||||
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
|
||||
} catch {}
|
||||
} else if (stage === "drained" && now - clearStops.get(c.op) >= stopTimeoutMs) {
|
||||
await host.op({ op: "sessionClearUncertain", record: where });
|
||||
record({ event: "session-clear-uncertain", instance: c.instance, thread: c.thread, session: c.session, run: s.run, op: c.op });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Some other run of the session (one that never claimed) exits on its refused claim first.
|
||||
if (s) return;
|
||||
// An earlier host's run that outlived recover()'s kill was ended host-lost, but it still runs:
|
||||
// its exit, not that end, is the proof (row 269 Q2). It isn't signalled.
|
||||
if (survivors.some((x) => x.business === b.id && x.run === c.run && liveRun(x))) return;
|
||||
if (c.run === null) {
|
||||
// No run: the proof is the session's workspace lock, which a spawned run holds until its close.
|
||||
const live = registry.sessions.filter((x) => x.business === b.id && x.workspace).map((x) => x.workspace);
|
||||
const workspace = sessionWorkspace(dataRoot, b.id, c.instance, c.session, { live });
|
||||
lockSessionWorkspace(workspace, { hostPid: process.pid, hostStartTime: startTimeOf(process.pid), business: b.id, instance: c.instance, thread: c.thread, session: c.session }).release();
|
||||
}
|
||||
await host.op({ op: "sessionClearExited", record: { ...where, run: c.run } });
|
||||
stage = "exited";
|
||||
}
|
||||
if (stage !== "exited") return;
|
||||
const out = await host.op({ op: "sessionClearRetire", record: where });
|
||||
clearStops.delete(c.op);
|
||||
record({ event: out.state === "retired" ? "session-clear-retired" : "session-clear-failed", instance: c.instance, thread: c.thread, session: c.session, successor: c.successor, op: c.op, ...(out.refusal ? { code: out.refusal } : {}) });
|
||||
}
|
||||
|
||||
// D8: capacity and limits hold a start, which spends no work attempt. Any other refusal is one.
|
||||
const CLEAR_HELD = new Set(["capacity-full", "limit-reached"]);
|
||||
|
||||
async function clearStart(c, legacyThreads) {
|
||||
const where = { business: b.id, thread: c.thread, op: c.op };
|
||||
await serialize(async () => {
|
||||
if (closed) return;
|
||||
if (!registry.running(b.id, c.instance, c.thread, c.successor)) {
|
||||
const session = { thread: c.thread, session: c.successor };
|
||||
try {
|
||||
await start({ instance: c.instance, launchedBy: b.human, ...session, legacy: false, legacyThread: legacyThreads.has(c.thread), ...(await check(c.instance, session)) });
|
||||
} catch (e) {
|
||||
const code = e instanceof Refusal ? e.code : "launch-failed";
|
||||
// Reported and logged once per code, not once per tick.
|
||||
if (clearLogged.get(c.op) === code) return;
|
||||
clearLogged.set(c.op, code);
|
||||
log(`clear ${c.op}: successor ${c.successor} (${c.instance}): start ${CLEAR_HELD.has(code) ? "held" : "refused"}: ${code}`);
|
||||
record({ event: "refused", by: b.human, instance: c.instance, thread: c.thread, session: c.successor, code, why: e.message, ...(e.limit ? { limit: e.limit } : {}) });
|
||||
await host.op({ op: "sessionClearStart", record: { ...where, start: CLEAR_HELD.has(code) ? "held" : "refused", refusal: code } });
|
||||
return;
|
||||
}
|
||||
}
|
||||
clearLogged.delete(c.op);
|
||||
await host.op({ op: "sessionClearStart", record: { ...where, start: "started" } });
|
||||
});
|
||||
}
|
||||
|
||||
// Row 156 (#1651): start each listed merge decision holder once, and stop a
|
||||
// running holder that started before its decision's resolution.
|
||||
async function landPass() {
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// Compaction counts on a real run (row 270, 264c, #1775, T3's Pi half): the
|
||||
// launcher collects a thread session's completed compactions into its
|
||||
// store's ledger at run start and at run end, and session.list reads the
|
||||
// ledger through the runtime's reader. The count holds across a resume and a
|
||||
// host restart. Real runners in the bwrap sandbox; a stub Pi adapter writes
|
||||
// the session stream (fixtures/compaction-adapter.mjs).
|
||||
//
|
||||
// Round 2 (duckworth-0223 B2, sage-3031, mos-1352): a failed collection, the
|
||||
// read budget from the system config, a clear's retired session and its
|
||||
// successor, and a legacy session read through the runtime's reader. Round 3
|
||||
// (duckworth-0226 B4, sage-3038): the stream cap from the system config.
|
||||
//
|
||||
// The host is session-launch.test.mjs's: startBroker on bootConfig's config,
|
||||
// with the ops the broker child forwards, and the human capability from
|
||||
// bindHuman directly.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { collectCompactions, LEDGER } from "../../seat/src/compactions.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, SESSION_CREDENTIALS, sessionStore } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/compaction-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
|
||||
async function until(fn, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
// A mutant's kill is an assertion failure, not a timeout (sage-2707), so a wait a mutant could
|
||||
// stall returns what it last saw and the assertions after it decide.
|
||||
async function settle(fn, ms = 5000) {
|
||||
const end = Date.now() + ms;
|
||||
let v;
|
||||
while (Date.now() < end) {
|
||||
v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
// The assistant is the thread instance, on Pi.
|
||||
function prepare(t) {
|
||||
const root = tmp(t, "mosaic-compactions-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet: 3 });
|
||||
doc.launch.threadIdleSeconds = 1;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
gitIn(project, "add", "file.txt");
|
||||
gitIn(project, "commit", "-q", "-m", "one");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
// A host and its launcher on the prepared root.
|
||||
async function start(t, { f, adapter }) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker(boot);
|
||||
const ops = {
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
sessionClears: (m) => runtime.sessionClears(m.business),
|
||||
sessionClearExited: (m) => runtime.sessionClearExited(m.record),
|
||||
sessionClearUncertain: (m) => runtime.sessionClearUncertain(m.record),
|
||||
sessionClearRetire: (m) => runtime.sessionClearRetire(m.record),
|
||||
sessionClearStart: (m) => runtime.sessionClearStart(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: "acme",
|
||||
path: runtime.path,
|
||||
dataRoot: f.dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [ADAPTER],
|
||||
threadTickMs: 100,
|
||||
log: (l) => logs.push(l),
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
return { runtime, launcher, call, logs, close };
|
||||
}
|
||||
|
||||
const launchLog = (f) =>
|
||||
existsSync(launchLogFile(f.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
|
||||
: [];
|
||||
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
|
||||
const counted = (f) => events(f, "compactions").map((e) => [e.session, e.count, e.known, e.reason]);
|
||||
|
||||
// Send to a thread's default session and wait for the assistant's RESULT reply.
|
||||
async function ask(call, thread, body) {
|
||||
const sent = call("thread.send", { thread, body });
|
||||
const r = await until(() => call("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
|
||||
assert.equal(r.from_role, "assistant");
|
||||
return JSON.parse(r.body.match(/THREAD (\{.*\})/)[1]);
|
||||
}
|
||||
|
||||
const line = (e) => `${JSON.stringify(e)}\n`;
|
||||
|
||||
test("REQ-THREAD-2 T3 a run collects compactions at its start and its end, and the count holds across a resume and a host restart", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
let host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
||||
const [{ id: D }] = host.call("session.list", { thread: A });
|
||||
// An earlier run left a compaction no run-end collection saw (a host that died mid-run).
|
||||
// Only the run-start collection counts it before the agent's first turn.
|
||||
const store = sessionStore(f.dataRoot, "acme", A, D);
|
||||
writeFileSync(join(store.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c0", parentId: null, timestamp: "2026-10-22T10:59:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
||||
|
||||
// Run 1: start counts c0 (M13), the turn compacts c1, the end counts it (M14).
|
||||
assert.deepEqual(await ask(host.call, A, "compact c1"), { body: "compact c1", before: [] });
|
||||
await until(() => events(f, "end").length === 1, 30000);
|
||||
await settle(() => counted(f).length >= 2);
|
||||
assert.deepEqual(counted(f), [[D, 1, true, null], [D, 2, true, null]]);
|
||||
|
||||
// A restart: a new host and launcher on the same data root. Run 2 resumes the session.
|
||||
await host.close();
|
||||
host = await start(t, prepared);
|
||||
assert.deepEqual(await ask(host.call, A, "compact c2"), { body: "compact c2", before: ["compact c1"] });
|
||||
await until(() => events(f, "end").length === 2, 30000);
|
||||
await settle(() => counted(f).length >= 4);
|
||||
assert.deepEqual(counted(f), [[D, 1, true, null], [D, 2, true, null], [D, 2, true, null], [D, 3, true, null]]);
|
||||
|
||||
// session.list reads the ledger through the runtime's reader (M15), after another restart.
|
||||
await host.close();
|
||||
host = await start(t, prepared);
|
||||
const [row] = host.call("session.list", { thread: A });
|
||||
assert.equal(row.id, D);
|
||||
assert.deepEqual({ ...row.compactions, at: typeof row.compactions?.at }, { count: 3, known: true, reason: null, at: "string" });
|
||||
});
|
||||
|
||||
test("REQ-THREAD-2 T3 a collection that fails is logged and recorded store-unreadable, and the run ends as one whose collections count", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
||||
const [{ id: D }] = host.call("session.list", { thread: A });
|
||||
// The ledger's path is a directory, so readLedger refuses it (ledger-unsafe).
|
||||
const store = sessionStore(f.dataRoot, "acme", A, D);
|
||||
mkdirSync(join(store.dir, LEDGER));
|
||||
assert.deepEqual(await ask(host.call, A, "compact c1"), { body: "compact c1", before: [] });
|
||||
await until(() => events(f, "end").length === 1, 30000);
|
||||
await settle(() => counted(f).length >= 2);
|
||||
assert.deepEqual(counted(f), [[D, null, false, "store-unreadable"], [D, null, false, "store-unreadable"]]);
|
||||
const [{ run: first }] = events(f, "launch");
|
||||
assert.equal(host.logs.filter((l) => l.startsWith(`run ${first}: compactions: `)).length, 2);
|
||||
// With the ledger's path free again, the next run's collections count what the stream holds.
|
||||
rmSync(join(store.dir, LEDGER), { recursive: true });
|
||||
assert.deepEqual(await ask(host.call, A, "compact c2"), { body: "compact c2", before: ["compact c1"] });
|
||||
await until(() => events(f, "end").length === 2, 30000);
|
||||
await settle(() => counted(f).length >= 4);
|
||||
assert.deepEqual(counted(f).slice(2), [[D, 1, true, null], [D, 2, true, null]]);
|
||||
const ends = events(f, "end").map((e) => [e.reason, e.exitCode, e.signal]);
|
||||
assert.deepEqual(ends[0], ends[1]);
|
||||
});
|
||||
|
||||
test("REQ-THREAD-2 T3 the read budget is the system config's compactionReadBytes, and a bad value takes the default", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const setting = (v) => {
|
||||
const doc = JSON.parse(readFileSync(f.config, "utf8"));
|
||||
doc.compactionReadBytes = v;
|
||||
writeFileSync(f.config, JSON.stringify(doc));
|
||||
};
|
||||
setting(1 << 20);
|
||||
let host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
||||
const [{ id: D }] = host.call("session.list", { thread: A });
|
||||
// c0, then a 2 MiB hole with no newline: a line past 1 MiB, inside the 64 MiB default.
|
||||
const store = sessionStore(f.dataRoot, "acme", A, D);
|
||||
const path = join(store.session, "pi.jsonl");
|
||||
const whole = line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c0", parentId: null, timestamp: "2026-10-22T10:59:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 });
|
||||
writeFileSync(path, whole, { mode: 0o600 });
|
||||
truncateSync(path, Buffer.byteLength(whole) + 2 * (1 << 20));
|
||||
assert.deepEqual(await ask(host.call, A, "one"), { body: "one", before: [] });
|
||||
await until(() => events(f, "end").length === 1, 30000);
|
||||
await settle(() => counted(f).length >= 2);
|
||||
assert.deepEqual(counted(f), [[D, 1, false, "not-collected"], [D, 1, false, "not-collected"]]);
|
||||
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), []);
|
||||
|
||||
setting("x");
|
||||
await host.close();
|
||||
host = await start(t, prepared);
|
||||
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), [
|
||||
'system config: compactionReadBytes "x" isn\'t a whole number of bytes from 1048576 to 1073741824; compaction collections read up to the default 67108864',
|
||||
]);
|
||||
assert.deepEqual(await ask(host.call, A, "two"), { body: "two", before: ["one"] });
|
||||
await until(() => events(f, "end").length === 2, 30000);
|
||||
await settle(() => counted(f).length >= 4);
|
||||
assert.deepEqual(counted(f).slice(2), [[D, 1, true, null], [D, 1, true, null]]);
|
||||
});
|
||||
|
||||
test("REQ-THREAD-2 T3 the stream cap is the system config's compactionStreams, and a bad value takes the default", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const setting = (v) => {
|
||||
const doc = JSON.parse(readFileSync(f.config, "utf8"));
|
||||
doc.compactionStreams = v;
|
||||
writeFileSync(f.config, JSON.stringify(doc));
|
||||
};
|
||||
setting(32);
|
||||
let host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
||||
const [{ id: D }] = host.call("session.list", { thread: A });
|
||||
// 33 empty streams that sort before pi.jsonl: 34 in all, past a cap of 32, so pi.jsonl and its
|
||||
// c0 aren't opened. The default cap opens all 34.
|
||||
const store = sessionStore(f.dataRoot, "acme", A, D);
|
||||
for (let i = 0; i < 33; i++) writeFileSync(join(store.session, `e${String(i).padStart(2, "0")}.jsonl`), "", { mode: 0o600 });
|
||||
writeFileSync(join(store.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c0", parentId: null, timestamp: "2026-10-22T10:59:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
||||
assert.deepEqual(await ask(host.call, A, "one"), { body: "one", before: [] });
|
||||
await until(() => events(f, "end").length === 1, 30000);
|
||||
await settle(() => counted(f).length >= 2);
|
||||
assert.deepEqual(counted(f), [[D, null, false, "not-collected"], [D, null, false, "not-collected"]]);
|
||||
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), []);
|
||||
|
||||
setting("x");
|
||||
await host.close();
|
||||
host = await start(t, prepared);
|
||||
assert.deepEqual(host.logs.filter((l) => l.startsWith("system config: ")), [
|
||||
'system config: compactionStreams "x" isn\'t a whole number from 32 to 65536; compaction collections open up to the default 1024 streams',
|
||||
]);
|
||||
assert.deepEqual(await ask(host.call, A, "two"), { body: "two", before: ["one"] });
|
||||
await until(() => events(f, "end").length === 2, 30000);
|
||||
await settle(() => counted(f).length >= 4);
|
||||
assert.deepEqual(counted(f).slice(2), [[D, 1, true, null], [D, 1, true, null]]);
|
||||
});
|
||||
|
||||
test("REQ-THREAD-2 T3 T4 a clear leaves the retired session's count as it was, its successor counts from 0, and a legacy session reads the thread's ledger", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
let host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const A = host.call("thread.create", { project, name: "alpha" }).id;
|
||||
const [{ id: D }] = host.call("session.list", { thread: A });
|
||||
assert.deepEqual(await ask(host.call, A, "compact c1"), { body: "compact c1", before: [] });
|
||||
await until(() => events(f, "end").length === 1, 30000);
|
||||
await settle(() => counted(f).length >= 2);
|
||||
assert.deepEqual(counted(f), [[D, 0, true, null], [D, 1, true, null]]);
|
||||
|
||||
// The run has ended, so the clear retires D at once and starts the successor, whose run-start
|
||||
// collection is its first.
|
||||
const S = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" }).successor;
|
||||
const done = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done?.state, done?.start], ["retired", "started"]);
|
||||
await settle(() => counted(f).some(([s]) => s === S));
|
||||
assert.deepEqual(counted(f).filter(([s]) => s === S)[0], [S, 0, true, null]);
|
||||
const list = () => Object.fromEntries(host.call("session.list", { thread: A }).map((r) => [r.id, r.compactions && { count: r.compactions.count, known: r.compactions.known, reason: r.compactions.reason }]));
|
||||
assert.deepEqual(list(), { [D]: { count: 1, known: true, reason: null }, [S]: { count: 0, known: true, reason: null } });
|
||||
|
||||
// A legacy session (op legacy-3l, from the 3k-3l migration): its store is the thread's, made by
|
||||
// hand as packages/bus/tests/session-compactions.test.mjs does, with a ledger the launcher's
|
||||
// collect wrote.
|
||||
await until(() => events(f, "end").length === 2, 30000);
|
||||
await host.close();
|
||||
const L = "4b1c3a52-9a51-4d7e-8f2e-6c0a1b2d3e4f";
|
||||
const seeded = new Store(f.dataRoot);
|
||||
const at = "2026-10-21T00:00:00.000Z";
|
||||
seeded.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", L, at, "acme", project, "assistant", f.doc.human, "legacy");
|
||||
seeded.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,'default','legacy-3l' FROM threads WHERE id=?", L);
|
||||
seeded.run("INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?", L);
|
||||
seeded.close();
|
||||
const legacy = sessionStore(f.dataRoot, "acme", L, L, { legacy: true });
|
||||
writeFileSync(join(legacy.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c7", parentId: null, timestamp: "2026-10-22T11:07:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
||||
assert.equal(collectCompactions(legacy, "pi").count, 1);
|
||||
host = await start(t, prepared);
|
||||
const [row] = host.call("session.list", { thread: L });
|
||||
assert.equal(row.id, L);
|
||||
assert.deepEqual({ ...row.compactions, at: typeof row.compactions?.at }, { count: 1, known: true, reason: null, at: "string" });
|
||||
assert.deepEqual(list(), { [D]: { count: 1, known: true, reason: null }, [S]: { count: 0, known: true, reason: null } });
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// A stand-in harness turn for the clear context tests (row 271, #1776).
|
||||
// session-adapter.mjs's turn, plus what the run sees of the pid namespace:
|
||||
// the numeric entries of /proc and pid 1's argv, so a test can tell that the
|
||||
// session runs in its own namespace (N6).
|
||||
|
||||
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const request = process.env.MOSAIC_REQUEST ?? "";
|
||||
const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? "";
|
||||
const historyFile = join(process.env.MOSAIC_SESSION_DIR, "history");
|
||||
const before = existsSync(historyFile) ? readFileSync(historyFile, "utf8").split("\n").filter(Boolean) : [];
|
||||
const cwd = process.cwd();
|
||||
const pids = readdirSync("/proc").filter((n) => /^\d+$/.test(n)).map(Number).sort((a, b) => a - b);
|
||||
const pid1 = readFileSync("/proc/1/cmdline", "utf8").split("\0").filter(Boolean);
|
||||
appendFileSync(historyFile, `${body}\n`);
|
||||
process.stdout.write(`THREAD ${JSON.stringify({ body, before, cwd, pids, pid1 })}\n`);
|
||||
writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
|
||||
@@ -0,0 +1,24 @@
|
||||
// A stand-in Pi turn for the compaction launch tests (row 270, #1775).
|
||||
// session-adapter.mjs's turn, plus a Pi session stream in
|
||||
// $MOSAIC_SESSION_DIR: a session header when the stream is new, and for a
|
||||
// message "compact <id>" one completed compaction entry with that id, in
|
||||
// Pi 0.85.1's shape.
|
||||
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const request = process.env.MOSAIC_REQUEST ?? "";
|
||||
const body = request.match(/^Message [^\n]*:\n\n([\s\S]*)$/m)?.[1] ?? "";
|
||||
const historyFile = join(process.env.MOSAIC_SESSION_DIR, "history");
|
||||
const before = existsSync(historyFile) ? readFileSync(historyFile, "utf8").split("\n").filter(Boolean) : [];
|
||||
const stream = join(process.env.MOSAIC_SESSION_DIR, "pi.jsonl");
|
||||
const line = (e) => `${JSON.stringify(e)}\n`;
|
||||
if (!existsSync(stream)) appendFileSync(stream, line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: process.cwd() }));
|
||||
const id = body.match(/^compact (c\d+)$/)?.[1];
|
||||
if (id) {
|
||||
const timestamp = new Date(Date.UTC(2026, 9, 22, 11, Number(id.slice(1)))).toISOString();
|
||||
appendFileSync(stream, line({ type: "compaction", id, parentId: null, timestamp, summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }));
|
||||
}
|
||||
appendFileSync(historyFile, `${body}\n`);
|
||||
process.stdout.write(`THREAD ${JSON.stringify({ body, before })}\n`);
|
||||
writeFileSync(process.env.MOSAIC_TURN_MARKER, "done\n");
|
||||
@@ -0,0 +1,397 @@
|
||||
// Clear context (row 271, 264d, #1776, T4): the launcher half of D7 and D8. A clear of a running
|
||||
// session stops its drained run once, retires it on the run's own end, and starts the successor
|
||||
// through the same check()/start() as any session; a held start waits for capacity; an earlier
|
||||
// host's live run of the cleared session holds the clear until it exits; and a session runs in its
|
||||
// own pid namespace (N6); a bus that can't answer holds a clear where it is (T6). Real runners in
|
||||
// the bwrap sandbox, with clear-adapter.mjs as the turn.
|
||||
// The bus half is packages/bus/tests/session-clear.test.mjs.
|
||||
//
|
||||
// The host is session-launch.test.mjs's, with the five clear ops the broker child forwards.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/clear-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
|
||||
async function until(fn, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
// A mutant's kill is an assertion failure, not a timeout (sage-2707): the waits return what they
|
||||
// last saw and the assertions after them decide.
|
||||
async function settle(fn, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
let v;
|
||||
while (Date.now() < end) {
|
||||
v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
// The assistant is the thread instance; sonnet is its model's limit.
|
||||
function prepare(t, { sonnet = 3, idle = 30 } = {}) {
|
||||
const root = tmp(t, "mosaic-clear-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
|
||||
doc.launch.threadIdleSeconds = idle;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
gitIn(project, "add", "file.txt");
|
||||
gitIn(project, "commit", "-q", "-m", "one");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { tickMs = 100, stopTimeoutMs } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker(boot);
|
||||
const ops = {
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
sessionClears: (m) => runtime.sessionClears(m.business),
|
||||
sessionClearExited: (m) => runtime.sessionClearExited(m.record),
|
||||
sessionClearUncertain: (m) => runtime.sessionClearUncertain(m.record),
|
||||
sessionClearRetire: (m) => runtime.sessionClearRetire(m.record),
|
||||
sessionClearStart: (m) => runtime.sessionClearStart(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: "acme",
|
||||
path: runtime.path,
|
||||
dataRoot: f.dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [ADAPTER],
|
||||
threadTickMs: tickMs,
|
||||
...(stopTimeoutMs ? { stopTimeoutMs } : {}),
|
||||
log: (l) => logs.push(l),
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
return { host, runtime, launcher, call, logs, close };
|
||||
}
|
||||
|
||||
const launchLog = (f) =>
|
||||
existsSync(launchLogFile(f.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
|
||||
: [];
|
||||
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
|
||||
const sessionWs = (f, session) => join(f.dataRoot, "session-workspaces", "acme", "assistant", session);
|
||||
const historyOf = (f, thread, session) => {
|
||||
const p = join(f.dataRoot, "threads", "acme", thread, "sessions", session, "session", "history");
|
||||
return existsSync(p) ? readFileSync(p, "utf8") : null;
|
||||
};
|
||||
|
||||
function send(call, thread, session, body) {
|
||||
const sent = call("thread.send", { thread, ...(session ? { session } : {}), body });
|
||||
const reply = async () => {
|
||||
const r = await until(() => call("thread.messages", { thread, ...(session ? { session } : {}) }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
|
||||
assert.equal(r.from_role, "assistant");
|
||||
return JSON.parse(r.body.match(/THREAD (\{.*\})/)[1]);
|
||||
};
|
||||
return { sent, reply };
|
||||
}
|
||||
|
||||
function thread(call) {
|
||||
const project = call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const A = call("thread.create", { project, name: "alpha" }).id;
|
||||
const [{ id: D }] = call("session.list", { thread: A });
|
||||
return { project, A, D };
|
||||
}
|
||||
|
||||
test("REQ-THREAD-2 T4 D7 D8 N6: a clear of a running session stops its drained run once, retires it on the run's end, and starts the successor fresh in its own namespace", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const { A, D } = thread(host.call);
|
||||
const first = await send(host.call, A, D, "before the clear").reply();
|
||||
assert.deepEqual([first.before, first.cwd], [[], sessionWs(f, D)]);
|
||||
// N6: the run sees its own pid namespace. Pid 1 there is the sandboxed command, not bwrap, and
|
||||
// the host's processes aren't listed.
|
||||
assert.notEqual(first.pid1[0], BWRAP);
|
||||
assert.ok(first.pids.length < 20, JSON.stringify(first.pids));
|
||||
assert.equal(first.pids.includes(1), true);
|
||||
const [{ run }] = events(f, "launch");
|
||||
|
||||
const op = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" });
|
||||
const S = op.successor;
|
||||
const done = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start, done.refusal], ["retired", "started", null]);
|
||||
// The drained run got one stop, and ended stopped; the clear retired it after that end.
|
||||
const stops = events(f, "session-clear-stop");
|
||||
assert.deepEqual(stops.map((e) => [e.session, e.run, e.op]), [[D, run, "clear-1"]]);
|
||||
assert.deepEqual(events(f, "session-clear-uncertain"), []);
|
||||
const kinds = launchLog(f).map((e) => [e.event, e.session ?? null]).filter(([e]) => ["session-clear-stop", "end", "session-clear-retired", "launch"].includes(e));
|
||||
assert.deepEqual(kinds, [
|
||||
["launch", D],
|
||||
["session-clear-stop", D],
|
||||
["end", D],
|
||||
["session-clear-retired", D],
|
||||
["launch", S],
|
||||
]);
|
||||
assert.deepEqual(events(f, "session-clear-retired").map((e) => [e.successor, e.op]), [[S, "clear-1"]]);
|
||||
// The successor is the default now; a thread-only send reaches it, with no history and its own
|
||||
// workspace. The old session's store stays as it was.
|
||||
const after = await send(host.call, A, null, "after the clear").reply();
|
||||
assert.deepEqual([after.body, after.before, after.cwd], ["after the clear", [], sessionWs(f, S)]);
|
||||
assert.equal(historyOf(f, A, D), "before the clear\n");
|
||||
assert.equal(historyOf(f, A, S), "after the clear\n");
|
||||
assert.deepEqual(host.call("session.list", { thread: A }).map((r) => [r.id, r.state, r.default]), [
|
||||
[D, "retired", false],
|
||||
[S, "active", true],
|
||||
]);
|
||||
assert.deepEqual(readSessions(f.dataRoot).map((s) => s.session), [S]);
|
||||
});
|
||||
|
||||
test("REQ-THREAD-2 T4 D8: a successor start refused capacity-full is held, reported and logged once, and starts when capacity frees", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t, { sonnet: 1, idle: 4 });
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const { A, D } = thread(host.call);
|
||||
const N = host.call("session.create", { thread: A, name: "side", op: "op-side" }).id;
|
||||
// The side session takes the one sonnet slot; the default never ran, so its clear has no run.
|
||||
await send(host.call, A, N, "fill the slot").reply();
|
||||
const S = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" }).successor;
|
||||
const held = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "held" && e;
|
||||
});
|
||||
assert.deepEqual([held.state, held.start, held.refusal], ["retired", "held", null]);
|
||||
assert.deepEqual(events(f, "session-clear-stop"), [], "no run to stop");
|
||||
// The side session idles out, and the held start goes on the next tick after.
|
||||
const started = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.equal(started.start, "started");
|
||||
assert.deepEqual(events(f, "launch").map((e) => e.session), [N, S]);
|
||||
const lines = host.logs.filter((l) => l.startsWith("clear clear-1:"));
|
||||
assert.deepEqual(lines, [`clear clear-1: successor ${S} (assistant): start held: capacity-full`]);
|
||||
assert.deepEqual(events(f, "refused").map((e) => [e.session, e.code]), [[S, "capacity-full"]]);
|
||||
});
|
||||
|
||||
// sage-2830, Q2: after a restart, an earlier host's run of the cleared session that outlived
|
||||
// recover() was ended host-lost, but it still runs. Its exit, not that end, is the proof, and it
|
||||
// isn't signalled. A sleep child stands in for its runner, as in session-launch.test.mjs's Q2 test.
|
||||
test("REQ-THREAD-2 T4 D7 Q2: after a restart, a live survivor of the cleared run holds the clear at drained with no signal, and its exit lets it retire", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const one = await start(t, prepared);
|
||||
const { A, D } = thread(one.call);
|
||||
const pid = 2 ** 22 + 7;
|
||||
const record = { business: "acme", role: "assistant", run: "run-left", harness: "pi", pid, startTime: "1", thread: A, session: D };
|
||||
const left = one.runtime.bindLaunch(record);
|
||||
one.runtime.broker.request(left.cap, { verb: "role.claim" });
|
||||
one.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" });
|
||||
assert.deepEqual(one.runtime.broker.request(left.cap, { verb: "message.receive" }), []);
|
||||
assert.deepEqual(one.runtime.sessionClears("acme").map((c) => [c.stage, c.run]), [["drained", "run-left"]]);
|
||||
await one.close();
|
||||
|
||||
const runner = spawn("sleep", ["60"], { stdio: "ignore" });
|
||||
const gone = new Promise((r) => runner.once("exit", r));
|
||||
t.after(() => runner.kill("SIGKILL"));
|
||||
const runnerStartTime = await until(() => startTimeOf(runner.pid));
|
||||
new Registry(f.dataRoot).add({ business: "acme", instance: "assistant", thread: A, session: D, run: "run-left", harness: "pi", pid, startTime: "1", runnerPid: runner.pid, runnerStartTime });
|
||||
const two = await start(t, prepared);
|
||||
assert.ok(two.logs.includes(`run run-left (assistant) from an earlier host ended: host-lost`), two.logs.join("\n"));
|
||||
// Ended in the bus, live on the host: the clear waits, and nothing is signalled.
|
||||
for (let i = 0; i < 5; i++) await two.launcher.tick();
|
||||
assert.deepEqual(two.runtime.sessionClears("acme").map((c) => c.stage), ["drained"]);
|
||||
assert.deepEqual(events(f, "session-clear-stop"), []);
|
||||
assert.equal(startTimeOf(runner.pid), runnerStartTime, "the survivor wasn't signalled");
|
||||
|
||||
runner.kill("SIGKILL");
|
||||
await gone;
|
||||
const done = await settle(() => {
|
||||
const e = two.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start], ["retired", "started"]);
|
||||
assert.deepEqual(events(f, "session-clear-stop"), []);
|
||||
});
|
||||
|
||||
// D7 step 3: a drained run that doesn't exit within the stop timeout. The ticks are the test's, so
|
||||
// its runner is stopped from outside its namespace before the clear's one TERM, as
|
||||
// launcher.test.mjs's SIGTERM test does, and the TERM waits. The clear records uncertain once,
|
||||
// sends nothing more, and retires when the run's end comes.
|
||||
test("REQ-THREAD-2 T4 D7: a drained run that outlives the stop timeout makes the clear uncertain once, with one signal, and it retires on the run's end", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared, { tickMs: 3600000, stopTimeoutMs: 1000 });
|
||||
const tick = () => host.launcher.tick();
|
||||
const { A, D } = thread(host.call);
|
||||
const first = send(host.call, A, D, "before the clear");
|
||||
await tick();
|
||||
await first.reply();
|
||||
const [s] = readSessions(f.dataRoot);
|
||||
const kill = () => {
|
||||
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
|
||||
};
|
||||
t.after(kill);
|
||||
host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" });
|
||||
// The run's own receive drains it; no tick is needed for that.
|
||||
const drained = await settle(() => host.runtime.sessionClears("acme").find((c) => c.stage === "drained"));
|
||||
assert.equal(drained?.run, s.run);
|
||||
process.kill(s.runnerPid, "SIGSTOP");
|
||||
await tick();
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
const uncertain = await settle(async () => {
|
||||
await tick();
|
||||
return host.runtime.sessionClears("acme").find((c) => c.stage === "uncertain");
|
||||
}, 15000);
|
||||
assert.equal(uncertain?.run, s.run);
|
||||
for (let i = 0; i < 5; i++) await tick();
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
assert.equal(events(f, "session-clear-uncertain").length, 1);
|
||||
assert.equal(startTimeOf(s.runnerPid), s.runnerStartTime, "nothing forced it");
|
||||
process.kill(s.runnerPid, "SIGCONT");
|
||||
const done = await settle(async () => {
|
||||
await tick();
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start], ["retired", "started"]);
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
assert.equal(events(f, "session-clear-uncertain").length, 1);
|
||||
});
|
||||
|
||||
// T6 (sage-3001): the bus that proves the stop is configured but unavailable. Its host's op
|
||||
// rejects as host.mjs does when the broker exits before it replies (broker-exited), first at the
|
||||
// exit proof, then at retirement. While it is down nothing retires, no successor launches and the
|
||||
// cleared session's slot isn't handed on; each failure is logged once, and the clear resumes where
|
||||
// it stopped once the bus answers again.
|
||||
test("REQ-THREAD-2 T4 T6: a bus unavailable at the exit proof and then at retirement holds the clear with no retirement, successor or launch, and it resumes when the bus is back", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const down = new Set();
|
||||
const op = host.host.op;
|
||||
host.host.op = (m) => (down.has(m.op) ? Promise.reject(Object.assign(new Error("broker exited before it replied"), { code: "broker-exited" })) : op(m));
|
||||
const { A, D } = thread(host.call);
|
||||
await send(host.call, A, D, "before the clear").reply();
|
||||
const stage = () => host.runtime.sessionClears("acme").map((c) => c.stage);
|
||||
const lines = () => host.logs.filter((l) => l.startsWith("clear clear-1") || l.startsWith("thread tick:"));
|
||||
const line = `clear clear-1 (session ${D}): broker-exited`;
|
||||
const held = () => {
|
||||
assert.deepEqual(events(f, "session-clear-retired"), []);
|
||||
assert.deepEqual(events(f, "session-clear-failed"), []);
|
||||
assert.deepEqual(events(f, "launch").map((e) => e.session), [D]);
|
||||
assert.deepEqual(readSessions(f.dataRoot), [], "nothing runs in the cleared session's slot");
|
||||
assert.deepEqual(host.call("session.list", { thread: A }).map((r) => [r.id, r.default]), [[D, true]]);
|
||||
};
|
||||
|
||||
down.add("sessionClearExited");
|
||||
const S = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" }).successor;
|
||||
// The drained run is stopped and ends; the exit proof is what fails.
|
||||
await settle(() => events(f, "end").length === 1 && host.logs.includes(line));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
assert.deepEqual(lines(), [line]);
|
||||
assert.deepEqual(stage(), ["drained"]);
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
held();
|
||||
|
||||
down.clear();
|
||||
down.add("sessionClearRetire");
|
||||
await settle(() => stage()[0] === "exited");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
assert.deepEqual(stage(), ["exited"]);
|
||||
assert.deepEqual(lines(), [line], "the same code isn't logged again");
|
||||
held();
|
||||
|
||||
down.clear();
|
||||
const done = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start, done.refusal], ["retired", "started", null]);
|
||||
const kinds = launchLog(f).map((e) => [e.event, e.session ?? null]).filter(([e]) => ["session-clear-stop", "end", "session-clear-retired", "launch"].includes(e));
|
||||
assert.deepEqual(kinds, [
|
||||
["launch", D],
|
||||
["session-clear-stop", D],
|
||||
["end", D],
|
||||
["session-clear-retired", D],
|
||||
["launch", S],
|
||||
]);
|
||||
assert.deepEqual(lines(), [line]);
|
||||
assert.deepEqual(host.call("session.list", { thread: A }).map((r) => [r.id, r.state, r.default]), [
|
||||
[D, "retired", false],
|
||||
[S, "active", true],
|
||||
]);
|
||||
});
|
||||
@@ -295,9 +295,10 @@ export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "ha
|
||||
assert.notEqual(KINDS_OLD,KINDS_NEW);
|
||||
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
|
||||
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader.
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader. Row 203: B's
|
||||
// loader also refuses a `federation` key, as a pre-203 loader does (its list has no federation).
|
||||
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
|
||||
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
|
||||
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console", "federation"]','["configVersion", "environment", "dataRoot", "execution"]');
|
||||
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
|
||||
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
|
||||
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
|
||||
|
||||
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
|
||||
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
|
||||
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
|
||||
// well as T46.
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records'];
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels'];
|
||||
const LATE='2999-01-01T00:00:00.000Z';
|
||||
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
|
||||
|
||||
@@ -1468,7 +1468,7 @@ const SHOW=`show-${CON}.txt`;
|
||||
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
|
||||
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
|
||||
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --tester-port as the pointer has it`;
|
||||
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
||||
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console or federation key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
||||
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
|
||||
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
|
||||
function setEdit(set,name,from,to,sums=true){
|
||||
@@ -1582,6 +1582,19 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('row 203: a config whose federation key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
// B's loader has no federation key in its list, as a pre-203 loader has none: it refuses the key.
|
||||
const setFed=(fed)=>{const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(fed===undefined)delete c.federation;else c.federation=fed;writeFileSync(e.cfg,JSON.stringify(c))};
|
||||
setFed({self:'hub',peers:[{id:'factory',origin:'https://mosaic.woltje.com',label:'Software factory'}]});
|
||||
const why='mosaic-config: unsupported configuration key: "federation"';
|
||||
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
||||
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
||||
setFed(undefined);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Row 270 (264c, #1775, T1 item 7 D6): the count of completed compactions over
|
||||
// one thread session's lifetime, across its runs and native branches.
|
||||
//
|
||||
// The host collects it from the session's native store and appends what it
|
||||
// found to a ledger, <store dir>/compactions.jsonl (0600, append-only). The
|
||||
// store dir holds the mounted session/ and claude-projects/ but is not mounted
|
||||
// itself (sandbox.mjs binds()), so the agent can't reach the ledger. The
|
||||
// launcher is its only writer, and collects while it holds the session's
|
||||
// workspace lock (session.mjs lockSessionWorkspace); each collection is one
|
||||
// O_APPEND write. The count is rebuilt from the ledger, never from a page.
|
||||
//
|
||||
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
|
||||
// written by SessionManager.appendCompaction after the summary exists. A start
|
||||
// or an aborted compaction writes none. A `compactionSummary` message, a
|
||||
// message inside a compaction's retainedTail and a branch_summary are not
|
||||
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
|
||||
// file of the same directory with their id and timestamp unchanged, so a
|
||||
// compaction's key is (entry id, entry timestamp) over the whole store, and the
|
||||
// stream it was first seen in is recorded beside it.
|
||||
//
|
||||
// Claude Code has no pinned compaction format yet (Vikunja 466): a session
|
||||
// any Claude run collected for is unknown with harness-unsupported, never 0.
|
||||
// So is one whose first collection finds Claude history in claude-projects/
|
||||
// from before this ledger existed (row 270 r2, B3).
|
||||
//
|
||||
// One collection reads at most a budget of stream bytes (row 270 r2, B1).
|
||||
// The budget is the system config's compactionReadBytes, which only a human
|
||||
// writes (mos-1352, DECISIONS Q222); readBudget() takes the default for a
|
||||
// missing or bad value. A collection the budget stops short is unknown with
|
||||
// not-collected, and its cursors stop at the last whole line read, so a later
|
||||
// collection reads on from there.
|
||||
//
|
||||
// One collection opens at most a cap of stream files, in name order (row 270
|
||||
// r3, B4). The cap is the system config's compactionStreams, human-only too
|
||||
// (sage-3038); readStreams() takes the default for a missing or bad value.
|
||||
// Past the cap a collection is not-collected, and the streams it didn't open
|
||||
// get no cursor line, so neither a collection's work nor the ledger lines it
|
||||
// writes grow with the number of files the agent makes.
|
||||
//
|
||||
// Ledger lines, each one JSON object with v 1:
|
||||
// { kind: "compaction", entry, completedAt, stream, at } once per key
|
||||
// { kind: "cursor", stream, bytes, tail, at } read through `bytes`; `tail`
|
||||
// is the sha256 of the up to 4096 bytes that end there
|
||||
// { kind: "collected", at, known, reason, harness, version }
|
||||
// A cursor lets the next collection read only what was appended. A stream
|
||||
// that shrank, changed under its cursor or went away is store-incomplete from
|
||||
// then on: entries may be lost, so the count stays a lower bound for good.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, writeSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
export const PI_VERSION = "0.85.1";
|
||||
export const LEDGER = "compactions.jsonl";
|
||||
const TAIL = 4096;
|
||||
const CHUNK = 1 << 20;
|
||||
// BUILD.md (queue-270, round 2) has the measured stream sizes behind the default.
|
||||
export const READ_BYTES = 64 * CHUNK;
|
||||
const READ_MIN = CHUNK, READ_MAX = 1024 * CHUNK;
|
||||
// BUILD.md (queue-270, round 3) has the measured stream counts behind the default.
|
||||
export const STREAMS = 1024;
|
||||
const STREAMS_MIN = 32, STREAMS_MAX = 65536;
|
||||
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
|
||||
|
||||
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
|
||||
const keyOf = (entry, completedAt) => JSON.stringify([entry, completedAt]);
|
||||
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
|
||||
|
||||
// The store directory session.mjs sessionStore() makes, as a path only: the
|
||||
// reader never creates it. Null for an id that isn't one.
|
||||
export function storeDir(dataRoot, business, thread, session, { legacy = false } = {}) {
|
||||
if (![business, thread, session].every(segment)) return null;
|
||||
const dir = join(dataRoot, "threads", business, thread);
|
||||
return legacy ? dir : join(dir, "sessions", session);
|
||||
}
|
||||
|
||||
// The budget from the system config's compactionReadBytes: a whole number of
|
||||
// bytes from 1 MiB to 1 GiB, or the default for anything else, missing included.
|
||||
export function readBudget(value) {
|
||||
return Number.isSafeInteger(value) && value >= READ_MIN && value <= READ_MAX ? value : READ_BYTES;
|
||||
}
|
||||
|
||||
// The stream cap from the system config's compactionStreams: a whole number
|
||||
// from 32 to 65536, or the default for anything else, missing included.
|
||||
export function readStreams(value) {
|
||||
return Number.isSafeInteger(value) && value >= STREAMS_MIN && value <= STREAMS_MAX ? value : STREAMS;
|
||||
}
|
||||
|
||||
// Only the short tail reads (at most TAIL bytes) use this.
|
||||
function readRange(fd, from, to) {
|
||||
const out = Buffer.alloc(to - from);
|
||||
let off = 0;
|
||||
while (off < out.length) {
|
||||
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
|
||||
if (n === 0) break;
|
||||
off += n;
|
||||
}
|
||||
return out.subarray(0, off);
|
||||
}
|
||||
|
||||
// One collection over a Pi session directory. `cursors` are the ledger's,
|
||||
// by stream. Opens at most `streams` streams, in name order, and reads at
|
||||
// most `budget` bytes past their cursors, over all of them.
|
||||
// Returns the compactions found past each cursor, the new cursors, and the
|
||||
// problems: store-unreadable (a file that can't be read or isn't a Pi
|
||||
// session), store-incomplete (a stream that shrank, changed or went away, or
|
||||
// a line that isn't JSON) and not-collected (the budget or the stream cap
|
||||
// ran out first). A
|
||||
// missing directory is empty.
|
||||
export function scanPi(dir, cursors = {}, budget = READ_BYTES, streams = STREAMS) {
|
||||
const found = [], next = {}, problems = new Set();
|
||||
let left = budget;
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") problems.add("store-unreadable");
|
||||
names = [];
|
||||
}
|
||||
const present = new Set(names);
|
||||
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
|
||||
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
|
||||
if (visit.length > streams) problems.add("not-collected");
|
||||
for (const name of visit.slice(0, streams)) {
|
||||
const path = join(dir, name);
|
||||
let fd;
|
||||
try {
|
||||
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
|
||||
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const size = fstatSync(fd).size;
|
||||
const c = cursors[name];
|
||||
let from = 0;
|
||||
if (c) {
|
||||
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
|
||||
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
|
||||
else problems.add("store-incomplete");
|
||||
}
|
||||
let header = from > 0;
|
||||
const take = (raw) => {
|
||||
const line = raw.toString("utf8");
|
||||
if (!line.trim()) return;
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
e = null;
|
||||
}
|
||||
if (!e || typeof e !== "object" || Array.isArray(e)) {
|
||||
problems.add("store-incomplete");
|
||||
return;
|
||||
}
|
||||
if (!header) {
|
||||
header = true;
|
||||
if (e.type !== "session") problems.add("store-unreadable");
|
||||
return;
|
||||
}
|
||||
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
|
||||
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
|
||||
}
|
||||
};
|
||||
// A CHUNK at a time, up to the budget left. Complete lines only: a line
|
||||
// split across chunks waits in `carry`, and a partial last line waits
|
||||
// for its newline. `end` is past the last whole line.
|
||||
const stop = Math.min(size, from + left);
|
||||
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
|
||||
let pos = from, end = from, carry = [];
|
||||
while (pos < stop) {
|
||||
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
|
||||
if (n === 0) break;
|
||||
const view = chunk.subarray(0, n);
|
||||
let i = 0;
|
||||
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
|
||||
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
|
||||
carry = [];
|
||||
i = nl + 1;
|
||||
end = pos + i;
|
||||
}
|
||||
if (i < n) carry.push(Buffer.from(view.subarray(i)));
|
||||
pos += n;
|
||||
}
|
||||
left -= pos - from;
|
||||
if (stop < size) problems.add("not-collected");
|
||||
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
return { found, cursors: next, problems: [...problems].sort() };
|
||||
}
|
||||
|
||||
// The ledger as recorded. Throws on a ledger that can't be read. `bad` is set
|
||||
// by any line that isn't a v 1 record, a torn last line included.
|
||||
export function readLedger(dir) {
|
||||
const events = new Map(), cursors = {};
|
||||
let last = null, incomplete = false, unsupported = false, bad = false, torn = false, text;
|
||||
const file = join(dir, LEDGER);
|
||||
try {
|
||||
if (!lstatSync(file).isFile()) throw Object.assign(new Error(`${file} isn't a regular file`), { code: "ledger-unsafe" });
|
||||
text = readFileSync(file, { encoding: "utf8", flag: fsc.O_RDONLY | fsc.O_NOFOLLOW });
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
throw e;
|
||||
}
|
||||
const lines = text.split("\n");
|
||||
if (lines.pop() !== "") bad = torn = true;
|
||||
for (const line of lines) {
|
||||
let r;
|
||||
try {
|
||||
r = JSON.parse(line);
|
||||
} catch {
|
||||
r = null;
|
||||
}
|
||||
if (!r || typeof r !== "object" || r.v !== 1) {
|
||||
bad = true;
|
||||
continue;
|
||||
}
|
||||
if (r.kind === "compaction") {
|
||||
const key = keyOf(r.entry, r.completedAt);
|
||||
if (!events.has(key)) events.set(key, r);
|
||||
} else if (r.kind === "cursor") cursors[r.stream] = { bytes: r.bytes, tail: r.tail };
|
||||
else if (r.kind === "collected") {
|
||||
last = r;
|
||||
if (r.reason === "store-incomplete") incomplete = true;
|
||||
if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;
|
||||
} else bad = true;
|
||||
}
|
||||
return { events, cursors, last, incomplete, unsupported, bad, torn };
|
||||
}
|
||||
|
||||
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
|
||||
// directory that can't be read throws, so the collection is not recorded and
|
||||
// the next one checks again.
|
||||
function claudeHistory(store) {
|
||||
if (!store.projects) return false;
|
||||
try {
|
||||
return readdirSync(store.projects).length > 0;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// What session.list shows: { count, known, reason, at }. Known only when the
|
||||
// ledger is whole, every collection ran on Pi and none lost entries. Any other
|
||||
// answer is unknown with a reason and a lower bound, or null for none: never 0.
|
||||
export function compactionCount(dir) {
|
||||
const unknown = (reason, count = null, at = null) => ({ count: count || null, known: false, reason, at });
|
||||
let ledger;
|
||||
try {
|
||||
ledger = readLedger(dir);
|
||||
} catch {
|
||||
return unknown("store-unreadable");
|
||||
}
|
||||
if (!ledger) return unknown("not-collected");
|
||||
const count = ledger.events.size;
|
||||
const at = ledger.last?.at ?? null;
|
||||
if (ledger.bad) return unknown("store-incomplete", count, at);
|
||||
if (!ledger.last) return unknown("not-collected", count);
|
||||
if (ledger.unsupported) return unknown("harness-unsupported", count, at);
|
||||
if (ledger.incomplete) return unknown("store-incomplete", count, at);
|
||||
if (!ledger.last.known) return unknown(ledger.last.reason, count, at);
|
||||
return { count, known: true, reason: null, at };
|
||||
}
|
||||
|
||||
// One collection for a session's store ({ dir, session } from sessionStore),
|
||||
// at run start (resume) and run end, under the session's workspace lock.
|
||||
// Appends the new compactions, the moved cursors and one collected line in a
|
||||
// single O_APPEND write, then answers the count. A key already in the ledger
|
||||
// is never written again, and nothing rewrites the file.
|
||||
export function collectCompactions(store, harness, { now = () => new Date().toISOString(), budget = READ_BYTES, streams = STREAMS } = {}) {
|
||||
const prior = readLedger(store.dir);
|
||||
const ledger = prior ?? { events: new Map(), cursors: {}, torn: false };
|
||||
const at = now();
|
||||
const out = [];
|
||||
if (harness === "pi" && !prior && claudeHistory(store)) {
|
||||
// Claude runs before this ledger: their compactions can't be counted
|
||||
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
|
||||
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: PI_VERSION });
|
||||
} else if (harness === "pi") {
|
||||
const scan = scanPi(store.session, ledger.cursors, budget, streams);
|
||||
for (const f of scan.found) {
|
||||
const key = keyOf(f.entry, f.completedAt);
|
||||
if (ledger.events.has(key)) continue;
|
||||
ledger.events.set(key, f);
|
||||
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
|
||||
}
|
||||
for (const [stream, c] of Object.entries(scan.cursors)) {
|
||||
const old = ledger.cursors[stream];
|
||||
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
|
||||
}
|
||||
// store-incomplete outranks store-unreadable: lost entries don't come back.
|
||||
// not-collected is last: the next collection reads on.
|
||||
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
|
||||
out.push({ v: 1, kind: "collected", at, known: reason === null, reason, harness, version: PI_VERSION });
|
||||
} else {
|
||||
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: null });
|
||||
}
|
||||
const fd = openSync(join(store.dir, LEDGER), fsc.O_WRONLY | fsc.O_APPEND | fsc.O_CREAT | fsc.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
if (!fstatSync(fd).isFile()) throw Object.assign(new Error(`${join(store.dir, LEDGER)} isn't a regular file`), { code: "ledger-unsafe" });
|
||||
// After a torn line, start on a fresh one: the torn line stays bad and
|
||||
// keeps the count unknown, and the new lines stay whole.
|
||||
const buf = Buffer.from((ledger.torn ? "\n" : "") + out.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
||||
let off = 0;
|
||||
while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
return compactionCount(store.dir);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Row 270 round 2 (264c, #1775): the read budget (B1, duckworth-0223, sage-3031,
|
||||
// mos-1352) and Claude history from before the ledger (B3). Round 3: the cap on
|
||||
// streams one collection opens (B4, duckworth-0226). The streams are Pi
|
||||
// 0.85.1's shapes, written by hand as in compactions.test.mjs. A stream over
|
||||
// the budget is a sparse file: truncate() makes the hole, so no test writes
|
||||
// the bytes it reads past.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { appendFileSync, chmodSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { collectCompactions, LEDGER, READ_BYTES, readBudget, readStreams, STREAMS } from "../src/compactions.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
|
||||
const MiB = 1 << 20;
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), "seat-compactions-budget-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = sessionStore(root, "acme", "th1", "s1");
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
const collect = (harness = "pi", budget = undefined, streams = undefined) =>
|
||||
collectCompactions(store, harness, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
|
||||
const records = () => readFileSync(join(store.dir, LEDGER), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
return { root, store, collect, records };
|
||||
}
|
||||
|
||||
const header = (id) => ({ type: "session", version: 3, id, timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" });
|
||||
const compaction = (id, timestamp) => ({ type: "compaction", id, parentId: null, timestamp, summary: "s", firstKeptEntryId: "k", tokensBefore: 100 });
|
||||
const line = (e) => `${JSON.stringify(e)}\n`;
|
||||
const at = (n) => `2026-10-22T12:00:0${n}.000Z`;
|
||||
|
||||
test("the budget is the setting's whole number of bytes from 1 MiB to 1 GiB, and the default for anything else", () => {
|
||||
assert.equal(READ_BYTES, 64 * MiB);
|
||||
for (const v of [MiB, 8 * MiB, 1024 * MiB]) assert.equal(readBudget(v), v);
|
||||
for (const v of [undefined, null, "8388608", MiB + 0.5, -1, 0, MiB - 1, 1024 * MiB + 1, NaN, Infinity, {}, [MiB], true]) {
|
||||
assert.equal(readBudget(v), READ_BYTES, JSON.stringify(v) ?? String(v));
|
||||
}
|
||||
});
|
||||
|
||||
test("a stream past the default budget is not-collected with a lower bound, and a later collection catches up", (t) => {
|
||||
const f = setup(t);
|
||||
const path = join(f.store.session, "a.jsonl");
|
||||
const whole = line(header("a")) + line(compaction("c1", "2026-10-22T11:01:00.000Z"));
|
||||
writeFileSync(path, whole);
|
||||
// A hole past the default budget, with no newline in it: a line still being written.
|
||||
truncateSync(path, READ_BYTES + 2 * MiB);
|
||||
assert.deepEqual(f.collect(), { count: 1, known: false, reason: "not-collected", at: at(0) });
|
||||
// The cursor stops at the last whole line read.
|
||||
assert.deepEqual(f.records().filter((r) => r.kind === "cursor").map((r) => r.bytes), [Buffer.byteLength(whole)]);
|
||||
// The long line ends up as c2: the bytes past the cursor are read again, from the cursor.
|
||||
truncateSync(path, Buffer.byteLength(whole));
|
||||
appendFileSync(path, line(compaction("c2", "2026-10-22T11:02:00.000Z")));
|
||||
assert.deepEqual(f.collect(), { count: 2, known: true, reason: null, at: at(1) });
|
||||
});
|
||||
|
||||
test("a budget smaller than the streams counts over several collections, once each, and is known when it catches up", (t) => {
|
||||
const f = setup(t);
|
||||
const h = Buffer.byteLength(line(header("a"))), c = Buffer.byteLength(line(compaction("c1", "2026-10-22T11:01:00.000Z")));
|
||||
writeFileSync(join(f.store.session, "a.jsonl"), line(header("a")) + line(compaction("c1", "2026-10-22T11:01:00.000Z")) + line(compaction("c2", "2026-10-22T11:02:00.000Z")));
|
||||
writeFileSync(join(f.store.session, "b.jsonl"), line(header("b")) + line(compaction("c3", "2026-10-22T11:03:00.000Z")));
|
||||
// One byte past a's first compaction: a stops there and b isn't reached; then all of c2 and b's
|
||||
// header; then c3.
|
||||
const budget = h + c + 1;
|
||||
assert.deepEqual(f.collect("pi", budget), { count: 1, known: false, reason: "not-collected", at: at(0) });
|
||||
assert.deepEqual(f.collect("pi", budget), { count: 2, known: false, reason: "not-collected", at: at(1) });
|
||||
assert.deepEqual(f.collect("pi", budget), { count: 3, known: true, reason: null, at: at(2) });
|
||||
assert.deepEqual(f.records().filter((r) => r.kind === "compaction").map((r) => [r.entry, r.stream]), [["c1", "a.jsonl"], ["c2", "a.jsonl"], ["c3", "b.jsonl"]]);
|
||||
});
|
||||
|
||||
test("a line split across reads counts once", (t) => {
|
||||
const f = setup(t);
|
||||
// A message that ends just short of the first read's end, so c1 crosses it.
|
||||
const first = line(header("a"));
|
||||
const pad = { type: "message", id: "m1", parentId: null, timestamp: "2026-10-22T11:00:01.000Z", message: { role: "user", content: "" } };
|
||||
pad.message.content = "x".repeat(MiB - 100 - Buffer.byteLength(first) - Buffer.byteLength(line(pad)));
|
||||
writeFileSync(join(f.store.session, "a.jsonl"), first + line(pad) + line(compaction("c1", "2026-10-22T11:01:00.000Z")));
|
||||
assert.equal(Buffer.byteLength(first + line(pad)), MiB - 100);
|
||||
assert.deepEqual(f.collect(), { count: 1, known: true, reason: null, at: at(0) });
|
||||
});
|
||||
|
||||
// B3 (Vikunja 466): Claude Code runs before this ledger left history in claude-projects/ that
|
||||
// can't be counted. The first collection is harness-unsupported, and it stays so.
|
||||
test("Claude history from before the ledger makes the first Pi collection harness-unsupported, for good", (t) => {
|
||||
const f = setup(t);
|
||||
writeFileSync(join(f.store.projects, "old.jsonl"), "{}\n");
|
||||
writeFileSync(join(f.store.session, "a.jsonl"), line(header("a")) + line(compaction("c1", "2026-10-22T11:01:00.000Z")));
|
||||
assert.deepEqual(f.collect(), { count: null, known: false, reason: "harness-unsupported", at: at(0) });
|
||||
assert.deepEqual(f.collect(), { count: 1, known: false, reason: "harness-unsupported", at: at(1) });
|
||||
});
|
||||
|
||||
test("an empty claude-projects/ is no Claude history, and one filled after the ledger exists isn't checked", (t) => {
|
||||
const f = setup(t);
|
||||
writeFileSync(join(f.store.session, "a.jsonl"), line(header("a")) + line(compaction("c1", "2026-10-22T11:01:00.000Z")));
|
||||
assert.deepEqual(f.collect(), { count: 1, known: true, reason: null, at: at(0) });
|
||||
// A Claude run after the ledger exists collects for itself (compactions.test.mjs).
|
||||
writeFileSync(join(f.store.projects, "later.jsonl"), "{}\n");
|
||||
assert.deepEqual(f.collect(), { count: 1, known: true, reason: null, at: at(1) });
|
||||
});
|
||||
|
||||
test("a claude-projects/ that can't be read records nothing, and the next collection checks again", (t) => {
|
||||
const f = setup(t);
|
||||
writeFileSync(join(f.store.projects, "old.jsonl"), "{}\n");
|
||||
chmodSync(f.store.projects, 0o000);
|
||||
try {
|
||||
assert.throws(() => f.collect(), { code: "EACCES" });
|
||||
assert.throws(() => readFileSync(join(f.store.dir, LEDGER)), { code: "ENOENT" });
|
||||
} finally {
|
||||
chmodSync(f.store.projects, 0o700);
|
||||
}
|
||||
assert.deepEqual(f.collect(), { count: null, known: false, reason: "harness-unsupported", at: at(1) });
|
||||
});
|
||||
|
||||
test("the stream cap is the setting's whole number from 32 to 65536, and the default for anything else", () => {
|
||||
assert.equal(STREAMS, 1024);
|
||||
for (const v of [32, 2048, 65536]) assert.equal(readStreams(v), v);
|
||||
for (const v of [undefined, null, "2048", 40.5, -1, 0, 31, 65537, NaN, Infinity, {}, [64], true]) {
|
||||
assert.equal(readStreams(v), STREAMS, JSON.stringify(v) ?? String(v));
|
||||
}
|
||||
});
|
||||
|
||||
test("more streams than the cap is not-collected, with cursors only for the streams opened, and a larger cap catches up", (t) => {
|
||||
const f = setup(t);
|
||||
const names = ["a", "b", "c", "d"];
|
||||
names.forEach((n, i) => writeFileSync(join(f.store.session, `${n}.jsonl`), line(header(n)) + line(compaction(`c${n}`, `2026-10-22T11:0${i}:00.000Z`))));
|
||||
const cursors = () => f.records().filter((r) => r.kind === "cursor").map((r) => r.stream);
|
||||
assert.deepEqual(f.collect("pi", undefined, 3), { count: 3, known: false, reason: "not-collected", at: at(0) });
|
||||
assert.deepEqual(cursors(), ["a.jsonl", "b.jsonl", "c.jsonl"]);
|
||||
// The same cap again opens the same three, which haven't moved: no new lines but the collected one.
|
||||
assert.deepEqual(f.collect("pi", undefined, 3), { count: 3, known: false, reason: "not-collected", at: at(1) });
|
||||
assert.deepEqual(cursors(), ["a.jsonl", "b.jsonl", "c.jsonl"]);
|
||||
assert.deepEqual(f.collect("pi", undefined, 4), { count: 4, known: true, reason: null, at: at(2) });
|
||||
assert.deepEqual(cursors(), ["a.jsonl", "b.jsonl", "c.jsonl", "d.jsonl"]);
|
||||
});
|
||||
|
||||
test("the default cap opens STREAMS streams and writes no more cursor lines than that", (t) => {
|
||||
const f = setup(t);
|
||||
for (let i = 0; i <= STREAMS; i++) writeFileSync(join(f.store.session, `e${String(i).padStart(4, "0")}.jsonl`), "");
|
||||
assert.deepEqual(f.collect(), { count: null, known: false, reason: "not-collected", at: at(0) });
|
||||
const cursors = f.records().filter((r) => r.kind === "cursor");
|
||||
assert.equal(cursors.length, STREAMS);
|
||||
assert.equal(cursors.at(-1).stream, "e1023.jsonl");
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
// Row 270 (264c, #1775, T1 item 7 D6, T3's Pi half): the compaction count
|
||||
// from a session's native Pi store, through the host ledger
|
||||
// (src/compactions.mjs). The streams are Pi 0.85.1's shapes, written by hand:
|
||||
// a session header, then entries, one JSON object per line.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { appendFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative, isAbsolute } from "node:path";
|
||||
import { collectCompactions, compactionCount, LEDGER, readLedger, storeDir } from "../src/compactions.mjs";
|
||||
import { binds } from "../src/sandbox.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
|
||||
function setup(t, { legacy = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "seat-compactions-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = sessionStore(root, "acme", "th1", legacy ? "th1" : "s1", { legacy });
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
const collect = (harness = "pi") => collectCompactions(store, harness, { now });
|
||||
const ledger = () => readFileSync(join(store.dir, LEDGER), "utf8");
|
||||
const records = () => ledger().trim().split("\n").map((l) => JSON.parse(l));
|
||||
return { root, store, collect, ledger, records };
|
||||
}
|
||||
|
||||
const header = (id) => ({ type: "session", version: 3, id, timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" });
|
||||
const message = (id, role = "user") => ({ type: "message", id, parentId: null, timestamp: "2026-10-22T11:00:01.000Z", message: { role, content: "x" } });
|
||||
const compaction = (id, timestamp) => ({ type: "compaction", id, parentId: null, timestamp, summary: "s", firstKeptEntryId: "k", tokensBefore: 100 });
|
||||
const line = (e) => `${JSON.stringify(e)}\n`;
|
||||
const stream = (store, name, entries) => writeFileSync(join(store.session, name), entries.map(line).join(""));
|
||||
const append = (store, name, ...entries) => appendFileSync(join(store.session, name), entries.map(line).join(""));
|
||||
|
||||
test("an empty store, a store with no compaction and a failed start count 0, known", (t) => {
|
||||
const f = setup(t);
|
||||
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:00.000Z" });
|
||||
// A started and aborted compaction writes no entry; a compactionSummary message, a
|
||||
// branch_summary and a message are not compactions.
|
||||
stream(f.store, "a.jsonl", [header("a"), message("m1"), message("m2", "compactionSummary"), { type: "branch_summary", id: "b1", parentId: null, timestamp: "2026-10-22T11:00:02.000Z", summary: "b" }]);
|
||||
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
|
||||
});
|
||||
|
||||
test("one and three completed compactions", (t) => {
|
||||
const f = setup(t);
|
||||
stream(f.store, "a.jsonl", [header("a"), message("m1"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
assert.equal(f.collect().count, 1);
|
||||
append(f.store, "a.jsonl", message("m2"), compaction("c2", "2026-10-22T11:02:00.000Z"), compaction("c3", "2026-10-22T11:03:00.000Z"));
|
||||
assert.deepEqual(f.collect(), { count: 3, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
|
||||
assert.equal(compactionCount(f.store.dir).count, 3);
|
||||
});
|
||||
|
||||
// sage-2814: Pi's createBranchedSession and forkFrom copy entries into a new stream with
|
||||
// their id and timestamp, so a copied compaction counts once.
|
||||
test("a compaction copied into a branched stream counts once, under the stream it was first seen in", (t) => {
|
||||
const f = setup(t);
|
||||
const c1 = compaction("c1", "2026-10-22T11:01:00.000Z");
|
||||
stream(f.store, "a.jsonl", [header("a"), message("m1"), c1]);
|
||||
assert.equal(f.collect().count, 1);
|
||||
stream(f.store, "b.jsonl", [header("b"), message("m1"), c1, message("m3")]);
|
||||
assert.deepEqual(f.collect(), { count: 1, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
|
||||
append(f.store, "b.jsonl", compaction("c2", "2026-10-22T11:05:00.000Z"));
|
||||
assert.equal(f.collect().count, 2);
|
||||
const lines = f.records().filter((r) => r.kind === "compaction");
|
||||
assert.deepEqual(lines.map((r) => [r.entry, r.stream]), [["c1", "a.jsonl"], ["c2", "b.jsonl"]]);
|
||||
});
|
||||
|
||||
test("one collection over two streams holding the same copy counts it once and writes it once", (t) => {
|
||||
const f = setup(t);
|
||||
const c1 = compaction("c1", "2026-10-22T11:01:00.000Z");
|
||||
stream(f.store, "a.jsonl", [header("a"), c1]);
|
||||
stream(f.store, "b.jsonl", [header("b"), c1]);
|
||||
assert.equal(f.collect().count, 1);
|
||||
assert.equal(f.records().filter((r) => r.kind === "compaction").length, 1);
|
||||
});
|
||||
|
||||
// Pi's ids are unique only within a file: the same id at another time is another compaction.
|
||||
test("the same entry id at another time in another stream is another compaction", (t) => {
|
||||
const f = setup(t);
|
||||
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
stream(f.store, "b.jsonl", [header("b"), compaction("c1", "2026-10-22T11:09:00.000Z")]);
|
||||
assert.equal(f.collect().count, 2);
|
||||
});
|
||||
|
||||
test("resume: each collection reads past its cursor and writes a key once", (t) => {
|
||||
const f = setup(t);
|
||||
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
f.collect();
|
||||
f.collect();
|
||||
append(f.store, "a.jsonl", compaction("c2", "2026-10-22T11:02:00.000Z"));
|
||||
assert.equal(f.collect().count, 2);
|
||||
const r = f.records();
|
||||
assert.deepEqual(r.filter((x) => x.kind === "compaction").map((x) => x.entry), ["c1", "c2"]);
|
||||
assert.deepEqual(r.filter((x) => x.kind === "cursor").map((x) => x.bytes), [statSync(join(f.store.session, "a.jsonl")).size - line(compaction("c2", "2026-10-22T11:02:00.000Z")).length, statSync(join(f.store.session, "a.jsonl")).size]);
|
||||
assert.equal(r.filter((x) => x.kind === "collected").length, 3);
|
||||
assert.equal(lstatSync(join(f.store.dir, LEDGER)).mode & 0o777, 0o600);
|
||||
});
|
||||
|
||||
test("a partial trailing line waits for its newline", (t) => {
|
||||
const f = setup(t);
|
||||
stream(f.store, "a.jsonl", [header("a")]);
|
||||
const whole = line(compaction("c1", "2026-10-22T11:01:00.000Z"));
|
||||
appendFileSync(join(f.store.session, "a.jsonl"), whole.slice(0, 30));
|
||||
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:00.000Z" });
|
||||
appendFileSync(join(f.store.session, "a.jsonl"), whole.slice(30));
|
||||
assert.deepEqual(f.collect(), { count: 1, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
|
||||
});
|
||||
|
||||
test("a missing ledger is not-collected and an unreadable one store-unreadable, never 0", (t) => {
|
||||
const f = setup(t);
|
||||
assert.deepEqual(compactionCount(f.store.dir), { count: null, known: false, reason: "not-collected", at: null });
|
||||
mkdirSync(join(f.store.dir, LEDGER));
|
||||
assert.deepEqual(compactionCount(f.store.dir), { count: null, known: false, reason: "store-unreadable", at: null });
|
||||
assert.throws(() => f.collect());
|
||||
});
|
||||
|
||||
test("a symlinked ledger is store-unreadable and is not written through", (t) => {
|
||||
const f = setup(t);
|
||||
const target = join(f.root, "elsewhere.jsonl");
|
||||
writeFileSync(target, "");
|
||||
symlinkSync(target, join(f.store.dir, LEDGER));
|
||||
assert.equal(compactionCount(f.store.dir).reason, "store-unreadable");
|
||||
assert.throws(() => f.collect());
|
||||
assert.equal(readFileSync(target, "utf8"), "");
|
||||
});
|
||||
|
||||
// sage-2806 condition 3: a torn last line is a bad line, not a skipped one.
|
||||
test("a torn last ledger line makes the count unknown, a lower bound, and stays so", (t) => {
|
||||
const f = setup(t);
|
||||
assert.equal(f.collect().count, 0);
|
||||
appendFileSync(join(f.store.dir, LEDGER), '{"v":1,"kind":"compac');
|
||||
assert.deepEqual(compactionCount(f.store.dir), { count: null, known: false, reason: "store-incomplete", at: "2026-10-22T12:00:00.000Z" });
|
||||
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
assert.deepEqual(f.collect(), { count: 1, known: false, reason: "store-incomplete", at: "2026-10-22T12:00:01.000Z" });
|
||||
// The next write started on a fresh line: every line after the torn one is whole.
|
||||
const lines = f.ledger().split("\n");
|
||||
assert.equal(lines.pop(), "");
|
||||
const bad = lines.filter((l) => {
|
||||
try {
|
||||
JSON.parse(l);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
assert.deepEqual(bad, ['{"v":1,"kind":"compac']);
|
||||
assert.equal(readLedger(f.store.dir).events.size, 1);
|
||||
});
|
||||
|
||||
test("a bad ledger line in the middle makes the count unknown", (t) => {
|
||||
const f = setup(t);
|
||||
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
f.collect();
|
||||
appendFileSync(join(f.store.dir, LEDGER), "not json\n");
|
||||
append(f.store, "a.jsonl", compaction("c2", "2026-10-22T11:02:00.000Z"));
|
||||
assert.deepEqual(f.collect(), { count: 2, known: false, reason: "store-incomplete", at: "2026-10-22T12:00:01.000Z" });
|
||||
});
|
||||
|
||||
test("a stream that shrank, changed under its cursor or went away is store-incomplete for good", async (t) => {
|
||||
for (const change of ["shrink", "rewrite", "remove"]) {
|
||||
await t.test(change, (t) => {
|
||||
const f = setup(t);
|
||||
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
assert.equal(f.collect().known, true);
|
||||
const path = join(f.store.session, "a.jsonl");
|
||||
if (change === "shrink") stream(f.store, "a.jsonl", [header("a")]);
|
||||
// Same length, other bytes: only the cursor's tail digest sees it.
|
||||
if (change === "rewrite") stream(f.store, "a.jsonl", [header("a"), compaction("c9", "2026-10-22T11:01:00.000Z")]);
|
||||
if (change === "remove") unlinkSync(path);
|
||||
const c = f.collect();
|
||||
assert.equal(c.known, false);
|
||||
assert.equal(c.reason, "store-incomplete");
|
||||
assert.equal(c.count, change === "rewrite" ? 2 : 1);
|
||||
// A later clean collection doesn't make it known again.
|
||||
assert.equal(f.collect().reason, "store-incomplete");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a stream that isn't a regular file or a Pi session is store-unreadable, and that passes", (t) => {
|
||||
const f = setup(t);
|
||||
const target = join(f.root, "outside.jsonl");
|
||||
writeFileSync(target, line(header("x")) + line(compaction("c1", "2026-10-22T11:01:00.000Z")));
|
||||
symlinkSync(target, join(f.store.session, "link.jsonl"));
|
||||
assert.deepEqual(f.collect(), { count: null, known: false, reason: "store-unreadable", at: "2026-10-22T12:00:00.000Z" });
|
||||
unlinkSync(join(f.store.session, "link.jsonl"));
|
||||
assert.deepEqual(f.collect(), { count: 0, known: true, reason: null, at: "2026-10-22T12:00:01.000Z" });
|
||||
});
|
||||
|
||||
test("a first line that isn't a session header is store-unreadable", (t) => {
|
||||
const f = setup(t);
|
||||
stream(f.store, "a.jsonl", [compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
assert.equal(f.collect().reason, "store-unreadable");
|
||||
});
|
||||
|
||||
// RULINGS Q6: Claude Code is unknown with the Vikunja 466 reason, never 0, and a session any
|
||||
// Claude run collected for stays unknown.
|
||||
test("a Claude run is harness-unsupported, and stays so after a Pi run", (t) => {
|
||||
const f = setup(t);
|
||||
assert.deepEqual(f.collect("claude"), { count: null, known: false, reason: "harness-unsupported", at: "2026-10-22T12:00:00.000Z" });
|
||||
stream(f.store, "a.jsonl", [header("a"), compaction("c1", "2026-10-22T11:01:00.000Z")]);
|
||||
assert.deepEqual(f.collect("pi"), { count: 1, known: false, reason: "harness-unsupported", at: "2026-10-22T12:00:01.000Z" });
|
||||
});
|
||||
|
||||
test("storeDir is the store sessionStore makes, without making it", (t) => {
|
||||
const f = setup(t);
|
||||
assert.equal(storeDir(f.root, "acme", "th1", "s1"), f.store.dir);
|
||||
assert.equal(storeDir(f.root, "acme", "th1", "th1", { legacy: true }), join(f.root, "threads", "acme", "th1"));
|
||||
assert.equal(storeDir(f.root, "acme", "th1", "s2"), join(f.store.dir, "..", "s2"));
|
||||
assert.throws(() => lstatSync(join(f.store.dir, "..", "s2")), { code: "ENOENT" });
|
||||
for (const bad of ["..", "a/b", "", "A", null]) assert.equal(storeDir(f.root, "acme", "th1", bad), null);
|
||||
assert.equal(storeDir(f.root, "../x", "th1", "s1"), null);
|
||||
});
|
||||
|
||||
// sage-2806 condition 4: the agent can't reach the ledger. It sits in the store dir, which no
|
||||
// bind mounts; only the store's session/ and claude-projects/ are.
|
||||
test("the ledger is under no sandbox mount", (t) => {
|
||||
for (const legacy of [false, true]) {
|
||||
const f = setup(t, { legacy });
|
||||
f.collect();
|
||||
const file = join(f.store.dir, LEDGER);
|
||||
const runDir = join(f.root, "runs", "r1");
|
||||
const list = binds({ runDir, workspace: join(f.root, "ws"), adapter: join(f.root, "a.sh"), brokerSocket: join(f.root, "bus.sock"), threadStore: f.store });
|
||||
assert.ok(list.some((b) => b.src === f.store.session));
|
||||
for (const b of list) {
|
||||
if (!b.src || !isAbsolute(b.src)) continue;
|
||||
const rel = relative(b.src, file);
|
||||
assert.ok(rel.startsWith("..") || isAbsolute(rel), `${b.src} mounts ${file}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -237,13 +237,71 @@ async function validateConsole(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// FED R2 (#1701, DESIGN.md section 3.1 D1): the optional `federation` key names this instance and its
|
||||
// peers. Ids and origins are labels, not trust: nothing here grants a peer anything (3.1, W1). The
|
||||
// origin must be exactly an https origin, the form `new URL(origin).origin` returns, so a path, a
|
||||
// trailing slash, a port that is the default, or userinfo refuses.
|
||||
const FEDERATION_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const FEDERATION_LABEL_MAX = 80;
|
||||
|
||||
function validateFederationOrigin(value, where) {
|
||||
let url = null;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
if (url === null || url.protocol !== "https:" || url.origin !== value) {
|
||||
fail(2, `${where} must be an https origin with no path, query, or userinfo (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFederation(value) {
|
||||
if (!isPlainObject(value)) {
|
||||
fail(2, '"federation" must be a JSON object');
|
||||
}
|
||||
rejectUnknownKeys(value, ["self", "peers"], "federation");
|
||||
if (typeof value.self !== "string" || !FEDERATION_ID_PATTERN.test(value.self)) {
|
||||
fail(2, `federation.self must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(value.self)})`);
|
||||
}
|
||||
if (!Array.isArray(value.peers)) {
|
||||
fail(2, 'federation.peers must be an array');
|
||||
}
|
||||
const ids = new Set([value.self]);
|
||||
const peers = value.peers.map((peer, index) => {
|
||||
const where = `federation.peers[${index}]`;
|
||||
if (!isPlainObject(peer)) {
|
||||
fail(2, `${where} must be a JSON object`);
|
||||
}
|
||||
rejectUnknownKeys(peer, ["id", "origin", "label"], where);
|
||||
if (typeof peer.id !== "string" || !FEDERATION_ID_PATTERN.test(peer.id)) {
|
||||
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(peer.id)})`);
|
||||
}
|
||||
if (ids.has(peer.id)) {
|
||||
fail(2, `${where}.id ${JSON.stringify(peer.id)} is already named (by federation.self or an earlier peer)`);
|
||||
}
|
||||
ids.add(peer.id);
|
||||
validateFederationOrigin(peer.origin, `${where}.origin`);
|
||||
if (typeof peer.label !== "string" || peer.label.trim() === "" || peer.label.length > FEDERATION_LABEL_MAX) {
|
||||
fail(2, `${where}.label must be a non-empty string of at most ${FEDERATION_LABEL_MAX} characters`);
|
||||
}
|
||||
return { id: peer.id, origin: peer.origin, label: peer.label };
|
||||
});
|
||||
return { self: value.self, peers };
|
||||
}
|
||||
|
||||
async function validate(document, file) {
|
||||
if (!isPlainObject(document)) {
|
||||
fail(2, "configuration must be a JSON object");
|
||||
}
|
||||
// Row 270 r2 and r3 (mos-1352, DECISIONS Q222, sage-3038): compactionReadBytes and
|
||||
// compactionStreams, optional and human-only, pass on as written; the launcher takes the
|
||||
// default for a missing or bad value (packages/seat/src/compactions.mjs readBudget and
|
||||
// readStreams). The first list stays as it was:
|
||||
// packages/cli/tests/stack-release-fixture.mjs edits it to make an older loader.
|
||||
rejectUnknownKeys(
|
||||
document,
|
||||
["configVersion", "environment", "dataRoot", "execution", "console"],
|
||||
["configVersion", "environment", "dataRoot", "execution", "console", "federation"].concat(["compactionReadBytes", "compactionStreams"]),
|
||||
"configuration",
|
||||
);
|
||||
|
||||
@@ -281,6 +339,9 @@ async function validate(document, file) {
|
||||
// it, so a config without it prints what it always printed.
|
||||
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
|
||||
|
||||
// FED R2: the same rule as `console`. The key appears in the output only when the file has it.
|
||||
const federationKey = Object.hasOwn(document, "federation") ? validateFederation(document.federation) : undefined;
|
||||
|
||||
return {
|
||||
configVersion: document.configVersion,
|
||||
environment: document.environment,
|
||||
@@ -292,6 +353,9 @@ async function validate(document, file) {
|
||||
adapter,
|
||||
},
|
||||
...(consoleKey === undefined ? {} : { console: consoleKey }),
|
||||
...(federationKey === undefined ? {} : { federation: federationKey }),
|
||||
...(Object.hasOwn(document, "compactionReadBytes") ? { compactionReadBytes: document.compactionReadBytes } : {}),
|
||||
...(Object.hasOwn(document, "compactionStreams") ? { compactionStreams: document.compactionStreams } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ plugin_checks() {
|
||||
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
|
||||
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
|
||||
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records"
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels"
|
||||
|
||||
STORE_JS='
|
||||
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
||||
@@ -1078,12 +1078,12 @@ slot_problem() {
|
||||
# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept.
|
||||
config_checks() {
|
||||
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first
|
||||
# 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key
|
||||
# 0: a console or federation key (row 203: a pre-203 loader refuses federation too); 1: neither, or no file; 2: the file doesn't parse, so whether it holds the key
|
||||
# is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on
|
||||
# such a file before anything is read or stopped, so this is reached only if the file changes after it.
|
||||
local key=0
|
||||
node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)}
|
||||
process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' -- "$cfg" 2>/dev/null || key=$?
|
||||
process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.hasOwn(d,"federation"))?0:1)' -- "$cfg" 2>/dev/null || key=$?
|
||||
[ "$key" != 1 ] || return 0
|
||||
if [ "$key" != 0 ]; then
|
||||
check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again"
|
||||
@@ -1093,7 +1093,7 @@ process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' --
|
||||
elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0
|
||||
fi
|
||||
first="${out%%$'\n'*}"
|
||||
check_refuse config-console-key "$cfg holds a console key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
|
||||
check_refuse config-console-key "$cfg holds a console or federation key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
|
||||
}
|
||||
|
||||
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
|
||||
|
||||
@@ -191,12 +191,95 @@ expect_refusal "console.sso slot name outside the pattern exits 2 naming the pat
|
||||
expect_refusal "unknown key under console exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
|
||||
|
||||
# --- FED R2 (#1701, DESIGN.md section 3.1): the optional federation key ---
|
||||
# fed_body FEDERATION-JSON: a config whose federation key is the argument.
|
||||
fed_body() {
|
||||
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"federation":%s}' "$DATA_ROOT" "$1"
|
||||
}
|
||||
FED_VALID='{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"Software factory"}]}'
|
||||
|
||||
cfg fed-valid.json "$(fed_body "$FED_VALID")"
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/fed-valid.json" timeout 20 $CONFIG_OP validate)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"self": "hub"' && printf '%s' "$OUT" | grep -q '"origin": "https://mosaic.woltje.com"'
|
||||
check "valid federation key loads and is in the output" $?
|
||||
|
||||
# expect_fed_refusal NAME PATH-IN-MESSAGE FEDERATION-JSON: exit 2, and the message names the bad key path.
|
||||
expect_fed_refusal() {
|
||||
cfg fed-bad.json "$(fed_body "$3")"
|
||||
local err rc
|
||||
err="$(MOSAIC_CONFIG="$SANDBOX/fed-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$2"
|
||||
check "$1 (exit $rc)" $?
|
||||
}
|
||||
expect_fed_refusal "federation unknown sub-key exits 2 naming the key" 'unsupported federation key: "extra"' \
|
||||
'{"self":"hub","peers":[],"extra":1}'
|
||||
expect_fed_refusal "federation peer unknown sub-key exits 2 naming the key" 'unsupported federation.peers[0] key: "trust"' \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F","trust":"full"}]}'
|
||||
expect_fed_refusal "federation peer origin over http exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"http://mosaic.woltje.com","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer origin with a path exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/console","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer origin with userinfo exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://[email protected]","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer id equal to self exits 2 naming the key" "federation.peers[0].id" \
|
||||
'{"self":"hub","peers":[{"id":"hub","origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer label empty exits 2 naming the key" "federation.peers[0].label" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":""}]}'
|
||||
expect_fed_refusal "federation not an object exits 2 naming the key" '"federation" must be a JSON object' '"hub"'
|
||||
expect_fed_refusal "federation self pattern exits 2 naming the key" "federation.self must match" \
|
||||
'{"self":"Hub","peers":[]}'
|
||||
expect_fed_refusal "federation peers not an array exits 2 naming the key" "federation.peers must be an array" \
|
||||
'{"self":"hub","peers":{}}'
|
||||
expect_fed_refusal "federation peer not an object exits 2 naming the key" "federation.peers[0] must be a JSON object" \
|
||||
'{"self":"hub","peers":["factory"]}'
|
||||
expect_fed_refusal "federation peer id pattern exits 2 naming the key" "federation.peers[0].id must match" \
|
||||
'{"self":"hub","peers":[{"id":"Factory","origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
FED_LONG_LABEL="$(printf 'x%.0s' $(seq 81))"
|
||||
expect_fed_refusal "federation peer label over 80 characters exits 2 naming the key" "federation.peers[0].label" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"'"$FED_LONG_LABEL"'"}]}'
|
||||
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id "factory" is already named' \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F"},{"id":"factory","origin":"https://other.woltje.com","label":"G"}]}'
|
||||
expect_fed_refusal "federation peer origin with a trailing slash exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/","label":"F"}]}'
|
||||
# A missing value: the pattern test coerces undefined to "undefined", which matches, so the typeof half is what refuses.
|
||||
expect_fed_refusal "federation self missing exits 2 naming the key" "federation.self must match" \
|
||||
'{"peers":[]}'
|
||||
expect_fed_refusal "federation peer id missing exits 2 naming the key" "federation.peers[0].id must match" \
|
||||
'{"self":"hub","peers":[{"origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer label missing exits 2 naming the key" "federation.peers[0].label must be" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com"}]}'
|
||||
|
||||
# Without the key: no federation in the output, and the config still loads (FED R2 boundary).
|
||||
cfg no-federation.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
|
||||
NO_FED_OUT="$(MOSAIC_CONFIG="$SANDBOX/no-federation.json" timeout 20 $CONFIG_OP validate)"
|
||||
NO_FED_RC=$?
|
||||
[ "$NO_FED_RC" -eq 0 ] && ! printf '%s' "$NO_FED_OUT" | grep -q '"federation"'
|
||||
check "config without federation loads and has no federation output" $?
|
||||
|
||||
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
|
||||
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
|
||||
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
|
||||
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
|
||||
check "config without console loads with unchanged output" $?
|
||||
|
||||
# --- Row 270 r2 (mos-1352, DECISIONS Q222): the optional compactionReadBytes key ---
|
||||
# The loader passes it on as written, a bad value too: the launcher takes the default for a
|
||||
# missing or bad one (packages/seat/src/compactions.mjs readBudget), so it never refuses a config.
|
||||
for v in 8388608 '"x"'; do
|
||||
cfg budget.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"},"compactionReadBytes":'"$v"'}'
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/budget.json" timeout 20 $CONFIG_OP validate 2>&1)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -qxF " \"compactionReadBytes\": $v"
|
||||
check "compactionReadBytes $v loads and is in the output as written" $?
|
||||
done
|
||||
# Row 270 r3 (sage-3038): compactionStreams the same way (readStreams).
|
||||
for v in 2048 '"x"'; do
|
||||
cfg streams.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"},"compactionStreams":'"$v"'}'
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/streams.json" timeout 20 $CONFIG_OP validate 2>&1)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -qxF " \"compactionStreams\": $v"
|
||||
check "compactionStreams $v loads and is in the output as written" $?
|
||||
done
|
||||
|
||||
# --- validation must not modify the file ---
|
||||
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
|
||||
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
|
||||
|
||||
Reference in new issue
Block a user