Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
631a256ba4 | ||
|
|
16b9c6df6f | ||
|
|
aca16404cf | ||
|
|
f7e4ec3df5 | ||
|
|
b789188504 | ||
|
|
84f53540dd | ||
|
|
2cc34aef87 | ||
|
|
93f4af12bc | ||
|
|
75f049ed23 | ||
|
|
54f65f9abd | ||
|
|
7451878b66 | ||
|
|
16889a46be | ||
|
|
c3f43960eb | ||
|
|
0431778465 | ||
|
|
f3e9b6512c | ||
|
|
b7a05482bb | ||
|
|
4beb6433a4 | ||
|
|
fbfaab3655 | ||
|
|
9ff668550f | ||
|
|
5baaaa447b | ||
|
|
c4a12de3d8 | ||
|
|
72fc5a330b | ||
|
|
d6301dffce | ||
|
|
ae525c4095 | ||
|
|
b5630ca198 | ||
|
|
26380e3f0b | ||
|
|
e479521d3a | ||
|
|
7979fb41eb | ||
|
|
54ebc5d71c | ||
|
|
5e05e21794 | ||
|
|
6b6eb533e8 | ||
|
|
f9fcf5d7b3 | ||
|
|
c055dddf44 | ||
|
|
6d62ef04e0 | ||
|
|
4982fa0adf | ||
|
|
7c2c876319 | ||
|
|
7d5f16ef95 | ||
|
|
a7b4f6785c | ||
|
|
938378455d | ||
|
|
5f4cd2faa6 | ||
|
|
6faa0f1479 | ||
|
|
9867a6f322 | ||
|
|
15ab01c0c3 |
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,132 @@
|
||||
# Row 206 build notes (Launchpad): 193b2, thread shares (#1704)
|
||||
|
||||
Brief: docs/plans/2026-10-20_human-users-build.md, "Brief: 193b2, thread shares (schema 3l)"
|
||||
(blob de3a6310b). Design: agents/morgana/work/queue-193/DESIGN.md at e2d161e2e (sha256 dd7fcb41…),
|
||||
sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789.
|
||||
|
||||
## Base and chain
|
||||
|
||||
- Started on aca16404c (sage-2789) on branch launchpad/row206.
|
||||
- 206 chains on 275 (193b1b, viewer verbs and board refusals), which Morgana is building. I rebase
|
||||
onto Morgana's 275 sha when it comes, and onto 253's landing under a new branch name.
|
||||
- At this base a user binds only with a collaborator grant (`#userGrants`, USER_LEVEL). Viewer
|
||||
sessions arrive with 275, so the viewer column of design 4.3 is not testable here (sage-2818
|
||||
Q4: the viewer column waits on the 275 rebase; 275 moves once more for Duckworth's B1,
|
||||
duckworth-0166). On the 275
|
||||
rebase, USER_VERBS and the viewer gate merge with the three share verbs, and the share verbs
|
||||
must refuse a viewer (design 4.3: share, unshare and shares are "refused" for viewers).
|
||||
|
||||
- The live `--migrate` (3l→3m on the running bus) is not part of this build. It is Mos's go under
|
||||
Q173, with a backup first and a STATE report after (mos-1310 item 4).
|
||||
|
||||
## What changed
|
||||
|
||||
- Schema 3l→3m. `thread_shares` as design 5.2, its index and three append-only triggers, in
|
||||
schema.sql and in the new step `src/migrations/3l-3m.sql` with the same SQL text. SCHEMA_SHA256
|
||||
d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9, SCHEMA_VERSION 3m.
|
||||
`tests/fixtures/schema-3l.sql` is the 3l schema.sql (sha256 cd68512212ce…).
|
||||
- `thread_shares` joins `timedTables` (store.mjs), the broker's clock list, `COUNTED` in
|
||||
scripts/stack-release.sh and `LISTED` in stack-release.test (T46). It has `at`, so it goes in
|
||||
the counted list, not the untimed one.
|
||||
- broker.mjs:
|
||||
- `#readable(s, t)` holds rules 1-4 of design 5.4. `#shared` reads the latest row per thread and
|
||||
target. `#covered` reads the access document once per call and covers nothing on a read
|
||||
failure, as `#userGrants` does.
|
||||
- `#threadRead` admits the owner (with `#grantedThread`) or `#readable`, and serves only
|
||||
`thread.messages`. `#thread` keeps the owner check for every write verb.
|
||||
- `thread.share`, `thread.unshare`, `thread.shares` (design 5.3). `thread.list {shared:true}`
|
||||
adds `owner`; the plain list keeps its shape (5.5).
|
||||
- `replies` leaves out user thread messages except those in threads `#readable` admits.
|
||||
`trail` needed no change: it already filters through `#hiddenMessage`, which calls `#readable`.
|
||||
- USER_VERBS gains the three share verbs. `thread.shares` joins `#processingGate`'s read list.
|
||||
- packages/bus/README.md: the verb rows, a "Thread shares" section, and the schema line (v3m, the
|
||||
new sha, 23 tables, 90 triggers). schema.test counts 89 triggers because it drops one first.
|
||||
|
||||
## Choices and departures (for Sage and Duckworth)
|
||||
|
||||
1. Label 3l→3m, not 3i-3j (design) or 3l (brief title). 264a took 3k-3l, and labels go by
|
||||
landing order (sage-2789).
|
||||
2. A share covers every session of the thread, and `session.list` stays owner-only (sage-2818
|
||||
Q1, from Sage's Q7 ruling, agents/sage/work/queue-264/RULINGS.md:72-77). broker.mjs keeps its
|
||||
"no shares (Q7)" refusal: a sharee reads any session it can name but can't list them. A test
|
||||
holds the refusal (human `thread-not-owned`, reader `read-only`, user `access-refused`).
|
||||
**Follow-up**, bound to whichever of row 193 or the share wiring lands second: wire the sharee's
|
||||
`session.list`. The bar is T1 sessions line 7 and REQ-USER-4 (candidate PRD 1509c7cc5,
|
||||
docs/prd/mosaic-stack.md:1027-1052): read-only, and it needs current grants, the thread
|
||||
owner's included.
|
||||
3. A reader session matches shares as the business human (sage-2818 Q2: accepted, for the human
|
||||
principal only). Design 4.3 says reader sessions "stay owner-only", and 5.5 says "messages in
|
||||
other users' unshared threads hidden" for human, reader and tester. The reader sees what the
|
||||
owner sees, shares to the owner included. Q2 also found a gap in the first tip (16b9c6df6): an
|
||||
agent session has no owner, but it matched a `*` share, so a shared user message showed in an
|
||||
agent's trail. `#readable` now returns false unless the session is the business human, a
|
||||
reader or a user. A test binds an assistant run to the shared thread and a chief run with no
|
||||
thread, and checks that neither (nor the tester) sees the message after a `*` share and a share
|
||||
to the owner.
|
||||
4. Access is read per request (sage-2818 Q3). Design 5.6's "30 s timer" and `access.reload` have
|
||||
no path at this base: runtime.mjs:263 passes `access: () => loadAccess(file, context)`, read on
|
||||
every request. The tests cover the per-request read (removed, expired, out of scope and
|
||||
unreadable), which is the stricter case.
|
||||
5. Rule 3 asks the owner's grant for any level, like rule 2 (design: "still has a live grant
|
||||
covering t"). A user owner lowered to viewer keeps the thread readable to its sharees.
|
||||
6. An unshare checks no coverage, so an owner can end a share whose target lost its grant.
|
||||
7. `thread.shares` for a user goes through `#grantedThread`, so it needs the collaborator grant
|
||||
to still cover the thread, like share and unshare. Design 4.3 says "own threads" with no grant
|
||||
note; at this base every user is a collaborator, so the two agree until 275.
|
||||
|
||||
## Tests
|
||||
|
||||
New: tests/thread-shares.test.mjs, 15 tests: the read flow and the write refusals, `*`, rule 2,
|
||||
rule 3 (with trail for human and reader), latest-row state, share-not-covered, target checks,
|
||||
lost owner coverage, tester and reader refusals (18 `action.refused` events, no rows), trail and
|
||||
replies, sessions, the sharee's `session.list` refusal (Q1), agents and the tester matching no
|
||||
share (Q2), the 3l→3m migration and the 3l engine's refusal of a 3m store.
|
||||
|
||||
Pins moved 3l→3m: account-, chief-of-staff, comment-, gitea-, grant-, name-, spec-, usage- and
|
||||
thread-migration, sessions, schema (23 tables, 89 triggers) and user-principal (USER_VERBS).
|
||||
The engine copies in account-, gitea-, grant- and comment-migration gain 3l-3m.sql;
|
||||
comment-migration's future step becomes 3m→3n. `later3l` in account-, gitea- and grant-migration
|
||||
also leaves out `thread_shares` objects, and the gitea and grant `rows()` lists leave out the
|
||||
table, since each compares a store migrated through 3m against an older one.
|
||||
|
||||
## Mutants (design section 8 numbers)
|
||||
|
||||
Each runs on a copy of the candidate tree with one edit, under `timeout -k 10 300`. The named
|
||||
test must fail on an assertion; a thrown error or a refusal before the assertion doesn't count.
|
||||
|
||||
- M2. In `#readable`, delete the rule 2 line (`if (s.user && !this.#covered(s.user, ...)) return
|
||||
false;`). Killed by "mutant 2: a share never widens a grant" (bob reads after his grant moves to
|
||||
p2) and by the `*` test (carol reads).
|
||||
- M3. In `#readable`, delete the rule 3 line. Killed by "mutant 3" (the human reads A after
|
||||
alice's grant is removed).
|
||||
- M4. In `#shared`, replace the latest-row lookup with any share row:
|
||||
`SELECT op FROM thread_shares WHERE thread=? AND target=? AND op='share' LIMIT 1`. Killed by
|
||||
"mutant 4" (bob still reads after the unshare).
|
||||
- M7. In `thread.send`, replace `this.#thread(s, a.thread)` with `this.#threadRead(s, a.thread)`.
|
||||
Killed by the first test (bob's send to A must refuse `thread-not-owned`).
|
||||
- M8. In `thread.share`, delete the `share-not-covered` line. Killed by "mutant 8" (the share
|
||||
to user:carol must refuse).
|
||||
- M9. In `thread.share`/`thread.unshare`, replace `this.#thread(s, a.thread)` with
|
||||
`this.#threadRow(s, a.thread)`. Killed by the first test (bob's share of A to `*` must refuse).
|
||||
- Q2 probe (not a design mutant). In `#readable`, delete the human-principal line
|
||||
(`if (!s.human && !s.reader && !s.user) return false;`). Killed by "only a human principal
|
||||
matches a share" (the agent's trail shows the message after the `*` share).
|
||||
|
||||
## Run limits
|
||||
|
||||
From 264's table (agents/gizmoduck/work/queue-264/BUILD.md, Run limits), each at least 3x the
|
||||
longest recorded run. A timeout with no failed test goes to Sage first; a timeout in the shell
|
||||
phase goes to Mos unplaced (mos-1275).
|
||||
|
||||
| Run | LIMIT |
|
||||
|---|---|
|
||||
| stack-release.test alone | 1800 s |
|
||||
| each other touched file alone | 300 s |
|
||||
| bus suite | 900 s |
|
||||
| cli suite | 1800 s |
|
||||
| each scripts/test-*.sh | 900 s |
|
||||
| each mutant probe | 300 s |
|
||||
|
||||
## Runs
|
||||
|
||||
None yet. Node and https git wait for Sage's CLEAR (Gander holds the slot for row 253 run 5).
|
||||
@@ -0,0 +1,931 @@
|
||||
# Row 281 build notes: a fire or a delete revokes the instance's grants (#1786)
|
||||
|
||||
Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d6 for Dewey's approval. No code until it is
|
||||
approved. The results sections are filled in after the runs on Sage's gate.
|
||||
|
||||
History: d1 dfebd16cc. d2 c746ce378 added Sage's Q1/Q2 rulings (sage-2573). d3 36c2d98b8 added the
|
||||
approved-before-fire tests (sage-2574). d4 answers Dewey's CHANGES on d3 (dewey-0823):
|
||||
- B1: the set takes every grant decision with no outcome, and a raise refuses a fired or firing agent.
|
||||
- B2: step G runs detached from the verb, so neither caller waits on the network.
|
||||
- B3: one step G per agent, with named refusals for everything that could race it.
|
||||
- N1-N3 are folded in.
|
||||
|
||||
d5 adds sage-2575: two requests per granted delete, with its five conditions. It also adds a `job` id to every
|
||||
answer, event and message, and tests T3, T3b and T3c with mutants M16-M18.
|
||||
|
||||
d6 answers Dewey's CHANGES on d5 (dewey-0825):
|
||||
- C1: the job's step C records authority without the chief's claim (`#authorityFor`, as a landing's record
|
||||
does), processing-halted doesn't hold the job, and a step B or C failure records `fire.failed {job, step,
|
||||
code}` with its own message. Tests T11 and T12, mutants M19 and M20.
|
||||
- N4: boot closes a job a restart interrupted, with one message. Test T13, mutant M21.
|
||||
- N5: section 7 names the refusals the manual verbs gain.
|
||||
- N6: the set counts Q1 leftover lines, in fire step 3 and in the delete text.
|
||||
|
||||
## Sources
|
||||
|
||||
- Brief: `docs/plans/2026-10-22_fire-revokes-grants.md`, "Brief: no grant outlives its instance" (sage-2568,
|
||||
mos-1271, Vikunja 524).
|
||||
- Code read at 47a35bf43: `packages/bus/src/broker.mjs`, `runtime.mjs`, `credentials.mjs`, `host-grant.mjs`,
|
||||
`gitea-grant.mjs`, `packages/business/src/instance-settings.mjs`, `tools/host/mosaic-keys-write`.
|
||||
- Row 256's delete (honker/row256-r2 at 1e761962a, not landed) changes only the delete's checks. Its write still
|
||||
goes through `lifecycleDelete` and the same `revoke()`, so this design covers it unchanged.
|
||||
- Row 235 r6 section 6 (host revoke) and #436b r5 section 6 (Gitea revoke and the fire steps).
|
||||
|
||||
## 1. Why: what happens today (read from the code, to be pinned by a test before the change)
|
||||
|
||||
In short: today a delete of a granted instance unlinks the key files but leaves both grant records, the host
|
||||
lines and the Gitea token live, and a fire refuses `credential-revoke-failed` (sage-2573 asked for this here
|
||||
and on #1786). The details:
|
||||
|
||||
- **Fire.** `revoke()` (runtime.mjs:151) revokes only the roles entry's `credentials` services, then lists the
|
||||
agent's store directory and refuses `credential-revoke-failed` if any file is left. A host grant's key is
|
||||
`<store>/<business>/<agent>/host.<id>` (host-grant.mjs:102), and a Gitea grant's token is `.../gitea`
|
||||
(gitea-grant.mjs:379). Neither is a roles-entry service, so a fire of an instance with a grant refuses at
|
||||
step B with "host.<id> still in the store". It doesn't leak, but it can't finish until a human runs
|
||||
`mosaic hosts revoke` or `mosaic grants revoke` by hand. If the business file also names `gitea` as a
|
||||
roles-entry credential, `credentials.revoke(instance, 'gitea')` unlinks the granted token file with no
|
||||
`gitea_grants` change and no Gitea call.
|
||||
- **Delete.** `credentials.remove(instance)` (credentials.mjs:400) revokes every registered reference,
|
||||
`host.<id>` and `gitea` included, and unlinks the directory. The `host_grants` and `gitea_grants` records
|
||||
stay, the host line stays on each host, and the Gitea token, user and login stay live. This is the leak.
|
||||
The next start then finds a grant record whose key file is gone. What the start does with that isn't read
|
||||
yet. The T1 test below pins it.
|
||||
- **An open grant decision.** A fire delists the instance but keeps its roles entry. An approved host or Gitea
|
||||
grant decision for that agent that hasn't run yet still passes `#hostDefinition` and `#giteaRole`, so it can
|
||||
still apply after the fire. A delete removes the roles entry, so its executor refuses `unknown-role` or
|
||||
`gitea-not-declared`.
|
||||
- **A raise after the fire** (dewey-0823 B1). `#raiseGrant` (broker.mjs:1529) checks only that the caller is
|
||||
the chief, and the grant bindings check only the roles entry. So the chief can raise a new grant for a fired
|
||||
agent, and if the human approves it, it applies.
|
||||
|
||||
## 2. Where the grant revokes sit (D1, rewritten in d4 for B2)
|
||||
|
||||
d3 awaited step G inside the verb. Dewey's B2 showed that neither caller can wait that long:
|
||||
|
||||
- **Delete.** The launcher's `host.op` waits `REQUEST_TIMEOUT_MS = 10000` (cli/src/host.mjs:31). On a timeout,
|
||||
`breakChannel` stops the whole bus host. One SSH remove can take 30 s (host-grant.mjs:231, :311), and each
|
||||
Gitea call 15 s (`CALL_MS`, gitea-grant.mjs:38).
|
||||
- **Fire.** server.mjs answers a verb that isn't on its async list synchronously (:154), and the async list
|
||||
gives 120 s (:150). The chief's runner client times out at 30 s (harness/src/runner.mjs:449). Two hosts
|
||||
plus the Gitea calls can pass both limits.
|
||||
|
||||
So step G never runs inside a verb. The fire and the delete both use the same answer-at-once job, for the same
|
||||
timeout reason (sage-2575 condition 5). Both verbs stay synchronous and answer at once. Step G runs as tracked
|
||||
broker work after the answer, as row 236's grant executors do (`#track`, `setImmediate`). No caller waits on
|
||||
the network, so `server.mjs`, `process.mjs`, `cli/src/host.mjs` and the launcher don't change.
|
||||
|
||||
Each job gets an id, `job` (a UUID), when it starts. The verb's answer, the job's start event, its end event
|
||||
and every inbox message about it carry that id. The human and the chief can then match a later outcome to
|
||||
the request that started it.
|
||||
|
||||
### The fire
|
||||
|
||||
1. `write('instance.fireCheck')`, as today.
|
||||
2. Step A, as today, with one addition. In the same transaction, after `fire.stopped`, the grant set is read
|
||||
(section 3). Reading it there means a grant raise either committed before step A, and is in the set, or
|
||||
comes after `fire.stopped` and refuses (section 3d).
|
||||
3. If the set is empty, steps B and C run synchronously, exactly as today. A fire with no grant answers
|
||||
today's result with today's events (T7). The set counts everything step G would touch (d6, N6): grant
|
||||
rows, open decisions, the agent's Gitea leftovers (section 5) and its Q1 leftover host lines (section 5).
|
||||
Any of them makes the set non-empty and goes to the job.
|
||||
4. Otherwise the verb takes the agent's step G lock (section 3e), records `fire.revoking {job, grants}` in
|
||||
step A's transaction, starts the job, and answers at once:
|
||||
`{...stopped, job, revoking: ['host:<id>' | 'gitea', ...], done: false}`. There's no `transaction`, since
|
||||
step B hasn't run.
|
||||
5. The job runs step G, then step B (`write('instance.fire')`) and step C (`#recordAuthority`, below, then
|
||||
`fire.done {job, grants}`). It releases the lock when it ends, whichever way it ends, `fire.failed`
|
||||
included.
|
||||
|
||||
What the chief sees meanwhile: the instance is "firing". That's an existing state (`#firing`, broker.mjs:645):
|
||||
`fire.stopped` with no later `fire.done`. Starts already refuse `instance-firing`.
|
||||
- A repeat fire while the job runs refuses `fire-in-progress`, a new code.
|
||||
- A repeat after the job finishes takes the delisted path (webby-0094 N1) and answers done.
|
||||
- A repeat after a refused step G runs step G again (section 5).
|
||||
|
||||
The human sees the outcome either way: `fire.done` names the job and the grants, and a refusal sends the
|
||||
inbox message in section 4.
|
||||
|
||||
**Steps B and C in the job (d6, C1).** d4 left "step C without the chief's run" as a build-time check, and
|
||||
Dewey checked it: it fails.
|
||||
- `#consumeAuthority` calls `#checkAuthority` (broker.mjs:2852). That runs `#processingWork()` and
|
||||
`#agent(s)`, and `#agent` refuses `not-holder` once the chief's run no longer holds its claim (:749).
|
||||
- After the verb answers, the job can take a minute or more. If the chief's run ends meanwhile, step C would
|
||||
throw after step B had delisted. The instance would be left delisted with no `fire.done`.
|
||||
|
||||
So the verb does the full check, and the job only records:
|
||||
- The verb runs `#checkAuthority(s, action, authority)`, as today, before step A. The chief holds its claim
|
||||
then.
|
||||
- The job's step C runs `#authorityFor(s, action, authority)`, the check without the holder test. It then
|
||||
runs the `#spent` check and the `action.allowed` event, as `#consumeAuthority` does. That's the shape a
|
||||
landing's record already uses (`recordPublish`, broker.mjs:3174, row 137): the run that asked held its
|
||||
claim when it asked, and may have ended since. It's one small private method, `#recordAuthority`, shared
|
||||
with nothing else.
|
||||
- The job keeps a copy of the session fields `#authorityFor` and `#event` read (business, role, thread,
|
||||
run). It doesn't keep the live session object.
|
||||
- `processing-halted` doesn't hold the job. A halt stops agent work. Step G only removes access, and steps
|
||||
B and C finish a decision the chief made while processing ran. Holding them would leave a delisted or
|
||||
half-revoked instance "firing" for the length of the halt. The verb itself still refuses during a halt,
|
||||
through `#checkAuthority`, as today.
|
||||
|
||||
**A step B or C failure in the job** gets its own record, not `grant.revoke.refused`, since every grant in the
|
||||
set was revoked by then:
|
||||
- It records `fire.failed {job, step: 'B' | 'C', code}`. `code` is the thrown code, for example the writer's
|
||||
`credential-revoke-failed`, or `decision-consumed`.
|
||||
- It sends an inbox message:
|
||||
|
||||
> Fire of <agent>: job <job> revoked its grants (<list>) but stopped at step <B|C> (<code>). The instance
|
||||
> is still firing. Run the fire again once <code> is cleared.
|
||||
|
||||
- The instance stays firing. A repeat fire finds an empty set and takes today's path for that state, with the
|
||||
chief's own claim: B and C after a step B failure (T12), the delisted path (webby-0094 N1) after a step C
|
||||
failure.
|
||||
|
||||
`grant.revoke.refused` with reason `internal` stays only for an unexpected throw inside step G.
|
||||
|
||||
### The delete (sage-2575)
|
||||
|
||||
`lifecycleDelete` (broker.mjs:3293) stays synchronous. After its running and starting checks, it reads the
|
||||
grant set.
|
||||
|
||||
- **No grant.** An empty set: `instances.set delete`, one request, exactly as today (condition 4, T3c).
|
||||
- **The first request.** For a non-empty set, it takes the lock and records `grants.revoking {job, agent,
|
||||
verb: 'lifecycle.delete', grants}`. It starts the job and refuses `grants-revoking` at once. The launcher
|
||||
closes the request with that code, well inside its 10 s.
|
||||
|
||||
The launcher can carry only the code, so the broker also sends the human an inbox message (condition 1):
|
||||
|
||||
> Delete of <agent> is waiting on its grants. Revoking started (job <job>) for: host:<id>, ..., gitea.
|
||||
> Raise the delete again when the inbox says the revoke is done.
|
||||
|
||||
- **A delete raised while the job runs.** It refuses `grants-revoking` again and starts no second job
|
||||
(condition 2, the 3e lock). No second message goes out either: the first one still applies.
|
||||
- **The job ends.** The human gets one inbox message (condition 3).
|
||||
- If it completed, the message names each grant revoked, with any `grantsLeft` (section 5), then "Raise the
|
||||
delete again." It records `grants.revoked.done {job, agent, verb, grants, grantsLeft}`.
|
||||
- If it refused, the message names the grant, the reason and how to clear it by hand, as section 4
|
||||
describes. It records `grant.revoke.refused {job, ...}`.
|
||||
- **The delete raised after a completed job** finds an empty set and goes as today.
|
||||
- **The delete raised after a refused job** doesn't complete over a live grant. Its set is non-empty, since
|
||||
the refused grant's record is still there. It refuses `grant-revoke-failed`, naming the earlier job's
|
||||
reason in a message:
|
||||
|
||||
> Delete of <agent> refused: job <job> couldn't revoke <grant> (<reason>). Retrying now as job <new>. Raise
|
||||
> the delete again when the inbox says it's done, or clear <grant> by hand first.
|
||||
|
||||
It starts a new job for what's left. The broker knows the last job refused because the agent's latest job
|
||||
end event is a `grant.revoke.refused` with verb `lifecycle.delete`, and no `grants.revoked.done` came after
|
||||
it. A delete after the human cleared the grant by hand doesn't find an empty set if a manual revoke left a
|
||||
line (Q1). The leftover line makes the set non-empty, so that delete starts a job for the line removal
|
||||
and refuses `grants-revoking` (d6, N6, and T3b's second case). If nothing is left, the set is empty and the
|
||||
delete goes on.
|
||||
|
||||
The tradeoff (ruled by sage-2575): deleting a granted instance now always takes two requests. That keeps the
|
||||
cli and the host channel out of row 281, and a delete of an instance with a grant is rare. Holding the
|
||||
launcher's request open past its 10 s would need a host channel change and a strand path, and polling would
|
||||
widen the row into the launcher for a one-time wait.
|
||||
|
||||
While a delete's step G runs, the instance is still listed and not firing, so it could be started. That's
|
||||
harmless: its grants are being revoked, and the repeat delete refuses `instance-running` as today. No new
|
||||
start check.
|
||||
|
||||
### Why not in `beforeWrite` or `revoke()`
|
||||
|
||||
Both are synchronous and run inside the organization writer, after its checks. A host `remove` is SSH and a
|
||||
Gitea revoke is HTTP, so they can't run there. The business package also has no reach into the broker's grant
|
||||
tables, and shouldn't get any.
|
||||
|
||||
### Why after step A
|
||||
|
||||
A fire is the decision to stop the agent. If step G came first, a host that can't be reached would keep the
|
||||
fired agent running. After step A, a refused step G leaves the same state as a failed step B today: the
|
||||
instance is "firing" (stopped, still listed) until a repeat. Row 194 already handles that state. The repeat
|
||||
fire's delisted path (webby-0094 N1) also runs step G, since the set is read before step B on both paths.
|
||||
|
||||
## 3. Step G: what it revokes, and how
|
||||
|
||||
The set, for (business, agent), is read in one store transaction: step A's for a fire, the delete's checks
|
||||
for a delete (section 2). It holds:
|
||||
|
||||
- each `host_grants` row of the agent;
|
||||
- each host named by an open host grant decision for the agent;
|
||||
- the Gitea grant if the agent has a `gitea_grants` row, a `gitea_users` row, or an open Gitea grant decision.
|
||||
|
||||
An open decision (d4, B1) is any grant decision raised for the agent with no outcome yet. That is: not denied,
|
||||
not expired, no `grant.applied` or `grant.failed`, and no `grant.revoked` of the same (agent, host), or of the
|
||||
agent's Gitea, recorded after its raise. A raised decision the human hasn't decided is open. d3 required
|
||||
"approved", which left that one out.
|
||||
|
||||
An empty set means no step G and no new records. A fire of an instance with no grant is then exactly today's
|
||||
fire, with no new events (brief tests, fourth bullet).
|
||||
|
||||
### 3a. A host grant: the host line first (D2)
|
||||
|
||||
hosts.revoke goes local first: the record drops, the key leaves memory and its file, and only then is
|
||||
`remove <user> <tag>` sent. If the host can't be reached, the revoke closes with `hostLineLeft` and the
|
||||
record is already gone. The brief says a fire may not drop a grant record while its key could still be on the
|
||||
host. So the fire's host revoke reverses one step and keeps the rest the same:
|
||||
|
||||
1. Abort an executor in flight for (agent, host), with no record change (`hostKeys.abortClaim`, as the
|
||||
`credential-unknown` branch of `#revokeHost` already does).
|
||||
2. `remove <user> <tag>` through the installer, pinned to the host record's keys. `mosaic-keys-write` says
|
||||
"a missing tag changes nothing", so this is safe to repeat and safe for a host named only by an open
|
||||
decision. If the host record is gone, or the remove doesn't exit 0, step G refuses for this host
|
||||
(section 4). Nothing local has changed for this grant: the record, the key file and the memory copy stay.
|
||||
3. hosts.revoke's step 1 transaction, unchanged: the `host_grants` row goes and `grant.revoked {revoke, agent,
|
||||
host, tag, hostLine: 'pending', granted, reason, by}` is recorded, plus `fire: true` (or `delete: true`)
|
||||
so the human can tell it from a manual revoke.
|
||||
4. hosts.revoke's step 2, unchanged: `hostKeys.revoke`, so the key leaves memory and its file goes.
|
||||
5. `grant.revoke.closed {hostLine: 'removed'}` with no second remove. Step 2 already did it.
|
||||
|
||||
This is one shared method, `#revokeHostGrant(business, agent, host, reason, by, { lineFirst })`.
|
||||
hosts.revoke calls it with `lineFirst: false` and its behavior and records don't change (brief item 4). The
|
||||
fire and the delete call it with `lineFirst: true`.
|
||||
|
||||
The tradeoff: hosts.revoke goes local first so that "the part that matters can't wait on the network" (r6
|
||||
section 6). For a fire, step A has already stopped the agent's runs, and a refused fire keeps the instance
|
||||
"firing". So waiting on the host costs nothing in exposure, and the record never lies about the host.
|
||||
|
||||
### 3b. The Gitea grant (D3)
|
||||
|
||||
The Gitea revoke stays grants.revoke's steps with `fire: true`, which is what `credentials delete` already
|
||||
runs (#436b section 6). Step 1 takes the token out of memory and unlinks the file. Step 2 is
|
||||
`#giteaRevokeLocal(..., fire: true)`. Step 3 is `#giteaCloseRevoke`: every token deleted and
|
||||
`prohibit_login: true`. The fire awaits step 3 instead of leaving it to `setImmediate`.
|
||||
|
||||
The Gitea side can't go remote first the way a host does. The record has to drop and `grant.revoked` has to
|
||||
name the tokens before the DELETE calls, or a restart in between would find a live record whose tokens are
|
||||
gone (section 5's reconcile). Two changes keep the fire closed:
|
||||
|
||||
- **Preflight.** Before step 1, `#giteaAdminChecked` runs. If the admin token can't be read or checked,
|
||||
step G refuses with nothing changed.
|
||||
- **Left means not done.** If `grant.revoke.closed` lists anything in `serviceLeft`, step G refuses. The
|
||||
token ids stay in the evidence, which is exactly what `mosaic grants sweep gitea` retries, so nothing
|
||||
forgets them. The record is gone, but no key of this stack's is still usable: the file and memory copy are
|
||||
gone, and only the Gitea side is left. That is the case the brief's host rule guards against, and it is
|
||||
kept visible here, not hidden.
|
||||
|
||||
**A Gitea executor in flight** (d3 T6c, and B3 seen from the executor's side). Step G's Gitea part goes in
|
||||
this order:
|
||||
1. Record `grant.revoked {granted: false}` for each open Gitea decision (3c).
|
||||
2. Await this agent's tracked Gitea executors and remote undos, if any are running. Each one ends `revoked` at
|
||||
its next `#giteaRevokedSince` check, or it committed `applied` before step 1.
|
||||
3. Read the `gitea_grants` row again and run the steps above.
|
||||
|
||||
`#giteaCloseRevoke` with `fire: true` lists the user's tokens and deletes every one. The list comes after
|
||||
step 2, so it includes any token an in-flight executor created. `#giteaRuns` is a single Set today. It
|
||||
becomes keyed by (business, agent) so step G can await one agent's executors alone.
|
||||
|
||||
### 3c. Open decisions
|
||||
|
||||
A host named only by an open decision has no record. Step 3a still runs `remove` (a no-op) and records
|
||||
`grant.revoked {granted: false}`, as hosts.revoke does for a revoke with no record (N4). The executor's
|
||||
`#revokedSince` then ends that decision `revoked`. The Gitea side is the same through
|
||||
`#giteaRevokedSince`.
|
||||
|
||||
This is the case where a decision is approved before the fire and executed after it (sage-2574). Both checks
|
||||
look for a `grant.revoked` of the agent recorded after the decision was raised (broker.mjs:1156 and :1678 at
|
||||
47a35bf43). A fire's `grant.revoked` is always later than the approval, so the executor ends the decision
|
||||
whenever it runs. The fired agent keeps its roles entry, so `#hostDefinition` and `#giteaRole` still pass, and
|
||||
this check is the only thing that stops the decision. T6 and T6b test it. A delete also removes the roles
|
||||
entry, so the executor refuses there as well.
|
||||
|
||||
The same holds for a decision raised but not yet approved (B1). It's in the set, so it gets its
|
||||
`grant.revoked {granted: false}`. If the human approves it later, its executor ends `revoked` (T6d). The
|
||||
decision stays approvable in the inbox. Approving it does nothing but end it, which `grant.failed` reports as
|
||||
it does for any revoked grant.
|
||||
|
||||
### 3d. A raise for a fired agent (d4, B1)
|
||||
|
||||
`#hostGrantBinding` and `#giteaGrantBinding` both run inside the raise transaction (broker.mjs:1539 and
|
||||
:1548; the host raise's keyscan is awaited before that transaction). Both gain one check. The raise refuses
|
||||
`instance-fired` if the agent is:
|
||||
- firing (`#firing`);
|
||||
- fired, by the writer's own rule (instance-settings.mjs, hire): a roles entry, not in `launch.instances`,
|
||||
and not `launch.by`.
|
||||
|
||||
A rehire lists the name again, so a rehired agent can be granted again.
|
||||
|
||||
Because the check is in the transaction that records the decision, there's no gap. A raise that commits
|
||||
before step A's transaction is in the set. One that commits after it refuses. A raise for an agent whose
|
||||
delete's step G is running refuses `grant-revoke-running` (3e).
|
||||
|
||||
No executor recheck is added. A raise can't commit after `fire.stopped`, and every decision raised before it
|
||||
is in the set and gets its `grant.revoked`.
|
||||
|
||||
### 3e. One step G per agent (d4, B3)
|
||||
|
||||
The broker keeps `#revoking`, a map from (business, agent) to the verb whose step G is running. The verb sets
|
||||
it synchronously, in the same turn as the set read, before it answers. The job clears it in a `finally`.
|
||||
While it's set:
|
||||
|
||||
| Caller | Answer |
|
||||
|---|---|
|
||||
| `instance.fire` | refuses `fire-in-progress` |
|
||||
| `lifecycleDelete` | refuses `grants-revoking`, no second job |
|
||||
| `hosts.revoke` / `grants.revoke` for the agent | refuse `grant-revoke-running` |
|
||||
| grant raise for the agent | refuses `grant-revoke-running` |
|
||||
| `grants.sweep` | skips this agent's entries and names it as `running` in its result |
|
||||
|
||||
Two fires started together (the chief retrying after a client timeout): the first takes the lock before it
|
||||
answers, and the second refuses. The map is in memory. After a broker restart the job is gone and so is the
|
||||
lock. The instance is still firing, or the delete still has a non-empty set, and the repeat starts a fresh
|
||||
step G (section 5).
|
||||
|
||||
A manual revoke can't race step G, since it refuses while the lock is held. Step G's transactions still
|
||||
check what they read: a `host_grants` row already gone at 3a step 3 records `grant.revoked {granted: false}`
|
||||
and goes on, as hosts.revoke does today for a revoke with no record.
|
||||
|
||||
## 4. A refusal (D4)
|
||||
|
||||
Step G stops at the first grant that can't be revoked. Grants already revoked in this step G stay revoked and
|
||||
recorded, and the rest stay as they were. The job doesn't run step B: the organization file is unchanged, and
|
||||
the credentials are unrevoked, since `revoke()` never ran. A fire stays "firing". A delete's request was
|
||||
already closed with `grants-revoking` (section 2).
|
||||
|
||||
The verb has already answered, so the refusal is a record, not a thrown code:
|
||||
- One grant-evidence event, `grant.revoke.refused {job, agent, grant: 'host:<id>' | 'gitea', reason, verb:
|
||||
'instance.fire' | 'lifecycle.delete', by}`.
|
||||
- `reason` is one of `host-unknown`, `host-remove-failed`, `gitea-admin-unavailable` and `gitea-left:<ids>`.
|
||||
- `grant-revoke-failed` is the code in the event and in the inbox message.
|
||||
|
||||
A message lands in the human's inbox, as `grant.failed` does today (broker.mjs:1139). It names the instance,
|
||||
the grant and the reason, with the manual way out beside the retry (N2):
|
||||
|
||||
> Fire of <agent> stopped: its grant on <host> couldn't be revoked (<reason>). Run the fire again once <host>
|
||||
> is reachable. If <host> won't come back, run `mosaic hosts revoke <agent> <host>` first: the grant record
|
||||
> goes, the line is named as left, and the next fire completes and names that line (section 5).
|
||||
|
||||
The delete's message has the same shape, with "raise the delete again". For Gitea, the way out is
|
||||
`mosaic grants sweep gitea` once Gitea is back.
|
||||
|
||||
A job that throws something unexpected inside step G records `grant.revoke.refused` with reason `internal`,
|
||||
sends the same message, and releases the lock. There's no unhandled rejection. A failure at step B or C is
|
||||
`fire.failed` (section 2).
|
||||
|
||||
**A restart mid-job (d6, N4).** The lock and the job are in memory, so a restart ends them with no end
|
||||
record. A fire would stay "firing" until the chief happened to repeat it. A delete's request is already
|
||||
closed, so no one would hear anything. Boot closes that gap:
|
||||
- A job is a start record (`fire.revoking` or `grants.revoking`) whose `job` has no end record. The end
|
||||
records are `fire.done`, `fire.failed`, `grants.revoked.done`, `grant.revoke.refused` and the new
|
||||
`grants.job.interrupted`.
|
||||
- When the broker starts, for each such job of each business, it records `grants.job.interrupted {job,
|
||||
agent, verb}` and sends the human one inbox message:
|
||||
|
||||
> <Fire|Delete> of <agent>: job <job> was interrupted by a bus restart. Some grants may be revoked. Run the
|
||||
> fire again, or raise the delete again. The repeat goes on from what's left.
|
||||
|
||||
- Recording the end record in the same transaction as the message means a second boot finds nothing to
|
||||
send.
|
||||
- Boot reads only events, with no network call, so it adds nothing to the start time beyond one query.
|
||||
|
||||
## 5. A repeat fire (D5)
|
||||
|
||||
A repeat runs the whole fire again: the checks, step A (nothing left to stop), the set read, then step G, B
|
||||
and C in the job. A repeat while the job runs refuses `fire-in-progress` (3e). A repeat delete works the same
|
||||
way through the human's new request.
|
||||
|
||||
- A host grant already revoked has no row and no open decision, so it isn't in the set. It's done.
|
||||
- A host grant that refused still has its row, so it's retried from 3a step 1.
|
||||
- Gitea (d4, N1): if the agent has entries in `#giteaLeft` or `#giteaOpenRevokes`, step G retries only those.
|
||||
It calls `#giteaCloseRevoke` for each, as `#sweepGitea` does per entry. It doesn't run the business-wide
|
||||
sweep, which would retry every agent's entries and settle every open rotation under the chief's name. Any
|
||||
entry still left for this agent refuses `gitea-left`. An entry left by an earlier manual
|
||||
`mosaic grants revoke` of the same agent blocks the fire the same way. That's intended: no grant outlives
|
||||
its instance, whichever verb started its revoke. These entries count toward a non-empty set (section 2,
|
||||
fire step 3), so they go to the job and never run inside the verb.
|
||||
- A host line left by an earlier manual `hosts.revoke` doesn't block the fire (Q1 below). These are the
|
||||
agent's `grant.revoke.closed` records with `hostLineLeft` and no later line removal for that tag. For each
|
||||
one whose host record still exists, step G sends `remove <user> <tag>` once. On success it records
|
||||
`grant.revoke.closed {revoke: <the old revoke id>, hostLine: 'removed', fire: true}`, so a later fire doesn't
|
||||
find it again. On failure it records nothing new and the fire goes on. The host and the tag go in
|
||||
`grantsLeft` (section 6). These leftover lines also make the set non-empty, so the retry runs in the job,
|
||||
inside B2's answer (dewey-0823 accepted the single retry on that condition).
|
||||
|
||||
## Rulings
|
||||
|
||||
- **Q1 (sage-2573):** a leftover line doesn't block the fire. If the host is reachable, the fire tries the
|
||||
removal once, because a fired agent's key line still on a host is the exact risk this row closes. If that
|
||||
try fails, the fire doesn't refuse. It names the host and the leftover line in the result and in the event
|
||||
the human sees. The human accepted that residue when their manual `hosts.revoke` left it, so it shouldn't
|
||||
stop a fire forever on a dead host. Dewey may push back on the retry if it widens step G more than it's
|
||||
worth.
|
||||
- **Q2 (sage-2573):** a delete refused at step G closes its request through the launcher's normal refusal
|
||||
path, and the human raises it again. A request left taken would strand, which is the shape DEFERRED already
|
||||
records for the >64-stops done. Conditions: the refusal names the instance, the grant and the reason (the
|
||||
`grant.revoke.refused` event, section 4). The repeat delete skips grants already revoked, as the repeat fire
|
||||
does. T3b tests both.
|
||||
- **Q2 extended (sage-2575):** every delete of a granted instance takes two requests. The first starts the
|
||||
revoke job and refuses `grants-revoking`, and the human raises the delete again when the inbox says it's
|
||||
done. This keeps the cli and the host channel out of the row, and such a delete is rare. Conditions, each
|
||||
with a test:
|
||||
1. The first refusal tells the human what happened and what's next: the named grants, and to raise the
|
||||
delete again when the inbox says done. A bare code isn't enough (T3).
|
||||
2. A delete raised while the job runs refuses `grants-revoking` again and starts no second job (T3, T9).
|
||||
3. The job's end message names each grant revoked. If one failed, it names the grant, the reason and the
|
||||
manual way out (`mosaic hosts revoke` or `mosaic grants revoke`). The next delete then refuses with that
|
||||
reason and doesn't complete over a live grant (T3b).
|
||||
4. A delete of an instance with no grant completes in one request, exactly as today (T3c).
|
||||
5. Section 2 says the fire uses the same answer-at-once job, and the fire's result and event name the job
|
||||
(T2).
|
||||
|
||||
## 6. The result and the event (D6)
|
||||
|
||||
- **A fire with a non-empty set** answers at once with `job`, `revoking` and `done: false`, and records
|
||||
`fire.revoking {job, grants}` (section 2).
|
||||
- **The job's outcome** is recorded, not returned. `fire.done` (step C) carries `job` and `grants: [{grant:
|
||||
'host:<id>' | 'gitea', revoke, granted}]`, plus `grantsLeft` for any leftover from section 5's last bullet.
|
||||
So the event the human sees names them.
|
||||
- **A delete's job** records `grants.revoking {job, ...}` at the start and `grants.revoked.done {job, agent,
|
||||
verb: 'lifecycle.delete', grants, grantsLeft}` at the end. It sends the inbox messages in section 2.
|
||||
- **A fire with an empty set** carries none of these keys, so its shape is exactly today's.
|
||||
|
||||
## 7. What doesn't change (brief item 4)
|
||||
|
||||
- `hosts.revoke`, `grants.revoke` and `grants.sweep`: same callers and the same records. Their refusals stay
|
||||
the same (`tester-dev-only` included) with one addition from section 3e (d6, N5): while a step G job holds
|
||||
the agent's lock, `hosts.revoke` and `grants.revoke` for that agent refuse `grant-revoke-running`, and
|
||||
`grants.sweep` skips the agent and names it as `running`. hosts.revoke's own order stays local first.
|
||||
- No new verb, no Console route, no CLI change, no schema change. Every new record is an `events` row in the
|
||||
existing grant-evidence shape.
|
||||
- `revoke()` in runtime.mjs and the organization writer don't change.
|
||||
- `server.mjs`, `process.mjs`, `cli/src/host.mjs` and `cli/src/launcher.mjs` don't change. Both verbs stay
|
||||
synchronous. The launcher already closes a request with whatever code `lifecycleDelete` refuses.
|
||||
- New codes: `fire-in-progress`, `grants-revoking`, `grant-revoke-running`, `instance-fired`, and
|
||||
`grant-revoke-failed` in events and messages. New event types: `fire.revoking`, `grants.revoking`,
|
||||
`grants.revoked.done`, `grant.revoke.refused`, `fire.failed` and `grants.job.interrupted`.
|
||||
- The broker's start gains one events query for interrupted jobs (section 4, N4). `lifecycleDone` checks a refusal against `LIFECYCLE_CODE =
|
||||
/^[a-z-]{1,64}$/` (broker.mjs:149), and `grants-revoking` matches it.
|
||||
|
||||
## 8. Tests (each in the owning package, whole files)
|
||||
|
||||
The bus tests use the fake installer and the fake Gitea from rows 235 and 236. They wait on the job with a new
|
||||
trusted test API, `grantsIdle()`, which resolves once every step G job and Gitea executor has ended. It's the
|
||||
same shape as `giteaIdle()`. Unless a test says otherwise, every fire or delete below is awaited that way.
|
||||
|
||||
- T1 (pins section 1 before the change): a fire of an instance with a host grant refuses
|
||||
`credential-revoke-failed` today. A delete leaves the `host_grants` row. Then: what the next start does with
|
||||
a record whose key file is gone.
|
||||
- T2: a fire with a host grant on two hosts and a Gitea grant. The verb answers with a `job` id, `revoking`
|
||||
with all three, and `done: false`, and `fire.revoking` carries the same job (sage-2575, condition 5). After
|
||||
the job:
|
||||
- all three are revoked, each with `grant.revoked` and `grant.revoke.closed` as the manual verbs record
|
||||
them, plus `fire: true`;
|
||||
- the line is gone on both fake hosts, every token is deleted, and login is prohibited at the fake Gitea;
|
||||
- `fire.done` names all three;
|
||||
- that agent's `host.run` and Gitea use both refuse.
|
||||
- T3 (sage-2575, conditions 1-3): the same grants through the delete (`lifecycleTake` then
|
||||
`lifecycleDelete`).
|
||||
- The first delete refuses `grants-revoking` and starts the job. The human's inbox gets the start message
|
||||
naming the job and all three grants, and saying to raise the delete again when it's done.
|
||||
- A delete raised while the job is held refuses `grants-revoking`, starts no second job and sends no second
|
||||
message.
|
||||
- After the job, the end message names the job and each grant revoked.
|
||||
- A third request completes as today.
|
||||
- T3b (Q2, sage-2575 condition 3): a delete whose job refuses (host B unreachable).
|
||||
- The end message and `grant.revoke.refused` name the instance, `host:B`, the reason and
|
||||
`mosaic hosts revoke`.
|
||||
- The file and the instance are unchanged.
|
||||
- The next delete refuses `grant-revoke-failed`, not `grants-revoking`. Its message names the earlier job's
|
||||
reason and the new job, and the instance is not deleted.
|
||||
- With B back, that retry job revokes only B and Gitea, skipping host A, and the delete after it completes.
|
||||
- A second case clears B by hand with `hosts.revoke` before the next delete. That delete starts a job only
|
||||
for Gitea and the leftover line.
|
||||
- T3c (sage-2575 condition 4): a delete of an instance with no grant completes in one request. Same result,
|
||||
same events counted, no inbox message.
|
||||
- T4: host B unreachable in a fire. The job records `grant.revoke.refused` naming the agent, `host:B` and
|
||||
`host-remove-failed`, and the inbox message names `mosaic hosts revoke` (N2). Afterwards:
|
||||
- the file is unchanged and the instance is still firing;
|
||||
- host A is revoked and recorded, and host B's row and key file are still there;
|
||||
- the Gitea grant is untouched, since it comes after B in the order;
|
||||
- the credentials are unrevoked.
|
||||
A repeat with B reachable completes and revokes only B and Gitea.
|
||||
- T4b (Q1): an earlier manual `hosts.revoke` left a line on host C. A fire with C reachable removes it and
|
||||
records the close. With C unreachable, the fire completes and names C and the tag in `grantsLeft` and in
|
||||
`fire.done`.
|
||||
- T5: Gitea's admin token can't be checked. The job refuses with the Gitea grant intact.
|
||||
- T5b: a token DELETE fails. The job refuses `gitea-left`. A repeat with Gitea back retries only this agent's
|
||||
entries and completes. Another agent's left entry is still left afterwards (N1).
|
||||
- T6 (sage-2574): a host grant decision approved before the fire, executed after it. The fire records
|
||||
`grant.revoked {granted: false}` for (agent, host). The executor runs after `fire.done` and ends the decision
|
||||
`revoked`: no `host_grants` row, no key file, no `add` sent to the host.
|
||||
- T6b (sage-2574): the same for a Gitea grant decision. The executor ends it `revoked` before any Gitea call:
|
||||
no `gitea_grants` row, no token file, and the fake Gitea saw no token create.
|
||||
- T6c: a host or Gitea executor already in flight when step G starts.
|
||||
- The host side aborts its claim (3a step 1) and ends `revoked`.
|
||||
- The Gitea side: step G awaits the executor (3b), and then the fake Gitea holds no token for the user. That
|
||||
includes one the executor created mid-flight.
|
||||
- T6d (B1): a host decision and a Gitea decision, raised before the fire and still undecided. The fire puts
|
||||
both in the set. The human approves both after `fire.done`, and both executors end `revoked`, with nothing
|
||||
applied.
|
||||
- T6e (B1): a host raise and a Gitea raise refuse `instance-fired`:
|
||||
- while the instance is firing;
|
||||
- after `fire.done`.
|
||||
After a rehire of the same name, a raise goes through. While a delete's job runs, a raise refuses
|
||||
`grant-revoke-running`.
|
||||
- T7: an instance with no grant fires exactly as today, synchronously. Same result keys, no `revoking`, and
|
||||
the same events, counted.
|
||||
- T8: hosts.revoke and grants.revoke keep their records, their order and their tester refusals.
|
||||
- T9 (B3): with the job held on a remove:
|
||||
- a second fire refuses `fire-in-progress`;
|
||||
- a delete of the same agent refuses `grants-revoking` and starts no second job;
|
||||
- `hosts.revoke` and `grants.revoke` for the agent refuse `grant-revoke-running`;
|
||||
- `grants.sweep` names the agent as `running`.
|
||||
After a release, one set of `grant.revoked` records exists per grant.
|
||||
- T10 (B2): the fake installer holds a remove and the fake Gitea holds a DELETE, neither answering until
|
||||
released.
|
||||
- `instance.fire` and `lifecycleDelete` both answer before either is released. The test checks this with a
|
||||
timer, not a wall-clock bound.
|
||||
- Meanwhile the broker answers another verb from another session.
|
||||
- After release, the jobs complete.
|
||||
- A cli test drives the launcher's delete pass against a bus whose remove is held. The pass closes the
|
||||
request `grants-revoking` and the host channel stays up.
|
||||
- T11 (d6, C1): a fire whose job is held on a remove. While it's held, the chief's run ends and its claim is
|
||||
released, and processing is halted. After the release, the job completes: `fire.done` is recorded with the
|
||||
job, the consumed `action.allowed` event is there, a second use of the same authority refuses
|
||||
`decision-consumed`, and the instance is out of the file. No `fire.failed` and no `grant.revoke.refused`.
|
||||
- T12 (d6, C1): a fire with a grant whose step B refuses in the job. The test uses T1's setup, where
|
||||
`revoke()` refuses `credential-revoke-failed`. After the job:
|
||||
- `fire.failed` carries the job, step `B` and `credential-revoke-failed`, and the inbox message names the
|
||||
job, the revoked grants, the step and the code;
|
||||
- there's no `grant.revoke.refused` and no `fire.done`;
|
||||
- the instance is still firing and still in the file.
|
||||
With the cause cleared, a repeat fire finds an empty set, runs B and C synchronously and completes.
|
||||
- T13 (d6, N4): a fire and a delete, each with its job held, then the broker is closed and a new one opened
|
||||
on the same data root. The new broker records `grants.job.interrupted` for both jobs and sends two inbox
|
||||
messages, one saying to fire again and one to raise the delete again. A third broker on the same root sends
|
||||
nothing more. A job that ended before the restart gets no interrupted record.
|
||||
|
||||
Files (N3). Every file listed runs whole in the manifest round, touched or not:
|
||||
- New: `packages/bus/tests/fire-revokes-grants.test.mjs` holds T2-T13.
|
||||
- Changed:
|
||||
- `grant-host.test.mjs` and `gitea-grant.test.mjs`: T1's pins, and the T8 regressions next to their
|
||||
existing cases;
|
||||
- `packages/cli/tests/lifecycle.test.mjs`: the T10 launcher pass;
|
||||
- `fake-gitea-api.mjs` and `host-fixtures.mjs`, if T10's holds need a hook there.
|
||||
- Run whole because they call `instance.fire` or `lifecycleDelete` at 47a35bf43:
|
||||
- bus: `chief-of-staff`, `lifecycle-revoke`, `lifecycle-verbs`, `reload`;
|
||||
- cli: `lifecycle`, `instances`;
|
||||
- harness: `tools`.
|
||||
- Also run: `grant-executor.test.mjs` (the executors' `#giteaRuns` change) and `gitea-grant-docker.test.mjs`
|
||||
(brief gate).
|
||||
- `server.mjs`, `process.mjs`, `host.mjs` and `launcher.mjs` are unchanged, so no test changes there for this
|
||||
row.
|
||||
|
||||
## 9. Mutants
|
||||
|
||||
- M1: step G skips the host revokes. Killed by T2.
|
||||
- M2: step G skips the Gitea revoke. Killed by T2.
|
||||
- M3: a refused grant revoke is ignored and the job goes on to step B. Killed by T4.
|
||||
- M4: `lifecycleDelete` skips step G. Killed by T3.
|
||||
- M5: the fire's host revoke goes local first (`lineFirst: false`). Killed by T4, because B's row is gone.
|
||||
- M6: a Gitea revoke with something in `serviceLeft` counts as done. Killed by T5b.
|
||||
- M7: open decisions left out of the set. Killed by T6 and T6b.
|
||||
- M8: step G skips the leftover-line retry. Killed by T4b.
|
||||
- M9: a failed leftover-line retry refuses the fire. Killed by T4b.
|
||||
- M10: the set takes only approved decisions (d3's rule). Killed by T6d.
|
||||
- M11: the raise bindings skip the fired check. Killed by T6e.
|
||||
- M12: no `#revoking` lock. Killed by T9.
|
||||
- M13: the verb awaits the job before it answers. Killed by T10.
|
||||
- M14: step G's Gitea part doesn't await the in-flight executor. Killed by T6c.
|
||||
- M15: step G's Gitea retry runs the business-wide `#sweepGitea`. Killed by T5b.
|
||||
- M16: the delete's first refusal sends no start message. Killed by T3.
|
||||
- M17: a delete after a refused job refuses `grants-revoking` instead of `grant-revoke-failed`. Killed by T3b.
|
||||
- M18: a delete of an instance with no grant goes through the job. Killed by T3c.
|
||||
- M19: the job's step C uses `#consumeAuthority`, with the holder and processing checks. Killed by T11.
|
||||
- M20: a step B or C failure in the job is recorded as `grant.revoke.refused internal`. Killed by T12.
|
||||
- M21: boot skips the interrupted-job check. Killed by T13.
|
||||
|
||||
## 10. Order and rebase
|
||||
|
||||
Built from 47a35bf43 for this read. The code goes on whatever refactor is when the build starts. Rows 274, 256
|
||||
and 194's follow-ups touch the broker and instance-settings, so expect a rebase and a whole rerun. The row lands
|
||||
before the .53 Case 1 install hires an instance with a grant. It doesn't hold the dragon-lin re-pin.
|
||||
|
||||
## Build notes
|
||||
|
||||
Dewey approved d6 (dewey-0829, 10:11Z 10-22) with two build-time items. Both are test details, so there's no
|
||||
new draft:
|
||||
- N7, T12's setup. T1's setup makes `revoke()` refuse because the grant's `host.<id>` file is still in the
|
||||
store. Step G's `hostKeys.revoke` removes that file before step B, so it can't be T12's cause. T12 uses a
|
||||
step B refusal that survives step G, for example a stray file in the agent's store directory or a roles-entry
|
||||
credential whose revoke fails. The test asserts it reaches `fire.failed` with step `B`.
|
||||
- N8, `decision-consumed` in T11. A chief's fire is within-role: `#checkAuthority` returns `{ class }` with no
|
||||
decision, and the lifecycle verbs take none (broker.mjs:3712). `#spent` never applies to a fire. T11 drops the
|
||||
second-use `decision-consumed` assertion. Instead, a repeat fire after `fire.done` takes the delisted path and
|
||||
answers done. Section 2's `decision-consumed` example for `fire.failed` can't occur, so the build uses only
|
||||
codes that can.
|
||||
|
||||
Choices made in the code (b1, broker.mjs only so far). None changes an approved section; each fills in a
|
||||
detail the design left open. Dewey, flag any you read differently.
|
||||
- Record writers. `writeGrantEvent` takes only `grant.*` actions. So `fire.revoking`, `fire.done`, `fire.failed`
|
||||
and `grants.job.interrupted` are not grant evidence. `fire.*` goes through `#event` under the chief's session
|
||||
(the job keeps a copy, `sc`). `grants.revoking`, `grants.revoked.done` and `grants.job.interrupted` go under
|
||||
the bus session `{ role: null, run: null }`, subject the agent. `grant.revoke.refused` is grant evidence, as
|
||||
section 4 has it.
|
||||
- A fired Gitea user. The `gitea_users` row survives a fire (#436b section 6), so on its own the row would
|
||||
never let a fired agent's set empty. The user counts as pending until a later Gitea `grant.revoked` with
|
||||
`fire: true` and a non-null `user` names the agent, and counts again after a new grant step (a rehire).
|
||||
- Open Gitea decisions (3c) end with a `grant.revoked` that has `user: null`, `tokens: []`, `fire: false` and
|
||||
`decisions: [ids]`, plus an immediate close with `serviceLeft: []`. `#giteaRevokedSince` then ends them. Since
|
||||
`user` is null, the pair never counts as the user's fire.
|
||||
- Leftover lines (section 5, Q1). A line is in the set as `line:<host>` when its tag is in `#hostLinesLeft`, its
|
||||
host isn't one the agent holds a grant on, and no `fire.done` or `grants.revoked.done` for the agent since the
|
||||
line was last named left lists it in `grantsLeft`. A line removed in step G gets `grant.revoke.closed` with
|
||||
the old revoke id, `hostLine: 'removed'` and the flag. `#hostLinesLeft` now clears a tag on a later close
|
||||
with `hostLine: 'removed'` and `fire` or `delete` set, so the line also leaves `hosts sweep`'s list. The
|
||||
removed lines appear in `grants` as `{ grant: 'line:<host>', revoke, tag }`.
|
||||
- 3a's wait for host executors. `#runHostGrant` marks itself in `#hostRuns` from its claim to its release. Step
|
||||
G's host part runs `abortClaim`, waits for every mark on (agent, host), and sends the remove. If a new mark
|
||||
appeared during the remove, it runs again, up to five rounds, and then refuses `host-remove-failed`. From the
|
||||
last check to the step 1 commit there's no await.
|
||||
- `hosts sweep gitea` (3e) skips the open revokes, settles and left entries of every agent a job holds, and
|
||||
keeps those entries in `left`. Its answer has `running: [agents]` only when there's at least one. The retry
|
||||
of one left entry is now `#giteaRetry`, which the sweep and step G share. The behavior is unchanged.
|
||||
- Step G's Gitea retries record a sweep-shaped pair for the agent: `grant.sweep { agent, job }`, then the
|
||||
closes and retries, then `grant.swept` whose `left` keeps every other agent's entries. The pair is written
|
||||
first, so a serviceLeft from a close between the two can't drop out of `#giteaLeft`.
|
||||
- `#giteaRuns` is now a Map from each promise to its `business/agent` key, or null for business-wide work.
|
||||
Step G waits for its own agent's keys and the null ones. `#track` adds `.catch` to its `finally` chain, so a
|
||||
rejected executor can't leave an unhandled rejection behind.
|
||||
- A delete repeat after a refusal. `grant-revoke-failed` is returned only when the agent's last delete job end
|
||||
was a `grant.revoke.refused` whose grant is still in the set. If that grant was cleared by hand and only a
|
||||
leftover line remains, the repeat answers `grants-revoking`.
|
||||
- The delete job's reason, in `grant.revoked`, is `lifecycle.delete <request>`.
|
||||
- Step G's messages go to the human's inbox alone (no decision, so no raising role to thread to), through the
|
||||
same insert and route as `#grantNotice`.
|
||||
- `#recordAuthority` is `#consumeAuthority` less `#checkAuthority`'s holder and processing checks, for a
|
||||
fire's step C (C1).
|
||||
|
||||
Choices made in the tests (b2). Each changes where a test sits or how it checks, not what it checks:
|
||||
- T1 is in `lifecycle-revoke.test.mjs`, not `grant-host.test.mjs` and `gitea-grant.test.mjs`. T1 pins the
|
||||
runtime's `revoke()` (step B's credential check) and the start sweep, and only `lifecycle-revoke` builds a
|
||||
broker through `startBroker` with the real credential store. Its `setup` takes a new `seed` hook that writes
|
||||
a host record and grant before the start.
|
||||
- T8 is in `grant-executor.test.mjs` for `hosts.revoke`, where G12, G13, G26 and G27 already are, and in
|
||||
`gitea-grant.test.mjs` for `grants.revoke`. G12 already pins the host verb's two records, their bodies and
|
||||
their order, and G26 its role refusals, so T8 adds what no test had: `tester-dev-only` once `bus.tester` is
|
||||
off, and that a verb revoke writes no `fire.*` or `grants.*` record.
|
||||
- T2-T13 use a fake installer and a fake organization in `fire-revokes-grants.test.mjs`. The organization's
|
||||
`instance.fire` delists and reloads the business, as the writer does, and can throw a set error, which is
|
||||
how T12 gets its step B cause (N7): a `credential-revoke-failed` that survives step G.
|
||||
- "A fired agent's `host.run` and Gitea use refuse" is checked through the state those uses read: no
|
||||
`host_grants` row, no key file, a revoked credential, and no Gitea token for the user.
|
||||
- T10's cli case runs the real runtime and launcher. The seeded grant is on a TEST-NET host
|
||||
(192.0.2.10), so the job's remove fails or waits. The first request closes `grants-revoking` either way, and
|
||||
the case waits on `grantsIdle()` before close.
|
||||
- The mutants are `scratch/megavolt-r281/r281-mutants.sh`, one sed each on `broker.mjs`, run against
|
||||
`fire-revokes-grants.test.mjs`. M13's verbs are synchronous, so its mutant makes the fire return the job's
|
||||
promise, and the call waits for the job. That's what the verb awaiting its job would look like.
|
||||
|
||||
Rebase onto 46dedf925, after row 256 (sage-2597). It's on branch `megavolt/row281-b`, and
|
||||
`megavolt/row281` stays where it is. `git range-diff 0165be266..bd0bb96ae origin/refactor..HEAD` gives `=` for
|
||||
all nine commits, and no path changes on both sides. Row 256 threads the actor through `planDelete`,
|
||||
`checkDelete` and `instances.check`, and adds `protectedRefusal`. That doesn't change what a delete calls in
|
||||
this row:
|
||||
- At the verb, `instances.set` op delete already ran `instances.check` with `s.human` before 256. A
|
||||
creative instance's delete by anyone but the human now refuses there, before any request exists, so no
|
||||
step G job starts for it.
|
||||
- In the pass, step G runs in `lifecycleDelete` before `#organization('instances.set')`, and the actor
|
||||
is still `r.by`. `planDelete` there can still refuse after step G has revoked. Before 256 a references or
|
||||
last-listed refusal could do the same thing, so the order is unchanged. A delete the verb accepted only
|
||||
meets a new refusal in the pass if the file changed between the two.
|
||||
- `read(business, actor)` changes only the listing's `endProtected`. The broker and these tests don't read it.
|
||||
The T10 cli case deletes `sec-1` (security-reviewer), which isn't END_PROTECTED.
|
||||
|
||||
Rebase onto 980cec88c, after row 277 (sage-2605). It's on branch `megavolt/row281-c`.
|
||||
`git range-diff 46dedf925..7454be493 980cec88c..c174f37ac` gives `=` for all ten commits. Row 277's
|
||||
`thread.claims` change to `broker.mjs` doesn't touch step G or any line the mutants change:
|
||||
`scratch/megavolt-r281/anchors.sh` counts each M1-M21 sed anchor in the rebased `broker.mjs`, and all 28
|
||||
counts are 1. The mutants script runs it after a green base and stops if a count isn't 1.
|
||||
|
||||
b3, after the first base run on c174f37ac (11:24:47Z) came back red. These tests had never run before: b2
|
||||
was written under git and docs only. bus: 120 tests, 116 pass, 4 fail. cli lifecycle: 24 tests, 23 pass,
|
||||
1 fail. Every older test in those files passed. No mutant ran. Each failure is a test fault, and b3 changes
|
||||
no code:
|
||||
- T4b, `fire-revokes-grants`: **test changed**. The test read `manual.revoke`, but the `hosts.revoke`
|
||||
answer has never carried `revoke`: `#revokeHost` drops it, so the verb's answer keeps its pre-281 shape.
|
||||
The fire's close names the manual revoke, as section 3a says. The test now takes that id from the
|
||||
manual's `grant.revoked` event.
|
||||
- T5b, `fire-revokes-grants`: **test changed**. The job refused `gitea-left:9,tokens`, and the test
|
||||
expected `gitea-left:<digits>`. Section 4 sets the reason as `gitea-left:<ids>`, the ids left at Gitea.
|
||||
For a fire, a close's `serviceLeft` is the N10 list from before 281: each token id whose DELETE failed,
|
||||
then `tokens` or `login` for a fire step that failed (`#giteaCloseRevoke`). With the DELETE failing,
|
||||
`#giteaFire`'s token list never clears, so `tokens` is a real entry left at Gitea. It belongs in the
|
||||
reason. The test now expects `gitea-left:<id>,tokens`.
|
||||
- T6b, `fire-revokes-grants`: **test changed**. After the fire, the executor made 4 Gitea calls where the
|
||||
test expected none. They are step 0's admin checks C1 to C4: version, self, token scopes and the repo
|
||||
check, all GET. Step 1's `#giteaRevokedSince` then ends the decision `revoked` before any write. Section
|
||||
3c of d6 names that step 1 check as the only thing that stops the decision. T6b's line in section 8 says
|
||||
"before any Gitea call", but the evidence it lists is no row, no token file and no token create. That
|
||||
phrase was wrong, because step 0 always runs first. The approved text stays as it is, and this note is
|
||||
the correction. The test now checks that every call after the fire is a GET and that no token create
|
||||
happened. Moving the revoked check ahead of step 0 would change the executor for every revoke, not just
|
||||
step G's, and the design doesn't ask for that.
|
||||
- T8, `gitea-grant`: **test changed**. The human revoke's events between `grant.revoked` and
|
||||
`grant.revoke.closed` include its 5 `grant.call`/`grant.result` pairs (`#giteaLog` logs both around every
|
||||
Gitea call). T8 checks that a human revoke writes no job records, not that it makes no calls. It now
|
||||
leaves the call log out before comparing.
|
||||
- T10, cli `lifecycle`: **test changed** (sage-2615 asked about this one). The `credential-file` came from
|
||||
`startBroker`, not from the delete and not from Gitea. The seeded host grant gives `sec-1` a `host.web1`
|
||||
store reference. `Credentials` checks the store root (`<config dir>/secrets/agents`) with `privateDir` at
|
||||
start, and the fixture had no store root. That check is row 199's, and it is right to fail closed. The
|
||||
test now makes the store root at 0700 under `dirname(p.f.config)`, as the bus host fixtures do, never
|
||||
under the real config. sage-2575's condition still holds. This business has no Gitea grant, so
|
||||
`#grantSet` adds no `gitea` entry (`#giteaPending` and `#giteaUnfired` read only the store), and step G
|
||||
never reaches `#stepGGitea`. A delete with no Gitea grant reads no Gitea credential file.
|
||||
|
||||
### b4a: the M1 hang's cause (mos-1273, sage-2636)
|
||||
|
||||
The first M1-M21 run on 06ee5ed6b hung on M1. All 22 tests ended in about 80 s (16 fail, 5 pass, 1
|
||||
cancelled), but the file's process stayed alive until the harness killed it at 1762 s. This is a test fault,
|
||||
and no code changed.
|
||||
|
||||
- Cause: T6c holds `web1:add`, and the fake `install` loops in `pause()` on `sleep(5)` while the key is
|
||||
held. Only the fire's abort signal can end that loop, and M1 removes the host revoke that sends the
|
||||
abort. In M1's log, T6c fails at its `until` on line 683, before it deletes the key on line 691. So the loop keeps a
|
||||
timer alive forever. That reading comes from the log and the file. The rerun checks it: before M1-M21 it runs M1
|
||||
once without `--test-force-exit`, and that run must exit by itself inside 180 s.
|
||||
- Fix: setup's `t.after` clears `held` and `failing` before it closes the store, so any held loop ends
|
||||
with its test. The base is unchanged because every passing test already releases its keys.
|
||||
- Sage's second candidate, the Gitea `gate` in T6c and T10, isn't a live handle. The fake server's
|
||||
`t.after` closes all its connections, and a parked promise with no timer doesn't keep node alive. Both
|
||||
tests now also release the gate in `t.after` (`go()` only, not `off()`: a second `off()` would splice
|
||||
index -1 and remove another rule).
|
||||
- The runner's 180 s timeout with `--test-force-exit` (sage-2633) still caps any future hang.
|
||||
|
||||
### b4: T6b pins the four reads (dewey-0839, sage-2627)
|
||||
|
||||
- T6b: **test changed**, stricter. `slice(calls).every(GET)` also passed with zero calls. It now
|
||||
deep-equals the exact list in order: `GET /api/v1/version`, `GET /api/v1/user`, `GET /api/v1/token`,
|
||||
`GET /api/v1/user/repos?limit=1&page=1` (step 0's C1 to C4, `gitea-grant.mjs` adminChecks). It also fails
|
||||
if step 1 stops ending the run and step 2's reads go out. The title says "before any Gitea write".
|
||||
|
||||
### T14 and M22: a declined decision on a removed host (sage-2653, dewey-0844, N1)
|
||||
|
||||
Test only, written before the slot. Not run yet.
|
||||
|
||||
- T14 (new, in `fire-revokes-grants.test.mjs`): coder's web1 decision is raised and left undecided. The human
|
||||
then runs `hosts.remove web1`, which goes through because hosts.remove doesn't check open decisions (N1).
|
||||
The fire starts a job whose set is `host:web1`. Step G refuses `host-unknown` with one
|
||||
`grant.revoke.refused`, and one inbox message names the job, web1 and host-unknown. There's no fire.done.
|
||||
The human resolves the decision `no`. No executor runs (`#approvedGrant` is null for a no), so the closed
|
||||
check in `#openAgentDecisions` is the only thing that takes it out of the set. The repeat fire answers
|
||||
with no job and records one fire.done with no job.
|
||||
- M22 deletes that closed check (`broker.mjs:1684`). The declined decision stays open, so the repeat fire
|
||||
starts a second job and refuses host-unknown again, and T14 fails on `repeat.job`. M10 mutates the same
|
||||
line in another way (approved-only), and T6d kills it. M22 runs on the fire-revokes file like M1-M21, and
|
||||
its anchor makes 29.
|
||||
- N1's hint text (hosts.remove naming the open decision) is a source change. It stays out of this row and
|
||||
goes to a DEFERRED line that Sage files at landing.
|
||||
- If T14 is red on its first run, it goes to Sage and then Mos untouched, because 281 has had its one placed red
|
||||
(mos-1272).
|
||||
|
||||
### Gate plan (sage-2649, dewey-0840, mos-1275)
|
||||
|
||||
Written before the slot. The slot runs on row281-e after the rebase onto 278's landing, and every step
|
||||
stops at the first red.
|
||||
|
||||
1. The base: the touched files whole (fire-revokes-grants, lifecycle-revoke, grant-executor, gitea-grant
|
||||
in bus; lifecycle in cli). Then the 29 anchors, the M1 exit check without `--test-force-exit`, and
|
||||
M1-M22 under `timeout -k 10 180` with `--test-force-exit`.
|
||||
2. dewey-0840's files whole: bus chief-of-staff, lifecycle-verbs, reload, grant-host and
|
||||
gitea-grant-docker; cli instances; harness tools. If gitea-grant-docker skips, the report names the skip
|
||||
and its reason.
|
||||
3. bus, business and cli whole.
|
||||
4. Every `scripts/test-*.sh`, each as `timeout -k 10 LIMIT bash scripts/test-X.sh`, and no script is
|
||||
edited. LIMIT is Gander's row 278 limit (`agents/gander/work/queue-278/SHELL-LIMITS.json`, sage-2646): 3x
|
||||
the longest successful run from Gander's row 196 receipts, plus 120 s headroom. I checked it against two
|
||||
more records. One is my row 236 gate at bd5f11ded (10-22). The other is 278's run 3
|
||||
(`run3/whole/results.json`). Neither has a longer run, so every limit is at least 3x the longest of all
|
||||
three. A script with no limit stops the gate before it starts. Any timeout goes to Sage, then Mos,
|
||||
unplaced.
|
||||
|
||||
| Script | Row 236 | 196 receipts | 278 run 3 | Longest | 3x | LIMIT |
|
||||
|---|---|---|---|---|---|---|
|
||||
| test-auth | 1 s | 1.9 s | 0.8 s | 1.9 s | 6 s | 126 s |
|
||||
| test-conductor | 6 s | 8.1 s | 6.1 s | 8.1 s | 25 s | 145 s |
|
||||
| test-config | 1 s | 3.2 s | 1.9 s | 3.2 s | 10 s | 130 s |
|
||||
| test-discord | 8 s | 8.8 s | 7.4 s | 8.8 s | 27 s | 147 s |
|
||||
| test-extension-package | 1 s | 3.0 s | 1.4 s | 3.0 s | 10 s | 130 s |
|
||||
| test-foundation | 39 s | 39.7 s | 39.9 s | 39.9 s | 120 s | 240 s |
|
||||
| test-land-work | 11 s | 12.6 s | 11.3 s | 12.6 s | 38 s | 158 s |
|
||||
| test-queue | 20 s | 23.4 s | 21.2 s | 23.4 s | 71 s | 191 s |
|
||||
| test-release | 2 s | 12.2 s | 2.7 s | 12.2 s | 37 s | 157 s |
|
||||
| test-task | 14 s | 36.8 s | 13.8 s | 36.8 s | 111 s | 231 s |
|
||||
|
||||
## Runs
|
||||
|
||||
The manifest-round slot on row281-e 7979fb41e (base 385a7194c, row 278's landing), CLEAR sage-2661. Runner
|
||||
`r281-slot.sh` (scratch), from `scratch/megavolt-r222`: 12:43:17Z to 12:59:25Z, 10-22, SLOT_EXIT 0. No red,
|
||||
no timeout. Every step ran in a detached worktree at the candidate, with TMPDIR under
|
||||
`/mnt/storage/scratch/tmp`. The gate worktree was clean at the end, and the trap removed both worktrees.
|
||||
|
||||
1. The base, each file whole, plain `node --test`: bus fire-revokes-grants, lifecycle-revoke,
|
||||
grant-executor and gitea-grant 121/121, 0 skipped, 22 s; cli lifecycle 24/24, 0 skipped, 13 s. T14 passed
|
||||
on its first run. All 29 anchors matched once. The M1 exit check exited by itself: rc 1, 17 fails (the 16
|
||||
on 3f29375a8, plus T14), 63 s. M1-M22: 22 killed, 0 survived (Mutants).
|
||||
2. dewey-0840's files whole: bus chief-of-staff, lifecycle-verbs, reload, grant-host and gitea-grant-docker
|
||||
76/76, 0 skipped, 11 s (gitea-grant-docker ran, with no skip); cli instances 14/14, 0 skipped, 3 s; harness
|
||||
tools 23/23, 0 skipped.
|
||||
3. Whole packages: bus 819/819, 23 s; business 231/231, 3 s; cli 837/837, 591 s. Each has 0 skipped.
|
||||
4. The shells, each under `timeout -k 10 LIMIT`, all rc 0:
|
||||
|
||||
| Script | Result | Time | LIMIT |
|
||||
|---|---|---|---|
|
||||
| test-auth | 15 passed, 0 failed | 1 s | 126 s |
|
||||
| test-conductor | 17 passed, 0 failed | 6 s | 145 s |
|
||||
| test-config | 31 passed, 0 failed | 1 s | 130 s |
|
||||
| test-discord | 81 passed, 0 failed | 8 s | 147 s |
|
||||
| test-extension-package | 18 passed, 0 failed | 1 s | 130 s |
|
||||
| test-foundation | 44 passed, 0 failed | 36 s | 240 s |
|
||||
| test-land-work | 86 passed, 0 failed | 10 s | 158 s |
|
||||
| test-queue | node 156/156; 28 passed, 0 failed | 20 s | 191 s |
|
||||
| test-release | 14 passed, 0 failed | 3 s | 157 s |
|
||||
| test-task | 91 passed, 0 failed | 13 s | 231 s |
|
||||
|
||||
Skips, from a grep of every shell log for `^skip`, `# skip` and `SKIP` (dewey-0845):
|
||||
|
||||
- test-task, lines 87 and 93: `skip live provider cases (row 244: no provider call in the gate)`. Row 244
|
||||
keeps provider calls out of the gate by design. The log has no Docker lines and no other skip.
|
||||
- test-queue, line 34: `skip queue verify and render --check: this checkout (.../r281-gate-wt) is not the
|
||||
queue's canonical root (/mnt/storage/src/mosaic-stack)`. The gate runs in a worktree, so the canonical-root
|
||||
checks are skipped.
|
||||
- No other shell log, and no node suite above, has a skip.
|
||||
|
||||
## Mutants
|
||||
|
||||
Run on b4a 3f29375a8 (base 6af85df4b), 12:16:18Z to 12:20:15Z, 10-22, slot sage-2643; runner
|
||||
`r281-mutants.sh` (scratch). Base first, plain `node --test`, each file whole: bus fire-revokes-grants,
|
||||
lifecycle-revoke, grant-executor and gitea-grant 120/120, 0 skipped; cli lifecycle 24/24, 0 skipped. The
|
||||
worktree was clean after the base, and all 28 sed anchors matched once in `broker.mjs`.
|
||||
|
||||
The M1 exit check (mos-1273) applied M1 and ran fire-revokes-grants without `--test-force-exit`. It exited by
|
||||
itself, rc 1, with 16 fails, in 63 s. The first run on 06ee5ed6b hung there until it was killed at 1762 s
|
||||
(b4a above).
|
||||
|
||||
Each mutant ran fire-revokes-grants.test.mjs whole under `timeout -k 10 180 node --test --test-force-exit`
|
||||
(sage-2633). Every one ran all 21 tests, with no load error and no timeout.
|
||||
|
||||
| Mutant | Change | Verdict | rc | Fails of 21 | Time | Section 9 names | Failing tests (from the log) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M1 | skip-host-revokes | KILLED | 1 | 16 | 63 s | T2 | T2 T3 T3b×2 T4 T4b×2 T6 T6c T6d T6e T9 T10 T11 T12 T13 |
|
||||
| M2 | skip-gitea-revoke | KILLED | 1 | 12 | 12 s | T2 | T2 T3 T3b×2 T4 T5 T5b T6b T6c T6d T9 T10 |
|
||||
| M3 | refusal-goes-on | KILLED | 1 | 3 | 3 s | T4 | T4 T5 T5b |
|
||||
| M4 | delete-skips-step-g | KILLED | 1 | 6 | 3 s | T3 | T3 T3b×2 T6e T10 T13 |
|
||||
| M5 | host-local-first | KILLED | 1 | 4 | 3 s | T4 | T2 T3b×2 T4 |
|
||||
| M6 | service-left-done | KILLED | 1 | 1 | 3 s | T5b | T5b |
|
||||
| M7 | set-no-open-decisions | KILLED | 1 | 4 | 3 s | T6, T6b | T6 T6b T6c T6d |
|
||||
| M8 | skip-line-retry | KILLED | 1 | 3 | 4 s | T4b | T3b T4b×2 |
|
||||
| M9 | line-retry-refuses | KILLED | 1 | 2 | 3 s | T4b | T3b T4b |
|
||||
| M10 | approved-only | KILLED | 1 | 1 | 3 s | T6d | T6d |
|
||||
| M11 | raise-no-fired-check | KILLED | 1 | 3 | 3 s | T6e | T4 T6e T12 |
|
||||
| M12 | no-lock | KILLED | 1 | 3 | 3 s | T9 | T3 T6e T9 |
|
||||
| M13 | verb-awaits-job | KILLED | 1 | 14 | 3 s | T10 | T2 T4 T4b×2 T5 T5b T6 T6b T6c T6d T10 T11 T12 T13 |
|
||||
| M14 | no-executor-wait | KILLED | 1 | 1 | 3 s | T6c | T6c |
|
||||
| M15 | business-wide-sweep | KILLED | 1 | 1 | 4 s | T5b | T5b |
|
||||
| M16 | no-start-message | KILLED | 1 | 2 | 3 s | T3 | T3 T3b |
|
||||
| M17 | repeat-grants-revoking | KILLED | 1 | 1 | 3 s | T3b | T3b |
|
||||
| M18 | empty-set-job | KILLED | 1 | 4 | 4 s | T3c | T3 T3b×2 T3c |
|
||||
| M19 | step-c-consume | KILLED | 1 | 1 | 4 s | T11 | T11 |
|
||||
| M20 | bc-as-internal | KILLED | 1 | 2 | 3 s | T12 | T6e T12 |
|
||||
| M21 | no-interrupted-check | KILLED | 1 | 1 | 4 s | T13 | T13 |
|
||||
|
||||
Every mutant fails the test section 9 names for it; none was killed only by a different test. "T3b×2" and "T4b×2" are the two tests that share each id. The failing-test ids are taken from each log's "failing tests" list (dewey-0841).
|
||||
|
||||
21 killed, 0 survived. b4 makes one T6b assertion stricter after this run, and fire-revokes-grants is
|
||||
rerun whole on b4 (Runs).
|
||||
|
||||
### On row281-e 7979fb41e (sage-2661)
|
||||
|
||||
12:43:17Z to 12:47:12Z, 10-22, the first part of the manifest-round slot (Runs). Same runner and flags as
|
||||
above, with M22 added (T14 and M22 in the build notes). Every mutant ran all 22 tests, with no load error and
|
||||
no timeout.
|
||||
|
||||
| Mutant | Change | Verdict | rc | Fails of 22 | Time | Section 9 names | Failing tests (from the log) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M1 | skip-host-revokes | KILLED | 1 | 17 | 62 s | T2 | T2 T3 T3b×2 T4 T4b×2 T6 T6c T6d T6e T9 T10 T11 T12 T13 T14 |
|
||||
| M2 | skip-gitea-revoke | KILLED | 1 | 12 | 13 s | T2 | T2 T3 T3b×2 T4 T5 T5b T6b T6c T6d T9 T10 |
|
||||
| M3 | refusal-goes-on | KILLED | 1 | 4 | 3 s | T4 | T4 T5 T5b T14 |
|
||||
| M4 | delete-skips-step-g | KILLED | 1 | 6 | 3 s | T3 | T3 T3b×2 T6e T10 T13 |
|
||||
| M5 | host-local-first | KILLED | 1 | 4 | 3 s | T4 | T2 T3b×2 T4 |
|
||||
| M6 | service-left-done | KILLED | 1 | 1 | 3 s | T5b | T5b |
|
||||
| M7 | set-no-open-decisions | KILLED | 1 | 5 | 3 s | T6, T6b | T6 T6b T6c T6d T14 |
|
||||
| M8 | skip-line-retry | KILLED | 1 | 3 | 3 s | T4b | T3b T4b×2 |
|
||||
| M9 | line-retry-refuses | KILLED | 1 | 2 | 3 s | T4b | T3b T4b |
|
||||
| M10 | approved-only | KILLED | 1 | 2 | 3 s | T6d | T6d T14 |
|
||||
| M11 | raise-no-fired-check | KILLED | 1 | 3 | 3 s | T6e | T4 T6e T12 |
|
||||
| M12 | no-lock | KILLED | 1 | 3 | 3 s | T9 | T3 T6e T9 |
|
||||
| M13 | verb-awaits-job | KILLED | 1 | 15 | 3 s | T10 | T2 T4 T4b×2 T5 T5b T6 T6b T6c T6d T10 T11 T12 T13 T14 |
|
||||
| M14 | no-executor-wait | KILLED | 1 | 1 | 3 s | T6c | T6c |
|
||||
| M15 | business-wide-sweep | KILLED | 1 | 1 | 3 s | T5b | T5b |
|
||||
| M16 | no-start-message | KILLED | 1 | 2 | 3 s | T3 | T3 T3b |
|
||||
| M17 | repeat-grants-revoking | KILLED | 1 | 1 | 3 s | T3b | T3b |
|
||||
| M18 | empty-set-job | KILLED | 1 | 4 | 3 s | T3c | T3 T3b×2 T3c |
|
||||
| M19 | step-c-consume | KILLED | 1 | 1 | 3 s | T11 | T11 |
|
||||
| M20 | bc-as-internal | KILLED | 1 | 2 | 3 s | T12 | T6e T12 |
|
||||
| M21 | no-interrupted-check | KILLED | 1 | 1 | 3 s | T13 | T13 |
|
||||
| M22 | no-closed-check | KILLED | 1 | 1 | 3 s | T14 | T14 |
|
||||
|
||||
22 killed, 0 survived. Every mutant fails the test it's named for, and M22 fails T14 alone. Compared with
|
||||
3f29375a8, the only new failures are T14's (under M1, M3, M7, M10, M13 and M22), and every other failing set is
|
||||
unchanged.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+76
-11
@@ -240,10 +240,14 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
|
||||
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
|
||||
| `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.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
|
||||
| `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). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
|
||||
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
|
||||
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
|
||||
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
|
||||
| `session.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)
|
||||
|
||||
@@ -281,7 +285,31 @@ any run but the thread's current holder.
|
||||
|
||||
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
|
||||
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
|
||||
another user's thread.
|
||||
another user's thread, unless it is shared with them (Thread shares, below).
|
||||
|
||||
### Thread shares (row 206, #1704)
|
||||
|
||||
Schema `3m` adds `thread_shares`, append-only: one row per `share` or `unshare`, and the latest
|
||||
row per thread and target is the share's state. A thread's owner shares it read-only with a
|
||||
user (`user:<id>`), the business human or everyone (`*`). The 3l→3m step only adds the table,
|
||||
so every existing thread stays private to its owner.
|
||||
|
||||
A session may read a thread it doesn't own only when all four hold (row 193 design 5.4):
|
||||
|
||||
1. a share is in force to the caller's owner string or to `*`;
|
||||
2. a user caller has a live grant, of any level, covering the thread's project and instance;
|
||||
3. a thread owned by a user is still covered by that user's grant;
|
||||
4. neither the caller nor the owner is the tester.
|
||||
|
||||
Only a human principal matches a share: the business human, a reader session as that human, or
|
||||
a user. An agent session matches none, `*` included, even when it is bound to the thread.
|
||||
A reader session reads as the business human. A sharee can't call `session.list` on the
|
||||
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
|
||||
access document the broker reads then, so a removed, expired or unreadable grant or an
|
||||
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
|
||||
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
|
||||
user's thread messages to the owner and readers while the check holds. The tester can't call
|
||||
the share verbs (`tester-refused`). Tests: `tests/thread-shares.test.mjs`.
|
||||
|
||||
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
|
||||
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
|
||||
@@ -311,6 +339,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 +413,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 +792,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 v3m, SHA-256
|
||||
`d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9`.
|
||||
It has 23 tables, 90 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3m`, 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.
|
||||
|
||||
+89
-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;
|
||||
@@ -415,3 +478,18 @@ CREATE TABLE gitea_grants (
|
||||
PRIMARY KEY (business, agent),
|
||||
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
|
||||
) STRICT;
|
||||
-- Row 206 (193b2, #1704, row 193 design 5.2): a thread owner's read-only shares. The latest row per
|
||||
-- (thread, target) is the share's state. Append-only.
|
||||
CREATE TABLE thread_shares (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
|
||||
target TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
|
||||
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
+834
-131
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Row 206 (193b2, #1704, row 193 design 5.2): thread shares. Only a new table, its index and its
|
||||
-- append-only triggers; no existing row changes, so every thread stays private to its owner.
|
||||
CREATE TABLE thread_shares (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
|
||||
target TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
|
||||
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
|
||||
@@ -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 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
|
||||
export const SCHEMA_VERSION = '3m';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -66,6 +66,17 @@ 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'),
|
||||
}, {
|
||||
// Row 206 (193b2, #1704): thread shares. Only a new table; no existing row changes.
|
||||
from: '3l',
|
||||
to: '3m',
|
||||
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -77,8 +88,11 @@ const timedTables = [
|
||||
'task_snapshots',
|
||||
'projects',
|
||||
'threads',
|
||||
'sessions',
|
||||
'session_defaults',
|
||||
'thread_claims',
|
||||
'thread_opens',
|
||||
'thread_shares',
|
||||
'instance_names',
|
||||
'usage_records',
|
||||
];
|
||||
|
||||
@@ -15,6 +15,10 @@ 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'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3h(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -48,6 +52,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 +84,8 @@ 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'));
|
||||
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -78,9 +93,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), '3m');
|
||||
// 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 +105,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 +143,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), '3m');
|
||||
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 +155,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3k');
|
||||
assert.equal(version(path), '3m');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -614,7 +614,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3k ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3m ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,954 @@
|
||||
// Row 281 (#1786, design d6 section 8): a fire or a delete revokes the instance's host and Gitea grants
|
||||
// (step G), as a job after the verb answers. T2-T13. The broker runs bare, as chief-of-staff.test.mjs
|
||||
// runs it, with a fake organization writer, a fake installer in place of the host's remote steps, and
|
||||
// the fake Gitea from fake-gitea-api.mjs on 127.0.0.1. Every token is a fake the fake made; no test
|
||||
// reaches git.mosaicstack.dev or any provider, and none creates a real account or token (Q188). Host
|
||||
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
|
||||
// test needs outside the human's yes go in by SQL as fixtures.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Broker, GRANT_EVIDENCE, registrySha256 } from '../src/broker.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { adminSha256, definitionSha256 as giteaDefinitionSha256, giteaGrant, giteaKeys, identitySha256 } from '../src/gitea-grant.mjs';
|
||||
import { definitionSha256 as hostDefinitionSha256, grantKeys, grantTag, hostGrant } from '../src/host-grant.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { grantDecision, hostRecord } from './host-fixtures.mjs';
|
||||
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const REV = 'b'.repeat(64);
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
||||
const ENTRY = { address: '127.0.0.1', port: 2222, user: 'deploy', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl'] };
|
||||
const HOSTS = ['web1', 'web2', 'web3'];
|
||||
const GENERATION = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69';
|
||||
async function until(fn, ms = 10000) {
|
||||
const end = Date.now() + ms;
|
||||
while (!fn()) {
|
||||
if (Date.now() > end) throw new Error('timed out');
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
const rejects = (fn, code) => assert.rejects(async () => fn(), (e) => (assert.equal(e.code, code), true));
|
||||
const code = (fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function makeBusiness(url) {
|
||||
const gitea = (name, user) => ({ name, email: `${user}@example.test`, gitea: { url, user } });
|
||||
return {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: chiefAuthority },
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none, identity: gitea('Coder', 'demo-coder'), publish: ['app'] },
|
||||
helper: { definition: 'coder', ...none, identity: gitea('Helper', 'demo-helper'), publish: ['app'] },
|
||||
ops: { definition: 'ops', ...none },
|
||||
},
|
||||
definitions: {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }, { service: 'host' }] },
|
||||
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
|
||||
},
|
||||
projects: { app: { remote: `${url}/acme/app` } },
|
||||
launch: { by: 'chief', instances: ['pm', 'coder', 'helper', 'ops'] },
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'g281-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
||||
gitea.addUser('acme');
|
||||
gitea.addRepo('acme', 'app');
|
||||
const configDir = join(dir, 'cfg');
|
||||
mkdirSync(configDir, { mode: 0o700 });
|
||||
const storeRoot = join(configDir, 'secrets', 'agents');
|
||||
const runRoot = join(configDir, 'secrets', 'run');
|
||||
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
||||
const store = new Store(join(dir, 'data'));
|
||||
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
|
||||
t.after(() => {
|
||||
// A test that fails while a host step is held must not leave pause() looping after it ends.
|
||||
held.clear();
|
||||
failing.clear();
|
||||
credentials.close();
|
||||
store.close();
|
||||
});
|
||||
const doc = makeBusiness(gitea.url);
|
||||
const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: 5000 });
|
||||
const meta = { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT };
|
||||
grantApi.writeAdmin('demo', gitea.adminToken, meta);
|
||||
const gKeys = giteaKeys({ credentials, storeRoot });
|
||||
const hKeys = grantKeys({ credentials, storeRoot });
|
||||
// The fake installer: each host's lines as a set of tags. `held` keeps a host's add or remove from
|
||||
// answering until the test deletes the key; `failing` makes every request to a host fail.
|
||||
const lines = Object.fromEntries(HOSTS.map((h) => [h, new Set()]));
|
||||
const sent = [];
|
||||
const held = new Set();
|
||||
const failing = new Set();
|
||||
const pause = async (key, signal) => {
|
||||
while (held.has(key)) {
|
||||
if (signal?.aborted) throw new Error('aborted');
|
||||
await sleep(5);
|
||||
}
|
||||
if (signal?.aborted) throw new Error('aborted');
|
||||
};
|
||||
const installer = {
|
||||
...hostGrant({ configDir, runRoot }),
|
||||
async install(business, host, record, tag, publicLine, signal) {
|
||||
sent.push(`add ${host} ${tag}`);
|
||||
await pause(host + ':add', signal);
|
||||
if (failing.has(host)) throw new Error('host-failed');
|
||||
lines[host].add(tag);
|
||||
},
|
||||
async verify(host, record, key, signal) {
|
||||
if (signal?.aborted) throw new Error('aborted');
|
||||
if (failing.has(host)) throw new Error('host-failed');
|
||||
},
|
||||
async remove(business, host, record, tag) {
|
||||
sent.push(`remove ${host} ${tag}`);
|
||||
await pause(host + ':remove');
|
||||
if (failing.has(host)) throw new Error('host-failed');
|
||||
lines[host].delete(tag);
|
||||
},
|
||||
};
|
||||
// The fake organization writer. A fire delists the name and a delete drops the role, each with a
|
||||
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse.
|
||||
const org = { log: [], fireError: null };
|
||||
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
|
||||
let broker;
|
||||
const organization = (business, op, args) => {
|
||||
org.log.push([op, args?.name ?? args?.instance ?? null]);
|
||||
if (op === 'instance.fire') {
|
||||
if (org.fireError) throw org.fireError;
|
||||
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
|
||||
broker.reloadBusiness('demo', doc);
|
||||
return { revision: REV, transaction: 't-' + org.log.length };
|
||||
}
|
||||
if (op === 'instances.set') {
|
||||
delete doc.roles[args.instance];
|
||||
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.instance);
|
||||
broker.reloadBusiness('demo', doc);
|
||||
return { revision: REV, transaction: 't-' + org.log.length };
|
||||
}
|
||||
return { revision: REV };
|
||||
};
|
||||
const make = () =>
|
||||
new Broker({
|
||||
store,
|
||||
businesses: { demo: structuredClone(doc) },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
organization,
|
||||
processing: { read: () => sw.state },
|
||||
hostGrant: installer,
|
||||
hostKeys: hKeys,
|
||||
giteaGrant: grantApi,
|
||||
giteaKeys: gKeys,
|
||||
});
|
||||
broker = make();
|
||||
for (const host of HOSTS) hostRecord(store, { host, entry: ENTRY });
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = broker.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const grantEvents = () =>
|
||||
store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||
const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||
const allowed = (action) =>
|
||||
store
|
||||
.all("SELECT actor_role, subject, body FROM events WHERE business='demo' AND kind='action.allowed' ORDER BY seq")
|
||||
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
|
||||
.filter((e) => e.body.action === action);
|
||||
// The bus's step G messages to the human: no decision, so no grant notice.
|
||||
const inbox = () =>
|
||||
store.all("SELECT body FROM messages WHERE business='demo' AND from_role='_bus' AND to_role='human' AND decision IS NULL ORDER BY rowid").map((r) => r.body);
|
||||
const raiseHost = (agent, host, commands = [['uptime']]) =>
|
||||
call('chief', 'decision.raise', {
|
||||
action: 'credential.mint', question: `Grant ${agent} ${host}?`, options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'host', agent, host, commands, rotateBy: dayFrom(30) },
|
||||
});
|
||||
const raiseGitea = (agent) =>
|
||||
call('chief', 'decision.raise', {
|
||||
action: 'credential.mint', question: `Grant ${agent} Gitea?`, options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'gitea', agent, target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
||||
});
|
||||
const approve = async (d) => {
|
||||
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => ended(d.id));
|
||||
await broker.grantsIdle();
|
||||
return ended(d.id);
|
||||
};
|
||||
const approveHost = async (agent, host) => {
|
||||
const end = await approve(await raiseHost(agent, host));
|
||||
assert.equal(end.action, 'grant.applied', `${agent} on ${host}`);
|
||||
return end;
|
||||
};
|
||||
const approveGitea = async (agent) => {
|
||||
const end = await approve(await raiseGitea(agent));
|
||||
assert.equal(end.action, 'grant.applied', `${agent} gitea`);
|
||||
return end;
|
||||
};
|
||||
// A host decision approved and never run, with R4's body built here (T6).
|
||||
const fixtureHost = (agent, host, commands = [['uptime']]) =>
|
||||
grantDecision(store, {
|
||||
grant: {
|
||||
method: 'host', agent, host, commands, commandsSha256: sha256(JSON.stringify(commands)), rotateBy: dayFrom(30),
|
||||
registrySha256: registrySha256({ ...ENTRY, sudo: true }), definitionSha256: hostDefinitionSha256(doc.definitions[doc.roles[agent].definition]),
|
||||
},
|
||||
});
|
||||
// A Gitea decision approved and never run (T6b).
|
||||
const fixtureGitea = (agent) =>
|
||||
grantDecision(store, {
|
||||
grant: {
|
||||
method: 'gitea', agent, target: { projects: ['app'] }, rotateBy: dayFrom(30),
|
||||
definitionSha256: giteaDefinitionSha256(doc.definitions[doc.roles[agent].definition]),
|
||||
identitySha256: identitySha256(doc.roles[agent], doc.projects),
|
||||
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
|
||||
},
|
||||
});
|
||||
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'the work is done' });
|
||||
// A delete as the launcher's pass runs it: the human's request, the take, then lifecycleDelete. A
|
||||
// refused delete closes the request with its code, as the launcher does.
|
||||
const remove = (instance) => {
|
||||
const { request } = call('human', 'instances.set', { revision: REV, instance, op: 'delete' });
|
||||
assert.equal(broker.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
try {
|
||||
return { request, ...broker.lifecycleDelete({ business: 'demo', request }) };
|
||||
} catch (e) {
|
||||
if (typeof e.code !== 'string') throw e;
|
||||
broker.lifecycleDone({ business: 'demo', request, refusal: e.code });
|
||||
return { request, code: e.code };
|
||||
}
|
||||
};
|
||||
const tag = (agent, host) => grantTag('demo', agent, host);
|
||||
const keyFile = (agent, host) => join(storeRoot, 'demo', agent, 'host.' + host);
|
||||
const tokenFile = (agent) => join(storeRoot, 'demo', agent, 'gitea');
|
||||
const hostRow = (agent, host) => store.get("SELECT * FROM host_grants WHERE business='demo' AND agent=? AND host=?", agent, host);
|
||||
const giteaRow = (agent) => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent=?", agent);
|
||||
const state = (agent, service) => credentials.status().find((e) => e.instance === 'demo/' + agent && e.service === service)?.state;
|
||||
const userTokens = (login) => gitea.tokens(login) ?? [];
|
||||
const revoked = (agent, where = () => true) => grantEvents().filter((e) => e.action === 'grant.revoked' && e.agent === agent && where(e));
|
||||
const closed = (revoke) => grantEvents().filter((e) => e.action === 'grant.revoke.closed' && e.revoke === revoke);
|
||||
const noLeak = () => {
|
||||
const text = [
|
||||
...store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body),
|
||||
...store.all("SELECT body FROM messages WHERE business='demo'").map((r) => r.body),
|
||||
JSON.stringify(gitea.requests),
|
||||
].join('\n');
|
||||
assert.ok(gitea.issued.every((tok) => !text.includes(tok)), 'a token value reached the store or the request log');
|
||||
};
|
||||
return {
|
||||
dir, gitea, store, credentials, doc, org, sw, lines, sent, held, failing, caps, call, make, grantEvents, ended, allowed, inbox,
|
||||
raiseHost, raiseGitea, approve, approveHost, approveGitea, fixtureHost, fixtureGitea, fire, remove, tag, keyFile, tokenFile,
|
||||
hostRow, giteaRow, state, userTokens, revoked, closed, noLeak,
|
||||
get broker() {
|
||||
return broker;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Coder's three grants: web1, web2 and Gitea, each through the human's yes and its executor.
|
||||
async function threeGrants(f) {
|
||||
await f.approveHost('coder', 'web1');
|
||||
await f.approveHost('coder', 'web2');
|
||||
await f.approveGitea('coder');
|
||||
for (const h of ['web1', 'web2']) {
|
||||
assert.ok(f.lines[h].has(f.tag('coder', h)));
|
||||
assert.ok(existsSync(f.keyFile('coder', h)));
|
||||
}
|
||||
assert.equal(f.userTokens('demo-coder').length, 1);
|
||||
assert.ok(existsSync(f.tokenFile('coder')));
|
||||
}
|
||||
|
||||
// Every grant of coder is gone: records, key and token files, host lines, Gitea tokens, login.
|
||||
function allRevoked(f, hosts = ['web1', 'web2']) {
|
||||
for (const h of hosts) {
|
||||
assert.equal(f.hostRow('coder', h), undefined, h);
|
||||
assert.equal(existsSync(f.keyFile('coder', h)), false, h);
|
||||
assert.equal(f.lines[h].has(f.tag('coder', h)), false, h);
|
||||
assert.notEqual(f.state('coder', 'host.' + h), 'valid', h);
|
||||
}
|
||||
assert.equal(f.giteaRow('coder'), undefined);
|
||||
assert.equal(existsSync(f.tokenFile('coder')), false);
|
||||
assert.notEqual(f.state('coder', 'gitea'), 'valid');
|
||||
assert.deepEqual(f.userTokens('demo-coder'), []);
|
||||
assert.equal(f.gitea.users.get('demo-coder').prohibit_login, true);
|
||||
}
|
||||
|
||||
test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers with a job, then revokes all three', async (t) => {
|
||||
const f = await setup(t);
|
||||
await threeGrants(f);
|
||||
const out = f.fire('coder');
|
||||
assert.equal(out.done, false);
|
||||
assert.match(out.job, UUID);
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'host:web2', 'gitea']);
|
||||
assert.ok(!('transaction' in out));
|
||||
const [revoking, ...more] = f.allowed('fire.revoking');
|
||||
assert.equal(more.length, 0);
|
||||
assert.equal(revoking.body.job, out.job);
|
||||
assert.deepEqual(revoking.body.grants, out.revoking);
|
||||
assert.equal(revoking.actor_role, 'chief');
|
||||
assert.equal(revoking.subject, 'coder');
|
||||
await f.broker.grantsIdle();
|
||||
// Each host as hosts.revoke records it, with fire: true, line first so the close says removed.
|
||||
for (const h of ['web1', 'web2']) {
|
||||
const [r, ...rest] = f.revoked('coder', (e) => e.host === h);
|
||||
assert.equal(rest.length, 0, h);
|
||||
assert.equal(r.fire, true);
|
||||
assert.equal(r.granted, true);
|
||||
assert.equal(r.hostLine, 'pending');
|
||||
assert.equal(r.tag, f.tag('coder', h));
|
||||
const [c] = f.closed(r.revoke);
|
||||
assert.equal(c.hostLine, 'removed');
|
||||
assert.equal(c.fire, true);
|
||||
}
|
||||
const [g, ...rest] = f.revoked('coder', (e) => e.method === 'gitea');
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(g.user, 'demo-coder');
|
||||
assert.equal(g.granted, true);
|
||||
assert.deepEqual(f.closed(g.revoke).map((c) => c.serviceLeft), [[]]);
|
||||
allRevoked(f);
|
||||
const [done, ...again] = f.allowed('fire.done');
|
||||
assert.equal(again.length, 0);
|
||||
assert.equal(done.body.job, out.job);
|
||||
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
|
||||
assert.deepEqual(done.body.grantsLeft, []);
|
||||
assert.match(done.body.transaction, /^t-/);
|
||||
assert.equal(done.actor_role, 'chief');
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
|
||||
assert.ok(!f.doc.launch.instances.includes('coder'));
|
||||
// No step G message for a job that completed.
|
||||
assert.deepEqual(f.inbox(), []);
|
||||
f.noLeak();
|
||||
});
|
||||
|
||||
test('row 281 T3 (sage-2575): a delete with grants takes two requests: the first starts the job, the next completes', async (t) => {
|
||||
const f = await setup(t);
|
||||
await threeGrants(f);
|
||||
f.held.add('web1:remove');
|
||||
const first = f.remove('coder');
|
||||
assert.equal(first.code, 'grants-revoking');
|
||||
const [start, ...more] = f.allowed('grants.revoking');
|
||||
assert.equal(more.length, 0);
|
||||
assert.match(start.body.job, UUID);
|
||||
assert.deepEqual(start.body.grants, ['host:web1', 'host:web2', 'gitea']);
|
||||
assert.equal(start.body.verb, 'lifecycle.delete');
|
||||
assert.equal(start.body.request, first.request);
|
||||
assert.equal(start.actor_role, null);
|
||||
assert.equal(start.subject, 'coder');
|
||||
const job = start.body.job;
|
||||
const [message] = f.inbox();
|
||||
assert.equal(f.inbox().length, 1);
|
||||
for (const s of [job, 'host:web1', 'host:web2', 'gitea', 'Raise the delete again']) assert.ok(message.includes(s), s);
|
||||
// Held: a second delete refuses grants-revoking, with no second job and no second message.
|
||||
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
|
||||
assert.equal(f.remove('coder').code, 'grants-revoking');
|
||||
assert.equal(f.allowed('grants.revoking').length, 1);
|
||||
assert.equal(f.inbox().length, 1);
|
||||
f.held.delete('web1:remove');
|
||||
await f.broker.grantsIdle();
|
||||
for (const h of ['web1', 'web2']) assert.equal(f.revoked('coder', (e) => e.host === h && e.delete === true).length, 1, h);
|
||||
allRevoked(f);
|
||||
const [done] = f.allowed('grants.revoked.done');
|
||||
assert.equal(done.body.job, job);
|
||||
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
|
||||
const end = f.inbox()[1];
|
||||
for (const s of [job, 'host:web1', 'host:web2', 'gitea', 'Raise the delete again']) assert.ok(end.includes(s), s);
|
||||
assert.equal(f.org.log.filter(([op]) => op === 'instances.set').length, 0);
|
||||
// The third request completes as today.
|
||||
const third = f.remove('coder');
|
||||
assert.equal(third.code, undefined);
|
||||
assert.match(third.transaction, /^t-/);
|
||||
assert.equal(third.retry, false);
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instances.set'), [['instances.set', 'coder']]);
|
||||
assert.equal(f.allowed('grants.revoking').length, 1);
|
||||
f.noLeak();
|
||||
});
|
||||
|
||||
test('row 281 T3b (Q2): a delete whose job refuses on host B; the retry refuses grant-revoke-failed and revokes only B and Gitea', async (t) => {
|
||||
const f = await setup(t);
|
||||
await threeGrants(f);
|
||||
f.failing.add('web2');
|
||||
assert.equal(f.remove('coder').code, 'grants-revoking');
|
||||
await f.broker.grantsIdle();
|
||||
const job = f.allowed('grants.revoking')[0].body.job;
|
||||
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
|
||||
assert.equal(more.length, 0);
|
||||
assert.deepEqual(
|
||||
{ job: refused.job, agent: refused.agent, grant: refused.grant, reason: refused.reason, code: refused.code, verb: refused.verb },
|
||||
{ job, agent: 'coder', grant: 'host:web2', reason: 'host-remove-failed', code: 'grant-revoke-failed', verb: 'lifecycle.delete' },
|
||||
);
|
||||
const end = f.inbox()[1];
|
||||
for (const s of [job, 'coder', 'web2', 'host-remove-failed', 'mosaic hosts revoke coder web2']) assert.ok(end.includes(s), s);
|
||||
// Nothing deleted: the writer saw no delete and the role is still there. Host A is revoked, B isn't.
|
||||
assert.equal(f.org.log.filter(([op]) => op === 'instances.set').length, 0);
|
||||
assert.ok(f.doc.roles.coder);
|
||||
assert.equal(f.hostRow('coder', 'web1'), undefined);
|
||||
assert.ok(f.hostRow('coder', 'web2'));
|
||||
assert.ok(existsSync(f.keyFile('coder', 'web2')));
|
||||
assert.ok(f.giteaRow('coder'));
|
||||
assert.equal(f.allowed('grants.revoked.done').length, 0);
|
||||
// B back: the next delete refuses grant-revoke-failed, names the earlier reason and the new job.
|
||||
f.failing.delete('web2');
|
||||
assert.equal(f.remove('coder').code, 'grant-revoke-failed');
|
||||
const retry = f.allowed('grants.revoking')[1].body;
|
||||
assert.deepEqual(retry.grants, ['host:web2', 'gitea']);
|
||||
const message = f.inbox()[2];
|
||||
for (const s of [job, retry.job, 'host:web2', 'host-remove-failed']) assert.ok(message.includes(s), s);
|
||||
assert.equal(f.org.log.filter(([op]) => op === 'instances.set').length, 0);
|
||||
await f.broker.grantsIdle();
|
||||
const [done] = f.allowed('grants.revoked.done');
|
||||
assert.equal(done.body.job, retry.job);
|
||||
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web2', 'gitea']);
|
||||
// Host A was sent one remove only, by the first job.
|
||||
assert.equal(f.sent.filter((x) => x === `remove web1 ${f.tag('coder', 'web1')}`).length, 1);
|
||||
allRevoked(f);
|
||||
assert.match(f.remove('coder').transaction, /^t-/);
|
||||
});
|
||||
|
||||
test('row 281 T3b: B cleared by hand with hosts.revoke; the next delete starts a job for Gitea and the leftover line only', async (t) => {
|
||||
const f = await setup(t);
|
||||
await threeGrants(f);
|
||||
f.failing.add('web2');
|
||||
assert.equal(f.remove('coder').code, 'grants-revoking');
|
||||
await f.broker.grantsIdle();
|
||||
const manual = await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web2', reason: 'web2 is gone' });
|
||||
assert.equal(manual.hostLineLeft, f.tag('coder', 'web2'));
|
||||
assert.equal(f.hostRow('coder', 'web2'), undefined);
|
||||
// The refused grant isn't in the set any more, so this is a new start, not a retry.
|
||||
assert.equal(f.remove('coder').code, 'grants-revoking');
|
||||
const second = f.allowed('grants.revoking')[1].body;
|
||||
assert.deepEqual(second.grants, ['gitea', 'line:web2']);
|
||||
await f.broker.grantsIdle();
|
||||
const done = f.allowed('grants.revoked.done')[0].body;
|
||||
assert.equal(done.job, second.job);
|
||||
assert.deepEqual(done.grants.map((x) => x.grant), ['gitea']);
|
||||
assert.deepEqual(done.grantsLeft, [{ host: 'web2', tag: f.tag('coder', 'web2') }]);
|
||||
assert.ok(f.inbox().at(-1).includes(`${f.tag('coder', 'web2')} on web2`));
|
||||
// The line was tried once and listed: it doesn't hold the delete again.
|
||||
assert.match(f.remove('coder').transaction, /^t-/);
|
||||
assert.equal(f.allowed('grants.revoking').length, 2);
|
||||
});
|
||||
|
||||
test('row 281 T3c (sage-2575 condition 4): a delete of an instance with no grant completes in one request', async (t) => {
|
||||
const f = await setup(t);
|
||||
const before = f.store.get("SELECT count(*) AS n FROM events WHERE business='demo'").n;
|
||||
const out = f.remove('helper');
|
||||
assert.equal(out.code, undefined);
|
||||
assert.match(out.transaction, /^t-/);
|
||||
assert.equal(out.retry, false);
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instances.set'), [['instances.set', 'helper']]);
|
||||
assert.equal(f.allowed('grants.revoking').length, 0);
|
||||
assert.deepEqual(f.inbox(), []);
|
||||
// The request, the take and the delete's own records: none from step G.
|
||||
const after = f.store
|
||||
.all("SELECT body FROM events WHERE business='demo' ORDER BY seq")
|
||||
.slice(before)
|
||||
.map((e) => JSON.parse(e.body).action ?? JSON.parse(e.body).operation ?? null);
|
||||
assert.ok(after.every((a) => !String(a).startsWith('grant')), after.join());
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.allowed('grants.revoked.done').length, 0);
|
||||
});
|
||||
|
||||
test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked, B and Gitea untouched; a repeat completes', async (t) => {
|
||||
const f = await setup(t);
|
||||
await threeGrants(f);
|
||||
f.failing.add('web2');
|
||||
const out = f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
|
||||
assert.equal(more.length, 0);
|
||||
assert.deepEqual(
|
||||
{ job: refused.job, agent: refused.agent, grant: refused.grant, reason: refused.reason, verb: refused.verb },
|
||||
{ job: out.job, agent: 'coder', grant: 'host:web2', reason: 'host-remove-failed', verb: 'instance.fire' },
|
||||
);
|
||||
const [message] = f.inbox();
|
||||
for (const s of [out.job, 'web2', 'host-remove-failed', 'mosaic hosts revoke coder web2', 'Run the fire again']) assert.ok(message.includes(s), s);
|
||||
// The file is unchanged and the instance still firing.
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), []);
|
||||
assert.ok(f.doc.launch.instances.includes('coder'));
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
assert.equal(f.allowed('fire.failed').length, 0);
|
||||
await rejects(() => f.raiseHost('coder', 'web3'), 'instance-fired');
|
||||
// A revoked; B's record, key and line there (line first: nothing local changed on B).
|
||||
assert.equal(f.revoked('coder', (e) => e.host === 'web1').length, 1);
|
||||
assert.equal(f.hostRow('coder', 'web1'), undefined);
|
||||
assert.equal(f.revoked('coder', (e) => e.host === 'web2').length, 0);
|
||||
assert.ok(f.hostRow('coder', 'web2'));
|
||||
assert.ok(existsSync(f.keyFile('coder', 'web2')));
|
||||
assert.equal(f.state('coder', 'host.web2'), 'valid');
|
||||
// Gitea comes after B: untouched.
|
||||
assert.equal(f.revoked('coder', (e) => e.method === 'gitea').length, 0);
|
||||
assert.ok(f.giteaRow('coder'));
|
||||
assert.equal(f.userTokens('demo-coder').length, 1);
|
||||
assert.equal(f.state('coder', 'gitea'), 'valid');
|
||||
// B back: the repeat revokes only B and Gitea.
|
||||
f.failing.delete('web2');
|
||||
const repeat = f.fire('coder');
|
||||
assert.deepEqual(repeat.revoking, ['host:web2', 'gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
const [done] = f.allowed('fire.done');
|
||||
assert.equal(done.body.job, repeat.job);
|
||||
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web2', 'gitea']);
|
||||
allRevoked(f);
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
|
||||
});
|
||||
|
||||
test('row 281 T4b (Q1): a line a manual hosts.revoke left on C is removed by the fire, with its close', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
await f.approveHost('coder', 'web3');
|
||||
f.failing.add('web3');
|
||||
const manual = await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
|
||||
const tag3 = f.tag('coder', 'web3');
|
||||
assert.equal(manual.hostLineLeft, tag3);
|
||||
f.failing.delete('web3');
|
||||
const out = f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.lines.web3.has(tag3), false);
|
||||
const close = f.grantEvents().filter((e) => e.action === 'grant.revoke.closed' && e.host === 'web3' && e.fire === true);
|
||||
assert.equal(close.length, 1);
|
||||
assert.equal(close[0].hostLine, 'removed');
|
||||
assert.equal(close[0].tag, tag3);
|
||||
// hosts.revoke's answer has no revoke id; the manual's grant.revoked has it.
|
||||
const [manualRevoked] = f.revoked('coder', (e) => e.host === 'web3' && e.fire !== true);
|
||||
assert.equal(close[0].revoke, manualRevoked.revoke);
|
||||
const [done] = f.allowed('fire.done');
|
||||
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'line:web3']);
|
||||
assert.deepEqual(done.body.grantsLeft, []);
|
||||
});
|
||||
|
||||
test('row 281 T4b (Q1): with C unreachable, the fire completes and names the line in grantsLeft', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
await f.approveHost('coder', 'web3');
|
||||
f.failing.add('web3');
|
||||
await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
|
||||
const tag3 = f.tag('coder', 'web3');
|
||||
const out = f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
|
||||
const [done] = f.allowed('fire.done');
|
||||
assert.equal(done.body.job, out.job);
|
||||
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1']);
|
||||
assert.deepEqual(done.body.grantsLeft, [{ host: 'web3', tag: tag3 }]);
|
||||
assert.ok(f.lines.web3.has(tag3));
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
|
||||
});
|
||||
|
||||
test("row 281 T5: Gitea's admin token can't be checked: the job refuses with the Gitea grant intact", async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveGitea('coder');
|
||||
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/version', { status: 500, json: {} });
|
||||
const out = f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
off();
|
||||
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
|
||||
assert.deepEqual([refused.grant, refused.reason, refused.job], ['gitea', 'gitea-admin-unavailable', out.job]);
|
||||
assert.ok(f.inbox()[0].includes('mosaic grants sweep gitea'));
|
||||
assert.ok(f.giteaRow('coder'));
|
||||
assert.ok(existsSync(f.tokenFile('coder')));
|
||||
assert.equal(f.state('coder', 'gitea'), 'valid');
|
||||
assert.equal(f.userTokens('demo-coder').length, 1);
|
||||
assert.equal(f.gitea.users.get('demo-coder').prohibit_login, false);
|
||||
assert.equal(f.revoked('coder').length, 0);
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), []);
|
||||
});
|
||||
|
||||
test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat retries only this agent's entries", async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveGitea('helper');
|
||||
await f.approveGitea('coder');
|
||||
const tokenDelete = (login) => (e) => e.method === 'DELETE' && e.path.startsWith(`/api/v1/users/${login}/tokens/`);
|
||||
// Another agent's left entry, from a manual revoke whose DELETE failed.
|
||||
const offHelper = f.gitea.rule(tokenDelete('demo-helper'), { status: 500, json: {} });
|
||||
const manual = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'helper', reason: 'test' });
|
||||
assert.equal(manual.serviceLeft.length, 1);
|
||||
const helperDeletes = () => f.gitea.requests.filter(tokenDelete('demo-helper')).length;
|
||||
const before = helperDeletes();
|
||||
const offCoder = f.gitea.rule(tokenDelete('demo-coder'), { status: 500, json: {} });
|
||||
const out = f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
|
||||
assert.equal(refused.grant, 'gitea');
|
||||
// The close's serviceLeft (N10): the token id, and "tokens" for the fire's list that didn't clear.
|
||||
assert.match(refused.reason, /^gitea-left:\d+,tokens$/);
|
||||
assert.equal(refused.job, out.job);
|
||||
assert.equal(f.userTokens('demo-coder').length, 1);
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
// Gitea back for coder only. The helper's DELETE still fails, so a business-wide sweep would show.
|
||||
offCoder();
|
||||
const repeat = f.fire('coder');
|
||||
assert.deepEqual(repeat.revoking, ['gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
const [done] = f.allowed('fire.done');
|
||||
assert.equal(done.body.job, repeat.job);
|
||||
assert.deepEqual(f.userTokens('demo-coder'), []);
|
||||
const swept = f.grantEvents().filter((e) => e.action === 'grant.swept' && e.job === repeat.job);
|
||||
assert.equal(swept.length, 1);
|
||||
assert.equal(swept[0].agent, 'coder');
|
||||
assert.deepEqual(swept[0].left.map((e) => e.agent), ['helper']);
|
||||
// Neither job touched the helper's token.
|
||||
assert.equal(helperDeletes(), before);
|
||||
assert.equal(f.userTokens('demo-helper').length, 1);
|
||||
offHelper();
|
||||
});
|
||||
|
||||
test('row 281 T6 (sage-2574): a host decision approved before the fire and run after it ends revoked, with nothing applied', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = f.fixtureHost('coder', 'web1');
|
||||
const out = f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1']);
|
||||
await f.broker.grantsIdle();
|
||||
const [r] = f.revoked('coder', (e) => e.host === 'web1');
|
||||
assert.equal(r.granted, false);
|
||||
assert.equal(r.fire, true);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
assert.equal(await f.broker.runHostGrant('demo', id), 'revoked');
|
||||
assert.equal(f.ended(id).code, 'revoked');
|
||||
assert.equal(f.hostRow('coder', 'web1'), undefined);
|
||||
assert.equal(existsSync(f.keyFile('coder', 'web1')), false);
|
||||
assert.ok(!f.sent.some((x) => x.startsWith('add ')));
|
||||
});
|
||||
|
||||
test('row 281 T6b (sage-2574): a Gitea decision approved before the fire and run after it ends revoked before any Gitea write', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = f.fixtureGitea('coder');
|
||||
const out = f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
const [r] = f.revoked('coder', (e) => e.method === 'gitea');
|
||||
assert.deepEqual([r.user, r.granted, r.fire, r.decisions], [null, false, true, [id]]);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
const calls = f.gitea.requests.length;
|
||||
await f.broker.runGiteaGrant('demo', id);
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.ended(id).code, 'revoked');
|
||||
assert.equal(f.giteaRow('coder'), undefined);
|
||||
assert.equal(existsSync(f.tokenFile('coder')), false);
|
||||
// Only step 0's C1 to C4 reach Gitea, all reads; step 1's recheck ends it before any write.
|
||||
assert.deepEqual(f.gitea.requests.slice(calls).map((e) => `${e.method} ${e.path}`), [
|
||||
'GET /api/v1/version',
|
||||
'GET /api/v1/user',
|
||||
'GET /api/v1/token',
|
||||
'GET /api/v1/user/repos?limit=1&page=1',
|
||||
]);
|
||||
assert.ok(!f.gitea.requests.some((e) => e.method === 'POST' && e.path.endsWith('/tokens')));
|
||||
});
|
||||
|
||||
test('row 281 T6c: a host executor in flight aborts and ends revoked; a Gitea executor in flight is awaited and leaves no token', async (t) => {
|
||||
const f = await setup(t);
|
||||
// Host: held at its add.
|
||||
f.held.add('web1:add');
|
||||
const host = await f.raiseHost('coder', 'web1');
|
||||
f.call('human', 'decision.resolve', { id: host.id, choice: 'yes' });
|
||||
await until(() => f.sent.some((x) => x.startsWith('add web1 ')));
|
||||
// Gitea: held at the token create.
|
||||
let go;
|
||||
const gate = new Promise((r) => (go = r));
|
||||
t.after(() => go());
|
||||
let reached = false;
|
||||
const off = f.gitea.rule(
|
||||
(e) => e.method === 'POST' && e.path === '/api/v1/users/demo-coder/tokens',
|
||||
async () => {
|
||||
reached = true;
|
||||
await gate;
|
||||
return { pass: true };
|
||||
},
|
||||
);
|
||||
const gd = await f.raiseGitea('coder');
|
||||
f.call('human', 'decision.resolve', { id: gd.id, choice: 'yes' });
|
||||
await until(() => reached);
|
||||
const out = f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
|
||||
await until(() => f.ended(host.id));
|
||||
assert.equal(f.ended(host.id).code, 'revoked');
|
||||
// The job waits on the Gitea executor.
|
||||
await sleep(50);
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
go();
|
||||
await f.broker.grantsIdle();
|
||||
off();
|
||||
f.held.delete('web1:add');
|
||||
assert.equal(f.hostRow('coder', 'web1'), undefined);
|
||||
assert.equal(existsSync(f.keyFile('coder', 'web1')), false);
|
||||
assert.equal(f.lines.web1.has(f.tag('coder', 'web1')), false);
|
||||
assert.ok(f.ended(gd.id));
|
||||
assert.deepEqual(f.userTokens('demo-coder'), []);
|
||||
assert.equal(f.giteaRow('coder'), undefined);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
f.noLeak();
|
||||
});
|
||||
|
||||
test('row 281 T6d (B1): undecided host and Gitea decisions are in the set, and approved after the fire both end revoked', async (t) => {
|
||||
const f = await setup(t);
|
||||
const host = await f.raiseHost('coder', 'web1');
|
||||
const gd = await f.raiseGitea('coder');
|
||||
const out = f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
for (const d of [host, gd]) {
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await until(() => f.ended(d.id));
|
||||
}
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.ended(host.id).code, 'revoked');
|
||||
assert.equal(f.ended(gd.id).code, 'revoked');
|
||||
assert.ok(!f.sent.some((x) => x.startsWith('add ')));
|
||||
assert.ok(!f.gitea.requests.some((e) => e.method === 'POST' && e.path.endsWith('/tokens')));
|
||||
assert.equal(f.hostRow('coder', 'web1'), undefined);
|
||||
assert.equal(f.giteaRow('coder'), undefined);
|
||||
});
|
||||
|
||||
test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and after the fire, goes through after a rehire, and refuses grant-revoke-running during a delete job', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
// Firing with no job: step B refused, so fire.stopped stands with no fire.done.
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
|
||||
f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.allowed('fire.failed').length, 1);
|
||||
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
|
||||
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
|
||||
// After fire.done: delisted.
|
||||
f.org.fireError = null;
|
||||
assert.match(f.fire('coder').transaction, /^t-/);
|
||||
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
|
||||
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
|
||||
// A rehire lists the name again.
|
||||
f.doc.launch.instances.push('coder');
|
||||
f.broker.reloadBusiness('demo', f.doc);
|
||||
assert.match((await f.raiseHost('coder', 'web2')).id, UUID);
|
||||
// While a delete's job holds ops.
|
||||
await f.approveHost('ops', 'web3');
|
||||
f.held.add('web3:remove');
|
||||
assert.equal(f.remove('ops').code, 'grants-revoking');
|
||||
await rejects(() => f.raiseHost('ops', 'web1'), 'grant-revoke-running');
|
||||
f.held.delete('web3:remove');
|
||||
await f.broker.grantsIdle();
|
||||
});
|
||||
|
||||
test('row 281 T7: an instance with no grant fires as today, synchronously, with the same events', async (t) => {
|
||||
const f = await setup(t);
|
||||
const out = f.fire('helper');
|
||||
assert.deepEqual(Object.keys(out).sort(), ['cancelled', 'request', 'revision', 'runs', 'taken', 'transaction']);
|
||||
assert.match(out.transaction, /^t-/);
|
||||
// Recorded before the verb answered: no job.
|
||||
assert.equal(f.allowed('fire.stopped').length, 1);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
assert.equal(f.allowed('fire.done')[0].body.job, undefined);
|
||||
assert.equal(f.allowed('fire.revoking').length, 0);
|
||||
assert.equal(f.allowed('role.fire').length, 1);
|
||||
assert.deepEqual(f.org.log.map(([op]) => op), ['instance.fireCheck', 'instance.fire']);
|
||||
await f.broker.grantsIdle();
|
||||
assert.deepEqual(f.inbox(), []);
|
||||
assert.equal(f.grantEvents().filter((e) => e.action?.startsWith('grant.revoke')).length, 0);
|
||||
});
|
||||
|
||||
test('row 281 T9 (B3): one step G per agent; the manual verbs refuse while it runs, and sweep names it running', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
await f.approveGitea('coder');
|
||||
f.held.add('web1:remove');
|
||||
f.fire('coder');
|
||||
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
|
||||
assert.equal(code(() => f.fire('coder')), 'fire-in-progress');
|
||||
assert.equal(f.remove('coder').code, 'grants-revoking');
|
||||
assert.equal(f.allowed('grants.revoking').length, 0);
|
||||
assert.equal(f.allowed('fire.revoking').length, 1);
|
||||
await rejects(() => f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web1', reason: 'r' }), 'grant-revoke-running');
|
||||
await rejects(() => f.call('tester', 'hosts.revoke', { agent: 'coder', host: 'web1', reason: 'r' }), 'grant-revoke-running');
|
||||
await rejects(() => f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'grant-revoke-running');
|
||||
const sweep = await f.call('human', 'grants.sweep', { service: 'gitea' });
|
||||
assert.deepEqual(sweep.running, ['coder']);
|
||||
f.held.delete('web1:remove');
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.revoked('coder', (e) => e.host === 'web1').length, 1);
|
||||
assert.equal(f.revoked('coder', (e) => e.method === 'gitea').length, 1);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
allRevoked(f, ['web1']);
|
||||
});
|
||||
|
||||
test('row 281 T10 (B2): the fire and the delete answer while their jobs are held, and the broker answers other verbs', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveGitea('coder');
|
||||
await f.approveHost('ops', 'web2');
|
||||
let go;
|
||||
const gate = new Promise((r) => (go = r));
|
||||
t.after(() => go());
|
||||
let reached = false;
|
||||
const off = f.gitea.rule(
|
||||
(e) => e.method === 'DELETE' && e.path.startsWith('/api/v1/users/demo-coder/tokens/'),
|
||||
async () => {
|
||||
reached = true;
|
||||
await gate;
|
||||
return { pass: true };
|
||||
},
|
||||
);
|
||||
f.held.add('web2:remove');
|
||||
// The verbs answer before the event loop turns, so before any part of a job has run.
|
||||
let turned = false;
|
||||
setImmediate(() => (turned = true));
|
||||
const fired = f.fire('coder');
|
||||
const deleted = f.remove('ops');
|
||||
assert.equal(turned, false);
|
||||
assert.equal(fired.done, false);
|
||||
assert.equal(deleted.code, 'grants-revoking');
|
||||
await until(() => reached && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
|
||||
// Held: another session's verb answers.
|
||||
assert.ok(f.call('pm', 'message.send', { to: 'chief', body: 'still here' }));
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
assert.equal(f.allowed('grants.revoked.done').length, 0);
|
||||
go();
|
||||
f.held.delete('web2:remove');
|
||||
await f.broker.grantsIdle();
|
||||
off();
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
assert.equal(f.allowed('grants.revoked.done').length, 1);
|
||||
assert.deepEqual(f.userTokens('demo-coder'), []);
|
||||
assert.equal(f.hostRow('ops', 'web2'), undefined);
|
||||
});
|
||||
|
||||
test("row 281 T11 (C1): a fire's job completes after the chief's run ends and processing halts", async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
f.held.add('web1:remove');
|
||||
const out = f.fire('coder');
|
||||
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
|
||||
f.broker.endLaunch({ business: 'demo', run: 'chief-run', reason: 'test' });
|
||||
f.sw.state = { version: 1, halted: true, generation: GENERATION };
|
||||
f.held.delete('web1:remove');
|
||||
await f.broker.grantsIdle();
|
||||
const [done] = f.allowed('fire.done');
|
||||
assert.equal(done.body.job, out.job);
|
||||
assert.match(done.body.transaction, /^t-/);
|
||||
const [used, ...more] = f.allowed('role.fire');
|
||||
assert.equal(more.length, 0);
|
||||
assert.equal(used.actor_role, 'chief');
|
||||
assert.equal(used.body.transaction, done.body.transaction);
|
||||
assert.ok(!f.doc.launch.instances.includes('coder'));
|
||||
assert.equal(f.allowed('fire.failed').length, 0);
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
|
||||
});
|
||||
|
||||
test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the repeat runs B and C synchronously", async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
// N7: the writer's own credential-revoke-failed, which step G's key revokes can't clear.
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
|
||||
const out = f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [failed, ...more] = f.allowed('fire.failed');
|
||||
assert.equal(more.length, 0);
|
||||
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'credential-revoke-failed']);
|
||||
assert.equal(failed.actor_role, 'chief');
|
||||
const [message] = f.inbox();
|
||||
for (const s of [out.job, 'host:web1', 'step B', 'credential-revoke-failed']) assert.ok(message.includes(s), s);
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
assert.ok(f.doc.launch.instances.includes('coder'));
|
||||
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
|
||||
// The grant was revoked before step B.
|
||||
assert.equal(f.hostRow('coder', 'web1'), undefined);
|
||||
f.org.fireError = null;
|
||||
const repeat = f.fire('coder');
|
||||
assert.equal(repeat.job, undefined);
|
||||
assert.match(repeat.transaction, /^t-/);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
assert.ok(!f.doc.launch.instances.includes('coder'));
|
||||
});
|
||||
|
||||
test('row 281 T13 (N4): a restart records each held job interrupted once, with one message each; an ended job gets none', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approveHost('helper', 'web3');
|
||||
await f.approveHost('coder', 'web1');
|
||||
await f.approveHost('ops', 'web2');
|
||||
// A delete job that ended before the restart.
|
||||
assert.equal(f.remove('helper').code, 'grants-revoking');
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.allowed('grants.revoked.done').length, 1);
|
||||
f.held.add('web1:remove');
|
||||
f.held.add('web2:remove');
|
||||
const fired = f.fire('coder');
|
||||
assert.equal(f.remove('ops').code, 'grants-revoking');
|
||||
const deleteJob = f.allowed('grants.revoking').at(-1).body.job;
|
||||
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`) && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
|
||||
const restartNotes = () => f.inbox().filter((m) => m.includes('interrupted by a bus restart'));
|
||||
const second = f.make();
|
||||
second.reconcileGrants();
|
||||
const interrupted = f.allowed('grants.job.interrupted');
|
||||
assert.deepEqual(
|
||||
interrupted.map((e) => [e.body.job, e.body.agent, e.body.verb]).sort(),
|
||||
[[fired.job, 'coder', 'instance.fire'], [deleteJob, 'ops', 'lifecycle.delete']].sort(),
|
||||
);
|
||||
assert.ok(interrupted.every((e) => e.actor_role === null));
|
||||
const notes = restartNotes();
|
||||
assert.equal(notes.length, 2);
|
||||
assert.ok(notes.some((m) => m.includes(fired.job) && m.includes('Run the fire again')));
|
||||
assert.ok(notes.some((m) => m.includes(deleteJob) && m.includes('Raise the delete again')));
|
||||
// A third broker on the same root sends nothing more.
|
||||
f.make().reconcileGrants();
|
||||
assert.equal(f.allowed('grants.job.interrupted').length, 2);
|
||||
assert.equal(restartNotes().length, 2);
|
||||
f.held.clear();
|
||||
await f.broker.grantsIdle();
|
||||
});
|
||||
|
||||
test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision stops the fire on host-unknown; the human's no closes it, and the repeat fire has no job", async (t) => {
|
||||
const f = await setup(t);
|
||||
const d = await f.raiseHost('coder', 'web1');
|
||||
// N1: hosts.remove doesn't check open decisions, so the decision outlives its host.
|
||||
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
|
||||
const out = f.fire('coder');
|
||||
assert.match(out.job, UUID);
|
||||
assert.deepEqual(out.revoking, ['host:web1']);
|
||||
await f.broker.grantsIdle();
|
||||
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
|
||||
assert.equal(more.length, 0);
|
||||
assert.deepEqual([refused.job, refused.grant, refused.reason, refused.verb], [out.job, 'host:web1', 'host-unknown', 'instance.fire']);
|
||||
const [message, ...others] = f.inbox();
|
||||
assert.equal(others.length, 0);
|
||||
for (const s of [out.job, 'web1', 'host-unknown']) assert.ok(message.includes(s), s);
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
assert.ok(f.doc.launch.instances.includes('coder'));
|
||||
// The human's no: no executor runs, so the closed check alone takes it out of the set (M22).
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.ended(d.id), undefined);
|
||||
const repeat = f.fire('coder');
|
||||
assert.equal(repeat.job, undefined);
|
||||
assert.match(repeat.transaction, /^t-/);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
assert.equal(f.allowed('fire.done')[0].body.job, undefined);
|
||||
assert.ok(!f.doc.launch.instances.includes('coder'));
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 1);
|
||||
});
|
||||
+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;
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'session.launched',
|
||||
'session.ended',
|
||||
'action.allowed',
|
||||
'action.refused',
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.state',
|
||||
'task.closed',
|
||||
'task.changed.external',
|
||||
'task.conflict',
|
||||
'task.missing',
|
||||
'review.requested',
|
||||
'review.verdict',
|
||||
'human.input',
|
||||
'config.refused',
|
||||
'credential.expiring',
|
||||
'credential.expired',
|
||||
'credential.changed',
|
||||
'launch.revoked',
|
||||
'launch.restored',
|
||||
'digest.sent')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
subject TEXT,
|
||||
corrects TEXT REFERENCES events(id),
|
||||
body TEXT NOT NULL CHECK (json_valid(body))
|
||||
) STRICT;
|
||||
CREATE TABLE role_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, role TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE decisions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, project TEXT,
|
||||
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
||||
action TEXT NOT NULL,
|
||||
route_to TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
||||
recommendation TEXT NOT NULL,
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
, spec_path TEXT, spec_commit TEXT) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
||||
by TEXT NOT NULL,
|
||||
choice TEXT, note TEXT, via TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
||||
to_role TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
in_reply_to TEXT REFERENCES messages(id),
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
, to_name TEXT) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
||||
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE task_snapshots (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||
updated TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||
read_at TEXT,
|
||||
role TEXT, run TEXT,
|
||||
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||
) STRICT;
|
||||
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
||||
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
||||
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
||||
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
||||
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
||||
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
||||
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
||||
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.missing' AND (
|
||||
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.created' AND (
|
||||
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||
AND kind = 'human.input' AND business = NEW.business)
|
||||
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||
CREATE VIEW launch_state AS
|
||||
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||
CREATE VIEW task_external_changes AS
|
||||
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||
FROM task_snapshots p
|
||||
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||
WHERE p.source = 'poll'
|
||||
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||
CREATE VIEW task_current AS
|
||||
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
||||
FROM task_snapshots s
|
||||
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
||||
WHERE c.business = s.business AND c.task_ref = s.task_ref
|
||||
AND (c.source = 'self'
|
||||
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
||||
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
||||
ORDER BY ls.seq DESC LIMIT 1), '')));
|
||||
CREATE VIEW tasks_open AS
|
||||
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||
FROM task_current s
|
||||
WHERE json_type(s.fields, '$.gone') IS NULL
|
||||
AND json_extract(s.fields, '$.done') = 0;
|
||||
CREATE VIEW urgent_inbox AS
|
||||
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||
FROM decisions d
|
||||
WHERE d.class = 'gated' AND d.blocking = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||
CREATE TABLE task_comment_cursors (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL CHECK (task_id > 0),
|
||||
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
||||
) STRICT;
|
||||
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
||||
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
||||
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
||||
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
||||
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
||||
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
||||
CREATE TABLE projects (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE (business, name)
|
||||
) STRICT;
|
||||
CREATE TABLE threads (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE sessions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
predecessor TEXT REFERENCES sessions(id),
|
||||
UNIQUE (thread, name),
|
||||
UNIQUE (thread, op)
|
||||
) STRICT;
|
||||
CREATE TABLE session_defaults (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
generation INTEGER NOT NULL CHECK (generation >= 1),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE INDEX message_threads_session ON message_threads (session);
|
||||
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
|
||||
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
|
||||
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
|
||||
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
|
||||
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
|
||||
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
|
||||
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
|
||||
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
|
||||
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
|
||||
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
|
||||
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
|
||||
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
|
||||
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
|
||||
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TABLE instance_names (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
|
||||
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
|
||||
AND n.instance <> NEW.instance
|
||||
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
|
||||
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
|
||||
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
|
||||
CREATE TABLE usage_records (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
run TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
account TEXT,
|
||||
turn TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
|
||||
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
|
||||
code TEXT,
|
||||
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
|
||||
input INTEGER,
|
||||
output INTEGER,
|
||||
cache_write INTEGER,
|
||||
cache_read INTEGER,
|
||||
UNIQUE (business,run,turn,source,phase),
|
||||
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
|
||||
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
|
||||
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TABLE hosts (
|
||||
business TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
user TEXT NOT NULL,
|
||||
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
|
||||
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
|
||||
installer_fingerprint TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
checked_at TEXT,
|
||||
PRIMARY KEY (business, host),
|
||||
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TABLE host_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent, host),
|
||||
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
|
||||
) STRICT;
|
||||
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'action.allowed'
|
||||
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
|
||||
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
|
||||
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
|
||||
CREATE TABLE gitea_users (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
id INTEGER NOT NULL CHECK (id > 0),
|
||||
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent)
|
||||
) STRICT;
|
||||
CREATE TABLE gitea_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
token_id INTEGER NOT NULL CHECK (token_id > 0),
|
||||
token_name TEXT NOT NULL,
|
||||
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent),
|
||||
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
|
||||
) STRICT;
|
||||
@@ -1216,6 +1216,27 @@ test('X-G14: a revoke Gitea cannot reach stays open, and a restart closes it', a
|
||||
await rejects(f.call('pm', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'revoke-refused');
|
||||
});
|
||||
|
||||
// Row 281 (#1786, d6 section 8, T8): grants.revoke shares its Gitea steps with a fire's step G now. This
|
||||
// pins the tester's dev-only refusal, the verb's records and their order, and that it starts no job.
|
||||
test('row 281 T8: a tester grants.revoke refuses tester-dev-only once bus.tester is off; the human revoke keeps its records and starts no job', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
f.broker.reloadBusiness('demo', { ...f.business, vars: { 'bus.tester': 'yes' } });
|
||||
const before = f.grantEvents().length;
|
||||
await rejects(f.call('tester', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'tester-dev-only');
|
||||
assert.equal(f.grantEvents().length, before);
|
||||
assert.ok(f.record());
|
||||
await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' });
|
||||
const after = f.grantEvents().slice(before)
|
||||
.filter((e) => e.action !== 'grant.call' && e.action !== 'grant.result')
|
||||
.map((e) => [e.action, e.fire ?? false]);
|
||||
assert.deepEqual(after, [['grant.revoked', false], ['grant.revoke.closed', false]]);
|
||||
assert.deepEqual(f.userTokens(), []);
|
||||
const jobs = f.store.all("SELECT body FROM events WHERE business='demo' AND kind='action.allowed'").map((e) => JSON.parse(e.body).action)
|
||||
.filter((a) => /^(fire|grants)\./.test(a));
|
||||
assert.deepEqual(jobs, []);
|
||||
});
|
||||
|
||||
test('X-G15: look-alike commit steps outside the grant writer are ignored at start, by revoke and by status', async (t) => {
|
||||
const f = await setup(t);
|
||||
const first = await f.approve();
|
||||
|
||||
@@ -32,14 +32,27 @@ 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'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
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','thread_shares')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
// 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 +74,8 @@ 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'));
|
||||
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -82,10 +97,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), '3m');
|
||||
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 +123,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), '3m');
|
||||
});
|
||||
|
||||
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 +134,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 = '3m';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3k');
|
||||
assert.equal(version(path), '3m');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -780,6 +780,24 @@ test('G26: the human, the assistant and the tester revoke; the chief and a pm re
|
||||
assert.deepEqual((await f.call('tester', 'hosts.sweep', { host: 'web1' })).removed, []);
|
||||
});
|
||||
|
||||
// Row 281 (#1786, d6 section 8, T8): hosts.revoke shares its steps with a fire's step G now. G12 pins its
|
||||
// records and their order; this pins the tester's dev-only refusal and that a verb revoke starts no job.
|
||||
test('row 281 T8: a tester hosts.revoke refuses tester-dev-only once bus.tester is off, and a human revoke writes no job records', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
f.caps.tester = f.broker.bindTester({ business: 'demo' });
|
||||
f.broker.reloadBusiness('demo', { ...f.business, vars: { 'bus.tester': 'yes' } });
|
||||
const args = { agent: 'ops', host: 'web1', reason: 'r' };
|
||||
await refusesWith(f.call('tester', 'hosts.revoke', args), 'tester-dev-only');
|
||||
assert.equal(revokeBodies(f).length, 0);
|
||||
assert.ok(f.record());
|
||||
assert.equal((await f.call('human', 'hosts.revoke', args)).granted, true);
|
||||
assert.deepEqual(revokeBodies(f).map((e) => [e.action, e.fire, e.delete]), [['grant.revoked', undefined, undefined], ['grant.revoke.closed', undefined, undefined]]);
|
||||
const jobs = f.store.all("SELECT body FROM events WHERE business='demo' AND kind='action.allowed'").map((e) => JSON.parse(e.body).action)
|
||||
.filter((a) => /^(fire|grants)\./.test(a));
|
||||
assert.deepEqual(jobs, []);
|
||||
});
|
||||
|
||||
test('G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then', async (t) => {
|
||||
const f = await setup(t);
|
||||
await f.approve();
|
||||
|
||||
@@ -18,6 +18,10 @@ 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'];
|
||||
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
|
||||
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -37,11 +41,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','thread_shares')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
// 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 +77,8 @@ 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'));
|
||||
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -87,10 +102,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), '3m');
|
||||
assert.deepEqual(migrated3l(rows(path)), before);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), 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 +127,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), '3m');
|
||||
});
|
||||
|
||||
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 +138,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 = '3m';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3k');
|
||||
assert.equal(version(path), '3m');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { busBusiness } from '../src/business.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
@@ -19,7 +21,7 @@ const store = (agent, service) => ({ store: true, [service === 'gitea' ? 'rotate
|
||||
// coder: gitea in the store, vikunja a file reference. rev-2 and rev-3: both in the store, with a
|
||||
// tracker bot. `drop` names instances whose credentials the boot copy leaves out, as a restart that
|
||||
// dropped the entry would: the running bus then has no entry for a file on disk (sage-1592).
|
||||
async function setup(t, { drop = [] } = {}) {
|
||||
async function setup(t, { drop = [], seed = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-lifecycle-revoke-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
@@ -31,6 +33,12 @@ async function setup(t, { drop = [] } = {}) {
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
// Row 281 T1: records written before the start, as an earlier bus left them.
|
||||
if (seed) {
|
||||
const seeded = new Store(join(root, 'data'));
|
||||
seed(seeded);
|
||||
seeded.close();
|
||||
}
|
||||
const b = loadBusiness('demo', { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
|
||||
@@ -181,3 +189,32 @@ test('T21 delete: a write between the raise and the take makes the delete refuse
|
||||
assert.deepEqual(readdirSync(f.agentDir('rev-2')), ['vikunja']);
|
||||
assert.deepEqual(f.status('rev-2'), { gitea: 'not-issued', vikunja: 'valid' });
|
||||
});
|
||||
|
||||
// Row 281 (#1786, d6 section 1) T1: pins what section 1 read from the code, and what still holds after the
|
||||
// change. Step B's revoke refuses while a grant's file is in the agent's store, which is why step G runs
|
||||
// first. A fire with no grant record goes straight to step B, so a stray host.<id> file planted after the
|
||||
// start (the start sweep would unlink it) still refuses there.
|
||||
test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses step B credential-revoke-failed, file unchanged', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.plant('coder', 'host.web1');
|
||||
const before = f.bytes();
|
||||
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
|
||||
assert.equal(f.bytes(), before);
|
||||
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
|
||||
rmSync(join(f.agentDir('coder'), 'host.web1'));
|
||||
assert.equal(f.fire('coder').delisted, true);
|
||||
});
|
||||
|
||||
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
|
||||
// start loads the reference with no token, so the bus comes up and the entry reads not-issued.
|
||||
test('row 281 T1 start: a host grant record whose key file is gone starts and reads not-issued', async (t) => {
|
||||
const entry = { address: '192.0.2.10', port: 22, user: 'deploy', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
|
||||
const f = await setup(t, {
|
||||
seed: (store) => {
|
||||
hostRecord(store, { host: 'web1', entry });
|
||||
hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['uptime']] });
|
||||
},
|
||||
});
|
||||
assert.equal(existsSync(join(f.agentDir('coder'), 'host.web1')), false);
|
||||
assert.equal(f.status('coder')['host.web1'], 'not-issued');
|
||||
});
|
||||
@@ -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, '3m');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
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,9 @@ 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.
|
||||
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 23);
|
||||
assert.equal(count('trigger'), 89);
|
||||
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), '3m');
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
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 = '3m';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
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), '3m');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
|
||||
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, '3m');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
} 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 = '3m';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3k ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
|
||||
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
|
||||
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
|
||||
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
|
||||
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, randomUUID } from 'node:crypto';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const grant = (over = {}) => ({
|
||||
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
|
||||
});
|
||||
|
||||
// As user-principal.test.mjs: the access document is a function each test edits between requests.
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
},
|
||||
access: () => {
|
||||
if (access.fail) throw new Error('access file refused');
|
||||
return access.doc;
|
||||
},
|
||||
});
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
reader: b.bindReader({ business: 'demo' }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
};
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const code = (who, verb, args = {}) => {
|
||||
try {
|
||||
call(who, verb, args);
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const p1 = call('human', 'project.create', { name: 'shared' }).id;
|
||||
const p2 = call('human', 'project.create', { name: 'private' }).id;
|
||||
// alice and bob hold collaborator grants on p1, carol on p2 only.
|
||||
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
|
||||
access.doc.grants = base();
|
||||
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
|
||||
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
|
||||
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
|
||||
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
|
||||
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
|
||||
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
|
||||
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
|
||||
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
|
||||
}
|
||||
|
||||
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
|
||||
const { call, code, A, p1, rows, read } = setup(t);
|
||||
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
|
||||
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
|
||||
assert.deepEqual(read('bob', A), ['alice: hello']);
|
||||
const [listed] = call('bob', 'thread.list', { shared: true });
|
||||
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
|
||||
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
|
||||
// The plain list keeps its shape and holds only bob's own threads.
|
||||
assert.deepEqual(call('bob', 'thread.list'), []);
|
||||
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
|
||||
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
|
||||
for (const [verb, args] of [
|
||||
['thread.send', { body: 'bob was here' }],
|
||||
['thread.open', {}],
|
||||
['thread.share', { to: '*' }],
|
||||
['thread.share', { to: 'jason' }],
|
||||
['thread.unshare', { to: 'user:bob' }],
|
||||
['thread.shares', {}],
|
||||
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
|
||||
assert.equal(rows(), 1);
|
||||
assert.deepEqual(read('alice', A), ['alice: hello']);
|
||||
// The share is bob's alone: the owner and carol still can't read.
|
||||
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
|
||||
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
});
|
||||
|
||||
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
|
||||
const { call, code, A, H, rows, read } = setup(t);
|
||||
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
|
||||
assert.deepEqual(read('alice', H), ['jason: hello']);
|
||||
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
|
||||
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
|
||||
assert.deepEqual(read('bob', A), ['alice: hello']);
|
||||
assert.deepEqual(read('human', A), ['alice: hello']);
|
||||
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
|
||||
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
|
||||
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
|
||||
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
|
||||
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
|
||||
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
|
||||
assert.equal(rows(), 2);
|
||||
});
|
||||
|
||||
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
|
||||
const { access, call, code, base, A, p2, read } = setup(t);
|
||||
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
||||
assert.deepEqual(read('bob', A), ['alice: hello']);
|
||||
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
|
||||
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
|
||||
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
|
||||
// A grant on another instance is the same.
|
||||
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
|
||||
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
access.doc.grants = base();
|
||||
assert.deepEqual(read('bob', A), ['alice: hello']);
|
||||
// Removed, expired or an unreadable file: the gate refuses bob's next request.
|
||||
for (const [what, change] of [
|
||||
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
|
||||
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
|
||||
['unreadable', () => { access.fail = true; }],
|
||||
]) {
|
||||
change();
|
||||
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
|
||||
access.doc.grants = base();
|
||||
access.fail = false;
|
||||
assert.deepEqual(read('bob', A), ['alice: hello'], what);
|
||||
}
|
||||
});
|
||||
|
||||
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
|
||||
const { access, call, code, base, A, sent, read } = setup(t);
|
||||
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
||||
assert.deepEqual(read('human', A), ['alice: hello']);
|
||||
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
|
||||
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
|
||||
for (const [what, change] of [
|
||||
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
|
||||
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
|
||||
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
|
||||
['unreadable', () => { access.fail = true; }],
|
||||
]) {
|
||||
change();
|
||||
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
|
||||
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
|
||||
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
|
||||
access.doc.grants = base();
|
||||
access.fail = false;
|
||||
assert.deepEqual(read('human', A), ['alice: hello'], what);
|
||||
}
|
||||
// A viewer grant still covers: rule 3 asks for any level.
|
||||
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
|
||||
assert.deepEqual(read('human', A), ['alice: hello']);
|
||||
});
|
||||
|
||||
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
|
||||
const { store, call, code, A, rows, read } = setup(t);
|
||||
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
||||
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
|
||||
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
|
||||
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
|
||||
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
|
||||
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
|
||||
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
|
||||
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
||||
assert.deepEqual(read('bob', A), ['alice: hello']);
|
||||
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
|
||||
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
|
||||
{ op: 'share', target: 'user:bob', by: 'user:alice' },
|
||||
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
|
||||
{ op: 'share', target: 'user:bob', by: 'user:alice' },
|
||||
]);
|
||||
// An unshare of `*` leaves bob's own share in force.
|
||||
call('alice', 'thread.share', { thread: A, to: '*' });
|
||||
call('alice', 'thread.unshare', { thread: A, to: '*' });
|
||||
assert.deepEqual(read('bob', A), ['alice: hello']);
|
||||
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.equal(rows(), 5);
|
||||
});
|
||||
|
||||
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
|
||||
const { access, call, code, base, A, p1, rows } = setup(t);
|
||||
// carol's grant covers p2 only; dave has none; erin's is expired.
|
||||
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
|
||||
for (const to of ['user:carol', 'user:dave', 'user:erin'])
|
||||
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
|
||||
assert.equal(rows(), 0);
|
||||
// A viewer grant is enough to be shared with, though at this base it can't bind a session.
|
||||
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
|
||||
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
|
||||
// An unshare checks no coverage: an uncovered target's share can still be ended.
|
||||
access.doc.grants = base();
|
||||
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
|
||||
assert.equal(rows(), 2);
|
||||
});
|
||||
|
||||
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
|
||||
const { call, code, A, H, rows } = setup(t);
|
||||
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
|
||||
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
|
||||
for (const to of ['jason', 'user:x y'])
|
||||
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
|
||||
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
|
||||
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
|
||||
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
|
||||
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
|
||||
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
|
||||
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
|
||||
assert.equal(rows(), 0);
|
||||
// The owner shares its own threads only; a user's thread refuses the business human too.
|
||||
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
|
||||
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
|
||||
assert.equal(rows(), 0);
|
||||
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
|
||||
});
|
||||
|
||||
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
|
||||
const { access, call, code, base, A, p2, rows } = setup(t);
|
||||
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
|
||||
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
|
||||
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
|
||||
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
|
||||
assert.equal(rows(), 1);
|
||||
});
|
||||
|
||||
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
|
||||
const { store, call, code, A, H, rows } = setup(t);
|
||||
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
|
||||
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
|
||||
const was = refusals();
|
||||
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
|
||||
for (const thread of [T, H, A]) {
|
||||
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
|
||||
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
|
||||
}
|
||||
// Each refusal is recorded, as every refused request is.
|
||||
assert.equal(refusals() - was, 18);
|
||||
assert.equal(rows(), 0);
|
||||
// A share with `*` of the owner's thread doesn't reach the tester's reads.
|
||||
call('human', 'thread.share', { thread: H, to: '*' });
|
||||
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
|
||||
});
|
||||
|
||||
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
|
||||
const { store, call, code, A, sent } = setup(t);
|
||||
// The run's reply to alice, as R3's `to: 'user'` path writes it.
|
||||
const reply = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
|
||||
);
|
||||
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, A);
|
||||
for (const who of ['human', 'reader']) {
|
||||
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
|
||||
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
|
||||
}
|
||||
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
||||
for (const who of ['human', 'reader']) {
|
||||
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
|
||||
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
|
||||
}
|
||||
// The tester never reads another's thread (rule 4).
|
||||
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
|
||||
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
|
||||
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
|
||||
for (const who of ['human', 'reader']) {
|
||||
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
|
||||
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
|
||||
}
|
||||
});
|
||||
|
||||
test('a share covers every session of the thread', (t) => {
|
||||
const { store, call, code, H, read } = setup(t);
|
||||
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
|
||||
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
|
||||
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
|
||||
call('human', 'thread.share', { thread: H, to: 'user:alice' });
|
||||
assert.deepEqual(read('alice', H), ['jason: hello']);
|
||||
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
|
||||
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
|
||||
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
|
||||
});
|
||||
|
||||
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
|
||||
const { call, code, A, H } = setup(t);
|
||||
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
||||
call('human', 'thread.share', { thread: H, to: '*' });
|
||||
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
|
||||
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
|
||||
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
|
||||
// The owners still list their own.
|
||||
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
|
||||
});
|
||||
|
||||
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
|
||||
const { b, caps, call, code, A, H, sent } = setup(t);
|
||||
// The assistant run serving alice's thread, and a chief run with no thread.
|
||||
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
|
||||
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
for (const who of ['agent', 'chief']) call(who, 'role.claim');
|
||||
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
|
||||
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
|
||||
call('alice', 'thread.share', { thread: A, to: '*' });
|
||||
call('alice', 'thread.share', { thread: A, to: 'jason' });
|
||||
// The human principals now read it; the agents and the tester see what they saw before.
|
||||
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
|
||||
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
|
||||
for (const who of ['agent', 'chief']) {
|
||||
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
|
||||
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
|
||||
}
|
||||
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
|
||||
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
|
||||
call('human', 'thread.share', { thread: H, to: '*' });
|
||||
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
|
||||
});
|
||||
|
||||
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
|
||||
function source3l(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'thread-shares-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(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
|
||||
db.exec(ddl3l);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
|
||||
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
|
||||
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
|
||||
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
|
||||
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
|
||||
}
|
||||
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
|
||||
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
|
||||
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
|
||||
db.close();
|
||||
chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
const open = (path, fn) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try { return fn(db); } finally { db.close(); }
|
||||
};
|
||||
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
|
||||
// Every table's rows but meta's, whose version and digest the step changes.
|
||||
const tables = (path) => open(path, (db) => Object.fromEntries(
|
||||
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
|
||||
));
|
||||
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
|
||||
|
||||
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
|
||||
const { root, path } = source3l(t);
|
||||
const before = tables(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3m');
|
||||
const after = tables(path);
|
||||
assert.deepEqual(after.thread_shares, []);
|
||||
delete after.thread_shares;
|
||||
assert.deepEqual(after, before);
|
||||
// The migrated schema is the fresh one, object for object.
|
||||
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
|
||||
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
||||
new Store(freshRoot).close();
|
||||
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
||||
// No existing thread is shared: each stays private to its owner.
|
||||
const store = new Store(root);
|
||||
t.after(() => store.close());
|
||||
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
|
||||
for (const [sql, args] of [
|
||||
["UPDATE thread_shares SET target='*'", []],
|
||||
['DELETE FROM thread_shares', []],
|
||||
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
|
||||
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
|
||||
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
|
||||
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
|
||||
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
|
||||
});
|
||||
|
||||
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
|
||||
async function engine3l(root) {
|
||||
const moduleRoot = join(root, 'engine-3l');
|
||||
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(ddl3l).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
|
||||
const { root, path } = source3l(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const was = master(path), before = tables(path);
|
||||
const Store3l = await engine3l(root);
|
||||
assert.throws(() => new Store3l(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3m');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(tables(path), before);
|
||||
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
|
||||
const { root: old } = source3l(t);
|
||||
new Store3l(old).close();
|
||||
});
|
||||
@@ -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, '3m');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
@@ -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();
|
||||
|
||||
@@ -139,7 +139,9 @@ test('R3: every verb off the collaborator list refuses a user access-refused bef
|
||||
]);
|
||||
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
|
||||
// rather than leaving the set below with the verb it added (M3x, M6x).
|
||||
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
|
||||
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
|
||||
// Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs).
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
for (const v of USER_VERBS) others.delete(v);
|
||||
assert.ok(others.size > 40);
|
||||
for (const verb of others) {
|
||||
|
||||
@@ -13,11 +13,13 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { hostGrantRecord, hostRecord } from "../../bus/tests/host-fixtures.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { processIdentity, registerRuntime, sweepRuntimes, unregisterRuntime, updateRuntime } from "../../processing-control/src/runtimes.mjs";
|
||||
@@ -428,6 +430,31 @@ test("row 194 V5: a delete with no workspace commits and closes with the transac
|
||||
assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false);
|
||||
});
|
||||
|
||||
// Row 281 (#1786, d6 section 2, T10 B2): a delete of an instance with a grant answers at once. The pass
|
||||
// closes the first request grants-revoking while step G runs as a job, and the host channel stays up:
|
||||
// the next op answers. The grant is a seeded record on a TEST-NET host, so the job's remove can only
|
||||
// fail or wait; either way the request has already closed.
|
||||
test("row 281 T10: the delete pass closes a granted instance's first request grants-revoking and the host channel stays up", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// A host grant gives the agent a host.web1 store reference, so Credentials checks the store root at start.
|
||||
mkdirSync(join(dirname(p.f.config), "secrets", "agents"), { recursive: true, mode: 0o700 });
|
||||
const seeded = new Store(p.f.dataRoot);
|
||||
const entry = { address: "192.0.2.10", port: 22, user: "deploy", hostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests"] };
|
||||
hostRecord(seeded, { business: "acme", host: "web1", entry });
|
||||
hostGrantRecord(seeded, { business: "acme", agent: "sec-1", host: "web1", commands: [["uptime"]] });
|
||||
seeded.close();
|
||||
const s = await start(t, p);
|
||||
const del = s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request;
|
||||
const r = await s.carry(del);
|
||||
assert.equal(r.done.refusal, "grants-revoking");
|
||||
assert.equal(r.done.transaction ?? null, null);
|
||||
// The channel answers the next op and the next pass.
|
||||
assert.ok(s.call("instances").revision);
|
||||
await s.launcher.tick();
|
||||
assert.ok(Object.hasOwn(JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8")).roles, "sec-1"));
|
||||
await s.runtime.broker.grantsIdle();
|
||||
});
|
||||
|
||||
// Every regular file under root, by path, as a sha256. Sockets and other special files are left out.
|
||||
function snapshot(root) {
|
||||
const out = {};
|
||||
|
||||
@@ -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','thread_shares','instance_names','usage_records'];
|
||||
const LATE='2999-01-01T00:00:00.000Z';
|
||||
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
|
||||
|
||||
|
||||
@@ -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 thread_shares instance_names usage_records"
|
||||
|
||||
STORE_JS='
|
||||
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
||||
|
||||
Reference in new issue
Block a user