Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c3403e17 | ||
|
|
aecb014279 | ||
|
|
4b1b1bf2e5 | ||
|
|
aca16404cf | ||
|
|
f7e4ec3df5 | ||
|
|
b789188504 | ||
|
|
84f53540dd | ||
|
|
2cc34aef87 | ||
|
|
93f4af12bc | ||
|
|
75f049ed23 | ||
|
|
54f65f9abd | ||
|
|
7451878b66 | ||
|
|
16889a46be | ||
|
|
c3f43960eb | ||
|
|
0431778465 | ||
|
|
f3e9b6512c | ||
|
|
b7a05482bb | ||
|
|
4beb6433a4 | ||
|
|
fbfaab3655 | ||
|
|
9ff668550f | ||
|
|
5baaaa447b | ||
|
|
c4a12de3d8 | ||
|
|
72fc5a330b | ||
|
|
d6301dffce | ||
|
|
ae525c4095 | ||
|
|
b5630ca198 | ||
|
|
26380e3f0b |
No files matched your search
@@ -5303,3 +5303,11 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
### 2026-10-22 | Gander | row278 gate run3
|
||||
|
||||
Fresh gate on3e4c77ac6 under sage-2648: bus793/0, WebUI345/0/1 (esbuild version skip), all ten shell files425/0, touched files15/0, M1-M6 killed6/6. No timeout; sweep0 run-created leftovers. Runs1/2 void. Seven-path manifest8fa859a0 goes to Webby for final review. No landing or live change.
|
||||
|
||||
2026-10-22 11:29:09 UTC | Rocko | V502 carry/rerun on980cec88c: source407c8d7be and packet04a86cf33 match approved8e3c6a79b/9bf4683c6 patch-ids, no conflicts. Dewey0834 carry conditioned on rerun, satisfied under sage2616: stop-run13,processingHTTP6,bustasks9 allgreen (Node28/28),Python4/4,0skips/timeouts. Manifest5758d699,source5/5restored,cwdsweep[]. Doc rerun packet6af85df4b explicitly pushed to rocko/vikunja502-980c-20261022; no live action.
|
||||
|
||||
2026-10-22 11:30:17 UTC | Sage | Vikunja 502 (row 191 follow-up, no queue row) LANDED 6af85df4b, a fast-forward from 980cec88c. Before: the processing failure diagnostics and cleanup in stop-run, processing HTTP and bus tasks tests were Dewey-approved on the pre-194 base only (8e3c6a79b/9bf4683c6). After: Rocko's picks 407c8d7be/04a86cf33 carry the same patch-ids (37135ebe/0dbcda54), plus a doc-only rerun record 6af85df4b. The rerun on the new base was green: stop-run 13/13, processing HTTP 6/6, bus tasks 9/9, Python 4/4, 0 skips. Dewey carried the approve (dewey-0836). mosaic-task.mjs is unchanged, so N7 (runs suite) didn't trigger. Overlap with in-review rows 13 and 257 was clean. LIVE is still 86f95bb27.
|
||||
|
||||
2026-10-22 13:10 UTC | Sage | Rows 278 and 281 LANDED. Before: refactor at 6af85df4b (V502). Row 278 (#1783, Gander, Webby): the Console Agents view shows thread claims in their own section. It landed as 385a7194c at 12:39Z, ff, 9 commits, manifest 8fa859a0. Run 3 was green: bus 793, webui 345/0/1, ten shells 425/0 under the 3x limits (mos-1275), M1-M6 killed. Runs 1 and 2 were void, on fixture fixes 964b6b184 (placed by Sage) and 9696d1fcb (ruled by Mos, mos-1275). Webby approved (c30588, rev 3200). Done rev 3202, #1783 closed c30589. Row 281 (#1786, Megavolt, Dewey): a fire or a delete revokes the instance's host and Gitea grants. It landed as e479521d3 at 13:06Z, ff, 17 commits, manifest b73d4c81. Slot on 7979fb41e: bus 819, business 231, cli 837, dewey-bus 76 with the Docker gitea-grant test, shells green, M1-M22 killed. The M1 hang was fixed at its cause in b4a (mos-1273). Dewey approved (c30614, rev 3207). Done rev 3209, #1786 closed c30621. Skips on both: test-task's row 244 live-provider pair and test-queue's canonical-root check, plus webui's esbuild rebuild test on 278. After: refactor at e479521d3. DEFERRED moves the grant gap to Done and opens Dewey's N1/N2 text gaps. LIVE is still 86f95bb27.
|
||||
|
||||
2026-10-22 14:18:23 UTC | Sage | Row 264a LANDED (#1769, Gizmoduck, Duckworth): sessions per thread, schema 3k-3l. Before: refactor at e479521d3 (row 281). It landed as 84f53540d, ff, 21 commits, manifest 257408e9 (Duckworth approve rev 3214, comment 30640). Gate r13 on 2cc34aef8: 20 touched files, bus 827, cli 837, webui 345/1 named skip, ten shells under 3x limits, mutants M1-M4 killed on assertions. Corrections on the way: the kit stopped on first red, so four reds surfaced one at a time. r10 later3l missed a trigger (mos-1276). T2 had failed since r2 because refusals log action.refused (mos-1277, my sage-2257 wording). threads.test:341 kept the old trigger text (mos-1278). M2 was caught by the store trigger before any assertion, so it now drops the trigger too (mos-1279, four files with the SCHEMA_SHA256 pin). Deferred: actor_run pin binds row 269, esbuild skip to a later gate, session-scoped user reply binds row 272. No live change; LIVE is still 86f95bb27.
|
||||
@@ -0,0 +1,548 @@
|
||||
# Row 264 build notes (Gizmoduck)
|
||||
|
||||
T1 item 7: independent sessions, compaction counts and clear context. Gitea #1769, Vikunja 463
|
||||
(design) and 466 (Claude compaction format, T3's dependency, linked and not built here). Brief:
|
||||
docs/plans/2026-10-21_t1-sessions.md, "T1 item 7: independent sessions, compaction counts and clear
|
||||
context" (blob ff0be0c79). Reviewer Duckworth, gate owner Sage. Branch gizmoduck/row264 from refactor
|
||||
da9555ca2. MVP due 10-27.
|
||||
|
||||
The brief pins its code citations at cca206cdc. `git diff --stat cca206cdc da9555ca2` over every cited
|
||||
source file is empty, so F1-F11 still hold line for line. Only docs/prd/mosaic-stack.md moved.
|
||||
|
||||
## Phase A: what the code has today
|
||||
|
||||
- Bus. `threads` binds one `instance` per thread (schema.sql:240-250). `thread_claims` keys on
|
||||
`thread` alone (:251-261), and `message_threads` maps a message to a thread (:262-265). Schema is
|
||||
3j (store.mjs:17). The thread tables first appear in migrations/3d-3e.sql.
|
||||
- Launcher. `check()` refuses a second run for the same instance and thread
|
||||
(launcher.mjs:233-240, `registry.running(b.id, instance, thread)`).
|
||||
- Seat. `threadStore()` makes one `session/` and one `claude-projects/` per thread
|
||||
(session.mjs:51-74). The registry's running key is business, instance and thread (:215-218).
|
||||
- Runner. `stop()` sends TERM to the child group, then KILL after 5 s (runner.mjs:402-414). It has
|
||||
no drain, no idle acknowledgement and no dequeue fence. D7 step 2 needs all three.
|
||||
|
||||
## Consumer audit (D9), first pass
|
||||
|
||||
`git grep -F` over packages/*/src/* and adapters/* at da9555ca2:
|
||||
|
||||
| Name | Files and hits |
|
||||
| --- | --- |
|
||||
| `thread_claims` | broker.mjs 12, store.mjs 1 |
|
||||
| `message_threads` | broker.mjs 9 |
|
||||
| `thread_opens` | broker.mjs 4, store.mjs 1 |
|
||||
| `threadStore` | launcher.mjs 4, sandbox.mjs 7, session.mjs 3 |
|
||||
| `running(` | launcher.mjs 5, session.mjs 1 |
|
||||
|
||||
Still to read: the broker holder, agents, end, usage, reply correlation and pending-approval paths
|
||||
named in D9, the two adapters (F5), both harness plugins, the conversation reader (F8) and the
|
||||
Console. Each gets a file:line row here before the first code commit.
|
||||
|
||||
## Proposed slices
|
||||
|
||||
One seat can't build D1-D9 in six days and keep every slice reviewable. I propose five candidates
|
||||
under row 264, each with its own manifest and Duckworth round:
|
||||
|
||||
1. 264a bus. Session tables, claim key `(organization, thread, instance, session)` with a
|
||||
generation, the D3 default session, the D9 bus mapping, `session.list` and `session.create`, and an
|
||||
optional session selector on the thread verbs. Tests: T1 bus half, T2 claims, T7 legacy clients and
|
||||
the rollback refusal.
|
||||
2. 264b seat and launcher. Per-session native store and workspace, the single-writer fence, the
|
||||
registry key with session, sandbox mounts of the selected store only, and pinned native ids in both
|
||||
adapters. Tests: T1 run half, T2 workspace and path mutants.
|
||||
3. 264c compaction count. Host collector, exact for Pi. Claude shows `unknown` with the 466 reason
|
||||
until that format is pinned. Tests: T3 Pi half.
|
||||
4. 264d clear. The D7 journal, the runner drain fence and idle acknowledgement, stop proof, then
|
||||
retirement and successor in one transaction. Tests: T4, T6.
|
||||
5. 264e surface. `mosaic session` CLI and the Console chooser, count and clear confirmation. Tests:
|
||||
T5 for the legacy single human, T7 Console half.
|
||||
|
||||
## Phase A rulings (sage-2235)
|
||||
|
||||
Q1-Q7 went to Sage in gizmoduck-0154. Sage's answers are in agents/sage/work/queue-264/RULINGS.md at
|
||||
5f5b51dd6. In short:
|
||||
|
||||
- Q1. Five candidates in the order a, b, c, d, e, each with its own manifest, Duckworth round and
|
||||
landing. Row 264 carries 264a. Sage adds rows for 264b-264e. All five are MVP.
|
||||
- Q2. 264a builds as 3j-3k.sql. If 236 or 206 lands first, I rebase onto a new branch name, take the
|
||||
next label, and rerun the whole bus suite, the schema.test.mjs counts and the T46 lists.
|
||||
- Q3. PRD 1.13 has landed, and REQ-ROLE-2 (mosaic-stack.md:323-329) already carries the session
|
||||
text. Build to it.
|
||||
- Q4. Row 194's lifecycle path owns the stop. honker/row194-b5 dfabbbe8a is read-only reference.
|
||||
264d rebases onto 194's landing. The runner's dequeue fence and idle acknowledgement are new code
|
||||
and a named review item. No 194 code gets copied.
|
||||
- Q5. One shared admission hook for every session start, using today's limits and revocation. Of
|
||||
191 and 264b, whichever lands second wires 191's checks in. No setting turns it off, and a check
|
||||
that's configured but unavailable fails closed.
|
||||
- Q6. 264c lands with Pi exact and Claude `unknown` with the 466 reason. The Claude half of T3 stays
|
||||
open on row 264 and Vikunja 466.
|
||||
- Q7. Legacy single human only. Collaborator and share paths refuse. A4 and the full matrix go to
|
||||
whichever of 264 and 193 lands second.
|
||||
|
||||
## 264a design: the bus
|
||||
|
||||
Line numbers are at da9555ca2 unless they say otherwise.
|
||||
|
||||
### Schema 3j-3k
|
||||
|
||||
- `sessions`, new and immutable: `id`, `at`, `business`, `thread`, `instance`, `owner`, `name`, `op`
|
||||
(the creation operation key) and `predecessor` (null until 264d's clear). UNIQUE `(thread, name)`
|
||||
and `(thread, op)`, so a repeated create with the same key finds the first row. Guard triggers
|
||||
check that the business, instance and owner match the thread's, and that a predecessor sits in
|
||||
the same thread.
|
||||
- `session_defaults`, new and append-only: `thread`, `session`, `at`, `by`. The latest row per
|
||||
thread is that thread's default (D3). A trigger checks that the session is in the thread.
|
||||
- `thread_claims`, `message_threads` and `thread_opens` are rebuilt, each with a `session` column,
|
||||
and `thread_claims` also gets `generation`. Rows are copied with their seq values and every old
|
||||
column as it was, and the sequence carries over, as 3h-3i.sql did for `usage_records`. That is a
|
||||
named review item: the copy adds two derived columns and changes nothing else.
|
||||
- Each existing thread gets one legacy default session with **id equal to the thread id**, the
|
||||
name `default`, op `legacy-3k`, and the thread's own `at` and owner. Every copied row names that
|
||||
session. This is D9's immutable mapping. 264b binds it to the existing native store at
|
||||
`threads/<business>/<thread>/session/`. A new thread's default session gets a random id.
|
||||
- `generation` is the claim's number within its session, counting from 1. A release or revoke
|
||||
carries its claim's number. On copy, it's the count of claim rows in the thread up to that row.
|
||||
- Triggers keyed on the thread now key on the session: one holder, release only while held, only the
|
||||
holder releases, and a generation check. These two keep their thread meaning: one held session per
|
||||
run per business, and the business check. New checks: the claim's, message's and open's session
|
||||
is in the named thread. `delivery_same_thread` becomes `delivery_same_session`, which sends a
|
||||
threaded message only to its own session's holder.
|
||||
- A 3j binary refuses a 3k store with `schema-mismatch` (store.mjs:181-186), which covers D9's
|
||||
rollback refusal. T7 tests it.
|
||||
|
||||
### Broker
|
||||
|
||||
- Run binding. `bindLaunch` takes an optional `session`. When it's missing, the run gets the
|
||||
thread's default session, which is the D3 path for today's launcher. The binding refuses a
|
||||
session that isn't in its thread. The run's `s.session` then drives `role.claim`, `#own`,
|
||||
`message.receive` and the reply routing.
|
||||
- Lookups. `#threadHolder(thread)` becomes `#sessionHolder(session)`, which `#recipient`,
|
||||
`#routeThread`, `#threadHolders`, `#claimEnd`, `threadIdle` and run end all use.
|
||||
`#threadFor` returns a thread and its session: a thread run's own, or the session of the nearest
|
||||
threaded message up the reply chain. `#specThread` uses the opened thread's default session.
|
||||
- Launcher view. `threadsState` and `threadIdle` keep their row shape, one row per thread computed
|
||||
on its default session. Until 264b, the launcher starts only default sessions. Messages to another
|
||||
session wait, and nothing reaches the default session's run.
|
||||
- Verbs:
|
||||
- The thread verbs take an optional `session`, and leaving it out uses the default (D3).
|
||||
`thread.create` also makes the default session. `thread.messages` without a session reads the
|
||||
default session.
|
||||
- `session.list {thread}` and `session.create {thread, name, op}` (D8: metadata only). The build
|
||||
dropped `session.get`, `session.open` and `session.send`. `thread.open`, `thread.send` and
|
||||
`thread.messages` take an optional `session`, which does the same job with one owner check and
|
||||
one code path.
|
||||
- All of them are human or tester only, through `#human`/`#thread`, the same owner check as the
|
||||
thread verbs (Q7: single human, no shares). They go into `TESTER_VERBS`.
|
||||
- Other readers. `#grantNotice`, `#raisedThread` and the decision-close path (:736, :1500, :2941)
|
||||
read the holder run's claim row. The rebuilt `thread_claims` keeps that query working, and the
|
||||
session comes along from the same row.
|
||||
|
||||
### Tests and lists
|
||||
|
||||
- New file `packages/bus/tests/sessions.test.mjs`:
|
||||
- T1 bus half: two sessions in one thread, distinct histories, receive and reply isolation, and a
|
||||
non-thread role refused.
|
||||
- T2: one holder per session, generation fencing, sibling delivery refused by the trigger.
|
||||
- T7: legacy clients, the 3j-3k migration from a fixture (default session id = thread id, rows
|
||||
copied with seq), and the rollback refusal.
|
||||
- A new `fixtures/schema-3j.sql`.
|
||||
- Update the schema.test.mjs pins (:519-525), the `'3j'` version pins in the migration tests,
|
||||
`COUNTED` in scripts/stack-release.sh:404, and the T46 lists in
|
||||
packages/cli/tests/stack-release.test.mjs:860-934. Also `timedTables` (store.mjs:65-79) and the
|
||||
broker clock-seed list (:325-336).
|
||||
|
||||
### Duckworth's design checks (duckworth-0106)
|
||||
|
||||
Duckworth had no objection to the in-place rebuild and asked for seven checks. Where each one is:
|
||||
|
||||
1. The cross-table trigger. 3j-3k.sql drops `delivery_same_thread` before any rename and creates
|
||||
`delivery_same_session` after the new tables.
|
||||
2. Index names. The step drops `thread_claims_thread` and `message_threads_thread` before the
|
||||
renames, so the new tables can reuse the names.
|
||||
3. Per-table triggers. The step drops each guard and append-only trigger and creates it again with
|
||||
text that matches schema.sql. The digest check at store.mjs:231 enforces that, and the T7
|
||||
`sqlite_master` comparison shows it.
|
||||
4. Generation counts claim rows only. T7's fixture is Duckworth's: claim, release, claim, revoke,
|
||||
giving 1, 1, 2, 2, with t2's claim between them.
|
||||
5. Sequences. Rows keep their seq values, `sqlite_sequence` carries over, and T7 checks that the
|
||||
next insert is 41 after 40.
|
||||
6. Id collisions. A legacy session id is its thread's id. `sessions_id_not_thread` refuses a session
|
||||
id that names another thread, and `threads_id_not_session` refuses a thread id that names a
|
||||
session. New ids come from randomUUID.
|
||||
7. `thread_one_per_run`. The inner `max(seq)` is per session.
|
||||
|
||||
### Duckworth's early look (duckworth-0107) and Sage's ruling (sage-2257)
|
||||
|
||||
Duckworth read 312cf33a1 with git only and found nothing blocking. Round 2 (gizmoduck/row264-r2,
|
||||
rebased on dcd8d4dda with no shared paths) answers his notes:
|
||||
|
||||
1. Retired-generation replay. Sage ruled it into 264a (sage-2257). `#agent` now states the thread
|
||||
run's fence in generations: the session's latest claim must have the generation of the run's
|
||||
own latest claim (`#runGeneration`). This refuses nothing new. The trigger
|
||||
`thread_claim_generation` gives each claim in a session its own number, so with `h.op` 'claim'
|
||||
the generations match exactly when `h.holder_run` is the run, which is the check it replaced
|
||||
(duckworth-0108). 312cf33a1 would most likely refuse the same calls. A new sessions.test case has a released run and a revoked run each try
|
||||
`decision.withdraw`, `message.receive` and `message.read` after another run claims the next
|
||||
generation. All refuse `not-holder` and every table's row count stays the same. The workspace
|
||||
and path halves of T2 stay in 264b.
|
||||
2. `assistant.stop` keeps returning `{thread, run}`. Adding `session` would change the pins in
|
||||
tester-threads.test and threads.test for a field no caller reads yet. The Console stop in 264e
|
||||
is the first reader, so the field goes there.
|
||||
3. `#route` refuses an unknown session with `session-not-found` instead of a TypeError.
|
||||
4. The README's session section says the `default` retry answers on new threads only, because a
|
||||
migrated default has op `legacy-3k` (`legacy-3l` from round 5).
|
||||
5. The Row 155 comment at the role.revoke end now says "held thread session".
|
||||
|
||||
### Mutants
|
||||
|
||||
Each runs on a copy of the candidate tree with one edit, and the named test must fail.
|
||||
|
||||
- M1 (sage-2257). In `#agent`, replace `h.generation !== this.#runGeneration(s)` with `false`.
|
||||
Killed by sessions.test "a released or revoked run's retired generation". M1 removes the thread
|
||||
run's fence entirely, so the kill shows the refusal is tested, not that generations add a check.
|
||||
A mutant that puts back `h.holder_run !== s.run` survives by equivalence. That isn't a coverage
|
||||
gap.
|
||||
- M2 (mos-1279, sage-2688, sage-2690). Four edits, and the restore sha check covers all four files:
|
||||
1. broker.mjs:5251, in `message.receive`: drop the `mt.session=?` filter (the session clause and
|
||||
its argument).
|
||||
2. 3k-3l.sql: drop the `delivery_same_session` trigger.
|
||||
3. schema.sql: drop the same trigger.
|
||||
4. store.mjs:16: set `SCHEMA_SHA256` to the sha256 of the mutated schema.sql, with the replace
|
||||
sessions.test:432 uses. store.mjs:94 checks schema.sql against it at import (duckworth-0144).
|
||||
`LEGACY_SHA256` stays. The chain check at :117 holds because both SQL files drop the trigger.
|
||||
Without edits 2-4, the trigger refuses #delivery's INSERT first. request() then returns
|
||||
storage-refused before any assertion runs. That happened at r13 (duckworth-0143).
|
||||
A kill counts only as an assertion in a test that exercises receive: sessions.test T1 :89, T7 :422,
|
||||
or another receive assertion the log names. A failure only at the trigger's own pin (:164-170)
|
||||
doesn't count, and neither does a thrown error. The killing test is expected to be T1, where a1
|
||||
would take the second session's message.
|
||||
- M3. In 3k-3l.sql, count every claim row for `generation` instead of `op='claim'` rows only.
|
||||
Killed by sessions.test T7, generations 1, 1, 1, 2, 2.
|
||||
|
||||
Phase 4 rerun on r13 (mos-1279, sage-2688). The r13 gate's phases 1-3 carry. M1-M4 rerun whole on
|
||||
2cc34aef8 plus this note, each under `timeout -k 10 300` (Run limits). Any survivor, throw or
|
||||
non-receive kill goes to Mos unplaced.
|
||||
|
||||
### Dry run
|
||||
|
||||
Before writing any node test, I ran 3j-3k.sql in an in-memory Python sqlite3 on a 3j fixture
|
||||
(gizmo-264-out/dry-3k.py, no repo code). The claims were kept with generations 1, 1, 1, 2, 2 in seq
|
||||
order for the mixed t1/t2 fixture. Seq 40 carried over and the next insert was 41. The migrated
|
||||
`sqlite_master` matched a fresh schema.sql, 119 rows each. Every guard refused its bad write.
|
||||
|
||||
### Hand-made threads in tests
|
||||
|
||||
A thread that a test writes by hand has no default session, because only `thread.create` and the
|
||||
migration write one. The broker refuses such a thread with `session-not-found`, and the launcher view
|
||||
leaves it out. So every test that inserts a `threads` row now also writes the legacy-shape default
|
||||
session (`DEFAULT_SESSION` in threads, end-launch, thread-cli, thread-launch and chat tests). That
|
||||
mirrors the migration's rows rather than inventing a third shape.
|
||||
|
||||
### Round 5: on row 236, the step is 3k-3l
|
||||
|
||||
Row 236 (8f51900b5, which round 5 is built on) adds `migrations/3j-3k.sql` with the
|
||||
`gitea_users` and `gitea_grants` tables, and lands first. By the landing-order label (Q2,
|
||||
sage-2412) 264a's step moves one version. The design above still says 3j-3k; read it as 3k-3l.
|
||||
|
||||
- `migrations/3k-3l.sql` is round 4's `3j-3k.sql` with three renames and nothing else: the copy
|
||||
tables are `*_3k`, and the op values are `legacy-3l` and `migration-3l`. The op values name the
|
||||
step's target version, as round 4's `legacy-3k` did on 3j-3k (sage-2415). No earlier migration
|
||||
set a precedent either way.
|
||||
- store.mjs runs 236's 3j to 3k step, then 3k to 3l. `SCHEMA_VERSION` is `3l`, and
|
||||
`SCHEMA_SHA256` is the merged schema.sql's, cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a.
|
||||
schema.sql merged with no conflict: 264a's edits are above 236's two tables at the end.
|
||||
- The T7 fixture is `fixtures/schema-3k.sql`, 236's schema.sql at 8f51900b5 (SHA-256 aba92408...).
|
||||
236's `fixtures/schema-3j.sql` is the same blob round 4 added, so that path no longer differs.
|
||||
- Pins: schema.test 22 tables and 86 triggers; README 22 tables and 87 triggers. Every `'3k'`
|
||||
version pin is `'3l'`, the comment-migration future step is 3l to 3m, and each copied engine
|
||||
also copies `3k-3l.sql`.
|
||||
- account-migration and grant-migration filter both 236's objects and the 3k-3l objects.
|
||||
gitea-migration.test (236's) now reads its store after both steps: version `3l`, `rows` skips
|
||||
`sessions` and `session_defaults`, `master` skips the 3k-3l objects, and its failing engine
|
||||
copies `3k-3l.sql`. Its 3j engine already stripped every step after 3i-3j.
|
||||
- sessions.test's engine is a 3k engine: 236's schema and steps through 3j-3k, no 3k-3l.
|
||||
- The restore guard lists merged with no conflict: 236 adds `gitea_users` and `gitea_grants` to
|
||||
T46's `untimed`, and 264a adds `sessions` and `session_defaults` to COUNTED and LISTED.
|
||||
|
||||
Dry run (gizmo-264-out/dry-3l.py, Python sqlite3 in memory, no repo node): 3k-3l.sql on 236's
|
||||
schema kept every claim with generations 1, 1, 1, 2, 2, kept seq 40 with 41 next, and the migrated
|
||||
`sqlite_master` matched a fresh schema.sql, 121 rows each. Against round 4's run from 86f95bb27
|
||||
(dry-3k-r4.py), the output differs only in the op values and the 119 rows that 236's two tables make 121.
|
||||
|
||||
### Round 5 on landed 236 (47a35bf43)
|
||||
|
||||
Row 236 landed at bd5f11ded and refactor was 47a35bf43 (sage-2579). The prep commits moved from
|
||||
8f51900b5 to 47a35bf43 by cherry-pick. schema.sql, the migrations, store.mjs and the fixtures are
|
||||
the same as the prep tip 4b369ddba, so the SHA-256 and the dry run above still hold.
|
||||
|
||||
- Two conflicts, both with row 194. In broker.mjs `TESTER_VERBS` keeps 194's `instance.launch`,
|
||||
`instance.end` and `instance.request` and then 264a's `session.list` and `session.create`, and
|
||||
the private fields keep 194's block before 264a's claim comment. tester-writes.test pins the
|
||||
same list in the same order.
|
||||
- Row 205's message-filter.test (new since 8f51900b5) makes threads and `message_threads` rows by
|
||||
hand. Its threads now get the default session, as the other hand-made threads do, and its
|
||||
`message_threads` rows name that session. That makes 19 touched test files, not 18.
|
||||
- A defect from round 1: edit-264a-6.py wrote `DEFAULT_SESSION` with bare `'` quotes inside
|
||||
single-quoted strings in threads.test, end-launch.test and chat.test. Those three files could
|
||||
not parse in any earlier round. The quotes are now escaped. The two cli tests used double quotes
|
||||
and were fine. Runs below records no run, so no reported result rests on those files.
|
||||
- Every broker insert into `thread_claims`, `thread_opens` and `message_threads` names a session,
|
||||
194's new callers included. 194's lifecycle-verbs.test reads `thread_claims` by thread only.
|
||||
- Pins on this base: schema.test 22 tables and 86 triggers, README 22 tables and 87 triggers,
|
||||
COUNTED and LISTED carry `sessions` and `session_defaults`, T46's untimed list carries 236's
|
||||
`gitea_users` and `gitea_grants`.
|
||||
|
||||
### Correction (sage-2581)
|
||||
|
||||
Rounds 1 to 4 never parsed bus threads.test, bus end-launch.test or webui chat.test. Each had the
|
||||
bare quotes in `DEFAULT_SESSION` described above, from edit-264a-6.py in round 1. No run claimed
|
||||
those files: this packet's Runs section was empty through round 4 and the r4 gate was voided
|
||||
before it started (sage-2412). Round 5 escapes the quotes.
|
||||
|
||||
The round 5 gate is wider than the plan above (sage-2581, mos-1200: a rebase carries no result).
|
||||
Each of the 19 touched test files runs whole on its own, then the bus, cli and webui suites whole,
|
||||
then every `scripts/test-*.sh`, then the mutants, with every skip named.
|
||||
|
||||
### Round 6 on landed 274 (0165be266)
|
||||
|
||||
Row 274 (FED R3) landed at 4c485c63b and refactor was 0165be266 (sage-2588). The round 5 commits
|
||||
moved from 47a35bf43 by cherry-pick. broker.mjs is the only path both rows change.
|
||||
|
||||
- Four conflicts in broker.mjs, all from 274's user principal. `USER_VERBS` and `USER_LEVEL` follow
|
||||
`TESTER_VERBS`, which ends with the session verbs as before. In thread.open, thread.send and
|
||||
thread.messages each verb keeps 274's `!s.tester && !s.user` check and `#grantedThread`, and adds
|
||||
264a's optional `session` key, `#threadSession` and the session on `thread_opens` and `#route`.
|
||||
thread.send keeps 274's return, where a user's message writes no human.input.
|
||||
- Users stay at the seven ruled verbs (sage-2576): `session.list` and `session.create` are not in
|
||||
`USER_VERBS`. user-principal.test:133 already checks every verb outside the list refuses a user,
|
||||
and that set now includes the two session verbs.
|
||||
- 274's B6 check (a run answers a user only in reply to that user's message in its thread) joins
|
||||
`message_threads` by thread, not by session. No code change: a user can't call `session.create`,
|
||||
and `#thread` refuses anyone but the owner, so a user's thread has only its default session and
|
||||
the two scopes are the same rows. A slice that lets a user add sessions (264e) must make it
|
||||
session-scoped and test it.
|
||||
- 274's other writes and tests make threads through the broker, so each gets its default session.
|
||||
Its tests read `thread_opens` and `message_threads` by thread, and those columns stay.
|
||||
- schema.sql, the migrations, store.mjs and the fixtures are unchanged from round 5, and so are
|
||||
the touched test files: 19.
|
||||
|
||||
### Round 7 on landed 277 (980cec88c)
|
||||
|
||||
Row 277 (thread.claims, #1782) landed at 62f53b1e0, after row 256 (f12441d4f), and refactor was
|
||||
980cec88c (sage-2608). The round 6 commits moved from 0165be266 by cherry-pick with no conflict, and
|
||||
the range-diff is `=` for all eleven.
|
||||
|
||||
- 256 changes `packages/bus/src/runtime.mjs`, `packages/business/src/instance-settings.mjs` and
|
||||
`packages/webui/src/public/agents.js`. None is a manifest path and none of their changed lines
|
||||
names a thread, a session, `message_threads` or `thread_claims`. Three touched tests load these
|
||||
modules (bus chief-of-staff and tester-writes, cli thread-launch), so the whole-file runs cover
|
||||
256's code.
|
||||
- 277's `thread.claims` took the latest `thread_claims` row per thread. With sessions that is the
|
||||
last claim of any session, so a second held session would hide the first (duckworth-0123 N3,
|
||||
sage-2591). It now joins `sessions` and takes each session's latest row, as `#threadHolders`
|
||||
does, ordered by thread then session. The result keeps 277's five keys: two rows of one thread
|
||||
differ by `holder_run`, and `mosaic agents` prints each as its own thread line. Adding the
|
||||
session id to the row is left to the slice that shows sessions to the owner.
|
||||
- thread-claims.test (277's) makes its threads by hand, so each now gets the default session the
|
||||
migration gives an old thread (C5, :47), and its T5 message names that session (:111). T6 holds
|
||||
two sessions of one thread, sees both rows, releases one and sees the other (C4). M4 swaps the
|
||||
query back to 277's per-thread latest row and must fail T6.
|
||||
- 274's thread-scoped user reply check binds row 272 (264e) in `docs/plans/DEFERRED.md`, written
|
||||
here (sage-2608): a slice that lets a user add sessions makes it session-scoped.
|
||||
- 277's other tests: message-filter.test's claim pin reads thread.claims for a thread made with
|
||||
its default session, and the cli format and tester tests use stub rows or no threads.
|
||||
- Touched test files: 20 (thread-claims.test joins). Mutants M1-M4.
|
||||
|
||||
### Correction: the r7 red (sage-2621, duckworth-0129)
|
||||
|
||||
The r7 gate on Sage's CLEAR (sage-2619) stopped on its first file. account-migration.test failed 2 of
|
||||
3: "3h copy migrates to 3i" at :87 and "a 3h→3i step that fails partway" at :136. Each compares a
|
||||
whole snapshot of the store after the steps to 3l, `sqlite_sequence` included, with the snapshot
|
||||
before them, and found two more rows: `session_defaults` and `sessions`, both at seq 0.
|
||||
|
||||
The cause is mine. 3k-3l.sql:50-52 fills `sessions` and `session_defaults` with INSERT ... SELECT,
|
||||
and SQLite writes a `sqlite_sequence` row for an AUTOINCREMENT table on that insert even when it
|
||||
copies no row. My round 1 schema commit (67fded6af then, 31fcecf7b in r8) changed the file's
|
||||
version pin, `master` filter and engine copy, but no round ran the file before r7 (Correction above:
|
||||
no run was claimed). The same gap held in every migration test that dumps `sqlite_sequence`.
|
||||
|
||||
What I read in r8, before any run: every touched test (and every bus, cli and webui test that calls
|
||||
`migrateSchema`) that compares a whole snapshot or whole table across a step to 3l.
|
||||
`git grep migrateSchema` and `git grep sqlite_sequence` over `packages/*/tests/*` and `scripts/*`
|
||||
gave the list. A Python sqlite3 dry run of the real steps on each fixture's shape
|
||||
(gizmo-264-out/dry-seq-r8.py, no repo node) gave the rows after the step.
|
||||
|
||||
- account-migration :87 and :136: red as run. Fixed. :132 compares before the 3l step (the failing
|
||||
3h→3i engine rolls back) and is unchanged.
|
||||
- gitea-migration :90 (3j to 3l) and grant-migration :95 (3i to 3l): `rows()` leaves out the new
|
||||
tables by name but keeps `sqlite_sequence`. Same two rows at seq 0. Fixed. gitea :113 and grant
|
||||
:117 compare after a rolled-back step and are unchanged.
|
||||
- usage-migration :67 (3g to 3l), name-migration :65 (3f to 3l), spec-migration :61 (3e to 3l):
|
||||
whole snapshots with `sqlite_sequence`. Their fixtures have one thread, so the two rows are at
|
||||
seq 1. Fixed. In usage-migration the step also moves the `thread_claims` and `thread_opens` rows
|
||||
after `instance_names`' (3k-3l.sql:84-86 deletes and writes them again), so its snapshot now reads
|
||||
`sqlite_sequence` by name, as account-migration's has since row 126 (ca15d73e9).
|
||||
- Read and not affected: thread-migration :110 and :144 compare `messages` and `deliveries` only;
|
||||
sessions.test T7 and the D9 refusal compare named 3l tables, 3l against 3l, and :177 lists table
|
||||
names; each engine-refusal test (account :156, gitea and grant's third test) compares 3l with
|
||||
3l; comment-migration and chief-of-staff count rows and compare no snapshot; stack-release.test
|
||||
T46 lists table names from schema.sql, and its `--migrate` tests run stub copies at 3g to 3h.
|
||||
|
||||
The fix, in all six files: the compare after the step leaves out the two new rows and asserts them
|
||||
exactly, names and seq, so an extra row of any other name still fails. account, gitea and grant
|
||||
leave out the rows `SESSIONS_3L` names (sage-2621); their stores have no thread rows, so nothing
|
||||
else in that list has a sequence row. usage, name and spec leave out only `sessions` and
|
||||
`session_defaults`, because their `thread_claims` and `thread_opens` rows must stay in the compare:
|
||||
the step carries those seqs. Nothing else in the files changes.
|
||||
|
||||
### Round 9 on landed 278 (385a7194c)
|
||||
|
||||
Row 278 (thread claims in the Console) landed, and refactor moved from 6af85df4b to
|
||||
385a7194c, fast-forward (sage-2657). r8's 15 commits moved by rebase with no conflict, and the
|
||||
range-diff is `=` for all fifteen.
|
||||
|
||||
- 278 changes packages/webui (`src/cli.mjs`, `src/serve.mjs`, `src/public/bus.js`, README, and the
|
||||
tests `shell`, `thread-claims` and `thread-claims-browser`), BUILD-LOG.md and its own packet. None
|
||||
is a manifest path.
|
||||
- Webui reads by 264a's tests: stack-release.test reads `packages/webui/src/cli.mjs` at :718 (a
|
||||
`--tester-port` match, still present) and its copy tests at :2475-2573 replace or append the whole
|
||||
file, so 278's added line doesn't reach them. No 264a test pins a hash of a webui file; the 64-hex
|
||||
pins in the migration tests and sessions.test are fixture DDL.
|
||||
- 278's `thread.claims` reader in bus.js renders one table row per result row and checks only that
|
||||
`thread`, `holder_run`, `harness` and `at` are strings, so 264a's per-session rows (a thread with two
|
||||
held sessions lists twice, five keys kept) show as two rows. 278's three webui tests use fake bus
|
||||
objects and never reach the broker's query.
|
||||
|
||||
### Round 10 on landed 281 (e479521d3)
|
||||
|
||||
Row 281 (a fire or delete revokes the instance's grants) landed, and refactor moved from 385a7194c
|
||||
to e479521d3, fast-forward (sage-2668). r9's 16 commits moved by rebase with no conflict, and the
|
||||
range-diff gives all 16 `=`. The r9 run never started (sage-2665), so no result carries over.
|
||||
|
||||
- 281's paths over 385a7194c: packages/bus/src/broker.mjs, five tests (bus fire-revokes-grants,
|
||||
gitea-grant, grant-executor, lifecycle-revoke; cli lifecycle) and its own packet. The one shared
|
||||
path is broker.mjs, and the hunks don't touch: 264a's at :128, :455-:1081 and :3276-:5537, 281's at
|
||||
:396-:400 and :1273-:3831 (base lines).
|
||||
- 264a removes #routeThread, #threadFor and #threadHolder. No line 281 adds calls them, and every
|
||||
private name the merged broker calls is defined. No line 281 adds names thread_claims,
|
||||
thread_opens, sessions or session_defaults; its section 3a "claim" is the host-key claim.
|
||||
- 281's tests name no schema version, migration, sqlite_sequence, thread_claims or 64-hex pin. cli
|
||||
lifecycle.test's `sessions` are launcher.registry.sessions, which 264a doesn't change.
|
||||
- No 264a test reads a 281 path as text or pins its hash. The touched tests that import broker.mjs
|
||||
run against the merged file in this round's gate, which is the test of the pair.
|
||||
- 281's gate (its BUILD.md, "Whole packages") recorded the cli suite at 591 s, longer than the 535 s
|
||||
the table used. 3x is 1773 s, so the cli limit goes from 1650 s to 1800 s. Its other times (bus 23 s,
|
||||
each scripts/test-*.sh at most 36 s) are under the table's, and those limits stand.
|
||||
|
||||
### Correction: the r11 red, T2 (mos-1277, sage-2676)
|
||||
|
||||
The r11 gate (75f049ed2, ffc49615, CLEAR sage-2674) went red in its first phase at sessions.test T2
|
||||
"a released or revoked run's retired generation", :204: the events table had 10 rows before the
|
||||
three refused calls and 13 after. Every other table's count was equal. request()'s catch
|
||||
(broker.mjs:4242) sends each refusal through `#refused` (:4081-:4096), which writes one
|
||||
`action.refused` event with body `{code}`. r2's base dcd8d4dda has the same catch, so the
|
||||
assertion that every table's row count stays the same (round 2, item 1 above) could never pass.
|
||||
Sage reproduced it at 72fc5a330, ed6773f72 and 75f049ed2, and the three rows are
|
||||
`action.refused {"code":"not-holder"}`. Mos ruled it a test defect, not a product fault.
|
||||
|
||||
Mos's reading of sage-2257: "writes nothing" means a retired generation's approval, delivery and
|
||||
read write no approval, delivery, read or claim state. The refusal trail stays. Commit 19 (test
|
||||
only) makes `refused()` say that, for both calls (released and revoked):
|
||||
|
||||
- every table's row count stays equal except events;
|
||||
- each refused call adds exactly one event, kind `action.refused` with body `{"code":"not-holder"}`,
|
||||
checked after each call in call order (withdraw, receive, read), so events grows by three.
|
||||
|
||||
Before the next gate, one survey pass runs the touched files the r10 and r11 runs never reached
|
||||
(sage-2676 step 3), with M1 against the fixed T2.
|
||||
|
||||
### Run limits (sage-2634, sage-2637, sage-2647, mos-1275)
|
||||
|
||||
Every run in run-264 goes through `timeout -k 10 LIMIT`, so a hang stops the run instead of holding
|
||||
the slot. Each LIMIT is at least 3x the longest recorded run of that file, suite or script. The
|
||||
records are my own gate logs (`ℹ duration_ms` for node runs, the lap lines for scripts), the times
|
||||
in committed BUILD.md files, and row 278's committed receipts: `SHELL-LIMITS.json` (row 196's
|
||||
longest per script) and `run3/whole/results.json` in `agents/gander/work/queue-278`. A timeout with no failed test is a hang, not a pass or a
|
||||
red, and goes to Sage first (sage-2637). A timeout in the shell phase goes to Mos unplaced
|
||||
(mos-1275).
|
||||
|
||||
| Run | Longest recorded | Where | LIMIT |
|
||||
|---|---|---|---|
|
||||
| stack-release.test alone | 575 s | the r12 survey, 2026-10-22 (row 234 r3 singles with mutants: 505 s; plain r3: 482 s); 3 x 575 = 1725 (mos-1275 item 3) | 1800 s |
|
||||
| each other touched file alone | 15 s (thread-launch.test); end-launch, gitea-migration, message-filter, sessions, thread-claims, thread-cli and chat have none | rows 234, 235, 210 logs | 300 s |
|
||||
| bus suite | 23 s | row 235 run 1b; row 278 run 3: 22.5 s | 900 s |
|
||||
| cli suite | 591 s | row 281's gate, 2026-10-22 (row 235 run 1: 535 s; Megavolt row 199: 472 s) | 1800 s |
|
||||
| webui suite | 57 s | row 278 run 3 (row 66 r2: about 60 s) | 600 s |
|
||||
| each scripts/test-*.sh | 42 s (test-foundation); task 36.8, queue 23.4, land-work 12.6, release 12.2, discord 8.8, conductor 8.1, config 3.2, extension-package 3.0, auth 1.9 | the longest of row 268's gate (2026-10-22 04:40Z), row 196's receipts and row 278 run 3 | 900 s |
|
||||
| mutant probes M1-M4 (sessions.test, thread-claims.test) | 1 s wall each (longest `ℹ duration_ms` 512, M3) | the r13p4 rerun, 2026-10-22 14:08:43Z to 14:08:45Z | 300 s |
|
||||
|
||||
## Runs
|
||||
|
||||
- r7 a23afbf95 on 980cec88c, Sage's CLEAR sage-2619, 2026-10-22 11:29:47Z. HEAD and the manifest
|
||||
digest eb7c1ab3 printed and matched. RED on the first file, account-migration.test 1 pass 2 fail
|
||||
(Correction above); the run stopped there as ruled, with no retry. The sweep found 0 node
|
||||
processes in the worktree. Slot returned.
|
||||
- r10 54f65f9ab on e479521d3, Sage's CLEAR sage-2669, 2026-10-22 13:11Z. RED on the first file,
|
||||
account-migration.test :106: later3l missed the 3l trigger threads_id_not_session (3k-3l.sql:104).
|
||||
Mos ruled option A (mos-1276): commit 18 names it in account-, gitea- and grant-migration. On r11
|
||||
all 8 *-migration files ran whole, 25/25, 0 skipped (2026-10-22 13:14:25Z).
|
||||
- r11 75f049ed2 on e479521d3, manifest ffc49615, Sage's CLEAR sage-2674, 2026-10-22 13:16:37Z.
|
||||
Phase 1 was green for account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5,
|
||||
end-launch 7/7, gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1
|
||||
and schema 1/1. Then RED at sessions.test T2 :204 (Correction: the r11 red, above), and the run
|
||||
stopped. 0 node processes left. Mos ruled a test defect (mos-1277): commit 19.
|
||||
- r12 93f4af12b on e479521d3, manifest 16603087, survey pass (sage-2676 step 3), Sage's CLEAR
|
||||
sage-2678, 2026-10-22 13:24:09Z to 13:34:07Z, 598 s. Each file whole under `timeout -k 10`, with
|
||||
no stop on red and no edits, all 0 skipped and 0 todo:
|
||||
sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3,
|
||||
threads 16/17 RED, usage-migration 1/1, thread-cli 11/11, thread-launch 11/11, chat 23/23,
|
||||
stack-release 218/218 in 575 s. M1 against the fixed T2: rc=1, killed on an assertion, file
|
||||
restored, sessions 6/7 with T2 sage-2257 the only failure ("Missing expected exception: approval"
|
||||
at :204). Log gizmo-264-out/r12-survey/probes/M1.log. Mos accepted M1 killed by T2 alone at r12
|
||||
(mos-1278 item 4). The threads.test red: :330 expected `/only to its thread's holder/` (:341,
|
||||
from row 121, 2c16d6b93) and got the 3l trigger's "only to its session's holder". Mos ruled
|
||||
option A (mos-1278): delivery_same_thread in the base (schema.sql:300-306 at e479521d3) and
|
||||
delivery_same_session (3k-3l.sql:147-153) share the WHEN, the ops and the holder check, and
|
||||
differ only in taking the latest claim per session and in the text, so :330 keeps its strength.
|
||||
Commit 20 changes :341 to `/only to its session's holder/` and nothing else in that file.
|
||||
- r13 gate, 2cc34aef8 on e479521d3, manifest 65e04d27, Sage's CLEAR sage-2684. Start
|
||||
2026-10-22 13:39:53Z, busy count 0, HEAD and digest printed and matched, blobs checked with
|
||||
`sha256sum -c`. Every count below has 0 fail, 0 cancelled and 0 todo.
|
||||
- Phase 1, the 20 touched files whole, one at a time, all 0 skipped, wall 600 s: bus
|
||||
account-migration 3/3, chief-of-staff 42/42, comment-migration 5/5, end-launch 7/7,
|
||||
gitea-migration 5/5, grant-migration 6/6, message-filter 6/6, name-migration 1/1, schema 1/1,
|
||||
sessions 7/7, spec-migration 1/1, tester-writes 8/8, thread-claims 6/6, thread-migration 3/3,
|
||||
threads 17/17, usage-migration 1/1; cli stack-release 218/218, thread-cli 11/11,
|
||||
thread-launch 11/11; webui chat 23/23.
|
||||
- Phase 2, suites whole: bus 827/827 (23 s), cli 837/837 (593 s), webui 345 pass of 346 with 1
|
||||
skipped (55 s). The skip is the esbuild 0.28.1 editor rebuild (Duckworth named it in
|
||||
duckworth-0146; DEFERRED row 264a N2).
|
||||
- Phase 3, the 10 scripts/test-*.sh, all rc=0: auth 15, conductor 17, config 31, discord 81,
|
||||
extension-package 18, foundation 44 (36 s), land-work 86, queue 156 node tests and 28 suite
|
||||
checks, release 14, task 91. The worktree was clean after.
|
||||
- Phase 4: M1 killed by T2 at sessions.test:204 (6/7). M2 then failed with no assertion: T1
|
||||
(:89) and T7 (:422) threw `storage-refused` from #refused (broker.mjs:4082) through request()'s
|
||||
catch (:4242), because the delivery_same_session trigger refused #delivery's INSERT. The run
|
||||
stopped there at 14:02:47Z, total wall 1374 s, and M3 and M4 did not run. 0 node processes
|
||||
left. Mos ruled option B (mos-1279): phases 1-3 carry and phase 4 reruns with M2's four edits.
|
||||
- Logs: gizmo-264-out/r13-run.out, with the per-file, suite and probe logs under
|
||||
gizmo-264-out/r13/single, r13/suite and r13/probes.
|
||||
- r13p4, phase 4 rerun under mos-1279 at HEAD 84f53540d (2cc34aef8 plus commit 21, the M2 note
|
||||
in this file), Sage's CLEAR sage-2688 with the fourth edit confirmed in sage-2690. Start
|
||||
2026-10-22 14:08:43Z, busy count 0. `git diff --name-only 2cc34aef8..HEAD` was this file only,
|
||||
and the non-BUILD.md blobs matched manifest 65e04d27. Kit gizmo-264-out/probes-264-r13p4.py
|
||||
(`--check ok`), each probe under `timeout -k 10 300`, each file restored and its sha checked.
|
||||
End 14:08:45Z, failures 0, 0 node processes left.
|
||||
- M1, the #agent generation fence set to `false`: killed, sessions 6/7, T2 sage-2257 at
|
||||
sessions.test:204 (`assert.throws(fn, { code: 'not-holder' }, label)`).
|
||||
- M2, four edits: the `mt.session=?` clause and its argument dropped from message.receive
|
||||
(broker.mjs:5251), delivery_same_session dropped from 3k-3l.sql and from schema.sql, and
|
||||
store.mjs:16 SCHEMA_SHA256 set to the sha256 of the mutated schema.sql. Killed, sessions 4/7,
|
||||
by T1 at sessions.test:89 (a1's receive got the second session's message). T7 also failed on
|
||||
its receive assertion at :422. T2 failed at :168, the trigger's own pin, which doesn't count as
|
||||
a kill under mos-1279. The kill rule: only an assertion in a test that exercises receive
|
||||
counts, never a thrown error.
|
||||
- M3, generation counted over every claim row in 3k-3l.sql: killed, sessions 6/7, T7 at
|
||||
sessions.test:390 (the thread_claims generations).
|
||||
- M4, thread.claims back to the latest row per thread: killed, thread-claims 5/6, T6 at
|
||||
thread-claims.test:133 (two held sessions list as two rows).
|
||||
- Logs: gizmo-264-out/r13p4-run.out and gizmo-264-out/r13p4/probes/M1.log to M4.log.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Row 207 build notes: 193c, `mosaic access user` verbs and `--level viewer` (#1705)
|
||||
|
||||
Builder Megavolt, reviewer Duckworth, gate owner Sage (sage-2790, Q220, mos-1301). Branch megavolt/row207 off
|
||||
aca16404c. The brief is "Brief: 193c, mosaic access user verbs and --level viewer" in
|
||||
docs/plans/2026-10-20_human-users-build.md (blob c74af2510). Its gate: Duckworth approves the manifest, the cli
|
||||
suite and every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
|
||||
## Sources
|
||||
|
||||
- The brief, quoted: "`mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
|
||||
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5 already
|
||||
has all of them, this row records that and closes with no code."
|
||||
- Morgana's row 193 design, section 4.5 and section 8 (agents/morgana/work/queue-193/DESIGN.md at e2d161e2e on
|
||||
morgana/row193): `user add <id> --home local --subject <provider>:<sub>`, `user remove <id>`, `--level viewer`
|
||||
on `grant`, a receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call.
|
||||
- R5 as it stands at aca16404c: packages/cli/src/access.mjs, packages/cli/tests/access.test.mjs,
|
||||
packages/business/src/access.mjs (checkAccess, read only here), packages/bus/src/broker.mjs and runtime.mjs.
|
||||
|
||||
## 1. What R5 already has
|
||||
|
||||
- `grant --level viewer`: present. LEVELS is `["collaborator", "viewer"]` (packages/business/src/vocabulary.mjs),
|
||||
grant checks `--level` against it, and access.test.mjs already grants at viewer. This row adds no code for it
|
||||
and records it here.
|
||||
- `user add` and `user remove`: absent. accessVerb takes only list, grant and revoke, and the only way to add a
|
||||
user today is to hand-edit access.json. This row adds them.
|
||||
- `access.reload`: absent everywhere. No bus verb has that name. The R5 brief says "R4 later makes these verbs
|
||||
call `access.reload`, so R5 leaves no other reload path", FED R3's brief leaves it to R4, and access.mjs's
|
||||
proveHuman says "Row R4 swaps this call for access.reload". FED R4 is not filed.
|
||||
|
||||
## 2. Decisions (Q items for Sage and Duckworth, stated as assumptions, not blockers)
|
||||
|
||||
- Q1, the reload call. 193c adds no `access.reload` call, because there is no verb to call. The user verbs take
|
||||
R5's path exactly: the same `project.list` human proof, and R4 swaps the proof for `access.reload` on all five
|
||||
verbs at once. Nothing is lost meanwhile: runtime.mjs hands the broker `() => loadAccess(...)`, and the broker
|
||||
reads the file once per user request (broker.mjs:384), so a written change acts on the next request. If Sage
|
||||
or Duckworth reads the brief as "add `access.reload` here", that is R4's bus verb and a scope change.
|
||||
- Q2, remove with grants. `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, while any
|
||||
grant at any business names the user. The other reading drops the user's grants in the same revision. Refusing
|
||||
is the smaller, fail-closed choice: nothing disappears that the owner did not name, and the help text already
|
||||
says to revoke first. checkAccess would also refuse the file (a grant naming a missing user), but with a key
|
||||
path, not a reason, and exit 3.
|
||||
- Q3, the subject. `--subject PROVIDER:SUB` splits at the first colon, so a sub may hold colons. One subject per
|
||||
add, because parse keeps one value per flag. A second subject is a later row if anyone needs it; checkAccess
|
||||
allows several.
|
||||
- Q4, `--business`. Required on the user verbs too. The user list is instance-wide, but the human proof runs
|
||||
through one business's bus, and the receipt names it.
|
||||
- Q5, the receipt. `{at, verb: "user.add" | "user.remove", business, user, actor: "human", revisionBefore,
|
||||
revisionAfter, grants: []}`: grant and revoke's shape, with an empty grants list.
|
||||
- `user remove` of an id not in the file refuses, exit 2, and writes nothing, as revoke of nothing does.
|
||||
- `user add` of a duplicate id, a reserved id (human, tester), a business's human, an unknown provider, a
|
||||
non-local home, an owner sso subject or a subject another user holds refuses through checkAccess, exit 3,
|
||||
before any write. The verb does not repeat those checks.
|
||||
|
||||
### Rulings (sage-2810, 16:40Z, cc Duckworth)
|
||||
|
||||
- Q1 agreed: no reload call. The reason is broker.mjs:384-386, the `#access` field's comment: "() => the checked
|
||||
access document (R1's loadAccess), read once per user request", wired in runtime.mjs:263 as
|
||||
`() => loadAccess(access.file, access.context)`. A write acts on the next request. FED R4 owns any reload verb.
|
||||
- Q2 agreed: `user remove` refuses with `user-has-grants`, exit 2, naming the grant ids, and writes nothing.
|
||||
Dropping the grants silently would make removal revoke access without saying so.
|
||||
- Usage and help pins: grep the tests for the changed strings, sha256 and snapshot pins included, and run every
|
||||
reader whole (section 6).
|
||||
- Row 203 (Quackerjack) does not touch access.mjs; its stale help lines stay a follow-up, so no overlap.
|
||||
|
||||
### The usage and help pin sweep (sage-2810), on aca16404c plus this row
|
||||
|
||||
- Source readers of ACCESS_USAGE and ACCESS_HELP: packages/cli/src/access.mjs only (cli.mjs imports
|
||||
accessCommand and accessContextFor, not the strings).
|
||||
- `git grep` over 'packages/*/tests/*' and 'scripts/*' for ACCESS_USAGE, ACCESS_HELP, "mosaic access",
|
||||
"access list", "access grant", "access revoke", "Access (FED" and the help's "revoke them here first": the only
|
||||
hits are packages/cli/tests/access.test.mjs (positive control: `accessCommand` hits it twice).
|
||||
- Hash pins: no test file names packages/cli/README.md or packages/cli/src/access.mjs next to a sha256 or a
|
||||
64-hex value. The one 64-hex file that mentions a README (packages/queue/tests/fixtures/queue-marked.md) names
|
||||
packages/queue/README.md and packages/ledger/README.md, not this one. scripts/stack-copy.sh copies
|
||||
packages/cli/README.md; stack-copy.test.mjs builds its own fake repo and is in the cli package, run whole.
|
||||
- packages/business/tests/access*.test.mjs import packages/business/src/access.mjs, a different file, unchanged.
|
||||
- So the readers are access.test.mjs, run whole in step 1, and the cli package whole in step 2.
|
||||
|
||||
## 3. The change (code and docs)
|
||||
|
||||
- packages/cli/src/access.mjs: two ACCESS_USAGE lines and one help line; accessVerb accepts `user add ID` and
|
||||
`user remove ID` (three positionals) and hands off to `userVerb` after the shared checks (tester, business,
|
||||
proof, file, context, revision). `userVerb` takes the lock, checks the revision, builds the new users list,
|
||||
runs checkAccess on the whole document, writes atomically and appends the receipt.
|
||||
- packages/cli/README.md: the usage block and one paragraph.
|
||||
- No change to packages/business, packages/bus or cli.mjs (it already routes `access` to accessCommand).
|
||||
|
||||
## 4. Tests (packages/cli/tests/access.test.mjs, whole file)
|
||||
|
||||
Manifest check against Morgana's row 275 (sage-2790), 10-22: morgana/row275 has no commits past aca16404c, and
|
||||
row 275's brief ("193b1b, viewer verbs and board refusals") is bus only: the viewer verb list, `covers` level
|
||||
checks and board refusals, gated on the bus suite. This row touches only packages/cli (access.mjs, README,
|
||||
access.test.mjs) and this file, so no path is shared on the briefs. I recheck the paths when Morgana pushes.
|
||||
|
||||
- T1 `user add` writes the user (id, home, one subject), bumps the revision by one, prints the line and appends
|
||||
one receipt with verb `user.add` and `grants: []`. A sub with a colon (`authentik:bob:x`) keeps `bob:x`.
|
||||
- T2 `user add` refusals before any write, file and receipts unchanged: missing `--home` or `--subject`, a
|
||||
subject with no colon, an empty provider or sub (exit 4); an unknown provider, a duplicate id, `tester`, the
|
||||
business's human, an owner sso subject, a subject alice already holds, a non-local home (exit 3).
|
||||
- T3 `user remove` of a user with no grants drops them, bumps the revision, receipt `user.remove`.
|
||||
- T4 `user remove` of a user with a grant refuses `user-has-grants` naming the grant id, exit 2, nothing written.
|
||||
- T5 `user remove` of an id not in the file refuses, exit 2, nothing written.
|
||||
- T6 a stale `--revision` refuses `revision-conflict` on both user verbs, exit 3.
|
||||
- T7 `--tester` and an agent env (MOSAIC_RUN_ID) refuse both user verbs, exit 3, and the fake human CLI is never
|
||||
called.
|
||||
- T8 a bad shape (`user`, `user add`, `user list x`, `user add a b`) prints the help, exit 4.
|
||||
- T9 a bus that refuses the human proof (the fixture's `mode: "refuse"`) blocks both user verbs: exit 3, the
|
||||
file and receipts unchanged.
|
||||
|
||||
## 5. Mutants
|
||||
|
||||
Each runs the whole access.test.mjs and counts as killed only on an assertion in the named test.
|
||||
|
||||
- M1 drop the `user-has-grants` refusal. Second fence: checkAccess then refuses the file with exit 3, so T4 must
|
||||
assert the `user-has-grants` text and exit 2, not only "nothing written". Killed by T4.
|
||||
- M2 drop the "no user to remove" check. The write then lands with an unchanged user list. Killed by T5.
|
||||
- M3 split the subject at the last colon. Killed by T1's colon case.
|
||||
- M4 skip checkAccess in userVerb (write the unchecked document). Killed by T2's exit-3 cases.
|
||||
- M5 drop the receipt in userVerb. Killed by T1 and T3.
|
||||
- M6 drop the revision check in userVerb. Killed by T6.
|
||||
- M7 exempt the user verbs from the `--tester` check (`&& sub !== "user"`). Killed by T7's tester case.
|
||||
- M8 skip proveHuman for the user verbs (`if (sub !== "user")`). Killed by T9. T7's agent case does not reach it: accessCommand
|
||||
calls refuseInsideAgent before accessVerb, so an agent env is refused whatever the order inside.
|
||||
|
||||
## 6. Gate plan (mos-1275)
|
||||
|
||||
On Sage's CLEAR, from a rebase onto 253's landing under a new branch name, stopping at the first red:
|
||||
|
||||
1. access.test.mjs whole, then M1-M8, each under `timeout -k 10 180`.
|
||||
2. The cli package whole.
|
||||
3. Every `scripts/test-*.sh` as `timeout -k 10 LIMIT bash scripts/test-X.sh`, no script edited. LIMIT is row
|
||||
281's table (Gander's row 278 limits, 3x the longest of three records plus 120 s): auth 126, conductor 145,
|
||||
config 130, discord 147, extension-package 130, foundation 240, land-work 158, queue 191, release 157,
|
||||
task 231 seconds. The base has the same ten scripts. Any timeout goes to Sage, then Mos, unplaced.
|
||||
|
||||
## Runs
|
||||
|
||||
Filled in after the runs.
|
||||
@@ -1519,3 +1519,9 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-22 10:30:51 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 274 landing, row 281 d6 and 264a r5 plans | 274 landed 4c485c63b (ff, #1779 closed c30491, P19 414 c646); review request posted for Morgana (no token) c30488; 281 d6 approved by Dewey, code started; 264a r5 c4c75b6b4/5cb234285 run plan accepted; N7 DEFERRED (runs suite gates mosaic-task.mjs) committed
|
||||
2026-10-22 10:59:30 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 256 #1761 creative delete landing | 256 landed f12441d4f (ff on 0165be266, 4 Honker commits); Webby APPROVE c30519 posted as jarvis for Webby (no token), rev 3185; request c30518; note 3186, done 3187; #1761 closed c30520; P19 416 c649; 277 rebases onto this landing next (sage-2594)
|
||||
2026-10-22 11:21:00 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 277 #1782 owner thread claims landing | 277 landed 62f53b1e0 (ff on 46dedf925, 7 Morgana commits); Duckworth APPROVE c30539 posted as jarvis, rev 3192; request c30538; note 3193, done 3194; #1782 closed c30540; P19 506 c652; Honker 256 and Megavolt 236 BUILD.md notes picked; DEFERRED M8 resolve pin
|
||||
2026-10-22T11:25Z | Dewey (Opus 5.5, 84fb346c) | Vikunja 502 rebase carry onto 980cec88c (Rocko 407c8d7be/04a86cf33) | APPROVE 9bf4683c6 carried: range-diff 1:= 2:=, patch-ids 37135ebe/0dbcda54; conditional on green touched-file and Python reruns on the new base (f5611c937 drift under stop-run); dewey-0834/0835
|
||||
2026-10-22 11:29:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | Vikunja 502 on 980cec88c | clean picks407c8d7be/04a86cf33, patch-ids match; whole Node28/28,Python4/4,no skips/timeouts,sweepclear; rerun packet6af85df4b pushed explicitly to rocko/vikunja502-980c-20261022; Sage lands.
|
||||
2026-10-22T13:06Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) manifest round 1 on megavolt/row281-e e479521d3 (manifest b73d4c81, 7 paths, on 385a7194c) | APPROVE c30614, rev 3207; N1 hosts.remove/open-decision hint and N2 fire-tool text non-blocking, DEFERRED via Sage
|
||||
2026-10-22 11:32 UTC | Sage (Opus 5.5, 1ef1e4f8) | Vikunja 502 landing; 281 and 264a slot reds placed | V502 LANDED 6af85df4b (ff from 980cec88c, Dewey carry dewey-0836). Row 281 c174f37ac and 264a r7 a23afbf95 each went red on first-run test defects. 281 b3 is test-only (T6b ruling accepted by Dewey 0837). 264a r8 pins the 3l seq-0 rows. Megavolt reruns 281 on 6af85df4b.
|
||||
2026-10-22 13:10 UTC | Sage (Opus 5.5, 1ef1e4f8) | Rows 278 and 281 landings; slot order 281 then 264a r10 then 253 | 278 LANDED 385a7194c (Webby c30588), 281 LANDED e479521d3 (Dewey c30614). DEFERRED: grant gap closed, N1/N2 open. 264a rebasing to r10 on e479521d3; 253 waits on 264a.
|
||||
2026-10-22 14:22 UTC | Sage (Opus 5.5, 1ef1e4f8) | Row 264a landing; doc sweep | 264a LANDED 84f53540d (Duckworth c30640, #1769 closed c30646, P19 463 c669) after four slot reds ruled in mos-1276 to mos-1279. Doc sweep: V502, 278/281 and 264a BUILD-LOG lines, DEFERRED 281 N1/N2 and 264a N1/N2. 253 rebases onto the landing next.
|
||||
+45
-11
@@ -431,17 +431,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
comes near 64 runs. The follow-up row either raises the cap or splits
|
||||
the done. Its test: a fire's done over the cap refuses, and the runs it
|
||||
left alive still get the alive flag. (2026-10-22)
|
||||
- **A fired or deleted instance keeps its grants.** (Dewey on the row 236
|
||||
rebase look, dewey-0815; Mos mos-1271; Sage sage-2558.) Row 194's
|
||||
`instance.fire` and row 256's delete revoke only the roles entry's
|
||||
credential services through `revoke()` in `runtime.mjs`. The agent's
|
||||
host grants (row 235) and its Gitea user and token (row 236) stay live
|
||||
until a human runs `mosaic hosts revoke` or `mosaic grants revoke`. The
|
||||
Console has no revoke route and doesn't say a grant is left. Row 281
|
||||
(#1786) closes it: a fire or a delete revokes every host and Gitea grant
|
||||
the instance holds, fail closed, with a test for each kind. It lands
|
||||
before the .53 Case 1 install hires an instance with a grant.
|
||||
(2026-10-22)
|
||||
|
||||
- **The runs suite doesn't gate a change to `scripts/mosaic-task.mjs`.**
|
||||
(Dewey N7 on row 191, repeated on the Vikunja 502 review.)
|
||||
@@ -460,6 +449,44 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
changes `packages/cli/tests/tester.test.mjs`: add the resolve pin so M8
|
||||
dies on an assertion. (2026-10-22)
|
||||
|
||||
- **Row 274's user reply check is scoped to the thread, not the session.**
|
||||
(Row 264a r6, sage-2591.) In `packages/bus/src/broker.mjs`, the check that
|
||||
a run answers a user only in reply to that user's message in its thread
|
||||
joins `message_threads` by thread. That holds while a user's thread has
|
||||
only its default session: a user can't call `session.create`, and
|
||||
`#thread` refuses anyone but the owner. Binds row 272 (264e, #1777), or
|
||||
any row that lets a user add sessions: make the check session-scoped and
|
||||
test it with two sessions. (2026-10-22)
|
||||
|
||||
- **Row 281's refusal texts point the wrong way.** (Dewey N1 and N2 on
|
||||
the row 281 manifest round, dewey-0842/0843 and dewey-0846; Sage
|
||||
sage-2653.) N1: when a host decision is still open and the host is
|
||||
removed, step G's fire or delete job refuses `host-unknown`, and the
|
||||
inbox hint names a verb that can't help. The way out is to decline or
|
||||
withdraw the decision (`packages/bus/src/broker.mjs`, the closed check
|
||||
near :1684 and `decision.withdraw` near :5015). T14 now pins that path.
|
||||
N2: the chief's fire tool text in `packages/harness/src/tools.mjs`
|
||||
(near :186) doesn't say the fire's job can come back for an answer.
|
||||
Neither changes behaviour. Binds the next row that changes the step G
|
||||
hint text in `broker.mjs` or the fire tool text in `tools.mjs`: fix
|
||||
both wording gaps and pin the hint in a test. (2026-10-22)
|
||||
|
||||
- **Row 264a's refusal check in sessions.test doesn't pin the actor.**
|
||||
(Duckworth N1 on the row 264a r12 delta, duckworth-0138; Sage sage-2695.)
|
||||
`refusedOnce` in `packages/bus/tests/sessions.test.mjs` checks that each
|
||||
refused call adds one `action.refused` event with code `not-holder`, but
|
||||
not that the event's `actor_run` is the refused run. Binds row 269 (264b,
|
||||
#1774), or the next row that changes `sessions.test.mjs`: pin `actor_run`
|
||||
in `refusedOnce`. (2026-10-22)
|
||||
|
||||
- **editor-provenance skips its rebuild check on esbuild 0.28.1.** (Duckworth
|
||||
N2 on the row 264a manifest round, duckworth-0146.) The webui test
|
||||
"rebuilding from the source gives the same bytes" needs esbuild 0.28.2,
|
||||
the checkout has 0.28.1, and the gate logs a skip (webui.log:99). The file
|
||||
is at base and was not in 264a's manifest. Binds the next gate that
|
||||
touches the webui build: install the pinned esbuild in the gate checkout
|
||||
so the check runs, or record why it can't. (2026-10-22)
|
||||
|
||||
## Queue
|
||||
|
||||
Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
|
||||
@@ -531,3 +558,10 @@ Moved to `docs/plans/QUEUE.md` on 2026-09-13. This file holds only gaps.
|
||||
`packages/cli/tests/gate-lane-auth.test.mjs` proves no gate step mounts
|
||||
the host default. The real-model cases this moved off the gate are open
|
||||
above.
|
||||
- A fired or deleted instance kept its grants (2026-10-22, Dewey on the
|
||||
row 236 rebase look, mos-1271): closed by row 281 (#1786), landing
|
||||
e479521d3. A fire or a delete now runs a step G job that revokes every
|
||||
host and Gitea grant the instance holds, fail closed, and tells the
|
||||
human in the inbox when it can't. Megavolt built it; Dewey approved the
|
||||
design (d6) and the manifest (c30614). The text gaps it left are open
|
||||
above.
|
||||
+4
-4
@@ -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 3194 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3216 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -300,7 +300,7 @@ Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not e
|
||||
| 261 | Email E05: Graph source module against a test-local registry (post-v1) | unassigned | #1766 | queued | 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E05 Graph source module against a test-local registry | — |
|
||||
| 262 | Email E06: IMAP source module (post-v1) | unassigned | #1767 | queued | 261 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E06 IMAP source module | — |
|
||||
| 263 | Email E07: address and rule evaluator (post-v1) | unassigned | #1768 | queued | 258 done, 259 done, 260 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E07 address and rule evaluator | — |
|
||||
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | in-progress | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat). |
|
||||
| 264 | T1 item 7: independent sessions, compaction counts and clear context | gizmoduck; reviewers duckworth | #1769 | done | — | The named reviewer approves the manifest. The design's T1-T7 acceptance tests run whole plus every touched suite on Sage's slot; T3 waits on Vikunja 466 (N2, Claude's compaction count format and the managed dataRoot reader). The design's section 6 mutants are killed. (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695). |
|
||||
| 265 | Case 2 C2-09b rooms: room.open, room.send with @tags, room.read | rocko; reviewers dewey | #1770 | queued | 219 done | Dewey approves the manifest. The brief's tests run whole plus the bus suite and 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-09b rooms: room.open, room.send with @tags, room.read | — |
|
||||
| 266 | Case 2 C2-10c Console room view: merged timeline and @name completion | researcher; reviewers dewey | #1771 | queued | 225 done, 265 done | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every touched suite. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10c Console room view: merged timeline and @name completion | Haiku high subagent under decision 129. |
|
||||
| 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. |
|
||||
@@ -314,10 +314,10 @@ Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not e
|
||||
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | briefed | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | — |
|
||||
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
|
||||
| 277 | The owner sees a thread agent's claims: an owner or reader read lists held thread claims (10-27, mos-1262; no user thread live before it) | morgana; reviewers duckworth | #1782 | done | 205 done | Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the owner sees thread claims | LANDED 62f53b1e0 ff from 46dedf925 (7 Morgana commits, range patch-id 7f0533520bd2). Duckworth APPROVE manifest e281aa2c (10 paths), comment 30539. Gate 11:01-11:14Z: 7 touched files 0 fail, bus 793, cli 836, harness 151, business 231, seat 167, 10 scripts rc=0, M1-M9 killed; skips named, env only (test-queue root, test-task live x2). Overlap 13/257 clean. N3 per-session claims carry to 264a r7. |
|
||||
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | briefed | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | — |
|
||||
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | done | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes. |
|
||||
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | done | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | LANDED ceadad993 (carry of cad927137 onto 343b6dd76: range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey APPROVE dewey-0809 c30409 + carry). Fix: stopRun loop is for(;;), deadline branch is the only time exit. Sage gate green: business 209/0/0, cli 794/0/0, discord processing+stop 9/0/0; Rocko stop-run 13/13, processing-control 62/62, M1 killed. Overlap sweep clean (13, 257). Next: 194 b16 rebases onto it (mos-1269). |
|
||||
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
|
||||
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | in-progress | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | — |
|
||||
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | done | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614. |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+698
-21
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
|
||||
"revision": 3194,
|
||||
"revision": 3216,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -26839,7 +26839,7 @@
|
||||
"closes": [
|
||||
1769
|
||||
],
|
||||
"state": "in-progress",
|
||||
"state": "done",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -26854,16 +26854,70 @@
|
||||
"reviewers": [
|
||||
"duckworth"
|
||||
],
|
||||
"review": null,
|
||||
"claim": {
|
||||
"seat": "gizmoduck",
|
||||
"op": "gizmoduck-264-claim-1"
|
||||
"review": {
|
||||
"rounds": [
|
||||
{
|
||||
"n": 1,
|
||||
"op": "gizmoduck-264-review-r13b",
|
||||
"by": "gizmoduck",
|
||||
"at": "2026-10-22T14:11:07.284Z",
|
||||
"issue": 1769,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e",
|
||||
"text": "0f14a8e813a75abc8ab2a49d4574496e68a83ae3a37019c5bbfbbbc439fbe2ab agents/gizmoduck/work/queue-264/BUILD.md\nee456d3f7f5f36c499b1144f5c1a51bd3400662bd576c0a2c403a70cd49772c8 docs/plans/DEFERRED.md\n764b3d7865bfa3a0829f3c7d5553446fc61478cc8b46c0da5dd9463ea7ef1543 packages/bus/README.md\ncd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a packages/bus/schema.sql\na581d9e687ec2784751e293bd320885ba2ee75cbff38dd0f7f0816c3b6ccf1f1 packages/bus/src/broker.mjs\neb2822c73e527697399691ae520092277ddbd4d2a9a42a0731b3c81cc3569e58 packages/bus/src/migrations/3k-3l.sql\nca4a8eceef394dae290a5ad7bdb9f335ebbe53ba02aa3af71be7001ba2725a93 packages/bus/src/store.mjs\n4e2f46031bb2025bb7d674a7da5b663312ed827d346f354df0453d3595fa8781 packages/bus/tests/account-migration.test.mjs\necf7536dbb12765ef30897fbd0436f68d11233da7e79ff12d4cf80baf3b961fd packages/bus/tests/chief-of-staff.test.mjs\n7d8ddb1f603bda89488e521e27b68ada373efb19f23864da78613eb14baf6f6d packages/bus/tests/comment-migration.test.mjs\n45b3a93609179a2f856a9f2620acca2f0b07ab239d2d5957a792d6c6fadeb0af packages/bus/tests/end-launch.test.mjs\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/tests/fixtures/schema-3k.sql\n06c33d2101278f71b315327d7be1650d97f828b0dc2aafcc28c73a57d0fe0024 packages/bus/tests/gitea-migration.test.mjs\nb7a560894b9a6eb999b80dc9b2989ac4745b568f74d0b52eec3ab0459b46ad2b packages/bus/tests/grant-migration.test.mjs\n6e41496a5d55705f484e074e2264a84f5299e04211cd1a2f384f18c5b4f6f9c5 packages/bus/tests/message-filter.test.mjs\nb1921b88ba6ee0c28b4d9652ff529abba4b7e8b12423e99cf7996c3e207ef3f5 packages/bus/tests/name-migration.test.mjs\n52e7de4bdf04f7787f70079f78017fd034abd968370059f22d859ce5a58e27a8 packages/bus/tests/schema.test.mjs\nc09cdcca750d44ae1cebd3e8970708d39edd70682b99eeb77e19258d9fa8c068 packages/bus/tests/sessions.test.mjs\nc2f9002a8ba4474fa1bafc84d8ffd67756d450cf9a1346194a871cd0d541d524 packages/bus/tests/spec-migration.test.mjs\n4fd9c541735a8e654bee84ecab0172f9b1f21c668091fe12624ce812919327e4 packages/bus/tests/tester-writes.test.mjs\n0007f55235ef25e70bf89cfb9407e07c4780b54566163183d19b1f902fc0251e packages/bus/tests/thread-claims.test.mjs\n19e4200961fd81a23f2bf7089f81b2a57165a6a81c026ef879ab756587d705dc packages/bus/tests/thread-migration.test.mjs\n90d25f35389478cd3307b2383d93dc0a2519dd88eddc3c26bd37c39161083269 packages/bus/tests/threads.test.mjs\ncdd758deb9bcbb33eb1f4138673a11b9a5fd82fdd4fd88c14197d5240fe4b780 packages/bus/tests/usage-migration.test.mjs\n43f09ab4e7d3f8178a6cab6889ce2d85c83b8151a0f0dc26325d39bbe4236dc0 packages/cli/tests/stack-release.test.mjs\ncf6c1310f32d8495b5838620760646abcff7be0d38924511e14c53528d581802 packages/cli/tests/thread-cli.test.mjs\n4d4885bc624056b559e4c5cbdd7650c4fdeedc83695c8f1884a0bfa27b00a0cf packages/cli/tests/thread-launch.test.mjs\n3e794faab71337d13a531564183b5e0fd316c666bffbe834c8e5eedf9fad4a6a packages/webui/tests/chat.test.mjs\nd03dd152cde23c895bfbd07f1df8084925ae6638854d3a99a6aca82779ad4f12 scripts/stack-release.sh\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "gizmoduck-264-review-r13b",
|
||||
"by": "gizmoduck",
|
||||
"at": "2026-10-22T14:11:07.284Z",
|
||||
"state": "failed",
|
||||
"comment": null,
|
||||
"transport": {
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"resolutions": []
|
||||
},
|
||||
{
|
||||
"op": "sage-264-review-r13b",
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T14:12:46.925Z",
|
||||
"state": "posted",
|
||||
"comment": 30639,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 30639,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "duckworth",
|
||||
"op": "duckworth-0146-record",
|
||||
"at": "2026-10-22T14:17:52.013Z",
|
||||
"verdict": "approve",
|
||||
"comment": 30640,
|
||||
"candidate": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": "Vikunja 463 (T1 item 7, PRD 1.13 REQ-SESS-1 to 3). Acceptance test T3 depends on Vikunja 466 (N2), mos-1237. v1 under Q211, ahead of Q198 S1. Owner and reviewer to be named (Scrooge approved the design but has no agents/ seat).",
|
||||
"claim": null,
|
||||
"note": "LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695).",
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-21T22:42:17.481Z",
|
||||
"updatedAt": "2026-10-22T00:04:18.160Z",
|
||||
"updatedBy": "gizmoduck"
|
||||
"updatedAt": "2026-10-22T14:19:39.888Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 265,
|
||||
@@ -27642,7 +27696,7 @@
|
||||
"closes": [
|
||||
1783
|
||||
],
|
||||
"state": "briefed",
|
||||
"state": "done",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -27662,12 +27716,69 @@
|
||||
"reviewers": [
|
||||
"webby"
|
||||
],
|
||||
"review": null,
|
||||
"review": {
|
||||
"rounds": [
|
||||
{
|
||||
"n": 1,
|
||||
"op": "gander-278-inreview-r1",
|
||||
"by": "gander",
|
||||
"at": "2026-10-22T12:34:26.185Z",
|
||||
"issue": 1783,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3",
|
||||
"text": "426007525d15ad0fa11ba02d6cc5d8436c7ff3fa38beb01e9a651c202169d6a5 packages/webui/README.md\n7ac85787f614aabce4b8eccef01fcdbf1018003e806c5de97d40f47c4041710a packages/webui/src/cli.mjs\nc0306a4db82c286826b157cceb036496666f58ce8073d14280e58c10377ed0d0 packages/webui/src/public/bus.js\naa25b50543b5bc632ac984e85b0d8b43b1c9af2d617bfc7c19351d07310f396d packages/webui/src/serve.mjs\nfd7ad903b682f8f6ceee2d75111b01aa97f50233cd875dd5553e3e83de33ce32 packages/webui/tests/shell.test.mjs\n3b2770db03a2fcce87512b600f386a51f822b251ccb992a46208ee484ee99f7e packages/webui/tests/thread-claims-browser.test.mjs\n48117fe02ef9b76ad12f15dfb0d25e77d187c088c0bff572f33e18418e566913 packages/webui/tests/thread-claims.test.mjs\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "gander-278-inreview-r1",
|
||||
"by": "gander",
|
||||
"at": "2026-10-22T12:34:26.185Z",
|
||||
"state": "failed",
|
||||
"comment": null,
|
||||
"transport": {
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"resolutions": []
|
||||
},
|
||||
{
|
||||
"op": "sage-278-review-r1b",
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T12:37:23.447Z",
|
||||
"state": "posted",
|
||||
"comment": 30587,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 30587,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "webby",
|
||||
"op": "webby-278-verdict-r1",
|
||||
"at": "2026-10-22T12:38:51.557Z",
|
||||
"verdict": "approve",
|
||||
"comment": 30588,
|
||||
"candidate": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"claim": null,
|
||||
"note": null,
|
||||
"note": "LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes.",
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-22T05:15:43.015Z",
|
||||
"updatedAt": "2026-10-22T05:16:17.583Z",
|
||||
"updatedAt": "2026-10-22T12:40:51.151Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
@@ -27860,7 +27971,7 @@
|
||||
"closes": [
|
||||
1786
|
||||
],
|
||||
"state": "in-progress",
|
||||
"state": "done",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -27880,16 +27991,70 @@
|
||||
"reviewers": [
|
||||
"dewey"
|
||||
],
|
||||
"review": null,
|
||||
"claim": {
|
||||
"seat": "megavolt",
|
||||
"op": "megavolt-281-claim"
|
||||
"review": {
|
||||
"rounds": [
|
||||
{
|
||||
"n": 1,
|
||||
"op": "megavolt-281-inreview-r1",
|
||||
"by": "megavolt",
|
||||
"at": "2026-10-22T13:01:27.161Z",
|
||||
"issue": 1786,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56",
|
||||
"text": "7ce8139b6ffa3077d858f0cb059f6d2b8d5c09f6a8acf867611d84830be35868 agents/megavolt/work/queue-281/BUILD.md\n5fb103dccf550a0d0568771ca3ddec47196d7f5f24775501c5aecff7599343db packages/bus/src/broker.mjs\n6fe79a481bb35c6ab5bcda487bcc346fcb812e8ed96bd62f7198ebb09e6d292a packages/bus/tests/fire-revokes-grants.test.mjs\n5f9a8b73b6df8b83db990f6c9f15935e51dcc00ddf59d10eabe0f1dfd23d718c packages/bus/tests/gitea-grant.test.mjs\n88c5193c723f9362008995b46b697b8f455574b3fc2ddc7f31f549082f941efd packages/bus/tests/grant-executor.test.mjs\ndd3d99d48d28e2a001a013bafb486385c777056ea9d29e9c5ec9e38174aa02cd packages/bus/tests/lifecycle-revoke.test.mjs\n5ce79205c4ca6e5f6e77671968ce9d8b8ebfdbdde07b31676f2d05b284acb938 packages/cli/tests/lifecycle.test.mjs\n"
|
||||
},
|
||||
"request": "comment",
|
||||
"attempts": [
|
||||
{
|
||||
"op": "megavolt-281-inreview-r1",
|
||||
"by": "megavolt",
|
||||
"at": "2026-10-22T13:01:27.161Z",
|
||||
"state": "failed",
|
||||
"comment": null,
|
||||
"transport": {
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"resolutions": []
|
||||
},
|
||||
{
|
||||
"op": "sage-281-review-r1b",
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T13:02:59.024Z",
|
||||
"state": "posted",
|
||||
"comment": 30613,
|
||||
"transport": {
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 30613,
|
||||
"detail": "created"
|
||||
},
|
||||
"resolutions": []
|
||||
}
|
||||
],
|
||||
"duplicateRisk": false,
|
||||
"receipts": [
|
||||
{
|
||||
"reviewer": "dewey",
|
||||
"op": "dewey-281-verdict-r1",
|
||||
"at": "2026-10-22T13:05:20.490Z",
|
||||
"verdict": "approve",
|
||||
"comment": 30614,
|
||||
"candidate": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"note": null,
|
||||
"claim": null,
|
||||
"note": "LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614.",
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-22T09:44:24.187Z",
|
||||
"updatedAt": "2026-10-22T09:51:21.600Z",
|
||||
"updatedBy": "megavolt"
|
||||
"updatedAt": "2026-10-22T13:07:09.587Z",
|
||||
"updatedBy": "sage"
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
@@ -112108,6 +112273,518 @@
|
||||
"receipt": "ok sage-277-done rev 3194 row 277 in-review→done round 1"
|
||||
},
|
||||
"viewSha": "47105a02fc98e4b65b8f9b39fbbcfea3a7a295516e992e95c26f6e5f2d052fdb"
|
||||
},
|
||||
{
|
||||
"rev": 3195,
|
||||
"op": "gander-278-start",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 278,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "gander",
|
||||
"at": "2026-10-22T12:04:51.778Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok gander-278-start rev 3195 row 278 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "d3e0918fab9cd369618d3cce25053035c3593cc157ef02b3e67824577540a383"
|
||||
},
|
||||
{
|
||||
"rev": 3196,
|
||||
"op": "gander-278-inreview-r1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 278,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "/home/jwoltje/.t3/worktrees/mosaic-stack/gander-row196/agents/gander/work/queue-278/MANIFEST.sha256",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "gander",
|
||||
"at": "2026-10-22T12:34:26.185Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 1,
|
||||
"issue": 1783,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3",
|
||||
"text": "426007525d15ad0fa11ba02d6cc5d8436c7ff3fa38beb01e9a651c202169d6a5 packages/webui/README.md\n7ac85787f614aabce4b8eccef01fcdbf1018003e806c5de97d40f47c4041710a packages/webui/src/cli.mjs\nc0306a4db82c286826b157cceb036496666f58ce8073d14280e58c10377ed0d0 packages/webui/src/public/bus.js\naa25b50543b5bc632ac984e85b0d8b43b1c9af2d617bfc7c19351d07310f396d packages/webui/src/serve.mjs\nfd7ad903b682f8f6ceee2d75111b01aa97f50233cd875dd5553e3e83de33ce32 packages/webui/tests/shell.test.mjs\n3b2770db03a2fcce87512b600f386a51f822b251ccb992a46208ee484ee99f7e packages/webui/tests/thread-claims-browser.test.mjs\n48117fe02ef9b76ad12f15dfb0d25e77d187c088c0bff572f33e18418e566913 packages/webui/tests/thread-claims.test.mjs\n"
|
||||
},
|
||||
"receipt": "ok gander-278-inreview-r1 rev 3196 row 278 in-progress→in-review round 1 on #1783; request gander-278-inreview-r1 requesting"
|
||||
},
|
||||
"viewSha": "10e0b103a4099660e8e0f332214db3ffec46d7e31afce0be052efa11b29aca43"
|
||||
},
|
||||
{
|
||||
"rev": 3197,
|
||||
"op": "gander-278-inreview-r1.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 278,
|
||||
"attempt": "gander-278-inreview-r1",
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"by": "gander",
|
||||
"at": "2026-10-22T12:35:03.307Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"round": 1,
|
||||
"attempt": "gander-278-inreview-r1",
|
||||
"state": "failed",
|
||||
"receipt": "ok gander-278-inreview-r1.outcome rev 3197 row 278 round 1 request gander-278-inreview-r1 failed"
|
||||
},
|
||||
"viewSha": "807e3fdfac503cb45c13b8963b38c9131d36b5c9fdc6a071525ca0a115b2c39b"
|
||||
},
|
||||
{
|
||||
"rev": 3198,
|
||||
"op": "sage-278-review-r1b",
|
||||
"verb": "review-request",
|
||||
"args": {
|
||||
"id": 278
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T12:37:23.447Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"round": 1,
|
||||
"attempt": "sage-278-review-r1b",
|
||||
"state": "requesting",
|
||||
"receipt": "ok sage-278-review-r1b rev 3198 row 278 round 1 request sage-278-review-r1b requesting"
|
||||
},
|
||||
"viewSha": "6ab64156b87571dcc2e7d9fe0b7750eff8f576419ad147e6af460fd9ba9018b3"
|
||||
},
|
||||
{
|
||||
"rev": 3199,
|
||||
"op": "sage-278-review-r1b.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 278,
|
||||
"attempt": "sage-278-review-r1b",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 30587,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T12:38:00.443Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"round": 1,
|
||||
"attempt": "sage-278-review-r1b",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-278-review-r1b.outcome rev 3199 row 278 round 1 request sage-278-review-r1b posted comment 30587"
|
||||
},
|
||||
"viewSha": "459834d1aec5d3abd4e09095e189ef39c9696265d30c85a6ee69b1668bfe773d"
|
||||
},
|
||||
{
|
||||
"rev": 3200,
|
||||
"op": "webby-278-verdict-r1",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 278,
|
||||
"verdict": "approve",
|
||||
"comment": 30588,
|
||||
"candidate": "8fa859a0c617defe42e50028b057a603c6634f6db47ac2e3f7c01dfb290228a3"
|
||||
},
|
||||
"by": "webby",
|
||||
"at": "2026-10-22T12:38:51.557Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"round": 1,
|
||||
"verdict": "approve",
|
||||
"receipt": "ok webby-278-verdict-r1 rev 3200 row 278 round 1 approve by webby (comment 30588)"
|
||||
},
|
||||
"viewSha": "7bc559a0a0ee4cd7c6fb80e79dfc96ab797460da2121ec82e784cecb57b72780"
|
||||
},
|
||||
{
|
||||
"rev": 3201,
|
||||
"op": "sage-278-landed-note",
|
||||
"verb": "note",
|
||||
"args": {
|
||||
"id": 278,
|
||||
"text": "LANDED 385a7194c ff on 6af85df4b (9 commits, manifest 8fa859a0). Run 3 green: bus 793/0/0, webui 345/0/1, ten shells 425/0 under SHELL-LIMITS 3x, M1-M6 killed. Runs 1-2 void (fixture fixes 964b6b184, 9696d1fcb per mos-1275). Skips named (Webby N1): webui esbuild 0.28.1/0.28.2 rebuild; test-queue canonical-root check; test-task two live-provider cases (row 244). Webby APPROVE c30588. #1783 closes."
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T12:40:15.700Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"field": "note",
|
||||
"receipt": "ok sage-278-landed-note rev 3201 row 278 note"
|
||||
},
|
||||
"viewSha": "0c929f230fb391defb269606b2e29c1b3d49bc53723762270d49c71eedd95afb"
|
||||
},
|
||||
{
|
||||
"rev": 3202,
|
||||
"op": "sage-278-done",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 278,
|
||||
"to": "done",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T12:40:51.151Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 278,
|
||||
"from": "in-review",
|
||||
"to": "done",
|
||||
"round": 1,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-278-done rev 3202 row 278 in-review→done round 1"
|
||||
},
|
||||
"viewSha": "b9a7f299fbd07afe042bec4491791dd511487e47577e2cf816ef3b00506aca98"
|
||||
},
|
||||
{
|
||||
"rev": 3203,
|
||||
"op": "megavolt-281-inreview-r1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 281,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "/mnt/storage/scratch/megavolt-r281/manifest-r1.txt",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "megavolt",
|
||||
"at": "2026-10-22T13:01:27.161Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 281,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 1,
|
||||
"issue": 1786,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56",
|
||||
"text": "7ce8139b6ffa3077d858f0cb059f6d2b8d5c09f6a8acf867611d84830be35868 agents/megavolt/work/queue-281/BUILD.md\n5fb103dccf550a0d0568771ca3ddec47196d7f5f24775501c5aecff7599343db packages/bus/src/broker.mjs\n6fe79a481bb35c6ab5bcda487bcc346fcb812e8ed96bd62f7198ebb09e6d292a packages/bus/tests/fire-revokes-grants.test.mjs\n5f9a8b73b6df8b83db990f6c9f15935e51dcc00ddf59d10eabe0f1dfd23d718c packages/bus/tests/gitea-grant.test.mjs\n88c5193c723f9362008995b46b697b8f455574b3fc2ddc7f31f549082f941efd packages/bus/tests/grant-executor.test.mjs\ndd3d99d48d28e2a001a013bafb486385c777056ea9d29e9c5ec9e38174aa02cd packages/bus/tests/lifecycle-revoke.test.mjs\n5ce79205c4ca6e5f6e77671968ce9d8b8ebfdbdde07b31676f2d05b284acb938 packages/cli/tests/lifecycle.test.mjs\n"
|
||||
},
|
||||
"receipt": "ok megavolt-281-inreview-r1 rev 3203 row 281 in-progress→in-review round 1 on #1786; request megavolt-281-inreview-r1 requesting"
|
||||
},
|
||||
"viewSha": "64bd164fc938ff48e88bbe1f73b1b2092a44b947ef319f97956425d99324c86b"
|
||||
},
|
||||
{
|
||||
"rev": 3204,
|
||||
"op": "megavolt-281-inreview-r1.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 281,
|
||||
"attempt": "megavolt-281-inreview-r1",
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"by": "megavolt",
|
||||
"at": "2026-10-22T13:02:03.649Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 281,
|
||||
"round": 1,
|
||||
"attempt": "megavolt-281-inreview-r1",
|
||||
"state": "failed",
|
||||
"receipt": "ok megavolt-281-inreview-r1.outcome rev 3204 row 281 round 1 request megavolt-281-inreview-r1 failed"
|
||||
},
|
||||
"viewSha": "dffd6ed44dbe0fce968c061f6be08378dde897ddaa251c9b8dd6d2c293fb2db4"
|
||||
},
|
||||
{
|
||||
"rev": 3205,
|
||||
"op": "sage-281-review-r1b",
|
||||
"verb": "review-request",
|
||||
"args": {
|
||||
"id": 281
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T13:02:59.024Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 281,
|
||||
"round": 1,
|
||||
"attempt": "sage-281-review-r1b",
|
||||
"state": "requesting",
|
||||
"receipt": "ok sage-281-review-r1b rev 3205 row 281 round 1 request sage-281-review-r1b requesting"
|
||||
},
|
||||
"viewSha": "b862b16cf093400a938b3506bace6fae82155c9e651734447760c73f0c20ff8e"
|
||||
},
|
||||
{
|
||||
"rev": 3206,
|
||||
"op": "sage-281-review-r1b.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 281,
|
||||
"attempt": "sage-281-review-r1b",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 30613,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T13:03:38.863Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 281,
|
||||
"round": 1,
|
||||
"attempt": "sage-281-review-r1b",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-281-review-r1b.outcome rev 3206 row 281 round 1 request sage-281-review-r1b posted comment 30613"
|
||||
},
|
||||
"viewSha": "d4b562903728382f89434caf9e4f41d7ddbaf8daa313f6c5d174bbbab3731ca7"
|
||||
},
|
||||
{
|
||||
"rev": 3207,
|
||||
"op": "dewey-281-verdict-r1",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 281,
|
||||
"verdict": "approve",
|
||||
"comment": 30614,
|
||||
"candidate": "b73d4c81d57204c95f86acd1f901ee9cf83619707ccd414406e057866c4dcd56"
|
||||
},
|
||||
"by": "dewey",
|
||||
"at": "2026-10-22T13:05:20.490Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 281,
|
||||
"round": 1,
|
||||
"verdict": "approve",
|
||||
"receipt": "ok dewey-281-verdict-r1 rev 3207 row 281 round 1 approve by dewey (comment 30614)"
|
||||
},
|
||||
"viewSha": "ed7c23dfa5ee98a2502e4b6410b2527b6f357c775cb6b2afa7386f7feafb50fd"
|
||||
},
|
||||
{
|
||||
"rev": 3208,
|
||||
"op": "sage-281-landed-note",
|
||||
"verb": "note",
|
||||
"args": {
|
||||
"id": 281,
|
||||
"text": "LANDED e479521d3 ff on 385a7194c (17 commits, manifest b73d4c81). Slot on 7979fb41e green: bus 819, business 231, cli 837, dewey-bus 76 with gitea-grant-docker run, shells green under 3x limits, M1-M22 killed. M1 hang fixed in b4a (mos-1273). Skips: test-task row 244 live-provider pair, test-queue canonical root. DEFERRED: N1 host-unknown hint verb, N2 fire tool text, to the follow-up row. Dewey APPROVE c30614."
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T13:06:32.779Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 281,
|
||||
"field": "note",
|
||||
"receipt": "ok sage-281-landed-note rev 3208 row 281 note"
|
||||
},
|
||||
"viewSha": "e777e0997655c84a7f3d842126db35fcb0d3e5b02a57aa9358eebda281f49e76"
|
||||
},
|
||||
{
|
||||
"rev": 3209,
|
||||
"op": "sage-281-done",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 281,
|
||||
"to": "done",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T13:07:09.587Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 281,
|
||||
"from": "in-review",
|
||||
"to": "done",
|
||||
"round": 1,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-281-done rev 3209 row 281 in-review→done round 1"
|
||||
},
|
||||
"viewSha": "9ac1eeb17b185f038531d58b8ba85ccca0cd9607902614d3b1bc2f5e8085c0c0"
|
||||
},
|
||||
{
|
||||
"rev": 3210,
|
||||
"op": "gizmoduck-264-review-r13b",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 264,
|
||||
"to": "in-review",
|
||||
"reason": null,
|
||||
"candidate": "/mnt/storage/scratch/gizmo-264-out/manifest-264-r13b.txt",
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "gizmoduck",
|
||||
"at": "2026-10-22T14:11:07.284Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 264,
|
||||
"from": "in-progress",
|
||||
"to": "in-review",
|
||||
"round": 1,
|
||||
"issue": 1769,
|
||||
"candidate": {
|
||||
"kind": "manifest",
|
||||
"digest": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e",
|
||||
"text": "0f14a8e813a75abc8ab2a49d4574496e68a83ae3a37019c5bbfbbbc439fbe2ab agents/gizmoduck/work/queue-264/BUILD.md\nee456d3f7f5f36c499b1144f5c1a51bd3400662bd576c0a2c403a70cd49772c8 docs/plans/DEFERRED.md\n764b3d7865bfa3a0829f3c7d5553446fc61478cc8b46c0da5dd9463ea7ef1543 packages/bus/README.md\ncd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a packages/bus/schema.sql\na581d9e687ec2784751e293bd320885ba2ee75cbff38dd0f7f0816c3b6ccf1f1 packages/bus/src/broker.mjs\neb2822c73e527697399691ae520092277ddbd4d2a9a42a0731b3c81cc3569e58 packages/bus/src/migrations/3k-3l.sql\nca4a8eceef394dae290a5ad7bdb9f335ebbe53ba02aa3af71be7001ba2725a93 packages/bus/src/store.mjs\n4e2f46031bb2025bb7d674a7da5b663312ed827d346f354df0453d3595fa8781 packages/bus/tests/account-migration.test.mjs\necf7536dbb12765ef30897fbd0436f68d11233da7e79ff12d4cf80baf3b961fd packages/bus/tests/chief-of-staff.test.mjs\n7d8ddb1f603bda89488e521e27b68ada373efb19f23864da78613eb14baf6f6d packages/bus/tests/comment-migration.test.mjs\n45b3a93609179a2f856a9f2620acca2f0b07ab239d2d5957a792d6c6fadeb0af packages/bus/tests/end-launch.test.mjs\naba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0 packages/bus/tests/fixtures/schema-3k.sql\n06c33d2101278f71b315327d7be1650d97f828b0dc2aafcc28c73a57d0fe0024 packages/bus/tests/gitea-migration.test.mjs\nb7a560894b9a6eb999b80dc9b2989ac4745b568f74d0b52eec3ab0459b46ad2b packages/bus/tests/grant-migration.test.mjs\n6e41496a5d55705f484e074e2264a84f5299e04211cd1a2f384f18c5b4f6f9c5 packages/bus/tests/message-filter.test.mjs\nb1921b88ba6ee0c28b4d9652ff529abba4b7e8b12423e99cf7996c3e207ef3f5 packages/bus/tests/name-migration.test.mjs\n52e7de4bdf04f7787f70079f78017fd034abd968370059f22d859ce5a58e27a8 packages/bus/tests/schema.test.mjs\nc09cdcca750d44ae1cebd3e8970708d39edd70682b99eeb77e19258d9fa8c068 packages/bus/tests/sessions.test.mjs\nc2f9002a8ba4474fa1bafc84d8ffd67756d450cf9a1346194a871cd0d541d524 packages/bus/tests/spec-migration.test.mjs\n4fd9c541735a8e654bee84ecab0172f9b1f21c668091fe12624ce812919327e4 packages/bus/tests/tester-writes.test.mjs\n0007f55235ef25e70bf89cfb9407e07c4780b54566163183d19b1f902fc0251e packages/bus/tests/thread-claims.test.mjs\n19e4200961fd81a23f2bf7089f81b2a57165a6a81c026ef879ab756587d705dc packages/bus/tests/thread-migration.test.mjs\n90d25f35389478cd3307b2383d93dc0a2519dd88eddc3c26bd37c39161083269 packages/bus/tests/threads.test.mjs\ncdd758deb9bcbb33eb1f4138673a11b9a5fd82fdd4fd88c14197d5240fe4b780 packages/bus/tests/usage-migration.test.mjs\n43f09ab4e7d3f8178a6cab6889ce2d85c83b8151a0f0dc26325d39bbe4236dc0 packages/cli/tests/stack-release.test.mjs\ncf6c1310f32d8495b5838620760646abcff7be0d38924511e14c53528d581802 packages/cli/tests/thread-cli.test.mjs\n4d4885bc624056b559e4c5cbdd7650c4fdeedc83695c8f1884a0bfa27b00a0cf packages/cli/tests/thread-launch.test.mjs\n3e794faab71337d13a531564183b5e0fd316c666bffbe834c8e5eedf9fad4a6a packages/webui/tests/chat.test.mjs\nd03dd152cde23c895bfbd07f1df8084925ae6638854d3a99a6aca82779ad4f12 scripts/stack-release.sh\n"
|
||||
},
|
||||
"receipt": "ok gizmoduck-264-review-r13b rev 3210 row 264 in-progress→in-review round 1 on #1769; request gizmoduck-264-review-r13b requesting"
|
||||
},
|
||||
"viewSha": "7a3ea1661ded736fbe138c569e5ab120d96bd48c8348a394f2da89f669bb7fc9"
|
||||
},
|
||||
{
|
||||
"rev": 3211,
|
||||
"op": "gizmoduck-264-review-r13b.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 264,
|
||||
"attempt": "gizmoduck-264-review-r13b",
|
||||
"outcome": "failed",
|
||||
"status": null,
|
||||
"comment": null,
|
||||
"detail": "pre-send: the credential or account check failed"
|
||||
},
|
||||
"by": "gizmoduck",
|
||||
"at": "2026-10-22T14:11:43.637Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 264,
|
||||
"round": 1,
|
||||
"attempt": "gizmoduck-264-review-r13b",
|
||||
"state": "failed",
|
||||
"receipt": "ok gizmoduck-264-review-r13b.outcome rev 3211 row 264 round 1 request gizmoduck-264-review-r13b failed"
|
||||
},
|
||||
"viewSha": "cadd960bd868b8c63ecb7a1128b9e2628434706849bdcac6f2b8a24c26bc6701"
|
||||
},
|
||||
{
|
||||
"rev": 3212,
|
||||
"op": "sage-264-review-r13b",
|
||||
"verb": "review-request",
|
||||
"args": {
|
||||
"id": 264
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T14:12:46.925Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 264,
|
||||
"round": 1,
|
||||
"attempt": "sage-264-review-r13b",
|
||||
"state": "requesting",
|
||||
"receipt": "ok sage-264-review-r13b rev 3212 row 264 round 1 request sage-264-review-r13b requesting"
|
||||
},
|
||||
"viewSha": "fe49d268532fe1223929e2c660b3acc815daf109b5607f7909d30c9d53bf18fb"
|
||||
},
|
||||
{
|
||||
"rev": 3213,
|
||||
"op": "sage-264-review-r13b.outcome",
|
||||
"verb": "review-outcome",
|
||||
"args": {
|
||||
"id": 264,
|
||||
"attempt": "sage-264-review-r13b",
|
||||
"outcome": "posted",
|
||||
"status": 201,
|
||||
"comment": 30639,
|
||||
"detail": "created"
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T14:13:24.411Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 264,
|
||||
"round": 1,
|
||||
"attempt": "sage-264-review-r13b",
|
||||
"state": "posted",
|
||||
"receipt": "ok sage-264-review-r13b.outcome rev 3213 row 264 round 1 request sage-264-review-r13b posted comment 30639"
|
||||
},
|
||||
"viewSha": "a7fb599ce8e5c0320b4b9dbd9f812934b1098406169790370dc37bce946668c1"
|
||||
},
|
||||
{
|
||||
"rev": 3214,
|
||||
"op": "duckworth-0146-record",
|
||||
"verb": "review-record",
|
||||
"args": {
|
||||
"id": 264,
|
||||
"verdict": "approve",
|
||||
"comment": 30640,
|
||||
"candidate": "257408e9e3e9e9bbc751729c2693eb3f199724374ed05db467656a95aa873c8e"
|
||||
},
|
||||
"by": "duckworth",
|
||||
"at": "2026-10-22T14:17:52.013Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 264,
|
||||
"round": 1,
|
||||
"verdict": "approve",
|
||||
"receipt": "ok duckworth-0146-record rev 3214 row 264 round 1 approve by duckworth (comment 30640)"
|
||||
},
|
||||
"viewSha": "765bffd5742a3e0d8fac6bc4971ddba7fea0a937ce63529fd1ff3a82baa66d35"
|
||||
},
|
||||
{
|
||||
"rev": 3215,
|
||||
"op": "sage-264-landed-note",
|
||||
"verb": "note",
|
||||
"args": {
|
||||
"id": 264,
|
||||
"text": "LANDED 84f53540d ff on e479521d3 14:18:23Z, 21 commits = gizmoduck/row264-r13 tip (no rewrite, range-diff identity). Manifest 257408e9 approved Duckworth rev 3214 (c30640). Gate r13 2cc34aef8: 20 files, bus 827, cli 837, webui 345/1 skip, 10 shells; M1-M4 assertion kills (mos-1279 B, M2 4 files). Overlap none (13, 257). N1 actor_run binds row 269; N2 esbuild skip later gate (sage-2695)."
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T14:19:03.485Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 264,
|
||||
"field": "note",
|
||||
"receipt": "ok sage-264-landed-note rev 3215 row 264 note"
|
||||
},
|
||||
"viewSha": "830e39c04975c14ffe061b4a292ac05b088574a6932f47bd91ac43d982e92ef6"
|
||||
},
|
||||
{
|
||||
"rev": 3216,
|
||||
"op": "sage-264-done",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 264,
|
||||
"to": "done",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T14:19:39.888Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 264,
|
||||
"from": "in-review",
|
||||
"to": "done",
|
||||
"round": 1,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-264-done rev 3216 row 264 in-review→done round 1"
|
||||
},
|
||||
"viewSha": "e81b02877f6780a2cc04aaa05922ddcfec132716d03de5613412d8c6f85a579a"
|
||||
}
|
||||
]
|
||||
}
|
||||
+48
-9
@@ -241,9 +241,11 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
|
||||
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
|
||||
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
|
||||
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
|
||||
| `thread.open` | `{thread}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
|
||||
| `thread.send` | `{thread,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
|
||||
| `thread.messages` | `{thread,after?,limit?}`; human sessions only. The thread's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the thread), at most `limit` (default 50, 1 to 200). Writes nothing. |
|
||||
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
|
||||
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
|
||||
| `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). 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)
|
||||
|
||||
@@ -311,6 +313,42 @@ the threads they made. A revoked run can't claim its thread again (`run-revoked`
|
||||
thread run can still be stopped another way: the launcher stops it once the thread is idle, the
|
||||
human stops it with `mosaic stop <run>`, and the run's end releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
|
||||
|
||||
### Sessions (row 264a, #1769)
|
||||
|
||||
Schema `3l` puts sessions below a thread. `sessions` and `session_defaults` are new and
|
||||
append-only. `thread_claims`, `message_threads` and `thread_opens` each gain a `session`
|
||||
column, and `thread_claims` gains `generation`, the claim's number in its session counting
|
||||
from 1. A release or revoke carries its claim's number.
|
||||
|
||||
Every thread has a default session, the latest `session_defaults` row (D3). `thread.create`
|
||||
writes one with a random id. The 3k→3l step gives each existing thread a default session whose
|
||||
id is the thread's own, named `default` with op `legacy-3l`, and files every old claim, message
|
||||
and open under it. A thread verb, or a `bindLaunch` record, that names no `session` works the
|
||||
default, so a client from before 3l keeps working. A `session` from another thread refuses
|
||||
`session-not-found`, and a non-thread role that names one refuses `thread-not-allowed`.
|
||||
|
||||
The thread rules above hold per session. One run holds a session at a time, and a run holds at
|
||||
most one session in its business, while sibling sessions of a thread may each have a holder.
|
||||
A thread run receives and replies in its own session, and the chief of staff's reply returns
|
||||
to the session that sent the work. `threadsState` and `threadIdle` keep one row per thread,
|
||||
computed on its default session; 264b's launcher starts the others.
|
||||
|
||||
A thread run's claim fences by generation (T2). A run acts only while its session's latest claim
|
||||
row has the generation of the run's own latest claim. After a release or revoke, once another run
|
||||
claims the session, the old run's `decision.withdraw`, `message.receive` and `message.read`
|
||||
refuse `not-holder` and write nothing.
|
||||
|
||||
`thread.create` writes the default session with op `default`, so a `session.create` retry naming
|
||||
`default` with op `default` answers with it. A migrated thread's default has op `legacy-3l`, and
|
||||
the same retry there refuses `session-exists`.
|
||||
|
||||
The triggers check that a session has its thread's business, instance and owner, that its
|
||||
predecessor and any default, claim, message or open name a session of the same thread, and that
|
||||
a claim's generation is the one its session expects. `delivery_same_session` replaces
|
||||
`delivery_same_thread`. `sessions_id_not_thread` and `threads_id_not_session` keep a session id
|
||||
from naming another thread and a thread id from naming a session. A 3k engine refuses a 3l
|
||||
store with `schema-mismatch`.
|
||||
|
||||
### Agent state (row 59, #1548)
|
||||
|
||||
For a human or reader session, `agents` derives each held role's state from
|
||||
@@ -349,8 +387,9 @@ Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
|
||||
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
|
||||
`thread.claims` takes no arguments and lists the held thread claims in the
|
||||
business, oldest thread first, as `{thread, instance, holder_run, harness,
|
||||
at}`. A claim is held while its thread's latest `thread_claims` row is a
|
||||
claim, so a released or revoked thread drops out. Human and reader sessions
|
||||
at}`. A claim is held while its session's latest `thread_claims` row is a
|
||||
claim (row 264a), so a released or revoked session drops out and a thread
|
||||
with two held sessions lists twice. Human and reader sessions
|
||||
only: a role session refuses `human-required` and the tester
|
||||
`tester-refused`. The result carries the thread id but never its name, which
|
||||
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
|
||||
@@ -727,10 +766,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3k, SHA-256
|
||||
`aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0`.
|
||||
It has 20 tables, 72 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3k`, WAL,
|
||||
`schema.sql` is v3l, SHA-256
|
||||
`cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a`.
|
||||
It has 22 tables, 87 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3l`, WAL,
|
||||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||||
|
||||
+74
-11
@@ -248,11 +248,34 @@ CREATE TABLE threads (
|
||||
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,
|
||||
@@ -261,55 +284,95 @@ CREATE TABLE thread_claims (
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(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 thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
|
||||
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 thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
|
||||
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 thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
|
||||
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 thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
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 delivery_same_thread BEFORE INSERT ON deliveries
|
||||
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 thread = t.id)
|
||||
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 thread''s holder'); END;
|
||||
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;
|
||||
|
||||
+173
-73
@@ -125,7 +125,9 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
|
||||
'instance.launch', 'instance.end', 'instance.request']);
|
||||
'instance.launch', 'instance.end', 'instance.request',
|
||||
// Row 264a (#1769): the sessions of the tester's own threads.
|
||||
'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.*.
|
||||
@@ -460,6 +462,8 @@ export class Broker {
|
||||
'task_snapshots',
|
||||
'projects',
|
||||
'threads',
|
||||
'sessions',
|
||||
'session_defaults',
|
||||
'thread_claims',
|
||||
'thread_opens',
|
||||
]) {
|
||||
@@ -580,14 +584,26 @@ export class Broker {
|
||||
#threaded(business, role) {
|
||||
return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
}
|
||||
#threadHolder(thread) {
|
||||
return this.#store.get('SELECT * FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread);
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
return this.#store.get('SELECT * FROM thread_claims WHERE session=? ORDER BY seq DESC LIMIT 1', session);
|
||||
}
|
||||
// Row 155 (#1649): the held claims of every thread of a thread instance, oldest thread first, or
|
||||
// D3: the thread's default session, the latest session_defaults row. thread.create and the 3k-3l
|
||||
// migration write one for every thread.
|
||||
#defaultSession(thread) {
|
||||
return this.#store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1', thread)?.session ?? fail('session-not-found');
|
||||
}
|
||||
// The session a thread verb names, or the thread's default when it names none.
|
||||
#threadSession(t, session) {
|
||||
if (session === undefined) return this.#defaultSession(t.id);
|
||||
if (!this.#store.get('SELECT 1 FROM sessions WHERE id=? AND thread=?', identifier(session), t.id)) fail('session-not-found');
|
||||
return session;
|
||||
}
|
||||
// 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) {
|
||||
return this.#store.all(
|
||||
"SELECT c.* FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND t.instance=? AND (? IS NULL OR t.owner=?) AND c.op='claim' ORDER BY t.seq",
|
||||
"SELECT c.* FROM threads t JOIN sessions se ON se.thread=t.id JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id) WHERE t.business=? AND t.instance=? AND (? IS NULL OR t.owner=?) AND c.op='claim' ORDER BY t.seq, se.seq",
|
||||
business,
|
||||
instance,
|
||||
owner,
|
||||
@@ -685,23 +701,23 @@ export class Broker {
|
||||
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
|
||||
return null;
|
||||
}
|
||||
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
|
||||
// The claim a run acts under: its session's for a thread run, its role's otherwise.
|
||||
#own(s) {
|
||||
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
|
||||
return s.thread ? this.#sessionHolder(s.session) : this.#holder(s.business, s.role);
|
||||
}
|
||||
// Who a message to `to` goes to now: the thread's holder when the message is in a thread of
|
||||
// `to`, the role's otherwise. Null while nobody holds it, and the message waits.
|
||||
#recipient(business, to, thread) {
|
||||
const h = thread && this.#threaded(business, to) ? this.#threadHolder(thread) : this.#holder(business, to);
|
||||
// Who a message to `to` goes to now: the session's holder when the message is in a session of a
|
||||
// thread of `to`, the role's otherwise. Null while nobody holds it, and the message waits.
|
||||
#recipient(business, to, session) {
|
||||
const h = session && this.#threaded(business, to) ? this.#sessionHolder(session) : this.#holder(business, to);
|
||||
return h?.op === 'claim' ? h : null;
|
||||
}
|
||||
// The thread a new message belongs to (REQ-MSG-1). A thread run's message is in its thread.
|
||||
// A message to a thread instance from anyone else is in the thread of the nearest message up
|
||||
// its in_reply_to chain that has one, so the chief of staff's reply returns to the thread
|
||||
// The session a new message belongs to (REQ-MSG-1). A thread run's message is in its session.
|
||||
// A message to a thread instance from anyone else is in the session of the nearest message up
|
||||
// its in_reply_to chain that has one, so the chief of staff's reply returns to the session
|
||||
// that sent the work. Messages are append-only and in_reply_to names an earlier message, so
|
||||
// the chain has an end; `seen` stops the walk there and needs no depth constant.
|
||||
#threadFor(s, to, inReplyTo) {
|
||||
if (s.thread) return s.thread;
|
||||
#sessionFor(s, to, inReplyTo) {
|
||||
if (s.thread) return s.session;
|
||||
if (!this.#threaded(s.business, to)) return null;
|
||||
// The user names the thread with thread.send, which checks the owner (REQ-USER-2).
|
||||
if (s.human || s.user) fail('thread-required-use-thread-send');
|
||||
@@ -709,18 +725,20 @@ export class Broker {
|
||||
for (let at = inReplyTo ?? null; at !== null && !seen.has(at); ) {
|
||||
seen.add(at);
|
||||
const m = this.#store.get(
|
||||
'SELECT m.in_reply_to, m.from_role, m.body, t.thread FROM messages m LEFT JOIN message_threads t ON t.message=m.id WHERE m.id=? AND m.business=?',
|
||||
'SELECT m.in_reply_to, m.from_role, m.body, t.thread, t.session FROM messages m LEFT JOIN message_threads t ON t.message=m.id WHERE m.id=? AND m.business=?',
|
||||
at,
|
||||
s.business,
|
||||
);
|
||||
if (m?.thread) {
|
||||
if (this.#store.get('SELECT instance FROM threads WHERE id=?', m.thread).instance !== to) fail('thread-instance-mismatch');
|
||||
return this.#testerOwns(s, m.thread);
|
||||
this.#testerOwns(s, m.thread);
|
||||
return m.session;
|
||||
}
|
||||
// Row 143: a chain that reaches a spec notice belongs to the user's thread in that notice's project.
|
||||
// Row 143: a chain that reaches a spec notice belongs to the user's thread in that notice's
|
||||
// project, in its default session.
|
||||
if (m?.from_role === NOTICE_ROLE && m.body.startsWith(SPEC_NOTICE)) {
|
||||
const spec = this.#specThread(s, to, m.body);
|
||||
if (spec) return this.#testerOwns(s, spec);
|
||||
if (spec) return this.#defaultSession(this.#testerOwns(s, spec));
|
||||
}
|
||||
at = m?.in_reply_to ?? null;
|
||||
}
|
||||
@@ -794,9 +812,15 @@ export class Broker {
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.human) fail('agent-required');
|
||||
const h = this.#own(s);
|
||||
if (h?.op !== 'claim' || h.holder_run !== s.run) fail('not-holder');
|
||||
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run)) fail('not-holder');
|
||||
return h;
|
||||
}
|
||||
// Row 264a (#1769, T2): the generation a thread run holds, its latest claim's in its session, or
|
||||
// null if it never claimed. Once another run claims the session, the run's own generation is
|
||||
// retired, and #agent refuses its approvals, deliveries and reads.
|
||||
#runGeneration(s) {
|
||||
return this.#store.get("SELECT max(generation) AS g FROM thread_claims WHERE session=? AND holder_run=? AND op='claim'", s.session, s.run).g;
|
||||
}
|
||||
#human(s) {
|
||||
if (s.reader) fail('read-only');
|
||||
if (!s.human || s.via !== 'cli' || s.outsideAgent !== true) fail('human-required');
|
||||
@@ -832,26 +856,30 @@ export class Broker {
|
||||
const key = record.business + ':' + identifier(record.run);
|
||||
const session = this.#runs.get(key) ?? fail('unknown-run');
|
||||
if (this.#ended.has(key)) fail('run-ended');
|
||||
// A run holds at most one thread, so this also refuses another thread and a non-thread run.
|
||||
// A run holds at most one session, so this also refuses another thread and a non-thread run.
|
||||
// Row 264a: the row is the thread's default session's, so a run of another session is refused too.
|
||||
const [t] = this.#threadRows('t.business=? AND t.id=?', record.business, identifier(record.thread));
|
||||
if (t?.holder !== record.run) fail('not-holder');
|
||||
if (t.pending || t.unread) return { released: false, pending: t.pending, unread: t.unread };
|
||||
this.#store.transaction(() => this.#claimEnd(session, this.#threadHolder(t.thread), 'release', null));
|
||||
this.#store.transaction(() => this.#claimEnd(session, this.#own(session), 'release', null));
|
||||
return { released: true };
|
||||
}
|
||||
// Row 264a (#1769): each row is the thread's default session's. Until 264b the launcher starts
|
||||
// default sessions only, so another session's messages wait.
|
||||
#threadRows(where, ...params) {
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT t.id, t.instance,
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session AND m.to_role=t.instance
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) AS pending,
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session 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,
|
||||
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id) AS last_message,
|
||||
(SELECT max(at) FROM thread_opens WHERE thread=t.id) AS last_open,
|
||||
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session) AS last_message,
|
||||
(SELECT max(at) FROM thread_opens WHERE session=ds.session) AS last_open,
|
||||
c.at AS claim_at, c.op AS claim_op, c.holder_run AS holder_run
|
||||
FROM threads t LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
|
||||
FROM threads t JOIN session_defaults ds ON ds.seq=(SELECT max(seq) FROM session_defaults WHERE thread=t.id)
|
||||
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=ds.session)
|
||||
WHERE ${where} ORDER BY t.seq`,
|
||||
...params,
|
||||
)
|
||||
@@ -870,7 +898,7 @@ export class Broker {
|
||||
bindLaunch(record) {
|
||||
keys(
|
||||
record,
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness'],
|
||||
);
|
||||
const b = this.#business(identifier(record.business));
|
||||
@@ -893,10 +921,13 @@ export class Broker {
|
||||
if (record.thread === undefined) fail('thread-required');
|
||||
if (!this.#store.get('SELECT 1 FROM threads WHERE id=? AND business=? AND instance=?', identifier(record.thread), record.business, record.role))
|
||||
fail('thread-not-found');
|
||||
} else if (record.thread !== undefined) fail('thread-not-allowed');
|
||||
} else if (record.thread !== undefined || record.session !== undefined) fail('thread-not-allowed');
|
||||
// Row 264a (#1769): a thread run works one session of its thread, the thread's default when the
|
||||
// record names none (D3).
|
||||
const bound = record.thread === undefined ? null : this.#threadSession({ id: record.thread }, record.session);
|
||||
const key = record.business + ':' + record.run;
|
||||
if (this.#runs.has(key)) fail('duplicate-run');
|
||||
const session = { ...record, address: record.address ?? null, thread: record.thread ?? null, human: false };
|
||||
const session = { ...record, address: record.address ?? null, thread: record.thread ?? null, session: bound, human: false };
|
||||
this.#secretCheck(record);
|
||||
const old = this.#store.get(
|
||||
"SELECT actor_role,body FROM events WHERE business=? AND kind='session.launched' AND subject=? ORDER BY seq LIMIT 1",
|
||||
@@ -910,6 +941,8 @@ export class Broker {
|
||||
pid: record.pid ?? null,
|
||||
startTime: record.startTime ?? null,
|
||||
...(record.thread === undefined ? {} : { thread: record.thread }),
|
||||
// A record without a session keeps the body it had before row 264a.
|
||||
...(record.session === undefined ? {} : { session: record.session }),
|
||||
};
|
||||
// A run an earlier broker bound is bound again unchanged (the host's recover()), even after its
|
||||
// harness was switched off, so it can be ended.
|
||||
@@ -1078,14 +1111,14 @@ export class Broker {
|
||||
// close is.
|
||||
#grantNotice(business, decision, body) {
|
||||
const d = this.#store.get('SELECT raised_by_role,raised_by_run FROM decisions WHERE id=?', decision);
|
||||
const thread = this.#threaded(business, d.raised_by_role)
|
||||
const session = this.#threaded(business, d.raised_by_role)
|
||||
? (this.#store.get(
|
||||
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
"SELECT session FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
business,
|
||||
d.raised_by_run,
|
||||
)?.thread ?? null)
|
||||
)?.session ?? null)
|
||||
: null;
|
||||
for (const [to, t] of [['human', null], [d.raised_by_role, thread]]) {
|
||||
for (const [to, t] of [['human', null], [d.raised_by_role, session]]) {
|
||||
const mid = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -3735,7 +3768,7 @@ export class Broker {
|
||||
null,
|
||||
body,
|
||||
);
|
||||
this.#route(s.business, mid, s.role, s.thread);
|
||||
this.#route(s.business, mid, s.role, s.session);
|
||||
}
|
||||
// Trusted launcher API: whether the role, in the thread (null for a role run), holds a merge
|
||||
// decision a landing could use: cross-role, raised there with a task ref as its target, resolved
|
||||
@@ -4775,22 +4808,25 @@ export class Broker {
|
||||
keys(a, []);
|
||||
if (s.human) fail('agent-required');
|
||||
// Row 121: a thread run claims its thread, so idle threads hold nothing (REQ-THREAD-2).
|
||||
// Row 264a (#1769): the claim is the run's session's, with the session's next generation.
|
||||
if (s.thread) {
|
||||
if (this.#store.get("SELECT 1 FROM thread_claims WHERE thread=? AND holder_run=? AND op='revoke' LIMIT 1", s.thread, s.run))
|
||||
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');
|
||||
if (this.#threadHolder(s.thread)?.op === 'claim') fail('thread-already-held');
|
||||
if (this.#sessionHolder(s.session)?.op === 'claim') fail('thread-already-held');
|
||||
this.#store.run(
|
||||
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by) VALUES(?,?,?,?,?,?,?,?)',
|
||||
"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'),?,?,?,?,?)",
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.thread,
|
||||
s.session,
|
||||
s.session,
|
||||
'claim',
|
||||
s.run,
|
||||
s.harness,
|
||||
s.address,
|
||||
by,
|
||||
);
|
||||
this.#routeThread(s.thread);
|
||||
this.#routeSession(s.session);
|
||||
return { role: s.role, run: s.run, thread: s.thread };
|
||||
}
|
||||
if (
|
||||
@@ -4842,7 +4878,8 @@ export class Broker {
|
||||
identifier(a.decision);
|
||||
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
|
||||
}
|
||||
// Row 155 (#1649): a thread instance holds a claim per thread, so the end revokes each one.
|
||||
// Row 155 (#1649): a thread instance holds a claim per held thread session (264a), so the end
|
||||
// revokes each one.
|
||||
if (this.#threaded(s.business, a.role)) {
|
||||
const held = this.#threadHolders(s.business, a.role);
|
||||
if (held.length === 0) fail('not-held');
|
||||
@@ -5066,14 +5103,14 @@ export class Broker {
|
||||
op === 'resolved'
|
||||
? `resolved by ${by}: ${a.choice} (${JSON.parse(d.options).find((o) => o.key === a.choice).text})`
|
||||
: `withdrawn by ${by}`;
|
||||
// A close to a thread instance goes to the thread the raising run held. A decision
|
||||
// A close to a thread instance goes to the session the raising run held. A decision
|
||||
// raised before row 121 has no thread claim and stays unthreaded.
|
||||
const thread = this.#threaded(s.business, d.raised_by_role)
|
||||
const session = this.#threaded(s.business, d.raised_by_role)
|
||||
? (this.#store.get(
|
||||
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
"SELECT session FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
s.business,
|
||||
d.raised_by_run,
|
||||
)?.thread ?? null)
|
||||
)?.session ?? null)
|
||||
: null;
|
||||
const mid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -5090,7 +5127,7 @@ export class Broker {
|
||||
null,
|
||||
`Decision ${d.id} on ${d.action} was ${outcome}.${a.note === undefined ? '' : `\nNote: ${a.note}`}`,
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, thread);
|
||||
this.#route(s.business, mid, d.raised_by_role, session);
|
||||
}
|
||||
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
|
||||
}
|
||||
@@ -5171,7 +5208,7 @@ export class Broker {
|
||||
}
|
||||
if (a.class === 'BLOCK') this.#checkBlock(s, to, a.in_reply_to);
|
||||
if (a.decision) this.#decision(s, a.decision);
|
||||
const thread = this.#threadFor(s, to, a.in_reply_to);
|
||||
const thread = this.#sessionFor(s, to, a.in_reply_to);
|
||||
// Row 170 (#1667): a tester's message to a role nobody holds is refused before it is
|
||||
// stored, so a PM test never waits for the role's next run. A run launched but not yet
|
||||
// claimed counts as no holder, as in #waiting. No lock: a holder that releases once the
|
||||
@@ -5208,14 +5245,14 @@ export class Broker {
|
||||
this.#received.add(s.business + ':' + s.run);
|
||||
}
|
||||
const role = s.human ? 'human' : s.role;
|
||||
// A thread run receives its own thread's messages only (REQ-THREAD-1).
|
||||
// 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'))" +
|
||||
(s.thread ? ' AND EXISTS(SELECT 1 FROM message_threads mt WHERE mt.message=m.id AND mt.thread=?)' : '') +
|
||||
(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,
|
||||
role,
|
||||
...(s.thread ? [s.thread] : []),
|
||||
...(s.thread ? [s.session] : []),
|
||||
);
|
||||
for (const m of rows)
|
||||
this.#delivery(
|
||||
@@ -5449,13 +5486,14 @@ export class Broker {
|
||||
}
|
||||
// Row 277 (#1782, design D1): the thread claims held in the business, for human and reader
|
||||
// sessions only. A thread run's role.claim writes thread_claims, which `agents` doesn't read,
|
||||
// so this is where the owner sees it (PRD :1083-1084). A claim is held when its thread's latest
|
||||
// thread_claims row is a claim, as #threadHolders reads it. No thread name: a user wrote that.
|
||||
// so this is where the owner sees it (PRD :1083-1084). A claim is held when its session's latest
|
||||
// thread_claims row is a claim, as #threadHolders reads it (row 264a, sage-2591), so a thread
|
||||
// with two held sessions lists twice. No thread name: a user wrote that.
|
||||
case 'thread.claims': {
|
||||
if (!s.human && !s.reader) fail('human-required');
|
||||
keys(a, [], []);
|
||||
return this.#store.all(
|
||||
"SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND c.op='claim' ORDER BY t.seq",
|
||||
"SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t JOIN sessions se ON se.thread=t.id JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id) WHERE t.business=? AND c.op='claim' ORDER BY t.seq, se.seq",
|
||||
s.business,
|
||||
);
|
||||
}
|
||||
@@ -5703,6 +5741,8 @@ export class Broker {
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
return { id: tid, instance: instances[0] };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
@@ -5721,20 +5761,23 @@ export class Broker {
|
||||
...(a.project === undefined ? [] : [a.project]),
|
||||
).filter((t) => !s.user || this.#granted(s, t.project, t.instance));
|
||||
}
|
||||
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1).
|
||||
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). Row 264a: a thread
|
||||
// verb names one session of the thread, or works on its default (D3).
|
||||
case 'thread.open': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread'], ['thread']);
|
||||
keys(a, ['thread', 'session'], ['thread']);
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
this.#store.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', this.#now(), t.id, this.#owner(s));
|
||||
const session = this.#threadSession(t, a.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 };
|
||||
}
|
||||
// Row 177 (#1675): a tester's message is the tester's, from tester-cli, and writes no
|
||||
// human.input, as on message.send. In its own thread the lists read it as the human.
|
||||
case 'thread.send': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread', 'body', 'class'], ['thread', 'body']);
|
||||
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
const session = this.#threadSession(t, a.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);
|
||||
@@ -5754,32 +5797,61 @@ export class Broker {
|
||||
null,
|
||||
a.body,
|
||||
);
|
||||
this.#route(s.business, mid, t.instance, t.id);
|
||||
this.#route(s.business, mid, t.instance, session);
|
||||
// A user's message, like a tester's, is never the owner's authority evidence (design 3.5).
|
||||
return { id: mid, request: s.tester || s.user ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
|
||||
}
|
||||
// The thread's history, oldest first. Reading writes nothing.
|
||||
case 'thread.messages': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread', 'after', 'limit'], ['thread']);
|
||||
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
const session = this.#threadSession(t, a.session);
|
||||
const limit = a.limit ?? THREAD_PAGE;
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
|
||||
let after = 0;
|
||||
if (a.after !== undefined) {
|
||||
after = this.#store.get(
|
||||
'SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND mt.thread=?',
|
||||
'SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND mt.session=?',
|
||||
identifier(a.after),
|
||||
t.id,
|
||||
session,
|
||||
)?.seq;
|
||||
if (after === undefined) fail('message-not-found');
|
||||
}
|
||||
return this.#store.all(
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
t.id,
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
session,
|
||||
after,
|
||||
);
|
||||
}
|
||||
// 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).
|
||||
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);
|
||||
return this.#store
|
||||
.all('SELECT id,at,name,predecessor FROM sessions WHERE thread=? ORDER BY seq', t.id)
|
||||
.map((r) => ({ ...r, default: r.id === fallback }));
|
||||
}
|
||||
// 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
|
||||
// under the same op refuses.
|
||||
case 'session.create': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread', 'name', 'op'], ['thread', 'name', 'op']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
identifier(a.op);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const old = this.#store.get('SELECT id,name FROM sessions WHERE thread=? AND op=?', t.id, a.op);
|
||||
if (old) {
|
||||
if (old.name !== a.name) fail('session-op-conflict');
|
||||
return { id: old.id, thread: t.id, name: old.name };
|
||||
}
|
||||
if (this.#store.get('SELECT 1 FROM sessions WHERE thread=? AND name=?', t.id, a.name)) fail('session-exists');
|
||||
return { id: this.#newSession(s, t, a.name, a.op, false), thread: t.id, name: a.name };
|
||||
}
|
||||
case 'replies': {
|
||||
if (!s.human && !s.reader) fail('human-required');
|
||||
keys(a, ['id'], ['id']);
|
||||
@@ -5840,10 +5912,12 @@ export class Broker {
|
||||
#claimEnd(s, h, op, decision, reason = null, by = s.run ?? s.human) {
|
||||
if (h.thread)
|
||||
return this.#store.run(
|
||||
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)',
|
||||
'INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
this.#now(),
|
||||
s.business,
|
||||
h.thread,
|
||||
h.session,
|
||||
h.generation,
|
||||
op,
|
||||
h.holder_run,
|
||||
h.harness,
|
||||
@@ -5886,7 +5960,7 @@ export class Broker {
|
||||
}
|
||||
// The message a review verb sends, routed like message.send's.
|
||||
#reviewMessage(s, mid, { to, cls, in_reply_to = null, decision = null, body }) {
|
||||
const thread = this.#threadFor(s, to, in_reply_to);
|
||||
const thread = this.#sessionFor(s, to, in_reply_to);
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
mid,
|
||||
@@ -5904,9 +5978,35 @@ export class Broker {
|
||||
this.#route(s.business, mid, to, thread);
|
||||
}
|
||||
// Row 121: a message names its thread (REQ-MSG-1) and goes to the holder that may have it.
|
||||
#route(business, mid, to, thread) {
|
||||
if (thread) this.#store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', mid, thread);
|
||||
this.#delivery(mid, 'routed', this.#recipient(business, to, thread));
|
||||
// Row 264a (#1769): it names one session of the thread, and the session's holder may have it.
|
||||
#route(business, mid, to, session) {
|
||||
if (session) {
|
||||
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);
|
||||
}
|
||||
this.#delivery(mid, 'routed', this.#recipient(business, to, session));
|
||||
}
|
||||
// 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
|
||||
// one that names another thread.
|
||||
#newSession(s, t, sessionName, op, makeDefault) {
|
||||
const sid = randomUUID();
|
||||
const at = this.#now();
|
||||
this.#store.run(
|
||||
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
sid,
|
||||
at,
|
||||
t.business,
|
||||
t.id,
|
||||
t.instance,
|
||||
t.owner,
|
||||
sessionName,
|
||||
op,
|
||||
null,
|
||||
);
|
||||
if (makeDefault)
|
||||
this.#store.run('INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,?)', at, t.id, sid, this.#owner(s));
|
||||
return sid;
|
||||
}
|
||||
// Row 146 (#1640): a message.send that nobody holds the target for waits for a claim. Null when
|
||||
// the message was routed to a holder, or is the human's, a tester's, or in a thread to a thread
|
||||
@@ -6033,12 +6133,12 @@ export class Broker {
|
||||
);
|
||||
for (const m of rows) this.#delivery(m.id, 'routed', h);
|
||||
}
|
||||
// A new thread holder gets the thread's waiting messages to the instance.
|
||||
#routeThread(thread) {
|
||||
const h = this.#threadHolder(thread);
|
||||
// A new session holder gets the session's waiting messages to the instance.
|
||||
#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.thread=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read'))",
|
||||
thread,
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Row 264a (#1769, T1 item 7): sessions below a thread. Each existing thread gets one legacy default
|
||||
-- session whose id is the thread id, and its claims, messages and opens name that session (D9's
|
||||
-- mapping). thread_claims, message_threads and thread_opens are rebuilt with a session column, and
|
||||
-- thread_claims gains generation, the claim's number in its session. Rows are copied with their seq
|
||||
-- values and old columns as they were, and the sequences carry over, as 3h-3i did. The guards and the
|
||||
-- delivery trigger that read the old tables are dropped first, so the renames rewrite nothing.
|
||||
DROP TRIGGER thread_one_holder;
|
||||
DROP TRIGGER thread_one_per_run;
|
||||
DROP TRIGGER thread_release_by_holder;
|
||||
DROP TRIGGER thread_release_same_run;
|
||||
DROP TRIGGER thread_revoke_needs_decision;
|
||||
DROP TRIGGER thread_claims_business;
|
||||
DROP TRIGGER message_threads_match;
|
||||
DROP TRIGGER delivery_same_thread;
|
||||
DROP TRIGGER thread_claims_no_update;
|
||||
DROP TRIGGER thread_claims_no_delete;
|
||||
DROP TRIGGER thread_claims_no_replace;
|
||||
DROP TRIGGER message_threads_no_update;
|
||||
DROP TRIGGER message_threads_no_delete;
|
||||
DROP TRIGGER message_threads_no_replace;
|
||||
DROP TRIGGER thread_opens_no_update;
|
||||
DROP TRIGGER thread_opens_no_delete;
|
||||
DROP TRIGGER thread_opens_no_replace;
|
||||
DROP INDEX thread_claims_thread;
|
||||
DROP INDEX message_threads_thread;
|
||||
ALTER TABLE thread_claims RENAME TO thread_claims_3k;
|
||||
ALTER TABLE message_threads RENAME TO message_threads_3k;
|
||||
ALTER TABLE thread_opens RENAME TO thread_opens_3k;
|
||||
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;
|
||||
INSERT INTO sessions(at,business,thread,id,instance,owner,name,op,predecessor)
|
||||
SELECT at,business,id,id,instance,owner,'default','legacy-3l',NULL FROM threads ORDER BY seq;
|
||||
INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads ORDER BY seq;
|
||||
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;
|
||||
INSERT INTO thread_claims(seq,at,business,thread,session,generation,op,holder_run,harness,address,by,reason,decision)
|
||||
SELECT c.seq,c.at,c.business,c.thread,c.thread,
|
||||
(SELECT count(*) FROM thread_claims_3k p WHERE p.thread = c.thread AND p.op = 'claim' AND p.seq <= c.seq),
|
||||
c.op,c.holder_run,c.harness,c.address,c.by,c.reason,c.decision FROM thread_claims_3k c ORDER BY c.seq;
|
||||
INSERT INTO message_threads(message,thread,session) SELECT message,thread,thread FROM message_threads_3k ORDER BY rowid;
|
||||
INSERT INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,thread,by FROM thread_opens_3k ORDER BY seq;
|
||||
DELETE FROM sqlite_sequence WHERE name IN ('thread_claims','thread_opens');
|
||||
INSERT INTO sqlite_sequence(name,seq) SELECT 'thread_claims', seq FROM sqlite_sequence WHERE name='thread_claims_3k';
|
||||
INSERT INTO sqlite_sequence(name,seq) SELECT 'thread_opens', seq FROM sqlite_sequence WHERE name='thread_opens_3k';
|
||||
DROP TABLE thread_claims_3k;
|
||||
DROP TABLE message_threads_3k;
|
||||
DROP TABLE thread_opens_3k;
|
||||
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 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 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;
|
||||
@@ -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 = 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0';
|
||||
export const SCHEMA_VERSION = '3k';
|
||||
export const SCHEMA_SHA256 = 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a';
|
||||
export const SCHEMA_VERSION = '3l';
|
||||
// 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';
|
||||
@@ -66,6 +66,12 @@ const migrations = [{
|
||||
from: '3j',
|
||||
to: '3k',
|
||||
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 264a (#1769): sessions below a thread. Each thread gets a default session whose id is the
|
||||
// thread id, and thread_claims, message_threads and thread_opens are rebuilt to name it.
|
||||
from: '3k',
|
||||
to: '3l',
|
||||
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -77,6 +83,8 @@ const timedTables = [
|
||||
'task_snapshots',
|
||||
'projects',
|
||||
'threads',
|
||||
'sessions',
|
||||
'session_defaults',
|
||||
'thread_claims',
|
||||
'thread_opens',
|
||||
'instance_names',
|
||||
|
||||
@@ -15,6 +15,9 @@ const AT = '2026-10-18T00:00:00.000Z';
|
||||
const SEQUENCE = 50;
|
||||
const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url), 'utf8');
|
||||
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'];
|
||||
const later3l = (r) => SESSIONS_3L.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 }));
|
||||
@@ -48,6 +51,15 @@ function snapshot(path, columns) {
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
|
||||
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). An after-migration compare
|
||||
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
|
||||
// new row still fails.
|
||||
function migrated3l(rows) {
|
||||
const left = rows.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
|
||||
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'session_defaults', seq: 0 }, { name: 'sessions', seq: 0 }]);
|
||||
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
|
||||
}
|
||||
function master(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
@@ -71,6 +83,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
|
||||
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
|
||||
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'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -78,9 +91,9 @@ 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), '3k');
|
||||
assert.equal(version(path), '3l');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
assert.deepEqual({ ...before.rows.sqlite_sequence.find(r => r.name === 'usage_records') }, { name: 'usage_records', seq: SEQUENCE });
|
||||
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
|
||||
@@ -90,7 +103,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
// Row 236 (#1742) adds 3j→3k's; its test is gitea-migration.test.mjs.
|
||||
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1',
|
||||
'gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
|
||||
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.deepEqual(master(path).filter(r => !later.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)).map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
|
||||
const store = new Store(root);
|
||||
try {
|
||||
@@ -128,8 +141,8 @@ 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), '3k');
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.equal(version(path), '3l');
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
@@ -140,14 +153,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 = '3k';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3l';", "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), '3k');
|
||||
assert.equal(version(path), '3l');
|
||||
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 = '3k';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3l';", "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, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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 = '3k';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3m';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3l', to: '3m', 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'])
|
||||
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'])
|
||||
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, '3l');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
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 3k ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3l ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -8,6 +8,12 @@ import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
'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=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
|
||||
];
|
||||
|
||||
const businesses = {
|
||||
demo: {
|
||||
@@ -149,7 +155,8 @@ test('broker process: launch ops authorize role.launch, record refusals and end
|
||||
const side = new Store(data);
|
||||
side.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', '2026-10-15T00:00:00.000Z', 'demo', 'work', null, 'jason');
|
||||
side.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'th-1', '2026-10-15T00:00:00.000Z', 'demo', 'p-1', 'coder', 'jason', 'one');
|
||||
side.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', '2026-10-15T00:00:01.000Z', 'th-1', 'jason');
|
||||
for (const sql of DEFAULT_SESSION) side.run(sql, 'th-1');
|
||||
side.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', '2026-10-15T00:00:01.000Z', 'th-1', 'th-1', 'jason');
|
||||
side.close();
|
||||
const ready = await ask(child, {
|
||||
op: 'boot',
|
||||
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
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 thread_claims (
|
||||
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 ('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)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
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 thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread 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 thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); 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 thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread 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 thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); 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 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 delivery_same_thread 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 thread = t.id)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''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 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;
|
||||
@@ -32,14 +32,26 @@ function source3j(t) {
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
// 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'];
|
||||
const later3l = (r) => SESSIONS_3L.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')").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')").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(); }
|
||||
}
|
||||
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
|
||||
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). The compare after the step
|
||||
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
|
||||
// new row still fails.
|
||||
function migrated3l(now) {
|
||||
const left = now.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
|
||||
assert.deepEqual(left, [{ name: 'sessions', seq: 0 }, { name: 'session_defaults', seq: 0 }]);
|
||||
return { ...now, sqlite_sequence: now.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
|
||||
}
|
||||
function master(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
@@ -61,6 +73,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
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'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
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'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -82,10 +95,10 @@ 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), '3k');
|
||||
assert.deepEqual(rows(path), before);
|
||||
assert.equal(version(path), '3l');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
try {
|
||||
@@ -108,10 +121,10 @@ 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), '3k');
|
||||
assert.equal(version(path), '3l');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k', async t => {
|
||||
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
const { root, path } = source3j(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = rows(path), was = master(path);
|
||||
@@ -119,14 +132,14 @@ test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k',
|
||||
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 = '3k';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace("export const SCHEMA_VERSION = '3l';", "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), '3k');
|
||||
assert.equal(version(path), '3l');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ const here = (p) => new URL(p, import.meta.url);
|
||||
const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
|
||||
// Row 236 (#1742): 3j→3k's objects, after this step. Their test is gitea-migration.test.mjs.
|
||||
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_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'];
|
||||
const later3l = (r) => SESSIONS_3L.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 }));
|
||||
@@ -37,11 +40,20 @@ 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')").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')").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(); }
|
||||
}
|
||||
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
|
||||
// each a sqlite_sequence row even when it copies none (3k-3l.sql:50-52). The compare after the step
|
||||
// leaves out the rows SESSIONS_3L names and checks they are exactly those two at seq 0, so any other
|
||||
// new row still fails.
|
||||
function migrated3l(now) {
|
||||
const left = now.sqlite_sequence.filter(r => SESSIONS_3L.includes(r.name));
|
||||
assert.deepEqual(left, [{ name: 'sessions', seq: 0 }, { name: 'session_defaults', seq: 0 }]);
|
||||
return { ...now, sqlite_sequence: now.sqlite_sequence.filter(r => !SESSIONS_3L.includes(r.name)) };
|
||||
}
|
||||
function master(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
@@ -64,6 +76,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
|
||||
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'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -87,10 +100,10 @@ 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), '3k');
|
||||
assert.deepEqual(rows(path), before);
|
||||
assert.equal(version(path), '3l');
|
||||
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)), was);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
try {
|
||||
@@ -112,10 +125,10 @@ 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), '3k');
|
||||
assert.equal(version(path), '3l');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
|
||||
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
const { root, path } = source3i(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = rows(path), was = master(path);
|
||||
@@ -123,14 +136,14 @@ test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j',
|
||||
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 = '3k';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3l';", "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), '3k');
|
||||
assert.equal(version(path), '3l');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,12 @@ import { fixture } from './spec-fixtures.mjs';
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
'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=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
|
||||
];
|
||||
|
||||
// A thread owned by `owner`, on the business's assistant instance.
|
||||
function threadOf(store, project, owner, name) {
|
||||
@@ -23,6 +29,7 @@ function threadOf(store, project, owner, name) {
|
||||
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
|
||||
id, AT, 'demo', project, 'assistant', owner, name,
|
||||
);
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, id);
|
||||
return id;
|
||||
}
|
||||
// A message with its thread row, its routed delivery and one event about it. The event names the
|
||||
@@ -33,7 +40,7 @@ function put(store, { thread = null, from_role, from_run, to_role, in_reply_to =
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, AT, 'demo', from_role, from_run, to_role, 'INFO', in_reply_to, decision, null, body,
|
||||
);
|
||||
if (thread) store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', id, thread);
|
||||
if (thread) store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', id, thread, thread);
|
||||
store.run('INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)', id, AT, 'routed', null, null, null);
|
||||
store.run(
|
||||
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
|
||||
|
||||
@@ -50,19 +50,29 @@ function snapshot(path, columns) {
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
|
||||
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
|
||||
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
|
||||
// thread_claims and thread_opens stay in the compare: the step carries their seq.
|
||||
const SEQ_3L = ['sessions', 'session_defaults'];
|
||||
function migrated3l(rows) {
|
||||
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
|
||||
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'sessions', seq: 1 }, { name: 'session_defaults', seq: 1 }]);
|
||||
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
|
||||
}
|
||||
test('3f copy migrates to 3g preserving every old column/row, with an empty instance_names and null to_name', t => {
|
||||
const { root, path, copy } = source3f(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
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, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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) });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
} finally { store.close(); }
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(readFileSync(copy), untouched);
|
||||
assert.deepEqual(snapshot(copy).rows, before.rows);
|
||||
new Store(root).close();
|
||||
|
||||
@@ -521,7 +521,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// Row 124 adds usage_records and its three append-only triggers.
|
||||
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
|
||||
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 20);
|
||||
assert.equal(count('trigger'), 71);
|
||||
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 22);
|
||||
assert.equal(count('trigger'), 86);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
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 264a (#1769, T1 item 7): sessions below a thread, in the bus. T1's bus half, T2 and T7 of
|
||||
// docs/plans/2026-10-21_t1-sessions.md. The launcher, seat and Console halves are 264b-264e's.
|
||||
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: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
};
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
roles,
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, pm: {} },
|
||||
projects: { stack: { root: '/nonexistent' } },
|
||||
},
|
||||
};
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const now = () => new Date().toISOString();
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const ddl3k = readFileSync(here('./fixtures/schema-3k.sql'), 'utf8');
|
||||
|
||||
function broker(t, store) {
|
||||
const b = new Broker({ store, businesses });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const caps = { human };
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const launch = (name, role, extra = {}) => {
|
||||
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
|
||||
return caps[name];
|
||||
};
|
||||
const claim = (name) => call(name, 'role.claim');
|
||||
const sessionOf = (message) => store.get('SELECT session FROM message_threads WHERE message=?', message)?.session ?? null;
|
||||
const claims = (session) => store.all('SELECT op, holder_run, generation FROM thread_claims WHERE session=? ORDER BY seq', session).map((r) => ({ ...r }));
|
||||
return { b, store, caps, call, launch, claim, sessionOf, claims };
|
||||
}
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const ctx = broker(t, store);
|
||||
const project = ctx.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
|
||||
const A = ctx.call('human', 'thread.create', { project, name: 'alpha' }).id;
|
||||
for (const r of ['chief', 'pm']) {
|
||||
ctx.launch(r, r);
|
||||
ctx.claim(r);
|
||||
}
|
||||
const [first] = ctx.call('human', 'session.list', { thread: A });
|
||||
return { ...ctx, root, project, A, D: first.id };
|
||||
}
|
||||
|
||||
test('T1 REQ-ROLE-2: two sessions of one thread keep their own history, delivery and replies', (t) => {
|
||||
const { call, launch, claim, sessionOf, store, project, A, D } = setup(t);
|
||||
// thread.create made one default session, with a random id that isn't the thread's.
|
||||
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.name, r.predecessor, r.default]), [['default', null, true]]);
|
||||
assert.notEqual(D, A);
|
||||
assert.match(D, /^[a-f0-9-]{36}$/);
|
||||
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
|
||||
assert.deepEqual(call('human', 'session.list', { thread: A }).map((r) => [r.id, r.name, r.default]), [
|
||||
[D, 'default', true],
|
||||
[S, 'second', false],
|
||||
]);
|
||||
// A run with no session named works the default (D3). Each session has its own holder.
|
||||
launch('a1', 'assistant', { thread: A });
|
||||
launch('a2', 'assistant', { thread: A, session: S });
|
||||
assert.deepEqual(claim('a1'), { role: 'assistant', run: 'a1-run', thread: A });
|
||||
assert.deepEqual(claim('a2'), { role: 'assistant', run: 'a2-run', thread: A });
|
||||
const toDefault = call('human', 'thread.send', { thread: A, body: 'to the default' });
|
||||
const toSecond = call('human', 'thread.send', { thread: A, session: S, body: 'to the second' });
|
||||
assert.equal(sessionOf(toDefault.id), D);
|
||||
assert.equal(sessionOf(toSecond.id), S);
|
||||
assert.deepEqual(call('a1', 'message.receive').map((m) => m.body), ['to the default']);
|
||||
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['to the second']);
|
||||
assert.deepEqual(call('a1', 'message.receive'), []);
|
||||
// A reply stays in its run's session, and each history reads only its own.
|
||||
const reply = call('a2', 'message.send', { to: 'human', body: 'second answers', in_reply_to: toSecond.id });
|
||||
assert.equal(sessionOf(reply.id), S);
|
||||
assert.deepEqual(call('human', 'thread.messages', { thread: A }).map((m) => m.body), ['to the default']);
|
||||
assert.deepEqual(call('human', 'thread.messages', { thread: A, session: S }).map((m) => m.body), ['to the second', 'second answers']);
|
||||
// The chief's reply to the second session's work comes back to that session.
|
||||
const ask = call('a2', 'message.send', { to: 'chief', body: 'need help', class: 'REQUEST' });
|
||||
const back = call('chief', 'message.send', { to: 'assistant', body: 'here', in_reply_to: ask.id });
|
||||
assert.equal(sessionOf(back.id), S);
|
||||
assert.deepEqual(call('a2', 'message.receive').map((m) => m.body), ['here']);
|
||||
assert.deepEqual(call('a1', 'message.receive'), []);
|
||||
// An open names its session too.
|
||||
call('human', 'thread.open', { thread: A, session: S });
|
||||
assert.equal(store.get('SELECT session FROM thread_opens ORDER BY seq DESC LIMIT 1').session, S);
|
||||
// A non-thread role names no session, and a session of another thread is refused.
|
||||
assert.throws(() => launch('p2', 'pm', { session: S }), { code: 'thread-not-allowed' });
|
||||
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
|
||||
assert.throws(() => launch('a3', 'assistant', { thread: B, session: S }), { code: 'session-not-found' });
|
||||
assert.throws(() => call('human', 'thread.send', { thread: B, session: S, body: 'x' }), { code: 'session-not-found' });
|
||||
assert.throws(() => call('human', 'thread.messages', { thread: B, session: 'no-such-session' }), { code: 'session-not-found' });
|
||||
});
|
||||
|
||||
test('T2 REQ-ROLE-2: one holder per session, generations count its claims, and a sibling session\'s message never reaches the other holder', (t) => {
|
||||
const { b, call, launch, claim, claims, store, project, A, D } = setup(t);
|
||||
const S = call('human', 'session.create', { thread: A, name: 'second', op: 'op-second' }).id;
|
||||
launch('a1', 'assistant', { thread: A });
|
||||
claim('a1');
|
||||
launch('a1b', 'assistant', { thread: A });
|
||||
assert.throws(() => claim('a1b'), { code: 'thread-already-held' });
|
||||
// A sibling session is free to claim while the default is held.
|
||||
launch('a2', 'assistant', { thread: A, session: S });
|
||||
claim('a2');
|
||||
// Release and claim again: the second claim is generation 2, and its release carries it.
|
||||
call('a2', 'role.release');
|
||||
launch('a3', 'assistant', { thread: A, session: S });
|
||||
claim('a3');
|
||||
b.endLaunch({ business: 'demo', run: 'a3-run', reason: 'idle' });
|
||||
assert.deepEqual(claims(S), [
|
||||
{ op: 'claim', holder_run: 'a2-run', generation: 1 },
|
||||
{ op: 'release', holder_run: 'a2-run', generation: 1 },
|
||||
{ op: 'claim', holder_run: 'a3-run', generation: 2 },
|
||||
{ op: 'release', holder_run: 'a3-run', generation: 2 },
|
||||
]);
|
||||
// The human's stop revokes each held session, with that claim's generation.
|
||||
launch('a4', 'assistant', { thread: A, session: S });
|
||||
claim('a4');
|
||||
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), {
|
||||
stopped: [
|
||||
{ thread: A, run: 'a1-run' },
|
||||
{ thread: A, run: 'a4-run' },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(claims(D).at(-1), { op: 'revoke', holder_run: 'a1-run', generation: 1 });
|
||||
assert.deepEqual(claims(S).at(-1), { op: 'revoke', holder_run: 'a4-run', generation: 3 });
|
||||
// The triggers hold these for a write that skips the broker.
|
||||
const raw = (thread, session, generation, run) =>
|
||||
store.run(
|
||||
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo',?,?,?,'claim',?,'pi',?)",
|
||||
now(),
|
||||
thread,
|
||||
session,
|
||||
generation,
|
||||
run,
|
||||
run,
|
||||
);
|
||||
assert.throws(() => raw(A, S, 5, 'x-run'), /a claim row names its session's generation/);
|
||||
assert.throws(() => raw(A, S, 3, 'x-run'), /a claim row names its session's generation/);
|
||||
const B = call('human', 'thread.create', { project, name: 'beta' }).id;
|
||||
const [{ id: BD }] = call('human', 'session.list', { thread: B });
|
||||
assert.throws(() => raw(A, BD, 1, 'x-run'), /a claim's session is in its thread/);
|
||||
raw(A, S, 4, 'x-run');
|
||||
assert.throws(() => raw(B, BD, 1, 'x-run'), /run already holds a session/);
|
||||
// A threaded message goes only to its own session's holder.
|
||||
launch('a5', 'assistant', { thread: A });
|
||||
claim('a5');
|
||||
const m = call('human', 'thread.send', { thread: A, session: S, body: 'for x' });
|
||||
assert.throws(
|
||||
() => store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'a5-run'),
|
||||
/a threaded message goes only to its session's holder/,
|
||||
);
|
||||
store.run("INSERT INTO deliveries(message,at,op,holder_run) VALUES(?,?,'delivered',?)", m.id, now(), 'x-run');
|
||||
});
|
||||
|
||||
test('T2 sage-2257: a released or revoked run\'s retired generation refuses its approval, delivery and read, and writes nothing', (t) => {
|
||||
const { call, launch, claim, claims, store, A, D } = setup(t);
|
||||
const tables = store.all("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").map((r) => r.name);
|
||||
const counts = () => Object.fromEntries(tables.map((n) => [n, store.get(`SELECT count(*) AS n FROM "${n}"`).n]));
|
||||
// The run raises a decision, takes one message and leaves a second waiting.
|
||||
const prepare = (name) => {
|
||||
launch(name, 'assistant', { thread: A });
|
||||
claim(name);
|
||||
const d = call(name, 'decision.raise', {
|
||||
action: 'spend',
|
||||
target: 'demo',
|
||||
question: 'spend?',
|
||||
options: [
|
||||
{ key: 'yes', text: 'Yes' },
|
||||
{ key: 'no', text: 'No' },
|
||||
],
|
||||
recommendation: 'no',
|
||||
blocking: false,
|
||||
});
|
||||
call('human', 'thread.send', { thread: A, body: `for ${name}` });
|
||||
const [taken] = call(name, 'message.receive');
|
||||
call('human', 'thread.send', { thread: A, body: `waiting after ${name}` });
|
||||
return { d, taken };
|
||||
};
|
||||
// Each refusal leaves its one action.refused event (request()'s catch, #refused); nothing else is
|
||||
// written: no approval, delivery, read or claim state (mos-1277).
|
||||
const lastSeq = () => store.get('SELECT max(seq) AS s FROM events').s;
|
||||
const refusedOnce = (fn, label) => {
|
||||
const from = lastSeq();
|
||||
assert.throws(fn, { code: 'not-holder' }, label);
|
||||
const added = store.all('SELECT kind, body FROM events WHERE seq > ? ORDER BY seq', from);
|
||||
assert.deepEqual(
|
||||
added.map((e) => [e.kind, JSON.parse(e.body)]),
|
||||
[['action.refused', { code: 'not-holder' }]],
|
||||
label,
|
||||
);
|
||||
};
|
||||
const refused = (name, { d, taken }) => {
|
||||
const { events, ...before } = counts();
|
||||
refusedOnce(() => call(name, 'decision.withdraw', { id: d.id }), 'approval');
|
||||
refusedOnce(() => call(name, 'message.receive'), 'delivery');
|
||||
refusedOnce(() => call(name, 'message.read', { id: taken.id }), 'read');
|
||||
const { events: after, ...rest } = counts();
|
||||
assert.deepEqual(rest, before);
|
||||
assert.equal(after, events + 3);
|
||||
};
|
||||
// Released: generation 1 is retired once a2 claims generation 2.
|
||||
const one = prepare('a1');
|
||||
call('a1', 'role.release');
|
||||
const two = prepare('a2');
|
||||
refused('a1', one);
|
||||
// Revoked by the human's stop: generation 2 is retired once a3 claims generation 3.
|
||||
call('human', 'assistant.stop', { instance: 'assistant' });
|
||||
prepare('a3');
|
||||
refused('a2', two);
|
||||
assert.deepEqual(claims(D).map((r) => [r.op, r.holder_run, r.generation]), [
|
||||
['claim', 'a1-run', 1],
|
||||
['release', 'a1-run', 1],
|
||||
['claim', 'a2-run', 2],
|
||||
['revoke', 'a2-run', 2],
|
||||
['claim', 'a3-run', 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test('session.create is idempotent on its op, refuses a reused op or a taken name, and reaches only the owner\'s threads', (t) => {
|
||||
const { call, store, project, A, D } = setup(t);
|
||||
const first = call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' });
|
||||
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'second', op: 'op-1' }), first);
|
||||
assert.throws(() => call('human', 'session.create', { thread: A, name: 'third', op: 'op-1' }), { code: 'session-op-conflict' });
|
||||
assert.throws(() => call('human', 'session.create', { thread: A, name: 'second', op: 'op-2' }), { code: 'session-exists' });
|
||||
assert.throws(() => call('human', 'session.create', { thread: A, name: 'default', op: 'op-3' }), { code: 'session-exists' });
|
||||
// thread.create's op answers a retry that names the default.
|
||||
assert.deepEqual(call('human', 'session.create', { thread: A, name: 'default', op: 'default' }), { id: D, thread: A, name: 'default' });
|
||||
for (const args of [{ thread: A, name: 'x\ny', op: 'op-4' }, { thread: A, name: 'x' }, { thread: A, name: 'x', op: 'op-5', extra: 1 }])
|
||||
assert.throws(() => call('human', 'session.create', args), { code: 'invalid-request' });
|
||||
assert.equal(store.get('SELECT count(*) n FROM sessions WHERE thread=?', A).n, 2);
|
||||
// Another owner's thread, made by hand with the session the broker would write.
|
||||
const other = 'thread-of-someone-else';
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', other, now(), 'demo', project, 'assistant', 'someone-else', 'theirs');
|
||||
store.run("INSERT INTO sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-theirs',?,'demo',?,'assistant','someone-else','default','default')", now(), other);
|
||||
store.run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,'s-theirs','someone-else')", now(), other);
|
||||
for (const [verb, args] of [
|
||||
['session.list', { thread: other }],
|
||||
['session.create', { thread: other, name: 'mine', op: 'op-6' }],
|
||||
['thread.send', { thread: other, session: 's-theirs', body: 'hi' }],
|
||||
])
|
||||
assert.throws(() => call('human', verb, args), { code: 'thread-not-owned' }, verb);
|
||||
assert.throws(() => call('human', 'session.list', { thread: 'nope' }), { code: 'thread-not-found' });
|
||||
// Agents don't call the session verbs.
|
||||
assert.throws(() => call('pm', 'session.list', { thread: A }), { code: 'human-required' });
|
||||
});
|
||||
|
||||
test('the 3l triggers keep sessions in their thread and session ids apart from thread ids', (t) => {
|
||||
// A bare database from schema.sql, so only the triggers stand between a write and the tables.
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-sessions-sql-'));
|
||||
const db = new DatabaseSync(join(root, 'bus.sqlite'));
|
||||
t.after(() => {
|
||||
db.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
db.exec(readFileSync(here('../schema.sql'), 'utf8'));
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
const session = (id, thread, extra = {}) =>
|
||||
run(
|
||||
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
id,
|
||||
now(),
|
||||
extra.business ?? 'demo',
|
||||
thread,
|
||||
extra.instance ?? 'assistant',
|
||||
extra.owner ?? 'jason',
|
||||
extra.name ?? id,
|
||||
extra.op ?? id,
|
||||
extra.predecessor ?? null,
|
||||
);
|
||||
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
|
||||
for (const id of ['th-1', 'th-2'])
|
||||
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,'demo','p-1','assistant','jason',?)", id, now(), id);
|
||||
session('s-1', 'th-1');
|
||||
// The legacy shape: a session whose id is its own thread's.
|
||||
session('th-1', 'th-1', { name: 'default', op: 'legacy-3l' });
|
||||
// A session id that names another thread, and a thread id that names a session, are refused.
|
||||
assert.throws(() => session('th-2', 'th-1'), /a session id names another thread/);
|
||||
assert.throws(
|
||||
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('s-1',?,'demo','p-1','assistant','jason','x')", now()),
|
||||
/a thread id names a session/,
|
||||
);
|
||||
// A session has its thread's business, instance and owner, and its predecessor is in its thread.
|
||||
for (const extra of [{ business: 'other' }, { instance: 'pm' }, { owner: 'someone-else' }])
|
||||
assert.throws(() => session('s-x', 'th-1', extra), /a session has its thread's business, instance and owner/, JSON.stringify(extra));
|
||||
assert.throws(() => session('s-x', 'th-2', { predecessor: 's-1' }), /a session's predecessor is in its thread/);
|
||||
session('s-2', 'th-1', { predecessor: 's-1' });
|
||||
// The names and op keys are unique in a thread.
|
||||
assert.throws(() => session('s-3', 'th-1', { name: 's-1' }), /UNIQUE/);
|
||||
assert.throws(() => session('s-3', 'th-1', { op: 's-1' }), /UNIQUE/);
|
||||
// A default is a session of its thread.
|
||||
assert.throws(() => run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /a default session is in its thread/);
|
||||
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
|
||||
// A message's and an open's session is in their thread.
|
||||
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
|
||||
assert.throws(() => run("INSERT INTO message_threads(message,thread,session) VALUES('m-1','th-2','s-1')"), /a threaded message's session is in its thread/);
|
||||
assert.throws(() => run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-2','s-1','jason')", now()), /an open's session is in its thread/);
|
||||
});
|
||||
|
||||
// T7: a 3k store from the pinned 3k schema (8f51900b5's, the parent of this change's schema.sql)
|
||||
// and fixture rows. No live store is read or copied.
|
||||
function source3k(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sessions-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(ddl3k).digest('hex'), 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0');
|
||||
db.exec(ddl3k);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3k');
|
||||
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 of ['t1', 't2'])
|
||||
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', 'jason', id);
|
||||
// Duckworth's fixture: claim, release, claim, revoke on t1, with t2's claim between, at seqs
|
||||
// with gaps so a renumbered copy would show.
|
||||
const claimRow = (seq, thread, op, holder, extra = {}) =>
|
||||
run(
|
||||
'INSERT INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by,reason) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
seq, AT, 'demo', thread, op, holder, 'pi', extra.by ?? holder, extra.reason ?? null,
|
||||
);
|
||||
claimRow(10, 't1', 'claim', 'a-run');
|
||||
claimRow(11, 't2', 'claim', 'b-run');
|
||||
claimRow(12, 't1', 'release', 'a-run');
|
||||
claimRow(20, 't1', 'claim', 'c-run');
|
||||
claimRow(40, 't1', 'revoke', 'c-run', { by: 'jason', reason: 'stuck' });
|
||||
for (const [id, thread] of [['m-1', 't1'], ['m-2', 't2'], ['m-3', 't1']]) {
|
||||
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', id, AT, 'demo', 'human', 'human-cli', 'assistant', 'INFO', id);
|
||||
run('INSERT INTO message_threads VALUES(?,?)', id, thread);
|
||||
}
|
||||
run('INSERT INTO thread_opens(seq,at,thread,by) VALUES(?,?,?,?)', 7, AT, 't1', 'jason');
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
const master = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r }));
|
||||
} finally { db.close(); }
|
||||
};
|
||||
const rows = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens', 'meta']
|
||||
.map((name) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all().map((r) => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
};
|
||||
const version = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
|
||||
};
|
||||
|
||||
test('T7: a 3k store migrates to 3l, each thread\'s default session has the thread\'s id, and old rows keep their seqs', (t) => {
|
||||
const { root, path } = source3k(t);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3l');
|
||||
// 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 }));
|
||||
new Store(freshRoot).close();
|
||||
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
||||
const store = new Store(root);
|
||||
t.after(() => store.close());
|
||||
assert.deepEqual(store.all('SELECT id,at,business,thread,instance,owner,name,op,predecessor FROM sessions ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
|
||||
{ id, at: AT, business: 'demo', thread: id, instance: 'assistant', owner: 'jason', name: 'default', op: 'legacy-3l', predecessor: null })));
|
||||
assert.deepEqual(store.all('SELECT at,thread,session,by FROM session_defaults ORDER BY seq').map((r) => ({ ...r })), ['t1', 't2'].map((id) => (
|
||||
{ at: AT, thread: id, session: id, by: 'migration-3l' })));
|
||||
// Generation counts claim rows only: claim, release, claim, revoke on t1 is 1, 1, 2, 2.
|
||||
assert.deepEqual(store.all('SELECT seq,thread,session,op,generation FROM thread_claims ORDER BY seq').map((r) => ({ ...r })), [
|
||||
{ seq: 10, thread: 't1', session: 't1', op: 'claim', generation: 1 },
|
||||
{ seq: 11, thread: 't2', session: 't2', op: 'claim', generation: 1 },
|
||||
{ seq: 12, thread: 't1', session: 't1', op: 'release', generation: 1 },
|
||||
{ seq: 20, thread: 't1', session: 't1', op: 'claim', generation: 2 },
|
||||
{ seq: 40, thread: 't1', session: 't1', op: 'revoke', generation: 2 },
|
||||
]);
|
||||
assert.deepEqual(store.all('SELECT message,thread,session FROM message_threads ORDER BY rowid').map((r) => ({ ...r })), [
|
||||
{ message: 'm-1', thread: 't1', session: 't1' },
|
||||
{ message: 'm-2', thread: 't2', session: 't2' },
|
||||
{ message: 'm-3', thread: 't1', session: 't1' },
|
||||
]);
|
||||
assert.deepEqual({ ...store.get('SELECT seq,thread,session,by FROM thread_opens') }, { seq: 7, thread: 't1', session: 't1', by: 'jason' });
|
||||
for (const [name, seq] of [['thread_claims', 40], ['thread_opens', 7]])
|
||||
assert.equal(store.get('SELECT seq FROM sqlite_sequence WHERE name=?', name).seq, seq, name);
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
// A legacy client names no session and gets the default: t2's holder releases at generation 1,
|
||||
// and t1's next claim is its third.
|
||||
const { call, launch, claim, claims, sessionOf } = broker(t, store);
|
||||
for (const r of ['chief', 'pm']) {
|
||||
launch(r, r);
|
||||
claim(r);
|
||||
}
|
||||
assert.throws(() => call('human', 'session.create', { thread: 't1', name: 'default', op: 'op-new' }), { code: 'session-exists' });
|
||||
store.run("INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,'demo','t2','t2',1,'release','b-run','pi','b-run')", now());
|
||||
assert.equal(store.get("SELECT seq FROM thread_claims WHERE thread='t2' ORDER BY seq DESC LIMIT 1").seq, 41);
|
||||
launch('d', 'assistant', { thread: 't1' });
|
||||
claim('d');
|
||||
assert.deepEqual(claims('t1').at(-1), { op: 'claim', holder_run: 'd-run', generation: 3 });
|
||||
const sent = call('human', 'thread.send', { thread: 't1', body: 'after the step' });
|
||||
assert.equal(sessionOf(sent.id), 't1');
|
||||
assert.deepEqual(call('d', 'message.receive').map((m) => m.body), ['m-1', 'm-3', 'after the step']);
|
||||
assert.deepEqual(call('human', 'thread.messages', { thread: 't1' }).map((m) => m.body), ['m-1', 'm-3', 'after the step']);
|
||||
});
|
||||
|
||||
// The 3k engine is store.mjs at this change with the 3k schema and version and no 3k→3l step.
|
||||
async function engine3k(root) {
|
||||
const moduleRoot = join(root, 'engine-3k');
|
||||
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(ddl3k).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3l';", "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);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3k);
|
||||
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'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it as it was', async (t) => {
|
||||
const { root, path } = source3k(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const was = master(path), before = rows(path);
|
||||
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), '3l');
|
||||
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.
|
||||
const { root: old } = source3k(t);
|
||||
new Store3k(old).close();
|
||||
});
|
||||
@@ -48,17 +48,27 @@ function snapshot(path, columns) {
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
|
||||
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
|
||||
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
|
||||
// thread_claims and thread_opens stay in the compare: the step carries their seq.
|
||||
const SEQ_3L = ['sessions', 'session_defaults'];
|
||||
function migrated3l(rows) {
|
||||
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
|
||||
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'sessions', seq: 1 }, { name: 'session_defaults', seq: 1 }]);
|
||||
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
|
||||
}
|
||||
test('3e copy migrates through 3f to 3g preserving every old column/row and adding null spec citations', t => {
|
||||
const { root, path, copy } = source3e(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
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, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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'), []);
|
||||
} finally { store.close(); }
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(readFileSync(copy), untouched);
|
||||
assert.deepEqual(snapshot(copy).rows, before.rows);
|
||||
new Store(root).close();
|
||||
|
||||
@@ -23,6 +23,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
|
||||
'instance.launch', 'instance.end', 'instance.request',
|
||||
// Row 264a (#1769): the sessions of the tester's own threads.
|
||||
'session.list', 'session.create',
|
||||
]);
|
||||
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));
|
||||
|
||||
@@ -13,6 +13,11 @@ import { Broker } from '../src/broker.mjs';
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
|
||||
const ROW = ['thread', 'instance', 'holder_run', 'harness', 'at'];
|
||||
// Row 264a (#1769): a hand-made thread gets the default session the 3k-3l migration gives an old one.
|
||||
const DEFAULT_SESSION = [
|
||||
'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=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
|
||||
];
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-thread-claims-'));
|
||||
@@ -45,10 +50,12 @@ function setup(t) {
|
||||
const thread = (name) => {
|
||||
const id = randomUUID();
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', project, 'assistant', 'jason', name);
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, id);
|
||||
return id;
|
||||
};
|
||||
const claim = (H, run) => {
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H });
|
||||
// A run on the thread's default session, or on `session` when one is named.
|
||||
const claim = (H, run, session) => {
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H, ...(session ? { session } : {}) });
|
||||
call(cap, 'role.claim');
|
||||
return cap;
|
||||
};
|
||||
@@ -108,8 +115,22 @@ test('T5: the result carries neither the thread name a user wrote nor a message
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
m, AT, 'demo', 'assistant', 'assistant-run', 'human', 'INFO', null, null, null, 'alice secret message body',
|
||||
);
|
||||
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', m, H);
|
||||
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', m, H, H);
|
||||
const text = JSON.stringify(call('human', 'thread.claims'));
|
||||
assert.ok(text.includes(H), 'the thread row is there');
|
||||
assert.doesNotMatch(text, /secret/);
|
||||
});
|
||||
|
||||
// Row 264a (#1769, sage-2591, duckworth-0124 C4): a claim is held per session, so one thread with two
|
||||
// held sessions lists both runs, and releasing one leaves the other. A per-thread latest row would show
|
||||
// only the last claim.
|
||||
test('T6: two held sessions of one thread list as two rows, each its session\'s latest claim', (t) => {
|
||||
const { call, thread, claim } = setup(t);
|
||||
const H = thread('plan');
|
||||
const S = call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' }).id;
|
||||
const first = claim(H, 'run-default');
|
||||
claim(H, 'run-second', S);
|
||||
assert.deepEqual(call('human', 'thread.claims').map((r) => [r.thread, r.holder_run]), [[H, 'run-default'], [H, 'run-second']]);
|
||||
call(first, 'role.release');
|
||||
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-second'], 'the default released, the second held');
|
||||
});
|
||||
@@ -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 = '3k';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3l';", "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, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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 3k ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3l ready', ''].join('\n'));
|
||||
});
|
||||
@@ -7,6 +7,12 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, THREAD_PAGE } from '../src/broker.mjs';
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
'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=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
|
||||
];
|
||||
|
||||
// Row 121 (#1615): projects and threads in the bus. The assistant's messaging lists are the
|
||||
// shipped roles/assistant.json's, so the chain below runs under the lists the stack runs.
|
||||
@@ -81,32 +87,36 @@ test('REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the
|
||||
const { b, store, call, launch, claim, A, B } = twoThreads(t);
|
||||
launch('a2', 'assistant', { thread: A });
|
||||
assert.throws(() => claim('a2'), { code: 'thread-already-held' });
|
||||
// The trigger refuses a second claim that skips the broker.
|
||||
// The trigger refuses a second claim that skips the broker. Row 264a: the claim is of the
|
||||
// thread's default session, with the generation the trigger expects.
|
||||
const sessionOf = (thread) => store.get('SELECT session FROM session_defaults WHERE thread=?', thread).session;
|
||||
const second = () =>
|
||||
store.run(
|
||||
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
|
||||
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
|
||||
now(),
|
||||
'demo',
|
||||
A,
|
||||
sessionOf(A),
|
||||
'a2-run',
|
||||
'pi',
|
||||
'a2-run',
|
||||
);
|
||||
assert.throws(second, /thread already held/);
|
||||
// Second case: one run holds one thread. a-run already holds A.
|
||||
assert.throws(second, /session already held/);
|
||||
// Second case: one run holds one session. a-run already holds A's.
|
||||
call('b', 'role.release');
|
||||
assert.throws(
|
||||
() =>
|
||||
store.run(
|
||||
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
|
||||
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
|
||||
now(),
|
||||
'demo',
|
||||
B,
|
||||
sessionOf(B),
|
||||
'a-run',
|
||||
'pi',
|
||||
'a-run',
|
||||
),
|
||||
/run already holds a thread/,
|
||||
/run already holds a session/,
|
||||
);
|
||||
// A released thread can be claimed again by a new run; the claim is the thread's, not the role's.
|
||||
launch('b2', 'assistant', { thread: B });
|
||||
@@ -128,12 +138,20 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
|
||||
});
|
||||
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
// Row 264a (#1769): a claim row names its session and the generation thread_claim_generation
|
||||
// expects, so each refusal below comes from the trigger it names.
|
||||
const generation = (op) =>
|
||||
op === 'claim'
|
||||
? 1 + db.prepare("SELECT count(*) n FROM thread_claims WHERE session='s-1' AND op='claim'").get().n
|
||||
: (db.prepare("SELECT generation FROM thread_claims WHERE session='s-1' ORDER BY seq DESC LIMIT 1").get()?.generation ?? null);
|
||||
const claim = (op, run_, extra = {}) =>
|
||||
run(
|
||||
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
'INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
now(),
|
||||
extra.business ?? 'demo',
|
||||
extra.thread ?? 'th-1',
|
||||
's-1',
|
||||
generation(op),
|
||||
op,
|
||||
run_,
|
||||
'pi',
|
||||
@@ -143,9 +161,11 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
|
||||
);
|
||||
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
|
||||
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-1',?,'demo','p-1','assistant','jason','one')", now());
|
||||
run("INSERT INTO thread_opens(at,thread,by) VALUES(?,'th-1','jason')", now());
|
||||
run("INSERT INTO sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-1',?,'demo','th-1','assistant','jason','default','default')", now());
|
||||
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
|
||||
run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
|
||||
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
|
||||
run("INSERT INTO message_threads(message,thread) VALUES('m-1','th-1')");
|
||||
run("INSERT INTO message_threads(message,thread,session) VALUES('m-1','th-1','s-1')");
|
||||
// A thread is in its project's business.
|
||||
assert.throws(
|
||||
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-x',?,'other','p-1','assistant','jason','x')", now()),
|
||||
@@ -154,20 +174,22 @@ test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broke
|
||||
// A claim names the thread's business.
|
||||
assert.throws(() => claim('claim', 'a-run', { business: 'other' }), /a thread claim names the thread's business/);
|
||||
// Nothing releases or revokes a thread nobody holds.
|
||||
assert.throws(() => claim('release', 'a-run'), /thread is not held/);
|
||||
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /thread is not held/);
|
||||
assert.throws(() => claim('release', 'a-run'), /session is not held/);
|
||||
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /session is not held/);
|
||||
claim('claim', 'a-run');
|
||||
// Only the holder releases; a revoke needs a decision or a reason.
|
||||
assert.throws(() => claim('release', 'b-run'), /only the holder releases; others revoke/);
|
||||
assert.throws(() => claim('revoke', 'a-run'), /revoke needs a resolved decision or a reason/);
|
||||
claim('revoke', 'a-run', { reason: 'stuck' });
|
||||
// Every 3e table is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
|
||||
// Every 3e table, and row 264a's two, is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
|
||||
const rows = {
|
||||
projects: ["id='p-1'", 'name', "INSERT OR REPLACE INTO projects(seq,id,at,business,name,created_by) SELECT seq,id,at,business,name,created_by FROM projects WHERE id='p-1'"],
|
||||
threads: ["id='th-1'", 'name', "INSERT OR REPLACE INTO threads(seq,id,at,business,project,instance,owner,name) SELECT seq,id,at,business,project,instance,owner,name FROM threads WHERE id='th-1'"],
|
||||
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by) SELECT seq,at,business,thread,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
|
||||
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread) VALUES('m-1','th-1')"],
|
||||
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,by) SELECT seq,at,thread,by FROM thread_opens WHERE seq=1'],
|
||||
sessions: ["id='s-1'", 'name', "INSERT OR REPLACE INTO sessions(seq,id,at,business,thread,instance,owner,name,op) SELECT seq,id,at,business,thread,instance,owner,name,op FROM sessions WHERE id='s-1'"],
|
||||
session_defaults: ['seq=1', 'by', 'INSERT OR REPLACE INTO session_defaults(seq,at,thread,session,by) SELECT seq,at,thread,session,by FROM session_defaults WHERE seq=1'],
|
||||
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,session,generation,op,holder_run,harness,by) SELECT seq,at,business,thread,session,generation,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
|
||||
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread,session) VALUES('m-1','th-1','s-1')"],
|
||||
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,session,by FROM thread_opens WHERE seq=1'],
|
||||
};
|
||||
for (const [table, [where, col, replace]] of Object.entries(rows)) {
|
||||
const only = new RegExp(`${table} is append-only`);
|
||||
@@ -225,7 +247,7 @@ test('REQ-MSG-1: a message to or from the assistant names its thread, or is refu
|
||||
// The store refuses a thread on a message the instance is not party to.
|
||||
const pmOnly = call('pm', 'message.send', { to: 'coder', body: 'unrelated' });
|
||||
assert.throws(
|
||||
() => store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', pmOnly.id, A),
|
||||
() => store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,(SELECT session FROM session_defaults WHERE thread=?))', pmOnly.id, A, A),
|
||||
/to or from the thread's instance/,
|
||||
);
|
||||
void b;
|
||||
@@ -277,8 +299,10 @@ test('REQ-MSG-1: a reply can\'t carry one thread instance\'s thread to another t
|
||||
const helper = { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const { store, call, launch, claim, count } = setup(t, demo({ roles: { ...roles, helper } }));
|
||||
const project = call('human', 'project.create', { name: 'two instances' }).id;
|
||||
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']])
|
||||
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']]) {
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, now(), 'demo', project, instance, 'jason', id);
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, id);
|
||||
}
|
||||
launch('chief', 'chief');
|
||||
claim('chief');
|
||||
launch('a', 'assistant', { thread: 'th-a' });
|
||||
@@ -314,7 +338,7 @@ test('REQ-THREAD-1 REQ-USER-2: one thread\'s messages never reach another thread
|
||||
'pi',
|
||||
null,
|
||||
),
|
||||
/only to its thread's holder/,
|
||||
/only to its session's holder/,
|
||||
op,
|
||||
);
|
||||
// B's holder can't read A's message either.
|
||||
@@ -437,6 +461,7 @@ test('Q169 REQ-USER-2: assistant.stop ends the human\'s own assistant thread run
|
||||
'someone-else',
|
||||
'theirs',
|
||||
);
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, other);
|
||||
launch('o', 'assistant', { thread: other });
|
||||
claim('o');
|
||||
const last = (thread) => ({
|
||||
@@ -486,6 +511,7 @@ test('REQ-USER-2: the human verbs refuse another owner\'s thread', (t) => {
|
||||
'someone-else',
|
||||
'theirs',
|
||||
);
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, other);
|
||||
for (const [verb, args] of [
|
||||
['thread.open', { thread: other }],
|
||||
['thread.send', { thread: other, body: 'hi' }],
|
||||
|
||||
@@ -46,17 +46,29 @@ function snapshot(path, columns) {
|
||||
columns ??= Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
||||
.map(({ name }) => [name, db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name)]));
|
||||
const rows = Object.fromEntries(Object.entries(columns).map(([table, keys]) => [table,
|
||||
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY rowid`).all()
|
||||
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY ${table === 'sqlite_sequence' ? 'name' : 'rowid'}`).all()
|
||||
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
// Row 264a: the 3k→3l step fills sessions and session_defaults with INSERT ... SELECT, which gives
|
||||
// each a sqlite_sequence row (3k-3l.sql:50-52), at seq 1 for the one thread here. The compare after
|
||||
// the step leaves those two rows out and checks them exactly, so any other new row still fails.
|
||||
// thread_claims and thread_opens stay in the compare: the step carries their seq.
|
||||
// The step writes the thread_claims and thread_opens rows again, after instance_names', so the
|
||||
// snapshot reads sqlite_sequence by name, as account-migration.test.mjs does (row 126).
|
||||
const SEQ_3L = ['sessions', 'session_defaults'];
|
||||
function migrated3l(rows) {
|
||||
const left = rows.sqlite_sequence.filter(r => SEQ_3L.includes(r.name));
|
||||
assert.deepEqual(left.map(r => ({ ...r })), [{ name: 'session_defaults', seq: 1 }, { name: 'sessions', seq: 1 }]);
|
||||
return { ...rows, sqlite_sequence: rows.sqlite_sequence.filter(r => !SEQ_3L.includes(r.name)) };
|
||||
}
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
const { root, path, copy } = source3g(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
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, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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');
|
||||
@@ -64,7 +76,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
} finally { store.close(); }
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
|
||||
assert.deepEqual(readFileSync(copy), untouched);
|
||||
assert.deepEqual(snapshot(copy).rows, before.rows);
|
||||
new Store(root).close();
|
||||
|
||||
@@ -1084,6 +1084,8 @@ prints a token. A usage error exits 4 and a bus refusal 2.
|
||||
mosaic access list --business ID [--json]
|
||||
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
|
||||
mosaic access revoke --business ID --user ID [--grant GID] --revision N
|
||||
mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N
|
||||
mosaic access user remove ID --business ID --revision N
|
||||
```
|
||||
|
||||
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
|
||||
@@ -1107,6 +1109,14 @@ once. Removing a provider from `console.sso` takes effect only after a Console r
|
||||
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
|
||||
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
|
||||
|
||||
`user add` and `user remove` (row 193c, #1705) take the same proof, lock, revision check, file
|
||||
checks and receipt. `user add` takes one sign-in subject, `PROVIDER:SUB`, split at the first colon;
|
||||
the provider must be in `console.sso`. `user remove` refuses with `user-has-grants` while any grant,
|
||||
at any business, names the user: revoke those first. `--business` names the business whose bus
|
||||
takes the proof; the user list itself is instance-wide. Their receipt verbs are `user.add` and
|
||||
`user.remove`. The bus has no `access.reload` yet (FED R4). As with grant and revoke, the broker
|
||||
reads the file once per user request (`packages/bus/src/broker.mjs`), so the change acts at once.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
|
||||
// `mosaic access list|grant|revoke|user` (federation design section 3.5, FED R5, #414;
|
||||
// `user add|remove`, row 193c, #1705).
|
||||
// The human edits <configDir>/access.json here. Each write is checked against the
|
||||
// instance's context before it lands, bumps the revision by one, and appends one
|
||||
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
|
||||
@@ -18,6 +19,8 @@ export const ACCESS_USAGE = [
|
||||
"mosaic access list --business ID [--json]",
|
||||
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
|
||||
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
|
||||
"mosaic access user add ID --business ID --home local --subject PROVIDER:SUB --revision N",
|
||||
"mosaic access user remove ID --business ID --revision N",
|
||||
].join("\n");
|
||||
|
||||
// The help text. A grant's `instances` is the real limit: the granted instance
|
||||
@@ -33,6 +36,7 @@ list is the real limit: a collaborator's message drives a granted instance with
|
||||
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.
|
||||
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.
|
||||
@@ -206,7 +210,8 @@ export async function accessCommand(args, io, deps) {
|
||||
async function accessVerb(args, io, deps) {
|
||||
const parsed = parse(args);
|
||||
const sub = parsed.positional[0];
|
||||
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
|
||||
const shape = sub === "user" ? ["add", "remove"].includes(parsed.positional[1]) && parsed.positional.length === 3 : ["list", "grant", "revoke"].includes(sub) && parsed.positional.length === 1;
|
||||
if (!shape) throw new CliError(ACCESS_HELP, 4);
|
||||
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
|
||||
const business = parsed.values["--business"];
|
||||
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
|
||||
@@ -224,6 +229,7 @@ async function accessVerb(args, io, deps) {
|
||||
|
||||
const expected = parsed.values["--revision"];
|
||||
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
|
||||
if (sub === "user") return userVerb(parsed, io, deps, { business, file, context, expected });
|
||||
const user = parsed.values["--user"];
|
||||
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
@@ -278,6 +284,53 @@ async function accessVerb(args, io, deps) {
|
||||
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
|
||||
}
|
||||
|
||||
// `user add ID` and `user remove ID` (row 193c): the same lock, revision check, file checks,
|
||||
// atomic write and receipt as grant and revoke. add takes one subject, PROVIDER:SUB, split at the
|
||||
// first colon, so a sub may hold colons. remove refuses while any grant names the user, at any
|
||||
// business, so a user is never removed with live grants (the owner revokes first, as the help says).
|
||||
async function userVerb(parsed, io, deps, { business, file, context, expected }) {
|
||||
const [, action, user] = parsed.positional;
|
||||
let entry;
|
||||
if (action === "add") {
|
||||
const home = parsed.values["--home"];
|
||||
const subject = parsed.values["--subject"] ?? "";
|
||||
const colon = subject.indexOf(":");
|
||||
if (home === undefined || colon < 1 || colon === subject.length - 1) {
|
||||
throw new CliError(`user add needs --home and --subject PROVIDER:SUB\n${ACCESS_USAGE}`, 4);
|
||||
}
|
||||
entry = { id: user, home, subjects: [{ provider: subject.slice(0, colon), sub: subject.slice(colon + 1) }] };
|
||||
}
|
||||
const result = withLock(file, () => {
|
||||
const { document, revision } = readCurrent(file, context);
|
||||
if (Number(expected) !== revision) {
|
||||
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
|
||||
}
|
||||
let users;
|
||||
if (action === "add") users = [...document.users, entry];
|
||||
else {
|
||||
if (!document.users.some((u) => u.id === user)) throw new CliError(`refused: no user ${user} to remove`, 2);
|
||||
const held = document.grants.filter((g) => g.user === user).map((g) => g.id);
|
||||
if (held.length) throw new CliError(`refused: user-has-grants: ${user} still holds ${held.join(", ")}; revoke them first`, 2);
|
||||
users = document.users.filter((u) => u.id !== user);
|
||||
}
|
||||
const checked = checkAccess({ ...document, revision: revision + 1, users }, context);
|
||||
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
|
||||
return { checked, revision };
|
||||
}, deps.fs);
|
||||
|
||||
receipt(deps.system.dataRoot, {
|
||||
at: (deps.now ?? (() => new Date()))().toISOString(),
|
||||
verb: `user.${action}`,
|
||||
business,
|
||||
user,
|
||||
actor: "human",
|
||||
revisionBefore: result.revision,
|
||||
revisionAfter: result.checked.revision,
|
||||
grants: [],
|
||||
});
|
||||
io.stdout.write(`access user ${action}: ${user}, revision ${result.checked.revision}\n`);
|
||||
}
|
||||
|
||||
function formatList(doc) {
|
||||
const lines = [`access file revision ${doc.revision ?? "none"}`];
|
||||
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
|
||||
|
||||
@@ -312,3 +312,140 @@ test("the real human-cli, run from agent ancestry, refuses and writes nothing",
|
||||
assert.equal(readFileSync(file, "utf8"), before);
|
||||
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
|
||||
});
|
||||
|
||||
// Row 193c (#1705): `user add` and `user remove`. BUILD.md (agents/megavolt/work/queue-207) names
|
||||
// each test T1-T9 and the mutant it kills.
|
||||
const USER_ADD = (id, { subject = "authentik:bob-sub", home = "local", revision = "1" } = {}) => [
|
||||
"user", "add", id, "--business", "acme", "--home", home, "--subject", subject, "--revision", revision,
|
||||
];
|
||||
const USER_REMOVE = (id, revision = "1") => ["user", "remove", id, "--business", "acme", "--revision", revision];
|
||||
|
||||
test("T1 user add writes the user one revision up, prints it and appends a user.add receipt", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run(USER_ADD("bob", { subject: "authentik:bob:x" }), s.deps);
|
||||
assert.match(out, /access user add: bob, revision 2/);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.deepEqual(doc.users[1], { id: "bob", home: "local", subjects: [{ provider: "authentik", sub: "bob:x" }] });
|
||||
assert.deepEqual(doc.users[0], DOC.users[0]);
|
||||
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(lines.length, 1);
|
||||
assert.deepEqual(lines[0], {
|
||||
at: "2026-10-20T20:00:00.000Z", verb: "user.add", business: "acme", user: "bob", actor: "human",
|
||||
revisionBefore: 1, revisionAfter: 2, grants: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("T2 a bad user add refuses before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const usage = [
|
||||
["user", "add", "bob", "--business", "acme", "--subject", "authentik:bob-sub", "--revision", "1"],
|
||||
["user", "add", "bob", "--business", "acme", "--home", "local", "--revision", "1"],
|
||||
USER_ADD("bob", { subject: "authentik" }),
|
||||
USER_ADD("bob", { subject: ":bob-sub" }),
|
||||
USER_ADD("bob", { subject: "authentik:" }),
|
||||
];
|
||||
for (const args of usage) {
|
||||
const err = await refusal(run(args, s.deps), /user add needs --home and --subject PROVIDER:SUB/);
|
||||
assert.equal(err.exitCode, 4, args.join(" "));
|
||||
}
|
||||
const checked = [
|
||||
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
|
||||
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
|
||||
[USER_ADD("tester"), /a bus principal/],
|
||||
[USER_ADD("jason"), /a business's human/],
|
||||
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
|
||||
[USER_ADD("bob", { subject: "authentik:alice-sub" }), /appears more than once/],
|
||||
[USER_ADD("bob", { home: "peer-one" }), /home must be a configured peer/],
|
||||
];
|
||||
for (const [args, pattern] of checked) {
|
||||
const err = await refusal(run(args, s.deps), pattern);
|
||||
assert.equal(err.exitCode, 3, args.join(" "));
|
||||
}
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("T3 user remove of a user with no grant drops them one revision up, with a user.remove receipt", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run(USER_REMOVE("alice"), s.deps);
|
||||
assert.match(out, /access user remove: alice, revision 2/);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.deepEqual(doc.users, []);
|
||||
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(line.verb, "user.remove");
|
||||
assert.equal(line.user, "alice");
|
||||
assert.equal(line.revisionBefore, 1);
|
||||
assert.equal(line.revisionAfter, 2);
|
||||
assert.deepEqual(line.grants, []);
|
||||
});
|
||||
|
||||
// M1's second fence: without the user-has-grants refusal, checkAccess refuses the file with exit 3
|
||||
// (a grant naming a missing user). The test pins the reason and exit 2, so only the verb's refusal passes.
|
||||
test("T4 user remove refuses with user-has-grants while the user holds a grant, and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
const id = s.read().grants[0].id;
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run(USER_REMOVE("alice", "2"), s.deps), /user-has-grants: alice still holds /);
|
||||
assert.equal(err.exitCode, 2);
|
||||
assert.ok(err.message.includes(id));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts().trim().split("\n").length, 1);
|
||||
});
|
||||
|
||||
test("T5 user remove of an id not in the file refuses and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run(USER_REMOVE("carol"), s.deps), /no user carol to remove/);
|
||||
assert.equal(err.exitCode, 2);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("T6 a stale revision refuses both user verbs with revision-conflict", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
for (const args of [USER_ADD("bob", { revision: "2" }), USER_REMOVE("alice", "5")]) {
|
||||
const err = await refusal(run(args, s.deps), /revision-conflict/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
}
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("T7 --tester and an agent caller are refused on both user verbs before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
|
||||
const tester = await refusal(run([...args, "--tester"], s.deps), /not --tester/);
|
||||
assert.equal(tester.exitCode, 3);
|
||||
const agent = await refusal(run(args, s.deps, { ...humanEnv(), MOSAIC_RUN_ID: "run-1" }), /human shell/);
|
||||
assert.equal(agent.exitCode, 3);
|
||||
}
|
||||
assert.equal(existsSync(s.log), false);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("T8 a user verb of the wrong shape prints the help", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const shape of [["user"], ["user", "add"], ["user", "list", "x"], ["user", "add", "a", "b"]]) {
|
||||
const err = await refusal(run([...shape, "--business", "acme", "--revision", "1"], s.deps), /mosaic access user add ID/);
|
||||
assert.equal(err.exitCode, 4, shape.join(" "));
|
||||
}
|
||||
assert.equal(existsSync(s.log), false);
|
||||
});
|
||||
|
||||
test("T9 a bus that refuses the human proof blocks both user verbs", async (t) => {
|
||||
const s = setup(t, { mode: "refuse" });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
for (const args of [USER_ADD("bob"), USER_REMOVE("alice")]) {
|
||||
const err = await refusal(run(args, s.deps), /human-required/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
}
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
@@ -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','thread_opens','instance_names','usage_records'];
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','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()};
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ import { run } from "../src/cli.mjs";
|
||||
import { CliError } from "../src/errors.mjs";
|
||||
import { busExit } from "../src/transport.mjs";
|
||||
import { humanEnv, io } from "./helpers.mjs";
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
"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=?",
|
||||
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
|
||||
];
|
||||
|
||||
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), "utf8"));
|
||||
const roles = {
|
||||
@@ -185,6 +191,7 @@ test("REQ-CLI-1 a thread is its id, an id prefix of 8 characters or more, or <pr
|
||||
const at = new Date().toISOString();
|
||||
for (const n of ["1", "2"]) {
|
||||
s.store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", `abcdef01-0000-4000-8000-00000000000${n}`, at, "demo", s.project, "assistant", "jason", `twin${n}`);
|
||||
for (const sql of DEFAULT_SESSION) s.store.run(sql, `abcdef01-0000-4000-8000-00000000000${n}`);
|
||||
}
|
||||
const resolve = async (ref) => (await s.cli(["thread", "open", ref]));
|
||||
const opened = (r) => r.out.trim();
|
||||
@@ -322,6 +329,7 @@ test("REQ-USER-2 a thread of another user is refused for talk, open, read and fo
|
||||
const theirs = randomUUID();
|
||||
s.store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-theirs", at, "demo", "theirs", "stack", "someone-else");
|
||||
s.store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", theirs, at, "demo", "p-theirs", "assistant", "someone-else", "private");
|
||||
for (const sql of DEFAULT_SESSION) s.store.run(sql, theirs);
|
||||
// A message waiting for the user: message.receive would write a delivery row for it.
|
||||
s.call("chief", "message.send", { to: "human", body: "waiting", class: "INFO" });
|
||||
const before = s.writes();
|
||||
|
||||
@@ -27,6 +27,12 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
"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=?",
|
||||
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3l' FROM threads WHERE id=?",
|
||||
];
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -356,7 +362,8 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thre
|
||||
store.run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)", "p-hand", at(), "acme", "hand made", "stack", "jason");
|
||||
for (const [id, instance] of [["../evil", "assistant"], ["coder-thread", "coder"]]) {
|
||||
store.run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)", id, at(), "acme", "p-hand", instance, "jason", id);
|
||||
store.run("INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)", at(), id, "jason");
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, id);
|
||||
store.run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)", at(), id, id, "jason");
|
||||
}
|
||||
store.close();
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ import { createChat } from '../src/chat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
'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=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
|
||||
];
|
||||
|
||||
const P = '10000000-0000-4000-8000-000000000001';
|
||||
const T = '20000000-0000-4000-8000-000000000002';
|
||||
@@ -220,15 +226,17 @@ test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated histor
|
||||
const other = '40000000-0000-4000-8000-000000000004';
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
|
||||
other, new Date().toISOString(), 'demo', project, 'assistant', 'someone-else', 'PRIVATE-NAME');
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, other);
|
||||
store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
|
||||
M, new Date().toISOString(), 'demo', 'human', 'human-cli', 'assistant', 'INFO', 'PRIVATE-BODY');
|
||||
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', M, other);
|
||||
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', M, other, other);
|
||||
const foreignProject = '50000000-0000-4000-8000-000000000005';
|
||||
const foreignThread = '60000000-0000-4000-8000-000000000006';
|
||||
store.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)',
|
||||
foreignProject, new Date().toISOString(), 'foreign', 'FOREIGN-PROJECT', null, 'jason');
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
|
||||
foreignThread, new Date().toISOString(), 'foreign', foreignProject, 'assistant', 'jason', 'FOREIGN-THREAD');
|
||||
for (const sql of DEFAULT_SESSION) store.run(sql, foreignThread);
|
||||
assert.equal((await get('/api/chat/threads')).data.rows.length, 2);
|
||||
const before = ['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n);
|
||||
for (const id of [other, T, foreignThread]) for (const suffix of ['messages', 'open']) {
|
||||
|
||||
@@ -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 thread_opens instance_names usage_records"
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens instance_names usage_records"
|
||||
|
||||
STORE_JS='
|
||||
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
||||
|
||||
Reference in new issue
Block a user