Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e57bfde734 | ||
|
|
319d9f87ea | ||
|
|
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 |
No files matched your search
@@ -5366,3 +5366,17 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
|
||||
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)
|
||||
@@ -1,181 +0,0 @@
|
||||
# Row 219 / C2-09a: one thread per creative instance
|
||||
|
||||
Assignment sage-2793, base aca16404c; branch rocko/row219, own worktree
|
||||
/mnt/storage/scratch/rocko-r219. Claim waits for Sage's gap. No Node, tests,
|
||||
HTTPS git or push while Gander's row253 gate holds the slot.
|
||||
|
||||
Read both C2-09a and C2-09b briefs at aca16404c and 51cc0445c: the two
|
||||
sections are byte-identical. The newer brief changes C2-06's reply limits
|
||||
under Q222, not C2-09a. This row introduces no M5 limit, writer or setting.
|
||||
Row265 rooms chains on this row and stays separate.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
Broker: replace assistant-only #threaded with THREAD_DEFINITIONS membership.
|
||||
thread.create gains optional instance, validates it as a declared thread
|
||||
instance, and binds both the thread and default session to it. Omitted
|
||||
instance retains single-instance fallback and ambiguous/missing refusals.
|
||||
User grants must still cover the selected instance and project before
|
||||
project lookup; an explicit selector cannot bypass FED R3. Existing tester
|
||||
ownership and per-thread/per-session holder checks remain.
|
||||
|
||||
Launcher: import shared THREAD_DEFINITIONS and replace its two assistant
|
||||
checks (check and ending), preserving launch admission, quotas, session
|
||||
isolation and stop policy. No runtime.mjs, bundle or library adapter change;
|
||||
Della row224 was notified of these narrow launcher hunks.
|
||||
|
||||
Console: actual shipped new-thread UI is in public/chat.js, with transport
|
||||
mapping in chat.mjs and HTTP validation in chat-http.mjs, not public/bus.js.
|
||||
These three files need Sage's scope addition before their edits. Proposed
|
||||
metadata read GET /api/chat/instances in serve.mjs uses the existing
|
||||
bus.instances read, filters using shared THREAD_DEFINITIONS, and returns
|
||||
only {instance,name}, with vars['persona.name'] or instance as the label.
|
||||
It adds no channel operation or bus verb; send still traverses createChat
|
||||
and the existing channel dispatch. The new select sends optional instance
|
||||
through chat-http validation and chat.mjs. It is disabled when metadata
|
||||
cannot be read, so the UI never silently chooses between agents.
|
||||
|
||||
## Acceptance evidence to build (runs await CLEAR)
|
||||
|
||||
Bus: explicit creative selection binds the requested thread and default
|
||||
session; omitted selector preserves assistant compatibility, missing and
|
||||
ambiguous cases. Unknown/non-thread/malformed selectors refuse without
|
||||
new threads; user grants cover only the selected instance, with prior
|
||||
non-disclosure order intact. Real creative role definition messaging still
|
||||
holds. A creative thread claims/receives independently of its peer.
|
||||
|
||||
CLI: real fixture launcher starts a creative thread on pending input and
|
||||
keeps peer thread/session stores separate. Direct non-thread launch of a
|
||||
creative instance refuses thread-required. Existing assistant tests remain.
|
||||
|
||||
WebUI: metadata lists thread definitions only with persona/name labels;
|
||||
selection survives refresh and reaches thread.create exactly. Malformed
|
||||
instance fails before a write, old omitted instance still works. Browser
|
||||
asserts two creative options, inert hostile labels and read-only disablement.
|
||||
Keep all identity/origin/content/SSO fences. Named mutants cover each new
|
||||
acceptance guard; no runs until Sage's announced gap/CLEAR.
|
||||
|
||||
Before review: grep source readers/hash pins, update only pins required by
|
||||
owned changes, rebase onto row253 landing under a new branch, then rerun
|
||||
touched files on the new tree. No schema, role-policy, live or canonical
|
||||
product-path change. Handoff directly to Dewey, Mos and Sage when ready.
|
||||
|
||||
## Scope ruling and picker design before UI edits
|
||||
|
||||
Sage-2807 adds public/chat.js, chat.mjs and chat-http.mjs because the shipped
|
||||
form is chat.js:55/235, its forwarding is chat.mjs:23, and POST validation is
|
||||
chat-http.mjs:80-82. bus.js stays byte-identical. The new route sits after
|
||||
existing auth/session checks and uses startServer's own bus, so the tester
|
||||
socket's separately supplied testerBus is used too (Dewey U1). No new bus or
|
||||
channel operation. The route projects only instance/name and keeps the
|
||||
THREAD_DEFINITIONS filter server-side, rather than making the browser read
|
||||
all /api/bus/instances vars (Dewey route note).
|
||||
|
||||
Dewey-0849 U2-U5: multiple choices start with a required placeholder; exactly
|
||||
one starts chosen and is sent explicitly; none disables Create and says no
|
||||
thread instances are configured. Failed metadata reads disable Create with
|
||||
"Couldn't read the thread instances; reload to try again" and recover on a
|
||||
good read. Labels show persona name plus instance key when different. Picks
|
||||
live in form(key); a removed pick is cleared with a status and never moved
|
||||
to another option, even if only one remains. Repaints retain selection.
|
||||
U6 is deferred: thread-list/header still show their existing instance keys.
|
||||
The shared sign-out/redirect handling and strict HTTP write key validation
|
||||
remain in force. Add unauthenticated metadata and selected-instance user
|
||||
grant refusal tests as Sage requires.
|
||||
|
||||
## Existing fixture adaptations (unrun)
|
||||
|
||||
C2-08 creative.test's setup previously bound creative roles without threads
|
||||
and sent human input with message.send. This row makes those roles threaded:
|
||||
setup now selects assistant explicitly, creates each creative instance's
|
||||
thread and binds its claim there; the human uses thread.send. Existing
|
||||
M6/messaging refusal and count assertions stay in place. The user-principal
|
||||
fixture gains optional extra threadRoles for the selected-grant regression.
|
||||
|
||||
The browser fixture now answers /api/chat/instances with its one assistant.
|
||||
Its existing create receipt assertion gains instance:'assistant', since the
|
||||
new picker explicitly sends its single preselected choice. No existing
|
||||
assertion is dropped. New tests cover multiple/empty/failed metadata,
|
||||
duplicate hostile labels, selected-instance POST, refresh and disappearing
|
||||
choices. No Node or test has been run under Gander's hold.
|
||||
|
||||
The existing launcher.test C2-08 persona-mismatch regression cannot keep
|
||||
requesting a standalone creative launch: C2-09a now correctly refuses it
|
||||
thread-required before bundle construction. Its trigger is therefore the
|
||||
thread pass, with an injected host threadsState snapshot (the persona
|
||||
mismatch occurs before any bind). The exact mismatch reason, no persona
|
||||
content leak, one refusal and no running session assertions remain. The
|
||||
old per-run `['session']` directory assertion changes to `[]` and gains an
|
||||
assertion for the thread-owned session directory, matching row121's actual
|
||||
thread-store layout. A new e.thread assertion pins the path. This is a
|
||||
fixture/expected-layout adaptation, not a product exception for that test.
|
||||
|
||||
## Old-test approval: mos-1316
|
||||
|
||||
Mos approved the three adaptations in OLD-TEST-CHANGES.md (sha256
|
||||
9a1e51e6b43040593a3dd517c4a0c71f5ac42b6b57d66d474cd0517f06e97ed6)
|
||||
and old-persona-test.diff (sha256
|
||||
6aaca9820b692ec659058e9b044f7f60872cdecb16fe3ac1321c2c0ed530701d).
|
||||
The source commit must name mos-1316 and carry OLD-TEST-CHANGES.md.
|
||||
Dewey checks each changed line against that record.
|
||||
|
||||
Keep the new thread-launch test's fromSession(chief.cap, 'bean') refusal
|
||||
(thread-required), and the threads test's direct human message.send refusal
|
||||
for each creative definition (thread-required-use-thread-send). The former
|
||||
is sandbox-dependent: its gate record must show it ran without a skip;
|
||||
otherwise report to Mos rather than claiming the pin. T06 restores the old
|
||||
assistant-only launcher check; T22 restores the ending check separately.
|
||||
The persona mismatch fixture wraps only threadsState; all other host ops
|
||||
remain real. Under mos-1272, a first-run red in launcher.test.mjs,
|
||||
creative.test.mjs or chat-browser.test.mjs goes to Mos before any fix.
|
||||
No run has occurred as of this approval record.
|
||||
|
||||
### mos-1317: always-running standalone admission pin
|
||||
|
||||
Added a separate new test next to the assistant standalone refusal:
|
||||
`C2-09a: fromSession refuses a creative instance outside a thread without a sandbox`.
|
||||
It has no sandbox skip. The real fixture host binds/claims the chief, and
|
||||
fromSession must refuse Bean with thread-required before bundle/sandbox
|
||||
construction. The existing sandbox-dependent pin remains unchanged.
|
||||
T23 restores both launcher assistant-only comparisons and targets this new
|
||||
exact refusal assertion. T06 and T22 remain as separate launch/ending checks:
|
||||
23 mutants total. No tests run; slot scope amendment sent to Sage.
|
||||
Keep the Mos-approved threadsState wrap as the only persona-test host override.
|
||||
|
||||
## Inspection candidate under sage-3113 (2026-10-23)
|
||||
|
||||
Explicitly UNVERIFIED. Sage authorized a local inspection commit before
|
||||
runs; no claim of review readiness or green tests. Base remains aca16404c,
|
||||
branch rocko/row219. No rebase has been performed. No Node, browser, test,
|
||||
mutant or suite has run; the earlier gate holds and subsequent row219 hold
|
||||
were honored. Static Python exact-once preparation named 23 mutant sites;
|
||||
git diff --check passes. The mutant execution runner is not yet written.
|
||||
|
||||
14 source/test paths: six product files and eight whole test files. The
|
||||
candidate manifest excludes this packet. The new no-sandbox fromSession
|
||||
Bean test and T23 record mos-1317; old test adaptations stay governed by
|
||||
mos-1316 and OLD-TEST-CHANGES.md. First-run reds in launcher, creative and
|
||||
chat-browser must go to Mos before any fix. Run evidence must prove the
|
||||
sandbox-dependent pin ran, or report its skip to Mos.
|
||||
|
||||
Remaining: Sage-directed rebase to current landing base/new branch; slot
|
||||
CLEAR and claim; eight whole test files and 23 mutants with process-group
|
||||
cleanup; path/hash-reader sweep, acceptance evidence and final review packet.
|
||||
No product behavior is verified. Push waits for Sage's named gap; reviewers
|
||||
and dependent rows may inspect this commit but must not treat it as approved.
|
||||
|
||||
## Rebase under sage-3127 / exact resolution sage-3128
|
||||
|
||||
Rebased the unverified c4b4fab98 inspection candidate from aca16404c onto
|
||||
062646487451993302c768c6812b827c834e952b. The only conflict was broker.mjs's
|
||||
thread.create user guard: row206 added verb-sensitive #granted(s, verb,
|
||||
project, instance). Sage-3128 supplied the exact resolution: retain the
|
||||
selected-instance validation and call #granted(s, 'thread.create',
|
||||
identifier(a.project), instance). No guessed conflict resolution. The merged
|
||||
broker has no remaining three-argument #granted(s, ...) call (static balanced
|
||||
argument scan). T05 now matches that four-argument call and substitutes null
|
||||
only for instance; all 23 mutant old anchors, including T23's second edit,
|
||||
remain exact-once. Rebuild manifest/base/patch on this base. Other auto-merges
|
||||
were user-principal.test, launcher and thread-launch.test. No tests, Node,
|
||||
mutants or reader audit run: CLEAR still required after 574, covering every
|
||||
touched test file whole. This branch remains UNVERIFIED.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Old-test changes awaiting Mos under mos-1272 (sage-2822)
|
||||
|
||||
No test has run. Row219's product patch is uncommitted on rocko/row219,
|
||||
aca16404c, /mnt/storage/scratch/rocko-r219.
|
||||
|
||||
## Persona-mismatch regression: launcher.test.mjs
|
||||
|
||||
Name: `row 213 a creative instance's persona is read from <configDir>/personas:
|
||||
off its digest the launch fails with the mismatch as its reason`.
|
||||
|
||||
Old trigger: bind/claim a chief, then
|
||||
`assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });`.
|
||||
|
||||
New trigger: fixture-wrap only `host.op({op:'threadsState'})` to report one
|
||||
pending Bean thread, then `await launcher.tick()`. No fake grant, bind,
|
||||
writer or persona reader. The same actual persona bundle check fails before
|
||||
any thread run binds. All other host operations use the real fixture host.
|
||||
|
||||
Why: C2-08 declares companion and the shared THREAD_DEFINITIONS. Row219 now
|
||||
uses that list in check(). Bean's companion definition is therefore
|
||||
threaded, and fromSession's standalone request refuses thread-required
|
||||
before start()/buildBundle can read the persona. Keeping that trigger would
|
||||
stop testing the persona check. No product exemption is added.
|
||||
|
||||
Preserved assertions: exactly one refusal; event/code = refused/launch-failed;
|
||||
exact persona-mismatch reason including the fixture persona path; no
|
||||
PERSONA-MARKER-d2 in logs; no running sessions; exactly one failed run dir.
|
||||
The direct fromSession return assertion is replaced by a thread-id assertion
|
||||
on the same refusal record. Both prove the refusal at the appropriate entry.
|
||||
|
||||
Old layout assertion:
|
||||
`assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);`
|
||||
|
||||
New layout assertions:
|
||||
`assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);`
|
||||
`assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);`
|
||||
|
||||
This matches row121: a thread uses its thread-owned store, never a per-run
|
||||
session directory. The failed persona build still writes no bundle files.
|
||||
Exact diff: old-persona-test.diff.
|
||||
|
||||
## Other existing-test adaptations disclosed with it
|
||||
|
||||
- bus creative.test setup now explicitly chooses assistant, creates Bean and
|
||||
Quill threads, and binds those runs with their own thread ids. The existing
|
||||
human `message.send {to:who}` input becomes `thread.send {thread:threads[who]}`.
|
||||
All original M6 decision, messaging refusal and message-count assertions
|
||||
remain unchanged. This lets those same restrictions run on the now-threaded
|
||||
creative roles; direct human messages are correctly thread-required.
|
||||
- webui chat-browser.test fixture adds /api/chat/instances metadata. Its old
|
||||
expected create body `{project:P,name:'named thread'}` becomes
|
||||
`{project:P,name:'named thread',instance:'assistant'}` because the one-choice
|
||||
picker explicitly sends its preselected instance (Dewey U2). Every other
|
||||
existing assertion remains. The additional expected key strengthens the
|
||||
forwarding check and matches the requested UI behavior.
|
||||
|
||||
Please approve these adaptations before their results count. The builder
|
||||
continues static packet preparation only until Sage's test CLEAR.
|
||||
@@ -1 +0,0 @@
|
||||
062646487451993302c768c6812b827c834e952b
|
||||
@@ -1,777 +0,0 @@
|
||||
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
||||
index c50f8ac23..5ee242f1d 100644
|
||||
--- a/packages/bus/src/broker.mjs
|
||||
+++ b/packages/bus/src/broker.mjs
|
||||
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -312,7 +312,7 @@ const enrollBinding = (enroll) => ({
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
@@ -593,7 +593,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#threaded(business, role) {
|
||||
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
|
||||
}
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
@@ -5790,24 +5790,27 @@ export class Broker {
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
}
|
||||
- // A thread belongs to the user who made it and to the business's one thread instance.
|
||||
+ // A thread belongs to the user who made it and to one declared thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
- keys(a, ['project', 'name'], ['project', 'name']);
|
||||
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
+ const instance = a.instance === undefined
|
||||
+ ? (instances.length === 1 ? instances[0] : null)
|
||||
+ : identifier(a.instance);
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
|
||||
+ fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
- // The bus never picks between instances; the business file declares exactly one.
|
||||
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (!instances.length) fail('thread-instance-missing');
|
||||
- if (instances.length > 1) fail('thread-instance-ambiguous');
|
||||
+ // The human selects an instance; omission is compatible only with one candidate.
|
||||
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -5816,13 +5819,13 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.project,
|
||||
- instances[0],
|
||||
+ instance,
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
- return { id: tid, instance: instances[0] };
|
||||
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
|
||||
+ return { id: tid, instance };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
|
||||
index 6cfbf1cd6..6d421ba19 100644
|
||||
--- a/packages/bus/tests/creative.test.mjs
|
||||
+++ b/packages/bus/tests/creative.test.mjs
|
||||
@@ -44,15 +44,18 @@ function setup(t) {
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
||||
+ const threads = { assistant: thread };
|
||||
for (const role of Object.keys(roles)) {
|
||||
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
+ if (['bean', 'quill'].includes(role))
|
||||
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
||||
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
- return { call, count, store };
|
||||
+ return { call, count, store, threads };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
- const { call, count } = setup(t);
|
||||
+ const { call, count, threads } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
- call('human', 'message.send', { to: who, body: 'hello' });
|
||||
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
||||
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
|
||||
index 8b31da9f9..1410ec3fa 100644
|
||||
--- a/packages/bus/tests/threads.test.mjs
|
||||
+++ b/packages/bus/tests/threads.test.mjs
|
||||
@@ -601,3 +601,42 @@ test('project.create keeps unrelated names with and without a repository', t =>
|
||||
{ id: linked.id, name: 'work', repo: 'stack' },
|
||||
]);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
|
||||
+ } }));
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
|
||||
+ for (const [instance, role] of Object.entries(extra)) {
|
||||
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
|
||||
+ assert.equal(receipt.instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
|
||||
+ ctx.launch(instance, instance, { thread: receipt.id });
|
||||
+ assert.equal(ctx.claim(instance).thread, receipt.id);
|
||||
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
|
||||
+ const read = ctx.call(instance, 'message.receive');
|
||||
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
|
||||
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
|
||||
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
|
||||
+ { code: 'thread-required-use-thread-send' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
|
||||
+ const ctx = setup(t);
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ for (const instance of ['not-declared', 'pm', 'coder'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
|
||||
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
|
||||
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
|
||||
+ assert.equal(legacy.instance, 'assistant');
|
||||
+});
|
||||
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
|
||||
index 4dcd1ebeb..8471ddf85 100644
|
||||
--- a/packages/bus/tests/user-principal.test.mjs
|
||||
+++ b/packages/bus/tests/user-principal.test.mjs
|
||||
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
|
||||
});
|
||||
|
||||
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
||||
-function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ ...threadRoles,
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
|
||||
test('R3 r4: a non-function access throws before the store is read', () => {
|
||||
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
|
||||
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project] })];
|
||||
+ ctx.user('alice');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
|
||||
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
|
||||
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
|
||||
+ assert.equal(allowed.instance, 'assistant');
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
|
||||
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
|
||||
+ assert.equal(bean.instance, 'bean');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
||||
+});
|
||||
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
||||
index e044b7627..f019df83f 100644
|
||||
--- a/packages/cli/src/launcher.mjs
|
||||
+++ b/packages/cli/src/launcher.mjs
|
||||
@@ -53,7 +53,7 @@
|
||||
// session.json names it as gitSocket.
|
||||
//
|
||||
// Thread sessions (row 121, #1615). A thread instance (an instance whose
|
||||
-// role definition is the assistant) runs once per thread, never on its own:
|
||||
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
|
||||
// a launch from a session or --pm refuses thread-required. Every tick
|
||||
// (threadTickMs) the host asks the broker for the business's threads
|
||||
// (launch op threadsState) and:
|
||||
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
@@ -175,8 +176,6 @@ const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
const THREAD_TICK_MS = 2000;
|
||||
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
-const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
@@ -299,7 +298,7 @@ export function createLauncher({
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
|
||||
@@ -854,7 +853,7 @@ export function createLauncher({
|
||||
function ending(r) {
|
||||
const runs = r.runs ?? [];
|
||||
const at = Date.parse(r.at);
|
||||
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
|
||||
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
|
||||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
|
||||
}
|
||||
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
||||
index 2202c7753..0a34fe27a 100644
|
||||
--- a/packages/cli/tests/launcher.test.mjs
|
||||
+++ b/packages/cli/tests/launcher.test.mjs
|
||||
@@ -773,21 +773,28 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
- const { f, host, launcher } = await setup(t, undefined, given);
|
||||
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
+ const thread = 'persona-thread';
|
||||
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
+ ...host,
|
||||
+ op: message => message.op === 'threadsState'
|
||||
+ ? Promise.resolve([{ thread, instance: 'bean', holder: null, pending: 1 }])
|
||||
+ : host.op(message),
|
||||
+ }) });
|
||||
+ // The bundle's refusal remains launch-failed with the persona mismatch as its reason.
|
||||
+ await launcher.tick();
|
||||
const [e] = log(f);
|
||||
assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
+ assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
||||
index 0d207a0a0..84f8ed088 100644
|
||||
--- a/packages/cli/tests/thread-launch.test.mjs
|
||||
+++ b/packages/cli/tests/thread-launch.test.mjs
|
||||
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
|
||||
});
|
||||
|
||||
+// Admission refuses before bundle construction or a sandbox is needed.
|
||||
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
|
||||
+ const prepared = prepare(t, { edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ doc.launch.instances.push('bean');
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const { f } = prepared;
|
||||
+ const host = await start(t, prepared);
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+ assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
|
||||
+});
|
||||
+
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
|
||||
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
|
||||
// bus's plug-in loader.
|
||||
@@ -443,3 +459,71 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
|
||||
assert.deepEqual(threadRuns(f, A), []);
|
||||
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ const vars = { ...doc.roles.assistant.vars };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
|
||||
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
|
||||
+ doc.launch.instances = ['coder', 'bean', 'chet'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const host = await start(t, prepared), { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
|
||||
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
|
||||
+ const send = async (thread, instance, body) => {
|
||||
+ const message = host.call('thread.send', { thread, body });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ const reply = outcome;
|
||||
+ assert.equal(reply.from_role, instance);
|
||||
+ assert.equal(reply.to_role, 'human');
|
||||
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
|
||||
+ };
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
|
||||
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
+ const launches = launchLog(f).filter(row => row.event === 'launch');
|
||||
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
|
||||
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.launch.instances = ['coder', 'bean'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ let requests = [];
|
||||
+ const host = await start(t, prepared, { ops: () => ({
|
||||
+ lifecycleRequests: () => requests,
|
||||
+ lifecycleTake: () => ({ ...requests[0], go: true }),
|
||||
+ lifecycleDone: () => { requests = []; return {}; },
|
||||
+ }) });
|
||||
+ const { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
|
||||
+ for (const thread of threads) {
|
||||
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ }
|
||||
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
|
||||
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
|
||||
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
|
||||
+ await host.launcher.tick();
|
||||
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
|
||||
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
|
||||
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
+});
|
||||
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
|
||||
index c7770726d..396b42f3c 100644
|
||||
--- a/packages/webui/src/chat-http.mjs
|
||||
+++ b/packages/webui/src/chat-http.mjs
|
||||
@@ -78,7 +78,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
|
||||
action = 'createProject'; args = v;
|
||||
} else if (path === '/api/chat/threads') {
|
||||
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
|
||||
+ if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
|
||||
+ (Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
|
||||
action = 'createThread'; args = v;
|
||||
} else if (match?.[2] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
|
||||
index 018574ad4..65b89dab3 100644
|
||||
--- a/packages/webui/src/chat.mjs
|
||||
+++ b/packages/webui/src/chat.mjs
|
||||
@@ -20,7 +20,7 @@ export function createChat({ call }) {
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
|
||||
+ createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
|
||||
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
|
||||
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
|
||||
open: ({ thread }) => channel('thread.open', { thread }),
|
||||
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
|
||||
index a6b805c1b..15066bdc6 100644
|
||||
--- a/packages/webui/src/public/chat.js
|
||||
+++ b/packages/webui/src/public/chat.js
|
||||
@@ -14,7 +14,7 @@
|
||||
return states.get(id);
|
||||
};
|
||||
const form = key => {
|
||||
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
|
||||
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
class ChatError extends Error {
|
||||
@@ -58,7 +58,11 @@
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
|
||||
+ const choices = current.instances ?? [];
|
||||
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
|
||||
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
|
||||
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
|
||||
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
|
||||
}
|
||||
function threads(rows) {
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
|
||||
@@ -106,7 +110,7 @@
|
||||
const f = document.getElementById(keep.field.id);
|
||||
if (f && !f.disabled) {
|
||||
f.focus({ preventScroll: true });
|
||||
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -126,7 +130,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
|
||||
+ current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
@@ -147,6 +151,25 @@
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
}
|
||||
}
|
||||
+ if (kind === 'project') {
|
||||
+ try {
|
||||
+ const listed = await request('instances', undefined, signal);
|
||||
+ if (g !== generation || out()) return;
|
||||
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
|
||||
+ throw new ChatError('invalid-response');
|
||||
+ current.instances = listed.rows; current.instanceError = '';
|
||||
+ const f = form(id);
|
||||
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
|
||||
+ f.instance = ''; f.instanceChosen = true;
|
||||
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
|
||||
+ }
|
||||
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
|
||||
+ } catch (err) {
|
||||
+ if (err.name === 'AbortError' || out() || g !== generation) return;
|
||||
+ current.instances = [];
|
||||
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
|
||||
+ }
|
||||
+ }
|
||||
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
|
||||
if (kind === 'list') {
|
||||
try {
|
||||
@@ -179,7 +202,11 @@
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
|
||||
+ if (f && event.target.dataset.chatField) {
|
||||
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
+ draft[field] = event.target.value;
|
||||
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
|
||||
+ }
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
@@ -232,7 +259,8 @@
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
|
||||
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
|
||||
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
||||
index 288871d0c..d9d27a062 100644
|
||||
--- a/packages/webui/src/serve.mjs
|
||||
+++ b/packages/webui/src/serve.mjs
|
||||
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
|
||||
@@ -705,6 +706,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
|
||||
+ if (path === '/api/chat/instances') {
|
||||
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
|
||||
+ if (search) return json(res, 400, { error: 'invalid-request' });
|
||||
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
|
||||
+ try {
|
||||
+ const value = await bus.instances();
|
||||
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
|
||||
+ throw new BusReadError('invalid-response');
|
||||
+ const rows = Object.entries(value.instances)
|
||||
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
|
||||
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
|
||||
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
|
||||
+ return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
+ } catch (err) {
|
||||
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
+ return json(res, busStatus(code), { error: code });
|
||||
+ }
|
||||
+ }
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
|
||||
index f3eeeca96..cc3ec093d 100644
|
||||
--- a/packages/webui/tests/chat-browser.test.mjs
|
||||
+++ b/packages/webui/tests/chat-browser.test.mjs
|
||||
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
|
||||
+ window.__instances=[{instance:'assistant',name:'assistant'}];
|
||||
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
|
||||
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
@@ -33,6 +34,7 @@ const stub = `(() => {
|
||||
window.__reads.push(path);
|
||||
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
||||
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
|
||||
+ if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
|
||||
if(path==='/api/chat/projects')return data({rows:window.__projects});
|
||||
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
||||
await click('#chat-thread-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-send-form")');
|
||||
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
|
||||
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
||||
});
|
||||
+
|
||||
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
|
||||
+ const { b, wait, set, click, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
|
||||
+ assert.match(labels[1], /\(bean\)/);
|
||||
+ assert.match(labels[2], /\(chet\)/);
|
||||
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
|
||||
+ assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
+ await set('#chat-thread-name', 'Bean discussion');
|
||||
+ await click('#chat-thread-form button');
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
|
||||
+ await click('#chat-thread-form button');
|
||||
+ await wait('window.__writes.length===1');
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
|
||||
+ const { b, wait, set, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
|
||||
+ await refresh();
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
|
||||
+ await set('#chat-instance', 'chet');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
|
||||
+ const { b, wait } = await setup(t, '?readonly');
|
||||
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
|
||||
new file mode 100644
|
||||
index 000000000..893c38f80
|
||||
--- /dev/null
|
||||
+++ b/packages/webui/tests/chat-instances.test.mjs
|
||||
@@ -0,0 +1,87 @@
|
||||
+import test from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { request } from 'node:http';
|
||||
+import { mkdtempSync, rmSync } from 'node:fs';
|
||||
+import { join } from 'node:path';
|
||||
+import { tmpdir } from 'node:os';
|
||||
+import { startServer } from '../src/serve.mjs';
|
||||
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
+import { close } from './fixture.mjs';
|
||||
+
|
||||
+const path = '/api/chat/instances';
|
||||
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
|
||||
+const snapshot = {
|
||||
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
|
||||
+ instances: {
|
||||
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
|
||||
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
|
||||
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
|
||||
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
|
||||
+ },
|
||||
+};
|
||||
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
|
||||
+ const address = server.address(), unix = typeof address === 'string';
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
|
||||
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
|
||||
+ let text = '';
|
||||
+ res.on('data', chunk => text += chunk);
|
||||
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
|
||||
+ });
|
||||
+ req.on('error', reject); req.end();
|
||||
+ });
|
||||
+}
|
||||
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
|
||||
+ let calls = 0;
|
||||
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const refused = await get(server);
|
||||
+ assert.equal(refused.status, 401);
|
||||
+ assert.equal(calls, 0);
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
|
||||
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
|
||||
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
|
||||
+ assert.equal(calls, 1);
|
||||
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
|
||||
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok([400, 405].includes(result.status));
|
||||
+ }
|
||||
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
|
||||
+});
|
||||
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
|
||||
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
+ let reads = 0;
|
||||
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
|
||||
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const answer = await get(server);
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
|
||||
+ assert.equal(reads, 1);
|
||||
+});
|
||||
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
|
||||
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
|
||||
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok(answer.status >= 400);
|
||||
+ assert.doesNotMatch(answer.text, /private-path/);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
|
||||
+ let session = null, reads = 0;
|
||||
+ const touches = [];
|
||||
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
|
||||
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
|
||||
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const headers = { host: sso.publicHost };
|
||||
+ assert.equal((await get(server, { headers })).status, 401);
|
||||
+ assert.equal(reads, 0);
|
||||
+ session = { name: 'jason' };
|
||||
+ assert.equal((await get(server, { headers })).status, 200);
|
||||
+ assert.equal(reads, 1);
|
||||
+ assert.deepEqual(touches, [false, false]);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
|
||||
index 8c11611cf..b47ecf1a7 100644
|
||||
--- a/packages/webui/tests/chat.test.mjs
|
||||
+++ b/packages/webui/tests/chat.test.mjs
|
||||
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
|
||||
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
|
||||
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
|
||||
});
|
||||
+
|
||||
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ c.state.answer = { id: T, instance: 'bean' };
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
|
||||
+ assert.equal(answer.data.instance, 'bean');
|
||||
+});
|
||||
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
|
||||
+ assert.equal(answer.status, 400, JSON.stringify(value));
|
||||
+ }
|
||||
+ assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
|
||||
+ assert.deepEqual(c.calls, []);
|
||||
+});
|
||||
@@ -1,14 +0,0 @@
|
||||
38c4cb694cb756cfc771395afbeaa0779737bb94bc55bd1ea3a72a00d5448041 packages/bus/src/broker.mjs
|
||||
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
|
||||
de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/tests/threads.test.mjs
|
||||
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
|
||||
c905590032b6f8eb7d607d04ce6a585ed783a0c84033dd8436eab7922f2251e1 packages/cli/src/launcher.mjs
|
||||
fc301cde49e6db1286e90d6ac3410f5f4d87351bec61f1bae27c235a041848b6 packages/cli/tests/launcher.test.mjs
|
||||
a219036737419870e0aa9d0da20843759a66be5bafff44d3292593833f8fcb9a packages/cli/tests/thread-launch.test.mjs
|
||||
cbfd54e6b375561d1e4679ee8d78a3285f970d9a7198cec9d22a06510a36afba packages/webui/src/chat-http.mjs
|
||||
f49a49c6ec69783190d3f5eaf34316cb4d2c44d10d2ee9336fe1383207afefff packages/webui/src/chat.mjs
|
||||
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
|
||||
a597cc4902a873fe15650dacfce14dbc897db26310a7b6a24c293287add946c8 packages/webui/src/serve.mjs
|
||||
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
|
||||
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
|
||||
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
|
||||
@@ -1,14 +0,0 @@
|
||||
packages/bus/src/broker.mjs
|
||||
packages/bus/tests/creative.test.mjs
|
||||
packages/bus/tests/threads.test.mjs
|
||||
packages/bus/tests/user-principal.test.mjs
|
||||
packages/cli/src/launcher.mjs
|
||||
packages/cli/tests/launcher.test.mjs
|
||||
packages/cli/tests/thread-launch.test.mjs
|
||||
packages/webui/src/chat-http.mjs
|
||||
packages/webui/src/chat.mjs
|
||||
packages/webui/src/public/chat.js
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/chat-browser.test.mjs
|
||||
packages/webui/tests/chat-instances.test.mjs
|
||||
packages/webui/tests/chat.test.mjs
|
||||
@@ -1,193 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "T01",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);",
|
||||
"new": "return this.#business(business).roles[role]?.definition === 'assistant';",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: explicit creative"
|
||||
},
|
||||
{
|
||||
"id": "T02",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": ": identifier(a.instance);",
|
||||
"new": ": instances[0];",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: explicit creative"
|
||||
},
|
||||
{
|
||||
"id": "T03",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');",
|
||||
"new": "if (!instances.length) fail('thread-instance-missing');",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: explicit missing"
|
||||
},
|
||||
{
|
||||
"id": "T04",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');",
|
||||
"new": "if (false) fail('thread-instance-ambiguous');",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: explicit creative"
|
||||
},
|
||||
{
|
||||
"id": "T05",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "!this.#granted(s, 'thread.create', identifier(a.project), instance)",
|
||||
"new": "!this.#granted(s, 'thread.create', identifier(a.project), null)",
|
||||
"test": "packages/bus/tests/user-principal.test.mjs",
|
||||
"pattern": "C2-09a: naming an instance"
|
||||
},
|
||||
{
|
||||
"id": "T06",
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);",
|
||||
"new": "const threaded = b.roles[instance]?.definition === \"assistant\";",
|
||||
"test": "packages/cli/tests/thread-launch.test.mjs",
|
||||
"pattern": "C2-09a: creative thread messages"
|
||||
},
|
||||
{
|
||||
"id": "T07",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": ".filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))",
|
||||
"new": ".filter(() => true)",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T08",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "name: entry.vars?.['persona.name'] || instance",
|
||||
"new": "name: instance",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T09",
|
||||
"file": "packages/webui/src/chat-http.mjs",
|
||||
"old": "(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))",
|
||||
"new": "false",
|
||||
"test": "packages/webui/tests/chat.test.mjs",
|
||||
"pattern": "C2-09a malformed instance"
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"file": "packages/webui/src/chat.mjs",
|
||||
"old": "createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),",
|
||||
"new": "createThread: ({ project, name, instance }) => channel('thread.create', { project, name }),",
|
||||
"test": "packages/webui/tests/chat.test.mjs",
|
||||
"pattern": "C2-09a createThread forwards"
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": ": { project: key, name: f.name, instance: f.instance };",
|
||||
"new": ": { project: key, name: f.name };",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "!f.instance && listed.rows.length === 1",
|
||||
"new": "!f.instance && listed.rows.length >= 1",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "current.instanceError = \"Couldn't read the thread instances; reload to try again\";",
|
||||
"new": "current.instanceError = '';",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T14",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)",
|
||||
"new": "txt(row.name)",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T15",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {",
|
||||
"new": "if (false) {",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T16",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "f.instance = ''; f.instanceChosen = true;",
|
||||
"new": "f.instance = ''; f.instanceChosen = false;",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T17",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));",
|
||||
"new": "const unavailable = false;",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T18",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });",
|
||||
"new": "if (false) return json(res, 405, { error: 'method-not-allowed' });",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T19",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "if (search) return json(res, 400, { error: 'invalid-request' });",
|
||||
"new": "if (false) return json(res, 400, { error: 'invalid-request' });",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T20",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "current.instances = listed.rows; current.instanceError = '';",
|
||||
"new": "current.instances = listed.rows;",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T21",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "({ instance, name: entry.vars?.['persona.name'] || instance })",
|
||||
"new": "({ instance, name: entry.vars?.['persona.name'] || instance, vars: entry.vars })",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T22",
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);",
|
||||
"new": "const threaded = b.roles[r.instance]?.definition === \"assistant\";",
|
||||
"test": "packages/cli/tests/thread-launch.test.mjs",
|
||||
"pattern": "C2-09a: terminating one creative thread"
|
||||
},
|
||||
{
|
||||
"id": "T23",
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);",
|
||||
"new": "const threaded = b.roles[instance]?.definition === \"assistant\";",
|
||||
"test": "packages/cli/tests/thread-launch.test.mjs",
|
||||
"pattern": "C2-09a: fromSession refuses a creative instance outside a thread without a sandbox",
|
||||
"additional_edits": [
|
||||
{
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);",
|
||||
"new": "const threaded = b.roles[r.instance]?.definition === \"assistant\";"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,40 +0,0 @@
|
||||
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
||||
index 2202c7753..0a34fe27a 100644
|
||||
--- a/packages/cli/tests/launcher.test.mjs
|
||||
+++ b/packages/cli/tests/launcher.test.mjs
|
||||
@@ -773,21 +773,28 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
- const { f, host, launcher } = await setup(t, undefined, given);
|
||||
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
+ const thread = 'persona-thread';
|
||||
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
+ ...host,
|
||||
+ op: message => message.op === 'threadsState'
|
||||
+ ? Promise.resolve([{ thread, instance: 'bean', holder: null, pending: 1 }])
|
||||
+ : host.op(message),
|
||||
+ }) });
|
||||
+ // The bundle's refusal remains launch-failed with the persona mismatch as its reason.
|
||||
+ await launcher.tick();
|
||||
const [e] = log(f);
|
||||
assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
+ assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Static preparation only. Executing tests requires Sage CLEAR."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
root=Path(__file__).resolve().parents[4]
|
||||
p=Path(__file__).resolve().parent
|
||||
specs=[]
|
||||
def site(id,file,old,new,test,pattern):
|
||||
text=(root/file).read_text();assert text.count(old)==1,(id,text.count(old))
|
||||
specs.append(dict(id=id,file=file,old=old,new=new,test=test,pattern=pattern))
|
||||
bus='packages/bus/src/broker.mjs';bt='packages/bus/tests/threads.test.mjs';cli='packages/cli/src/launcher.mjs';ct='packages/cli/tests/thread-launch.test.mjs';web='packages/webui/src/serve.mjs';wt='packages/webui/tests/chat-instances.test.mjs';browser='packages/webui/src/public/chat.js';brtest='packages/webui/tests/chat-browser.test.mjs'
|
||||
site('T01',bus,'return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);',"return this.#business(business).roles[role]?.definition === 'assistant';",bt,'C2-09a: explicit creative')
|
||||
site('T02',bus,': identifier(a.instance);',': instances[0];',bt,'C2-09a: explicit creative')
|
||||
site('T03',bus,"if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');","if (!instances.length) fail('thread-instance-missing');",bt,'C2-09a: explicit missing')
|
||||
site('T04',bus,"if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');","if (false) fail('thread-instance-ambiguous');",bt,'C2-09a: explicit creative')
|
||||
site('T05',bus,"!this.#granted(s, 'thread.create', identifier(a.project), instance)","!this.#granted(s, 'thread.create', identifier(a.project), null)",'packages/bus/tests/user-principal.test.mjs','C2-09a: naming an instance')
|
||||
site('T06',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);','const threaded = b.roles[instance]?.definition === "assistant";',ct,'C2-09a: creative thread messages')
|
||||
site('T07',web,'.filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))','.filter(() => true)',wt,'C2-09a metadata uses the human')
|
||||
site('T08',web,"name: entry.vars?.['persona.name'] || instance",'name: instance',wt,'C2-09a metadata uses the human')
|
||||
site('T09','packages/webui/src/chat-http.mjs',"(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))",'false','packages/webui/tests/chat.test.mjs','C2-09a malformed instance')
|
||||
site('T10','packages/webui/src/chat.mjs',"createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),","createThread: ({ project, name, instance }) => channel('thread.create', { project, name }),",'packages/webui/tests/chat.test.mjs','C2-09a createThread forwards')
|
||||
site('T11',browser,": { project: key, name: f.name, instance: f.instance };",": { project: key, name: f.name };",brtest,'C2-09a picker: no silent choice')
|
||||
site('T12',browser,'!f.instance && listed.rows.length === 1','!f.instance && listed.rows.length >= 1',brtest,'C2-09a picker: no silent choice')
|
||||
site('T13',browser,'current.instanceError = "Couldn\'t read the thread instances; reload to try again";',"current.instanceError = '';",brtest,'C2-09a picker: metadata failure')
|
||||
site('T14',browser,'txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)','txt(row.name)',brtest,'C2-09a picker: no silent choice')
|
||||
site('T15',browser,'if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {','if (false) {',brtest,'C2-09a picker: metadata failure')
|
||||
site('T16',browser,"f.instance = ''; f.instanceChosen = true;","f.instance = ''; f.instanceChosen = false;",brtest,'C2-09a picker: metadata failure')
|
||||
site('T17',browser,"const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));",'const unavailable = false;',brtest,'C2-09a picker: no silent choice')
|
||||
# Route-local guards include their full unique response text to avoid touching other routes.
|
||||
site('T18',web,"if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });","if (false) return json(res, 405, { error: 'method-not-allowed' });",wt,'C2-09a metadata uses the human')
|
||||
site('T19',web,"if (search) return json(res, 400, { error: 'invalid-request' });","if (false) return json(res, 400, { error: 'invalid-request' });",wt,'C2-09a metadata uses the human')
|
||||
site('T20',browser,"current.instances = listed.rows; current.instanceError = '';","current.instances = listed.rows;",brtest,'C2-09a picker: metadata failure')
|
||||
site('T21',web,"({ instance, name: entry.vars?.['persona.name'] || instance })","({ instance, name: entry.vars?.['persona.name'] || instance, vars: entry.vars })",wt,'C2-09a metadata uses the human')
|
||||
site('T22',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);','const threaded = b.roles[r.instance]?.definition === "assistant";',ct,'C2-09a: terminating one creative thread')
|
||||
site('T23',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);','const threaded = b.roles[instance]?.definition === "assistant";',ct,'C2-09a: fromSession refuses a creative instance outside a thread without a sandbox')
|
||||
ending_old='const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);'
|
||||
assert (root/cli).read_text().count(ending_old)==1
|
||||
specs[-1]['additional_edits']=[dict(file=cli,old=ending_old,new='const threaded = b.roles[r.instance]?.definition === "assistant";')]
|
||||
(p/'mutants.json').write_text(json.dumps(specs,indent=2)+'\n')
|
||||
print('STATIC_PREFLIGHT',len(specs),'exact-once sites; no tests run')
|
||||
@@ -1558,3 +1558,13 @@ are never rewritten or removed; corrections are new entries.
|
||||
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.
|
||||
2026-10-23T03:15:01Z | Filbert (Opus 5.5, fenton-0011) | Q195 O2 S0 (#1798) re-check of Fenton's amendments at 74ec32013 | A1, A2 and N1-N3 closed, c30916; corrected my WOFF2 overstatement (wuff bounds WOFF2 at 128 MiB and ratio 100). Re-added: the uncommitted line from 00:1xZ was lost from the working tree. Static only, no slot.
|
||||
2026-10-23T03:15:01Z | Filbert (Opus 5.5, sage-3114) | row 293 (Vikunja 574, #1805) static review of gander/v574 7da382244, then the r2 rebase 6bfab273b and the run 1 stop diagnosis | Four argv insertions only, base hashes match, fixture audit holds; P1-P5 proof folds; run 1 red was the 120-byte tools.sock path (session.mjs:278), harness-only. Re-added: the earlier uncommitted line was lost from the working tree. Static only.
|
||||
2026-10-23T03:15:01Z | Filbert (Opus 5.5, gander-0182) | row 293 round 1 verdict, manifest cdda41f5 at a9cae2bd6 (tested 6bfab273b) | APPROVE c30982, queue rev 3411 (filbert-293-r1-approve). Run 2 GREEN is Gander's receipt (153/202/905, 312 flagged commits, 0 maintenance), not rerun by me. N1 doc-only: lifecycle.test needs TMPDIR of about 50 bytes or less.
|
||||
@@ -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
@@ -1,78 +1,107 @@
|
||||
# Q195 O2 W4: committed DOCX citations and access
|
||||
# Q195 O2 W4: committed DOCX citations and Case 2 access
|
||||
|
||||
Design for Gitea #1799, assignment sage-3060/mos-1360. Owner Rocko;
|
||||
independent reviewer Megavolt. Normative parent: `e36bce352`,
|
||||
`docs/plans/2026-10-21_q195-writing-editor.md`, blob
|
||||
`322ec50f2669cffc0f8256d6beffdaccc4d8dba8`, D12, D24-D26, W4 and W4b.
|
||||
This is a proposed implementation contract, not access enablement or a parser
|
||||
security PASS. Reviewer owns the separate verdict file; the author does not
|
||||
write an approval there.
|
||||
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. Gates and compatibility
|
||||
## 1. Confirmed format policy and approval gates
|
||||
|
||||
W4b implements this contract only after independent W4 APPROVE and Sage's
|
||||
accepted implementation brief, S0 and its other parent gates. A release must
|
||||
ship extractor, resolver, authorization and sharing together; no partial
|
||||
agent read rollout. Missing server readiness refuses every O2 read, resolve,
|
||||
share and flag. A human setting, pin or request field cannot assert readiness.
|
||||
W5 owns flags, not chapter writes. No agent execute, edit, replace, save,
|
||||
tracked-change or comment-writing operation is introduced.
|
||||
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.
|
||||
|
||||
Q36 / Vikunja 562 is OPEN. Production Markdown adoption remains closed under
|
||||
mos-1358, independently of whether this design is approved. If Jason approves
|
||||
superseded notices, a separate C2 reader/reply row must implement and pass
|
||||
them before adoption; otherwise Mos records Jason's disposition before any
|
||||
adoption brief proceeds. This document neither implements nor decides notices.
|
||||
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.
|
||||
|
||||
Existing C2 Markdown, its line-cite schema, library grants and supporting-doc
|
||||
writer remain unchanged. An authorized old Markdown original is historical
|
||||
source, never the current DOCX or a permission to its later edits. No DOCX
|
||||
paragraph is called a line. Following an adopted source from a C2 read,
|
||||
indexing it as Markdown, changing C2 replies or a synchronized Markdown
|
||||
projection requires a separately approved Case 2 amendment through Mos/Jason.
|
||||
W4b must test old C2 fixtures unchanged alongside the new discriminated DOCX
|
||||
contract. It may migrate only new O2 records, with a named schema edge Sage
|
||||
allocates; it must not reinterpret existing line citations in place.
|
||||
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.
|
||||
|
||||
## 2. Trust and document identity
|
||||
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 host reader alone opens `<dataRoot>/writing/<business>/<project>/`.
|
||||
Business, project and document are opaque IDs resolved through the host's
|
||||
owned manifest, never request paths. Each document has a stable ID and one
|
||||
canonical DOCX path; a rename does not change its identity. A copy/new import
|
||||
gets a new document ID and no inherited grant, share, pin or cache authority.
|
||||
Origin metadata is evidence only. Copying a source does not launder access.
|
||||
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.
|
||||
|
||||
A read snapshots one reachable committed manifest and its DOCX blob from the
|
||||
same commit. It never combines HEAD's manifest with another commit's DOCX.
|
||||
The reader accepts only server-resolved commits reachable from that document's
|
||||
published history; arbitrary git objects and another document's same blob
|
||||
are not selectable. Historical access also requires the document's current
|
||||
live authorization. Removed history, missing blobs or missing extractor
|
||||
revisions refuse; there is no worktree, export or engine-memory fallback.
|
||||
The commit/blob field is a locator, not an authority token.
|
||||
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.
|
||||
|
||||
The project's sole publication ref is `refs/heads/writing-published`; only
|
||||
the human writer advances it, atomically and without force updates. Each
|
||||
commit contains the book manifest. For a document, its publication commits
|
||||
are the first-parent commits on that ref that add or change its DOCX blob or
|
||||
its canonical manifest entry (including deletion/reinstatement). The first
|
||||
commit is a publication; a sibling-only save is not. `currentCommit` is the
|
||||
latest such commit for this document at the captured ref tip. Version latest
|
||||
selects that commit; a locator commit must be one of those publication commits,
|
||||
not an arbitrary intervening project commit. Deleted document entries refuse
|
||||
current reads, including history, until explicitly reinstated and granted.
|
||||
Published history is exactly that document's reachable publication sequence.
|
||||
A sibling save does not change its currentCommit, stale flag or cursor.
|
||||
Staleness is still exact commit inequality within that document, even if a
|
||||
human deliberately saves an earlier byte-identical blob as a new publication.
|
||||
## 2. Document identity, order and freshness
|
||||
|
||||
The writer's successful commit publishes atomically before Saved. A latest
|
||||
lookup begun after that receipt resolves that published commit, not an old
|
||||
cache entry. A lookup already in progress remains internally on its captured
|
||||
commit. Return the captured version explicitly; never label it latest after
|
||||
observing a newer publish. Recheck live grants immediately before returning
|
||||
content, not just before asynchronous extraction.
|
||||
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
|
||||
|
||||
@@ -81,7 +110,7 @@ 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 O2-only records.
|
||||
except the explicitly listed request defaults below. These are proposed DOCX records for the Case 2 lookup/citation union.
|
||||
|
||||
A locator is exactly:
|
||||
|
||||
@@ -214,10 +243,10 @@ extractor supports them; never guess equivalent namespaces.
|
||||
|
||||
| Class | Exact part names | Story selector | Agent exposure |
|
||||
| --- | --- | --- | --- |
|
||||
| body | word/document.xml | main | final by default, explicit grant |
|
||||
| footnote | word/footnotes.xml | w:footnote/@w:id | own story plus owning document grant |
|
||||
| endnote | word/endnotes.xml | w:endnote/@w:id | own story plus owning document grant |
|
||||
| comment | word/comments.xml | w:comment/@w:id | only explicit comment scope |
|
||||
| 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 |
|
||||
@@ -288,8 +317,12 @@ 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 and comments require separate scopes, so final body access
|
||||
never reveals hidden deletion/comment text. Revision author/date metadata
|
||||
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):
|
||||
@@ -372,7 +405,8 @@ 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. Caller/session/business come from the bound broker.
|
||||
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})`:
|
||||
@@ -412,14 +446,14 @@ without progress.
|
||||
|
||||
Cursor is a host-authenticated opaque token binding principal, session,
|
||||
project/document, commit/blob, extractor, view, part, next ordinal, the complete
|
||||
authority revision vector defined in section 7 and expiration (5 minutes). It carries no plaintext content.
|
||||
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 tuple, live scopes and exact/historical share (there is no separate undefined historical-view scope); load
|
||||
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
|
||||
@@ -431,14 +465,13 @@ 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 ownership/grant/share (same response for nonexistent or foreign IDs);
|
||||
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/editor-sharing-unsupported
|
||||
(both 409, defined in section 7), and message is fixed server text for that code (no user
|
||||
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,
|
||||
@@ -446,417 +479,142 @@ 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.
|
||||
|
||||
## 7. Authority data, dedicated sessions and sharing
|
||||
|
||||
### 7.1 Scope and principal records
|
||||
### 6.1 Case 2 query and citation adapters
|
||||
|
||||
O2 context is permitted only in newly created **O2-dedicated conversations
|
||||
and execution sessions**. Existing C2 threads/sessions cannot be converted,
|
||||
attached to O2 context or resumed/forked into O2 mode; new IDs and a fresh
|
||||
empty context are mandatory. There is no mixed-thread turn. The UI offers
|
||||
"Open dedicated writing conversation", never silently changes an existing
|
||||
chat's permissions. Existing C2-02 supporting-doc writes and C2-06 replies
|
||||
retain their semantics. The narrowing below belongs only to this explicitly
|
||||
opted-in O2 mode, closing review M1 without a Case 2 amendment. It changes
|
||||
neither the Case 2 schema nor the meaning of its replies/line citations.
|
||||
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.
|
||||
|
||||
Authorization principal is a tuple, not a seat's display name:
|
||||
`{business,human,agentInstance,conversation,executionSession}`. human is the
|
||||
stable authenticated identity that owns the O2 conversation; agentInstance
|
||||
and executionSession come from its bound broker run. The request cannot
|
||||
supply/replace them. All human readers and all agent lookups require live
|
||||
R5 project/universe access plus the O2 grant below; role files grant the
|
||||
operation only. The business owner is not an implicit reader of someone
|
||||
else's private conversation. A viewer can read only their authorized human
|
||||
view, never write a share/settings change or invoke an agent by sharing.
|
||||
An owner/collaborator with current project write access may share within
|
||||
their own readable O2 scope. Agent scope and sharing human scope intersect;
|
||||
a human cannot share a part or view they lack.
|
||||
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.
|
||||
|
||||
O2 scopes live in the new append-only bus table writing_access_events, not
|
||||
access.json or role-file wildcard changes. Proposed human-only verb
|
||||
`editor.access.set({project,document,subject,scope,expectedRevision})` takes
|
||||
subject exactly `{kind:"human"|"agent",id}`; agent id is the business's
|
||||
bus agent **instance ID**, equal to principal.agentInstance, never a role
|
||||
definition ID or a seat display name. scope is exactly
|
||||
`{enabled,parts,views,history,mayShare}`. parts is a sorted unique nonempty
|
||||
subset of body/footnote/endnote/comment (all stories within the selected
|
||||
class); views is a sorted unique nonempty subset final/original/markup;
|
||||
history and mayShare are booleans. mayShare must be false for agent subjects.
|
||||
enabled=false revokes all access; even its otherwise populated scope cannot
|
||||
authorize. No wildcards or implicit note/comment inheritance. A comment
|
||||
requires parts including comment and the requested view. history=true permits
|
||||
older publications subject to a matching share; history=false permits only
|
||||
the current publication. The document is always explicit.
|
||||
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.
|
||||
|
||||
Only the existing business-owner human proof may write these policy grants,
|
||||
through the existing trusted human CLI or authenticated write-enabled Console
|
||||
transport, with the existing origin/session/proof checks. No new proof file,
|
||||
caller or exemption. Not tester, agent, viewer or collaborator policy writes.
|
||||
Sharing and scope are different: an authorized collaborator may send context
|
||||
but cannot change grants. expectedRevision is the latest sequence for that
|
||||
business/project/document/subject, zero when none. Compare, append and audit
|
||||
are atomic; conflict returns editor-policy-conflict (409, fixed message).
|
||||
Receipt is `{ok:true,result:{id,revision,previousRevision,scopeDigest}}`, where
|
||||
revision is the appended seq, id is host-generated, and scopeDigest is SHA-256
|
||||
of canonical scope JSON using section 3 serialization. Missing grant denies.
|
||||
## 7. Existing Case 2 authority, remote durability and restore
|
||||
|
||||
Each access decision records an authority vector exactly
|
||||
`{restoreEpoch,humanAccess,agentAccess,projectAccess,threadAccess,sharingPolicy,share}`.
|
||||
humanAccess and agentAccess are the respective latest writing_access_events
|
||||
seq values; projectAccess is the existing R5 access revision; threadAccess
|
||||
is the latest O2 conversation membership-event seq; sharingPolicy is the
|
||||
latest writing_policy_events seq; share is the exact writing_shares seq.
|
||||
restoreEpoch is the independent restore-fence epoch in section 7.5.
|
||||
Missing inputs deny rather than zero-default except initial policy explicitly
|
||||
materialized by the human writer as the default. A cursor binds every value;
|
||||
any change invalidates it. Each new lookup reevaluates rights, including a
|
||||
revocation between asynchronous extraction and response emission.
|
||||
### 7.1 Access and supporting writes (reopened M1)
|
||||
|
||||
### 7.2 One human per context; settings and chip requests
|
||||
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.
|
||||
|
||||
An O2 conversation has exactly one owner human and one agent instance. No
|
||||
second human joins it in v1: a thread-share attempt refuses
|
||||
editor-sharing-unsupported (409), rather than borrowing the sender's grants
|
||||
for someone else. A second human starts a separate O2 conversation, supplies
|
||||
their own grant/share and gets no previous content. This is a new O2 mode,
|
||||
not a restriction on existing C2 sharing. Human and agent must still have
|
||||
matching business/project/universe rights. Read-only previews cannot send.
|
||||
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.
|
||||
|
||||
Human-only `editor.sharing.set({conversation,mode,expectedRevision})` is
|
||||
available only to that authenticated owner with current write access over
|
||||
the same trusted write transports. mode is off/visible-chip-per-send/
|
||||
conversation-current. It atomically appends writing_policy_events and returns
|
||||
`{ok:true,result:{id,revision,previousRevision,mode}}`. Default is the explicit
|
||||
visible-chip-per-send policy on new conversation creation. Selecting off
|
||||
immediately invalidates **all** prior shares, reads, resolves and cursors for
|
||||
that conversation, not only new sends. Selecting another mode later does
|
||||
not revive shares from the earlier policy revision. No pin changes a mode.
|
||||
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.
|
||||
|
||||
In visible-chip-per-send the human sends exactly
|
||||
`{conversation,message,expectedPolicyRevision,chip}` via the human send path.
|
||||
message is human-authored free-chat text; W4b's bounded human API handles it,
|
||||
never an agent-supplied impersonation. chip is null or one of:
|
||||
### 7.2 Selection convenience and ordinary messages
|
||||
|
||||
- `{kind:"range",document,baseCommit,selection}` where selection is one
|
||||
docx-range/docx-selection locator, with its business/project/document/
|
||||
commit equal to the server-owned conversation/document and baseCommit.
|
||||
- `{kind:"document",document,baseCommit,parts,views,history}` selecting a
|
||||
whole-document **exact publication**, with explicit part/view lists and
|
||||
history boolean constrained by both grants.
|
||||
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.
|
||||
|
||||
Both chips require a server-issued human editor selection token as an
|
||||
additional required `selectionToken` key. It binds authenticated login,
|
||||
conversation, document, baseCommit, saved editor model revision, and either
|
||||
exact locator or whole-document scope digest, expires in 2 minutes and is
|
||||
single-use. The human editor adapter creates it only after exact mapping to
|
||||
the committed projection. The request supplies no draft text or engine path
|
||||
for mapping; mismatched/edited selection fails locally and server-side.
|
||||
A stale base or document change before send refuses, requiring a fresh
|
||||
visible chip; an old citation remains resolvable through its already-sent
|
||||
share while authorized. Null chip sends no editor context. Before readiness,
|
||||
the chip says unavailable and sends no document ID, title, path or selection.
|
||||
### 7.3 Host-only off-host remote (replaces the ban)
|
||||
|
||||
A range share permits only editor_resolve_citation for that exact locator
|
||||
(or a range wholly contained within it with newly verified quote/digest).
|
||||
It always refuses editor_read_committed, since rows would expose surrounding
|
||||
text. A document exact share allows paging only its named publication,
|
||||
parts/views; it never grants future saves. No implicit body-to-note/comment
|
||||
or final-to-markup expansion occurs.
|
||||
`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.
|
||||
|
||||
conversation-current is an explicit human policy with a persistent visible
|
||||
indicator and a required whole-document chip. The same chip fields bind
|
||||
baseCommit and scope, but the share record has versionMode=latest: baseCommit
|
||||
is the acknowledged publication at issuance, not a future-version ceiling.
|
||||
The next authorized latest lookup after Saved follows currentCommit within
|
||||
the granted part/view/history bounds. This exception to exact-version sharing
|
||||
exists only in this explicit mode. Range chips are refused in this mode,
|
||||
not silently upgraded. Pins and earlier exact shares never become latest.
|
||||
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.
|
||||
|
||||
Latest shares expire on explicit policy revocation, owner grant loss, O2
|
||||
conversation close or **execution-session end/clear**. This means the new
|
||||
O2 execution session, not the Console login, bus connection or a C2 thread
|
||||
session. Relaunch/clear requires a fresh chip; a dropped socket/login does
|
||||
not silently widen or prolong one. Exact shares may remain verifiable across
|
||||
execution sessions through their same human conversation while live policy,
|
||||
history permission and grants hold, but never supply replay context to a
|
||||
new agent session without a new share. Their scope is citation verification
|
||||
only after the issuing execution session ends. No cross-human context reuse.
|
||||
### 7.4 Conversion events, rollback --restore and irreversible recovery
|
||||
|
||||
### 7.3 Stored share schema
|
||||
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.
|
||||
|
||||
The immutable writing_shares payload is exactly
|
||||
`{version,id,restoreEpoch,business,project,document,conversation,executionSession,message,
|
||||
human,agentInstance,mode,versionMode,baseCommit,blob,extractor,scope,selection,
|
||||
authority,policyRevision,issuedAt}`. version=1; IDs use section 3 grammar;
|
||||
mode is the policy at issuance; versionMode is exact/latest; baseCommit/blob
|
||||
are the acknowledged snapshot and extractor is its exact pinned revision.
|
||||
scope is `{parts,views,history}` as above; selection is the verified locator
|
||||
for a range, otherwise null. authority is the complete revision vector at
|
||||
issuance; its share field equals this row's seq (assigned atomically).
|
||||
restoreEpoch must equal authority.restoreEpoch and the live independent
|
||||
fence. issuedAt is canonical UTC millisecond ISO timestamp. policyRevision equals
|
||||
authority.sharingPolicy. message names a new writing_messages row, not a C2
|
||||
message. A unique `(conversation,message,document)` constraint and operation
|
||||
idempotency key prevent duplicate grants on retry. No user-provided human,
|
||||
agentInstance, authority, issuedAt, blob or share id is accepted.
|
||||
A scope decrease/revocation denies even historical shares; an unrelated new
|
||||
policy revision also invalidates prior policy-bound shares. A human who
|
||||
needs them again explicitly reshares. Missing session/share is no context.
|
||||
|
||||
### 7.4 Dedicated storage and every output surface
|
||||
|
||||
Every turn receiving O2 context runs with the new O2-only allowlist of
|
||||
read/resolve/flag plus its dedicated conversation reply. No generic file,
|
||||
shell, git, supporting-doc writer or arbitrary outgoing tool is available.
|
||||
This narrows only new O2 sessions, never C2 turns. All agent output from such
|
||||
a turn is conservatively O2-derived; it stays in the dedicated conversation
|
||||
with the union of the document provenance/scopes. No semantic quote detector
|
||||
or agent self-labelling supplies this boundary. Human text and titles in that
|
||||
conversation use the same dedicated storage, so a human pasting an O2 passage
|
||||
cannot accidentally route it into an old renderer. O2 replies never enter
|
||||
C2-06 processing. An O2 flag is metadata stored by W5, not a story edit.
|
||||
|
||||
| Existing surface | Required O2 behavior |
|
||||
| --- | --- |
|
||||
| threads/<business>/<thread>/session and claude-projects | no O2 content, prompt, model transcript, title or derived reply |
|
||||
| threads/<business>/<thread>/sessions/<session>/session and claude-projects | same; O2 mode never binds these native stores |
|
||||
| session-workspaces/<business>/<instance>/<session> | no O2 text or exports; no store mount |
|
||||
| launches/<business>/<run> including bundle, turns, runner.log and session | opaque IDs/status only, never O2 prompts/output; host transient O2 adapter instead |
|
||||
| bus events, messages, message_threads and existing sessions rows | no O2 text/titles/quotes; only fixed readiness/status audit with opaque IDs if needed |
|
||||
| Console legacy thread view | no O2 rows; dedicated writing view reauthorizes every read |
|
||||
| CLI thread, talk, follow and raw-session tools | no O2 content; unsupported mode refuses, not generic rendering |
|
||||
| Discord journal.mjs/context.mjs and control-board Discord delivery | no O2 conversation subscriptions/context/output; fixed unavailable notice only |
|
||||
| <dataRoot>/runs write-once evidence | never O2 text, prompts, titles, excerpts, stdout/stderr or derived output; only opaque run status |
|
||||
| bus-host/sessions.json, launch logs and activity projections | IDs/liveness only, no document or conversation title |
|
||||
|
||||
Host-only `<dataRoot>/writing-context/` (0700 owner, no generic mount/remote)
|
||||
holds the immutable dedicated message/share objects defined below, transient
|
||||
O2 adapter material and any unavoidable encrypted native transcript. Object
|
||||
files are regular 0600 owner files, no symlink/hardlink aliases. This is an
|
||||
O2-classified content store; it is never included in the bus release backup,
|
||||
post-attempt store, stack-copy, rehearse input or generic backup. Revocation
|
||||
changes permission at read time, never rewrites a write-once run record. It is keyed to O2 session, not an alias/symlink into seat stores.
|
||||
Release readiness requires an independently tested adapter that prevents
|
||||
plaintext native persistence/logging outside this boundary, including crash
|
||||
and timeout paths. If either Pi or Claude cannot meet it, that harness's O2
|
||||
tools stay off; do not expose the session then attempt post-hoc redaction of
|
||||
write-once run records. Readiness is per adapter. No O2 text enters immutable
|
||||
legacy evidence, even briefly. Stored dedicated replies are authorized afresh
|
||||
at read time; live grant loss hides cached UI content and prevents prompt
|
||||
replay/fork. Revocation cannot erase already delivered knowledge and the UI
|
||||
must not promise that it can.
|
||||
|
||||
O2-derived exports, extraction indexes, caches, previews, source copies and
|
||||
git history retain document classification outside generic roots and C2
|
||||
library mappings. O2 repositories have **no git remotes**. No push, mirror,
|
||||
fork, plaintext backup or remote replication of an O2 repository is allowed,
|
||||
including through human credentials; no seat Gitea account can discover,
|
||||
list, fork or clone one. Backup transport is out of scope and disabled, not
|
||||
an implied exception. Stack-copy/release copies source code and extractor
|
||||
artifacts only, never writing/, writing-context/, writing-control/, O2 content
|
||||
objects, exports or repository object directories. The separate release bus
|
||||
backup retains metadata rows only, under the private-store rules below;
|
||||
it is not a source copy or permission to read context. No raw O2 Gitea grant may be minted.
|
||||
Validate absence of remotes at store creation, before save/read and activation;
|
||||
a new remote or object alternate disables O2, rather than trusting a read-only
|
||||
mount. Symlinks, hardlinks, alternate worktrees and parent mounts must not
|
||||
expose it to generic tools. Native engine APIs remain human-only.
|
||||
|
||||
### 7.5 Named migration and rollback contract
|
||||
|
||||
W4b adds the following new STRICT append-only bus tables (seq INTEGER primary
|
||||
key, at TEXT canonical timestamp, id TEXT unique, all other fields NOT NULL
|
||||
unless stated). Each row also has restore_epoch TEXT NOT NULL, equal to the
|
||||
independent current fence epoch at write time. Every update/delete/replace
|
||||
refuses; latest seq **within that epoch** is state.
|
||||
JSON columns contain the exact canonical schema named above, not arbitrary
|
||||
unvalidated JSON. No existing C2 messages/events/sessions column changes.
|
||||
|
||||
| Table | Additional columns and key |
|
||||
| --- | --- |
|
||||
| writing_access_events | business TEXT, project TEXT, document TEXT, subject_kind TEXT (human/agent), subject_id TEXT, by_human TEXT, previous_revision INTEGER, scope_json TEXT, operation_id TEXT UNIQUE; latest per document/subject |
|
||||
| writing_conversations | business TEXT, project TEXT, owner_human TEXT, agent_instance TEXT; one creation row per id, private O2 identity only |
|
||||
| writing_conversation_events | conversation TEXT FK, op TEXT (close), by_human TEXT; latest membership/close revision, creation seq is initial threadAccess |
|
||||
| writing_execution_sessions | conversation TEXT FK, execution_session TEXT UNIQUE, adapter_revision TEXT; no native transcript path |
|
||||
| writing_session_events | execution_session TEXT FK, op TEXT (end/clear), by_principal TEXT; terminal event invalidates latest shares |
|
||||
| writing_policy_events | conversation TEXT FK, mode TEXT, by_human TEXT, previous_revision INTEGER, operation_id TEXT UNIQUE |
|
||||
| writing_messages | conversation TEXT FK, execution_session TEXT FK nullable for human-only messages, from_kind TEXT (human/agent), from_id TEXT, content_ref TEXT, content_digest TEXT, operation_id TEXT UNIQUE |
|
||||
| writing_shares | conversation TEXT FK, message TEXT FK, document TEXT, payload_ref TEXT, payload_digest TEXT, operation_id TEXT UNIQUE; UNIQUE(conversation,message,document) |
|
||||
|
||||
Each table also has business/project columns if not otherwise listed; FK and
|
||||
application checks require agreement, not just ID existence. The canonical message content object is exactly `{version:1,business,project,row,nonce,body,provenance}`:
|
||||
row is the owning writing_messages row ID; nonce is fresh cryptographically
|
||||
random 32-byte lowercase hex, stored only inside the private object, not its
|
||||
metadata backup. body is
|
||||
the complete human/agent message string; provenance is an array of `{document,share,commit,blob,extractor}`,
|
||||
sorted by document/share; every entry requires live scopes. The share content
|
||||
object is exactly `{version:1,business,project,row,nonce,payload}`, where row
|
||||
is its writing_shares row ID, nonce has the same fresh/private rule, and
|
||||
payload is the full section 7.3 record including any quoted selection.
|
||||
Neither message bodies nor share payloads/quotes enter bus.sqlite, WAL,
|
||||
events, SQL error logs or backup manifests. Human messages with no O2
|
||||
attachment have provenance=[] but use the same private content store.
|
||||
content_ref/payload_ref are opaque 64-hex object names, not paths; their
|
||||
corresponding digest equals SHA-256 of the exact canonical UTF-8 object.
|
||||
Objects reside at writing-context/objects/<business>/<row>/<digest>.json;
|
||||
row/business/project must match both reference metadata and the authorized
|
||||
caller. No cross-business or cross-row deduplication; retention is per owning
|
||||
row and cannot delete another business's object. The private random nonce
|
||||
prevents a metadata digest from confirming a guessed short body. No remote or
|
||||
release-backup copy. Write/flush the immutable object before committing its
|
||||
bus reference. A failed transaction leaves only an inaccessible orphan,
|
||||
never a visible partial message/share; a missing or mismatched object refuses.
|
||||
Object reads always require current authority, not knowledge of a digest.
|
||||
No
|
||||
existing renderer queries these new tables; generic bus recordEvent and
|
||||
recordTask cannot write their rows. Access/policy/shares use only the trusted
|
||||
handlers and atomic transactions. Grant seq values are table-specific, with
|
||||
kind-qualified revision vector fields; never compare unrelated seq counters.
|
||||
|
||||
Sage allocates the schema edge; migration creates these empty tables and
|
||||
triggers, with no backfill/conversion of C2 citations, messages or raw sessions.
|
||||
Production adoption stays refused. The writer registers new documents without
|
||||
creating grants; the owner explicitly sets both human/agent document scopes.
|
||||
O2 session creation and its default policy are one atomic operation behind
|
||||
owner write proof; receipt `{id,executionSession,policyRevision}` contains no
|
||||
content. No legacy thread/session can be attached by guessed ID.
|
||||
|
||||
Old release rollback must not render O2 data. The new store schema version
|
||||
normally makes old binaries refuse opening it, but security does not depend
|
||||
on that: even a restored pre-W4b database reader can see only its unchanged
|
||||
legacy tables with no O2 content. O2 messages/share/provenance remain in new
|
||||
tables, never a tagged body in existing messages. Activating a pre-W4b copy
|
||||
disables O2 context, never converts dedicated conversations to C2 or exports
|
||||
transcripts for compatibility. O2 repositories/stores remain outside copies.
|
||||
A rollback probe uses recognizable secret fixture text in O2-only content objects,
|
||||
then invokes old Console/CLI/Discord/raw-session renderers: none may emit it,
|
||||
its title, quote or prompt. No live rollback is part of acceptance testing.
|
||||
|
||||
#### Release backup, restore counting and independent revocation fence
|
||||
|
||||
The existing release behavior must be explicitly accounted for: at this base
|
||||
`scripts/stack-release.sh:474-493` copies bus.sqlite into
|
||||
`<dataRoot>/backups/stack/<ACT>/bus/`; restore_store at :498ff moves current
|
||||
bus/ to `<dataRoot>/bus.post-attempt-<stamp><suffix>/`. rehearse reads backup
|
||||
stores, and release never prunes them. These paths remain **O2-classified
|
||||
metadata stores** even though content/quotes are outside SQLite. Directories
|
||||
are 0700 owner and files (including SQLite/WAL/SHM if present) 0600 owner,
|
||||
with ownership, symlink/hardlink and generic mount checks. No remote, mirror,
|
||||
seat token access or generic export. rehearse may read metadata for validation
|
||||
only and cannot load content objects, emit quotes or restore readiness.
|
||||
|
||||
Release does not silently delete evidence. A separate explicit human/ops
|
||||
retention action may prune a stopped backup/post-attempt tree only after
|
||||
checking it is not the selected recovery set or needed restore evidence,
|
||||
recording its IDs/digests and a prune receipt. No automatic age-based prune,
|
||||
no editing of retained metadata, no pruning runs/ under this policy. Until
|
||||
that separately reviewed action exists, keep these private local metadata
|
||||
trees indefinitely; it grants no content access. writing-context objects
|
||||
have a distinct reviewed retention process and are never backed up by release.
|
||||
|
||||
W4b owns the release guard changes, not a follow-up: all eight writing_*
|
||||
tables above join COUNTED in scripts/stack-release.sh:420 and the T46 table
|
||||
classification in packages/cli/tests/stack-release.test.mjs:913. They are
|
||||
non-event writers, not classified as ignorable tables or double-counted as
|
||||
generic events. Jason's existing exact --events N confirmation remains
|
||||
required when the combined post-backup row count is nonzero. A restore plan
|
||||
also compares writing row identities with the proposed source store; equal
|
||||
aggregate counts cannot conceal a dropped revocation replaced by another row.
|
||||
A schema with any unclassified writing_* table refuses restore readiness.
|
||||
|
||||
An **independent host-private fence** lives outside bus/ and every backup:
|
||||
`<dataRoot>/writing-control/restore-fence.json`, directory 0700, regular file
|
||||
0600, no links/mounts/remote/source-copy/rehearse access. Its exact schema is
|
||||
`{version:1,epoch,restoreOperation,droppedRowsDigest,confirmedBusinesses}`.
|
||||
epoch is a host-generated opaque ID; restoreOperation is null before the
|
||||
first restore, otherwise a host-generated opaque operation ID; droppedRowsDigest
|
||||
is SHA-256 of the canonical sorted dropped `(table,id)` list (empty allowed);
|
||||
confirmedBusinesses is a sorted unique array of authenticated business IDs.
|
||||
Only the trusted release controller and existing owner-proof handler write
|
||||
it by durable atomic replacement; agents/testers/request paths cannot.
|
||||
It is not a human-editable readiness setting: verified release readiness is
|
||||
still required, and this file is an additional independent deny fence.
|
||||
|
||||
**First creation:** the compatible release controller durably creates the
|
||||
fence as part of W4b's first schema migration, before committing those empty
|
||||
tables or enabling any handler. It uses a fresh epoch, restoreOperation=null,
|
||||
droppedRowsDigest=SHA-256 of the canonical UTF-8 empty JSON array `[]`, and
|
||||
confirmedBusinesses=[]. Every initial writing row uses that epoch. A normal
|
||||
never-restored store does **not** require editor.restore.confirm: the
|
||||
confirmation gate applies only when restoreOperation is non-null. Initial
|
||||
live grants/shares and verified artifact readiness are still mandatory.
|
||||
|
||||
Initialization is allowed only when the pre-migration schema has no writing_*
|
||||
tables/history and there is no existing fence. If a crash leaves a durable
|
||||
initial fence but the schema transaction uncommitted, migration resumes with
|
||||
that same epoch, not another initial state. If writing tables/history exist
|
||||
and the fence is missing/malformed, fail closed; never repair by creating a
|
||||
new initial fence. Creating an initial fence is not a way around a previous
|
||||
restore or revocation. W4b acceptance exercises both migration crash orders.
|
||||
|
||||
Before **any** store swap/restore (not just one with nonzero counts), the
|
||||
current compatible controller durably creates a fresh epoch, records dropped
|
||||
writing rows and clears confirmedBusinesses. If fencing fails, restore
|
||||
refuses before changing bus/. A crash anywhere keeps O2 denied. Old snapshot
|
||||
rows have old restore_epoch; they cannot authorize even if their grant,
|
||||
policy or share says enabled. Automatic failure recovery and rehearse-driven
|
||||
restore use the same fence. Normal code rollback without store restore
|
||||
keeps the fence and leaves O2 off on incompatible code. A pre-W4b controller
|
||||
is not allowed to restore an O2-enabled data root: the active controller's
|
||||
capability preflight refuses it; operations must use the compatible restore
|
||||
controller even when the target runtime is old.
|
||||
|
||||
After restore, the verified W4b runtime remains unavailable for O2 in every
|
||||
unconfirmed business. New human-only
|
||||
`editor.restore.confirm({expectedEpoch})`, under the business owner's existing
|
||||
trusted human proof, acknowledges that business in the independent fence
|
||||
and returns `{ok:true,result:{business,epoch,confirmed:true}}`. It restores
|
||||
**no old grant/share/policy/conversation**. Fresh current-epoch grants,
|
||||
a fresh dedicated conversation/default policy and fresh human chips must
|
||||
be created explicitly. Past rows may be inspected by an authorized recovery
|
||||
operator as metadata, but never used as current authority or prompt replay.
|
||||
Every read/resolve/cursor/share checks matching restore_epoch and, when
|
||||
restoreOperation is non-null, that business's explicit confirmation; old object bytes remaining on disk are not capabilities.
|
||||
Thus even owner confirmation cannot revive a previously revoked old share.
|
||||
Missing/malformed fence state denies O2. This current-epoch file does not
|
||||
claim to detect a privileged manual rollback of both fence and database;
|
||||
the supported restore protocol never restores the fence from a backup and
|
||||
always rotates it first. A same-uid operator bypassing that protocol is not
|
||||
a boundary this file can prove. --events N approves a
|
||||
store operation only and is never a substitute for this recovery protocol.
|
||||
|
||||
Acceptance seeds a secret in a content object and a share, takes a backup,
|
||||
revokes access/sharing, then restores the backup with explicit row-loss
|
||||
approval: the old share stays refused before and after owner confirmation;
|
||||
a new grant/conversation/chip is needed. Sweep bus.sqlite, WAL/SHM, every
|
||||
backups/stack and bus.post-attempt-* tree, rehearse output and release logs:
|
||||
no secret/title/quote/body marker may appear. Only the explicitly named
|
||||
private writing-context content object may contain it. Verify COUNTED/T46
|
||||
covers each table and simulate failure before/after each fence/store step.
|
||||
|
||||
The XML/ZIP parser safety ceilings and absence-of-remote checks apply to
|
||||
human store ingestion too where needed to prevent path escape and remote
|
||||
replication; a projection-unsupported rich feature alone does not reject a
|
||||
valid human save. Parser refusal codes have a fixed human-readable reason
|
||||
category, never raw XML or a misleading empty context. Fields/hyperlinks/
|
||||
text boxes may make real chapters agent-unreadable in v1; the UI explicitly
|
||||
says this and never claims an empty successful projection.
|
||||
|
||||
Every extractor revision referenced by retained publication history or shares
|
||||
is retained as a verified immutable host artifact. An activation missing one
|
||||
refuses O2 readiness (no silent citation downgrade), including rollback to a
|
||||
W4b-era release that drops a revision. A pre-W4b release can run with O2 wholly
|
||||
off and cannot read those records. Retiring revisions requires a separately
|
||||
reviewed retention/prune operation for all dependent O2 history/shares; not
|
||||
an automatic stack-copy cleanup. Artifact caches are not authorization caches.
|
||||
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
|
||||
|
||||
Each row needs a named fixture/test and a mutant or bypass probe in W4b's
|
||||
packet. Independent fixtures construct OOXML directly and recompute Unicode
|
||||
slices/digests outside the production extractor. Security checks fail closed;
|
||||
no fixture-only readiness flag may exist in production APIs.
|
||||
These are required future fixture gates, not implementation results.
|
||||
|
||||
| ID | Evidence required | Negative control / bypass |
|
||||
| --- | --- | --- |
|
||||
@@ -865,117 +623,49 @@ no fixture-only readiness flag may exist in production APIs.
|
||||
| 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 | final-only grant cannot read deletion/comment; no document rewrite |
|
||||
| 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 | guessed IDs, same blob elsewhere and copied document grant nothing |
|
||||
| A13 | Visible range chip exact boundaries and default off before gate | no selection/title/path metadata before readiness; draft mapping refused |
|
||||
| A14 | Every lookup intersects live grant and share | pin, old send, revoked scope and range-to-whole-document escalation fail |
|
||||
| A15 | Whole-document latest share explicit; save rechecked | exact share cannot reveal future edits; persistent policy visible |
|
||||
| A16 | Generic file/search/git/export/index paths denied | source copy, symlink, hardlink, alternate object root and new O2 copy probes |
|
||||
| A17 | Raw session, fork/resume and transcript boundaries | revoked context cannot replay via raw tool or harness history |
|
||||
| A18 | Agent tools exactly read/resolve/flag after gates | native MCP, execute, save, replacement and story comment writes refuse |
|
||||
| A19 | Existing C2 Markdown reads/line cites unchanged | no DOCX-as-lines conversion or redirect following adoption |
|
||||
| A20 | Adoption gate before/after readiness and Q36 disposition | absent/revoked server readiness and required notice readiness 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 | No O2 git remote or release copy | seat Gitea list/fork/clone fails; configured remote/object alternate disables O2; copies exclude stores |
|
||||
| A26 | Every section 7.4 store/renderer | secret marker absent in each named legacy store, raw session, CLI/Discord/Console view and runs/ |
|
||||
| A27 | Pre-W4b rollback reader | new O2 rows never rendered through legacy tables; missing retained extractor disables O2 |
|
||||
| A28 | Dedicated mode vs C2 | mixed attachment/resume/fork denied; unchanged C2 supporting writes/replies fixtures pass |
|
||||
| A29 | Two humans/viewer/grant revisions | viewer cannot share; second human cannot join or reuse context; all cursor authority fields checked |
|
||||
| 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 | Initial migration and revocation/restore | initial null-operation fence needs no restore confirmation but no grant is implicit; non-null restore fence needs confirmation and fresh authority; all tables counted/classified; old share stays refused; crash/backup-marker sweeps |
|
||||
| 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 writing, bus, harness, business, WebUI,
|
||||
seat mount/import closure and stack-copy/release fixtures; explicit production
|
||||
adoption refusal/lift tests; independent parser/policy/tool review; G1 and
|
||||
Sage's allocated schema edge. Evidence must name skips and refuse enablement
|
||||
when an acceptance guard was not exercised. W4's review alone lifts nothing.
|
||||
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. Handoff and unresolved decisions
|
||||
## 9. Reopened review and handoff
|
||||
|
||||
Megavolt reviews this separate document and records a verdict in
|
||||
`docs/reviews/2026-10-22_q195-docx-citations.md`. Revisions repeat direct
|
||||
commit/blob handoff to Megavolt and Sage. Q36 remains with Jason through Mos;
|
||||
no adoption, C2 reader/reply notice change or schema reinterpretation ships
|
||||
on this document. W4b's brief must allocate ownership for every surface in
|
||||
A16/A17 and section 7.4 before code: an unowned enforcement path is a blocker, not a deferred security
|
||||
note. No live files, editor source imports, parser execution or tests were
|
||||
used to write this design.
|
||||
|
||||
## Round 2 changes against review 08fcb8c1c
|
||||
|
||||
B1: exact selection wrapper, canonical paragraph identity, wiki/anchor schema,
|
||||
chip/share and refusal records; ordinal-primary D25 refinement explicit.
|
||||
B2: closed leaves and nested/paragraph-mark revision rules. B3: per-document
|
||||
publication sequence and sibling-save acceptance. B4/B5: concrete append-only
|
||||
authority, human proof, exact/latest share policy, revision vector and
|
||||
single-human dedicated session lifetime. B6: complete-row paging; oversized
|
||||
paragraph unsupported, no circular subrange API. B7: no remotes/backup push,
|
||||
seat token and copy probes. B8: named native stores, renderers and immutable
|
||||
run evidence, with O2-only storage/adapter readiness. B9: tables/columns,
|
||||
rollback separation and extractor retention. M1: dedicated O2 conversations
|
||||
only; mixed C2 context refused, no C2 requirement change. N1-N7 addressed in
|
||||
canonical manifest, bound-business check, ratio/default wording, UI unavailable
|
||||
reason, release-derived readiness and new A23-A31 acceptance rows. These are
|
||||
proposed definitions, not passing implementation tests. Q36/adoption unchanged.
|
||||
|
||||
## Round 3 changes against review 0d627a2b3
|
||||
|
||||
R2-B1: paragraph-mark revisions preserve all run text; markup has a zero-width
|
||||
mark span, never a fabricated whole-paragraph deletion. Reserved note leaves
|
||||
are allowed only in reserved stories; whole-part structural validation is
|
||||
explicit. A31 covers both mark-only cases and reserved notes.
|
||||
R2-B2: bodies/quoted share payloads move out of SQLite into private content
|
||||
objects; backup/post-attempt metadata paths are named with access/retention
|
||||
rules. Every writing_* table is COUNTED/T46-classified. A non-restored deny
|
||||
fence changes epoch before any store swap; owner confirmation cannot revive
|
||||
old-epoch grants/shares. A32 tests revocation/restore and secret-marker sweeps.
|
||||
R2-N1: agent grant subject means agentInstance. R2-N2: structural leaves have
|
||||
one table. This remains design only; no code, restore, parser run or test.
|
||||
|
||||
## Round 4 changes against review 9fb999abf
|
||||
|
||||
R3-B1: reserved-note classification is by type with any canonical signed ID,
|
||||
not sign; normal notes require nonnegative IDs, and references to reserved
|
||||
notes refuse. A31 covers both actual Word layouts. R3-B2: first migration
|
||||
creates the durable initial epoch before schema commit; null restoreOperation
|
||||
needs no recovery confirmation, a restored epoch does. Missing established
|
||||
fences never reinitialize. A32 covers first enable and post-restore states.
|
||||
R3-N1: business/row/private random nonce are part of each content object and
|
||||
its path, so no cross-business deduplication or guessed-body digest oracle.
|
||||
R3-N2: removed the unsupported claim to detect privileged manual rollback
|
||||
of the fence itself. R3-N3: restore_store anchor corrected to :498.
|
||||
|
||||
### Public Word fixture confirmation (not an extractor test)
|
||||
|
||||
Inspected only public Microsoft-maintained test packages in memory using
|
||||
Python ZIP/XML readers, not the O2 parser or a live/user document. No binary
|
||||
was imported into this repository. Source:
|
||||
[OfficeDev/Open-Xml-PowerTools FootNote.docx](https://github.com/OfficeDev/Open-Xml-PowerTools/blob/a9187c5ec4199106562ec91c5ffd9f355251253b/NewDocxDocuments/FootNote.docx),
|
||||
14,214 bytes; DOCX SHA-256
|
||||
`40e2de78f0f70d8acf3db734df13f107c92986f1a40865cdf9772a8b55ab5e20`.
|
||||
Its app properties identify Microsoft Office Word, version 15.0000.
|
||||
footnotes.xml has separator=-1, continuationSeparator=0, normal note=1;
|
||||
endnotes.xml has separator=-1 and continuationSeparator=0.
|
||||
footnotes.xml SHA-256
|
||||
`1da08d9c0a4a1af2706c035dfb176defd179686f81418134b796d49649e850a0`;
|
||||
endnotes.xml SHA-256
|
||||
`f83e7f1c9f91b44db26b147b22ca615a13488bc6a8084febb392ee00a6ca4f5e`.
|
||||
This confirms the reviewer's -1/0 example in a Word-identified fixture.
|
||||
|
||||
An older independent Microsoft SDK fixture confirms why IDs cannot define
|
||||
the class:
|
||||
[Open-XML-SDK ForTestCase/footnote.docx](https://github.com/dotnet/Open-XML-SDK/blob/431ab05cf160248cc3885a4a766026d4f8243792/test/DocumentFormat.OpenXml.Tests.Assets/assets/TestDataStorage/v2FxTestFiles/ForTestCase/footnote.docx),
|
||||
69,706 bytes, SHA-256
|
||||
`26dc62a5d00e2c3c1df60e0d5bf1cac43b142a090ea595b9ad11ce73e3dab268`.
|
||||
It identifies Microsoft Office Word 12.0000 and uses reserved IDs 0/1 in
|
||||
both parts, with normal footnotes 2/3. No claim is made that the whole
|
||||
packages pass v1's otherwise deliberately restrictive projection rules.
|
||||
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.
|
||||
+13
-6
@@ -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 3368 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3434 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -244,7 +244,7 @@ Generated from `docs/plans/queue.json` revision 3368 by `queue render`. Do not e
|
||||
| 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 | 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 | 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 | — |
|
||||
| 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,9 +306,9 @@ Generated from `docs/plans/queue.json` revision 3368 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 | in-review, round 1, request posted | 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 | — |
|
||||
| 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 | — |
|
||||
| 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 | in-review, round 1, request posted | 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. |
|
||||
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | done | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | Landed 3410df7c3 on refactor (fast-forward from 48e450ce8, 3 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 39847b15 (4 paths) blobs match the commit. Gate: whole files 7/22/6, bus 834, cli 861, harness 151, business 231, seat 167, 0 fail; 10 scripts/test-*.sh rc 0 with 3 named skips; M6 and M10 killed. Duckworth APPROVE round 1, Gitea #1780 comment 30717. |
|
||||
@@ -328,7 +328,14 @@ Generated from `docs/plans/queue.json` revision 3368 by `queue render`. Do not e
|
||||
| 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 | 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) | — |
|
||||
| 292 | stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | gizmoduck; reviewers duckworth | #1797 | in-review, round 1, request posted | 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-review, round 1, request posted | — | 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 | in-review, round 1, request posted | — | 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 | in-review, round 1, request posted | — | 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 -->
|
||||
|
||||
|
||||
+2467
-26
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
+396
-28
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -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.*.
|
||||
@@ -312,7 +314,7 @@ const enrollBinding = (enroll) => ({
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
|
||||
// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
@@ -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;
|
||||
@@ -593,7 +598,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#threaded(business, role) {
|
||||
return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
|
||||
return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
}
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
@@ -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,
|
||||
@@ -5790,27 +6068,24 @@ export class Broker {
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
}
|
||||
// A thread belongs to the user who made it and to one declared thread instance.
|
||||
// A thread belongs to the user who made it and to the business's one thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
keys(a, ['project', 'name'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
const instance = a.instance === undefined
|
||||
? (instances.length === 1 ? instances[0] : null)
|
||||
: identifier(a.instance);
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
|
||||
fail('access-refused');
|
||||
const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
// The human selects an instance; omission is compatible only with one candidate.
|
||||
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
// The bus never picks between instances; the business file declares exactly one.
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
if (!instances.length) fail('thread-instance-missing');
|
||||
if (instances.length > 1) fail('thread-instance-ambiguous');
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -5819,13 +6094,13 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.project,
|
||||
instance,
|
||||
instances[0],
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
|
||||
return { id: tid, instance };
|
||||
this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
return { id: tid, instance: instances[0] };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
@@ -5855,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 };
|
||||
}
|
||||
@@ -5865,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);
|
||||
@@ -5942,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
|
||||
@@ -5960,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']);
|
||||
@@ -6096,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
|
||||
@@ -6254,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);
|
||||
|
||||
@@ -44,18 +44,15 @@ function setup(t) {
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
||||
const threads = { assistant: thread };
|
||||
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
for (const role of Object.keys(roles)) {
|
||||
if (['bean', 'quill'].includes(role))
|
||||
threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
return { call, count, store, threads };
|
||||
return { call, count, store };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
@@ -75,7 +72,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
const { call, count, threads } = setup(t);
|
||||
const { call, count } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
@@ -84,7 +81,7 @@ test('a creative instance sends only to the user and hears only from the user',
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
||||
call('human', 'message.send', { to: who, body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 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.
|
||||
|
||||
@@ -601,42 +601,3 @@ test('project.create keeps unrelated names with and without a repository', t =>
|
||||
{ id: linked.id, name: 'work', repo: 'stack' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
|
||||
const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
|
||||
} }));
|
||||
const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
|
||||
for (const [instance, role] of Object.entries(extra)) {
|
||||
const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
|
||||
assert.equal(receipt.instance, instance);
|
||||
assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
|
||||
assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
|
||||
ctx.launch(instance, instance, { thread: receipt.id });
|
||||
assert.equal(ctx.claim(instance).thread, receipt.id);
|
||||
const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
|
||||
const read = ctx.call(instance, 'message.receive');
|
||||
assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
|
||||
assert.equal(ctx.threadOf(sent.id), receipt.id);
|
||||
assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
|
||||
assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
|
||||
{ code: 'thread-required-use-thread-send' });
|
||||
}
|
||||
assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
|
||||
});
|
||||
|
||||
test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
|
||||
const ctx = setup(t);
|
||||
const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
for (const instance of ['not-declared', 'pm', 'coder'])
|
||||
assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
|
||||
for (const instance of [null, 7, {}, [], '', 'bad name'])
|
||||
assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
|
||||
assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
|
||||
const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
|
||||
assert.equal(legacy.instance, 'assistant');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
|
||||
});
|
||||
|
||||
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
||||
function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
|
||||
function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -40,7 +40,6 @@ function setup(t, { grants = [grant()], processing = null, threadRoles = {} } =
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
...threadRoles,
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
@@ -395,21 +394,3 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
|
||||
test('R3 r4: a non-function access throws before the store is read', () => {
|
||||
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
||||
});
|
||||
|
||||
test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
|
||||
const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
ctx.access.doc.grants = [grant({ projects: [project] })];
|
||||
ctx.user('alice');
|
||||
assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
|
||||
for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
|
||||
assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
|
||||
assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
|
||||
const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
|
||||
assert.equal(allowed.instance, 'assistant');
|
||||
ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
|
||||
const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
|
||||
assert.equal(bean.instance, 'bean');
|
||||
assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
||||
});
|
||||
@@ -53,7 +53,7 @@
|
||||
// session.json names it as gitSocket.
|
||||
//
|
||||
// Thread sessions (row 121, #1615). A thread instance (an instance whose
|
||||
// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
|
||||
// role definition is the assistant) runs once per thread, never on its own:
|
||||
// a launch from a session or --pm refuses thread-required. Every tick
|
||||
// (threadTickMs) the host asks the broker for the business's threads
|
||||
// (launch op threadsState) and:
|
||||
@@ -139,7 +139,6 @@ import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
@@ -163,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";
|
||||
@@ -176,6 +176,8 @@ const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
const THREAD_TICK_MS = 2000;
|
||||
// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
@@ -261,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
|
||||
@@ -282,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(() => {});
|
||||
@@ -298,7 +331,7 @@ export function createLauncher({
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
|
||||
@@ -471,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 }));
|
||||
@@ -611,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);
|
||||
@@ -656,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));
|
||||
@@ -706,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() {
|
||||
@@ -853,7 +997,7 @@ export function createLauncher({
|
||||
function ending(r) {
|
||||
const runs = r.runs ?? [];
|
||||
const at = Date.parse(r.at);
|
||||
const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
|
||||
const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
|
||||
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
|
||||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -773,28 +773,21 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
// Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
// The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
const thread = 'persona-thread';
|
||||
const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
...host,
|
||||
op: message => message.op === 'threadsState'
|
||||
? Promise.resolve([{ thread, instance: 'bean', holder: null, pending: 1 }])
|
||||
: host.op(message),
|
||||
}) });
|
||||
// The bundle's refusal remains launch-failed with the persona mismatch as its reason.
|
||||
await launcher.tick();
|
||||
const { f, host, launcher } = await setup(t, undefined, given);
|
||||
// The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
// The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
const [e] = log(f);
|
||||
assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);
|
||||
assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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()};
|
||||
|
||||
|
||||
@@ -396,22 +396,6 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
|
||||
});
|
||||
|
||||
// Admission refuses before bundle construction or a sandbox is needed.
|
||||
test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
|
||||
const prepared = prepare(t, { edit: doc => {
|
||||
doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
doc.launch.instances.push('bean');
|
||||
return doc;
|
||||
} });
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
|
||||
});
|
||||
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
|
||||
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
|
||||
// bus's plug-in loader.
|
||||
@@ -459,71 +443,3 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
|
||||
assert.deepEqual(threadRuns(f, A), []);
|
||||
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
|
||||
});
|
||||
|
||||
test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
|
||||
const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
const vars = { ...doc.roles.assistant.vars };
|
||||
delete doc.roles.assistant;
|
||||
doc.roles.bean = { definition: 'companion', vars: { ...vars } };
|
||||
doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
|
||||
doc.launch.instances = ['coder', 'bean', 'chet'];
|
||||
return doc;
|
||||
} });
|
||||
const host = await start(t, prepared), { f } = prepared;
|
||||
const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
|
||||
const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
|
||||
const send = async (thread, instance, body) => {
|
||||
const message = host.call('thread.send', { thread, body });
|
||||
const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
|
||||
launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
const reply = outcome;
|
||||
assert.equal(reply.from_role, instance);
|
||||
assert.equal(reply.to_role, 'human');
|
||||
return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
|
||||
};
|
||||
assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
|
||||
assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
|
||||
assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
|
||||
assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
const launches = launchLog(f).filter(row => row.event === 'launch');
|
||||
assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
|
||||
assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
|
||||
const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
});
|
||||
|
||||
test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
|
||||
const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
delete doc.roles.assistant;
|
||||
doc.launch.instances = ['coder', 'bean'];
|
||||
return doc;
|
||||
} });
|
||||
let requests = [];
|
||||
const host = await start(t, prepared, { ops: () => ({
|
||||
lifecycleRequests: () => requests,
|
||||
lifecycleTake: () => ({ ...requests[0], go: true }),
|
||||
lifecycleDone: () => { requests = []; return {}; },
|
||||
}) });
|
||||
const { f } = prepared;
|
||||
const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
|
||||
for (const thread of threads) {
|
||||
const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
|
||||
const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
|
||||
launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
}
|
||||
const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
|
||||
requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
|
||||
instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
|
||||
await host.launcher.tick();
|
||||
await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
|
||||
assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
|
||||
await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -78,8 +78,7 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
|
||||
action = 'createProject'; args = v;
|
||||
} else if (path === '/api/chat/threads') {
|
||||
if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
|
||||
(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
|
||||
if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
|
||||
action = 'createThread'; args = v;
|
||||
} else if (match?.[2] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createChat({ call }) {
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
|
||||
createThread: ({ project, name }) => channel('thread.create', { project, name }),
|
||||
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
|
||||
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
|
||||
open: ({ thread }) => channel('thread.open', { thread }),
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
return states.get(id);
|
||||
};
|
||||
const form = key => {
|
||||
if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
class ChatError extends Error {
|
||||
@@ -58,11 +58,7 @@
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
const choices = current.instances ?? [];
|
||||
const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
|
||||
const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
|
||||
const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
|
||||
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
|
||||
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
|
||||
}
|
||||
function threads(rows) {
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
|
||||
@@ -110,7 +106,7 @@
|
||||
const f = document.getElementById(keep.field.id);
|
||||
if (f && !f.disabled) {
|
||||
f.focus({ preventScroll: true });
|
||||
if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -130,7 +126,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
|
||||
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
@@ -151,25 +147,6 @@
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
}
|
||||
}
|
||||
if (kind === 'project') {
|
||||
try {
|
||||
const listed = await request('instances', undefined, signal);
|
||||
if (g !== generation || out()) return;
|
||||
if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
|
||||
throw new ChatError('invalid-response');
|
||||
current.instances = listed.rows; current.instanceError = '';
|
||||
const f = form(id);
|
||||
if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
|
||||
f.instance = ''; f.instanceChosen = true;
|
||||
f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
|
||||
}
|
||||
if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError' || out() || g !== generation) return;
|
||||
current.instances = [];
|
||||
current.instanceError = "Couldn't read the thread instances; reload to try again";
|
||||
}
|
||||
}
|
||||
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
|
||||
if (kind === 'list') {
|
||||
try {
|
||||
@@ -202,11 +179,7 @@
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
if (f && event.target.dataset.chatField) {
|
||||
const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
draft[field] = event.target.value;
|
||||
if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
|
||||
}
|
||||
if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
@@ -259,8 +232,7 @@
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
|
||||
const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
|
||||
const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
|
||||
@@ -706,25 +705,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
// Row 219: labels only, from this listener's own human or tester bus, after auth above.
|
||||
if (path === '/api/chat/instances') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
|
||||
if (search) return json(res, 400, { error: 'invalid-request' });
|
||||
if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
|
||||
try {
|
||||
const value = await bus.instances();
|
||||
if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
|
||||
throw new BusReadError('invalid-response');
|
||||
const rows = Object.entries(value.instances)
|
||||
.filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
|
||||
.map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
|
||||
if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code });
|
||||
}
|
||||
}
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
|
||||
window.__instances=[{instance:'assistant',name:'assistant'}];
|
||||
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
|
||||
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
@@ -34,7 +33,6 @@ const stub = `(() => {
|
||||
window.__reads.push(path);
|
||||
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
||||
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
|
||||
if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
|
||||
if(path==='/api/chat/projects')return data({rows:window.__projects});
|
||||
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
@@ -84,7 +82,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
||||
await click('#chat-thread-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-send-form")');
|
||||
assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
|
||||
assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
@@ -266,65 +264,3 @@ test('chat browser: a late project creation receipt cannot navigate away from an
|
||||
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
||||
});
|
||||
|
||||
test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
|
||||
const { b, wait, set, click, refresh } = await setup(t);
|
||||
await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
|
||||
await wait('document.querySelectorAll("#chat-instance option").length===3');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
|
||||
assert.match(labels[1], /\(bean\)/);
|
||||
assert.match(labels[2], /\(chet\)/);
|
||||
assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
|
||||
assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
await set('#chat-thread-name', 'Bean discussion');
|
||||
await click('#chat-thread-form button');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
await set('#chat-instance', 'bean');
|
||||
await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
|
||||
await click('#chat-thread-form button');
|
||||
await wait('window.__writes.length===1');
|
||||
assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
|
||||
});
|
||||
|
||||
test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
|
||||
const { b, wait, set, refresh } = await setup(t);
|
||||
await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
|
||||
await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
|
||||
await refresh();
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
|
||||
await refresh();
|
||||
await set('#chat-instance', 'bean');
|
||||
await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
|
||||
await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
|
||||
await set('#chat-instance', 'chet');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
});
|
||||
|
||||
test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
|
||||
const { b, wait } = await setup(t, '?readonly');
|
||||
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
||||
await wait('document.querySelectorAll("#chat-instance option").length===2');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
|
||||
const path = '/api/chat/instances';
|
||||
const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
|
||||
const snapshot = {
|
||||
revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
|
||||
instances: {
|
||||
assistant: { definition: 'assistant', vars: { model: 'private-model' } },
|
||||
bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
|
||||
chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
|
||||
pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
|
||||
},
|
||||
};
|
||||
function get(server, { route = path, method = 'GET', headers = {} } = {}) {
|
||||
const address = server.address(), unix = typeof address === 'string';
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
|
||||
path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', chunk => text += chunk);
|
||||
res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
|
||||
});
|
||||
req.on('error', reject); req.end();
|
||||
});
|
||||
}
|
||||
test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
|
||||
let calls = 0;
|
||||
const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
|
||||
t.after(() => close(server));
|
||||
const refused = await get(server);
|
||||
assert.equal(refused.status, 401);
|
||||
assert.equal(calls, 0);
|
||||
const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
assert.equal(answer.status, 200);
|
||||
assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
|
||||
for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
|
||||
assert.doesNotMatch(answer.text, /private|Hidden PM/);
|
||||
assert.equal(calls, 1);
|
||||
for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
|
||||
const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
|
||||
assert.ok([400, 405].includes(result.status));
|
||||
}
|
||||
assert.equal(calls, 1, 'invalid metadata requests never call a bus');
|
||||
});
|
||||
test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
let reads = 0;
|
||||
const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
|
||||
bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
|
||||
t.after(() => close(server));
|
||||
const answer = await get(server);
|
||||
assert.equal(answer.status, 200);
|
||||
assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
|
||||
assert.equal(reads, 1);
|
||||
});
|
||||
test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
|
||||
for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
|
||||
const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
|
||||
const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
assert.ok(answer.status >= 400);
|
||||
assert.doesNotMatch(answer.text, /private-path/);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
|
||||
let session = null, reads = 0;
|
||||
const touches = [];
|
||||
const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
|
||||
session: (_req, { touch }) => { touches.push(touch); return session; } };
|
||||
const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
|
||||
t.after(() => close(server));
|
||||
const headers = { host: sso.publicHost };
|
||||
assert.equal((await get(server, { headers })).status, 401);
|
||||
assert.equal(reads, 0);
|
||||
session = { name: 'jason' };
|
||||
assert.equal((await get(server, { headers })).status, 200);
|
||||
assert.equal(reads, 1);
|
||||
assert.deepEqual(touches, [false, false]);
|
||||
});
|
||||
@@ -335,21 +335,3 @@ test('the chat seam names the transport it is given: the human one and the teste
|
||||
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
|
||||
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
|
||||
});
|
||||
|
||||
test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
|
||||
const c = await setup(t);
|
||||
c.state.answer = { id: T, instance: 'bean' };
|
||||
const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
|
||||
assert.equal(answer.status, 200);
|
||||
assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
|
||||
assert.equal(answer.data.instance, 'bean');
|
||||
});
|
||||
test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
|
||||
const c = await setup(t);
|
||||
for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
|
||||
const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
|
||||
assert.equal(answer.status, 400, JSON.stringify(value));
|
||||
}
|
||||
assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
|
||||
assert.deepEqual(c.calls, []);
|
||||
});
|
||||
@@ -294,9 +294,14 @@ 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", "federation"],
|
||||
["configVersion", "environment", "dataRoot", "execution", "console", "federation"].concat(["compactionReadBytes", "compactionStreams"]),
|
||||
"configuration",
|
||||
);
|
||||
|
||||
@@ -349,6 +354,8 @@ async function validate(document, file) {
|
||||
},
|
||||
...(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");
|
||||
|
||||
@@ -263,6 +263,23 @@ EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n
|
||||
[ "$(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