Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f4fa4f939 | ||
|
|
691235ad57 | ||
|
|
8d209016b6 | ||
|
|
1ac76a4177 | ||
|
|
e36bce3526 | ||
|
|
17a48a7782 | ||
|
|
e012bab0be |
No files matched your search
@@ -5345,10 +5345,6 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
|
||||
|
||||
2026-10-22 18:11:58 UTC | Gander row276 #1781 prepared, gate pending | Before: row253 left5legacy Case1 AS_USER calls unsupervised across sudo pty sessions. After: candidate uses its unchanged supervisor with per-call inner/outer budgets, separate streams, strict display-only failure trailer, fake-only paired process-model cases and15unique mutants. Duckworth0182 B1 corrected the plan before product code;0183 cleared build. Parse-only Bash checks pass; no Node, behavioral test, host, unit or config action. Runtime gate and publication await Sage.
|
||||
|
||||
2026-10-22T22:51:32Z | Megavolt | Row 207 (193c, #1705): rebased onto 01983cd3f as 712f79c0a; gate green under sage-3041 (access.test 31/31, M1-M8 killed on named tests, cli 894/894, ten scripts under mos-1275 limits). Correction: the first run's kill check grepped TAP "not ok" against node's spec output and was stopped and rerun. Runs note in 9bb406c6c.
|
||||
|
||||
2026-10-22T22:51:32Z | Sage | Row 207 (#1705, Megavolt, Duckworth) LANDED c4f77373f. Before: refactor at f0e3dcdf4; mosaic access had no user verbs. After: fast-forward of 4 commits, a cherry-pick of the gated 01983cd3f..9bb406c6c (range-diff 4/4 equal, patch-ids equal, the 4 manifest blobs equal; the base moved only by the #1797 brief, which no test reads; no open branch touches the manifest). Duckworth APPROVE c30849 rev 3356; done rev after the note. N1 (any-business refusal untested) DEFERRED. No live change. Correction: my first 207 review request ran under timeout 120, which killed it while it wrote the outcome; the comment (c30841) had posted, so I removed the dead lock and resolved it at rev 3350.
|
||||
|
||||
2026-10-22T23:03:39Z | Gyro Q195 O2 rewrite, #1752 / Vikunja #448, mos-1356 | Before: landed O3 design 95e3f7166/blob 4cb59999. After: Q231/Q233 full WordCraft O2 design on gyro/editor-o2 from refactor c4f77373f; canonical git DOCX, original Markdown/wiki preservation, human cadence default 0 with instant accepted-save commits, full rich-feature first slice. Ten briefs include independent source/dependency security clearance before any build queue entry, exact editor-only CSP approval gate, restricted source/build/bridge, separate reviewed DOCX citation gate and flags-only agent access. Existing Case 2 semantics unchanged; any integration requirement change goes through Mos to Jason. Static checks: ten briefs/seven fields, independent reviewers, unique anchors, current source/base/blob pins, docs scope and append-only logs pass. No builds, browser/worker runs, code, queue or live changes. Fenton review pending; CSP and implementation gates remain closed.
|
||||
|
||||
2026-10-22T23:03:53Z | Gyro Q195 O2 push guard | CI queue guard identity gyro / purpose push returned exit 0, documented degraded-pass: CANNOT_ASSERT credential-unresolvable. Audit: /mnt/storage/scratch/gyro-q195-ci-queue-audit.jsonl. No credential fallback or CI-green claim.
|
||||
@@ -5363,10 +5359,6 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
|
||||
|
||||
2026-10-22T23:13:48Z | Gyro mos-1359 / r3 push guard | D13 records save-group timing as Mos's accepted reading of Q226 F41, not Jason's ruling; both defaults remain human-only settings. Push guard identity gyro/purpose push: exit 0 documented degraded-pass, CANNOT_ASSERT credential-unresolvable; audit gyro-q195-ci-queue-audit.jsonl, no credential fallback or CI-green claim.
|
||||
|
||||
2026-10-22T23:23:42Z | Rocko, W4 #1799 | Before, D24-D26 had no separate exact DOCX citation/access contract. After, 15e91538f proposed a versioned schema, parser/story/view/refusal rules, live authorization/sharing and generic/git/session bypass closure, compatibility gates and 22 acceptance rows. Two JSON examples parse, diff check clean; no code or implementation acceptance. Megavolt review pending.
|
||||
|
||||
2026-10-22T23:55:11Z | Sage, W4 #1799 docs landing | Megavolt reviewed four rounds (CHANGES 15e91538f, 019a0ea5b, 54246cb32; APPROVE 994c3f7fb, #1799 c30868/c30870/c30876/c30884). Landed here as cherry-picks: docs/plans/2026-10-22_q195-docx-citations.md blob 2af460ab7 and docs/reviews/2026-10-22_q195-docx-citations.md blob bedde1716, both new files. 32 acceptance rows go to W4b; R4-N1 (re-migration after a pre-W4b restore keeps the rotated fence and needs owner confirmation) goes into W4b's brief and A32. W4 is design-approved only: its gate still needs Sage's W4b implementation brief, which waits for S0 (#1798) PASS. Q36 open, adoption closed. Docs only, no test reads these files.
|
||||
|
||||
2026-10-23T00:52:58Z | Gyro Q235-Q237 policy rewrite started, Vikunja 571, mos-1366/1367 | Before: approved O2 e36bce352 keeps Markdown originals at their paths and Q36 notices open; landed W4 2af460ab7 has the same boundary. Imported that byte-identical W4 baseline as 1ac76a417, from refactor 062646487, to make the policy-only revision diff explicit. Prepared P1-P6/B1-B4: human one-way per-story import, human-set source-repo archive, DOCX current reader, scoped format map/archived non-canon reads, W7 conversion publication after W4b/approved PRD/10-27 and Jason first-real-book go. Q234 exact CSP, mos-1363 and S0 M1-M14 retained. W4 locator/extractor, shares and restore-fence blocks compare byte-identical to the approved baseline. Scope/source review ongoing; Mos impact map must be folded before Fenton handoff. No build/browser/worker run, code, queue, private-book or live action.
|
||||
|
||||
2026-10-23T01:37:07Z | Gyro Vikunja571 final impact-map / mos-1372/1375 fold | Prepared parent/W4 rewrite against brain-redux d183246 impact map at stack 062646487. P1-P6/B1-B4 and engineering Q1-Q16 are mapped to eleven briefs. D9 replaces the earlier private O2-only context/per-document/share model: normal Case 2 access in any authorized thread/room, supporting writes intact, changed policy awaits PRD 1.17 approval. New DOCX story creation has the same opening gate as human per-story import. Host-only push to human-set off-host remote replaces the no-remote ban; remoteURL defaults unset and same-host bare is disallowed. W7 gates on Hermes #423 step5, W4b, PRD approval, post-10-27 and first-real-book go; owns conversion events COUNTED/--events/pre-conversion restore reconciliation, manifest order/next_chapter, creative role cite wording, archive/old-path edits and format/link/citation routing. Store+off-host acknowledgment precedes archive commit/push, then catalogue publication; post-push failures remain resumable pending with pinned source. Current checks pass: complete brief fields, policy/map coverage, exact Q234 CSP, locator/projection mechanics byte-equivalent except explicit authority wording, A01-A35 IDs, immutable map pin, four-doc scope and append-only logs. W4 authority/remote/recovery sections reopened for Fenton; older APPROVE not reused. No product/build/browser/worker/private-book/queue/live action. Earlier entry describing preserved shares/fence applies only to the pre-D9 intermediate draft; D9 now withdraws those unimplemented structures.
|
||||
|
||||
@@ -1,669 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/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)
|
||||
@@ -1,790 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/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,297 +0,0 @@
|
||||
# Row 294 build notes (Launchpad): FED R6, Console granted-user sign-in (#1800)
|
||||
|
||||
Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: FED R6, Console granted-user sign-in".
|
||||
Design: agents/gizmoduck/work/federation/DESIGN.md at b93f098f0 (r4.11, sha256 1bcee8df…c8e6),
|
||||
sections 3.1, 3.4, 3.7 and the R6 row of section 7. Assigned in sage-3136. Reviewer Duckworth.
|
||||
|
||||
## Base and chain
|
||||
|
||||
Branch launchpad/row294 on origin/refactor 253346e3c (270+271, schema 3n in packages/bus, and 571).
|
||||
Parents done: 192 (#412 phase 1), R1 (201), R2, R3 (274). Row 208 and R7 (296) wait on this row.
|
||||
|
||||
## Plan
|
||||
|
||||
### What the Console does today
|
||||
|
||||
- `sso.mjs` callback: a `sub` that is not in `console.sso.providers[].subjects` refuses
|
||||
`subject-unknown`. A session record is `{provider, iss, sub, name, ...}` and every signed-in
|
||||
request runs as the business human.
|
||||
- `serve.mjs`: every bus route, chat route and write uses objects built once in `cli.mjs` over the
|
||||
human transport (`busCaller` over `HUMAN_CLI`).
|
||||
- `accessContextFor` (packages/cli/src/access.mjs:62) returns `peers: []`.
|
||||
|
||||
### Build items
|
||||
|
||||
1. **Granted-user sign-in (sso.mjs).** `createSso` takes an optional `users` hook:
|
||||
`users.find({provider, sub})` returns a user id with a live grant, or null. The callback asks it
|
||||
only after the owner subjects miss, so the owner path is unchanged. A user session record carries
|
||||
`user: <id>` and `name: <id>`. A miss on both refuses `subject-unknown`, as today (the log line
|
||||
names the provider, iss and sub; no new code is needed for the refusal).
|
||||
2. **The access hook (new `packages/webui/src/access.mjs`).** `consoleAccess({ load, business })`:
|
||||
- `load` is `() => loadAccess(accessFilePath(configDir()), accessContextFor(system, env))`, R1's
|
||||
loader, called on every use, never cached.
|
||||
- `find({provider, sub})`: the user whose `subjects` hold that pair, with at least one grant in
|
||||
the Console's business that `covers` at level `viewer` (the bus's own liveness test,
|
||||
`#userGrants`). A loader throw finds nobody (fail closed).
|
||||
- `check(session)`: the same test for the session's user and its own `(provider, sub)`. A user
|
||||
whose subject was removed, whose grants were all removed or expired, or whose file now refuses
|
||||
fails it.
|
||||
- `business` resolves as `busCaller` does: `--business`, else the live host state.
|
||||
3. **Per-request re-read (sso.mjs `session()`).** For a user session, `users.check(session)` runs
|
||||
after the expiry check, before any route. A failure deletes the session record and logs
|
||||
`sso refused code=user-revoked provider=<id> user="<id>"`. The request then goes the
|
||||
signed-out way: 401 (`sign-in required`) for /api/ and writes, or the sign-in redirect for a
|
||||
page GET. The cookie is not cleared on that response; it names a session that no longer exists,
|
||||
so it signs nothing in, and the next sign-in replaces it.
|
||||
4. **A user-cli transport per session (new user-bus.mjs, cli.mjs).** `userCall({ socket,
|
||||
business, user })` spawns `USER_CLI` (packages/bus/src/user-cli.mjs) as a
|
||||
direct child of the Console with `MOSAIC_BUS_USER_NONCE` set, so user-cli does not re-exec and
|
||||
the broker's parent check (principal.mjs: parent argv[1] is webui/src/cli.mjs, argv[2] `serve`)
|
||||
sees the Console. The request on stdin is `{business, user, verb, args}`. It never sets
|
||||
`MOSAIC_BUS_CLI_NONCE` (it strips one from the inherited env) and never runs `HUMAN_CLI`.
|
||||
`startServer` takes `userOptions(session)`, a factory that builds the whole bus-backed option
|
||||
set (`consoleBus`, new console-bus.mjs) over that session's user transport, per request.
|
||||
5. **Owner-only controls hidden (serve.mjs and a user page).** A user session reaches only:
|
||||
- the chat routes (`/api/chat/*`), over its own user transport, and
|
||||
- `GET /api/config`, `/healthz`, `/auth/*`, and the user page's static files.
|
||||
Every other path answers 403 `owner-only` before any bus call. Behind that gate, a user request
|
||||
never holds a human-transport object: the handler swaps every bus-backed option (`bus`, `chat`,
|
||||
`send` and each write, `processing`, project files) for the same object built over that
|
||||
session's user transport, and nulls the ones with no bus behind them (`reads`, the board proxy),
|
||||
so with the gate removed an owner route still reaches the broker only as the user, or answers
|
||||
`not-configured`. `GET /` serves a separate page,
|
||||
`user.html` with `user.js`, that renders only projects, threads and messages (no board, inbox,
|
||||
agents, settings, editor, processing or Sign-in-as-owner controls). The owner page's assets are
|
||||
not served to a user session. The gate is one function, `userRoute(path, method)`, so the M1
|
||||
mutant removes one call.
|
||||
6. **The notice.** `user.html` holds row 193's sentence word for word in the thread view:
|
||||
"The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and
|
||||
project files, but not your messages."
|
||||
7. **Hub label and peer links.** `/api/config` returns `federation: {self, peers: [{label,
|
||||
origin}]}` from `system.federation` when set, for owner and user sessions alike. Both pages show
|
||||
"This Console: <self>" and one plain `<a href="<origin>">label</a>` per peer, built with
|
||||
textContent and `setAttribute('href')`; no script, no fetch to a peer, no proxy (the hub proxy
|
||||
route is R8's).
|
||||
8. **F8 peers wiring.** `accessContextFor` returns
|
||||
`peers: (system.federation?.peers ?? []).map((p) => p.id)`, and the ACCESS_USAGE text and the
|
||||
function's comment drop "peer homes are not read yet".
|
||||
9. **Bus boot wiring (packages/cli/src/config.mjs).** `bootConfig` adds
|
||||
`access: { file: accessFilePath(configDir(env)), context: accessContextFor(system, env) }` when
|
||||
the file exists (lstat). No file: no `access` key, so no user binds, as before R6. A context
|
||||
that throws: a warning and no `access`. A file the loader refuses at boot (sage-3153): one
|
||||
warning line, which the host writes to stderr as `mosaic-bus: warning: access file refused at
|
||||
boot (<code>), no granted user binds until it is fixed: <path>`. The access stays wired, so
|
||||
the broker refuses each user bind and the owner's bus boots. R1's loader has no refusal codes,
|
||||
and its messages can quote the file (a JSON parse error, a `got "<value>"`), so the line never
|
||||
carries the message. The code comes from the loader's exit code instead: 4 (the file could not
|
||||
be opened as a regular file, including a symbolic link) is `access-unreadable`, and anything
|
||||
else is `access-invalid`.
|
||||
config.mjs now imports access.mjs, which imports config.mjs (`loadSystem`). The cycle is safe
|
||||
because neither module touches the other's exports at module top level, only inside
|
||||
functions (sage-3153).
|
||||
|
||||
### Choices and departures (for Sage and Duckworth)
|
||||
|
||||
- **C1. A separate user page, not the owner SPA with sections hidden.** "Hidden" here means never
|
||||
sent: the owner page's markup and scripts are not served to a user session. Hiding sections in
|
||||
app.js would ship the owner controls and rely on CSS. Tradeoff: a second small page to keep.
|
||||
- **C2. The Console's gate is an allowlist of user routes**, so a new owner route added later is
|
||||
owner-only by default.
|
||||
- **C2b. No human transport behind a user session.** Without the swap, M1 would send
|
||||
`/api/bus/message` through the owner's `send` over human-cli, and the bus would accept it as the
|
||||
owner. The swap is what makes "the bus refusal stands on its own" true.
|
||||
- **C3. Liveness is the bus's test** (`covers` at `viewer`, in the Console's business), so the
|
||||
Console and the broker agree on who is live. The broker still decides every verb, project,
|
||||
instance and thread (design 3.1 D4).
|
||||
- **C4. The bus's `access` boot option is wired here (sage-3142, Q2; accepted, sage-3153).** Build item 9 above.
|
||||
Sage's ruling: with the file, users bind; without it, none; with a bad file, it "refuses as R1's
|
||||
loader does"; a boot never invents a grant. My reading of "refuses": the broker's `access` is
|
||||
R1's loader, read on every user request, so a bad file refuses every user bind
|
||||
(`access-revoked`) and the owner's bus still boots. Refusing the whole boot would take the
|
||||
owner's bus down over a user file. Tests B3 pin this reading. No live unit or config changes.
|
||||
- **C5. Tests use an in-process Broker behind the user transport seam.** The broker's user
|
||||
principal needs the Console as the user-cli's parent process, which a node:test process is not.
|
||||
So the webui tests inject a `userOptions` factory over a real `Broker` with `bindUser` and an
|
||||
`access` function, and a separate transport test checks the spawn: the `USER_CLI` path, the user
|
||||
nonce variable, no human nonce, and the `{business, user, verb, args}` request. R7 runs the real
|
||||
Console process end to end.
|
||||
|
||||
- **C6. The chat's `plugins` read goes over the owner transport (accepted, sage-3153).**
|
||||
`createChat` asks the bus `plugins` first (createChannelCaller), and `plugins` is not a user
|
||||
verb, so a user chat would refuse before its first read. `createChat({ call, owner })` sends
|
||||
`owner` exactly `plugins` with `{}`, and every other verb goes over `call`. consoleBus passes
|
||||
`owner` through, and cli.mjs gives it `ownerCall`. createChannelCaller
|
||||
(plugins/src/channel.mjs:16-20) uses the answer only to gate the webui channel, server-side.
|
||||
Sage's conditions, each pinned by G9:
|
||||
- the plugins answer never appears in a user's response;
|
||||
- in a user's chat the owner transport carries only `plugins` with `{}`;
|
||||
- every chat verb goes over the user transport.
|
||||
Mutant M8 routes one more chat verb over the owner transport, and G9 kills it. A new broker
|
||||
user verb would have been a policy change, so it is out.
|
||||
- **C7. The user transport is a new file, user-bus.mjs, not a mode in bus.mjs.**
|
||||
bus/tests/channel-proof.test.mjs pins the sha256 of webui/src/bus.mjs (with human-cli.mjs,
|
||||
human.mjs and cli/src/transport.mjs). Changing bus.mjs would mean an old-test change through
|
||||
Mos. user-bus.mjs repeats the spawn and framing code (about 40 lines). bus.mjs is byte-identical
|
||||
to the base. Tradeoff: two copies of the spawn plumbing. Accepted (sage-3153).
|
||||
**Follow-up (not this row):** the next row that re-pins the channel-proof hash folds userCall
|
||||
back into bus.mjs and drops the copy.
|
||||
- **C8. console-bus.mjs.** The bus-backed option set moved out of cli.mjs into `consoleBus(call,
|
||||
{send, plugins})`, so the owner and each user session build the same set the same way. The
|
||||
agent verb lists and `agentCaller` moved with it; the tester socket still builds its own.
|
||||
- **C9. 401 and 403 from the broker.** chat-http's STATUS maps `access-refused` to 403 and
|
||||
`access-revoked` to 401, so a broker refusal reaches the browser as the right status.
|
||||
|
||||
## Exception: the owner transport in a user's chat (C6; mos-1394, sage-3157)
|
||||
|
||||
Duckworth rules on this directly. The same text goes in the manifest notes.
|
||||
|
||||
> In a granted user's chat the Console uses the owner's transport for one call: `plugins` with
|
||||
> `{}`, the channel gate read. Nothing else goes over it. Its answer stays in the Console and is
|
||||
> never sent to the user. No value from the user's session or request reaches the call. Mos ruled
|
||||
> in mos-1394 that this is not an access-rule change, because the Console reads its own channel
|
||||
> gate, and that it holds only while nothing from the user reaches the call. Every chat verb goes
|
||||
> over the user's own transport. Where: createChat's `owner` (chat.mjs) and userOptionsFor
|
||||
> (console-bus.mjs), wired with `ownerCall` in cli.mjs. Pinned by G9 and mutants M8, M10 and M11.
|
||||
> If this reads as owner authority used for the user, it goes back to Mos.
|
||||
|
||||
## Rulings (sage-3153)
|
||||
|
||||
1. Boot wiring is accepted: a bad file refuses every user bind and the owner's bus boots. Added:
|
||||
one stderr line at boot naming the path and a code, never the contents (item 9, B3, M9). The
|
||||
mutual import is fine with no top-level use (item 9).
|
||||
2. C6 is accepted under conditions: the plugins answer stays server-side, the owner transport
|
||||
carries only `plugins` with `{}`, one G test (G9) records both transports, and a mutant (M8)
|
||||
routing one more chat verb over the owner is killed.
|
||||
3. C7 is accepted, with the follow-up note under C7.
|
||||
4. Owned files are accepted. packages/cli/src/access.mjs overlaps Morgana's row 295 (527002f97),
|
||||
and merge-tree is clean. 294 lands first and 295 rebases after it. packages/cli/tests/
|
||||
access.test.mjs is now unchanged: the peer test moved to access-boot.test.mjs, so the only
|
||||
overlap is access.mjs.
|
||||
|
||||
Ruling sage-3157 (mos-1394): the boot line is accepted as built, with no codes from R1. For C6,
|
||||
G9 asserts the owner call is exactly `plugins` with exactly `{}`, and that no value from the
|
||||
session or the request reaches it. Mutants M10 (session value) and M11 (request value) are killed
|
||||
by G9. The exception is written above and goes in the manifest notes. The per-session builder
|
||||
moved into console-bus.mjs (`userOptionsFor`), so the wiring G9 runs is the same code cli.mjs
|
||||
uses, and a session value added there is a mutant G9 can kill.
|
||||
|
||||
## Owned files
|
||||
|
||||
- cli: src/config.mjs, src/access.mjs, tests/access-boot.test.mjs (new)
|
||||
- webui src: sso.mjs, access.mjs (new), console-bus.mjs (new), user-bus.mjs (new), chat.mjs,
|
||||
chat-http.mjs, cli.mjs, serve.mjs
|
||||
- webui public: user.html (new), user.js (new), federation.js (new), index.html, app.js
|
||||
- webui tests: granted-user.test.mjs (new)
|
||||
- this BUILD.md
|
||||
|
||||
No old test changes. Hash pins checked: `git grep` over 'packages/*/tests/*' for each touched
|
||||
path with a 64-hex pin found only channel-proof's bus.mjs pin, which C7 avoids.
|
||||
|
||||
## Tests
|
||||
|
||||
New file packages/webui/tests/granted-user.test.mjs, on Sage's fake OP (tests/helpers/fake-oidc.mjs)
|
||||
and an in-process Broker with an access function, like bus/tests/user-principal.test.mjs:
|
||||
|
||||
- G1 a granted user signs in: session cookie set, `/api/config` says `user`, `GET /` is the user
|
||||
page, the chat lists reach the broker as `user:<id>`.
|
||||
- G2 an ungranted `sub` is refused: a user in the file with no grant, a grant for another business,
|
||||
an expired grant, and a sub in no file; each is `subject-unknown`, no cookie, nothing bound.
|
||||
- G3 revoked mid-session: sign in, then remove the grant, remove the subject, expire the grant,
|
||||
or make the loader refuse. The next chat read is 401 with a `user-revoked` log line and no
|
||||
transport call. Restoring the grant does not bring the session back, and `GET /` redirects to
|
||||
sign-in.
|
||||
- G4 owner verbs: for each owner route (board reads under /api/bus/*, settings, names, instances,
|
||||
routes, agents, processing, project files, /api/bus/message, /api/board), a user session gets
|
||||
403 `owner-only` and the transport ran zero times.
|
||||
- G5 the bus refusal stands alone: the same session's user transport, called directly with each
|
||||
owner verb, gets `access-refused` from the broker and nothing is written.
|
||||
- G9 the C6 seam (sage-3153, sage-3157), with the test's userOptions built by userOptionsFor as
|
||||
cli.mjs builds them: a user session runs the chat's project list, thread create, list,
|
||||
open, send and messages. The owner transport (a recording `ownerCall`) saw only
|
||||
`['plugins', {}]`. Each chat verb went over the user transport, and `plugins` never did. A
|
||||
marker in the owner's answer appears in no response. Every owner call is exactly
|
||||
`['plugins', {}]`, and none of alice, alice-sub, the thread name, the message body, the project
|
||||
id or the thread id appears in what the owner transport received.
|
||||
- G6 the notice: the user page's notice element text equals the sentence exactly.
|
||||
- G7 hub label and peer links render from config: `/api/config` carries self and the peers'
|
||||
labels and origins; the user and owner pages show "This Console: hub" and an `<a>` per peer with
|
||||
the configured href and label (browser test, sso-browser.test.mjs style).
|
||||
- G8 the transport: `userCall` over a recording unix socket in a scratch dir. While the child
|
||||
waits for the answer, the test reads /proc/<pid>/cmdline (argv[1] is USER_CLI), environ
|
||||
(MOSAIC_BUS_USER_NONCE set; MOSAIC_BUS_CLI_NONCE absent though the parent env passed one) and
|
||||
stat (ppid is the test process, so user-cli did not re-exec). The request line is
|
||||
`{verb, args, user: {business, user, nonce, pid}}`, which user-cli builds from
|
||||
`{business, user, verb, args}`.
|
||||
|
||||
packages/cli/tests/access-boot.test.mjs (sage-3142), over bootConfig and startBroker in a scratch
|
||||
root, the boot config through a JSON round trip as over the host IPC:
|
||||
|
||||
- B1 with access.json: `access.file` and `context.peers` set; alice (home a configured peer)
|
||||
binds; bob gets `access-revoked`.
|
||||
- B2 without the file: no `access` key; alice gets `access-revoked`.
|
||||
- B3 with text that is not JSON, a home that is not a configured peer, and a symbolic link: the
|
||||
bus boots and alice gets `access-revoked`. The warnings are exactly one line, with
|
||||
`access-invalid`, `access-invalid` and `access-unreadable` and the path, and none quotes the
|
||||
file (a marker string in the file is absent from the line).
|
||||
|
||||
access-boot.test.mjs also holds the F8 peer test: a grant whose user's home is a configured peer id
|
||||
validates; an unconfigured peer id refuses `home must be a configured peer`.
|
||||
|
||||
Old tests: the sso.test.mjs and serve tests stay as they are. If one needs a change, it goes to
|
||||
Mos through Sage first (mos-1272).
|
||||
|
||||
## Mutants
|
||||
|
||||
| # | Change | Killed by | Expected |
|
||||
|---|---|---|---|
|
||||
| M1 | remove the `userRoute` gate call in serve.mjs | G4 (:239, first route answers 403 `access-refused`, not `owner-only`) | G4 red at :239 on its first route; G5 green in the same run |
|
||||
| M2 | skip `access.check(session)` per request | G3 | red |
|
||||
| M3 | change one word of the notice in user.html | G6 | red |
|
||||
| M4 | `peers: []` back in accessContextFor | the F8 peer test and B1 (access-boot.test.mjs) | red |
|
||||
| M5 | sign-in skips the grant test (any listed user signs in) | G2 | red |
|
||||
| M6 | userCall sets MOSAIC_BUS_CLI_NONCE or runs HUMAN_CLI | G8 | red |
|
||||
| M7 | bootConfig drops `access` | B1 | red |
|
||||
| M8 | chat.mjs routes `thread.list` over `owner` too | G9 | red |
|
||||
| M9 | accessFor drops the boot refusal line | B3 | red |
|
||||
| M10 | userOptionsFor passes `owner: (v, a) => owner(v, { ...a, sub: session.sub })` | G9 | red |
|
||||
| M11 | chat.mjs keeps the last request's args in `channel()` and passes them to `owner('plugins', …)` | G9 (thread create's `{project, name}` reaches owner) | red |
|
||||
|
||||
M1 is the brief's "owner verb still refused by the bus": with the Console gate gone, the owner
|
||||
verb reaches the broker over the user transport and the broker refuses it (`access-refused`, 403).
|
||||
G4 kills M1 at :239 on its first route, because it pins the Console's own code `owner-only`. The
|
||||
count and "nothing written" asserts at :242-244 come after the loop, so under M1 they never run;
|
||||
the run records G4's failure with the actual value (`access-refused`). The bus refusing on its own
|
||||
is shown by G5, green in the same M1 run. (Corrected per Duckworth C1, duckworth-0249b; the earlier
|
||||
row said G4's "refused, nothing written" stayed green, which cannot be observed under M1.)
|
||||
|
||||
## Run limits
|
||||
|
||||
Each `timeout -k 10 LIMIT`, TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`. A timeout with
|
||||
no failed test goes to Sage first; a timeout in the shell phase goes to Mos unplaced (mos-1275).
|
||||
|
||||
| Run | LIMIT |
|
||||
|---|---|
|
||||
| each touched or new file alone | 300 s |
|
||||
| webui suite | 1800 s |
|
||||
| bus suite | 900 s |
|
||||
| cli suite | 1800 s |
|
||||
| each scripts/test-*.sh | 900 s |
|
||||
| each mutant probe | 300 s |
|
||||
|
||||
## Review
|
||||
|
||||
Round 1, manifest 0b3f9c9d…94f3 (ca710eabd): Duckworth APPROVE (static), duckworth-0249b, note
|
||||
agents/duckworth/work/row294/ROUND1-ca710eabd.md on duckworth/reviews 5b7089ea1. The owner-transport
|
||||
exception is accepted and does not go to Mos.
|
||||
|
||||
- C1: the M1 row and note above are corrected. The M1 run records G4's :239 failure value and G5.
|
||||
- C2: the gate runs on Sage's CLEAR.
|
||||
- N1 (no test reaches the C2b swap): no M12 in this row. Adding one needs a new test and a new
|
||||
manifest round after approval; the brief does not require it. Left as a follow-up for Sage to
|
||||
place: a test that a user session's options carry no owner caller besides `plugins`, with an M12
|
||||
where serve.mjs keeps the owner's `send` for a user session.
|
||||
- N2, N3: accepted as they stand; no change.
|
||||
- N4: the user-bus.mjs fold-back (C7) is proposed to Sage as a queue row, for the next
|
||||
channel-proof re-pin.
|
||||
|
||||
## Runs
|
||||
@@ -1,31 +0,0 @@
|
||||
# Row 294 (FED R6, #1800) candidate manifest, round 1
|
||||
|
||||
Branch launchpad/row294, base 253346e3c, pushed tip 9450774cbe7986d80150b27a4da33b43d93bf026.
|
||||
MANIFEST.sha256 lists the 17 code and test paths changed from the base, hashed from the tip's
|
||||
blobs (`sha256sum -c` passes on the tip). BUILD.md is the row note and is not in the manifest.
|
||||
No old test changes. webui/src/bus.mjs is byte-identical to the base (channel-proof hash pin, C7).
|
||||
packages/cli/tests/access.test.mjs is unchanged; packages/cli/src/access.mjs overlaps row 295,
|
||||
which rebases after this row (sage-3153).
|
||||
|
||||
Nothing has run. Runs follow Duckworth's approval and Sage's test CLEAR, in slot order: each
|
||||
touched file alone, mutants M1-M11, the webui, cli and bus suites and every scripts/test-*.sh,
|
||||
with the limits in BUILD.md.
|
||||
|
||||
## Exception for Duckworth to rule on: the owner transport in a user's chat (C6; mos-1394, sage-3157)
|
||||
|
||||
> In a granted user's chat the Console uses the owner's transport for one call: `plugins` with
|
||||
> `{}`, the channel gate read. Nothing else goes over it. Its answer stays in the Console and is
|
||||
> never sent to the user. No value from the user's session or request reaches the call. Mos ruled
|
||||
> in mos-1394 that this is not an access-rule change, because the Console reads its own channel
|
||||
> gate, and that it holds only while nothing from the user reaches the call. Every chat verb goes
|
||||
> over the user's own transport. Where: createChat's `owner` (chat.mjs) and userOptionsFor
|
||||
> (console-bus.mjs), wired with `ownerCall` in cli.mjs. Pinned by G9 and mutants M8, M10 and M11.
|
||||
> If this reads as owner authority used for the user, it goes back to Mos.
|
||||
|
||||
## Other choices
|
||||
|
||||
C1-C9 and rulings sage-3142, sage-3153 and sage-3157 are in BUILD.md. In short: a separate user
|
||||
page (C1); an allowlist gate of user routes (C2); every bus-backed option swapped to the user
|
||||
transport behind the gate (C2b); liveness by the broker's own `covers` test (C3); the bus boot
|
||||
`access` wiring, where a bad file refuses each user bind and writes one stderr line with the path
|
||||
and a code, never the contents (C4, item 9); and user-bus.mjs as a copy of the spawn code (C7).
|
||||
@@ -1,17 +0,0 @@
|
||||
52206bd92b0f223bf9310d50db650f36e12836691d739b499192e3eedd81401c packages/cli/src/access.mjs
|
||||
a948886a8c57d700b3d116097c9597c2b29dc7cc89ff6e18ae57436754195853 packages/cli/src/config.mjs
|
||||
abb5ccb77b8d5c97c81e84fb1fb32367bf69890e8ea1f1912d739a3dd1678738 packages/cli/tests/access-boot.test.mjs
|
||||
1be61ba66c3b747decdb3e12de743265b8cc7ba7cd06c033393b157ce1bc0b70 packages/webui/src/access.mjs
|
||||
954dee1535b258d9590e4d102423d081303b640b3bea750bd3653883e67bf309 packages/webui/src/chat-http.mjs
|
||||
230a5759f5027f9208c716b176f9b0a30310d5f433493bfcd8862c71abe182ae packages/webui/src/chat.mjs
|
||||
7fcb28a7a67418a8d3fd26dd157860ec1a89fe1891887f661e53a17d3b3bbc7d packages/webui/src/cli.mjs
|
||||
941c8f91ad40264c5622d60af85e00f6198db31a0504b4b0caa48312d8f57c3e packages/webui/src/console-bus.mjs
|
||||
692c5eabb0fc866240b12931418c17d3392b6097575dfacf7425a6f0127dd291 packages/webui/src/public/app.js
|
||||
c41a34a32f23f742e30ca408bc7deda0fa8feb4f8c25c7036604c336bb1545eb packages/webui/src/public/federation.js
|
||||
e7673188c1d76ecc63cdc24e7d1470922b1d69bedeb994a862e94924edfc27d2 packages/webui/src/public/index.html
|
||||
969a52284b667e075b04e3fa63d45415f3517080784ee372ee4725caf51d15d9 packages/webui/src/public/user.html
|
||||
571ce191c0b5f545e3a65f7b388f754e2ca6396a68038ccc57321d8f490cb86e packages/webui/src/public/user.js
|
||||
9343ea9edb862c1545df2886754244ffd76b714cc90d7791fc32aee4ecfec594 packages/webui/src/serve.mjs
|
||||
70b243ed9a83ff822a4c7a148528ff5153574cc87d5c776fd2e12f340762ed76 packages/webui/src/sso.mjs
|
||||
8cc1185beda9085ced62470f1b9e39d95dcca2dcc8bf9325c18c2f0b2da618dc packages/webui/src/user-bus.mjs
|
||||
8f89a1cc853ad275e62a518eb4f5567b5aaa629d32060c11daaaa671aded29db packages/webui/tests/granted-user.test.mjs
|
||||
@@ -1,310 +0,0 @@
|
||||
# Row 203, FED R2: the federation key in the system config (#1701)
|
||||
|
||||
Owner Quackerjack, reviewer Webby, gate owner Sage. Brief: docs/plans/2026-10-20_federation-r1-r2-r5.md,
|
||||
"Brief: FED R2". Design: DESIGN.md section 3.1 (D1), section 4 (the key shape), section 7 row R2, at
|
||||
gizmoduck/federation-design b93f098f0.
|
||||
|
||||
Base: aca16404c, rebased onto f2735bb6a (origin/refactor, shared object store). Rebased onto 253's tip
|
||||
only if 253 lands first, under a new branch name.
|
||||
|
||||
## Commits
|
||||
|
||||
- 0eb4b6f1f, rebased to e704a18ae: the loader, the first FED R2 tests, and these notes.
|
||||
- Second commit on top of e704a18ae (sage-2998): B1 and B2 below, the fixture fix, and the notes
|
||||
rewrite. Its sha goes in the status line once committed.
|
||||
|
||||
## Shape
|
||||
|
||||
```json
|
||||
"federation": {
|
||||
"self": "hub",
|
||||
"peers": [{ "id": "factory", "origin": "https://mosaic.woltje.com", "label": "Software factory" }]
|
||||
}
|
||||
```
|
||||
|
||||
- Optional. Absent: the config loads and prints what it printed before (no `federation` in the output).
|
||||
- `self` and each `peers[].id` match `^[a-z][a-z0-9-]{0,31}$`. A peer id may not equal `self` or another peer's id.
|
||||
- `peers[].origin` must be an https origin exactly as `new URL(origin).origin` returns it: no path, no query,
|
||||
no userinfo, no trailing slash.
|
||||
- `peers[].label` is a non-empty string of at most 80 characters.
|
||||
- Labels and URLs only. Nothing here grants trust to a peer (section 3.1 D1, section 3.2 W1).
|
||||
|
||||
## Readers of the key
|
||||
|
||||
Two readers read the raw key. Nothing else reads it (`git grep "federation"` at e704a18ae):
|
||||
|
||||
1. The loader, `scripts/mosaic-config.mjs` (`validate`, the `federation` branch). This is the only reader of
|
||||
the validated value. Its output is read by the CLI and business code, which read named fields only.
|
||||
The output is checked in `scripts/test-config.sh`.
|
||||
2. The stack-release probe, `config_checks` in `scripts/stack-release.sh` (B1, below). It reads the config
|
||||
file itself, before a copy is activated or rolled back to, and asks whether the target copy's loader
|
||||
refuses a key the config holds.
|
||||
|
||||
Stale wording, NOT changed in this row (Sage rulings sage-2808 and sage-2809):
|
||||
`packages/cli/src/access.mjs` is out of row 203. Megavolt's row 207 changes that file. Follow-up, to be
|
||||
carried to DEFERRED by Sage:
|
||||
- access.mjs lines 26-27 (`ACCESS_HELP` comment): "Peer homes are empty until the federation key lands
|
||||
(row 203)". Still partly true after this row, since the access context does not read the key.
|
||||
- access.mjs line 36 (inside the `ACCESS_HELP` string, user-visible help): "Peer homes are not read yet
|
||||
(row 203 adds the federation key)". The same claim, in help text. No test in `packages/cli/tests` pins it
|
||||
(`git grep` for "row 203" and "Peer homes" finds nothing in tests).
|
||||
- access.mjs line 44 comment: "peers is empty until row 203".
|
||||
- `packages/cli/README.md` line 1107: "until row 203 adds the". Outside the manifest; flagged in the
|
||||
same follow-up.
|
||||
|
||||
Behavior after this row: `accessContextFor` still returns `peers: []` (access.mjs line 58). Row 203 adds the
|
||||
config key only, so grants naming a peer home still refuse. Wiring the key into the access context is not in
|
||||
the FED R2 brief.
|
||||
|
||||
## B1: the stack-release probe (sage-2998, in scope)
|
||||
|
||||
Webby's early look (webby-0189): a config holding `federation` and no `console` passed `config_checks`, because
|
||||
the probe looked only for `console`. A rollback or activate to a pre-203 copy would then pass its checks, and
|
||||
that copy's loader refuses `unsupported configuration key: "federation"` on the next read.
|
||||
|
||||
Fix (scripts/stack-release.sh, `config_checks`):
|
||||
- The probe's node check now reads `console` or `federation` (`Object.hasOwn(d,"console")||Object.hasOwn(d,"federation")`).
|
||||
- The refusal keeps the check name `config-console-key`, so existing callers and tests do not change. The
|
||||
message now reads "holds a console or federation key". This is a wording change to an existing refusal,
|
||||
and `KEY_MSG` in `packages/cli/tests/stack-release.test.mjs` moves with it.
|
||||
- The new test is `row 203: a config whose federation key the target loader refuses ...` in
|
||||
`packages/cli/tests/stack-release.test.mjs`, after the row 200 console test. Target B's loader has no
|
||||
federation key in its list, so it refuses the key. Activate to B and rollback to B are both refused, and
|
||||
a rollback succeeds once the key is removed.
|
||||
|
||||
Fixture (packages/cli/tests/stack-release-fixture.mjs, line 300): `LOADER_OLD` builds B's loader by a string
|
||||
replace on the `rejectUnknownKeys` list. Adding `federation` to that list in this row left the replace with no
|
||||
match, and the fixture's `assert.notEqual(LOADER_OLD, LOADER)` would fail at load. The replace now targets
|
||||
`"execution", "console", "federation"]`, so B's loader refuses both keys. This regression came from my
|
||||
e704a18ae, and the fix is in this commit.
|
||||
|
||||
## B2: a case and a mutant for each refusal (sage-2998: "one mutant per refusal")
|
||||
|
||||
Webby's list is covered below. Each refusal has a case in `scripts/test-config.sh` (FED R2 block) and a
|
||||
mutant. M5 is killed by the self case; M13 covers the duplicate half of the id check that M5 did not reach.
|
||||
|
||||
## Refusals and their tests
|
||||
|
||||
`scripts/test-config.sh`, FED R2 block:
|
||||
|
||||
| Case | Refusal (exit 2) | Mutant (killed by) |
|
||||
|---|---|---|
|
||||
| valid key loads and is in the output | none | base |
|
||||
| unknown top-level sub-key | `unsupported federation key: "extra"` | M1 |
|
||||
| unknown peer sub-key | `unsupported federation.peers[0] key: "trust"` | M2 |
|
||||
| origin over http | `federation.peers[0].origin must be an https origin ...` | M3 |
|
||||
| origin with a path | same | M4 |
|
||||
| origin with userinfo | same | M4 |
|
||||
| origin with a trailing slash | same | M4 |
|
||||
| peer id equal to self | `federation.peers[0].id ... is already named` | M5 |
|
||||
| duplicate peer id | `federation.peers[1].id "factory" is already named` | M13 |
|
||||
| peer label empty | `federation.peers[0].label must be a non-empty string ...` | M6 |
|
||||
| peer label over 80 characters | `federation.peers[0].label must be ...` | M12 |
|
||||
| federation not an object | `"federation" must be a JSON object` | M7 |
|
||||
| self does not match the pattern | `federation.self must match ...` | M8 |
|
||||
| peers not an array | `federation.peers must be an array` | M9 |
|
||||
| peer not an object | `federation.peers[0] must be a JSON object` | M10 |
|
||||
| peer id does not match the pattern | `federation.peers[0].id must match ...` | M11 |
|
||||
| self missing | `federation.self must match ...` | M15 |
|
||||
| peer id missing | `federation.peers[0].id must match ...` | M16 |
|
||||
| peer label missing | `federation.peers[0].label must be ...` | M17 |
|
||||
| no federation key | loads, no federation output | base |
|
||||
| B1: target loader refuses federation | `config-console-key` refusal (stack-release.test) | M14 |
|
||||
|
||||
Mutant plan (run on Sage's gate only, one edit each, a named test must fail). Before the gate each edit string
|
||||
must occur exactly once in its file. I checked all of M1 to M17 with grep -cF: each is 1. The
|
||||
gate's mutant runner checks the count again before it applies each edit:
|
||||
|
||||
- M1: `rejectUnknownKeys(value, ["self", "peers"], "federation");` removed. Killed by "federation unknown sub-key".
|
||||
- M2: `rejectUnknownKeys(peer, ["id", "origin", "label"], where);` removed. Killed by "federation peer unknown sub-key".
|
||||
- M3: `url.protocol !== "https:" ||` removed. Killed by "origin over http".
|
||||
- M4: `|| url.origin !== value` removed. Killed by "origin with a path", "origin with userinfo" and
|
||||
"origin with a trailing slash".
|
||||
- M5: `if (ids.has(peer.id)) {` changed to `if (false) {`. Killed by "peer id equal to self".
|
||||
- M6: `peer.label.trim() === "" ||` removed. Killed by "peer label empty".
|
||||
- M7: `if (!isPlainObject(value)) {` changed to `if (false) {`. Killed by "federation not an object".
|
||||
- M8: `|| !FEDERATION_ID_PATTERN.test(value.self)` removed. Killed by "self does not match the pattern".
|
||||
- M9: `if (!Array.isArray(value.peers)) {` changed to `if (false) {`. Killed by "peers not an array".
|
||||
- M10: `if (!isPlainObject(peer)) {` changed to `if (false) {`. Killed by "peer not an object".
|
||||
- M11: `|| !FEDERATION_ID_PATTERN.test(peer.id)` removed. Killed by "peer id does not match the pattern".
|
||||
- M12: ` || peer.label.length > FEDERATION_LABEL_MAX` removed. Killed by "peer label over 80 characters".
|
||||
- M13: ` ids.add(peer.id);` removed. Killed by "duplicate peer id".
|
||||
- M14 (stack-release.sh): `||Object.hasOwn(d,"federation")` removed. Killed by the row 203 federation test in
|
||||
`stack-release.test.mjs`.
|
||||
- M15: `typeof value.self !== "string" || ` removed. Killed by "federation self missing". Without the typeof
|
||||
half, an absent self passes the pattern (`/^[a-z]…$/.test(undefined)` is true).
|
||||
- M16: `typeof peer.id !== "string" || ` removed. Killed by "federation peer id missing". Same reason.
|
||||
- M17: `typeof peer.label !== "string" || ` removed. Killed by "federation peer label missing". Without it, a
|
||||
missing label throws a TypeError at `.trim()` (exit 1), not the exit 2 refusal.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No config is written. `~/.config/mosaic-dev/config.json` is not touched. Tests use `$SANDBOX` only.
|
||||
- No change to `packages/cli/src`, `scripts/bootstrap.sh`, or any reader beyond the stack-release probe.
|
||||
|
||||
## Manifest (sha256 of committed blobs; the list is the review scope)
|
||||
|
||||
- scripts/mosaic-config.mjs
|
||||
- scripts/test-config.sh
|
||||
- scripts/stack-release.sh (B1)
|
||||
- packages/cli/tests/stack-release.test.mjs (B1 test, KEY_MSG)
|
||||
- packages/cli/tests/stack-release-fixture.mjs (B's loader fixture; without this, the suite fails to load)
|
||||
- agents/quackerjack/work/queue-203/BUILD.md
|
||||
|
||||
## Slot estimate (for Sage's gap)
|
||||
|
||||
- test-config.sh: about 5 min, padded.
|
||||
- The cli suite: about 5 min, padded.
|
||||
- stack-release.test.mjs: up to 1800 s, per Sage. It copies the loader, so it runs whole at the gate (Webby N3).
|
||||
- Mutants M1–M13 and M15–M17 each re-run the FED block of test-config.sh. M14 re-runs stack-release.test.mjs.
|
||||
The per-mutant cost is not measured: this run has not been timed.
|
||||
|
||||
## Status
|
||||
|
||||
- e704a18ae: the loader, first tests and notes, rebased onto f2735bb6a.
|
||||
- 8d34a9e94: B1, B2, the fixture fix and the notes rewrite (Mos approved the two old-test edits, mos-1346).
|
||||
- Third commit on top: B3 cases M15–M17 and these notes (sha in the message to Sage and Webby).
|
||||
- NOT YET RUN: no node during Gander's slot. The test-config.sh, cli and stack-release gates run when Sage
|
||||
announces a CLEAR. `bash -n` passes on both shell scripts.
|
||||
- Not pushed, not claimed. Per invariant 8 the gate runs before any commit is called green.
|
||||
|
||||
## Review notes (Webby, webby-0189, answered here)
|
||||
|
||||
- B1: fixed as above (scope per sage-2998). The probe's check name `config-console-key` is kept on purpose (Webby
|
||||
N5, Sage): renaming it would change callers and tests for no gain in this row. The message says "console or
|
||||
federation key"; the name is the only old word left.
|
||||
- B3 (webby-0191): missing self, missing peer id, and missing peer label each have a case and a mutant (M15–M17).
|
||||
The typeof halves are the only thing refusing an absent value, since the pattern test coerces undefined.
|
||||
- B2: a case and a mutant for each uncovered refusal, plus the trailing-slash case under M4 (N4) and the
|
||||
duplicate-peer case (M13).
|
||||
- N1: this file is rewritten with the current base and status.
|
||||
- N2: consumers checked by `git grep "mosaic-config"`. The loader's output is read by `packages/cli/src/config.mjs`,
|
||||
`packages/business/src/*.mjs`, `packages/webui/src/sso-config.mjs`, `packages/seat/src/seat.mjs`,
|
||||
`packages/discord/src/binding.mjs`, `packages/conversation/src/guard.mjs`, and the shell scripts that call
|
||||
`validate` (bootstrap.sh, common.sh, instance.sh, land-work.sh, stack-copy.sh, stack-release.sh,
|
||||
conductor-apply.sh). Checked by `git grep "federation"` over packages and scripts: no reader outside the
|
||||
loader and the stack-release probe mentions `federation`. I did NOT read each call site for which fields it
|
||||
uses; that is still open for Webby's static read. Not run.
|
||||
- N3: the cli stack-release tests copy the loader; they run whole at the gate. Listed in the manifest and
|
||||
the slot estimate.
|
||||
- N4: trailing-slash case added (see table; M4).
|
||||
|
||||
## Gate on the rebased tip (Sage sage-3055, sage-3057; claim rev 3361)
|
||||
|
||||
Brief pin: docs/plans/2026-10-20_federation-r1-r2-r5.md re-pinned to 8adfb261f (sage-203-repin). "Brief: FED R2"
|
||||
is byte-identical between b624ddd49 and 8adfb261f (Sage's diff).
|
||||
|
||||
Rebase: onto origin/refactor c7b754d7b (row 207 landed at c4f77373f on that tip). The three commits are now
|
||||
93a491d73 (loader), 284d55cf2 (stack-release probe and fixture), 7c543fd4a (B3 cases). Blob check against the
|
||||
pre-rebase candidate d5428c1a8 (`git rev-parse <commit>:<path>`), all six identical:
|
||||
|
||||
| blob | path |
|
||||
|---|---|
|
||||
| 4c26453a0 | scripts/mosaic-config.mjs |
|
||||
| 06b9d8ea2 | scripts/test-config.sh |
|
||||
| 92bd6f7d0 | scripts/stack-release.sh |
|
||||
| beb8da252 | packages/cli/tests/stack-release.test.mjs |
|
||||
| f080bf2f5 | packages/cli/tests/stack-release-fixture.mjs |
|
||||
| 6f29dbbde | agents/quackerjack/work/queue-203/BUILD.md (pre-gate-commit blob) |
|
||||
|
||||
Timeouts (Mos mos-1275: `timeout -k 10 <S>` per script, at least 3x its longest run; written here before the
|
||||
first run). Basis: row 202's gate on this machine (scratch g202gatelBKGE0): cli suite 620.3 s (duration_ms
|
||||
620297.68, EXIT=0); the ten scripts together finished in about 2.5 min, so no single script was measured on its own.
|
||||
Figures are set above 3x of those, and the stack-release.test run is set from Sage's up-to-1800 s estimate.
|
||||
They are estimates, not measured. A timeout hit is a red and goes to Mos first. Mos rules on these figures.
|
||||
|
||||
| target | timeout S (seconds) | basis |
|
||||
|---|---|---|
|
||||
| scripts/test-auth.sh | 900 | 3x of a row 202 script, padded |
|
||||
| scripts/test-conductor.sh | 900 | same |
|
||||
| scripts/test-config.sh | 900 | same; also each mutant run of the FED block |
|
||||
| scripts/test-discord.sh | 900 | same |
|
||||
| scripts/test-extension-package.sh | 900 | same |
|
||||
| scripts/test-foundation.sh | 900 | same |
|
||||
| scripts/test-land-work.sh | 900 | same |
|
||||
| scripts/test-queue.sh | 900 | same |
|
||||
| scripts/test-release.sh | 900 | same |
|
||||
| scripts/test-task.sh | 900 | same |
|
||||
| packages/cli suite | 2400 | 3x of 620 s is 1861 s |
|
||||
| stack-release.test.mjs (whole) | 5400 | 3x of Sage's 1800 s estimate |
|
||||
|
||||
Run command shape: `timeout -k 10 <S> <cmd>`. Queue verbs are never wrapped in a timeout.
|
||||
|
||||
Mutant kill rule: each of M1-M17 must fail on an assertion, not on a crash. The mutant run records the failing
|
||||
test name. Skips are named in the gate record.
|
||||
|
||||
## Gate results on 7309260ae (23:00-23:16Z)
|
||||
|
||||
- stack-release.test.mjs, whole: 219 pass, 0 fail, 0 skipped, 583.5 s (timeout 5400 s).
|
||||
- test-config.sh: selftest 51 pass, 0 fail, EXIT 0, 2.4 s (timeout 900 s).
|
||||
- Mutants, one run each, file restored from git after each. Each run of M1-M13 and M15-M17 is 2.4 s
|
||||
(the first run is the timing: 2.4 s, so the 900 s timeout is far above it).
|
||||
|
||||
| mutant | result | killed by (FAIL line in test-config.sh, or stack-release.test) |
|
||||
|---|---|---|
|
||||
| M1 | killed | federation unknown sub-key |
|
||||
| M2 | killed | federation peer unknown sub-key |
|
||||
| M3 | killed | federation peer origin over http |
|
||||
| M4 | killed | federation peer origin with a path (also userinfo, trailing slash) |
|
||||
| M5 | killed | federation peer id equal to self (also duplicate peer id) |
|
||||
| M6 | killed | federation peer label empty |
|
||||
| M7 | killed | federation not an object |
|
||||
| M8 | killed | federation self pattern |
|
||||
| M9 | killed | federation peers not an array |
|
||||
| M10 | killed | federation peer not an object |
|
||||
| M11 | killed | federation peer id pattern |
|
||||
| M12 | killed | federation peer label over 80 characters |
|
||||
| M13 | killed | duplicate peer id |
|
||||
| M14 | killed | row 203 subtest of T57 in stack-release.test.mjs (parent T57 also fails) |
|
||||
| M15 | killed | federation self missing |
|
||||
| M16 | killed | federation peer id missing |
|
||||
| M17 | killed | federation peer label missing |
|
||||
|
||||
Correction (new entry; the kill-text column in the mutant plan above is kept as written): the plan named
|
||||
the kill labels for M8 and M11 as "self does not match the pattern" and "peer id does not match the pattern".
|
||||
Those labels are not in the test file. The assertions that fail are "federation self pattern" and
|
||||
"federation peer id pattern". Both mutants are killed; only the label text in the plan was wrong.
|
||||
|
||||
M14 note: `node --test --test-name-pattern="row 203"` runs zero inner tests, because the row 203 case is a
|
||||
t.test subtest of T57 and the filter skips the parent. The first automated M14 run reported SURVIVED for
|
||||
that reason (rc 0 in 0.9 s, no FAIL line). It was re-run with `--test-name-pattern="T57|row 203"`: the row
|
||||
203 subtest fails (rc 1), so M14 is killed. The automated result is the wrong one and is not the gate record.
|
||||
|
||||
## cli suite (23:20-23:37Z)
|
||||
|
||||
- First run (timeout 2400 s, 627.6 s): 895 tests, 816 pass, 77 fail, 2 cancelled, EXIT 1. Cause: this worktree
|
||||
had no node_modules, so scripts/case2-persona-import.mjs (and the other suites that import it) could not
|
||||
resolve package 'yaml' (declared at 2.9.0 in package.json). Not a federation failure. Not in the gate record.
|
||||
- Fix in the environment only: node_modules symlinked to /mnt/storage/src/mosaic-stack/node_modules (the row 202
|
||||
recipe). The symlink is excluded through .git/info/exclude and is not committed.
|
||||
- Rerun (timeout 2400 s, 621.9 s): 895 tests, 895 pass, 0 fail, 0 cancelled, 0 skipped, EXIT 0.
|
||||
|
||||
## Gate summary (23:00-23:40Z, rebased tip c7b754d7b, code tree 7309260ae)
|
||||
|
||||
| gate item | result | timeout S | measured |
|
||||
|---|---|---|---|
|
||||
| stack-release.test.mjs, whole | 219 pass, 0 fail, 0 skipped | 5400 | 583.5 s |
|
||||
| test-config.sh | 51 pass, 0 fail | 900 | 2.4 s |
|
||||
| mutants M1-M17 (named assertion) | 17 of 17 killed | 900 (per run) | 2.4 s per config run; M14 by row 203 subtest |
|
||||
| cli suite (npm test) | 895 pass, 0 fail, 0 skipped | 2400 | 621.9 s |
|
||||
| test-auth.sh | 15 pass, 0 fail | 900 | 1 s |
|
||||
| test-conductor.sh | 17 pass, 0 fail | 900 | 6 s |
|
||||
| test-config.sh | 51 pass, 0 fail | 900 | 2 s |
|
||||
| test-discord.sh | 81 pass, 0 fail | 900 | 8 s |
|
||||
| test-extension-package.sh | 18 pass, 0 fail | 900 | 1 s |
|
||||
| test-foundation.sh | 44 pass, 0 fail | 900 | 41 s |
|
||||
| test-land-work.sh | 86 pass, 0 fail | 900 | 12 s |
|
||||
| test-queue.sh | 28 pass, 0 fail, 1 skip | 900 | 21 s |
|
||||
| test-release.sh | 14 pass, 0 fail | 900 | 2 s |
|
||||
| test-task.sh | 91 pass, 0 fail, 2 skip | 900 | 14 s |
|
||||
|
||||
Named skips:
|
||||
- test-queue.sh: `queue verify and render --check` skipped. This checkout is not the queue's canonical root
|
||||
(/mnt/storage/src/mosaic-stack). The check runs on the canonical checkout only.
|
||||
- test-task.sh: two live-provider cases skipped by design (row 244: no provider call in the gate).
|
||||
|
||||
Timeouts: the ten per-script figures above were written before the first run and were not changed. Every script
|
||||
finished far inside its timeout. No timeout was hit, and none was placed around a queue verb.
|
||||
|
||||
Environment: the first cli run failed for a missing node_modules in the worktree; see the cli section above.
|
||||
The rerun is the gate record.
|
||||
@@ -1549,16 +1549,9 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-22 | Della | Row258 E01 3094e3bfa focused manifest review | APPROVED 11-path manifest/static F1/F2/N1 corrections; file and full-blob digest recipes verified. M1-M11 fixed; all execution/landing evidence still required in CLEAR. No shared-log edit, Node, tests, HTTPS Git or live action.
|
||||
2026-10-22T17:55:40Z | Sage (Opus 5.5, 1ef1e4f8) | Row 253 landing; post-253 doc sweep | 253 LANDED 48e450ce8. Sweep on sage/sweep-253: Gosalyn Case 2 design and rows, 229 design, Gyro 0acbdb0d8, Della 179391055, 268 docs, seat dirs for Della, Scrooge, Huey, Louie and Ludwig, human-users-build:70 under Q173, DEFERRED 253 N1, 198 N2, row 203 text, 224 residual and 206 sharee list, and seat log lines.
|
||||
2026-10-22 14:48 UTC | Researcher (Sonnet 5.5, 9bac0794) | Rows 220 and 216 resumed (sage-2708, sage-2716, sage-2719); local commits only | 220 e4a204836 on researcher/row220-r2 (on ea6dc9eff): C1 run tests, C2 default-reader M7 test, configDirectory fixture scope, BUILD.md round 2b. 216 4f3fbc25a on researcher/row216-r2 (on aca16404c): index cache, find/get/search/manuscript/universeInfo, PRD 1.14 levels. Nothing pushed, claimed or run; node --check not run. Waits for Sage's CLEAR and go; rebase onto 253 on a new branch before any gate.
|
||||
2026-10-22 | Megavolt | row 207 (193c, #1705) mosaic access user add/remove | gate green on 712f79c0a (31/31, M1-M8 killed, cli 894/894, ten scripts); pushed megavolt/row207-r3 9bb406c6c; to Duckworth.
|
||||
2026-10-22T22:51:32Z | Sage (Opus 5.5, 1ef1e4f8) | Post-207 gap: brief push, row 292 add, stuck-request repair, 207 landing | #1797 brief f0e3dcdf4 pushed; row 292 (#1797, Gander) briefed after 270/271/203. Dead queue locks removed twice; requests for 207/270 abandoned and reposted as jarvis (c30841, c30845), 291 requested (c30846); verdicts posted for Duckworth (c30847, c30849) and Webby (c30848). 207 LANDED c4f77373f, #1705 closed c30855, P19 269 c808. DEFERRED: 207 N1, 270 N6.
|
||||
2026-10-22T23:03:39Z | Gyro (Codex gpt-6.1-sol, mos-1356) | Q195/Q231/Q233 O2 design rewrite, gyro/editor-o2 from c4f77373f | Canonical queue next gyro: nothing. Ten docs-only briefs; full-feature WordCraft, independent 15-crate/199-package security review, editor-only CSP candidate, instant git DOCX saves and retained Markdown/wiki sources, committed-only DOCX citation gate, no agent story prose. Static structure/source-anchor/scope checks only; no code, build/run, queue or live change. Fenton review requested after push.
|
||||
2026-10-22T23:09:38Z | Gyro (Codex gpt-6.1-sol, fenton-0006) | Q195 O2 design corrections on gyro/editor-o2 | F1 requirement boundary sent to Mos, adoption gated; F2-F6 source-checked docs fixes prepared. Ten briefs and two open owner items checked statically. No implementation, build/browser/worker run, queue or live action; Fenton re-check after push.
|
||||
2026-10-22T23:13:27Z | Gyro (Codex gpt-6.1-sol, fenton-0007) | Q195 O2 r3 F7/F8 | Save-group keys/defaults/validation and W3b refusal/W4b lift ownership explicit. Static docs checks only. Q36 remains open; no implementation or gate lift. Direct Fenton/Mos handoff after push.
|
||||
2026-10-22 23:23:42Z | rocko | Q195 O2 W4 #1799 committed DOCX citation/access design | pushed rocko/q195-w4-design-20261022 at 15e91538f; blob 662abdb7; direct handoff to Megavolt; static docs checks only, review pending; Q36 open/adoption closed.
|
||||
2026-10-22 | Megavolt (Claude Opus 5.5) | Q195 O2 W4 DOCX citation/access design review (#1799, sage-3061/3067) | four rounds: CHANGES 15e91538f, 019a0ea5b, 54246cb32; APPROVE 994c3f7fb (verdict 67f82a12d, unpushed pending CLEAR); M1 closed with no ruling; R4-N1 to W4b
|
||||
2026-10-22 23:48Z | rocko | Q195 O2 W4 #1799 design approved | W4 #1799 design approved at 994c3f7fb (Megavolt 67f82a12d), 32 acceptance rows assigned to W4b; static docs/public-fixture inspection only, no implementation PASS. Q36 remains OPEN and adoption CLOSED. Sage's accepted implementation brief remains required. Row219 remains separately held pending your next direction.
|
||||
2026-10-22T23:55:11Z | Sage (Opus 5.5, 1ef1e4f8) | Row 203 landing, O2 design landing, W4 docs landing | 203 LANDED a4da4e6df (Webby c30877, rev 3366), #1701 closed c30882, P19 269 c835. O2 design (mos-1360/1361) pushed 25c09f19f, blob 322ec50f matches fenton-0008. W4 #1799 APPROVE round 4 posted c30884; design 2af460ab7 and review bedde1716 land here. Queue revs 3360-3368 in 4ca270307. 270+271 r4 rebased by Gizmoduck onto 25c09f19f for new Duckworth rounds.
|
||||
2026-10-23T00:05:06Z | Filbert (Opus 5.5, sage-3059/3064) | Q195 O2 S0 (#1798) static review of Fenton's fenton/q195-s0 85ae2be77 (security + CSP reports, receipts in s0-20261022) | CONCUR CONDITIONAL PASS, c30914; A1 M1 needs WOFF2 and summed-size tests, A2 F2/D7 misstate the ctrl+click gate, M2 retargets to DOCX save; N1-N4 non-blocking; D8 hash question stays with Mos; no queue row (mos-1361), no slot, harness not rerun
|
||||
2026-10-23T00:52:58Z | Gyro (Codex gpt-6.1-sol, mos-1366/1367) | Vikunja571 Q235-Q237 O2 parent/W4 format-policy rewrite on gyro/editor-o2 | Canonical queue next gyro: nothing. Prepared P1-P6 and B1-B4 docs, baseline W4 import 1ac76a417; no product/build/browser/worker/private-file/queue/live action. Mos impact map fold precedes review/push handoff.
|
||||
2026-10-23T01:37:07Z | Gyro (Codex gpt-6.1-sol, mos-1368/1372/1375) | Vikunja571 O2 parent/W4 revision complete for review | P1-P6/B1-B4 and A17-A41/Q1-Q16 folded; same-thread Case 2 lookups/support writes, human-set host-pushed off-host remote, Hermes/restore/counting/order/role gates. Static docs checks pass; old locator/projection mechanics retained, authority/access review reopened. No code, build/browser/worker run, private conversion, queue or live action. Direct Fenton/Mos/Sage handoff after push.
|
||||
2026-10-23T01:41:59Z | Gyro (Codex gpt-6.1-sol, sage-3121) | 571 PRD d6 A1-A3 alignment | Both designs corrected and statically checked; local commit for Fenton in shared repo object store. HTTPS push held for Sage gap, independent review pending. No product, test-slot, queue or live change.
|
||||
|
||||
@@ -638,18 +638,6 @@ 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
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
# MVP readiness rows: FED R6, R4, R7, the GitHub grant method, the Q228 grant scope, and 574/573
|
||||
|
||||
Sage, 2026-10-23. Mos filed these under mos-1371 (readiness at-risk) and accepted the owners, reviewers and
|
||||
slot order in mos-1372, from sage-3112. Row ids come from the queue CLI at add. Every row below has gate owner Sage.
|
||||
Slot order per sage-3112, with R6 first among the FED rows because it blocks 208, R7 and R8.
|
||||
|
||||
The federation rows follow Gizmoduck's design `agents/gizmoduck/work/federation/DESIGN.md` at b93f098f0 on
|
||||
`gizmoduck/federation-design` (r4.11, sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6),
|
||||
sections 3.1, 3.7 and 7, under Vikunja P19 #414. The R3 brief (`docs/plans/2026-10-22_federation-r3.md`) is the
|
||||
template.
|
||||
|
||||
Common boundaries for every row here: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, any
|
||||
live access.json or any live credential. Tests use scratch roots under `/mnt/storage/scratch/tmp`, never `/tmp`.
|
||||
Single test files run freely, and suites and mutants need Sage's slot. Every old-test change follows mos-1272.
|
||||
|
||||
## Brief: FED R6, Console granted-user sign-in
|
||||
|
||||
Issue #1800.
|
||||
|
||||
Owner Launchpad (built row 192). Reviewer Duckworth. Parents done: 192 (#412 phase 1), R1 (201), R2, R3 (274).
|
||||
Row 208 gets an after on this row.
|
||||
|
||||
What to build, in `packages/webui/src/` and `packages/cli/src/access.mjs`:
|
||||
1. Granted-user sign-in. The Console verifies the OIDC token itself (Q175) and matches `(provider, sub)` to a
|
||||
live access.json grant through R1's loader. An ungranted `sub` is refused.
|
||||
2. A `user-cli` transport per session, R3's path-bound `packages/bus/src/user-cli.mjs`. The Console never
|
||||
speaks for a granted user through `human-cli`.
|
||||
3. The grant is re-read on every request. A user revoked mid-session gets 401 on the next call.
|
||||
4. Owner-only controls are hidden from a granted user. The bus still refuses them (R3), and the mutant proves
|
||||
the bus refusal stands on its own.
|
||||
5. The notice on the user's thread page, row 193's sentence word for word (B9, N21): "The business owner sees
|
||||
your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages."
|
||||
6. Peer links and the hub label (design 3.1 D1). The Console shows which instance it is from
|
||||
`federation.self` and links each `federation.peers` entry by label and origin. A peer link is a plain link.
|
||||
7. The F8 peers wiring. `accessContextFor` at `packages/cli/src/access.mjs:62` returns `peers: []`, so an
|
||||
access.json that names a peer can't validate. It must pass the configured peer ids from
|
||||
`system.federation.peers`.
|
||||
|
||||
Tests, webui tests on Sage's fake OP plus the cli access tests: design section 7's R6 gate item by item
|
||||
(granted user signs in, ungranted `sub` refused, revoked mid-session gets 401, the owner-verb mutant, the
|
||||
notice equals row 193's sentence exactly), one test that the hub label and peer links render from config,
|
||||
and one cli test that a configured peer id validates and an unconfigured one refuses. Mutants: the UI check
|
||||
removed (owner verb still refused by the bus), the per-request re-read skipped, the notice text changed by
|
||||
one word, `peers` back to `[]`.
|
||||
|
||||
Gate: Duckworth approves the manifest. The webui, cli and bus suites and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed. The live provider client and the hub proxy route are R8's.
|
||||
|
||||
## Brief: FED R4, revocation
|
||||
|
||||
Issue #1801.
|
||||
|
||||
Owner Morgana (built R3, 205 and 275). Reviewer Dewey. Parents done: R3 (274), R5 (202), 193a (204). Design
|
||||
section 7 also names row 193's viewer level (mos-1019, N17). If 193 hasn't landed that level when R4 starts,
|
||||
R4 builds on 193's candidate, and the downgrade test and its mutant run on that base (Q177). Row 275's
|
||||
"after FED R4" note becomes an after on this row.
|
||||
|
||||
What to build, in `packages/bus/src/broker.mjs` and `packages/cli/src/launcher.mjs`, per design section 3.7:
|
||||
1. A broker timer every 30 s that re-reads access.json and re-evaluates expiry.
|
||||
2. `access.reload`, a human-only verb. R5's `mosaic access grant` and `revoke` call it after the write.
|
||||
3. On a loss, one transaction ends the claims of every `user:<id>` thread no live collaborator grant covers
|
||||
(`#threadHolders`, `#claimEnd`). The run's next bus call is refused.
|
||||
4. `threadsState` marks such a thread `revoked: true`, computed from the file on each call. A downgrade to
|
||||
viewer counts as a loss (`covers` with `level: 'collaborator'`).
|
||||
5. The launcher never starts a revoked thread, SIGTERMs a running one at once without waiting for
|
||||
`threadIdle`, and its launch check refuses a revoked thread.
|
||||
6. R5's help text keeps the provider-removal order (access.json first).
|
||||
|
||||
Tests, bus and launcher tests, each with a mutant (design section 7): a hand edit removing the grant ends the
|
||||
run within one timer period, an expiry with no request ends it, a pending message followed by a launcher
|
||||
restart starts no run, a running revoked thread gets SIGTERM, a downgrade to viewer counts as a loss, and
|
||||
another user's runs stay. Timer tests drive the clock or the timer callback. No test sleeps 30 s.
|
||||
|
||||
Gate: Dewey approves the manifest. The bus, cli and business suites and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
|
||||
## Brief: FED R7, scratch two-instance acceptance
|
||||
|
||||
Issue #1802.
|
||||
|
||||
Owner Quackerjack (built R5 and R2). Reviewer Filbert. After R4 and R6, and row 190 (several instances on one
|
||||
uid). Briefed now and claimable once R4 and R6 both have candidates. It builds on their combined candidate.
|
||||
|
||||
What to build: one script test, under `scripts/` with its harness in `packages/*/tests/` as row 190's tests are.
|
||||
It sets up two configs, two data roots, two Consoles and one fake OP. The Consoles start outside agent ancestry
|
||||
in a transient user unit, as the user proof requires. They never use the live stack's units, ports or
|
||||
sockets, and the script removes its units on exit (trap).
|
||||
|
||||
Test, design section 7's R7 gate: Jason's test identity, granted collaborator on one hub project, signs in to
|
||||
the hub, makes a thread, sends to the granted instance, gets the instance's reply in the thread, is refused
|
||||
on another project and on board reads, then is revoked, is refused, and has the run ended. The factory shows
|
||||
nothing of the hub. Mutants: the grant's project list widened to `*`, the revocation's claim end skipped.
|
||||
|
||||
Gate: Filbert approves the manifest. The script test and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
no unit, port or socket of the script's outlives the run. R8 is the live window and stays row 209.
|
||||
|
||||
## Brief: the GitHub grant method (Bushroot's half of #436b)
|
||||
|
||||
Issue #1803.
|
||||
|
||||
Owner Bushroot. Reviewer Dewey. After row 198, so Bushroot isn't on two rows. Q188 item 3: on Approve,
|
||||
protected stack code creates the grant on GitHub as it does on Gitea. The Gitea half is row 236 (landed
|
||||
bd5f11ded) under #436b design r5 (`agents/megavolt/work/436b/DESIGN.md` at 1be535aca).
|
||||
|
||||
Step 1, before any code: Q-B3. Bushroot answers design section 10's shared items S1-S6 in the packet, and
|
||||
megavolt-0036 is waiting on that answer. Where GitHub doesn't fit (S4, because App installation tokens expire
|
||||
within an hour and may not fit "mint once and store"), say so and propose the per-method S4. The rest stays
|
||||
shared. Dewey and Megavolt read the answer before the build starts. A change to a shared item that 236 already
|
||||
built goes to Sage first.
|
||||
|
||||
Step 2, the build: a `github` method behind the same grant body (S1), admin slot (S2), `grant.call` and
|
||||
`grant.result` records (S3), executor steps (S4 or its per-method form), revoke order (S5) and capability
|
||||
floor (S6). Scopes are fixed by code per method.
|
||||
|
||||
Tests: a fake GitHub API only. Mirror 236's test and mutant list for the GitHub method, plus the S4 expiry case
|
||||
if the method refreshes tokens. No call reaches github.com. Live use waits for Jason's admin credential step,
|
||||
as it did for Gitea, and no agent sets the admin slot.
|
||||
|
||||
Gate: Dewey approves the manifest. The touched suites and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed.
|
||||
|
||||
## Brief: the chief-of-staff grant scope (Q228)
|
||||
|
||||
Issue #1804.
|
||||
|
||||
Owner Megavolt (built 236 and 281). Reviewer Dewey. After row 252, because the inbox Approve path is the one
|
||||
this row lets in-scope grants skip.
|
||||
|
||||
What to build, per DECISIONS Q228 (Jason, "Q31: no"):
|
||||
1. A grant-scope setting for the chief of staff that only a human changes (Q222). A missing or bad value falls
|
||||
back to the default, which is an empty scope, and every grant then needs Jason's Approve as before.
|
||||
2. A chief-of-staff grant request that is inside the scope is issued by the protected grant code without a
|
||||
per-grant Approve. A request outside it goes to the inbox as today.
|
||||
3. Every grant is logged and revocable, in-scope ones included, with the same records 236 writes.
|
||||
4. Jason's Approve stays for the chief of staff's enrollment and for any change to the scope.
|
||||
5. Gosalyn's Case 2 write path keeps its grant step. Only the approver changes.
|
||||
|
||||
Tests: an agent or tester caller can't change the scope, a bad value falls back to the default, an in-scope
|
||||
request issues with no inbox item, an out-of-scope one waits for Approve, a scope change needs Approve, an
|
||||
in-scope grant is logged and revocable. Mutants: the scope check skipped, the fallback skipped, the human
|
||||
check on the setting removed.
|
||||
|
||||
Gate: Dewey approves the manifest. The touched suites and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed. The live scope value is Jason's to set.
|
||||
|
||||
## Brief: 574, maintenance-free test fixture commits (Vikunja 574)
|
||||
|
||||
Issue #1805.
|
||||
|
||||
Owner Gander. Reviewer Filbert. Gate owner Sage. Ruled in mos-1369 A4 and mos-1370 under mos-1272. Slot: ahead
|
||||
of the next row after 270+271 (A5).
|
||||
|
||||
Fixture git helpers commit through `gitEnv()`, which has no maintenance config. On git 2.55.0 a commit spawns a
|
||||
detached `git maintenance run --auto` that can write and delete `objects/maintenance.lock` after commit
|
||||
returns, while a test fingerprints the whole root, `.git` included. This turned 270+271 run 4 red at
|
||||
`packages/seat/tests/checkout.test.mjs:239`.
|
||||
|
||||
Change: only `-c maintenance.auto=false -c gc.auto=0` added to the fixture git helpers, with no assertion
|
||||
change, and the fingerprints still covering `.git`. Candidate `gander/v574` 7da382244 (source 65e919e5c) on
|
||||
062646487, four files: `packages/seat/tests/checkout.test.mjs`, `packages/seat/tests/working.test.mjs`,
|
||||
`packages/cli/tests/git-socket.test.mjs`, `packages/cli/tests/lifecycle.test.mjs` (the last approved in
|
||||
mos-1370).
|
||||
|
||||
Proof: a GIT_TRACE2 run through a scratch PATH shim shows no maintenance child for the flagged helpers, and an
|
||||
unflagged positive control shows one. Gate: Filbert approves. The four whole files, then the seat and cli
|
||||
suites, are green on Sage's gate. Filbert's static APPROVE (filbert-v574-7da38224-sage, 01:23Z) adds proof
|
||||
folds P1-P5: per-process GIT_TRACE2_EVENT files, the positive control in the gitEnv-shaped environment, a wait
|
||||
for detached git before counting, the commit count beside the zero child count, and checkout.test's wrappers
|
||||
through the shim. Residual outside 574: `packages/queue/src/store.mjs`, `packages/discord/src/git.mjs` and
|
||||
`packages/harness/src/tools.mjs` commit without the maintenance flags. No whole-root test covers them today,
|
||||
so that's a bounded follow-up note, not part of this row.
|
||||
|
||||
## Brief: 573, the P10 hop band (Vikunja 573)
|
||||
|
||||
Issue #1806.
|
||||
|
||||
Owner Gander, after 574. Reviewer Filbert. Gate owner Sage. Ruled in mos-1367 A5. 270+271 run 3 went red on
|
||||
`packages/cli/tests/case1-accept.test.mjs:368/:390`, the P10 inner hop band (probe 1.08 under 1.25), at load.
|
||||
|
||||
Gander's packet states the band's source and a fix that measures the same property under load. Changing an
|
||||
old test, or anything that loosens its assertion, goes to Mos before the build (mos-1272). Gate: Filbert
|
||||
approves. The whole file under load at least 3 times, then the cli suite, are green on Sage's gate.
|
||||
@@ -624,13 +624,3 @@ Open follow-up: scripts/case1-accept.sh:413 (at candidate3c4c463da) uses `tr < "
|
||||
|
||||
|
||||
Row276 mos-1345 R3 residual remains open: a same-uid process that escapes the session, or a stalled supervisor in that session, can retain descriptors to the private stdout/stderr spool after root unlinks it and keep growing the unlinked file. The outer bound releases the caller because no residual holds its capture pipe. Row276 adds no disk cap and no cross-session kill; this is not a cleanup or closure claim.
|
||||
|
||||
|
||||
## Row207 N1: "refuses at any business" has no test
|
||||
|
||||
Open follow-up (duckworth-0230 N1, sage-3043). Row 207 (#1705) lands on 9bb406c6c without a test that `mosaic access user remove` refuses while the user holds a grant at any business, not only the named one. A mutant adding `&& g.business === business` survives T4: checkAccess still refuses at exit 3, so nothing is written, but the reason and exit code are wrong. The next row that touches packages/cli/tests/access.test.mjs adds one T4 case with the grant at a second business and a mutant that the case kills. N2 (parse ignores stray flags) is pre-existing in grant and revoke and stays out of scope.
|
||||
|
||||
|
||||
## Row270 N6: ledger growth and the whole-file read on session.list
|
||||
|
||||
Open follow-up (duckworth-0228 N6), bound to row 272 (264e, #1777). Row 270 caps what the bus reads per compaction collection (compactionReadBytes, default 64 MiB) but an agent can still grow a session's compaction ledger by about 231 KB per collection, and session.list reads the whole file. Row 272 bounds the ledger or the read on session.list, with a test and a mutant, or records why the cap is enough.
|
||||
+7
-15
@@ -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 3398 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3338 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -239,12 +239,12 @@ Generated from `docs/plans/queue.json` revision 3398 by `queue render`. Do not e
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | Landed afad4993f on refactor (squash of gizmoduck ae3c36f24..44e36fc4c, manifest 225597146daf...0c520). patch-id ba604ad5e matches the reviewed diff on all 3 paths. Gate on 44e36fc4c in the slot (gizmoduck-0060): business 173/0, 10 scripts 422/0, mutants 44/44. Duckworth approve 29581. |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | done | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Landed a4da4e6df on refactor 23:47Z 10-22, fast-forward from c7b754d7b, 5 commits. Webby APPROVE c30877 rev 3366 on candidate a4da4e6df. Gate on Sage's slot: stack-release.test 219/0, test-config 51/0, M1-M17 killed, cli 895/0, ten scripts green, named skips only. Brief re-pinned 8adfb261f (Webby N2). 270+271 rebase onto this (mosaic-config.mjs conflict) as a new round. |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | done | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | Landed ce6a96ad6 fast-forward on 0fa7bf421 (3 commits, 4 paths, manifest 858463b1). Duckworth APPROVE r1 c30375 (signed :124 test diff, mos-1262). Gate: Morgana slot205 10 files, bus 652/0, M1 M11-M14 killed, 0 skips; Sage test-*.sh 10/10, 3 by-design skips. N1/N3 to 205b or R3, N2 to 193b2 brief, N4 = #1782 before any user thread. Rebase next: 268 r4, 194, 236, 253, 274. |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | launchpad; reviewers duckworth | #1704 | done | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | Landed 0cbe2e9b3 fast-forward on 17f415792 (10 commits, 22 paths), pushed 19:47Z. Duckworth APPROVE rev 3322 c30795 on 0cbe2e9b3; request rev 3320/3321 c30793 posted as jarvis. Gate (Launchpad, 9538e0fc8, tip differs in BUILD.md only): bus 850/0, cli 869/0, ten scripts/test-*.sh rc=0, touched files whole green, M2-M4 M7-M9 and Q2 killed. One placed fixture red (mos-1272). Schema 3l to 3m; live --migrate is Mos's go under Q173. |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | megavolt; reviewers duckworth | #1705 | done | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | LANDED c4f77373f 22:49Z 10-22 (fast-forward from f0e3dcdf4). Rebuild of gated 9bb406c6c: range-diff 4/4 =, patch-ids equal, 4 manifest blobs equal. Duckworth APPROVE c30849 rev 3356. Gate on 712f79c0a under sage-3041: access.test 31/31, M1-M8 killed, cli 894/894, ten scripts green. N1 DEFERRED (next access.test row). No live change. |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done, 294 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | megavolt; reviewers duckworth | #1705 | in-progress | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | done | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Landed f5753c0e8 on refactor (base 9d165be8c), pushed. Patch-id 723b1c83 matches git diff ad95c888c..0485384e9 on the 23 manifest paths; landed files pass r5 manifest 688d38b9. Duckworth approve r2 rev 2592, r3 29851. Mutants 52/53, M22b equivalent (rev 2584). Gate on ad95c888c+patch: packages 2796/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | darkwing; reviewers dewey | #1709 | in-progress | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
@@ -306,8 +306,8 @@ Generated from `docs/plans/queue.json` revision 3398 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 | 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. |
|
||||
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | in-review, round 1, request posted | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | huey; reviewers scrooge | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | done | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | landed 0bbf0f6d1 03:35:55Z, FF of 378a76b85; rebase of cac7d0f67, 6 range-diff pairs =, patch-ids equal, manifest e1031023 unchanged. Gate by Darkwing on 0bbf0f6d1: stack-release.test 218/218, external 25/25, test-release.sh rc 0, T58 c0 pass c1 fail, M1-M7 killed. Filbert APPROVE rev 3111 c30308, carry c30319. Docs 5365adaef (TOOLS.md N1 + BUILD-LOG). #1778. |
|
||||
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | done | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | LANDED 4c485c63b ff from 47a35bf43 (6 Morgana commits). Dewey APPROVE c30487 on 4c485c63b (manifest 00047b52, 13 paths). Gate 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, 10 scripts rc=0 (skips test-queue 1, test-task 2), 22/22 mutants. M3x wall code not-holder (kit comment said human-required). r5 f58cfc361 red, void. Overlap 13/257 clean. |
|
||||
@@ -327,15 +327,7 @@ Generated from `docs/plans/queue.json` revision 3398 by `queue render`. Do not e
|
||||
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | honker; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | — |
|
||||
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
|
||||
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | briefed | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | — |
|
||||
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | in-review, round 1, request posted | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | — |
|
||||
| 292 | stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | gander; reviewers duckworth | #1797 | briefed | 203 done, 270 done, 271 done | Duckworth approves the manifest. The touched test files run whole, the cli suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_config-checks-any-key-brief.md` § stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | — |
|
||||
| 293 | 574: maintenance-free test fixture commits (Vikunja 574) | gander; reviewers filbert | #1805 | queued | — | 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 | queued | — | 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 | queued | — | 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 | queued | 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 | queued | 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 | queued | 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 | queued | 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) | — |
|
||||
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | briefed | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+31
-2331
File diff suppressed because it is too large.
Load diff
@@ -1,528 +0,0 @@
|
||||
# Q195 O2 W4 review: committed DOCX citations and access
|
||||
|
||||
Reviewer Megavolt (Opus 5.5), assigned by Sage (sage-3061, mos-1360), Gitea #1799. Owner Rocko.
|
||||
Reviewed: `docs/plans/2026-10-22_q195-docx-citations.md`, blob 662abdb7c, commit 15e91538f (one
|
||||
docs-only commit on e36bce352). Against: parent blob 322ec50f2 (`docs/plans/2026-10-21_q195-writing-editor.md`)
|
||||
D11, D12, D24-D26, W4 and W4b. Static review only: no tests, parser runs or live files.
|
||||
|
||||
## Verdict: CHANGES
|
||||
|
||||
The W4 Gate asks for "no unresolved locator or permission ambiguity" and for every bypass closed.
|
||||
The design gets the core right, but nine items are still open against that bar (B1-B9). One part of
|
||||
the design changes Case 2 behaviour and goes to Mos and Jason (M1). None of these needs a new
|
||||
architecture. Each one needs a definition, a rule or an acceptance row.
|
||||
|
||||
## What holds
|
||||
|
||||
- Snapshot: manifest and DOCX come from one reachable commit. There is no worktree, export or
|
||||
engine-memory fallback, and commit/blob is a locator, not an authority token (section 2).
|
||||
- Identity: a copy or new import gets a new document ID with no inherited grant, share, pin or cache.
|
||||
The same blob in another document is not selectable.
|
||||
- Quote verification: exact part, story and ordinal, a code-point range, literal quote equality,
|
||||
SHA-256 equality, blob and commit membership, and a pinned extractor revision. A stale locator
|
||||
returns old text labelled stale and never relocates.
|
||||
- Write paths: none. There is no native MCP, execute, save, replace, tracked-change or comment write,
|
||||
and flags belong to W5 as metadata.
|
||||
- Case 2: the line-cite schema, library grants and supporting-doc writer are unchanged, and no DOCX
|
||||
paragraph is called a line. Q36 stays open and adoption stays closed (mos-1358). A19 and A20 test
|
||||
this.
|
||||
- Parser: transitional namespace only, closed relationship types, no DTD, entities or external
|
||||
relationships, ZIP bounds before allocation, and refusal rather than a truncated success.
|
||||
- Document text is data: comments, deletions and wiki tokens never become instructions.
|
||||
- Text kept out of generic stores: the design keeps O2 text out of generic raw stores and logs,
|
||||
allows audit events to carry only IDs and digests, and does not let a shared extraction cache act
|
||||
as an authorization cache.
|
||||
|
||||
## Blocking findings
|
||||
|
||||
### B1. Several named records have no exact shape
|
||||
|
||||
Section 3 says "all objects reject unknown keys", but these objects are never specified:
|
||||
|
||||
- **The multi-paragraph citation.** Line 100-101 calls it "an ordered nonempty list of these
|
||||
ranges", but `editor_resolve_citation` takes one `{locator}`. The design needs to give:
|
||||
- the wrapper and its kind;
|
||||
- whether each range carries its own quote and digest;
|
||||
- whether every range must share commit, blob, extractor, view and part;
|
||||
- whether the paragraphs must be contiguous;
|
||||
- the maximum count;
|
||||
- how the resolver and the share chip accept the list.
|
||||
- **wikiTokens entries** (line 120 and section 5). The field names, offsets and forms (`link`,
|
||||
`label`, `heading`, `embed`) are not given.
|
||||
- **Anchor story.** Anchors are "a canonical decimal signed XML ID string" (line 127), but locator
|
||||
stories are nonnegative (line 88). Use one grammar for both.
|
||||
- **`paragraph.xmlId`.** It is "JSON null when absent" (line 89), but also "if provided" (line 97).
|
||||
Two locators for the same range must not both be valid. State that null means "this node has no
|
||||
`w14:paraId`" and that a node with one must carry it, or make it always null.
|
||||
- **Departure from D25.** D25 says "resolve paragraph IDs when present, otherwise an ordinal". The
|
||||
design makes the ordinal primary and the paraId only a check. I agree with that on the merits,
|
||||
since paraId is not unique, but the design should name it as a deliberate departure from D25's
|
||||
wording so that Sage's brief carries it. This is O2-only, not a Case 2 item.
|
||||
- **The share record, the chip request and the refusal envelope.** Section 7 needs:
|
||||
- the fields the WebUI sends to have a selection mapped;
|
||||
- the fields of the stored share record;
|
||||
- the error object's keys (section 6 gives only the codes).
|
||||
|
||||
### B2. Extraction is not closed enough to be deterministic
|
||||
|
||||
Section 5 lists containers and a few leaves, then refuses "unknown text-bearing elements". Offsets
|
||||
depend on how each run-level leaf counts, so two conforming extractors can disagree. The design
|
||||
needs a closed leaf table that gives each leaf's text contribution or refusal. At least these are
|
||||
missing:
|
||||
|
||||
- `w:annotationRef`. Word starts every comment with it, so whether comments are readable at all
|
||||
depends on an unstated choice.
|
||||
- `w:footnoteRef` and `w:endnoteRef`, the number mark inside the note's own story.
|
||||
- `w:noBreakHyphen`, `w:softHyphen`, `w:sym` and `w:ptab`.
|
||||
- `w:moveFromRangeStart/End` and `w:moveToRangeStart/End`, which always come with the moves the
|
||||
design supports.
|
||||
- `w:commentRangeStart/End`, as anchors.
|
||||
- `w:permStart/End`.
|
||||
- `w:fldChar`, `w:instrText` and `w:fldSimple`, as refusals.
|
||||
|
||||
Two revision rules are also unstated:
|
||||
|
||||
- **Nested revisions.** A run is in `final` if and only if no ancestor is `del` or `moveFrom`, and in
|
||||
`original` if and only if no ancestor is `ins` or `moveTo`. The design should say so.
|
||||
- **Paragraph-mark revisions.** For `w:pPr/w:rPr/w:ins|w:del`, state that views never merge or split
|
||||
paragraphs, so that an inserted paragraph shows as empty in `original`. That follows from "ordinals
|
||||
do not change with view" but should be explicit.
|
||||
|
||||
### B3. `currentCommit` and staleness are ambiguous for multi-chapter projects
|
||||
|
||||
D10 puts one DOCX per chapter under one book manifest, and the store is per project
|
||||
(`<dataRoot>/writing/<business>/<project>/`). `stale` is "exactly commit != currentCommit"
|
||||
(line 268). If `currentCommit` is the project's newest commit, then saving chapter 2 marks every
|
||||
chapter 1 citation stale and breaks chapter 1's latest cursors, even though chapter 1's blob did not
|
||||
change.
|
||||
|
||||
Two things need a definition:
|
||||
|
||||
- `currentCommit`: for example, the newest published commit that changed this document's DOCX blob
|
||||
or its manifest entry. Alternatively, define `stale` by blob plus manifest entry.
|
||||
- The document's "published history" (line 49): which ref, and which commits count.
|
||||
|
||||
Add an acceptance row: a save to a sibling document leaves `stale:false`.
|
||||
|
||||
### B4. The permission model is prose, not data
|
||||
|
||||
Section 7 intersects six inputs, but the grant side has no schema. Before W4b can implement this
|
||||
the same way twice, the design must answer:
|
||||
|
||||
- Where do document, part, view and comment scopes live: R5's access.json, a new bus table or the
|
||||
role files? Who writes them, through which human-only verb, with what proof, receipt and revision?
|
||||
Rows 207 and R5 already provide that pattern for business grants.
|
||||
- Who is "the principal" in an agent turn: the human who sent the chip, the seat, or both?
|
||||
- Can a `viewer`-level human share? Can a share ever expose a part or view that the sharing human's
|
||||
own grant does not cover?
|
||||
- In a thread with two humans, whose grants does `conversation-current` intersect?
|
||||
- "Historical-view scope" (line 290) appears once and is undefined. Is it a separate scope, or the
|
||||
live grant plus an exact share?
|
||||
- The cursor binds a "grant revision" (line 282). Which revision counts?
|
||||
|
||||
### B5. Share semantics contradict each other
|
||||
|
||||
- Line 329-331 says a share "does not widen it or authorize a later version". Line 340 says "latest
|
||||
whole-document reads require a whole-document share", and A15 says "whole-document latest share
|
||||
explicit". The design must say what a whole-document chip binds:
|
||||
- one commit or latest;
|
||||
- whether it includes notes, comments and the `original`/`markup` views.
|
||||
- Range shares: rows are whole paragraphs, so `editor_read_committed` must refuse under a range-only
|
||||
share. Only `editor_resolve_citation` works with one. State this.
|
||||
- `off` "revokes future reads". Does it also stop resolution of exact shares already sent?
|
||||
- `conversation-current` lasts "until human revocation/session end". Which session is that: the bus
|
||||
session, the thread session (row 269) or the Console login?
|
||||
|
||||
### B6. Paging rules contradict each other
|
||||
|
||||
- Overflow. The design says "response overflow never returns partial data with success", but paging
|
||||
must end a page before a row that would overflow. State that the page ends before the first row
|
||||
that would exceed 64 KiB, and that only a single row too large on its own refuses.
|
||||
- Paragraphs over 64 KiB. The design says a caller "may request a checked subrange through a
|
||||
separately bounded citation", but a citation needs the quote and its digest first, so such a
|
||||
paragraph is unreachable. Either define a bounded subrange read (ordinal plus a code-point window,
|
||||
returning text and a server-made locator) or declare these paragraphs unsupported in v1.
|
||||
|
||||
### B7. Git grants: the design does not forbid a remote
|
||||
|
||||
Case 2's library has a Gitea remote. Agent supporting-doc writes push through each seat's own Gitea
|
||||
account (Q226, case2-rows.md:1634-1641), and seat tokens carry `write:repository` and can fork
|
||||
(packages/bus/src/gitea-grant.mjs:29 and :297). The design says "no raw O2 git clone/read grant", but
|
||||
nothing stops an O2 project repo from getting a remote, mirror or backup push that any seat account
|
||||
can then list, fork or clone.
|
||||
|
||||
The design must state that O2 store repositories have no remote, and that no mirror, backup or push
|
||||
reaches a host an agent account can access. It also needs a stack-copy/release rule: copies never
|
||||
carry the store. Add an A16 probe: a seat Gitea token cannot list, fork or clone an O2 project.
|
||||
|
||||
### B8. The raw-session surfaces are deferred, not closed
|
||||
|
||||
Line 437-438 hands the surface inventory to the W4b brief. The W4 Gate wants raw sessions closed
|
||||
by this design. The stores already exist and can be named now, each with a rule (no O2 text,
|
||||
opaque reference only, or enforced envelope):
|
||||
|
||||
- seat session stores `threads/<business>/<thread>/session/` and `claude-projects/`,
|
||||
`threads/<business>/<thread>/sessions/<session>/` and `session-workspaces/...`
|
||||
(packages/seat/src/session.mjs:23-29);
|
||||
- the bus store's event and thread-message tables, and row 264's sessions;
|
||||
- the Console thread view;
|
||||
- the CLI's `thread`, talk and follow;
|
||||
- the Discord connector (packages/discord, including journal.mjs and context.mjs) and the
|
||||
control-board Discord path;
|
||||
- `<dataRoot>/runs/` run records.
|
||||
|
||||
Run records need a rule of their own. Invariant 5 makes them write-once, so O2 text written there
|
||||
can't be withdrawn on revocation, only pruned. State that O2 content never enters a run record.
|
||||
|
||||
### B9. The migration and compatibility plan is too thin to review
|
||||
|
||||
W4 asks for "a reviewed migration/compatibility plan". Lines 33-35 say only "new O2 records, a schema
|
||||
edge from Sage, no reinterpretation". The plan needs:
|
||||
|
||||
- **New tables and columns.** Name them, and state whether any existing table (thread messages,
|
||||
events) gains a provenance column.
|
||||
- **Rollback.** After `release.sh rollback` to a pre-W4b copy, the old code does not enforce the
|
||||
envelope. Any O2-derived text stored in an existing table becomes readable through the old thread
|
||||
views. Require that O2-derived content is stored only where pre-W4b code can't render it (a new
|
||||
table, or opaque references in existing tables), and add a rollback probe.
|
||||
- **Extractor revision retention.** Old locators need their old revision. Say whether activating a
|
||||
release that drops a revision is allowed, which turns those citations into
|
||||
`editor-version-unavailable`, or whether every published revision is retained.
|
||||
|
||||
## Mos/Jason item
|
||||
|
||||
**M1. O2-context turns narrow Case 2 behaviour.**
|
||||
|
||||
Section 7, lines 362-371, does two things in any turn that received O2 context:
|
||||
|
||||
- It refuses "generic supporting-doc writes, git writes, exports and arbitrary outgoing tools".
|
||||
- It classifies every output of that turn, and of any resumed or forked descendant, with the O2
|
||||
document scopes.
|
||||
|
||||
In a Case 2 thread, that means:
|
||||
|
||||
- C2-02's supporting-doc write, from the Q226 rows (mos-1309), stops working once the human shares
|
||||
a chip;
|
||||
- the agent's C2-06 replies become invisible to thread members without the O2 grant.
|
||||
|
||||
That changes a Case 2 requirement. Under the W4 Gate it goes to Jason through Mos before code, not
|
||||
as a design choice.
|
||||
|
||||
My recommendation: confine O2 context to O2-dedicated threads or sessions. Case 2 semantics then
|
||||
stay untouched, and the narrowing applies only where it was opted into. Otherwise Jason has to
|
||||
approve the narrowing for mixed threads.
|
||||
|
||||
The rest of the design changes no Case 2 requirement. Q36 and D12 stay open with Jason as before,
|
||||
and the design builds no notice.
|
||||
|
||||
## Non-blocking notes
|
||||
|
||||
- N1. Give the canonical serialization of the extractor build manifest that `extractor.revision`
|
||||
digests, so that the digest can be recomputed independently.
|
||||
- N2. A locator's `business` field must equal the broker-bound business. Otherwise refuse with
|
||||
`editor-access-refused`, the same response as for a foreign ID.
|
||||
- N3. The 100:1 expansion ratio: say whether it is per entry, total, or both.
|
||||
- N4. Line 68-69 says "no implicit defaults except the reading view", but `limit` defaults to 20 and
|
||||
`cursor` to null. Reword.
|
||||
- N5. Hyperlinks, fields and text boxes refuse the projection. Many real chapters will then be
|
||||
agent-unreadable. That is acceptable for v1, but the W4b brief and the human UI should say why a
|
||||
chapter is unavailable.
|
||||
- N6. Readiness should come from the verified release artifact (extractor manifest plus the
|
||||
formatting-property list), not a system-config field, since configs are human-authored
|
||||
(invariant 2).
|
||||
- N7. Add acceptance rows for B3 (sibling save), B6 (oversized paragraph), B7 (seat token against an
|
||||
O2 repo), B8 (each named store, including run records) and B9 (rollback).
|
||||
|
||||
## Next round
|
||||
|
||||
Revise the design for B1-B9 and send the new commit and blob to me and Sage. I re-review only the
|
||||
changed sections and the items above. M1 waits on Mos and Jason. If the design takes the
|
||||
O2-only-thread recommendation, M1 closes without a ruling.
|
||||
|
||||
## Round 2: 019a0ea5b (blob 14dfe92d9)
|
||||
|
||||
Reviewed: Rocko's round 2 commit 019a0ea5b, a fast-forward on 15e91538f, design blob 14dfe92d9.
|
||||
I read the whole file again, not only the changed lines, because the change was large (+453/-120).
|
||||
Static review only.
|
||||
|
||||
### Verdict: CHANGES
|
||||
|
||||
The design now closes B1-B9 and M1 as they were written. Two problems remain. R2-B1 is a
|
||||
projection rule that contradicts D25's "final accepted reading". R2-B2 is an existing backup and
|
||||
restore path that section 7 does not cover. Each needs a rule and an acceptance row, not a new
|
||||
design.
|
||||
|
||||
### Round 1 items
|
||||
|
||||
- **B1, closed.**
|
||||
- The `docx-selection` wrapper: 2..32 consecutive ranges, a shared snapshot, empty intermediate
|
||||
paragraphs, one budget for the whole selection, atomic failure and one canonical form.
|
||||
- `xmlId` is null exactly when the node has no paraId.
|
||||
- The wikiToken and anchor grammars now match the locator.
|
||||
- The chip, share and refusal envelopes are exact.
|
||||
- The departure from D25 is named (lines 108-111).
|
||||
- **B2, closed except for R2-B1.** The closed leaf table and the ancestor veto for nested revisions
|
||||
are in (lines 268-310).
|
||||
- **B3, closed.**
|
||||
- `refs/heads/writing-published` advances first-parent with no force.
|
||||
- Publication commits are defined per document, and `currentCommit` and stale are per document.
|
||||
- A23 covers a sibling save.
|
||||
- **B4, closed.**
|
||||
- The principal is a tuple.
|
||||
- Scope lives in `writing_access_events`, written by the business owner's existing human proof
|
||||
only.
|
||||
- Agent `mayShare` is false, and viewers cannot share.
|
||||
- The authority vector names its sources.
|
||||
- Two-human threads are refused (`editor-sharing-unsupported`).
|
||||
- **B5, closed.**
|
||||
- A range share allows resolve only.
|
||||
- A document chip binds one exact publication.
|
||||
- A latest share exists only under `conversation-current` with a whole-document chip.
|
||||
- `off` and any policy change invalidate all earlier shares.
|
||||
- The share's session is the new O2 execution session.
|
||||
- **B6, closed.** A page ends before the first row that would overflow. A row too large for an
|
||||
empty page is unsupported in v1, and A24 covers it.
|
||||
- **B7, closed.**
|
||||
- O2 repositories have no remotes, mirrors or forks, and the store refuses one.
|
||||
- Copies exclude `writing/` and `writing-context/`.
|
||||
- A25 includes a seat-token probe.
|
||||
- **B8, closed except for R2-B2.** Section 7.4 names every store and renderer I listed, plus
|
||||
`launches/` and `bus-host/sessions.json`. The run-records rule is explicit, and A26 probes each
|
||||
store.
|
||||
- **B9, closed except for R2-B2.**
|
||||
- New tables are named, append-only, with no change to existing columns.
|
||||
- Old renderers never query the new tables.
|
||||
- A rollback probe and an extractor-retention rule are in.
|
||||
- **M1, closed. No Mos/Jason item.** O2 context exists only in new, dedicated O2 conversations and
|
||||
execution sessions. Existing C2 threads cannot be converted, attached, resumed or forked into O2
|
||||
mode. C2-02 supporting-doc writes and C2-06 replies keep their meaning (lines 433-441), and A28
|
||||
tests that. This adds new O2 behaviour and changes no Case 2 requirement.
|
||||
- **N1-N7, addressed.** N1 is the canonical manifest bytes (lines 155-168). N2 is the business
|
||||
check (lines 378-379). N3 is the ratio rule at line 257. N4 is the defaults wording at line 84.
|
||||
N5 is the UI reason (lines 688-690). N6 is release-derived readiness. N7 is A23-A31.
|
||||
|
||||
### R2-B1. Paragraph-mark revisions drop accepted text, and reserved notes would refuse
|
||||
|
||||
**What the design says.** Lines 276-281: "an inserted paragraph mark makes that paragraph's
|
||||
projected text empty in original; a deleted paragraph mark makes it empty in final."
|
||||
|
||||
**What the markup means.** In WordprocessingML, `w:pPr/w:rPr/w:del` marks only the paragraph mark
|
||||
as deleted. Accepting the change merges this paragraph's text into the next one. The text is gone
|
||||
only if its own runs sit under `w:del`, and the ancestor veto already handles that. `w:ins` on the
|
||||
mark works the same way in `original`.
|
||||
|
||||
**Why that breaks the projection.** When a human joins two paragraphs with tracking on, the first
|
||||
paragraph's live text disappears from `final`. A quote taken from the accepted reading then can't be
|
||||
cited. That contradicts D25's "default final accepted reading" and the design's own definition at
|
||||
line 267.
|
||||
|
||||
**Fix.** Paragraph-mark revisions never change text. Runs follow the ancestor rule alone. Since
|
||||
views never merge or split paragraphs, a mark-only revision shows up only as a zero-width
|
||||
whole-paragraph-mark span in `markup`. Extend A31 with a mark-only deletion whose text stays in
|
||||
`final`, and a mark-only insertion whose text stays in `original`.
|
||||
|
||||
**Reserved notes.** The leaf table "is exhaustive; unknown leaves refuse" (line 305). Word writes a
|
||||
separator note and a continuation-separator note into every `footnotes.xml` and `endnotes.xml`,
|
||||
holding `w:separator` and `w:continuationSeparator` (and sometimes `w:continuationNotice`). Line
|
||||
227-229 calls these notes structural, but the table doesn't name those leaves. Read literally, every
|
||||
document that has footnotes refuses. Name the three leaves as allowed only inside reserved notes,
|
||||
structurally validated and never projected. Say whether leaf validation covers the whole part or
|
||||
only the requested story.
|
||||
|
||||
### R2-B2. The release backup and restore path keeps O2 text and can revive revoked access
|
||||
|
||||
**What the design says.**
|
||||
- Section 7.4 forbids "plaintext backup" of O2 material (lines 625-626).
|
||||
- Section 7.5 puts O2 message bodies, share selections (which hold quotes) and every grant and
|
||||
policy event into new tables in the bus store (lines 638-663).
|
||||
|
||||
**What the release script does.** `scripts/stack-release.sh` copies that store, in plaintext, on
|
||||
every activate:
|
||||
- `backup_store` writes `$DATA/backups/stack/<ACT>/bus/bus.sqlite` (lines 474-493).
|
||||
- It never prunes backups (line 18).
|
||||
- `rehearse` reads them (line 22).
|
||||
- `restore_store` moves the current `bus/` into a post-attempt directory and deletes nothing
|
||||
(line 496).
|
||||
|
||||
So O2 text persists in backups and post-attempt directories that section 7.4 does not name.
|
||||
|
||||
**The restore gap.** `rollback --restore` puts back an older store. The guard counts `events` plus
|
||||
the fixed `COUNTED` list (lines 16-17 and 418-420). The design keeps O2 writes out of generic
|
||||
events (lines 659-661), and the new tables are not in `COUNTED`. A restore over revocations made
|
||||
after the backup therefore passes the guard without Jason's `--events N`. It also silently brings
|
||||
back the revoked `writing_access_events`, `writing_policy_events` and session-end rows. That
|
||||
revives revoked grants and shares, against section 7.3's "a scope decrease/revocation denies even
|
||||
historical shares".
|
||||
|
||||
**What the design needs to state.**
|
||||
1. **Backups.** Either O2 bodies and quotes live outside `bus.sqlite` (for example under
|
||||
`writing-context/`) so that release backups hold opaque rows only, or release backups and
|
||||
post-attempt directories are named O2-classified stores in section 7.4, with mode, access and a
|
||||
prune rule.
|
||||
2. **The guard.** Every `writing_*` table joins the restore guard's counted list. The T46 guard
|
||||
already requires every schema table to be classified.
|
||||
3. **After a restore.** A restore that drops any `writing_*` row disables O2 readiness until the
|
||||
owner reconfirms access. It never brings back an older grant, share or policy.
|
||||
4. **Acceptance.** Add a row: a revocation after the backup, then a restore, must leave the revoked
|
||||
share refused. A secret marker must stay absent from every backup and post-attempt path except
|
||||
the named O2-classified ones.
|
||||
|
||||
### Non-blocking
|
||||
|
||||
- R2-N1. Define the agent `subject.id` for `editor.access.set` as the bus agent instance ID (the
|
||||
principal tuple's `agentInstance`), so a grant can't name a role or a seat display name.
|
||||
- R2-N2. Lines 330-332 restate the structural leaves already in the table. Drop the sentence or
|
||||
point it to the table, so there is one list.
|
||||
|
||||
### Next round
|
||||
|
||||
Fix R2-B1 and R2-B2 and send the commit and blob to me and Sage. I'll re-check those two items and
|
||||
any lines they touch.
|
||||
|
||||
## Round 3: 54246cb32 (blob 0c4f55826)
|
||||
|
||||
Reviewed: Rocko's round 3 commit 54246cb32, a fast-forward on 019a0ea5b, design blob 0c4f55826.
|
||||
I read the whole delta and every definition it touches. I checked its citations against the same
|
||||
commit: `COUNTED` is at `scripts/stack-release.sh:420`, T46 is at
|
||||
`packages/cli/tests/stack-release.test.mjs:913`, and the guard counts `WHERE at > STAMP`, which
|
||||
works with the `at` column every `writing_*` table has. Static review only.
|
||||
|
||||
### Verdict: CHANGES
|
||||
|
||||
R2-B1's paragraph-mark rule and all of R2-B2 are closed. One gap from R2-B1 is still open, and the
|
||||
new fence adds a second. Each needs a sentence or two, not a redesign.
|
||||
|
||||
### Round 2 items
|
||||
|
||||
- **R2-B1, paragraph marks: closed.**
|
||||
- A mark-only `w:ins`/`w:del` changes no projected text in any view.
|
||||
- Paragraphs are never merged or split.
|
||||
- Markup records a zero-width `paragraph-mark-insert`/`paragraph-mark-delete` span, and
|
||||
`revisionSpans` allows those kinds with start=end.
|
||||
- A31 covers both cases.
|
||||
- **R2-B1, reserved-note leaves: open.** The table rows and whole-part validation are in (lines
|
||||
301 and 311-318). How a reserved note is recognized is still wrong; see R3-B1.
|
||||
- **R2-B2: closed.**
|
||||
- Message bodies and share payloads leave SQLite for 0600 content objects under
|
||||
`writing-context/objects/`. The bus tables hold only refs and digests.
|
||||
- Backup and post-attempt trees are named O2-classified metadata stores, with modes, link checks,
|
||||
rehearse limits and a receipt-based prune that only a human can run.
|
||||
- All eight `writing_*` tables join `COUNTED` and T46, and row identities are compared.
|
||||
- An independent fence epoch, set before every store swap, stops restored rows from granting
|
||||
access, and owner confirmation can't revive them.
|
||||
- A32 covers the revocation, restore, crash and marker sweep.
|
||||
- **R2-N1: closed.** The agent `subject.id` is the agent instance ID.
|
||||
- **R2-N2: closed.** The structural-leaf list now points to the table.
|
||||
|
||||
### R3-B1. Reserved notes are recognized by negative ID
|
||||
|
||||
Line 228 defines reserved notes as "negative IDs and type separator/continuationSeparator". Word
|
||||
normally writes the separator as `w:id="-1"` and the continuation separator as `w:id="0"`, so the
|
||||
continuation separator is not negative. A `w:type="continuationNotice"` note is a third reserved
|
||||
type, and line 228 leaves it out, although the leaf table names it.
|
||||
|
||||
Read with lines 315-317 ("A separator leaf in a normal note/body/comment refuses"), the id-0
|
||||
continuation separator counts as a normal note. Its `continuationSeparator` leaf then refuses, and
|
||||
every footnoted document Word writes is refused again. ECMA-376 identifies these notes by `w:type`;
|
||||
the ID is only a number. I'm working from Word's usual output here, so a fixture from real Word
|
||||
should confirm it.
|
||||
|
||||
Fix:
|
||||
- Classify a note as reserved by `w:type` in {separator, continuationSeparator,
|
||||
continuationNotice}, with any integer ID.
|
||||
- A normal note has no `w:type` or `w:type="normal"` and a canonical nonnegative ID.
|
||||
- A reference to a reserved note refuses.
|
||||
- Add Word's -1/0 layout to A31.
|
||||
|
||||
### R3-B2. The fence's first state and when confirmation is needed are undefined
|
||||
|
||||
Line 791 says a missing fence denies O2. Only release and the owner-proof handler may write it
|
||||
(lines 762-763), and only "before any store swap" is a write moment defined (line 767). Nothing says
|
||||
who creates the fence when W4b's tables are first created, or with what epoch.
|
||||
|
||||
It is also unclear whether `confirmedBusinesses` is consulted on a store that has never been
|
||||
restored. Read literally, a fresh fence has no confirmed business, so O2 stays off until an
|
||||
`editor.restore.confirm` that nothing calls for. The other reading, confirmation only after a
|
||||
restore, needs a rule saying so.
|
||||
|
||||
Either way this is a gap in an authorization check: it fails closed, but an implementer has to
|
||||
invent the enable path. Fix:
|
||||
- State that the compatible controller creates the fence durably with the W4b migration (or at
|
||||
first O2 enable), with a fresh epoch, `restoreOperation` null and the empty dropped-rows digest.
|
||||
- State whether `confirmedBusinesses` gates O2 always or only after a fence whose
|
||||
`restoreOperation` is non-null.
|
||||
- Add both states to A32.
|
||||
|
||||
### Non-blocking
|
||||
|
||||
- R3-N1. Content objects are named by the SHA-256 of `{body,provenance}`. Two equal objects in
|
||||
different businesses share one file, so a per-business prune or retention action can delete
|
||||
another business's message. The digest kept in metadata backups also lets anyone confirm a
|
||||
guessed short body ("ok"). Put the row `id` (or a random nonce) in the canonical object so each
|
||||
digest is unique and can't be guessed.
|
||||
- R3-N2. Line 791 says rolled-back fence state denies O2, but the fence keeps only its current epoch,
|
||||
so nothing can detect a rollback. Either define the check (for example, a monotonic fence
|
||||
generation that every new row also records, and deny when the bus holds a newer generation than
|
||||
the fence) or drop the claim.
|
||||
- R3-N3. `restore_store` begins at `scripts/stack-release.sh:498`; line 727 cites ":496ff", as my
|
||||
round 2 did. Cosmetic.
|
||||
|
||||
### Next round
|
||||
|
||||
Fix R3-B1 and R3-B2 and send me and Sage the commit and blob. I'll re-check those two items and the
|
||||
lines they touch.
|
||||
|
||||
## Round 4: 994c3f7fb (blob 2af460ab7)
|
||||
|
||||
Reviewed: Rocko's round 4 commit 994c3f7fb, a fast-forward on 54246cb32, design blob 2af460ab7.
|
||||
I read the whole delta and the definitions it touches. Static review only. I did not download the
|
||||
two public fixtures Rocko cites, so their sizes and hashes are Rocko's; what they report (-1/0 in
|
||||
Word 15, 0/1 in Word 12) matches my round 3 claim and also shows that an ID can't identify a
|
||||
reserved note.
|
||||
|
||||
### Verdict: APPROVE
|
||||
|
||||
Every blocker from rounds 1-3 is closed, and M1 closed in round 2 with no Mos/Jason item. The
|
||||
design meets the W4 Gate (spec blob 322ec50f2, lines 806-850):
|
||||
- no locator or permission ambiguity is left;
|
||||
- new O2 copies, generic reads, git grants and raw sessions are closed as bypasses;
|
||||
- quotes verify against a committed blob and an extractor revision;
|
||||
- there is no unsaved-context or agent write path;
|
||||
- Case 2 Markdown authorization is unchanged;
|
||||
- a migration and compatibility plan is present.
|
||||
|
||||
W4b owns the implementation and the A1-A32 evidence. This approval covers the design, not passing
|
||||
tests.
|
||||
|
||||
### Round 3 items
|
||||
|
||||
- **R3-B1: closed.**
|
||||
- Reserved notes are classified by `w:type` in {separator, continuationSeparator,
|
||||
continuationNotice}, with any canonical signed ID (line 227).
|
||||
- A normal note has no type or type normal, plus a nonnegative ID; unknown types refuse.
|
||||
- Any reference to a reserved note refuses, including an id-0 continuation separator.
|
||||
- Duplicate IDs within a part refuse.
|
||||
- A31 covers both the -1/0 and 0/1 layouts.
|
||||
- **R3-B2: closed.**
|
||||
- The first schema migration creates the fence durably before the tables commit, with a fresh
|
||||
epoch, `restoreOperation` null, the `[]` digest and no confirmed businesses (line 781).
|
||||
- Confirmation applies only when `restoreOperation` is non-null (line 819).
|
||||
- A missing or malformed fence over existing `writing_*` tables or history fails closed and
|
||||
never reinitializes (line 790), and a crash resumes on the same epoch.
|
||||
- A32 covers both states.
|
||||
- **R3-N1: closed.**
|
||||
- Each object holds business, project, row and a private 32-byte nonce, and lives under
|
||||
`objects/<business>/<row>/`.
|
||||
- There's no cross-row deduplication, and retention is per owning row.
|
||||
- **R3-N2: closed.** The rollback-detection claim is replaced by an honest limit: a same-uid
|
||||
operator bypassing the protocol is outside what the file can prove.
|
||||
- **R3-N3: closed.** The `:498` citation is fixed.
|
||||
|
||||
### Non-blocking, for W4b
|
||||
|
||||
- R4-N1. Line 790 allows initialization only with no `writing_*` tables and no fence. One case is
|
||||
unnamed: a compatible-controller restore of a backup taken before W4b, then a re-migration. The
|
||||
tables are then absent while a rotated fence with a non-null `restoreOperation` exists. Neither
|
||||
reading opens a bypass, because the restore dropped every `writing_*` row. W4b should still say
|
||||
that re-migration keeps the existing rotated epoch and its confirmation requirement, rather than
|
||||
refusing forever or writing a new null fence. Add that case to A32.
|
||||
@@ -246,7 +246,7 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
|
||||
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
|
||||
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
|
||||
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
|
||||
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default,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.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. 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,40 +493,3 @@ 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;
|
||||
+11
-382
@@ -127,9 +127,7 @@ 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',
|
||||
// Row 271 (264d, #1776): clear context in the tester's own threads.
|
||||
'session.clear', 'session.cancel', 'session.operation']);
|
||||
'session.list', 'session.create']);
|
||||
// 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.*.
|
||||
@@ -396,7 +394,6 @@ 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.
|
||||
@@ -422,14 +419,13 @@ 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, compactions = 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 }) {
|
||||
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.
|
||||
@@ -449,7 +445,6 @@ export class Broker {
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#access = access;
|
||||
this.#compactions = compactions;
|
||||
this.#project = project;
|
||||
this.#library = library;
|
||||
this.#usage = usage;
|
||||
@@ -615,108 +610,6 @@ 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) {
|
||||
@@ -870,15 +763,6 @@ 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.
|
||||
@@ -1055,158 +939,15 @@ 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. Row 271 (264d): a retired session has
|
||||
// none, so nothing starts or idles it, and a cancelled message isn't pending.
|
||||
// in-sandbox workspace path are the thread's old ones.
|
||||
#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'))
|
||||
AND NOT EXISTS(SELECT 1 FROM message_cancels x WHERE x.message=m.id)) AS pending,
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) 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,
|
||||
@@ -1215,7 +956,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} AND NOT EXISTS(SELECT 1 FROM session_ops o WHERE o.session=se.id AND o.stage='retired') ORDER BY t.seq, se.seq`,
|
||||
WHERE ${where} ORDER BY t.seq, se.seq`,
|
||||
...params,
|
||||
)
|
||||
.map((r) => ({
|
||||
@@ -1313,10 +1054,6 @@ 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`,
|
||||
@@ -5154,8 +4891,6 @@ 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'),?,?,?,?,?)",
|
||||
@@ -5574,12 +5309,10 @@ export class Broker {
|
||||
a.corrects ?? null,
|
||||
a.body,
|
||||
);
|
||||
const cancelled = this.#route(s.business, mid, to, thread);
|
||||
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 };
|
||||
}
|
||||
@@ -5590,21 +5323,10 @@ 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,
|
||||
@@ -6130,8 +5852,6 @@ 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 };
|
||||
}
|
||||
@@ -6142,8 +5862,6 @@ 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);
|
||||
@@ -6221,28 +5939,14 @@ 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,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,
|
||||
};
|
||||
});
|
||||
.all('SELECT id,at,name,predecessor FROM sessions WHERE thread=? ORDER BY seq', t.id)
|
||||
.map((r) => ({ ...r, default: r.id === fallback }));
|
||||
}
|
||||
// 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
|
||||
@@ -6253,80 +5957,14 @@ 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.#nameTaken(t.id, a.name)) fail('session-exists');
|
||||
if (this.#store.get('SELECT 1 FROM sessions WHERE thread=? AND name=?', 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']);
|
||||
@@ -6455,21 +6093,12 @@ 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, instance } = this.#store.get('SELECT thread, instance FROM sessions WHERE id=?', session) ?? fail('session-not-found');
|
||||
const { thread } = this.#store.get('SELECT thread 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
|
||||
@@ -6622,7 +6251,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')) AND NOT EXISTS(SELECT 1 FROM message_cancels WHERE message=m.id)",
|
||||
"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'))",
|
||||
session,
|
||||
);
|
||||
for (const m of rows) this.#delivery(m.id, 'routed', h);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
-- 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,11 +29,6 @@ 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);
|
||||
@@ -69,12 +64,6 @@ 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,7 +9,6 @@ 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';
|
||||
@@ -262,11 +261,6 @@ 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,
|
||||
@@ -594,12 +588,6 @@ 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 = 'a2ba3a46f12e56139ecae9e55227d00847842aedec50c1d92aaae28844ca48e7';
|
||||
export const SCHEMA_VERSION = '3n';
|
||||
export const SCHEMA_SHA256 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
|
||||
export const SCHEMA_VERSION = '3m';
|
||||
// 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,11 +77,6 @@ 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',
|
||||
@@ -100,8 +95,6 @@ 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,8 +18,7 @@ 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.
|
||||
// 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);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].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 }));
|
||||
@@ -87,7 +86,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -95,7 +93,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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
// 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]);
|
||||
@@ -145,7 +143,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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
});
|
||||
|
||||
@@ -157,14 +155,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 = '3n';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
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 = '3n';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "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, '3n');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
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, '3n');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
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 = '3n';", "export const SCHEMA_VERSION = '3o';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3n', to: '3o', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', 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', '3m-3n.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'])
|
||||
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, '3o');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
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 3n ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3m ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
-495
@@ -1,495 +0,0 @@
|
||||
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,12 +35,11 @@ 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.
|
||||
// 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);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].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','session_ops','message_cancels')").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')").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(); }
|
||||
@@ -77,7 +76,6 @@ 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) {
|
||||
@@ -99,7 +97,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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
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)));
|
||||
@@ -125,7 +123,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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -136,14 +134,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 = '3n';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -21,8 +21,7 @@ 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.
|
||||
// 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);
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].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 }));
|
||||
@@ -42,7 +41,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','session_ops','message_cancels')").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')").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(); }
|
||||
@@ -80,7 +79,6 @@ 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) {
|
||||
@@ -104,7 +102,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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
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)));
|
||||
@@ -129,7 +127,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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -140,14 +138,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 = '3n';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
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, '3n');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
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,8 +523,7 @@ 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.
|
||||
// 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('table') - 1, 23);
|
||||
assert.equal(count('trigger'), 89);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
@@ -1,597 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
// 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 = '3n';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
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, '3n');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
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,8 +25,6 @@ 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 = '3n';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "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, '3n');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
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 3n ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
|
||||
});
|
||||
@@ -403,15 +403,10 @@ 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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
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-'));
|
||||
@@ -439,7 +434,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 = '3n';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "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);
|
||||
@@ -456,7 +451,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), '3n');
|
||||
assert.equal(version(path), '3m');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
||||
|
||||
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
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');
|
||||
|
||||
@@ -26,9 +26,9 @@ export const ACCESS_USAGE = [
|
||||
// The help text. A grant's `instances` is the real limit: the granted instance
|
||||
// runs with the owner's role, so a collaborator drives it with that authority.
|
||||
// Removing a user acts at once. Removing a provider from console.sso waits for a
|
||||
// Console restart, so take the users out of access.json first. A user's home is
|
||||
// local or a peer named in federation.peers (FED R6, #1800). A grant naming a
|
||||
// subject needs console.sso.
|
||||
// Console restart, so take the users out of access.json first. Peer homes are
|
||||
// empty until the federation key lands (row 203), so a grant naming a peer
|
||||
// home refuses until then. A grant naming a subject needs console.sso.
|
||||
export const ACCESS_HELP = `${ACCESS_USAGE}
|
||||
|
||||
A grant gives a user exactly what the granted instances can do. The instances
|
||||
@@ -37,16 +37,16 @@ the owner's role.
|
||||
To cut a user off, revoke them here first. It acts at once. Removing a provider
|
||||
from console.sso takes effect only after a Console restart, so do that second.
|
||||
user remove refuses while the user has a grant at any business: revoke those first.
|
||||
A user's home is local or a peer id from federation.peers in the system config;
|
||||
any other home refuses. A grant that names a sign-in subject needs console.sso
|
||||
Peer homes are not read yet (row 203 adds the federation key), so a grant that
|
||||
names a peer home refuses. A grant that names a sign-in subject needs console.sso
|
||||
in the system config; without it every such grant refuses.
|
||||
The command runs as the instance's uid: access.json must belong to it and must
|
||||
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
|
||||
|
||||
// The instance's context for checkAccess, read from the system config and the business
|
||||
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
|
||||
// has an id and a subjects list). peers are the ids in federation.peers (FED R6, F8),
|
||||
// empty without the key. A business file that fails to load fails the whole command.
|
||||
// has an id and a subjects list). peers is empty until row 203. A business file that
|
||||
// fails to load fails the whole command.
|
||||
export function accessContextFor(system, env) {
|
||||
const providers = system.console?.sso?.providers ?? [];
|
||||
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
|
||||
@@ -59,8 +59,7 @@ export function accessContextFor(system, env) {
|
||||
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
|
||||
}
|
||||
}
|
||||
const peers = (system.federation?.peers ?? []).map((p) => p.id);
|
||||
return { providers: providers.map((p) => p.id), peers, businesses, ssoSubjects };
|
||||
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
|
||||
}
|
||||
|
||||
// Every verb needs --business: the human proof runs through that business's bus.
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
// Everything here reads and refuses; nothing writes.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { BusinessError, configDir, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { busBusiness } from "../../bus/src/business.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { accessContextFor } from "./access.mjs";
|
||||
|
||||
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
|
||||
@@ -84,36 +83,6 @@ function trackerFor(system, b, warn) {
|
||||
};
|
||||
}
|
||||
|
||||
// FED R6 (#1800, sage-3142): the runtime's `access`, { file, context }. The broker reads the file
|
||||
// with R1's loadAccess on every user request, so a bad file leaves every user with no grant and
|
||||
// the owner's bus still boots (sage-3153). The context is the cli's accessContextFor, peers included. No file,
|
||||
// or a context that can't be built, means no `access`: no user binds, as before R6.
|
||||
function accessFor(system, env, warn) {
|
||||
const file = accessFilePath(configDir(env));
|
||||
try {
|
||||
lstatSync(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let context;
|
||||
try {
|
||||
context = accessContextFor(system, env);
|
||||
} catch (e) {
|
||||
warn(`access.json is present but its context can't be read, so no granted user binds: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
// sage-3153: one line at boot when the loader refuses the file, so the owner sees why no user
|
||||
// binds. The loader's messages can quote the file, so the line names only the path and a code
|
||||
// from its exit code: 4 is a file it could not open as a regular file, anything else invalid.
|
||||
try {
|
||||
loadAccess(file, context);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusinessError && e.exitCode !== 4 ? "access-invalid" : "access-unreadable";
|
||||
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: ${file}`);
|
||||
}
|
||||
return { file, context };
|
||||
}
|
||||
|
||||
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
|
||||
// business. The host holds a reader capability for the notifier and binds
|
||||
// launches later through bindLaunch, so `launches` starts empty.
|
||||
@@ -132,8 +101,6 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
|
||||
};
|
||||
const tracker = trackerFor(vars, b, warn);
|
||||
if (tracker) config.trackers = { [b.id]: tracker };
|
||||
const access = accessFor(system, env, warn);
|
||||
if (access) config.access = access;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
@@ -162,7 +162,6 @@ 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";
|
||||
@@ -263,9 +262,6 @@ 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
|
||||
@@ -287,34 +283,6 @@ 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(() => {});
|
||||
@@ -504,7 +472,6 @@ 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 }));
|
||||
@@ -645,7 +612,6 @@ 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);
|
||||
@@ -691,12 +657,9 @@ 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));
|
||||
@@ -744,112 +707,6 @@ 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() {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// FED R6 (#1800, sage-3142, sage-3153): the bus host's boot config carries the runtime's `access`
|
||||
// ({ file, context }) when <configDir>/access.json exists, with the cli's accessContextFor as the
|
||||
// context. The broker then binds a granted user; with no file, or a file R1's loader refuses, no
|
||||
// user binds and the owner's bus still boots. A refused file gives one boot warning naming the path
|
||||
// and a code, never the file's contents. Scratch roots only. The boot config goes through a JSON
|
||||
// round trip first, as it does over the host's IPC.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { checkAccess } from "../../business/src/index.mjs";
|
||||
import { accessContextFor } from "../src/access.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const SSO = { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } };
|
||||
const FEDERATION = { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] };
|
||||
const DOC = {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "alice", home: "factory", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
|
||||
grants: [{ id: "g-1", user: "alice", business: "acme", projects: ["*"], instances: ["*"], level: "collaborator" }],
|
||||
};
|
||||
|
||||
// F8: the context's peers are federation.peers' ids, so a user whose home is a configured peer
|
||||
// validates and one whose home is any other id, the hub's own included, refuses.
|
||||
test("R6 a configured peer id validates as a user's home and an unconfigured one refuses", (t) => {
|
||||
const root = tmp(t, "mosaic-access-peers-");
|
||||
fixture(root, "acme");
|
||||
const ctx = accessContextFor({ console: SSO, federation: FEDERATION }, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(ctx.peers, ["factory"]);
|
||||
const doc = (home) => ({ ...DOC, users: [{ ...DOC.users[0], home }] });
|
||||
assert.equal(checkAccess(doc("factory"), ctx).users[0].home, "factory");
|
||||
assert.throws(() => checkAccess(doc("elsewhere"), ctx), /home must be a configured peer/);
|
||||
assert.throws(() => checkAccess(doc("hub"), ctx), /home must be a configured peer/);
|
||||
});
|
||||
|
||||
// A scratch instance whose system config names the sign-in provider and one peer, and the boot
|
||||
// config bootConfig makes for it. `file` is the access file's text, null for none, or a function
|
||||
// that makes the file at the path.
|
||||
async function boot(t, file) {
|
||||
const f = fixture(tmp(t, "mosaic-access-boot-"), "acme");
|
||||
const system = { ...loadSystem({ env: f.env }), console: SSO, federation: FEDERATION };
|
||||
const path = join(dirname(f.config), "access.json");
|
||||
if (typeof file === "function") file(path);
|
||||
else if (file !== null) {
|
||||
writeFileSync(path, file);
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
const warnings = [];
|
||||
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: "acme", env: f.env, warn: (w) => warnings.push(w) })));
|
||||
const runtime = await startBroker(config);
|
||||
t.after(() => runtime.close());
|
||||
const bind = (user) => {
|
||||
try {
|
||||
return runtime.broker.bindUser({ business: "acme", user }) ? "bound" : "none";
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
};
|
||||
return { config, path, bind, warnings };
|
||||
}
|
||||
|
||||
test("R6 B1 a boot with access.json binds a granted user, whose home is a configured peer", async (t) => {
|
||||
const s = await boot(t, JSON.stringify(DOC));
|
||||
assert.equal(s.config.access.file, s.path);
|
||||
assert.deepEqual(s.config.access.context.peers, ["factory"]);
|
||||
assert.deepEqual(s.config.access.context.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
|
||||
assert.equal(s.bind("alice"), "bound");
|
||||
assert.equal(s.bind("bob"), "access-revoked");
|
||||
assert.deepEqual(s.warnings, []);
|
||||
});
|
||||
|
||||
test("R6 B2 a boot without access.json has no access and binds no user", async (t) => {
|
||||
const s = await boot(t, null);
|
||||
assert.equal(Object.hasOwn(s.config, "access"), false);
|
||||
assert.equal(s.bind("alice"), "access-revoked");
|
||||
assert.deepEqual(s.warnings, []);
|
||||
});
|
||||
|
||||
test("R6 B3 a boot with a file R1's loader refuses still boots, binds no user and says so once", async (t) => {
|
||||
for (const [why, file, code] of [
|
||||
["not JSON", '{"secret-marker"', "access-invalid"],
|
||||
["a home that is not a configured peer", JSON.stringify({ ...DOC, users: [{ ...DOC.users[0], home: "secret-marker" }] }), "access-invalid"],
|
||||
["a symbolic link", (path) => { writeFileSync(`${path}.real`, JSON.stringify(DOC), { mode: 0o600 }); symlinkSync(`${path}.real`, path); }, "access-unreadable"],
|
||||
]) {
|
||||
const s = await boot(t, file);
|
||||
assert.equal(s.config.access.file, s.path, why);
|
||||
assert.equal(s.bind("alice"), "access-revoked", why);
|
||||
assert.deepEqual(s.warnings, [`access file refused at boot (${code}), no granted user binds until it is fixed: ${s.path}`], why);
|
||||
assert.doesNotMatch(s.warnings[0], /secret-marker/, why);
|
||||
}
|
||||
});
|
||||
@@ -1,350 +0,0 @@
|
||||
// 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
@@ -1,18 +0,0 @@
|
||||
// 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");
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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");
|
||||
@@ -1,397 +0,0 @@
|
||||
// Clear context (row 271, 264d, #1776, T4): the launcher half of D7 and D8. A clear of a running
|
||||
// session stops its drained run once, retires it on the run's own end, and starts the successor
|
||||
// through the same check()/start() as any session; a held start waits for capacity; an earlier
|
||||
// host's live run of the cleared session holds the clear until it exits; and a session runs in its
|
||||
// own pid namespace (N6); a bus that can't answer holds a clear where it is (T6). Real runners in
|
||||
// the bwrap sandbox, with clear-adapter.mjs as the turn.
|
||||
// The bus half is packages/bus/tests/session-clear.test.mjs.
|
||||
//
|
||||
// The host is session-launch.test.mjs's, with the five clear ops the broker child forwards.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, readSessions, Registry, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/clear-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
|
||||
async function until(fn, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
// A mutant's kill is an assertion failure, not a timeout (sage-2707): the waits return what they
|
||||
// last saw and the assertions after them decide.
|
||||
async function settle(fn, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
let v;
|
||||
while (Date.now() < end) {
|
||||
v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
// The assistant is the thread instance; sonnet is its model's limit.
|
||||
function prepare(t, { sonnet = 3, idle = 30 } = {}) {
|
||||
const root = tmp(t, "mosaic-clear-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet });
|
||||
doc.launch.threadIdleSeconds = idle;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
gitIn(project, "add", "file.txt");
|
||||
gitIn(project, "commit", "-q", "-m", "one");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { tickMs = 100, stopTimeoutMs } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker(boot);
|
||||
const ops = {
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
sessionClears: (m) => runtime.sessionClears(m.business),
|
||||
sessionClearExited: (m) => runtime.sessionClearExited(m.record),
|
||||
sessionClearUncertain: (m) => runtime.sessionClearUncertain(m.record),
|
||||
sessionClearRetire: (m) => runtime.sessionClearRetire(m.record),
|
||||
sessionClearStart: (m) => runtime.sessionClearStart(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: "acme",
|
||||
path: runtime.path,
|
||||
dataRoot: f.dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [ADAPTER],
|
||||
threadTickMs: tickMs,
|
||||
...(stopTimeoutMs ? { stopTimeoutMs } : {}),
|
||||
log: (l) => logs.push(l),
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
return { host, runtime, launcher, call, logs, close };
|
||||
}
|
||||
|
||||
const launchLog = (f) =>
|
||||
existsSync(launchLogFile(f.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
|
||||
: [];
|
||||
const events = (f, event) => launchLog(f).filter((e) => e.event === event);
|
||||
const sessionWs = (f, session) => join(f.dataRoot, "session-workspaces", "acme", "assistant", session);
|
||||
const historyOf = (f, thread, session) => {
|
||||
const p = join(f.dataRoot, "threads", "acme", thread, "sessions", session, "session", "history");
|
||||
return existsSync(p) ? readFileSync(p, "utf8") : null;
|
||||
};
|
||||
|
||||
function send(call, thread, session, body) {
|
||||
const sent = call("thread.send", { thread, ...(session ? { session } : {}), body });
|
||||
const reply = async () => {
|
||||
const r = await until(() => call("thread.messages", { thread, ...(session ? { session } : {}) }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT"));
|
||||
assert.equal(r.from_role, "assistant");
|
||||
return JSON.parse(r.body.match(/THREAD (\{.*\})/)[1]);
|
||||
};
|
||||
return { sent, reply };
|
||||
}
|
||||
|
||||
function thread(call) {
|
||||
const project = call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const A = call("thread.create", { project, name: "alpha" }).id;
|
||||
const [{ id: D }] = call("session.list", { thread: A });
|
||||
return { project, A, D };
|
||||
}
|
||||
|
||||
test("REQ-THREAD-2 T4 D7 D8 N6: a clear of a running session stops its drained run once, retires it on the run's end, and starts the successor fresh in its own namespace", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const { A, D } = thread(host.call);
|
||||
const first = await send(host.call, A, D, "before the clear").reply();
|
||||
assert.deepEqual([first.before, first.cwd], [[], sessionWs(f, D)]);
|
||||
// N6: the run sees its own pid namespace. Pid 1 there is the sandboxed command, not bwrap, and
|
||||
// the host's processes aren't listed.
|
||||
assert.notEqual(first.pid1[0], BWRAP);
|
||||
assert.ok(first.pids.length < 20, JSON.stringify(first.pids));
|
||||
assert.equal(first.pids.includes(1), true);
|
||||
const [{ run }] = events(f, "launch");
|
||||
|
||||
const op = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" });
|
||||
const S = op.successor;
|
||||
const done = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start, done.refusal], ["retired", "started", null]);
|
||||
// The drained run got one stop, and ended stopped; the clear retired it after that end.
|
||||
const stops = events(f, "session-clear-stop");
|
||||
assert.deepEqual(stops.map((e) => [e.session, e.run, e.op]), [[D, run, "clear-1"]]);
|
||||
assert.deepEqual(events(f, "session-clear-uncertain"), []);
|
||||
const kinds = launchLog(f).map((e) => [e.event, e.session ?? null]).filter(([e]) => ["session-clear-stop", "end", "session-clear-retired", "launch"].includes(e));
|
||||
assert.deepEqual(kinds, [
|
||||
["launch", D],
|
||||
["session-clear-stop", D],
|
||||
["end", D],
|
||||
["session-clear-retired", D],
|
||||
["launch", S],
|
||||
]);
|
||||
assert.deepEqual(events(f, "session-clear-retired").map((e) => [e.successor, e.op]), [[S, "clear-1"]]);
|
||||
// The successor is the default now; a thread-only send reaches it, with no history and its own
|
||||
// workspace. The old session's store stays as it was.
|
||||
const after = await send(host.call, A, null, "after the clear").reply();
|
||||
assert.deepEqual([after.body, after.before, after.cwd], ["after the clear", [], sessionWs(f, S)]);
|
||||
assert.equal(historyOf(f, A, D), "before the clear\n");
|
||||
assert.equal(historyOf(f, A, S), "after the clear\n");
|
||||
assert.deepEqual(host.call("session.list", { thread: A }).map((r) => [r.id, r.state, r.default]), [
|
||||
[D, "retired", false],
|
||||
[S, "active", true],
|
||||
]);
|
||||
assert.deepEqual(readSessions(f.dataRoot).map((s) => s.session), [S]);
|
||||
});
|
||||
|
||||
test("REQ-THREAD-2 T4 D8: a successor start refused capacity-full is held, reported and logged once, and starts when capacity frees", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t, { sonnet: 1, idle: 4 });
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const { A, D } = thread(host.call);
|
||||
const N = host.call("session.create", { thread: A, name: "side", op: "op-side" }).id;
|
||||
// The side session takes the one sonnet slot; the default never ran, so its clear has no run.
|
||||
await send(host.call, A, N, "fill the slot").reply();
|
||||
const S = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" }).successor;
|
||||
const held = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "held" && e;
|
||||
});
|
||||
assert.deepEqual([held.state, held.start, held.refusal], ["retired", "held", null]);
|
||||
assert.deepEqual(events(f, "session-clear-stop"), [], "no run to stop");
|
||||
// The side session idles out, and the held start goes on the next tick after.
|
||||
const started = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.equal(started.start, "started");
|
||||
assert.deepEqual(events(f, "launch").map((e) => e.session), [N, S]);
|
||||
const lines = host.logs.filter((l) => l.startsWith("clear clear-1:"));
|
||||
assert.deepEqual(lines, [`clear clear-1: successor ${S} (assistant): start held: capacity-full`]);
|
||||
assert.deepEqual(events(f, "refused").map((e) => [e.session, e.code]), [[S, "capacity-full"]]);
|
||||
});
|
||||
|
||||
// sage-2830, Q2: after a restart, an earlier host's run of the cleared session that outlived
|
||||
// recover() was ended host-lost, but it still runs. Its exit, not that end, is the proof, and it
|
||||
// isn't signalled. A sleep child stands in for its runner, as in session-launch.test.mjs's Q2 test.
|
||||
test("REQ-THREAD-2 T4 D7 Q2: after a restart, a live survivor of the cleared run holds the clear at drained with no signal, and its exit lets it retire", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const one = await start(t, prepared);
|
||||
const { A, D } = thread(one.call);
|
||||
const pid = 2 ** 22 + 7;
|
||||
const record = { business: "acme", role: "assistant", run: "run-left", harness: "pi", pid, startTime: "1", thread: A, session: D };
|
||||
const left = one.runtime.bindLaunch(record);
|
||||
one.runtime.broker.request(left.cap, { verb: "role.claim" });
|
||||
one.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" });
|
||||
assert.deepEqual(one.runtime.broker.request(left.cap, { verb: "message.receive" }), []);
|
||||
assert.deepEqual(one.runtime.sessionClears("acme").map((c) => [c.stage, c.run]), [["drained", "run-left"]]);
|
||||
await one.close();
|
||||
|
||||
const runner = spawn("sleep", ["60"], { stdio: "ignore" });
|
||||
const gone = new Promise((r) => runner.once("exit", r));
|
||||
t.after(() => runner.kill("SIGKILL"));
|
||||
const runnerStartTime = await until(() => startTimeOf(runner.pid));
|
||||
new Registry(f.dataRoot).add({ business: "acme", instance: "assistant", thread: A, session: D, run: "run-left", harness: "pi", pid, startTime: "1", runnerPid: runner.pid, runnerStartTime });
|
||||
const two = await start(t, prepared);
|
||||
assert.ok(two.logs.includes(`run run-left (assistant) from an earlier host ended: host-lost`), two.logs.join("\n"));
|
||||
// Ended in the bus, live on the host: the clear waits, and nothing is signalled.
|
||||
for (let i = 0; i < 5; i++) await two.launcher.tick();
|
||||
assert.deepEqual(two.runtime.sessionClears("acme").map((c) => c.stage), ["drained"]);
|
||||
assert.deepEqual(events(f, "session-clear-stop"), []);
|
||||
assert.equal(startTimeOf(runner.pid), runnerStartTime, "the survivor wasn't signalled");
|
||||
|
||||
runner.kill("SIGKILL");
|
||||
await gone;
|
||||
const done = await settle(() => {
|
||||
const e = two.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start], ["retired", "started"]);
|
||||
assert.deepEqual(events(f, "session-clear-stop"), []);
|
||||
});
|
||||
|
||||
// D7 step 3: a drained run that doesn't exit within the stop timeout. The ticks are the test's, so
|
||||
// its runner is stopped from outside its namespace before the clear's one TERM, as
|
||||
// launcher.test.mjs's SIGTERM test does, and the TERM waits. The clear records uncertain once,
|
||||
// sends nothing more, and retires when the run's end comes.
|
||||
test("REQ-THREAD-2 T4 D7: a drained run that outlives the stop timeout makes the clear uncertain once, with one signal, and it retires on the run's end", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared, { tickMs: 3600000, stopTimeoutMs: 1000 });
|
||||
const tick = () => host.launcher.tick();
|
||||
const { A, D } = thread(host.call);
|
||||
const first = send(host.call, A, D, "before the clear");
|
||||
await tick();
|
||||
await first.reply();
|
||||
const [s] = readSessions(f.dataRoot);
|
||||
const kill = () => {
|
||||
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
|
||||
};
|
||||
t.after(kill);
|
||||
host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" });
|
||||
// The run's own receive drains it; no tick is needed for that.
|
||||
const drained = await settle(() => host.runtime.sessionClears("acme").find((c) => c.stage === "drained"));
|
||||
assert.equal(drained?.run, s.run);
|
||||
process.kill(s.runnerPid, "SIGSTOP");
|
||||
await tick();
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
const uncertain = await settle(async () => {
|
||||
await tick();
|
||||
return host.runtime.sessionClears("acme").find((c) => c.stage === "uncertain");
|
||||
}, 15000);
|
||||
assert.equal(uncertain?.run, s.run);
|
||||
for (let i = 0; i < 5; i++) await tick();
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
assert.equal(events(f, "session-clear-uncertain").length, 1);
|
||||
assert.equal(startTimeOf(s.runnerPid), s.runnerStartTime, "nothing forced it");
|
||||
process.kill(s.runnerPid, "SIGCONT");
|
||||
const done = await settle(async () => {
|
||||
await tick();
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start], ["retired", "started"]);
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
assert.equal(events(f, "session-clear-uncertain").length, 1);
|
||||
});
|
||||
|
||||
// T6 (sage-3001): the bus that proves the stop is configured but unavailable. Its host's op
|
||||
// rejects as host.mjs does when the broker exits before it replies (broker-exited), first at the
|
||||
// exit proof, then at retirement. While it is down nothing retires, no successor launches and the
|
||||
// cleared session's slot isn't handed on; each failure is logged once, and the clear resumes where
|
||||
// it stopped once the bus answers again.
|
||||
test("REQ-THREAD-2 T4 T6: a bus unavailable at the exit proof and then at retirement holds the clear with no retirement, successor or launch, and it resumes when the bus is back", { skip, timeout: 90000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const down = new Set();
|
||||
const op = host.host.op;
|
||||
host.host.op = (m) => (down.has(m.op) ? Promise.reject(Object.assign(new Error("broker exited before it replied"), { code: "broker-exited" })) : op(m));
|
||||
const { A, D } = thread(host.call);
|
||||
await send(host.call, A, D, "before the clear").reply();
|
||||
const stage = () => host.runtime.sessionClears("acme").map((c) => c.stage);
|
||||
const lines = () => host.logs.filter((l) => l.startsWith("clear clear-1") || l.startsWith("thread tick:"));
|
||||
const line = `clear clear-1 (session ${D}): broker-exited`;
|
||||
const held = () => {
|
||||
assert.deepEqual(events(f, "session-clear-retired"), []);
|
||||
assert.deepEqual(events(f, "session-clear-failed"), []);
|
||||
assert.deepEqual(events(f, "launch").map((e) => e.session), [D]);
|
||||
assert.deepEqual(readSessions(f.dataRoot), [], "nothing runs in the cleared session's slot");
|
||||
assert.deepEqual(host.call("session.list", { thread: A }).map((r) => [r.id, r.default]), [[D, true]]);
|
||||
};
|
||||
|
||||
down.add("sessionClearExited");
|
||||
const S = host.call("session.clear", { thread: A, session: D, revision: 0, confirm: true, op: "clear-1" }).successor;
|
||||
// The drained run is stopped and ends; the exit proof is what fails.
|
||||
await settle(() => events(f, "end").length === 1 && host.logs.includes(line));
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
assert.deepEqual(lines(), [line]);
|
||||
assert.deepEqual(stage(), ["drained"]);
|
||||
assert.equal(events(f, "session-clear-stop").length, 1);
|
||||
held();
|
||||
|
||||
down.clear();
|
||||
down.add("sessionClearRetire");
|
||||
await settle(() => stage()[0] === "exited");
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
assert.deepEqual(stage(), ["exited"]);
|
||||
assert.deepEqual(lines(), [line], "the same code isn't logged again");
|
||||
held();
|
||||
|
||||
down.clear();
|
||||
const done = await settle(() => {
|
||||
const e = host.call("session.operation", { thread: A, op: "clear-1" });
|
||||
return e.start === "started" && e;
|
||||
}, 30000);
|
||||
assert.deepEqual([done.state, done.start, done.refusal], ["retired", "started", null]);
|
||||
const kinds = launchLog(f).map((e) => [e.event, e.session ?? null]).filter(([e]) => ["session-clear-stop", "end", "session-clear-retired", "launch"].includes(e));
|
||||
assert.deepEqual(kinds, [
|
||||
["launch", D],
|
||||
["session-clear-stop", D],
|
||||
["end", D],
|
||||
["session-clear-retired", D],
|
||||
["launch", S],
|
||||
]);
|
||||
assert.deepEqual(lines(), [line]);
|
||||
assert.deepEqual(host.call("session.list", { thread: A }).map((r) => [r.id, r.state, r.default]), [
|
||||
[D, "retired", false],
|
||||
[S, "active", true],
|
||||
]);
|
||||
});
|
||||
@@ -295,10 +295,9 @@ export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "ha
|
||||
assert.notEqual(KINDS_OLD,KINDS_NEW);
|
||||
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
|
||||
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader. Row 203: B's
|
||||
// loader also refuses a `federation` key, as a pre-203 loader does (its list has no federation).
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader.
|
||||
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
|
||||
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console", "federation"]','["configVersion", "environment", "dataRoot", "execution"]');
|
||||
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
|
||||
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
|
||||
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
|
||||
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
|
||||
|
||||
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
|
||||
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
|
||||
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
|
||||
// well as T46.
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels'];
|
||||
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 LATE='2999-01-01T00:00:00.000Z';
|
||||
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
|
||||
|
||||
@@ -1468,7 +1468,7 @@ const SHOW=`show-${CON}.txt`;
|
||||
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
|
||||
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
|
||||
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --tester-port as the pointer has it`;
|
||||
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console or federation key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
||||
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
||||
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
|
||||
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
|
||||
function setEdit(set,name,from,to,sums=true){
|
||||
@@ -1582,19 +1582,6 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('row 203: a config whose federation key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
// B's loader has no federation key in its list, as a pre-203 loader has none: it refuses the key.
|
||||
const setFed=(fed)=>{const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(fed===undefined)delete c.federation;else c.federation=fed;writeFileSync(e.cfg,JSON.stringify(c))};
|
||||
setFed({self:'hub',peers:[{id:'factory',origin:'https://mosaic.woltje.com',label:'Software factory'}]});
|
||||
const why='mosaic-config: unsupported configuration key: "federation"';
|
||||
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
||||
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
||||
setFed(undefined);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
@@ -1,230 +0,0 @@
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
// FED R6 (#1800, federation design 3.1 D3 and D5): who besides the owner may sign in to this
|
||||
// Console. `load` is R1's loadAccess over this instance's access.json and context, called on every
|
||||
// use and never cached, so a revoke, a removed subject or an expired grant counts on the next
|
||||
// request. A user is live while one grant in the Console's business covers at `viewer`, the same
|
||||
// test as the broker's #userGrants. The broker still decides every verb, project, instance and
|
||||
// thread (D4). Any failure to load, or to name the business, finds nobody (fail closed).
|
||||
import { covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
export function consoleAccess({ load, business }) {
|
||||
if (typeof load !== 'function' || typeof business !== 'function') throw new TypeError('consoleAccess needs load and business functions');
|
||||
const live = (doc, id, b) => doc.grants.some(g => g.user === id && g.business === b &&
|
||||
covers(g, { business: b, project: g.projects[0], instance: g.instances[0] }, new Date(), { level: 'viewer' }));
|
||||
const lookup = (provider, sub) => {
|
||||
try {
|
||||
const doc = load(), b = business();
|
||||
const user = doc.users.find(u => u.subjects.some(s => s.provider === provider && s.sub === sub));
|
||||
return user && live(doc, user.id, b) ? user.id : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
return Object.freeze({
|
||||
// The user id this (provider, sub) signs in as, or null.
|
||||
find: ({ provider, sub }) => lookup(provider, sub),
|
||||
// Is this session's user still the holder of its own subject, with a live grant?
|
||||
check: session => typeof session?.user === 'string' && lookup(session.provider, session.sub) === session.user,
|
||||
});
|
||||
}
|
||||
@@ -17,8 +17,6 @@ const STATUS = {
|
||||
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
|
||||
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
|
||||
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
|
||||
// FED R6 (#1800): the broker's refusals of a granted user.
|
||||
'access-refused': 403, 'access-revoked': 401,
|
||||
};
|
||||
function safeMessage(code) {
|
||||
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
|
||||
|
||||
@@ -6,14 +6,9 @@ import { createChannelCaller } from '../../plugins/src/channel.mjs';
|
||||
import { PluginError } from '../../plugins/src/kinds.mjs';
|
||||
import { BusReadError } from './bus.mjs';
|
||||
|
||||
// FED R6 (#1800, sage-3153): `owner`, when given, is the owner's transport, and it carries only
|
||||
// the channel settings read, `plugins` with {}. A granted user's chat needs it since `plugins` is
|
||||
// not a user verb. The result gates the webui channel server-side and is never sent to the user.
|
||||
// Every chat verb goes over the user's own `call`.
|
||||
export function createChat({ call, owner = null }) {
|
||||
export function createChat({ call }) {
|
||||
if (typeof call !== 'function') throw new TypeError('chat needs a bus transport function');
|
||||
if (owner !== null && typeof owner !== 'function') throw new TypeError('chat owner must be a bus transport function');
|
||||
const dispatch = createChannelCaller({ name: 'webui', call: owner === null ? call : (verb, args) => verb === 'plugins' ? owner('plugins', {}) : call(verb, args) });
|
||||
const dispatch = createChannelCaller({ name: 'webui', call });
|
||||
const channel = async (verb, args) => {
|
||||
try { return await dispatch(verb, args); }
|
||||
catch (err) {
|
||||
|
||||
+26
-21
@@ -3,11 +3,6 @@
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busCaller } from './bus.mjs';
|
||||
import { busBusiness, userCaller } from './user-bus.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
import { accessFilePath, loadAccess } from '../../business/src/access.mjs';
|
||||
import { startTesterSocket } from './tester-socket.mjs';
|
||||
import { TESTER_CLI } from '../../cli/src/transport.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
@@ -18,6 +13,14 @@ import { createSso, readSlot } from './sso.mjs';
|
||||
import { statSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
|
||||
// agentWrite reaches only these.
|
||||
const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
|
||||
const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||
const agentCaller = (call, verbs) => async (verb, args) => {
|
||||
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
return call(verb, args);
|
||||
};
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
@@ -70,13 +73,24 @@ try {
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
|
||||
let processing = null, ownerCall = null;
|
||||
let processing = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
processing = { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) };
|
||||
chat = createChat({ call });
|
||||
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
|
||||
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
|
||||
// (mos-0840): so is `routes`, and its write `routes.set`.
|
||||
// Row 278: this owner/reader read belongs to the Console, not the shared
|
||||
// role views or the tester adapter. The bus decides caller eligibility.
|
||||
bus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') };
|
||||
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
||||
projectFiles = args => call('project.files', args);
|
||||
projectFile = args => call('project.file', args);
|
||||
libraryFile = args => call('library.file', args); libraryStatus = args => call('library.status', args);
|
||||
if (sendOn) {send = args => call('message.send', args);librarySync=args=>call('library.sync',args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);}
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -88,24 +102,15 @@ try {
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
// The sign-in config is checked again here, then each slot file, before anything listens. A
|
||||
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
|
||||
let sso = null, userOptions = null;
|
||||
let sso = null;
|
||||
if (ssoOn) {
|
||||
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
|
||||
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
|
||||
// FED R6 (#1800): access.json users sign in too. R1's loader reads the file on every check,
|
||||
// with the same context as `mosaic access`; a missing or refused file signs nobody in.
|
||||
const users = consoleAccess({
|
||||
load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)),
|
||||
business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }),
|
||||
});
|
||||
sso = await createSso(system.console.sso, { secrets, users });
|
||||
// Each request of a granted user's session gets the bus options over that user's own
|
||||
// user-cli transport. The human transport above serves only the chat's `plugins` read (C6).
|
||||
userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) });
|
||||
sso = await createSso(system.console.sso, { secrets });
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, userOptions, federation: system?.federation ?? null });
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
// FED R6 (#1800): the Console's bus-backed options for startServer, built over one transport
|
||||
// `call`. cli.mjs builds them once over the human transport. A granted user's session gets the same
|
||||
// set over its own user transport (user-bus.mjs userCaller), so no route a user reaches holds a
|
||||
// human-transport object, and the broker decides every verb the user sends (design 3.1 D4).
|
||||
import { createChat } from './chat.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
|
||||
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
|
||||
// agentWrite reaches only these.
|
||||
export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
|
||||
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||
export const agentCaller = (call, verbs) => async (verb, args) => {
|
||||
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
return call(verb, args);
|
||||
};
|
||||
|
||||
// `send` is the --send flag: without it every write is null. `owner` is the owner's transport for
|
||||
// a granted user's options; it goes to createChat, which sends it only the `plugins` read.
|
||||
export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||
const o = {
|
||||
processing: { read: () => call('processing.status'), halt: args => call('processing.halt', args), resume: args => call('processing.resume', args) },
|
||||
chat: createChat({ call, owner }),
|
||||
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views. Row 165
|
||||
// (#1662): so is `instances`, and its write `instances.set` is only under --send. Row 65
|
||||
// (mos-0840): so is `routes`, and its write `routes.set`.
|
||||
// Row 278: this owner/reader read belongs to the Console, not the shared
|
||||
// role views or the tester adapter. The bus decides caller eligibility.
|
||||
bus: { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes'), 'thread.claims': () => call('thread.claims') },
|
||||
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
||||
projectFiles: args => call('project.files', args),
|
||||
projectFile: args => call('project.file', args),
|
||||
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
|
||||
send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
};
|
||||
if (sendOn) {
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
|
||||
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
// A granted user's options per session: consoleBus over `transport(session.user)`, that user's own
|
||||
// transport, with the owner's transport `owner` passed as it is. Nothing from the session or the
|
||||
// request reaches `owner` (mos-1394, sage-3157): createChat sends it only `plugins` with {}.
|
||||
export function userOptionsFor({ owner, transport, send = false }) {
|
||||
if (typeof owner !== 'function' || typeof transport !== 'function') throw new TypeError('userOptionsFor needs owner and transport functions');
|
||||
return session => consoleBus(transport(session.user), { send, owner });
|
||||
}
|
||||
@@ -476,5 +476,5 @@
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; window.MosaicFederation?.render(config.federation, $('federation')); if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
@@ -1,26 +0,0 @@
|
||||
/* FED R6 (#1800): the hub label and plain peer links from /api/config's `federation`. Text and
|
||||
href only: no script, no fetch to a peer and no proxy (the hub proxy route is R8's). */
|
||||
(() => {
|
||||
'use strict';
|
||||
function render(federation, container) {
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
if (!federation || typeof federation !== 'object') { container.hidden = true; return; }
|
||||
if (typeof federation.self === 'string') {
|
||||
const label = document.createElement('span');
|
||||
label.id = 'hub-label';
|
||||
label.textContent = `This Console: ${federation.self}`;
|
||||
container.append(label);
|
||||
}
|
||||
for (const peer of Array.isArray(federation.peers) ? federation.peers : []) {
|
||||
if (typeof peer?.origin !== 'string' || !/^https?:\/\//.test(peer.origin)) continue;
|
||||
const a = document.createElement('a');
|
||||
a.className = 'peer-link';
|
||||
a.setAttribute('href', peer.origin);
|
||||
a.textContent = typeof peer.label === 'string' && peer.label ? peer.label : peer.origin;
|
||||
container.append(' ', a);
|
||||
}
|
||||
container.hidden = !container.childNodes.length;
|
||||
}
|
||||
window.MosaicFederation = Object.freeze({ render });
|
||||
})();
|
||||
@@ -4,12 +4,11 @@
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
|
||||
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
|
||||
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
|
||||
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
|
||||
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-design="console" data-palette="harbor">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/chat.css">
|
||||
<script src="/federation.js" defer></script><script src="/chat.js" defer></script><script src="/user.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
<nav id="federation" class="federation small" aria-label="Consoles" hidden></nav>
|
||||
<div class="cmd-right"><form id="sign-out" method="post" action="/auth/logout"><button class="btn" type="submit">Sign out</button></form></div></header>
|
||||
<main id="main" class="content" tabindex="-1">
|
||||
<div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden>You are signed out. <a href="/">Sign in again</a>.</div>
|
||||
<p id="user-notice" class="banner ref">The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages.</p>
|
||||
<div id="chat"></div>
|
||||
</main>
|
||||
<footer class="foot"><span id="footer">Mosaic Stack</span></footer>
|
||||
</body></html>
|
||||
@@ -1,23 +0,0 @@
|
||||
/* FED R6 (#1800): a granted user's page. Projects, threads and messages through chat.js; the
|
||||
broker decides what this user reads and sends. No owner control is on this page. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
let canSend = false, ready = false;
|
||||
const route = () => {
|
||||
const path = location.hash.slice(1).split('?')[0];
|
||||
return path.startsWith('/chat') ? path : '/chat';
|
||||
};
|
||||
function show(navigated) {
|
||||
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, navigated });
|
||||
}
|
||||
document.addEventListener('mosaic-signed-out', () => { $('signed-out').hidden = false; $('sign-out').hidden = true; });
|
||||
window.addEventListener('hashchange', () => show(true));
|
||||
fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(async res => {
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) { document.dispatchEvent(new Event('mosaic-signed-out')); return; }
|
||||
const config = await res.json();
|
||||
canSend = config.send === true;
|
||||
window.MosaicFederation?.render(config.federation, $('federation'));
|
||||
ready = true; show(true);
|
||||
}).catch(() => { $('chat').textContent = 'The Console could not be read. Refresh to try again.'; });
|
||||
})();
|
||||
@@ -27,7 +27,7 @@ export function boardURL(value) {
|
||||
const root = resolve(import.meta.dirname, 'public');
|
||||
const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||||
@@ -35,26 +35,6 @@ const files = new Map([
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
|
||||
]);
|
||||
// FED R6 (#1800): a granted user's page. The owner page's markup and scripts are never served to a
|
||||
// user session, so its controls are not sent at all (design 3.1 D5).
|
||||
const USER_FILES = new Map([
|
||||
['/', ['user.html', 'text/html; charset=utf-8']],
|
||||
...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'),
|
||||
]);
|
||||
// The only routes a granted user's session reaches: the chat, the page config and the user page.
|
||||
// Anything else is the owner's, so a route added later is owner-only until it is added here.
|
||||
export function userRoute(path, method) {
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) return true;
|
||||
if (method !== 'GET' && method !== 'HEAD') return false;
|
||||
return path === '/api/config' || path === '/favicon.ico' || USER_FILES.has(path);
|
||||
}
|
||||
// The hub label and plain peer links, from system.federation: ids, labels and origins only.
|
||||
function federationView(federation) {
|
||||
if (federation === null || typeof federation !== 'object') return null;
|
||||
return { self: federation.self ?? null, peers: (federation.peers ?? []).map(({ id, label, origin }) => ({ id, label, origin })) };
|
||||
}
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
@@ -599,7 +579,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
@@ -611,11 +591,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
|
||||
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
|
||||
const upstream = boardURL(board);
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
||||
const hub = federationView(federation);
|
||||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
@@ -623,8 +598,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
|
||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
const server = createServer(async (req, res) => {
|
||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
@@ -680,14 +653,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
return res.end();
|
||||
}
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
// FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport
|
||||
// object is left, so a route past the gate reaches the broker only as this user.
|
||||
if (session?.user !== undefined) {
|
||||
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
reads = null; upstream = null; files = USER_FILES; granted = true;
|
||||
}
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
@@ -750,7 +715,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||||
if (allowed) {
|
||||
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
|
||||
if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' });
|
||||
let bytes;
|
||||
if (allowed === 'POST') {
|
||||
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
||||
@@ -799,8 +763,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -22,7 +22,7 @@ const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
|
||||
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
|
||||
const FETCH_TIMEOUT = 10000;
|
||||
const RETRY_CAP = 300000;
|
||||
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'discovery-failed', 'jwks-failed']);
|
||||
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
|
||||
|
||||
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
||||
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
||||
@@ -123,9 +123,7 @@ async function discover(p, fetchOptions) {
|
||||
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
|
||||
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
|
||||
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
|
||||
// `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the
|
||||
// owner's signs in as the access-file user with a live grant, and each request checks it again.
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null } = {}) {
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
|
||||
checkConsole({ sso: config });
|
||||
oauth ??= await import('oauth4webapi');
|
||||
const publicOrigin = config.publicOrigin;
|
||||
@@ -280,18 +278,14 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
|
||||
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
|
||||
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
|
||||
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
|
||||
let name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
|
||||
// FED R6: console.sso subjects and access-file subjects are disjoint (R1), so the owner's
|
||||
// list is asked first and a user is asked only on a miss.
|
||||
const user = name === undefined && typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null;
|
||||
if (user !== null) name = user;
|
||||
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
|
||||
if (name === undefined) {
|
||||
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 7: the session, then the return path.
|
||||
const id = randomBytes(32).toString('base64url'), now = Date.now();
|
||||
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
||||
}
|
||||
|
||||
@@ -304,13 +298,6 @@ export async function createSso(config, { secrets, fetch: customFetch = null, lo
|
||||
if (!s) return null;
|
||||
const now = Date.now();
|
||||
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
|
||||
// FED R6 (design 3.1 D5): a user's grant is read again on every request, so a user revoked,
|
||||
// removed or expired since sign-in has no session from the next request on.
|
||||
if (s.user !== undefined && !users?.check(s)) {
|
||||
sessions.delete(key);
|
||||
refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`);
|
||||
return null;
|
||||
}
|
||||
if (touch) s.seen = now;
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Kept apart
|
||||
// from bus.mjs so the human transport's reviewed bytes stay as row 164 froze them
|
||||
// (bus/tests/channel-proof.test.mjs). Same protocol as humanCall: the request on stdin, JSON on
|
||||
// stdout, or a bus error code as the last `^[a-z-]{1,64}$` line of stderr.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { BusReadError } from './bus.mjs';
|
||||
|
||||
export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url));
|
||||
|
||||
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
|
||||
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
|
||||
// can never pass as human-cli, and it only ever runs USER_CLI.
|
||||
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
|
||||
out.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
|
||||
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
|
||||
child.on('close', status => {
|
||||
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
|
||||
if (status !== 0) {
|
||||
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
|
||||
return done(reject, new BusReadError(code));
|
||||
}
|
||||
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
|
||||
export function busBusiness({ dataRoot, business = null }) {
|
||||
return () => {
|
||||
if (business) return business;
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
return state.business;
|
||||
};
|
||||
}
|
||||
|
||||
// One granted user's caller, for that user's session only.
|
||||
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
|
||||
const which = busBusiness({ dataRoot, business });
|
||||
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
// FED R6 (#1800, federation design 3.1 D3-D5, 3.4 and 3.7): a granted user signs in to the
|
||||
// Console. Every provider is the fake OP in helpers/fake-oidc.mjs on loopback; no test reaches a
|
||||
// real provider, the live Console or a real config, slot or access file. The broker is an
|
||||
// in-process Broker with an access function (bus/tests/user-principal.test.mjs), reached through a
|
||||
// per-session user transport that binds the user on every call, as user-cli.mjs does. The real
|
||||
// user-cli spawn is checked on its own in G8 against a recording socket.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request, createServer as createHttpServer } from 'node:http';
|
||||
import { createServer as createNetServer } from 'node:net';
|
||||
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 '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
import { startServer, userRoute } from '../src/serve.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { consoleBus, userOptionsFor } from '../src/console-bus.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { USER_CLI, userCall } from '../src/user-bus.mjs';
|
||||
import { close, fixture } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
const PUBLIC = new URL('../src/public/', import.meta.url);
|
||||
const NOTICE = "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your messages.";
|
||||
const OWNER_MARKER = 'owner-plugins-marker-7f3a';
|
||||
const FEDERATION = { self: 'hub', peers: [{ id: 'factory', origin: 'https://factory.example.test', label: 'Software factory' }] };
|
||||
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
|
||||
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
|
||||
const writeHeaders = cookie => ({ cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over });
|
||||
const ALICE = { id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] };
|
||||
|
||||
// The fake OP, an in-process broker for business `demo`, createSso with the access hook, and the
|
||||
// human listener. The owner's options are counting stubs; each user session's options are
|
||||
// consoleBus over that user's transport. `counts` records every owner stub and user transport call.
|
||||
async function setup(t, { users = [ALICE], grants = [grant()] } = {}) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = op.issuer('a');
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-granted-user-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const access = { doc: { accessVersion: 1, revision: 1, users, grants }, fail: false };
|
||||
const load = () => { if (access.fail) throw new Error('access file refused'); return access.doc; };
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8')).messaging } },
|
||||
},
|
||||
},
|
||||
access: load,
|
||||
});
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const counts = { owner: 0, user: 0 };
|
||||
// As user-cli.mjs: a fresh bind per call, and the broker's refusal code as the transport's.
|
||||
// `userVerbs` and `ownerVerbs` record what each transport carried for a user session.
|
||||
const userVerbs = [], ownerVerbs = [];
|
||||
const userTransport = user => async (verb, args = {}) => {
|
||||
counts.user++; userVerbs.push(verb);
|
||||
try { return broker.request(broker.bindUser({ business: 'demo', user }), { verb, args }); }
|
||||
catch (e) { throw new BusReadError(e.code ?? 'invalid-response'); }
|
||||
};
|
||||
// The owner's transport as cli.mjs passes it (ownerCall). Its answer carries a marker that must
|
||||
// never reach a user's response.
|
||||
const ownerCall = async (verb, args) => { ownerVerbs.push([verb, args]); return { plugins: { channel: ['webui'] }, marker: OWNER_MARKER }; };
|
||||
const opened = [];
|
||||
const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: user => { opened.push(user); return userTransport(user); } });
|
||||
const lines = [];
|
||||
const log = line => lines.push(line);
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log, users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
t.after(() => sso.close());
|
||||
const stub = name => async () => { counts.owner++; return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
|
||||
const ownerBus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(v)]));
|
||||
const owner = {
|
||||
bus: ownerBus, send: stub('send'), settingsWrite: stub('settings'), projectFiles: stub('files'), projectFile: stub('file'), projectSave: stub('save'),
|
||||
nameWrite: stub('name'), assistantStop: stub('stop'), instancesWrite: stub('instances'), routesWrite: stub('routes'), agentWrite: stub('agent'),
|
||||
processing: { read: stub('processing'), halt: stub('halt'), resume: stub('resume') },
|
||||
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(v)])),
|
||||
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(v)])),
|
||||
};
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log, ...owner, userOptions, federation: FEDERATION });
|
||||
t.after(() => close(web));
|
||||
const p1 = broker.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
|
||||
const p2 = broker.request(human, { verb: 'project.create', args: { name: 'private' } }).id;
|
||||
return { op, iss, broker, human, store, access, counts, opened, lines, port: web.address().port, p1, p2, userTransport, ownerCall, userVerbs, ownerVerbs };
|
||||
}
|
||||
|
||||
// A whole sign-in with `sub`: start, a code at the OP, the callback.
|
||||
async function signIn(env, sub) {
|
||||
const start = await call(env.port, { path: '/auth/login' });
|
||||
assert.equal(start.status, 303);
|
||||
const cookie = setCookies(start)[0].split(';')[0];
|
||||
const location = start.headers.location;
|
||||
const g = env.op.grant(env.iss, location, { sub });
|
||||
const q = new URLSearchParams({ code: g.code, state: new URL(location).searchParams.get('state') });
|
||||
const r = await call(env.port, { path: `/auth/callback?${q}`, headers: { cookie } });
|
||||
return { r, session: sessionOf(r) };
|
||||
}
|
||||
const events = env => env.store.get('SELECT count(*) n FROM events').n;
|
||||
|
||||
test('R6 G1 a granted user signs in, gets the user page and reaches the broker as itself', async t => {
|
||||
const env = await setup(t);
|
||||
env.access.doc.grants = [grant({ projects: [env.p1] })];
|
||||
const { r, session } = await signIn(env, 'alice-sub');
|
||||
assert.equal(r.status, 303, JSON.stringify(r.body));
|
||||
assert.ok(session, 'a session cookie was set');
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.status, 200);
|
||||
assert.equal(config.body.user, true);
|
||||
assert.equal(config.body.edit, false);
|
||||
assert.deepEqual(config.body.federation, FEDERATION);
|
||||
const page = await call(env.port, { path: '/', headers: { cookie: session } });
|
||||
assert.equal(page.status, 200);
|
||||
assert.match(page.body, /<script src="\/user\.js" defer><\/script>/);
|
||||
assert.doesNotMatch(page.body, /app\.js|bus\.js|agents\.js|processing\.js|editor\.js/);
|
||||
// Only the granted project shows: the broker answered as alice, not as the owner.
|
||||
const projects = await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } });
|
||||
assert.equal(projects.status, 200, JSON.stringify(projects.body));
|
||||
assert.deepEqual(projects.body.rows.map(p => p.id), [env.p1]);
|
||||
const made = await call(env.port, { method: 'POST', path: '/api/chat/threads', headers: writeHeaders(session), body: JSON.stringify({ project: env.p1, name: 'alice work' }) });
|
||||
assert.equal(made.status, 200, JSON.stringify(made.body));
|
||||
const threads = await call(env.port, { path: '/api/chat/threads', headers: { cookie: session } });
|
||||
assert.deepEqual(threads.body.rows.map(x => x.id), [made.body.id]);
|
||||
assert.equal(env.counts.owner, 0, 'an owner object ran for a user session');
|
||||
assert.ok(env.opened.length > 0 && env.opened.every(u => u === 'alice'));
|
||||
assert.ok(env.lines.some(l => / chat action=createThread user="alice" id=/.test(l)));
|
||||
// The owner signs in as before and gets the owner page and config.
|
||||
const ownerSession = (await signIn(env, 'owner-sub')).session;
|
||||
const ownerConfig = await call(env.port, { path: '/api/config', headers: { cookie: ownerSession } });
|
||||
assert.equal(Object.hasOwn(ownerConfig.body, 'user'), false);
|
||||
assert.deepEqual(ownerConfig.body.federation, FEDERATION);
|
||||
assert.match((await call(env.port, { path: '/', headers: { cookie: ownerSession } })).body, /<script src="\/app\.js" defer><\/script>/);
|
||||
});
|
||||
|
||||
test('R6 G2 a subject with no live grant in this business is refused subject-unknown', async t => {
|
||||
const past = new Date(Date.now() - 60000).toISOString();
|
||||
for (const [why, users, grants, sub] of [
|
||||
['a listed user with no grant', [ALICE], [], 'alice-sub'],
|
||||
['a grant in another business', [ALICE], [grant({ business: 'other' })], 'alice-sub'],
|
||||
['an expired grant', [ALICE], [grant({ expires: past })], 'alice-sub'],
|
||||
["another user's grant", [ALICE, { id: 'bob', home: 'local', subjects: [{ provider: 'a', sub: 'bob-sub' }] }], [grant({ user: 'bob' })], 'alice-sub'],
|
||||
['a sub in no file', [ALICE], [grant()], 'stranger-sub'],
|
||||
]) {
|
||||
await t.test(why, async t => {
|
||||
const env = await setup(t, { users, grants });
|
||||
const { r, session } = await signIn(env, sub);
|
||||
assert.equal(r.status, 403, why);
|
||||
assert.equal(session, null, `${why}: a session cookie was set`);
|
||||
assert.match(env.lines.at(-1), / sso refused code=subject-unknown provider=a /);
|
||||
assert.deepEqual(env.opened, []);
|
||||
assert.equal(env.counts.user + env.counts.owner, 0);
|
||||
});
|
||||
}
|
||||
await t.test('a file the loader refuses', async t => {
|
||||
const env = await setup(t);
|
||||
env.access.fail = true;
|
||||
const { r, session } = await signIn(env, 'alice-sub');
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(session, null);
|
||||
});
|
||||
});
|
||||
|
||||
test('R6 G3 a revoke counts on the next request: 401 and the session is gone', async t => {
|
||||
for (const [why, revoke] of [
|
||||
['grant removed', env => { env.access.doc.grants = []; }],
|
||||
['subject removed', env => { env.access.doc.users = [{ ...ALICE, subjects: [] }]; }],
|
||||
['grant expired', env => { env.access.doc.grants = [grant({ expires: new Date(Date.now() - 1000).toISOString() })]; }],
|
||||
['file now refused', env => { env.access.fail = true; }],
|
||||
]) {
|
||||
await t.test(why, async t => {
|
||||
const env = await setup(t);
|
||||
const { session } = await signIn(env, 'alice-sub');
|
||||
assert.equal((await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } })).status, 200);
|
||||
revoke(env);
|
||||
const before = env.counts.user;
|
||||
const r = await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } });
|
||||
assert.equal(r.status, 401, `${why}: ${JSON.stringify(r.body)}`);
|
||||
assert.equal(r.body.error, 'sign-in required');
|
||||
assert.match(env.lines.at(-1), / sso refused code=user-revoked provider=a user="alice"$/);
|
||||
assert.equal(env.counts.user, before, 'the transport ran after the revoke');
|
||||
// Restoring the grant does not bring the session back: it was deleted.
|
||||
env.access.fail = false; env.access.doc.users = [ALICE]; env.access.doc.grants = [grant()];
|
||||
assert.equal((await call(env.port, { path: '/api/chat/projects', headers: { cookie: session } })).status, 401);
|
||||
const page = await call(env.port, { path: '/', headers: { cookie: session } });
|
||||
assert.equal(page.status, 303);
|
||||
assert.match(page.headers.location, /^\/auth\/login\?return=/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Every owner route a user session might try; none is a chat route.
|
||||
const OWNER_ROUTES = [
|
||||
['GET', '/api/bus/inbox'], ['GET', '/api/bus/tasks'], ['GET', '/api/bus/names'], ['GET', '/api/bus/thread.claims'],
|
||||
['POST', '/api/bus/message', { to: 'coder', body: 'hi' }],
|
||||
['GET', '/api/settings'], ['POST', '/api/settings', { revision: 'x' }],
|
||||
['POST', '/api/name', { role: 'assistant', name: 'x' }], ['POST', '/api/assistant/stop', {}],
|
||||
['POST', '/api/instances', { instance: 'x' }], ['POST', '/api/routes', { routes: [] }],
|
||||
['POST', '/api/agent/launch', { instance: 'x' }], ['GET', '/api/agent/request?id=x'],
|
||||
['GET', '/api/processing'], ['POST', '/api/processing/halt', {}],
|
||||
['GET', '/api/project-files?project=p'], ['GET', '/api/project-file?project=p&path=a'], ['POST', '/api/project-file', { project: 'p' }],
|
||||
['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen', {}], ['POST', '/api/reply', {}],
|
||||
['GET', '/api/queue'], ['GET', '/api/queue/1'], ['GET', '/api/business'], ['GET', '/api/bus-status'], ['GET', '/api/task-fields'],
|
||||
['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],
|
||||
];
|
||||
|
||||
test('R6 G4 every owner route answers 403 owner-only before any bus call', async t => {
|
||||
const env = await setup(t);
|
||||
const { session } = await signIn(env, 'alice-sub');
|
||||
const before = events(env);
|
||||
for (const [method, path, value] of OWNER_ROUTES) {
|
||||
const r = await call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) });
|
||||
assert.equal(r.status, 403, `${method} ${path}: ${r.status} ${JSON.stringify(r.body)}`);
|
||||
assert.equal(r.body.error, 'owner-only', `${method} ${path}`);
|
||||
assert.equal(userRoute(new URL(path, 'http://x').pathname, method), false, `${method} ${path}`);
|
||||
}
|
||||
assert.deepEqual(env.counts, { owner: 0, user: 0 });
|
||||
assert.deepEqual(env.opened, [], 'a user transport was built for an owner route');
|
||||
assert.equal(events(env), before, 'something was written');
|
||||
});
|
||||
|
||||
test('R6 G5 the bus refuses every owner verb over the user transport on its own', async t => {
|
||||
const env = await setup(t);
|
||||
const o = consoleBus(env.userTransport('alice'), { send: true, owner: env.ownerCall });
|
||||
const before = events(env);
|
||||
for (const [why, run] of [
|
||||
['inbox', () => o.bus.inbox()], ['tasks', () => o.bus.tasks()], ['agents', () => o.bus.agents()], ['trail', () => o.bus.trail()],
|
||||
['decisions', () => o.bus.decisions()], ['instances', () => o.bus.instances()], ['routes', () => o.bus.routes()], ['thread.claims', () => o.bus['thread.claims']()],
|
||||
['message.send', () => o.send({ to: 'chief', body: 'as the owner' })], ['limits.set', () => o.settingsWrite({ revision: 'x' })],
|
||||
['project.files', () => o.projectFiles({ project: env.p1 })], ['project.file', () => o.projectFile({ project: env.p1, path: 'a' })],
|
||||
['project.file.save', () => o.projectSave({ project: env.p1 })], ['name.set', () => o.nameWrite({ role: 'assistant', name: 'x' })],
|
||||
['assistant.stop', () => o.assistantStop({})], ['instances.set', () => o.instancesWrite({})], ['routes.set', () => o.routesWrite({})],
|
||||
['instance.launch', () => o.agentWrite('instance.launch', {})], ['processing.status', () => o.processing.read()],
|
||||
['processing.halt', () => o.processing.halt({})], ['library.status', () => o.libraryStatus({})],
|
||||
['project.create', () => o.chat.createProject({ name: 'mine' })],
|
||||
]) await assert.rejects(Promise.resolve().then(run), e => e.code === 'access-refused', why);
|
||||
assert.equal(events(env), before, 'a refused verb wrote');
|
||||
});
|
||||
|
||||
// sage-3153 (C6): in a user's chat the owner transport carries `plugins` with {} and nothing else,
|
||||
// every chat verb goes over the user transport, and the owner's answer never reaches the user.
|
||||
test('R6 G9 a user chat sends only the plugins read over the owner transport', async t => {
|
||||
const env = await setup(t);
|
||||
const { session } = await signIn(env, 'alice-sub');
|
||||
const bodies = [];
|
||||
const go = async (method, path, value) => {
|
||||
const r = await call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) });
|
||||
assert.equal(r.status, 200, `${method} ${path}: ${JSON.stringify(r.body)}`);
|
||||
bodies.push(JSON.stringify(r.body));
|
||||
return r.body;
|
||||
};
|
||||
await go('GET', '/api/chat/projects');
|
||||
const thread = (await go('POST', '/api/chat/threads', { project: env.p1, name: 'alice work' })).id;
|
||||
await go('GET', `/api/chat/threads?project=${env.p1}`);
|
||||
await go('POST', `/api/chat/threads/${thread}/open`, {});
|
||||
await go('POST', `/api/chat/threads/${thread}/messages`, { body: 'hello' });
|
||||
await go('GET', `/api/chat/threads/${thread}/messages`);
|
||||
assert.ok(env.ownerVerbs.length > 0, 'the chat never read plugins');
|
||||
// Exactly the verb `plugins` and exactly {}: no value from the session or a request reaches it.
|
||||
for (const entry of env.ownerVerbs) assert.deepEqual(entry, ['plugins', {}]);
|
||||
const sent = JSON.stringify(env.ownerVerbs);
|
||||
for (const value of ['alice', 'alice-sub', 'alice work', 'hello', env.p1, thread]) assert.equal(sent.includes(value), false, `${value} reached the owner transport`);
|
||||
for (const verb of ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages']) assert.ok(env.userVerbs.includes(verb), `${verb} did not go over the user transport`);
|
||||
assert.equal(env.userVerbs.includes('plugins'), false);
|
||||
for (const body of bodies) assert.doesNotMatch(body, new RegExp(OWNER_MARKER));
|
||||
assert.equal(env.counts.owner, 0);
|
||||
});
|
||||
|
||||
test('R6 G6 the user page holds the notice word for word', async t => {
|
||||
const html = readFileSync(new URL('user.html', PUBLIC), 'utf8');
|
||||
const notice = /<p id="user-notice"[^>]*>([^<]*)<\/p>/.exec(html);
|
||||
assert.ok(notice, 'no #user-notice paragraph');
|
||||
assert.equal(notice[1], NOTICE);
|
||||
const env = await setup(t);
|
||||
const { session } = await signIn(env, 'alice-sub');
|
||||
assert.ok((await call(env.port, { path: '/', headers: { cookie: session } })).body.includes(NOTICE));
|
||||
});
|
||||
|
||||
// The user page outside a session: a small static server over the public files, /api/config stubbed.
|
||||
async function userPage(t, config) {
|
||||
const types = { html: 'text/html', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml' };
|
||||
const server = createHttpServer((req, res) => {
|
||||
const path = new URL(req.url, 'http://x').pathname;
|
||||
if (path === '/api/config') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(config)); }
|
||||
if (path.startsWith('/api/chat/')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ rows: [], at: new Date().toISOString() })); }
|
||||
const name = path === '/' ? 'user.html' : path.slice(1);
|
||||
if (!/^[a-z.]+$/.test(name)) { res.writeHead(404); return res.end(); }
|
||||
try { const body = readFileSync(new URL(name, PUBLIC)); res.writeHead(200, { 'content-type': types[name.split('.').pop()] ?? 'text/plain' }); res.end(body); }
|
||||
catch { res.writeHead(404); res.end(); }
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
t.after(() => close(server));
|
||||
return server.address().port;
|
||||
}
|
||||
|
||||
test('R6 G7 the hub label and plain peer links render from config on both pages', async t => {
|
||||
const links = 'JSON.stringify({label:document.querySelector("#hub-label")?.textContent,hidden:document.querySelector("#federation").hidden,peers:[...document.querySelectorAll("#federation a")].map(a=>[a.getAttribute("href"),a.textContent,a.attributes.length])})';
|
||||
const expected = { label: 'This Console: hub', hidden: false, peers: [['https://factory.example.test', 'Software factory', 2]] };
|
||||
const wait = (b, expression) => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
// The owner page, over the real server.
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const owner = await startServer({ port: 0, board: f.boardURL, federation: FEDERATION }); t.after(() => close(owner));
|
||||
await b.navigate(`http://127.0.0.1:${owner.address().port}/`);
|
||||
await wait(b, '!document.querySelector("#federation").hidden');
|
||||
assert.deepEqual(JSON.parse(await b.evaluate(links)), expected);
|
||||
// The user page, with a hostile label kept as text.
|
||||
const hostile = { self: 'hub', peers: [...FEDERATION.peers, { id: 'x', origin: 'https://x.example.test', label: '<img src=x onerror="window.injected=1">' }, { id: 'y', origin: 'javascript:alert(1)', label: 'bad' }] };
|
||||
await b.navigate(`http://127.0.0.1:${await userPage(t, { user: true, send: true, federation: hostile })}/`);
|
||||
await wait(b, '!document.querySelector("#federation").hidden');
|
||||
const user = JSON.parse(await b.evaluate(links));
|
||||
assert.equal(user.label, 'This Console: hub');
|
||||
assert.deepEqual(user.peers, [...expected.peers, ['https://x.example.test', '<img src=x onerror="window.injected=1">', 2]]);
|
||||
assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
assert.equal(await b.evaluate('document.querySelector("#user-notice").textContent'), NOTICE);
|
||||
// No federation in config: nothing is shown.
|
||||
await b.navigate(`http://127.0.0.1:${await userPage(t, { user: true, send: true })}/`);
|
||||
await wait(b, 'document.querySelector(".chat-area")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#federation").hidden'), true);
|
||||
});
|
||||
|
||||
test('R6 G8 userCall spawns user-cli directly with the user nonce, no human nonce, and the user request', async t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'webui-user-call-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const socket = join(dir, 'bus.sock');
|
||||
const seen = [];
|
||||
const server = createNetServer(conn => {
|
||||
let input = '';
|
||||
conn.on('data', chunk => {
|
||||
input += chunk;
|
||||
if (!input.includes('\n')) return;
|
||||
const message = JSON.parse(input);
|
||||
const pid = message.user?.pid;
|
||||
// Read the child while it waits on this answer.
|
||||
const proc = name => readFileSync(`/proc/${pid}/${name}`, 'utf8');
|
||||
seen.push({ message, argv: proc('cmdline').split('\0'), env: proc('environ').split('\0').map(e => e.split('=')[0]), ppid: Number(proc('stat').replace(/^.*\) /s, '').split(' ')[1]) });
|
||||
conn.end(JSON.stringify({ ok: true, result: { answered: true } }) + '\n');
|
||||
});
|
||||
});
|
||||
await new Promise(r => server.listen(socket, r));
|
||||
t.after(() => server.close());
|
||||
const result = await userCall({ socket, business: 'demo', user: 'alice', env: { ...process.env, MOSAIC_BUS_CLI_NONCE: 'f'.repeat(64) } })('thread.list', { project: 'p' });
|
||||
assert.deepEqual(result, { answered: true });
|
||||
assert.equal(seen.length, 1);
|
||||
const [{ message, argv, env, ppid }] = seen;
|
||||
assert.equal(argv[1], USER_CLI);
|
||||
assert.notEqual(argv[1], HUMAN_CLI);
|
||||
assert.equal(argv[2], socket);
|
||||
assert.equal(ppid, process.pid, 'user-cli re-executed: the Console is not its parent');
|
||||
assert.ok(env.includes('MOSAIC_BUS_USER_NONCE'));
|
||||
assert.equal(env.includes('MOSAIC_BUS_CLI_NONCE'), false);
|
||||
assert.deepEqual(Object.keys(message).sort(), ['args', 'user', 'verb']);
|
||||
assert.equal(message.verb, 'thread.list');
|
||||
assert.deepEqual(message.args, { project: 'p' });
|
||||
assert.equal(message.user.business, 'demo');
|
||||
assert.equal(message.user.user, 'alice');
|
||||
assert.match(message.user.nonce, /^[0-9a-f]{64}$/);
|
||||
assert.equal(USER_CLI, fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url)));
|
||||
});
|
||||
@@ -237,71 +237,13 @@ async function validateConsole(value) {
|
||||
}
|
||||
}
|
||||
|
||||
// FED R2 (#1701, DESIGN.md section 3.1 D1): the optional `federation` key names this instance and its
|
||||
// peers. Ids and origins are labels, not trust: nothing here grants a peer anything (3.1, W1). The
|
||||
// origin must be exactly an https origin, the form `new URL(origin).origin` returns, so a path, a
|
||||
// trailing slash, a port that is the default, or userinfo refuses.
|
||||
const FEDERATION_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
const FEDERATION_LABEL_MAX = 80;
|
||||
|
||||
function validateFederationOrigin(value, where) {
|
||||
let url = null;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
if (url === null || url.protocol !== "https:" || url.origin !== value) {
|
||||
fail(2, `${where} must be an https origin with no path, query, or userinfo (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFederation(value) {
|
||||
if (!isPlainObject(value)) {
|
||||
fail(2, '"federation" must be a JSON object');
|
||||
}
|
||||
rejectUnknownKeys(value, ["self", "peers"], "federation");
|
||||
if (typeof value.self !== "string" || !FEDERATION_ID_PATTERN.test(value.self)) {
|
||||
fail(2, `federation.self must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(value.self)})`);
|
||||
}
|
||||
if (!Array.isArray(value.peers)) {
|
||||
fail(2, 'federation.peers must be an array');
|
||||
}
|
||||
const ids = new Set([value.self]);
|
||||
const peers = value.peers.map((peer, index) => {
|
||||
const where = `federation.peers[${index}]`;
|
||||
if (!isPlainObject(peer)) {
|
||||
fail(2, `${where} must be a JSON object`);
|
||||
}
|
||||
rejectUnknownKeys(peer, ["id", "origin", "label"], where);
|
||||
if (typeof peer.id !== "string" || !FEDERATION_ID_PATTERN.test(peer.id)) {
|
||||
fail(2, `${where}.id must match ${FEDERATION_ID_PATTERN} (got ${JSON.stringify(peer.id)})`);
|
||||
}
|
||||
if (ids.has(peer.id)) {
|
||||
fail(2, `${where}.id ${JSON.stringify(peer.id)} is already named (by federation.self or an earlier peer)`);
|
||||
}
|
||||
ids.add(peer.id);
|
||||
validateFederationOrigin(peer.origin, `${where}.origin`);
|
||||
if (typeof peer.label !== "string" || peer.label.trim() === "" || peer.label.length > FEDERATION_LABEL_MAX) {
|
||||
fail(2, `${where}.label must be a non-empty string of at most ${FEDERATION_LABEL_MAX} characters`);
|
||||
}
|
||||
return { id: peer.id, origin: peer.origin, label: peer.label };
|
||||
});
|
||||
return { self: value.self, peers };
|
||||
}
|
||||
|
||||
async function validate(document, file) {
|
||||
if (!isPlainObject(document)) {
|
||||
fail(2, "configuration must be a JSON object");
|
||||
}
|
||||
// Row 270 r2 and r3 (mos-1352, DECISIONS Q222, sage-3038): compactionReadBytes and
|
||||
// compactionStreams, optional and human-only, pass on as written; the launcher takes the
|
||||
// default for a missing or bad value (packages/seat/src/compactions.mjs readBudget and
|
||||
// readStreams). The first list stays as it was:
|
||||
// packages/cli/tests/stack-release-fixture.mjs edits it to make an older loader.
|
||||
rejectUnknownKeys(
|
||||
document,
|
||||
["configVersion", "environment", "dataRoot", "execution", "console", "federation"].concat(["compactionReadBytes", "compactionStreams"]),
|
||||
["configVersion", "environment", "dataRoot", "execution", "console"],
|
||||
"configuration",
|
||||
);
|
||||
|
||||
@@ -339,9 +281,6 @@ async function validate(document, file) {
|
||||
// it, so a config without it prints what it always printed.
|
||||
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
|
||||
|
||||
// FED R2: the same rule as `console`. The key appears in the output only when the file has it.
|
||||
const federationKey = Object.hasOwn(document, "federation") ? validateFederation(document.federation) : undefined;
|
||||
|
||||
return {
|
||||
configVersion: document.configVersion,
|
||||
environment: document.environment,
|
||||
@@ -353,9 +292,6 @@ async function validate(document, file) {
|
||||
adapter,
|
||||
},
|
||||
...(consoleKey === undefined ? {} : { console: consoleKey }),
|
||||
...(federationKey === undefined ? {} : { federation: federationKey }),
|
||||
...(Object.hasOwn(document, "compactionReadBytes") ? { compactionReadBytes: document.compactionReadBytes } : {}),
|
||||
...(Object.hasOwn(document, "compactionStreams") ? { compactionStreams: document.compactionStreams } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ plugin_checks() {
|
||||
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
|
||||
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
|
||||
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels"
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records"
|
||||
|
||||
STORE_JS='
|
||||
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
||||
@@ -1078,12 +1078,12 @@ slot_problem() {
|
||||
# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept.
|
||||
config_checks() {
|
||||
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first
|
||||
# 0: a console or federation key (row 203: a pre-203 loader refuses federation too); 1: neither, or no file; 2: the file doesn't parse, so whether it holds the key
|
||||
# 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key
|
||||
# is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on
|
||||
# such a file before anything is read or stopped, so this is reached only if the file changes after it.
|
||||
local key=0
|
||||
node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)}
|
||||
process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.hasOwn(d,"federation"))?0:1)' -- "$cfg" 2>/dev/null || key=$?
|
||||
process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' -- "$cfg" 2>/dev/null || key=$?
|
||||
[ "$key" != 1 ] || return 0
|
||||
if [ "$key" != 0 ]; then
|
||||
check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again"
|
||||
@@ -1093,7 +1093,7 @@ process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.
|
||||
elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0
|
||||
fi
|
||||
first="${out%%$'\n'*}"
|
||||
check_refuse config-console-key "$cfg holds a console or federation key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
|
||||
check_refuse config-console-key "$cfg holds a console key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
|
||||
}
|
||||
|
||||
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
|
||||
|
||||
@@ -191,95 +191,12 @@ expect_refusal "console.sso slot name outside the pattern exits 2 naming the pat
|
||||
expect_refusal "unknown key under console exits 2 naming the path" \
|
||||
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
|
||||
|
||||
# --- FED R2 (#1701, DESIGN.md section 3.1): the optional federation key ---
|
||||
# fed_body FEDERATION-JSON: a config whose federation key is the argument.
|
||||
fed_body() {
|
||||
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"federation":%s}' "$DATA_ROOT" "$1"
|
||||
}
|
||||
FED_VALID='{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"Software factory"}]}'
|
||||
|
||||
cfg fed-valid.json "$(fed_body "$FED_VALID")"
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/fed-valid.json" timeout 20 $CONFIG_OP validate)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"self": "hub"' && printf '%s' "$OUT" | grep -q '"origin": "https://mosaic.woltje.com"'
|
||||
check "valid federation key loads and is in the output" $?
|
||||
|
||||
# expect_fed_refusal NAME PATH-IN-MESSAGE FEDERATION-JSON: exit 2, and the message names the bad key path.
|
||||
expect_fed_refusal() {
|
||||
cfg fed-bad.json "$(fed_body "$3")"
|
||||
local err rc
|
||||
err="$(MOSAIC_CONFIG="$SANDBOX/fed-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
|
||||
rc=$?
|
||||
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$2"
|
||||
check "$1 (exit $rc)" $?
|
||||
}
|
||||
expect_fed_refusal "federation unknown sub-key exits 2 naming the key" 'unsupported federation key: "extra"' \
|
||||
'{"self":"hub","peers":[],"extra":1}'
|
||||
expect_fed_refusal "federation peer unknown sub-key exits 2 naming the key" 'unsupported federation.peers[0] key: "trust"' \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F","trust":"full"}]}'
|
||||
expect_fed_refusal "federation peer origin over http exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"http://mosaic.woltje.com","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer origin with a path exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/console","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer origin with userinfo exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://[email protected]","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer id equal to self exits 2 naming the key" "federation.peers[0].id" \
|
||||
'{"self":"hub","peers":[{"id":"hub","origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer label empty exits 2 naming the key" "federation.peers[0].label" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":""}]}'
|
||||
expect_fed_refusal "federation not an object exits 2 naming the key" '"federation" must be a JSON object' '"hub"'
|
||||
expect_fed_refusal "federation self pattern exits 2 naming the key" "federation.self must match" \
|
||||
'{"self":"Hub","peers":[]}'
|
||||
expect_fed_refusal "federation peers not an array exits 2 naming the key" "federation.peers must be an array" \
|
||||
'{"self":"hub","peers":{}}'
|
||||
expect_fed_refusal "federation peer not an object exits 2 naming the key" "federation.peers[0] must be a JSON object" \
|
||||
'{"self":"hub","peers":["factory"]}'
|
||||
expect_fed_refusal "federation peer id pattern exits 2 naming the key" "federation.peers[0].id must match" \
|
||||
'{"self":"hub","peers":[{"id":"Factory","origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
FED_LONG_LABEL="$(printf 'x%.0s' $(seq 81))"
|
||||
expect_fed_refusal "federation peer label over 80 characters exits 2 naming the key" "federation.peers[0].label" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"'"$FED_LONG_LABEL"'"}]}'
|
||||
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id "factory" is already named' \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F"},{"id":"factory","origin":"https://other.woltje.com","label":"G"}]}'
|
||||
expect_fed_refusal "federation peer origin with a trailing slash exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/","label":"F"}]}'
|
||||
# A missing value: the pattern test coerces undefined to "undefined", which matches, so the typeof half is what refuses.
|
||||
expect_fed_refusal "federation self missing exits 2 naming the key" "federation.self must match" \
|
||||
'{"peers":[]}'
|
||||
expect_fed_refusal "federation peer id missing exits 2 naming the key" "federation.peers[0].id must match" \
|
||||
'{"self":"hub","peers":[{"origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer label missing exits 2 naming the key" "federation.peers[0].label must be" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com"}]}'
|
||||
|
||||
# Without the key: no federation in the output, and the config still loads (FED R2 boundary).
|
||||
cfg no-federation.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
|
||||
NO_FED_OUT="$(MOSAIC_CONFIG="$SANDBOX/no-federation.json" timeout 20 $CONFIG_OP validate)"
|
||||
NO_FED_RC=$?
|
||||
[ "$NO_FED_RC" -eq 0 ] && ! printf '%s' "$NO_FED_OUT" | grep -q '"federation"'
|
||||
check "config without federation loads and has no federation output" $?
|
||||
|
||||
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
|
||||
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
|
||||
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
|
||||
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
|
||||
check "config without console loads with unchanged output" $?
|
||||
|
||||
# --- Row 270 r2 (mos-1352, DECISIONS Q222): the optional compactionReadBytes key ---
|
||||
# The loader passes it on as written, a bad value too: the launcher takes the default for a
|
||||
# missing or bad one (packages/seat/src/compactions.mjs readBudget), so it never refuses a config.
|
||||
for v in 8388608 '"x"'; do
|
||||
cfg budget.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"},"compactionReadBytes":'"$v"'}'
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/budget.json" timeout 20 $CONFIG_OP validate 2>&1)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -qxF " \"compactionReadBytes\": $v"
|
||||
check "compactionReadBytes $v loads and is in the output as written" $?
|
||||
done
|
||||
# Row 270 r3 (sage-3038): compactionStreams the same way (readStreams).
|
||||
for v in 2048 '"x"'; do
|
||||
cfg streams.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"},"compactionStreams":'"$v"'}'
|
||||
OUT="$(MOSAIC_CONFIG="$SANDBOX/streams.json" timeout 20 $CONFIG_OP validate 2>&1)"
|
||||
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -qxF " \"compactionStreams\": $v"
|
||||
check "compactionStreams $v loads and is in the output as written" $?
|
||||
done
|
||||
|
||||
# --- validation must not modify the file ---
|
||||
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
|
||||
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
|
||||
|
||||
Reference in new issue
Block a user