Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312cf33a11 | ||
|
|
62253c288b | ||
|
|
b851fd8c51 | ||
|
|
8652158d32 |
No files matched your search
@@ -5219,5 +5219,3 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
2026-10-21T22:28:56Z | Huey | T1 sessions design r2, scrooge-0006, mos-1235/1236, Vikunja 463 | Before: r1 347278044 lacked the exact per-session checkout/single-writer invariant and safe runner drain ordering, and admitted other role instances. After: F1 exact workspace path, fenced checkout setup, legacy shared-workspace quiescence, clear retains old dirty/untracked/unpushed checkout and creates fresh approved checkout, T2 mutant; F2 dequeue fence and generation-bound turn-boundary idle proof before runner signal, busy drain stays clearing, T4 cases; F3 THREAD_DEFINITION only per Mos scope ruling, no role eligibility change; N1 private titles/history vs owner-visible count/activity projection; N2 Claude offline adapter/managed-store-reader dependency flagged as MVP risk. Section 8 records Mos’s Q1–Q3 readings, no questions to Jason. Verification: 27 pinned references resolve, summary within 15 lines, source/delta consistency review, whitespace and installed queue guard checked. No code, queue/PRD edits, builds/suites/live changes/provider calls/credential reads.
|
||||
|
||||
2026-10-21T22:30:20Z | Huey | T1 sessions design r3, scrooge-0007 R1, Vikunja 463 | Before: r2 e19e30df2 nested new session checkouts under the retained legacy instance workspace, allowing project-name collision or historical parent inspection to reach live siblings. After: exact path is dataRoot/session-workspaces/business/instance/session, outside the legacy root; canonicalized ancestor/descendant overlap refuses; T2 nesting mutant covers historical mount/inspection and a legacy project named sessions. All other r2 content retained as approved, revision label updated. Verification: old nested path absent, manual R1-only delta, whitespace and installed queue guard checked; no code/builds/suites/live changes/provider calls/credential reads.
|
||||
|
||||
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing (#1759), library.sync human timeout | Before: the human transport gave library.sync the 5-second default, so a sync longer than that reported outcome-unknown, and six library-* codes reached the Console as 502. After: 331597709 lands Megavolt's candidate (7 paths, tree d49e4626f, manifest digest be63cfbe recomputed and matched). human-cli.mjs waits 130 s for library.sync, past the server's 120 s hold, and 5 s for other verbs. webui bus.mjs maps library-sync-busy, library-diverged and library-accept-stale to 409, library-accept-invalid to 400, and library-credential-missing and library-fetch-failed to 503. channel-proof re-pinned. Merged-tree gate on the landing commit: library 77/0, bus 628/0, webui 305/0 (1 skip), cli 749/0 (23:44:33-23:55:11Z). eb9672c91 adds the README request-timeout sentence (Dewey N1) and a DEFERRED entry for the four library codes still at 502 (Dewey N2, binds the Console library routes row). Overlap sweep before push: 268 rebased to r2 ecd9b35eb (README only, approval carried webby-0096), 194 rebased to b7. Done rev 3072, #1759 closed.
|
||||
@@ -1,113 +0,0 @@
|
||||
# Row 273 (#1778): activate --send and --no-sso
|
||||
|
||||
Darkwing, 2026-10-22. Brief: `docs/plans/2026-10-22_activate-send-nosso.md` (blob in f0d0b31e9), approved by
|
||||
Mos in mos-1246 (F39). Base: origin/refactor 0042f713d. Reviewer: Filbert. Gate owner: Sage.
|
||||
|
||||
## What changed
|
||||
|
||||
Only `scripts/stack-release.sh` and its two node test files.
|
||||
|
||||
- `activate --send` puts `--send` into the run's Console flags after `--sso` and before the tester
|
||||
flags, where webui-service.sh renders it. It refuses `send-needs-sso` unless those flags hold `--sso`,
|
||||
given or carried. The plan line is `send: on; --send was given`, or `send: carried` when the pointer
|
||||
already holds it. `check_target` lets it name the pointer's copy only when the pointer holds no `--send`,
|
||||
so a second `--send` on the same copy refuses `already-active`.
|
||||
- `activate --no-sso` refuses `sso-not-on` when the pointer holds no `--sso`. Otherwise it takes the
|
||||
Console flags from a rollback set (below) and keeps the pointer's tester flags. `send_for_target`
|
||||
drops `--send`, whether it came from the set or the pointer. It is the only place that does, so one
|
||||
mutant there (M3) shows the drop is tested. The plan prints `sso: off; Console flags from <set>/<file>; restores <flags>`
|
||||
and a send line, `send: dropped; --no-sso drops --send (F39)` or `send: off; --no-sso was given and the
|
||||
pointer holds no --send`. The steps log gets `drop --send; --no-sso was given` and
|
||||
`turn off --sso; the Console flags come from <set>/<file>`.
|
||||
- Usage errors: `--send` with `--no-send`; `--no-sso` with `--sso`, `--send`, a proof pair or
|
||||
`--no-proof`; either flag on any verb but activate; either flag under `--units external`.
|
||||
`--no-sso --no-send` is allowed, since the brief doesn't forbid it and both drop the same flag.
|
||||
- The set read that `sso_base` did inline for a rollback to a Console without `--sso` moved into
|
||||
`set_flags`, which both paths call. Its three messages are unchanged, and T57 still pins them.
|
||||
- `from_log` has a new mode, `sets`: the activation of each ok activate and rollback line, newest first,
|
||||
stopping at the adopt line, which took no rollback set.
|
||||
|
||||
## The consequential choice: which set --no-sso reads
|
||||
|
||||
The brief says the flags come from "the rollback set of the activation that turned `--sso` on", and that
|
||||
"a set whose Console already ran with `--sso`" refuses. Each set holds the Console unit from before its
|
||||
run. So the activation that turned `--sso` on is the newest one whose set holds a Console without
|
||||
`--sso`: every activation after it ran with `--sso`, and so did every set it left.
|
||||
|
||||
`no_sso_base` walks the log's sets newest first and stops at the first set whose Console has no `--sso`.
|
||||
It refuses `sso-off-flags` at the first set it can't read (missing, failing its SHA256SUMS, or with no
|
||||
argv the script can parse), since it can't tell whether that set was the one. If every set ran with
|
||||
`--sso`, it refuses with `the Console in <set>/<file> already ran with --sso, and the log names no
|
||||
earlier activation`. A log with no set at all refuses with `the log names no activation with a rollback
|
||||
set`.
|
||||
|
||||
The tradeoff: a later same-copy re-pin that carried `--sso` (a `--no-send`, or a re-pin onto a newer
|
||||
copy) doesn't break `--no-sso`, which the phase 2 window needs if a re-pin lands between step 5 and a
|
||||
rollback. The cost is that "a set that ran with `--sso` refuses" now applies only when no older set
|
||||
without `--sso` exists. A tampered set on the walk still refuses, even one newer than the set the walk
|
||||
would have used. Filbert, please rule on this reading.
|
||||
|
||||
## Filbert's early look: B1 and N1 (sage-2315)
|
||||
|
||||
Filbert accepted the walk. Sage ruled both findings into this row.
|
||||
|
||||
- B1: row 200's rollback to a copy whose Console has no `--sso` took the flags from the set and kept
|
||||
the pointer's `--send`. From an `--sso --send` pointer, which this row's `--send` can now make, that
|
||||
gave a Console with `--send` and no `--sso`, which F39 b forbids. `send_for_target` now drops `--send`
|
||||
whenever the run drops the pointer's `--sso`, so it is still the one place that drops it. The plan
|
||||
line reads `send: dropped; the run drops --sso, and --send needs it (F39)`, and the steps log gets
|
||||
`drop --send; the run drops --sso`. Row 200's sso line now names only the tester as following the
|
||||
pointer.
|
||||
- Changed existing assertion, for the review to approve by name: T57 item 3 (sage-1389), "the
|
||||
plain case", ends with `assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)])`. It
|
||||
used to expect `'--send'` at the end. The case seeds a legacy `--send` pointer, turns `--sso` on
|
||||
(which carries `--send`), then rolls back to B, whose Console has no `--sso`. A carried `--send`
|
||||
that ends without `--sso` breaks F39 b whatever its source, so the old pin asserted the wrong
|
||||
outcome (Sage accepted this in sage-2321). Row 200's two sso plan strings also lose "--send and"
|
||||
(`ROLLBACK_PLAN` and the round 3 named-instance case), since `--send` no longer follows the pointer
|
||||
on that path.
|
||||
- N1: the walk skipped an ok line with no activation. Skipping it could land on an older set that
|
||||
isn't the right one, so it now refuses `sso-off-flags` with `an ok line in the log names no
|
||||
activation`. The walk reads the log through `mapfile`. That gives no entry for an empty log, so a
|
||||
log with no set still gets its own message.
|
||||
|
||||
## test-release.sh
|
||||
|
||||
The window plan says test-release.sh "gets cases for both". The brief lists it only as a file to run
|
||||
whole. test-release.sh is release.sh's Docker selftest and doesn't call stack-release.sh, so the cases
|
||||
live in the node tests and test-release.sh runs unchanged.
|
||||
|
||||
## Tests (T58 in stack-release.test.mjs)
|
||||
|
||||
- T1: `--sso --send` from B onto C1 with a tester port: plan, pointer, unit and steps. `--send` beside a
|
||||
carried `--sso` re-pins C1 once; a second `--send` refuses `already-active`; C carries it.
|
||||
- T2: `--send` without `--sso` refuses on another copy and on the pointer's copy, also when the pointer
|
||||
carries `--send` alone; `--plan` lists the refusal.
|
||||
- T2 (sage-2315 B1): `--sso --send` onto C1, then a rollback to B, whose Console has no `--sso`. The
|
||||
pointer and the unit end with neither flag, and the plan and steps name the drop.
|
||||
- T2 and T4 usage: every forbidden pairing and verb, checked against the full usage text.
|
||||
- T3: from an `--sso --send` pointer, `--no-sso` on the same copy gives back the pre-`--sso` ExecStart
|
||||
line byte for byte, without `--send`. A second case walks past a `--no-send` re-pin that ran with
|
||||
`--sso` and keeps the pointer's tester port (7334), which differs from the set's (7333). A third
|
||||
(sage-2305 a) turns `--sso` on, then re-pins twice with `--send` and `--no-send`, so the two newest
|
||||
sets both hold `--sso`. `--no-sso` then gives back the arrival's ExecStart line byte for byte.
|
||||
- T4 (sage-2305 b): two sets, both edited or written to hold `--sso` with sums intact, refuse
|
||||
`sso-off-flags` through `refused()`.
|
||||
- T4: `sso-not-on`; an ok line with no activation; a set that fails its sums, ran with `--sso`, has no
|
||||
argv or is missing; a log with no set. Each runs through `refused()`, so nothing stops and nothing else changes.
|
||||
- T5: stack-release-external.test.mjs adds `--send` and `--no-sso` to the row 232 usage list.
|
||||
- T55 test 5 and the T57 usage test match the new usage line.
|
||||
|
||||
## Mutants
|
||||
|
||||
| # | Change | Expected killer |
|
||||
|---|---|---|
|
||||
| M1 | `set_flags` skips the SHA256SUMS comparison when `--no-sso` is given | T4 tampered set |
|
||||
| M2 | `no_sso_base` skips the `sso-not-on` refusal | T4 `sso-not-on` |
|
||||
| M3 | the `--no-sso` branch of `send_for_target` keeps `--send` | T3 |
|
||||
| M4 | `--send` skips the `send-needs-sso` refusal | T2 |
|
||||
| M5 | parse_opts drops the `--send`/`--no-send` rule | T2 and T4 usage |
|
||||
| M6 | a rollback to a Console without `--sso` keeps `--send` (B1) | T2 (sage-2315 B1) |
|
||||
| M7 | the walk skips an ok line with no activation (N1) | T4 no-activation line |
|
||||
|
||||
Runs and results go in the round's notes once Sage clears the slot.
|
||||
@@ -0,0 +1,190 @@
|
||||
# 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Runs
|
||||
|
||||
None yet. Every run waits for Sage's CLEAR.
|
||||
@@ -1,74 +0,0 @@
|
||||
# Row 254 build: library sync follow-up (C2-03a deferrals)
|
||||
|
||||
Row 254, #1759, reviewer Dewey, gate owner Sage. Brief: docs/plans/2026-10-21_library-sync-followup.md
|
||||
(0de97f429), for the deferrals Dewey named in the row 222 approval (#1723 comment 30138). Built on
|
||||
origin/refactor c3d88840c, which carries row 222 (0b33f5e9d) and row 196 (7247d80c7). Launch: sage-2163.
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Client timeout.** `packages/bus/src/human-cli.mjs` gives `library.sync` a 130 s client timeout. Every
|
||||
other verb keeps Client's 5 s default (`timeout: undefined` takes the default). The server holds a library
|
||||
verb's socket for 120 s (`packages/bus/src/server.mjs`, the `socket.setTimeout(120_000)` branch), and the
|
||||
fetch alone may take 100 s (GIT_LIMITS). So the client waits past the server and gets the server's own
|
||||
answer, including its `request-timeout`, instead of giving up first.
|
||||
- The Console has no library route, so there is no Console timeout to change (brief item 1, "when one
|
||||
exists"). When a route is added, note that webui `humanCall` kills its child at 30 s by default. The
|
||||
route would need `timeoutMs` above 130 s for library.sync.
|
||||
- `tester-cli.mjs` is unchanged: library.sync is human-only, so the tester gets `human-required`.
|
||||
2. **STATUS codes (215 N3).** `packages/webui/src/bus.mjs` maps six codes:
|
||||
|
||||
| Code | Status | Why |
|
||||
|---|---|---|
|
||||
| library-sync-busy | 409 | a sync is running; read status again |
|
||||
| library-diverged | 409 | held for a human accept |
|
||||
| library-accept-stale | 409 | the tip moved; read status again |
|
||||
| library-accept-invalid | 400 | a malformed accept |
|
||||
| library-credential-missing | 503 | the remote can't be reached, like no bus to ask |
|
||||
| library-fetch-failed | 503 | the same |
|
||||
|
||||
Neither 503 is 502, the default for an unknown code, so dropping any one entry changes its status and the
|
||||
test catches it.
|
||||
3. **README (222 N4).** `packages/library/README.md` Sync section: the transport timeouts, that a client
|
||||
timeout leaves the sync running, that a second call gets `library-sync-busy`, and that `library.status`
|
||||
shows the result. It also says the Console has no library route yet.
|
||||
- Correction to row 222's BUILD.md, "What it does": "`webui/src/cli.mjs`: the Console gets the call" was
|
||||
not true. `cli.mjs` builds `librarySync` and passes it to `startServer`, and `serve.mjs` never reads it
|
||||
(checked on c3d88840c: no library reference in serve.mjs).
|
||||
|
||||
## Channel proof re-pin
|
||||
|
||||
`packages/bus/tests/channel-proof.test.mjs` pins both changed files by hash. The test change is two baseline
|
||||
values and a header comment, nothing else:
|
||||
|
||||
| File | Old | New |
|
||||
|---|---|---|
|
||||
| packages/bus/src/human-cli.mjs | 269c3a66008c0d57… | 9c9af66b98ea0a490c9a880fc81c3c62414a8ee0b5ba6478608771b8c4f87932 |
|
||||
| packages/webui/src/bus.mjs | 934ab2f9b6bde72a… | e69d17d4385a4d190d15373ed1a26e270a5c6906eec9e61fcc2bb6b05849cabb |
|
||||
|
||||
human.mjs and transport.mjs are unchanged, and so are the entry-path test and the plug-in import ban. No
|
||||
other test pins either file: `git grep` for both old hashes finds only queue.json history and old packets,
|
||||
and the one 64-hex string in webui service.test.mjs is a made-up UID.
|
||||
|
||||
## Dewey's other 222 notes
|
||||
|
||||
None is a few lines in a file this row changes, so all three stay deferred:
|
||||
- N1: a close waits on every entry left in a pass (runtime.mjs).
|
||||
- N2: a held tip still gets its tree read, and a refused read hides the divergence (sync.mjs).
|
||||
- N3: held states log every tick (runtime.mjs).
|
||||
|
||||
## Tests
|
||||
|
||||
- `packages/webui/tests/library-status.test.mjs` (new): each of the six codes maps to its status, and an
|
||||
unknown code is 502.
|
||||
- `packages/bus/tests/library-sync.test.mjs`, a new test. A stand-in bus socket answers every request after 6 s,
|
||||
and the real human-cli.mjs runs two requests in parallel. library.sync gets the answer, and library.status
|
||||
gets `outcome-unknown` at 5 s. It takes about 6 s.
|
||||
- `packages/bus/tests/channel-proof.test.mjs`: the new baselines.
|
||||
|
||||
Planned mutants on Sage's slot:
|
||||
1. Drop the human-cli timeout line: the library-sync test fails (sync gets outcome-unknown) and the
|
||||
channel-proof hash fails.
|
||||
2. Drop one STATUS entry (`library-fetch-failed`): library-status test fails, and so does the channel-proof hash.
|
||||
|
||||
Both files are hash-pinned, so channel-proof kills any edit to them. Each mutant is also expected to be killed
|
||||
by its behaviour test, and the run lists every failing test so that can be checked.
|
||||
@@ -1,83 +0,0 @@
|
||||
# Row 264 (T1 item 7 sessions, #1769): Sage's rulings on the phase A questions
|
||||
|
||||
Sage, 2026-10-21T23:34Z. These answer Q1-Q7 in `agents/gizmoduck/work/queue-264/BUILD.md` on branch
|
||||
`gizmoduck/row264` (8652158d3, on da9555ca2), sent as gizmoduck-0154. The brief is
|
||||
`docs/plans/2026-10-21_t1-sessions.md` at blob ff0be0c79 (design r3, Mos's rulings mos-1235 in section 8).
|
||||
None of these rulings changes what a requirement allows. They set order, labels and integration points the
|
||||
brief leaves open.
|
||||
|
||||
## Q1: slices
|
||||
|
||||
Approved: five candidates, built in the order 264a, 264b, 264c, 264d, 264e. Each gets its own manifest,
|
||||
its own Duckworth round and its own landing. Row 264 carries 264a. I add one row each for 264b-264e,
|
||||
with a Gitea issue, in the next slot gap. Their `after` lists:
|
||||
|
||||
- 264b after 264a.
|
||||
- 264c after 264b. It reads the per-session store, so it can't land before the store exists.
|
||||
- 264d after 264b and row 194 (see Q4).
|
||||
- 264e after 264a. Its clear and count parts switch on as 264c and 264d land. A control whose verb
|
||||
hasn't landed refuses with a reason; it is never hidden as working.
|
||||
|
||||
All five are MVP. TARGET.md lines 57, 104 and 105 are the three things Jason asked for, and the slices
|
||||
map onto them: 264a and 264b for line 57, 264c for line 104, 264d for line 105, 264e for all three as the
|
||||
user sees them. None of them is cut by this ruling. Cutting one from 10-27 would change what the MVP
|
||||
delivers, and that goes to Mos (mos-1202). I report the schedule risk on the standup, not as a question.
|
||||
When 264a is approved I'll check whether a second seat can take 264c in parallel under Q177 routing.
|
||||
|
||||
## Q2: schema label
|
||||
|
||||
The label goes by landing order, not by row. Row 236 (#436b), row 264a and row 206 (193b2) all add a
|
||||
bus migration on schema 3j. The first of the three to land takes 3j-3k. The next one rebases and takes
|
||||
the next label, and so on. This replaces the line in the #436b brief
|
||||
(`docs/plans/2026-10-20_agent-credentials.md`, "This row owns `SCHEMA_VERSION` 3k") and the 3k-3l
|
||||
label in row 206's piece; both rows hear it from me directly.
|
||||
|
||||
Build 264a as `3j-3k.sql` now. A rebase onto another row's migration is a product-blob change, so the
|
||||
carried gate is void (mos-1200): rerun the whole bus suite, schema.test.mjs with its step-count pins, and
|
||||
the T46 restore-guard table lists. Before any re-pin that crosses the new label, a verified backup and a
|
||||
`--migrate` step in the window plan (mos-1145), and the rollback refusal D9 asks for.
|
||||
|
||||
## Q3: PRD
|
||||
|
||||
Nothing to wait for. PRD 1.13 has landed on refactor with both replacements:
|
||||
`docs/prd/mosaic-stack.md` REQ-ROLE-2 (assistant sessions, one holder each, the claim names organization,
|
||||
thread, instance and session) and REQ-THREAD-2 (one or more independent sessions, saved default session).
|
||||
264a builds to that text. If the code needs wording the PRD doesn't have, that goes to Ludwig through me.
|
||||
|
||||
## Q4: stop proof
|
||||
|
||||
Row 194's lifecycle path is the stop owner. The brief names it (F12 and D7 step 2, "the reviewed
|
||||
lifecycle path"). Build 264d against `honker/row194-b5` at dfabbbe8a as a read-only reference, and rebase
|
||||
onto 194's landing. 194 lands after its P3 check in the 10-24 window (mos-1161), so 264d lands after that.
|
||||
Don't copy 194 code into 264d's manifest.
|
||||
|
||||
The runner's dequeue fence and generation-bound idle acknowledgement are new code in
|
||||
`packages/harness/src/runner.mjs`, and they belong to 264d. Approved as a named review item. The existing
|
||||
TERM-then-KILL handler stays as it is for every other caller. D7 forbids calling it while a child runs.
|
||||
|
||||
## Q5: kill checks
|
||||
|
||||
Approved, as D8 already says. Use today's launcher limit and revocation path. Add one shared admission
|
||||
hook that every session start goes through. Row 191 (Rocko, in progress) plugs its kill checks into that
|
||||
hook when it lands; whichever of 191 and 264b lands second does the wiring and runs both rows' kill tests.
|
||||
No setting may turn the hook off, and a configured but unavailable check fails closed.
|
||||
|
||||
## Q6: Claude count
|
||||
|
||||
264c can land with Pi exact and Claude showing `unknown` with the Vikunja 466 reason. T3 passes for Pi
|
||||
in 264c. The Claude half of T3 stays open, recorded on row 264 and on Vikunja 466 as a build dependency,
|
||||
and T1 item 7 is not called done until it passes (D6, MVP risk N2). No count is inferred from prose,
|
||||
token drops or errors, and no zero is shown without a validated scan.
|
||||
|
||||
## Q7: RBAC
|
||||
|
||||
Approved, as D4 says. Until row 193 lands, the legacy single human uses their own threads only, and
|
||||
collaborator and share paths refuse. T5 in this row covers that case, forged identity and cross-instance
|
||||
IDs. The A4 `session.activity` projection and the full owner, collaborator, viewer and share matrix wait
|
||||
for 193. The row that lands second wires them and runs the full T5.
|
||||
|
||||
## Process
|
||||
|
||||
- Claim the row in the first slot gap. Tests and queue writes run only on my CLEAR.
|
||||
- One branch per round. A rebase goes on a new branch name (mos-1086).
|
||||
- Design notes stay in `agents/gizmoduck/work/queue-264/BUILD.md`.
|
||||
@@ -1455,4 +1455,3 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-21T22:30:20Z | Huey (gpt-6.1-sol) | T1 item 7 r3, scrooge-0007 R1, Vikunja 463 | isolated session-workspaces root and nesting mutant recorded; R1-only confirmation requested, other r2 findings closed; doc-only checks
|
||||
2026-10-21 | Ludwig | PRD 1.11-1.14 drafts (Vikunja 459, 462, 463, 464) | approved by Della, handed to Sage
|
||||
2026-10-21T22:51:07Z | Sage (Opus 5.5) | PRD 1.13 and 1.14 landing with Gosalyn's C2-01c brief and Case 2 design (51fdfb7e2), rows 264-268 filed (#1769-#1773), Case 2 re-pin to a6ac627c8, Vikunja 463 closed | pushed 6ba8a582e..db1e7732a; requirements tests 3/3 and 3/3 and verbs 31/31 on 5f73b388c and db1e7732a
|
||||
2026-10-22T00:18:12Z | Sage (Opus 5.5) | row 254 landing, rows 269-272 (264b-264e, #1774-#1777), 268 r2 request, row 191 publish and gate slot | 331597709 and eb9672c91 landed, queue e3c0446f5 pushed; rocko/row191-stoprun-dcd8-20261022 7aa18b0f6 and 81889b58d published as jarvis; 191 full gate on Rocko's slot from 00:17Z
|
||||
@@ -12,7 +12,7 @@ Single test files run freely, and suites and mutant batches need Sage's slot.
|
||||
|
||||
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
|
||||
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
No user thread goes live before 193b1a's message filter has landed (mos-1021 B2, FED R8).
|
||||
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
|
||||
|
||||
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
|
||||
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
|
||||
@@ -34,39 +34,20 @@ gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193b1, viewer verbs, board refusals and the message filter
|
||||
|
||||
Split on 2026-10-22 (sage-2288, after morgana-0063) into 193b1a and 193b1b below. The text that stood here
|
||||
chained the whole row on FED R3 and R4, which weren't filed. Only the viewer half needs them.
|
||||
|
||||
## Brief: 193b1a, the message filter
|
||||
|
||||
Chained on row 196's verifier (done). The bus gets the section 5.7 `trail` and `replies` filter over owner,
|
||||
reader and tester sessions, with `#readable` owner-only. No schema change. A hidden `trail` subject returns
|
||||
`[]`, exactly as an unknown id does, and `replies` returns `message-not-found` for both. One test per verb
|
||||
compares the hidden response with the unknown-id response byte for byte. The owner-side governance pin test
|
||||
names each item in the user sentence above. Bus tests and mutants 11 and 12 from section 8, plus mutant 1
|
||||
as "`#readable` returns true" (the pin test kills it) and a mutant that makes a hidden subject
|
||||
distinguishable from an unknown id. FED R3 builds on this row's candidate and extends the filter to user
|
||||
sessions.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b1b, viewer verbs and board refusals
|
||||
|
||||
Chained on 193b1a, FED R3 and FED R4. The bus gets the viewer verb list, per-grant `covers` level checks,
|
||||
board reads refused to users, and the user-side governance pin test. Bus tests and mutants 6 and 10 from
|
||||
section 8.
|
||||
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
|
||||
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
|
||||
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
|
||||
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b2, thread shares (schema 3l)
|
||||
|
||||
Chained on 193b1a and 193b1b. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
section 5.5 share filters. Schema 3k to 3l (Sage, 2026-10-20: machine grant row a owns 3i to 3j and
|
||||
the #436b build owns 3j to 3k, so this row builds on #436b's landing). The table is append-only, and row 181's restore guard and
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8,
|
||||
plus mutant 3 (rule 3 dropped), moved here from 193b1 because only shares can kill it (sage-2288).
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
|
||||
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Row: stack-release.sh activate --send and --no-sso, for the Q175 phase 2 window
|
||||
|
||||
Sage, 2026-10-22, for Mos's go mos-1246 (re sage-2285). Gitea #385, Vikunja #412. The window plan is
|
||||
`docs/plans/2026-10-22_sso-phase2-window.md` (draft 2, 92be0db02).
|
||||
|
||||
## Brief: activate --send and --no-sso
|
||||
|
||||
Owner Darkwing. Reviewer Filbert (not Sage or Darkwing, mos-1246 item 1). Gate owner Sage. Base: origin/refactor
|
||||
at this brief's commit. It lands and is re-pinned before the phase 2 window. That re-pin is Q173 work on Mos's
|
||||
go, run by Jason or Mos. If the target copy's schema differs from 3j and needs `--migrate`, it waits for Jason.
|
||||
|
||||
Why: at 02da6a2ae, `scripts/stack-release.sh activate` has `--no-send` but no `--send`, and nothing turns
|
||||
`--sso` off. Without `--sso`, `sso_base` carries it from the pointer, and `check_target` refuses the
|
||||
pointer's copy as `already-active`. So the window can't turn send on with `--sso` (mos-1195), and its
|
||||
same-copy rollback (mos-1245 R1) can't run.
|
||||
|
||||
Send and forward auth (mos-1246 F39): send went off on 10-20 (mos-0972) because, under forward auth, any
|
||||
same-uid process can send as Jason through 127.0.0.1:7332. So send goes only with `--sso`.
|
||||
|
||||
What to build, in `scripts/stack-release.sh` only:
|
||||
1. `activate --send` puts `--send` into RUN_CONSOLE where `webui-service.sh` renders it. It refuses unless
|
||||
the run's Console flags hold `--sso`, given or carried. With `--no-send` it is a usage error. The plan's
|
||||
send line reads `send: on; --send was given`, or `send: carried` when the pointer already holds it.
|
||||
`check_target` lets `--send` name the pointer's copy when the pointer holds no `--send`.
|
||||
2. `activate --no-sso` is allowed only when the pointer's Console flags hold `--sso`. Otherwise it refuses
|
||||
with a named reason. It takes the Console flags from the rollback set of the activation that turned
|
||||
`--sso` on (that set's `show-$CONSOLE_UNIT.txt`, checked against its SHA256SUMS), the way `sso_base`
|
||||
already does for a rollback to a copy without `--sso`. It drops `--send`, and its plan line says so
|
||||
(F39 a). The tester follows the pointer. A set it can't read, or a set whose Console already ran with
|
||||
`--sso`, refuses before anything stops. `check_target` lets `--no-sso` name the pointer's copy. With
|
||||
`--sso`, `--send`, a proof pair or `--no-proof`, it is a usage error. The plan prints
|
||||
`sso: off; Console flags from <set>/<file>` and lists every flag it restores.
|
||||
3. Both flags are refused under `--units external`, like the other unit-render flags (row 232).
|
||||
4. The usage text and the header comment name both flags. No other behavior changes.
|
||||
|
||||
Tests, whole files on Sage's slot: `packages/cli/tests/stack-release.test.mjs`,
|
||||
`packages/cli/tests/stack-release-external.test.mjs` for item 3, and `scripts/test-release.sh`.
|
||||
- T1: `--sso --send` on a pointer without send. The plan shows `send: on`, and the unit and the pointer
|
||||
gain `--send`.
|
||||
- T2: `--send` without `--sso`, given or carried, refuses (F39 b). `--send --no-send` is a usage error.
|
||||
`--send` on the same copy when the pointer already has send refuses as `already-active`.
|
||||
- T3: from an `--sso --send` pointer, `--no-sso` on the same copy returns the unit and the pointer to the
|
||||
pre-`--sso` flags byte for byte, with no `--send` (F39 a and c). The tester stays as the pointer had it.
|
||||
- T4: `--no-sso` refusals, each before any unit stops: a pointer without `--sso`; a tampered set
|
||||
(SHA256SUMS mismatch); a set whose Console ran with `--sso`; `--no-sso` combined with `--sso`, `--send`
|
||||
or a proof flag.
|
||||
- T5: `--units external` refuses both flags.
|
||||
|
||||
Mutants, each killed: drop the SHA256SUMS check in the `--no-sso` path; let `--no-sso` run with no `--sso`
|
||||
in the pointer; let `--no-sso` keep `--send`; let `--send` run without `--sso`; let `--send` and
|
||||
`--no-send` combine.
|
||||
|
||||
Gate: Filbert approves the manifest. The two touched test files run whole plus test-release.sh, and the
|
||||
mutants are killed. No live activation in this row. The window uses it.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Federation build row R3, the broker user principal (#414)
|
||||
|
||||
Sage, 2026-10-22. The design is Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at b93f098f0 on
|
||||
`gizmoduck/federation-design` (r4.11, sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6).
|
||||
Sections 3.5, 3.6 and 7 set R3's scope and test gate. Vikunja P19 #414. R3's parents, R1 (row 201) and row 196,
|
||||
have landed. I owed this filing when they did and missed it (morgana-0063). Row 205 (193b1a) and 193b1b wait
|
||||
on it.
|
||||
|
||||
## Brief: FED R3, the broker user principal
|
||||
|
||||
Owner Morgana, after 193b1a's candidate goes to review. Reviewer Dewey (section 7: Filbert or Dewey). Gate
|
||||
owner Sage. Base: 193b1a's candidate (row 205), so R3 extends that row's `trail` and `replies` filter to user
|
||||
sessions instead of re-cutting it. If 193b1a changes in review, R3 rebases onto a new branch name.
|
||||
|
||||
What to build, in `packages/bus/src/` (broker.mjs and a new `user-cli.mjs`) plus `packages/harness/src/runner.mjs`
|
||||
for the header line:
|
||||
1. The user proof: a path-bound `packages/bus/src/user-cli.mjs` with its own nonce variable
|
||||
(`MOSAIC_BUS_USER_NONCE`), checked with the `user` kind of row 196's `verifyPrincipal` (section 3.6 checks
|
||||
1-3). R3 consumes that kind. It doesn't re-implement the parent walk.
|
||||
2. `bindUser({ business, user })` and the broker changes 1-7 in section 3.6, including the reply path (B6),
|
||||
`#userThread`, `user-thread-human`, `#waiting` returning null for `to: 'user'`, and the runner header line
|
||||
for `from_role` `user`, word for word.
|
||||
3. The collaborator verb list from section 3.5, exact, with its filters. Every other verb fails
|
||||
`access-refused` before its own checks. The grant is read per request through R1's loader and `covers`
|
||||
with `level: 'collaborator'`.
|
||||
4. The section 3.6 item 5 audit: every `s.human` and `s.tester` branch in broker.mjs, listed in the packet as
|
||||
user refused, user treated as human, or not reachable for a user.
|
||||
5. No revocation timer, `access.reload` or launcher change. Those are R4. No Console change. That is R6.
|
||||
|
||||
Tests, in `packages/bus/tests/` and the runner's tests: the R3 test gate in section 7, item by item. Mutants:
|
||||
the five in section 7's R3 row, plus one that drops the 193b1a filter for user sessions (a user's `trail` of
|
||||
a hidden subject differs from an unknown id's). The agent-parent, non-Console-parent and kind-confusion
|
||||
mutants are row 196's and stay there.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
|
||||
use scratch roots. Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Dewey approves the manifest. The bus and harness suites and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Plan: Q175 phase 2 window, the live Console switches to --sso
|
||||
|
||||
Sage, 2026-10-22. Draft 2 for Mos, after mos-1245. Sources: `agents/sage/work/q175-sso/DESIGN.md` sections 5
|
||||
and 10 (phase 2 order, rollback, Jason-gated steps), Mos's C1 condition (mos-0977, b416d2ede), Jason's phase 2
|
||||
answers (Q209, mos-1216, mos-1217), mos-1195, mos-1197 and mos-1245. Rows 192 (phase 1), 200 (`activate --sso`)
|
||||
and its N1 follow-up have landed. Vikunja #412, Gitea #385.
|
||||
|
||||
## Blocked on a tool gap
|
||||
|
||||
Two steps need `scripts/stack-release.sh` flags that don't exist at 02da6a2ae:
|
||||
|
||||
- `activate` has `--no-send` but no `--send`. The live pointer holds no `--send`: the 10-20 19:29:56Z
|
||||
re-pin of 490e2771d gave `--no-send`, and every activation since has carried it off. So step 5 can't turn
|
||||
`--send` on (mos-1195).
|
||||
- `activate` has no way to turn `--sso` off. Without `--sso`, `sso_base` carries it from the pointer
|
||||
(`sso: carried`), and `check_target` refuses the pointer's own copy as `already-active`. The pre-window
|
||||
flags (`--public-host`, `--auth-header`, `--allow-user`) have no activate option either. So R1's
|
||||
same-copy rollback can't run.
|
||||
|
||||
The fix is one small row, approved in mos-1246: `activate --send` and `activate --no-sso`. `--no-sso`
|
||||
takes the Console flags from the rollback set of the activation that turned `--sso` on, the way `sso_base`
|
||||
already does for a rollback to a copy without `--sso`. It drops `--send`, and its plan line says so. `--send`
|
||||
refuses unless the run's Console flags hold `--sso`, given or carried (F39). test-release.sh gets cases for
|
||||
both. Darkwing builds it, and a seat other than Sage and Darkwing reviews it. Brief:
|
||||
`docs/plans/2026-10-22_activate-send-nosso.md`. It lands and is re-pinned before the window, on Mos's go,
|
||||
run by Jason or Mos. The steps below assume it, and draft 3 names the new copy.
|
||||
|
||||
## When
|
||||
|
||||
Only when Jason starts the window (mos-1197). Nothing below runs before his start. The 10-24 re-pin
|
||||
condition is met: W12 passed on 7247d80c7 at 22:27:13Z (mos-1245). This plan names 7247d80c7, which already
|
||||
carries phase 1, so its loader accepts a config with or without `console`. If another re-pin lands before
|
||||
Jason starts, the plan names the copy live at that point, and step 3 runs against it.
|
||||
|
||||
## Who
|
||||
|
||||
- Sage or Darkwing runs the agent steps under Q173: backup first, STATE report after.
|
||||
- Mos or Jason starts and restarts the Console (decision 102).
|
||||
- Jason signs in, sends and signs out.
|
||||
- Config writes 1 and 2 (steps 4 and 8) are an agent's, `console` key only, each with a backup first and
|
||||
a validate after. Mos's message to Jason at the window start names them. If Jason says he'll write them
|
||||
himself, steps 4 and 8 become his and nothing else changes (mos-1245 (a)).
|
||||
|
||||
## Values
|
||||
|
||||
- Provider: the existing Authentik `mosaic-stack` provider (A5). Issuer
|
||||
`https://auth.diversecanvas.com/application/o/mosaic-stack/`. Redirect
|
||||
`https://mosaic.woltje.com/auth/callback`. The Logout URI field holds `/auth/signed-out`.
|
||||
- clientId `fvchAepp9ZGoVq8l7YKej3Gbh5dyf8vNv9ORpDaU` (mos-1217, not a secret).
|
||||
- clientSecret `{ "slot": "console-sso-authentik" }`. The slot file is
|
||||
`~/.config/mosaic-dev/secrets/mosaic-stack/console-sso-authentik`, set to 0600 by Mos at 21:25:06Z on 10-21
|
||||
(A6).
|
||||
- subjects: Jason's sub only, name `jason.woltje` (mos-1197). The loader refuses an empty list
|
||||
(`sso-config.mjs:62`), and the Console reads the config only at start. So the first write carries one
|
||||
placeholder subject. The real sub replaces it after his first sign-in, followed by a restart (A8).
|
||||
|
||||
## Steps
|
||||
|
||||
1. Backups: the bus store, the release pointer, the Console unit and config.json, each copied 0600 into
|
||||
a new dated directory, with sha256 recorded. The pointer backup records the pre-window Console flags
|
||||
the rollback restores.
|
||||
2. Slot shape check (A6), without printing or copying the value: `stat` shows mode 0600 and the owner;
|
||||
`wc -l` is 1; `tr -cd '\r' <slot | wc -c` is 0; the last byte is a newline (`tail -c1 | od -An -c`).
|
||||
3. Straggler check (C1): list every running process, unit and timer that reads config.json, with the
|
||||
copy it runs from (`/proc/<pid>/exe` and cwd links, `systemctl --user list-units`, `list-timers`). Each one
|
||||
must run from 7247d80c7, or be shown never to re-read config. Step 4 waits until the list is clean.
|
||||
4. Config write 1 (agent, backup from step 1): add `console.sso` with the values above and one subject
|
||||
`{ "sub": "pending-first-sign-in", "name": "jason.woltje" }`. `scripts/mosaic-config.mjs validate`
|
||||
reads it back before anything starts.
|
||||
5. First `scripts/stack-release.sh activate 7247d80c7 mosaic-stack --sso --send --plan`. Its from line must
|
||||
name 7247d80c7 (`--expect-from` is for `--units external` only, so the live user units refuse it as a
|
||||
usage error). Then the same command without `--plan`, the same way earlier re-pins handled the
|
||||
Console. The tester stays the private socket from row 196 (`--tester-socket`). The `--plan` output
|
||||
names every flag it drops (`--public-host`, `--auth-header`, `--allow-user`) and every flag it carries.
|
||||
The run stops if `--sso` or `--send` is missing or a forward-auth flag remains. Mos or Jason starts the
|
||||
Console. No agent starts it.
|
||||
6. The start line shows one provider, the issuer and `subjects 1`. No secret value, no sub.
|
||||
7. Jason's first sign-in (A8). It refuses `subject-unknown`. An agent reads `sub=` from that one log line,
|
||||
checks that `iss=` is the issuer above, and records that the refused sub is not the placeholder. The sub
|
||||
goes into the window record, not into any message.
|
||||
8. Config write 2 (agent, new backup first): replace the placeholder with Jason's sub, so the list holds
|
||||
that one subject only. Validate. Mos or Jason restarts the Console. The start line shows `subjects 1`.
|
||||
9. Jason signs in, sends one message and signs out.
|
||||
10. Unsigned write refused (mos-1195), within Q173 (mos-1245 (c)): an agent acts as an anonymous client,
|
||||
not as Jason. It POSTs one Console write to `127.0.0.1:7332` with no cookie and no auth header, and
|
||||
the body names a tester-owned thread, so a wrong 2xx reaches no live seat. It expects 401
|
||||
`sign-in required` with the bus event count unchanged, and it writes nothing else. No agent sends
|
||||
`X-authentik-username` to the live Console (design section 8).
|
||||
11. Forged header refused: on a scratch Console from the same copy (`serve --sso --port <scratch>`,
|
||||
`MOSAIC_CONFIG` at a scratch config with a scratch dataRoot and a dummy slot, issuer an unreachable
|
||||
loopback host, no `--send` and no tester), a request with `X-authentik-username` and no cookie
|
||||
gets 401. Mos or Jason may run the same check on live instead (B6).
|
||||
12. Sign-out landing (A5): after step 9, did Authentik land on `/auth/signed-out`, or did it refuse
|
||||
`post_logout_redirect_uri`? `sso.mjs` deletes the session before redirecting, so a miss changes only the
|
||||
landing page. On a miss, Mos turns it into one small ask to Jason, and the window still counts as passed.
|
||||
13. STATE report: pointer, copy, schema, Console flags from the unit, the start line, results of steps 7-12,
|
||||
backup paths and digests. Gitea #385 gets the STATE comment. The `--sso` flag carries forward on later
|
||||
re-pins (mos-1197).
|
||||
|
||||
## Stops
|
||||
|
||||
- Any refusal in steps 2-6 or 8: stop, keep the backups, and roll back as below. Never edit around it.
|
||||
- While the placeholder is the only subject, any sign-in that creates a session is a stop and a
|
||||
rollback (mos-1245 (b)).
|
||||
- Step 10: any result other than 401 with the event count unchanged is a stop, and it goes to Mos.
|
||||
- Two failures in the window: stop and report to Mos (mos-0823).
|
||||
|
||||
## Rollback
|
||||
|
||||
1. Same-copy re-activate (mos-1245 R1): `scripts/stack-release.sh activate 7247d80c7 mosaic-stack --no-sso
|
||||
--plan`, then the same without `--plan`. R1 asked for `--expect-from 7247d80c7`, but `--expect-from` is
|
||||
for `--units external` only and the live units are user units, so the plan's from line must name
|
||||
7247d80c7 instead. The `--plan` output must show the pre-window Console flags from the step 1 pointer backup: `--port 7332 --public-host
|
||||
mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-socket`, exactly.
|
||||
`--no-sso` drops `--send` (mos-1246 F39), so there's no `--send`. A plan that differs is a stop. Mos or Jason restarts the Console.
|
||||
2. Restore config.json from the step 1 backup, which removes `console`. 7247d80c7 loads config with or
|
||||
without the key, so the order of 1 and 2 doesn't matter on this copy.
|
||||
3. STATE after the rollback reads config.json, the unit's Console flags and the pointer.
|
||||
4. Last resort only, and it waits for Jason: `scripts/stack-release.sh rollback` goes to the previous
|
||||
distinct copy, 4429a104f at schema 3i. The store has been at 3j since 21:59Z on 10-21, so that rollback
|
||||
needs `--restore`, and any events after the backup make it Jason's call (header condition 3).
|
||||
@@ -346,14 +346,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
do nothing yet. The row that adds the Console library routes decides
|
||||
whether the tester listener gets `library.sync`. Duckworth's view is
|
||||
that it shouldn't, because sync is a human write. (2026-10-21)
|
||||
- **Console library routes: four library codes still map to 502.**
|
||||
(Dewey N2 on the row 254 manifest, #1759 comment 30173.) Row 254
|
||||
gave six library codes a STATUS entry in `packages/webui/src/bus.mjs`.
|
||||
`library-mapping-invalid`, `library-remote-invalid`,
|
||||
`library-state-invalid` and `library-git` still fall through
|
||||
`busStatus` to 502. The row that adds the Console library routes
|
||||
decides their statuses and gives its `humanCall` for `library.sync`
|
||||
a timeout over 130 seconds. (2026-10-21)
|
||||
- **T27 sweep test leaves two `sleep 60` children running.**
|
||||
(Darkwing row 251 slot, darkwing-0113 and darkwing-0117; Dewey on
|
||||
r2, comment 30168.) The sweep test spawns two detached shells,
|
||||
|
||||
+9
-16
@@ -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 3102 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3059 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -241,8 +241,8 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 2, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | briefed | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
|
||||
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
@@ -252,11 +252,11 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
|
||||
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | done | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | LANDED e54e05463 on 6096b051b (sage-2094). Patch-id 2f68188b6c9d equals a36523713..dde3445ab; 118 paths, blobs and modes equal; tree 5b1613245 equals merge-tree. No path overlap with rows 66/243; no hash pins either way. Gate: Filbert APPROVE rev 2924; herb-0095 grant sage-2071 2634 pass 0 fail, 1 webui skip (editor rebuild needs esbuild 0.28.2, env, not a 215 path), accepted. |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | Rulings on phase A: agents/sage/work/queue-216/RULINGS.md (da9555ca2). r2 rebases on row 268's landing and adds the four PRD 1.14 playground tests. |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | 268 done | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
|
||||
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | 268 done | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | Rulings on phase A: agents/sage/work/queue-220/RULINGS.md (da9555ca2). Scope adds packages/business/src/library-seats.mjs (imported by path, no index export), its test, and control-board serve.mjs. r2 building as a Haiku subagent. |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | done | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
|
||||
@@ -272,7 +272,7 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
|
||||
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | in-progress | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | queued | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
|
||||
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
|
||||
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
|
||||
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
|
||||
@@ -290,7 +290,7 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
|
||||
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | done | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | LANDED d37210288 on b79ad5f2c: candidate 27f01ef63 manifest f2bdd684, patch-id 440d2442c976 equal, blob diff empty, tree matches merge-tree. Dewey r2 approve rev 2996 (c30168). Gate: bus suite whole in a clean worktree, 627/627 at 21:30:33Z on Sage's slot. Overlap check vs 194/198/244/253/254 branches: none. DEFERRED sleep-orphan entry follows as a doc commit. |
|
||||
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | gander; reviewers webby | — | briefed | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
|
||||
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | in-progress | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
|
||||
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
|
||||
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | in-review, round 1, request posted | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Dewey's deferrals from 222 approval c30138. Chained after 196 (human-cli channel proof). Issue #1759. |
|
||||
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
|
||||
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | briefed | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | — |
|
||||
| 257 | Q198 S1: pressure parser and pure rules evaluator (post-v1) | morgana; reviewers filbert | #1762 | in-review, round 2, request posted | — | Reviewer approves the manifest; touched test files whole plus the package suite green on Sage's slot; both brief mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: Q198 S1 pressure parser and pure rules evaluator | — |
|
||||
@@ -300,18 +300,11 @@ Generated from `docs/plans/queue.json` revision 3102 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 | sage | #1769 | queued | — | 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). |
|
||||
| 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. |
|
||||
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | in-review, round 2, request posted | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | MVP first claim (mos-1240). Lands before C2-05a (216) and C2-06 (217) read classify; if either has a candidate first, the change goes into it too (gosalyn-0134). Code library-playground-other-universe accepted (sage). |
|
||||
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | briefed | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | gizmoduck; reviewers duckworth | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | briefed | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | — |
|
||||
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | briefed | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | — |
|
||||
| 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 | — |
|
||||
| 268 | Case 2 C2-01c playground levels: universe-level prefixes and draft universes | gizmoduck; reviewers webby | #1773 | briefed | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | MVP first claim (mos-1240). Lands before C2-05a (216) and C2-06 (217) read classify; if either has a candidate first, the change goes into it too (gosalyn-0134). Code library-playground-other-universe accepted (sage). |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+35
-1644
File diff suppressed because it is too large.
Load diff
+36
-7
@@ -236,9 +236,11 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
|
||||
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
|
||||
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
|
||||
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
|
||||
| `thread.open` | `{thread}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
|
||||
| `thread.send` | `{thread,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
|
||||
| `thread.messages` | `{thread,after?,limit?}`; human sessions only. The thread's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the thread), at most `limit` (default 50, 1 to 200). Writes nothing. |
|
||||
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
|
||||
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
|
||||
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). Writes nothing. |
|
||||
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
|
||||
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
|
||||
|
||||
### Projects and threads (row 121, #1615)
|
||||
|
||||
@@ -306,6 +308,33 @@ 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 `3k` 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 3j→3k step gives each existing thread a default session whose
|
||||
id is the thread's own, named `default` with op `legacy-3k`, 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 3k 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.
|
||||
|
||||
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 3j engine refuses a 3k
|
||||
store with `schema-mismatch`.
|
||||
|
||||
### Agent state (row 59, #1548)
|
||||
|
||||
For a human or reader session, `agents` derives each held role's state from
|
||||
@@ -711,10 +740,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3j, SHA-256
|
||||
`0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c`.
|
||||
It has 18 tables, 72 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3j`, WAL,
|
||||
`schema.sql` is v3k, SHA-256
|
||||
`860374bde3f063ecfd72618913e05924587b7c39b87b763eee4c6aeb59922f46`.
|
||||
It has 20 tables, 87 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3k`, WAL,
|
||||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||||
|
||||
+74
-11
@@ -248,11 +248,34 @@ CREATE TABLE threads (
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE sessions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
predecessor TEXT REFERENCES sessions(id),
|
||||
UNIQUE (thread, name),
|
||||
UNIQUE (thread, op)
|
||||
) STRICT;
|
||||
CREATE TABLE session_defaults (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
generation INTEGER NOT NULL CHECK (generation >= 1),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
@@ -261,55 +284,95 @@ CREATE TABLE thread_claims (
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id)
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
session TEXT NOT NULL REFERENCES sessions(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE INDEX message_threads_session ON message_threads (session);
|
||||
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
|
||||
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
|
||||
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
|
||||
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
|
||||
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
|
||||
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
|
||||
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
|
||||
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
|
||||
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
|
||||
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
|
||||
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
|
||||
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
|
||||
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
|
||||
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
|
||||
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
|
||||
+161
-69
@@ -116,7 +116,9 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task
|
||||
// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs.
|
||||
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop']);
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 264a (#1769): the sessions of the tester's own threads.
|
||||
'session.list', 'session.create']);
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -329,6 +331,8 @@ export class Broker {
|
||||
'task_snapshots',
|
||||
'projects',
|
||||
'threads',
|
||||
'sessions',
|
||||
'session_defaults',
|
||||
'thread_claims',
|
||||
'thread_opens',
|
||||
]) {
|
||||
@@ -389,37 +393,49 @@ export class Broker {
|
||||
#threaded(business, role) {
|
||||
return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
}
|
||||
#threadHolder(thread) {
|
||||
return this.#store.get('SELECT * FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread);
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
return this.#store.get('SELECT * FROM thread_claims WHERE session=? ORDER BY seq DESC LIMIT 1', session);
|
||||
}
|
||||
// Row 155 (#1649): the held claims of every thread of a thread instance, oldest thread first, or
|
||||
// D3: the thread's default session, the latest session_defaults row. thread.create and the 3j-3k
|
||||
// migration write one for every thread.
|
||||
#defaultSession(thread) {
|
||||
return this.#store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1', thread)?.session ?? fail('session-not-found');
|
||||
}
|
||||
// The session a thread verb names, or the thread's default when it names none.
|
||||
#threadSession(t, session) {
|
||||
if (session === undefined) return this.#defaultSession(t.id);
|
||||
if (!this.#store.get('SELECT 1 FROM sessions WHERE id=? AND thread=?', identifier(session), t.id)) fail('session-not-found');
|
||||
return session;
|
||||
}
|
||||
// Row 155 (#1649): the held claims of every session of a thread instance, oldest thread first, or
|
||||
// only the threads `owner` made. An end of the instance ends each of them.
|
||||
#threadHolders(business, instance, owner = null) {
|
||||
return this.#store.all(
|
||||
"SELECT c.* FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND t.instance=? AND (? IS NULL OR t.owner=?) AND c.op='claim' ORDER BY t.seq",
|
||||
"SELECT c.* FROM threads t JOIN sessions se ON se.thread=t.id JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=se.id) WHERE t.business=? AND t.instance=? AND (? IS NULL OR t.owner=?) AND c.op='claim' ORDER BY t.seq, se.seq",
|
||||
business,
|
||||
instance,
|
||||
owner,
|
||||
owner,
|
||||
);
|
||||
}
|
||||
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
|
||||
// The claim a run acts under: its session's for a thread run, its role's otherwise.
|
||||
#own(s) {
|
||||
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
|
||||
return s.thread ? this.#sessionHolder(s.session) : this.#holder(s.business, s.role);
|
||||
}
|
||||
// Who a message to `to` goes to now: the thread's holder when the message is in a thread of
|
||||
// `to`, the role's otherwise. Null while nobody holds it, and the message waits.
|
||||
#recipient(business, to, thread) {
|
||||
const h = thread && this.#threaded(business, to) ? this.#threadHolder(thread) : this.#holder(business, to);
|
||||
// Who a message to `to` goes to now: the session's holder when the message is in a session of a
|
||||
// thread of `to`, the role's otherwise. Null while nobody holds it, and the message waits.
|
||||
#recipient(business, to, session) {
|
||||
const h = session && this.#threaded(business, to) ? this.#sessionHolder(session) : this.#holder(business, to);
|
||||
return h?.op === 'claim' ? h : null;
|
||||
}
|
||||
// The thread a new message belongs to (REQ-MSG-1). A thread run's message is in its thread.
|
||||
// A message to a thread instance from anyone else is in the thread of the nearest message up
|
||||
// its in_reply_to chain that has one, so the chief of staff's reply returns to the thread
|
||||
// The session a new message belongs to (REQ-MSG-1). A thread run's message is in its session.
|
||||
// A message to a thread instance from anyone else is in the session of the nearest message up
|
||||
// its in_reply_to chain that has one, so the chief of staff's reply returns to the session
|
||||
// that sent the work. Messages are append-only and in_reply_to names an earlier message, so
|
||||
// the chain has an end; `seen` stops the walk there and needs no depth constant.
|
||||
#threadFor(s, to, inReplyTo) {
|
||||
if (s.thread) return s.thread;
|
||||
#sessionFor(s, to, inReplyTo) {
|
||||
if (s.thread) return s.session;
|
||||
if (!this.#threaded(s.business, to)) return null;
|
||||
// The user names the thread with thread.send, which checks the owner (REQ-USER-2).
|
||||
if (s.human) fail('thread-required-use-thread-send');
|
||||
@@ -427,18 +443,20 @@ export class Broker {
|
||||
for (let at = inReplyTo ?? null; at !== null && !seen.has(at); ) {
|
||||
seen.add(at);
|
||||
const m = this.#store.get(
|
||||
'SELECT m.in_reply_to, m.from_role, m.body, t.thread FROM messages m LEFT JOIN message_threads t ON t.message=m.id WHERE m.id=? AND m.business=?',
|
||||
'SELECT m.in_reply_to, m.from_role, m.body, t.thread, t.session FROM messages m LEFT JOIN message_threads t ON t.message=m.id WHERE m.id=? AND m.business=?',
|
||||
at,
|
||||
s.business,
|
||||
);
|
||||
if (m?.thread) {
|
||||
if (this.#store.get('SELECT instance FROM threads WHERE id=?', m.thread).instance !== to) fail('thread-instance-mismatch');
|
||||
return this.#testerOwns(s, m.thread);
|
||||
this.#testerOwns(s, m.thread);
|
||||
return m.session;
|
||||
}
|
||||
// Row 143: a chain that reaches a spec notice belongs to the user's thread in that notice's project.
|
||||
// Row 143: a chain that reaches a spec notice belongs to the user's thread in that notice's
|
||||
// project, in its default session.
|
||||
if (m?.from_role === NOTICE_ROLE && m.body.startsWith(SPEC_NOTICE)) {
|
||||
const spec = this.#specThread(s, to, m.body);
|
||||
if (spec) return this.#testerOwns(s, spec);
|
||||
if (spec) return this.#defaultSession(this.#testerOwns(s, spec));
|
||||
}
|
||||
at = m?.in_reply_to ?? null;
|
||||
}
|
||||
@@ -500,26 +518,30 @@ export class Broker {
|
||||
const key = record.business + ':' + identifier(record.run);
|
||||
const session = this.#runs.get(key) ?? fail('unknown-run');
|
||||
if (this.#ended.has(key)) fail('run-ended');
|
||||
// A run holds at most one thread, so this also refuses another thread and a non-thread run.
|
||||
// A run holds at most one session, so this also refuses another thread and a non-thread run.
|
||||
// Row 264a: the row is the thread's default session's, so a run of another session is refused too.
|
||||
const [t] = this.#threadRows('t.business=? AND t.id=?', record.business, identifier(record.thread));
|
||||
if (t?.holder !== record.run) fail('not-holder');
|
||||
if (t.pending || t.unread) return { released: false, pending: t.pending, unread: t.unread };
|
||||
this.#store.transaction(() => this.#claimEnd(session, this.#threadHolder(t.thread), 'release', null));
|
||||
this.#store.transaction(() => this.#claimEnd(session, this.#own(session), 'release', null));
|
||||
return { released: true };
|
||||
}
|
||||
// Row 264a (#1769): each row is the thread's default session's. Until 264b the launcher starts
|
||||
// default sessions only, so another session's messages wait.
|
||||
#threadRows(where, ...params) {
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT t.id, t.instance,
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session AND m.to_role=t.instance
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))) AS pending,
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id AND m.to_role=t.instance
|
||||
(SELECT count(*) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session AND m.to_role=t.instance
|
||||
AND c.op='claim' AND EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='delivered' AND d.holder_run=c.holder_run)
|
||||
AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op='read')) AS unread,
|
||||
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=t.id) AS last_message,
|
||||
(SELECT max(at) FROM thread_opens WHERE thread=t.id) AS last_open,
|
||||
(SELECT max(m.at) FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=ds.session) AS last_message,
|
||||
(SELECT max(at) FROM thread_opens WHERE session=ds.session) AS last_open,
|
||||
c.at AS claim_at, c.op AS claim_op, c.holder_run AS holder_run
|
||||
FROM threads t LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
|
||||
FROM threads t JOIN session_defaults ds ON ds.seq=(SELECT max(seq) FROM session_defaults WHERE thread=t.id)
|
||||
LEFT JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE session=ds.session)
|
||||
WHERE ${where} ORDER BY t.seq`,
|
||||
...params,
|
||||
)
|
||||
@@ -538,7 +560,7 @@ export class Broker {
|
||||
bindLaunch(record) {
|
||||
keys(
|
||||
record,
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness'],
|
||||
);
|
||||
const b = this.#business(identifier(record.business));
|
||||
@@ -561,10 +583,13 @@ export class Broker {
|
||||
if (record.thread === undefined) fail('thread-required');
|
||||
if (!this.#store.get('SELECT 1 FROM threads WHERE id=? AND business=? AND instance=?', identifier(record.thread), record.business, record.role))
|
||||
fail('thread-not-found');
|
||||
} else if (record.thread !== undefined) fail('thread-not-allowed');
|
||||
} else if (record.thread !== undefined || record.session !== undefined) fail('thread-not-allowed');
|
||||
// Row 264a (#1769): a thread run works one session of its thread, the thread's default when the
|
||||
// record names none (D3).
|
||||
const bound = record.thread === undefined ? null : this.#threadSession({ id: record.thread }, record.session);
|
||||
const key = record.business + ':' + record.run;
|
||||
if (this.#runs.has(key)) fail('duplicate-run');
|
||||
const session = { ...record, address: record.address ?? null, thread: record.thread ?? null, human: false };
|
||||
const session = { ...record, address: record.address ?? null, thread: record.thread ?? null, session: bound, human: false };
|
||||
this.#secretCheck(record);
|
||||
const old = this.#store.get(
|
||||
"SELECT actor_role,body FROM events WHERE business=? AND kind='session.launched' AND subject=? ORDER BY seq LIMIT 1",
|
||||
@@ -578,6 +603,8 @@ export class Broker {
|
||||
pid: record.pid ?? null,
|
||||
startTime: record.startTime ?? null,
|
||||
...(record.thread === undefined ? {} : { thread: record.thread }),
|
||||
// A record without a session keeps the body it had before row 264a.
|
||||
...(record.session === undefined ? {} : { session: record.session }),
|
||||
};
|
||||
// A run an earlier broker bound is bound again unchanged (the host's recover()), even after its
|
||||
// harness was switched off, so it can be ended.
|
||||
@@ -733,14 +760,14 @@ export class Broker {
|
||||
// close is.
|
||||
#grantNotice(business, decision, body) {
|
||||
const d = this.#store.get('SELECT raised_by_role,raised_by_run FROM decisions WHERE id=?', decision);
|
||||
const thread = this.#threaded(business, d.raised_by_role)
|
||||
const session = this.#threaded(business, d.raised_by_role)
|
||||
? (this.#store.get(
|
||||
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
"SELECT session FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
business,
|
||||
d.raised_by_run,
|
||||
)?.thread ?? null)
|
||||
)?.session ?? null)
|
||||
: null;
|
||||
for (const [to, t] of [['human', null], [d.raised_by_role, thread]]) {
|
||||
for (const [to, t] of [['human', null], [d.raised_by_role, session]]) {
|
||||
const mid = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -1958,7 +1985,7 @@ export class Broker {
|
||||
null,
|
||||
body,
|
||||
);
|
||||
this.#route(s.business, mid, s.role, s.thread);
|
||||
this.#route(s.business, mid, s.role, s.session);
|
||||
}
|
||||
// Trusted launcher API: whether the role, in the thread (null for a role run), holds a merge
|
||||
// decision a landing could use: cross-role, raised there with a task ref as its target, resolved
|
||||
@@ -2647,22 +2674,25 @@ export class Broker {
|
||||
keys(a, []);
|
||||
if (s.human) fail('agent-required');
|
||||
// Row 121: a thread run claims its thread, so idle threads hold nothing (REQ-THREAD-2).
|
||||
// Row 264a (#1769): the claim is the run's session's, with the session's next generation.
|
||||
if (s.thread) {
|
||||
if (this.#store.get("SELECT 1 FROM thread_claims WHERE thread=? AND holder_run=? AND op='revoke' LIMIT 1", s.thread, s.run))
|
||||
if (this.#store.get("SELECT 1 FROM thread_claims WHERE session=? AND holder_run=? AND op='revoke' LIMIT 1", s.session, s.run))
|
||||
fail('run-revoked');
|
||||
if (this.#threadHolder(s.thread)?.op === 'claim') fail('thread-already-held');
|
||||
if (this.#sessionHolder(s.session)?.op === 'claim') fail('thread-already-held');
|
||||
this.#store.run(
|
||||
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by) VALUES(?,?,?,?,?,?,?,?)',
|
||||
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,address,by) VALUES(?,?,?,?,1+(SELECT count(*) FROM thread_claims WHERE session=? AND op='claim'),?,?,?,?,?)",
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.thread,
|
||||
s.session,
|
||||
s.session,
|
||||
'claim',
|
||||
s.run,
|
||||
s.harness,
|
||||
s.address,
|
||||
by,
|
||||
);
|
||||
this.#routeThread(s.thread);
|
||||
this.#routeSession(s.session);
|
||||
return { role: s.role, run: s.run, thread: s.thread };
|
||||
}
|
||||
if (
|
||||
@@ -2711,7 +2741,7 @@ export class Broker {
|
||||
identifier(a.decision);
|
||||
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
|
||||
}
|
||||
// Row 155 (#1649): a thread instance holds a claim per thread, so the end revokes each one.
|
||||
// Row 155 (#1649): a thread instance holds a claim per session, so the end revokes each one.
|
||||
if (this.#threaded(s.business, a.role)) {
|
||||
const held = this.#threadHolders(s.business, a.role);
|
||||
if (held.length === 0) fail('not-held');
|
||||
@@ -2934,14 +2964,14 @@ export class Broker {
|
||||
op === 'resolved'
|
||||
? `resolved by ${by}: ${a.choice} (${JSON.parse(d.options).find((o) => o.key === a.choice).text})`
|
||||
: `withdrawn by ${by}`;
|
||||
// A close to a thread instance goes to the thread the raising run held. A decision
|
||||
// A close to a thread instance goes to the session the raising run held. A decision
|
||||
// raised before row 121 has no thread claim and stays unthreaded.
|
||||
const thread = this.#threaded(s.business, d.raised_by_role)
|
||||
const session = this.#threaded(s.business, d.raised_by_role)
|
||||
? (this.#store.get(
|
||||
"SELECT thread FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
"SELECT session FROM thread_claims WHERE business=? AND holder_run=? AND op='claim' ORDER BY seq DESC LIMIT 1",
|
||||
s.business,
|
||||
d.raised_by_run,
|
||||
)?.thread ?? null)
|
||||
)?.session ?? null)
|
||||
: null;
|
||||
const mid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -2958,7 +2988,7 @@ export class Broker {
|
||||
null,
|
||||
`Decision ${d.id} on ${d.action} was ${outcome}.${a.note === undefined ? '' : `\nNote: ${a.note}`}`,
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, thread);
|
||||
this.#route(s.business, mid, d.raised_by_role, session);
|
||||
}
|
||||
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
|
||||
}
|
||||
@@ -3025,7 +3055,7 @@ export class Broker {
|
||||
}
|
||||
if (a.class === 'BLOCK') this.#checkBlock(s, to, a.in_reply_to);
|
||||
if (a.decision) this.#decision(s, a.decision);
|
||||
const thread = this.#threadFor(s, to, a.in_reply_to);
|
||||
const thread = this.#sessionFor(s, to, a.in_reply_to);
|
||||
// Row 170 (#1667): a tester's message to a role nobody holds is refused before it is
|
||||
// stored, so a PM test never waits for the role's next run. A run launched but not yet
|
||||
// claimed counts as no holder, as in #waiting. No lock: a holder that releases once the
|
||||
@@ -3062,14 +3092,14 @@ export class Broker {
|
||||
this.#received.add(s.business + ':' + s.run);
|
||||
}
|
||||
const role = s.human ? 'human' : s.role;
|
||||
// A thread run receives its own thread's messages only (REQ-THREAD-1).
|
||||
// A thread run receives its own session's messages only (REQ-THREAD-1, row 264a).
|
||||
const rows = this.#store.all(
|
||||
"SELECT * FROM messages m WHERE business=? AND to_role=? AND NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read'))" +
|
||||
(s.thread ? ' AND EXISTS(SELECT 1 FROM message_threads mt WHERE mt.message=m.id AND mt.thread=?)' : '') +
|
||||
(s.thread ? ' AND EXISTS(SELECT 1 FROM message_threads mt WHERE mt.message=m.id AND mt.session=?)' : '') +
|
||||
' ORDER BY seq LIMIT 100',
|
||||
s.business,
|
||||
role,
|
||||
...(s.thread ? [s.thread] : []),
|
||||
...(s.thread ? [s.session] : []),
|
||||
);
|
||||
for (const m of rows)
|
||||
this.#delivery(
|
||||
@@ -3481,6 +3511,8 @@ export class Broker {
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
return { id: tid, instance: instances[0] };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
@@ -3498,20 +3530,23 @@ export class Broker {
|
||||
...(a.project === undefined ? [] : [a.project]),
|
||||
);
|
||||
}
|
||||
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1).
|
||||
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1). Row 264a: a thread
|
||||
// verb names one session of the thread, or works on its default (D3).
|
||||
case 'thread.open': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread'], ['thread']);
|
||||
keys(a, ['thread', 'session'], ['thread']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
this.#store.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', this.#now(), t.id, this.#owner(s));
|
||||
const session = this.#threadSession(t, a.session);
|
||||
this.#store.run('INSERT INTO thread_opens(at,thread,session,by) VALUES(?,?,?,?)', this.#now(), t.id, session, this.#owner(s));
|
||||
return { opened: t.id };
|
||||
}
|
||||
// Row 177 (#1675): a tester's message is the tester's, from tester-cli, and writes no
|
||||
// human.input, as on message.send. In its own thread the lists read it as the human.
|
||||
case 'thread.send': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread', 'body', 'class'], ['thread', 'body']);
|
||||
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const session = this.#threadSession(t, a.session);
|
||||
// Row 120's lists hold here as on message.send: the instance must hear from the human.
|
||||
this.#checkMessaging(s.business, 'human', t.instance);
|
||||
string(a.body, 32768);
|
||||
@@ -3531,31 +3566,60 @@ export class Broker {
|
||||
null,
|
||||
a.body,
|
||||
);
|
||||
this.#route(s.business, mid, t.instance, t.id);
|
||||
this.#route(s.business, mid, t.instance, session);
|
||||
return { id: mid, request: s.tester ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
|
||||
}
|
||||
// The thread's history, oldest first. Reading writes nothing.
|
||||
case 'thread.messages': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread', 'after', 'limit'], ['thread']);
|
||||
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const session = this.#threadSession(t, a.session);
|
||||
const limit = a.limit ?? THREAD_PAGE;
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
|
||||
let after = 0;
|
||||
if (a.after !== undefined) {
|
||||
after = this.#store.get(
|
||||
'SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND mt.thread=?',
|
||||
'SELECT m.seq FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE m.id=? AND mt.session=?',
|
||||
identifier(a.after),
|
||||
t.id,
|
||||
session,
|
||||
)?.seq;
|
||||
if (after === undefined) fail('message-not-found');
|
||||
}
|
||||
return this.#store.all(
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.thread=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
t.id,
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
session,
|
||||
after,
|
||||
);
|
||||
}
|
||||
// Row 264a (#1769, T1 item 7): a thread's sessions, oldest first, and which one is the default.
|
||||
// Each user reaches only their own threads' sessions (REQ-USER-2); there are no shares (Q7).
|
||||
case 'session.list': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread'], ['thread']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const fallback = this.#defaultSession(t.id);
|
||||
return this.#store
|
||||
.all('SELECT id,at,name,predecessor FROM sessions WHERE thread=? ORDER BY seq', t.id)
|
||||
.map((r) => ({ ...r, default: r.id === fallback }));
|
||||
}
|
||||
// D8: a new session is metadata only. Its run starts when the launcher starts it (264b).
|
||||
// The op key makes a retry find the first row: the same op and name answer it, another name
|
||||
// under the same op refuses.
|
||||
case 'session.create': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['thread', 'name', 'op'], ['thread', 'name', 'op']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
identifier(a.op);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const old = this.#store.get('SELECT id,name FROM sessions WHERE thread=? AND op=?', t.id, a.op);
|
||||
if (old) {
|
||||
if (old.name !== a.name) fail('session-op-conflict');
|
||||
return { id: old.id, thread: t.id, name: old.name };
|
||||
}
|
||||
if (this.#store.get('SELECT 1 FROM sessions WHERE thread=? AND name=?', t.id, a.name)) fail('session-exists');
|
||||
return { id: this.#newSession(s, t, a.name, a.op, false), thread: t.id, name: a.name };
|
||||
}
|
||||
case 'replies': {
|
||||
if (!s.human && !s.reader) fail('human-required');
|
||||
keys(a, ['id'], ['id']);
|
||||
@@ -3611,10 +3675,12 @@ export class Broker {
|
||||
#claimEnd(s, h, op, decision, reason = null, by = s.run ?? s.human) {
|
||||
if (h.thread)
|
||||
return this.#store.run(
|
||||
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?)',
|
||||
'INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,address,by,decision,reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
this.#now(),
|
||||
s.business,
|
||||
h.thread,
|
||||
h.session,
|
||||
h.generation,
|
||||
op,
|
||||
h.holder_run,
|
||||
h.harness,
|
||||
@@ -3657,7 +3723,7 @@ export class Broker {
|
||||
}
|
||||
// The message a review verb sends, routed like message.send's.
|
||||
#reviewMessage(s, mid, { to, cls, in_reply_to = null, decision = null, body }) {
|
||||
const thread = this.#threadFor(s, to, in_reply_to);
|
||||
const thread = this.#sessionFor(s, to, in_reply_to);
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
mid,
|
||||
@@ -3675,9 +3741,35 @@ export class Broker {
|
||||
this.#route(s.business, mid, to, thread);
|
||||
}
|
||||
// Row 121: a message names its thread (REQ-MSG-1) and goes to the holder that may have it.
|
||||
#route(business, mid, to, thread) {
|
||||
if (thread) this.#store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', mid, thread);
|
||||
this.#delivery(mid, 'routed', this.#recipient(business, to, thread));
|
||||
// Row 264a (#1769): it names one session of the thread, and the session's holder may have it.
|
||||
#route(business, mid, to, session) {
|
||||
if (session) {
|
||||
const { thread } = this.#store.get('SELECT thread FROM sessions WHERE id=?', session);
|
||||
this.#store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', mid, thread, session);
|
||||
}
|
||||
this.#delivery(mid, 'routed', this.#recipient(business, to, session));
|
||||
}
|
||||
// Row 264a (#1769): a session row and, for a new thread, its default. The 3j-3k migration made the
|
||||
// legacy defaults, whose id is the thread id; a new session's id is random, and a trigger refuses
|
||||
// one that names another thread.
|
||||
#newSession(s, t, sessionName, op, makeDefault) {
|
||||
const sid = randomUUID();
|
||||
const at = this.#now();
|
||||
this.#store.run(
|
||||
'INSERT INTO sessions(id,at,business,thread,instance,owner,name,op,predecessor) VALUES(?,?,?,?,?,?,?,?,?)',
|
||||
sid,
|
||||
at,
|
||||
t.business,
|
||||
t.id,
|
||||
t.instance,
|
||||
t.owner,
|
||||
sessionName,
|
||||
op,
|
||||
null,
|
||||
);
|
||||
if (makeDefault)
|
||||
this.#store.run('INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,?)', at, t.id, sid, this.#owner(s));
|
||||
return sid;
|
||||
}
|
||||
// Row 146 (#1640): a message.send that nobody holds the target for waits for a claim. Null when
|
||||
// the message was routed to a holder, or is the human's, a tester's, or in a thread to a thread
|
||||
@@ -3804,12 +3896,12 @@ export class Broker {
|
||||
);
|
||||
for (const m of rows) this.#delivery(m.id, 'routed', h);
|
||||
}
|
||||
// A new thread holder gets the thread's waiting messages to the instance.
|
||||
#routeThread(thread) {
|
||||
const h = this.#threadHolder(thread);
|
||||
// A new session holder gets the session's waiting messages to the instance.
|
||||
#routeSession(session) {
|
||||
const h = this.#sessionHolder(session);
|
||||
const rows = this.#store.all(
|
||||
"SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE mt.thread=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read'))",
|
||||
thread,
|
||||
"SELECT m.id FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE mt.session=? AND m.to_role=t.instance AND NOT EXISTS(SELECT 1 FROM deliveries WHERE message=m.id AND op IN ('delivered','read'))",
|
||||
session,
|
||||
);
|
||||
for (const m of rows) this.#delivery(m.id, 'routed', h);
|
||||
}
|
||||
|
||||
@@ -24,9 +24,6 @@ try {
|
||||
const me = readProcess(process.pid);
|
||||
const client = new Client({
|
||||
path: process.argv[2],
|
||||
// Row 254: library.sync fetches for up to 100 s and the server holds its socket for 120 s,
|
||||
// so wait past that for the server's own answer. Every other verb keeps the 5 s default.
|
||||
timeout: request.verb === 'library.sync' ? 130_000 : undefined,
|
||||
human: {
|
||||
business: request.business,
|
||||
pid: me.pid,
|
||||
|
||||
@@ -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_3j;
|
||||
ALTER TABLE message_threads RENAME TO message_threads_3j;
|
||||
ALTER TABLE thread_opens RENAME TO thread_opens_3j;
|
||||
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-3k',NULL FROM threads ORDER BY seq;
|
||||
INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3k' 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_3j 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_3j c ORDER BY c.seq;
|
||||
INSERT INTO message_threads(message,thread,session) SELECT message,thread,thread FROM message_threads_3j ORDER BY rowid;
|
||||
INSERT INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,thread,by FROM thread_opens_3j 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_3j';
|
||||
INSERT INTO sqlite_sequence(name,seq) SELECT 'thread_opens', seq FROM sqlite_sequence WHERE name='thread_opens_3j';
|
||||
DROP TABLE thread_claims_3j;
|
||||
DROP TABLE message_threads_3j;
|
||||
DROP TABLE thread_opens_3j;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE INDEX message_threads_session ON message_threads (session);
|
||||
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
|
||||
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
|
||||
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
|
||||
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
|
||||
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
|
||||
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
|
||||
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
|
||||
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
|
||||
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
|
||||
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
|
||||
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
|
||||
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
|
||||
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
|
||||
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
|
||||
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
|
||||
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
|
||||
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c';
|
||||
export const SCHEMA_VERSION = '3j';
|
||||
export const SCHEMA_SHA256 = '860374bde3f063ecfd72618913e05924587b7c39b87b763eee4c6aeb59922f46';
|
||||
export const SCHEMA_VERSION = '3k';
|
||||
// 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';
|
||||
@@ -61,6 +61,12 @@ const migrations = [{
|
||||
from: '3i',
|
||||
to: '3j',
|
||||
sql: readFileSync(new URL('./migrations/3i-3j.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: '3j',
|
||||
to: '3k',
|
||||
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -72,6 +78,8 @@ const timedTables = [
|
||||
'task_snapshots',
|
||||
'projects',
|
||||
'threads',
|
||||
'sessions',
|
||||
'session_defaults',
|
||||
'thread_claims',
|
||||
'thread_opens',
|
||||
'instance_names',
|
||||
|
||||
@@ -15,6 +15,9 @@ const AT = '2026-10-18T00:00:00.000Z';
|
||||
const SEQUENCE = 50;
|
||||
const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url), 'utf8');
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
// Row 264a (#1769): what the 3j→3k step rebuilds or adds. sessions.test.mjs tests those objects.
|
||||
const SESSIONS_3K = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
const later3k = (r) => SESSIONS_3K.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session'].includes(r.name);
|
||||
function source3h(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -70,6 +73,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/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'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -77,7 +81,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
// 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(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -87,7 +91,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
|
||||
// Row 234 (#1740) adds 3i→3j's own objects after this step. Its test is grant-migration.test.mjs.
|
||||
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_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) && !later3k(r)), was.filter(r => !later3k(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 {
|
||||
@@ -125,7 +129,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
});
|
||||
|
||||
@@ -137,14 +141,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 = '3j';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -2,8 +2,6 @@
|
||||
// Row 196 intentionally re-pins human.mjs for the distinct user nonce and shared human
|
||||
// walk, and webui bus.mjs for initial-spawn nonce plus peer refusal status. Entry paths
|
||||
// and the ban on channel plug-ins importing a proof caller are retained.
|
||||
// Row 254 (#1759) re-pins human-cli.mjs for the 130 s library.sync client timeout and webui
|
||||
// bus.mjs for six library-* STATUS entries; no proof or transport change.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -14,14 +12,14 @@ import { Client } from "../src/client.mjs";
|
||||
|
||||
const baselines = {
|
||||
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
|
||||
"packages/bus/src/human-cli.mjs": "9c9af66b98ea0a490c9a880fc81c3c62414a8ee0b5ba6478608771b8c4f87932",
|
||||
"packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073",
|
||||
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
||||
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
|
||||
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
|
||||
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
|
||||
// transport change. Row 155 (#1649) added eight for the assistant routes.
|
||||
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "e69d17d4385a4d190d15373ed1a26e270a5c6906eec9e61fcc2bb6b05849cabb",
|
||||
"packages/webui/src/bus.mjs": "934ab2f9b6bde72aafe7814758550c283eb25a4f3a23b284021b13718d67178f",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -606,7 +606,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 = '3j';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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');
|
||||
@@ -630,7 +630,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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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 = '3j';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3j', to: '3k', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', 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'])
|
||||
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'])
|
||||
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, '3k');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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 3j ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3k 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 3j-3k 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-3k' FROM threads WHERE id=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3k' 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',
|
||||
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
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;
|
||||
@@ -16,6 +16,9 @@ const TAG = 'mosaic-' + 'a'.repeat(32);
|
||||
const ddl3i = readFileSync(new URL('./fixtures/schema-3i.sql', import.meta.url), 'utf8');
|
||||
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 264a (#1769): what the 3j→3k step rebuilds or adds. sessions.test.mjs tests those objects.
|
||||
const SESSIONS_3K = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
|
||||
const later3k = (r) => SESSIONS_3K.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session'].includes(r.name);
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -35,7 +38,7 @@ function source3i(t) {
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants')").all()
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','sessions','session_defaults')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
@@ -61,6 +64,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql'])
|
||||
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'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -84,10 +88,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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(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) && !later3k(r)), was.filter(r => !later3k(r)));
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
try {
|
||||
@@ -109,10 +113,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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -120,14 +124,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 = '3j';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -4,9 +4,6 @@ import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, LIBRARY_VERBS } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
@@ -110,36 +107,3 @@ test('C2-03a runtime: the timer syncs each entry with a user at start and on eac
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
assert.equal((await library.readSyncState(mirror, { branch: 'main' })).lastError.code, 'library-fetch-failed');
|
||||
});
|
||||
|
||||
// Row 254 (#1759): the human transport waits past the server's 120 s library hold for library.sync
|
||||
// only. A stand-in bus answers each request after 6 s, past the 5 s default every other verb keeps.
|
||||
test('row 254 human-cli: library.sync outlasts the 5 s client default, and library.status does not', { timeout: 20000 }, async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-cli-'));
|
||||
const socket = join(root, 'bus.sock'), timers = new Set();
|
||||
const server = createServer(conn => {
|
||||
let input = '';
|
||||
conn.on('error', () => {});
|
||||
conn.on('data', b => {
|
||||
input += b;
|
||||
if (!input.includes('\n')) return;
|
||||
const { verb } = JSON.parse(input);
|
||||
timers.add(setTimeout(() => { if (!conn.destroyed) conn.end(JSON.stringify({ ok: true, result: { verb } }) + '\n'); }, 6000));
|
||||
});
|
||||
});
|
||||
await new Promise(r => server.listen(socket, r));
|
||||
t.after(() => { for (const timer of timers) clearTimeout(timer); server.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const cli = fileURLToPath(new URL('../src/human-cli.mjs', import.meta.url));
|
||||
const run = verb => new Promise(resolve => {
|
||||
const child = spawn(process.execPath, [cli, socket], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = '', err = '';
|
||||
child.stdout.on('data', b => { out += b; });
|
||||
child.stderr.on('data', b => { err += b; });
|
||||
child.on('close', status => resolve({ status, out, err }));
|
||||
child.stdin.end(JSON.stringify({ business: 'acme', verb, args: { project: 'books' } }) + '\n');
|
||||
});
|
||||
const [sync, status] = await Promise.all([run('library.sync'), run('library.status')]);
|
||||
assert.equal(sync.status, 0, sync.err);
|
||||
assert.deepEqual(JSON.parse(sync.out), { verb: 'library.sync' });
|
||||
assert.equal(status.status, 2);
|
||||
assert.equal(status.err.trim(), 'outcome-unknown');
|
||||
});
|
||||
@@ -55,7 +55,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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) });
|
||||
|
||||
@@ -520,7 +520,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them.
|
||||
// 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.
|
||||
assert.equal(count('table') - 1, 18);
|
||||
assert.equal(count('trigger'), 71);
|
||||
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 20);
|
||||
assert.equal(count('trigger'), 86);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
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 ddl3j = readFileSync(here('./fixtures/schema-3j.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('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 3k 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-3k' });
|
||||
// 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 3j store from the pinned 3j schema (the parent of this change's schema.sql) and fixture
|
||||
// rows. No live store is read or copied.
|
||||
function source3j(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(ddl3j).digest('hex'), '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c');
|
||||
db.exec(ddl3j);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3j');
|
||||
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 3j store migrates to 3k, each thread\'s default session has the thread\'s id, and old rows keep their seqs', (t) => {
|
||||
const { root, path } = source3j(t);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3k');
|
||||
// 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-3k', 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-3k' })));
|
||||
// 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 3j engine is store.mjs at this change with the 3j schema and version and no 3j→3k step.
|
||||
async function engine3j(root) {
|
||||
const moduleRoot = join(root, 'engine-3j');
|
||||
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(ddl3j).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(edited.includes("export const SCHEMA_VERSION = '3j';") && !edited.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3j);
|
||||
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));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
test('T7 D9: the 3j engine refuses a 3k store, migrating or not, and leaves it as it was', async (t) => {
|
||||
const { root, path } = source3j(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const was = master(path), before = rows(path);
|
||||
const Store3j = await engine3j(root);
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
// The same engine opens a fresh 3j store, so the refusal is the version's and not a broken copy.
|
||||
const { root: old } = source3j(t);
|
||||
new Store3j(old).close();
|
||||
});
|
||||
@@ -53,7 +53,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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'), []);
|
||||
|
||||
@@ -21,6 +21,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
|
||||
// Row 177 (#1675): the tester's own threads.
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// 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));
|
||||
|
||||
@@ -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 = '3j';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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 3j ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3k ready', ''].join('\n'));
|
||||
});
|
||||
@@ -7,6 +7,12 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, THREAD_PAGE } from '../src/broker.mjs';
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3j-3k 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-3k' FROM threads WHERE id=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3k' 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' });
|
||||
@@ -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' }],
|
||||
|
||||
@@ -51,12 +51,12 @@ function snapshot(path, columns) {
|
||||
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 => {
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h, 3i and 3j to 3k 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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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');
|
||||
|
||||
@@ -66,13 +66,13 @@ async function closed(e,x,a){
|
||||
const storeSha=(e)=>sha(e.store);
|
||||
const backupStore=(e,a)=>join(e.data,'backups','stack',a,'bus','bus.sqlite');
|
||||
|
||||
test('row 232: confirm, revert, gate, --to and --expect-from need --units external; the user-only flags are usage under it, and so are row 273\'s --send and --no-sso',async(t)=>{
|
||||
test('row 232: confirm, revert, gate, --to and --expect-from need --units external; the user-only flags are usage under it',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const logs=logLines(e).length;
|
||||
for(const a of [['confirm',C1,'acme'],['revert',C1,'acme'],['gate',B,'acme','--since','1'],['rollback','acme','--to',B],['activate',C1,'acme','--plan','--expect-from',B]]){
|
||||
const r=await run(e,...a);assert.equal(r.code,4,a.join(' '));assert.match(r.err,/^usage: stack-release.sh adopt BUSINESS\n/);
|
||||
}
|
||||
for(const a of [['activate',C1,'acme','--pm'],['activate',C1,'acme','--tester-port','7444'],['activate',C1,'acme','--no-proof'],['activate',C1,'acme','--no-send'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--no-sso'],['activate',C1,'acme','--proof-header','X','--proof-file','/p'],['rollback','acme'],['gate',B,'acme'],['activate',C1,'acme','--expect-from',B],['confirm',C1,'acme','--rollback',B],['confirm',C1,'acme','--cause','A5']]){
|
||||
for(const a of [['activate',C1,'acme','--pm'],['activate',C1,'acme','--tester-port','7444'],['activate',C1,'acme','--no-proof'],['activate',C1,'acme','--no-send'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--proof-header','X','--proof-file','/p'],['rollback','acme'],['gate',B,'acme'],['activate',C1,'acme','--expect-from',B],['confirm',C1,'acme','--rollback',B],['confirm',C1,'acme','--cause','A5']]){
|
||||
const r=await run(x,...a);assert.equal(r.code,4,a.join(' '));
|
||||
}
|
||||
assert.equal(logLines(e).length,logs);
|
||||
|
||||
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
|
||||
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
|
||||
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
|
||||
// well as T46.
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','thread_opens','instance_names','usage_records'];
|
||||
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','instance_names','usage_records'];
|
||||
const LATE='2999-01-01T00:00:00.000Z';
|
||||
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
|
||||
|
||||
@@ -1258,7 +1258,7 @@ test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{
|
||||
['activate',C1,'acme','--plan','--proof-file',f]]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\]/);
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1417,7 +1417,7 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
|
||||
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
|
||||
assert.match(usageText,/\| --no-proof \| --sso\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1465,7 +1465,7 @@ const DROPS='--public-host console.example --auth-header x-user --allow-user jas
|
||||
const SHOW=`show-${CON}.txt`;
|
||||
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
|
||||
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
|
||||
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --tester-port as the pointer has it`;
|
||||
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --send and --tester-port as the pointer has them`;
|
||||
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
||||
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
|
||||
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
|
||||
@@ -1526,7 +1526,7 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
['rollback','acme','--sso'],['pm-on','acme','--sso'],['status','acme','--sso'],['adopt','acme','--sso']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
@@ -1600,14 +1600,14 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.ok(stepText(e).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${SHOW}\n`));
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// The plain case: the preview set and pair come back as they were. --send lost its --sso, so it goes (row 273, F39 b).
|
||||
// The plain case: --send and the preview set and pair come back as they were.
|
||||
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
|
||||
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
await ssoConfig(g);
|
||||
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
setKey(g,undefined);
|
||||
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)]);
|
||||
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f),'--send']);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
|
||||
@@ -1679,7 +1679,7 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
setKey(a,undefined);
|
||||
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
|
||||
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]);
|
||||
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --send and --tester-port as the pointer has them`]);
|
||||
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
|
||||
assert.ok(stepText(a).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${show}\n`));
|
||||
@@ -1695,198 +1695,6 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
});
|
||||
});
|
||||
|
||||
// Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and
|
||||
// activate --no-sso turns --sso off with the Console flags from the set of the activation that turned
|
||||
// it on, dropping --send. The tester follows the pointer.
|
||||
const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)";
|
||||
const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off";
|
||||
const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`;
|
||||
const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`;
|
||||
const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
||||
test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
||||
await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
||||
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
const steps=stepText(e);
|
||||
assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// The same copy with send on again is already active.
|
||||
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--send');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']);
|
||||
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
||||
const want=['--port',e.port,'--business','acme','--sso','--send'];
|
||||
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
|
||||
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
||||
// Another copy carries both.
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T2: --send without --sso, given or carried, refuses before any stop, and --plan lists it',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);
|
||||
await refused(e,['activate',C1,'acme','--send'],'send-needs-sso',SEND_MSG);
|
||||
await refused(e,['activate',B,'acme','--send'],'send-needs-sso',SEND_MSG);
|
||||
const out=await planOnly(e,3,'activate',C1,'acme','--send');
|
||||
assert.deepEqual(sendLines(out),['send: off']);
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses send-needs-sso: ${SEND_MSG}`]);
|
||||
// A pointer that already holds --send without --sso still refuses a --send.
|
||||
const g=await mk(t);await seedPreview(g,undefined,['--send']);
|
||||
await refused(g,['activate',C1,'acme','--send'],'send-needs-sso',SEND_MSG);
|
||||
await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
||||
const set=arrivalSet(e,C1);
|
||||
setKey(e,undefined);
|
||||
const out=await planOnly(e,0,'rollback','acme');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]);
|
||||
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${base.join(' ')}`);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,base);
|
||||
assert.doesNotMatch(conExec(e),/ --sso( |$)| --send( |$)/);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${base.join(' ')}`),conExec(e));
|
||||
assert.ok(stepText(e).includes('Z drop --send; the run drops --sso\n'),stepText(e));
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
||||
for(const args of [['activate',C1,'acme','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'],
|
||||
['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'],
|
||||
['activate',C1,'acme','--no-sso',...pair(f)],['activate',C1,'acme','--no-sso','--no-proof'],
|
||||
['rollback','acme','--send'],['rollback','acme','--no-sso'],['pm-on','acme','--send'],['pm-on','acme','--no-sso'],['status','acme','--no-sso'],['adopt','acme','--send']]){
|
||||
r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
|
||||
assert.match(usageText,/\n--send needs --sso in the run's Console flags, given or carried\. --no-sso needs --sso in the\n/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const pre=conExec(e),want=noSend(base);
|
||||
assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
|
||||
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
||||
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
||||
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
||||
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
||||
assert.equal(conExec(e),pre.replace(' --send --tester-port 7333',' --tester-port 7333'));
|
||||
const steps=stepText(e);
|
||||
assert.ok(steps.includes('Z drop --send; --no-sso was given\n'),steps);
|
||||
assert.ok(steps.includes(`Z turn off --sso; the Console flags come from ${set}/${SHOW}\n`),steps);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
// --sso is off now, so a second --no-sso has nothing to turn off.
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
||||
// The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ.
|
||||
let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']);
|
||||
const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334'];
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
||||
assert.doesNotMatch(stepText(e),/drop --send/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
|
||||
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation);
|
||||
// Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it.
|
||||
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2);
|
||||
for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /);
|
||||
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
||||
assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,base);assert.equal(conExec(e),pre);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const first=setOf(e,pointer(e).activation);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
||||
// The set of the activation that turned --sso on now claims its Console ran with it too, sums intact.
|
||||
setEdit(first,SHOW,' --public-host ',' --sso --public-host ');
|
||||
const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why);
|
||||
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
||||
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]);
|
||||
// One set, from the activation that turned --sso on.
|
||||
await ssoConfig(e);
|
||||
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
||||
const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
||||
// An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above.
|
||||
const logText=readFileSync(e.log,'utf8');
|
||||
appendFileSync(e.log,JSON.stringify({at:'2026-10-22T00:00:00Z',op:'activate',from:C1,to:C1,backup:null,activation:null,result:'ok'})+'\n');
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG('an ok line in the log names no activation'));
|
||||
writeFileSync(e.log,logText);
|
||||
setEdit(set,SHOW,' --allow-user jason',' --allow-user mallory',false);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${show} does not match the set's SHA256SUMS`));
|
||||
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${OFF_MSG(`${show} does not match the set's SHA256SUMS`)}`]);
|
||||
setEdit(set,SHOW,' --allow-user mallory',' --sso --allow-user jason');
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`the Console in ${show} already ran with --sso, and the log names no earlier activation`));
|
||||
setEdit(set,SHOW,' serve ',' ');
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${show} holds no Console argv this script can read`));
|
||||
rmSync(show);
|
||||
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${set} has no ${SHOW} and SHA256SUMS`));
|
||||
writeFileSync(show,text);setEdit(set,SHOW,'\n','\n');
|
||||
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.doesNotMatch(conExec(e),/ --sso( |$)| --send( |$)/);
|
||||
// A pointer with --sso and only the adopt line: no activation took a set.
|
||||
const g=await mk(t);await seedPreview(g,undefined,['--sso']);
|
||||
await refused(g,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG('the log names no activation with a rollback set'));
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
|
||||
@@ -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 3j-3k 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-3k' FROM threads WHERE id=?",
|
||||
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3k' 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 3j-3k 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-3k' FROM threads WHERE id=?",
|
||||
"INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3k' 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();
|
||||
|
||||
|
||||
@@ -165,13 +165,6 @@ lets go, so an object must outlive the next move of `served`. The bus host runs
|
||||
with a `user`, once at start and then every 60 seconds; the slot is
|
||||
`<configDir>/secrets/<business>/library-gitea`. `library.sync` is the human's bus verb for the same.
|
||||
|
||||
The human transport (`packages/bus/src/human-cli.mjs`) waits 130 seconds for `library.sync`, past the
|
||||
server's 120-second hold, and 5 seconds for every other verb. A client that gives up before the answer
|
||||
gets `outcome-unknown`, but the sync keeps running: a second call meanwhile gets `library-sync-busy`,
|
||||
and `library.status` shows the result once it ends. A sync that outlasts the 120-second hold gets
|
||||
`request-timeout` from the server and still finishes the same way. The Console has no library route yet: `cli.mjs`
|
||||
passes `librarySync` to `startServer`, and `serve.mjs` doesn't read it.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
|
||||
@@ -102,10 +102,5 @@ const STATUS = {
|
||||
'organization-instance-exists': 409, 'organization-unknown-instance': 400,
|
||||
'organization-credentials-out-of-scope': 400, 'organization-launch-not-configured': 400,
|
||||
'organization-no-change': 400, 'organization-project-mismatch': 400, 'organization-unresolvable-instance': 400,
|
||||
// Row 254 (#1759): library sync. A sync already running, a held divergence and a stale accept are
|
||||
// the user's to resolve by reading library.status again; a missing credential or a failed fetch
|
||||
// leaves the remote unreachable, like no bus to ask.
|
||||
'library-sync-busy': 409, 'library-diverged': 409, 'library-accept-stale': 409, 'library-accept-invalid': 400,
|
||||
'library-credential-missing': 503, 'library-fetch-failed': 503,
|
||||
};
|
||||
export const busStatus = code => STATUS[code] ?? 502;
|
||||
@@ -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 3j-3k 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-3k' FROM threads WHERE id=?',
|
||||
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,'migration-3k' 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']) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// Row 254 (#1759): the library.sync refusal codes map to their HTTP status, not the 502 default.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { busStatus } from '../src/bus.mjs';
|
||||
|
||||
test('row 254: each library sync code maps to its status', () => {
|
||||
const expected = {
|
||||
'library-sync-busy': 409, 'library-diverged': 409, 'library-accept-stale': 409, 'library-accept-invalid': 400,
|
||||
'library-credential-missing': 503, 'library-fetch-failed': 503,
|
||||
};
|
||||
for (const [code, status] of Object.entries(expected)) assert.equal(busStatus(code), status, code);
|
||||
assert.equal(busStatus('library-not-a-code'), 502, 'an unknown code keeps the default');
|
||||
});
|
||||
+22
-143
@@ -5,8 +5,6 @@
|
||||
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
|
||||
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
|
||||
# activate --sso swaps the preview set and the pair for the Console's own sign-in (row 200, #1698).
|
||||
# activate --send turns send on, only beside --sso, and activate --no-sso turns --sso off with the
|
||||
# Console flags from before it, dropping --send (row 273, #1778, mos-1246 F39).
|
||||
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
|
||||
#
|
||||
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
|
||||
@@ -40,14 +38,14 @@ COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
||||
BIN="$HOME/.npm-global/bin"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT="" TESTER_SOCKET=0 EXPECTED_INTERPRETER=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 SEND=0 SSO=0 NO_SSO=0 R_BACKUP="" R_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 SSO=0 R_BACKUP="" R_PORT=""
|
||||
UNITS=user TO_FLAG="" EXPECT_FROM="" SINCE="" ROLLBACK_OLD="" CAUSE=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage: stack-release.sh adopt BUSINESS
|
||||
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-socket | --tester-port N (legacy copies)]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof | --sso | --no-sso] [--send | --no-send] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
|
||||
stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
stack-release.sh status BUSINESS
|
||||
@@ -63,9 +61,7 @@ every table in COUNTED.
|
||||
--units external (row 232) reads root's system units and starts or stops none. adopt, activate,
|
||||
rollback, status, confirm, revert and gate take it; rollback then needs --to ID, the previous copy.
|
||||
activate and rollback --plan take --expect-from OLD. --pm, --pm-after-harness, --tester-port, the
|
||||
proof flags, --no-proof, --send, --no-send, --sso, --no-sso and pm-on are not for it.
|
||||
--send needs --sso in the run's Console flags, given or carried. --no-sso needs --sso in the
|
||||
pointer's, and takes the Console flags from the rollback set of the activation that turned it on.
|
||||
proof flags, --no-proof, --no-send, --sso and pm-on are not for it.
|
||||
USAGE
|
||||
exit "${1:-4}"
|
||||
}
|
||||
@@ -132,9 +128,7 @@ parse_opts() {
|
||||
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
|
||||
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
|
||||
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
|
||||
--send) [ "$OP" = activate ] || usage; SEND=1; shift ;;
|
||||
--sso) [ "$OP" = activate ] || usage; SSO=1; shift ;;
|
||||
--no-sso) [ "$OP" = activate ] || usage; NO_SSO=1; shift ;;
|
||||
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [ "$2" = last ] || [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
@@ -156,8 +150,7 @@ parse_opts() {
|
||||
[ "$OP" != confirm ] && [ "$OP" != revert ] && [ "$OP" != gate ] && [ -z "$TO_FLAG" ] && [ -z "$EXPECT_FROM" ] || usage
|
||||
else
|
||||
[ "$OP" != rollback ] || [ -n "$TO_FLAG" ] || usage
|
||||
[ "$TESTER_SOCKET" = 0 ] && [ "$WANT_PM" = 0 ] && [ -z "$TESTER_PORT" ] && [ -z "$PROOF_HEADER" ] && [ -z "$PROOF_FILE" ] && [ "$NO_PROOF" = 0 ] && [ "$NO_SEND" = 0 ] && [ "$SSO" = 0 ] &&
|
||||
[ "$SEND" = 0 ] && [ "$NO_SSO" = 0 ] || usage
|
||||
[ "$TESTER_SOCKET" = 0 ] && [ "$WANT_PM" = 0 ] && [ -z "$TESTER_PORT" ] && [ -z "$PROOF_HEADER" ] && [ -z "$PROOF_FILE" ] && [ "$NO_PROOF" = 0 ] && [ "$NO_SEND" = 0 ] && [ "$SSO" = 0 ] || usage
|
||||
fi
|
||||
[ "$OP" != gate ] || [ -n "$SINCE" ] || usage
|
||||
[ -z "$EXPECT_FROM" ] || [ "$PLAN" = 1 ] || usage
|
||||
@@ -174,10 +167,6 @@ parse_opts() {
|
||||
[ "$NO_PROOF" = 0 ] || [ -z "$PROOF_HEADER" ] || usage
|
||||
# --sso drops the pair itself, so it takes neither a pair nor --no-proof (row 200).
|
||||
[ "$SSO" = 0 ] || { [ -z "$PROOF_HEADER" ] && [ "$NO_PROOF" = 0 ]; } || usage
|
||||
# Row 273: --send and --no-send contradict. --no-sso takes its Console flags from a rollback set
|
||||
# and drops --send, so it goes with no --sso, --send, pair or --no-proof.
|
||||
[ "$SEND" = 0 ] || [ "$NO_SEND" = 0 ] || usage
|
||||
[ "$NO_SSO" = 0 ] || { [ "$SSO" = 0 ] && [ "$SEND" = 0 ] && [ -z "$PROOF_HEADER" ] && [ "$NO_PROOF" = 0 ]; } || usage
|
||||
local d
|
||||
for d in "$COPIES" "$BIN"; do
|
||||
case "$d" in /*) ;; *) usage ;; esac
|
||||
@@ -236,8 +225,7 @@ for(const k of open.keys())process.stdout.write(k+"\n");' -- "$LOG"
|
||||
# line that made the pointer's copy live, the backup a rollback would restore. `newest` prints the
|
||||
# backup field of the newest line that has one, rehearse's --backup last (row 233). `arrival` prints the
|
||||
# activation and `from` of the line that moved the stack onto the pointer's copy from another one,
|
||||
# past any same-copy re-pin after it (row 200, sage-1389). `sets` prints the activation of each
|
||||
# activate and rollback, newest first, back to the adopt line, whose run took no rollback set (row 273).
|
||||
# past any same-copy re-pin after it (row 200, sage-1389).
|
||||
from_log() {
|
||||
[ -f "$LOG" ] || return 1
|
||||
node -e '
|
||||
@@ -246,13 +234,12 @@ const lines=require("fs").readFileSync(file,"utf8").split("\n").filter((l)=>l.tr
|
||||
for(const l of lines.reverse()){
|
||||
let o;try{o=JSON.parse(l)}catch{continue}
|
||||
if(o.result!=="ok"||!["adopt","activate","rollback"].includes(o.op)||!o.to)continue;
|
||||
if(mode==="sets"){if(o.op==="adopt")process.exit(0);process.stdout.write(`${o.activation??""}\n`);continue}
|
||||
if(mode==="previous"&&o.to!==current){process.stdout.write(o.to+"\n");process.exit(0)}
|
||||
if(mode==="backup"&&o.to===current){if(o.backup)process.stdout.write(o.backup+"\n");process.exit(o.backup?0:1)}
|
||||
if(mode==="newest"&&o.backup){process.stdout.write(o.backup+"\n");process.exit(0)}
|
||||
if(mode==="arrival"&&o.to===current&&o.from!==current){process.stdout.write(`${o.activation??""} ${o.from??""}\n`);process.exit(0)}
|
||||
}
|
||||
process.exit(mode==="sets"?0:1);' -- "$LOG" "$1" "$P_COPY"
|
||||
process.exit(1);' -- "$LOG" "$1" "$P_COPY"
|
||||
}
|
||||
|
||||
# --- Reading the units ---
|
||||
@@ -336,18 +323,15 @@ in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0;
|
||||
|
||||
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
|
||||
# sets a proof pair, drops the pointer's pair with --no-proof, drops the pointer's --send with
|
||||
# --no-send, turns on --sso, turns on --send, or turns off --sso may name the pointer's copy: it
|
||||
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1; row 189, mos-0972;
|
||||
# row 200, the phase 2 window; row 273). --no-sso on a pointer without --sso gets past here and
|
||||
# refuses sso-not-on in no_sso_base, which names the reason.
|
||||
# --no-send, or turns on --sso, may name the pointer's copy: it re-pins the live copy with new
|
||||
# Console flags (row 187, Part B's B3 and B-R1; row 189, mos-0972; row 200, the phase 2 window).
|
||||
check_target() {
|
||||
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] || [ "$NO_SSO" = 1 ] ||
|
||||
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
|
||||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
|
||||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
|
||||
{ [ "$SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " != *" --send "* ]]; } ||
|
||||
{ [ "$SSO" = 1 ] && [[ " ${P_CONSOLE[*]} " != *" --sso "* ]]; } ||
|
||||
refuse already-active "$TO is the pointer's copy"
|
||||
}
|
||||
@@ -417,7 +401,7 @@ plugin_checks() {
|
||||
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
|
||||
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
|
||||
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads thread_opens instance_names usage_records"
|
||||
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens instance_names usage_records"
|
||||
|
||||
STORE_JS='
|
||||
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");
|
||||
@@ -870,17 +854,21 @@ has_flag() { local f="$1"; shift; [[ " $* " == *" $f "* ]]; }
|
||||
# skips sso_checks after a refusal here, so --plan lists that refusal alone.
|
||||
sso_base() {
|
||||
BASE_CONSOLE=("${P_CONSOLE[@]}") SSO_PLAN="" SSO_STEP="" SSO_NO_TARGET=""
|
||||
if [ "$NO_SSO" = 1 ]; then no_sso_base; return 0; fi
|
||||
has_flag --sso "${P_CONSOLE[@]}" || return 0
|
||||
[ "$SSO" = 1 ] || SSO_PLAN="sso: carried"
|
||||
[ "$OP" = rollback ] && ! sso_supported || return 0
|
||||
local f="show-$CONSOLE_UNIT.txt" why="" act="" from="" set
|
||||
local f="show-$CONSOLE_UNIT.txt" argv="" why="" act="" from="" set
|
||||
read -r act from <<< "$(from_log arrival || true)"
|
||||
set="$STATE/stack-rollback/$act"
|
||||
if [ "$from" != "$TO" ]; then why="the log names no activation that moved the stack from $TO onto $P_COPY"
|
||||
elif ! [[ "$act" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]]; then why="the activation $act that moved the stack onto $P_COPY names no rollback set"
|
||||
elif ! set_flags "$set"; then why="$SET_WHY"
|
||||
elif has_flag --sso "${SERVE_FLAGS[@]}"; then why="the Console in $set/$f already ran with --sso"
|
||||
elif [ ! -f "$set/$f" ] || [ ! -f "$set/SHA256SUMS" ]; then why="$set has no $f and SHA256SUMS"
|
||||
elif [ "$(sum_of "$set" "$f")" != "$(sha256sum < "$set/$f" | cut -d' ' -f1)" ]; then why="$set/$f does not match the set's SHA256SUMS"
|
||||
else
|
||||
argv="$(argv_of "$(cat "$set/$f")")"
|
||||
if ! unquoted "$argv" || ! serve_flags "$argv"; then why="$set/$f holds no Console argv this script can read"
|
||||
elif has_flag --sso "${SERVE_FLAGS[@]}"; then why="the Console in $set/$f already ran with --sso"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$why" ]; then
|
||||
SSO_NO_TARGET=1
|
||||
@@ -888,8 +876,7 @@ sso_base() {
|
||||
return 0
|
||||
fi
|
||||
# --send and --tester-port follow the pointer, so a --no-send or a tester port set after that
|
||||
# activation stays (row 189's rule that rollback carries --send off). A --send carried here has
|
||||
# lost its --sso, so send_for_target drops it (row 273, F39 b).
|
||||
# activation stays (row 189's rule that rollback carries --send off).
|
||||
local i
|
||||
BASE_CONSOLE=()
|
||||
for ((i = 0; i < ${#SERVE_FLAGS[@]}; i++)); do
|
||||
@@ -907,79 +894,10 @@ sso_base() {
|
||||
--tester-socket) BASE_CONSOLE+=(--tester-socket) ;;
|
||||
esac
|
||||
done
|
||||
SSO_PLAN="sso: dropped; $TO's Console does not support --sso, so the rollback takes the Console flags from $set/$f, with --tester-port as the pointer has it"
|
||||
SSO_PLAN="sso: dropped; $TO's Console does not support --sso, so the rollback takes the Console flags from $set/$f, with --send and --tester-port as the pointer has them"
|
||||
SSO_STEP="drop --sso; $TO's Console does not support it, so the Console flags come from $set/$f"
|
||||
}
|
||||
|
||||
# Sets SERVE_FLAGS to the Console flags in rollback set $1's show-$CONSOLE_UNIT.txt, the Console
|
||||
# unit as it was before that set's run, checked against the set's SHA256SUMS. Returns 1 with
|
||||
# SET_WHY when the file is missing, fails its sum or holds no argv this script can read.
|
||||
set_flags() {
|
||||
local set="$1" f="show-$CONSOLE_UNIT.txt" argv
|
||||
SET_WHY=""
|
||||
if [ ! -f "$set/$f" ] || [ ! -f "$set/SHA256SUMS" ]; then SET_WHY="$set has no $f and SHA256SUMS"
|
||||
elif [ "$(sum_of "$set" "$f")" != "$(sha256sum < "$set/$f" | cut -d' ' -f1)" ]; then SET_WHY="$set/$f does not match the set's SHA256SUMS"
|
||||
else
|
||||
argv="$(argv_of "$(cat "$set/$f")")"
|
||||
unquoted "$argv" && serve_flags "$argv" && return 0
|
||||
SET_WHY="$set/$f holds no Console argv this script can read"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Row 273 (#1778, mos-1246 F39 a): activate --no-sso turns --sso off. The Console flags come from the
|
||||
# rollback set of the activation that turned --sso on. Each set holds the Console from before its
|
||||
# run, so that activation is the newest one whose set holds a Console without --sso: every newer
|
||||
# set ran with it. The walk reads the log's activations newest first, back to the adopt line, and
|
||||
# refuses at the first set it can't read, since it can't tell whether that set is the one. --send
|
||||
# is dropped (F39 a) and the tester follows the pointer. Sets BASE_CONSOLE, SSO_PLAN and SSO_STEP.
|
||||
no_sso_base() {
|
||||
local f="show-$CONSOLE_UNIT.txt" why="" act set="" found=0 i restored=""
|
||||
local -a acts=()
|
||||
if ! has_flag --sso "${P_CONSOLE[@]}"; then
|
||||
check_refuse sso-not-on "the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off"
|
||||
return 0
|
||||
fi
|
||||
# An empty entry is an ok line with no activation; skipping it could land on an older set.
|
||||
mapfile -t acts < <(from_log sets || true)
|
||||
for act in "${acts[@]}"; do
|
||||
set="$STATE/stack-rollback/$act"
|
||||
if [ -z "$act" ]; then why="an ok line in the log names no activation"; break; fi
|
||||
if ! [[ "$act" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]]; then why="the activation $act names no rollback set"; break; fi
|
||||
set_flags "$set" || { why="$SET_WHY"; break; }
|
||||
has_flag --sso "${SERVE_FLAGS[@]}" || { found=1; break; }
|
||||
done
|
||||
if [ -z "$why" ] && [ "$found" = 0 ]; then
|
||||
if [ -z "$set" ]; then why="the log names no activation with a rollback set"
|
||||
else why="the Console in $set/$f already ran with --sso, and the log names no earlier activation"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$why" ]; then
|
||||
SSO_NO_TARGET=1
|
||||
check_refuse sso-off-flags "the Console flags from before --sso cannot be read: $why"
|
||||
return 0
|
||||
fi
|
||||
# A --send in the set stays here: send_for_target is the one place that drops it, so the
|
||||
# restores list leaves it out.
|
||||
BASE_CONSOLE=()
|
||||
for ((i = 0; i < ${#SERVE_FLAGS[@]}; i++)); do
|
||||
case "${SERVE_FLAGS[i]}" in
|
||||
--tester-port) i=$((i + 1)) ;;
|
||||
--tester-socket) ;;
|
||||
*) BASE_CONSOLE+=("${SERVE_FLAGS[i]}")
|
||||
[ "${SERVE_FLAGS[i]}" = --send ] || restored+="${restored:+ }${SERVE_FLAGS[i]}" ;;
|
||||
esac
|
||||
done
|
||||
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do
|
||||
case "${P_CONSOLE[i]}" in
|
||||
--tester-port) i=$((i + 1)); BASE_CONSOLE+=(--tester-port "${P_CONSOLE[i]:-}") ;;
|
||||
--tester-socket) BASE_CONSOLE+=(--tester-socket) ;;
|
||||
esac
|
||||
done
|
||||
SSO_PLAN="sso: off; Console flags from $set/$f; restores ${restored:-nothing}"
|
||||
SSO_STEP="turn off --sso; the Console flags come from $set/$f"
|
||||
}
|
||||
|
||||
# Row 200 (#1698): activate --sso drops the preview set and the proof pair, which serve refuses beside
|
||||
# it, and puts --sso where webui-service.sh renders it: after the other flags, before --send and
|
||||
# --tester-port. Sets SSO_DROP (the plan's dropped preview flags), PROOF_PLAN and PROOF_STEP.
|
||||
@@ -1099,53 +1017,14 @@ process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' --
|
||||
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
|
||||
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
|
||||
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
|
||||
# Row 273 (mos-1246 F39): under forward auth any same-uid process can send as the signed-in user
|
||||
# through the Console's port, so --send turns send on only when the run's Console flags hold --sso,
|
||||
# given or carried. --no-sso drops the pointer's --send, and so does a rollback to a Console without
|
||||
# --sso.
|
||||
send_for_target() {
|
||||
local had=0 i
|
||||
local -a without_send=() with_send=()
|
||||
local -a without_send=()
|
||||
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
|
||||
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
|
||||
done
|
||||
SEND_STEP=""
|
||||
if [ "$NO_SSO" = 1 ]; then
|
||||
RUN_CONSOLE=("${without_send[@]}")
|
||||
if has_flag --send "${P_CONSOLE[@]}"; then
|
||||
SEND_PLAN="send: dropped; --no-sso drops --send (F39)"
|
||||
SEND_STEP="drop --send; --no-sso was given"
|
||||
else
|
||||
SEND_PLAN="send: off; --no-sso was given and the pointer holds no --send"
|
||||
fi
|
||||
elif has_flag --sso "${P_CONSOLE[@]}" && ! has_flag --sso "${RUN_CONSOLE[@]}"; then
|
||||
# Row 273 (F39 b): a rollback to a Console without --sso drops it, and --send goes with it.
|
||||
RUN_CONSOLE=("${without_send[@]}")
|
||||
if [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: dropped; the run drops --sso, and --send needs it (F39)"
|
||||
SEND_STEP="drop --send; the run drops --sso"
|
||||
else
|
||||
SEND_PLAN="send: off"
|
||||
fi
|
||||
elif [ "$SEND" = 1 ]; then
|
||||
if ! has_flag --sso "${RUN_CONSOLE[@]}"; then
|
||||
SEND_PLAN="send: off"
|
||||
check_refuse send-needs-sso "--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)"
|
||||
return 0
|
||||
fi
|
||||
if [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: carried"
|
||||
return 0
|
||||
fi
|
||||
# webui-service.sh renders --send after --sso, before the tester flags.
|
||||
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
|
||||
case "${RUN_CONSOLE[i]}" in --tester-port | --tester-socket) break ;; esac
|
||||
with_send+=("${RUN_CONSOLE[i]}")
|
||||
done
|
||||
RUN_CONSOLE=("${with_send[@]}" --send "${RUN_CONSOLE[@]:i}")
|
||||
SEND_PLAN="send: on; --send was given"
|
||||
SEND_STEP="turn on --send; --send was given"
|
||||
elif [ "$NO_SEND" = 1 ]; then
|
||||
if [ "$NO_SEND" = 1 ]; then
|
||||
RUN_CONSOLE=("${without_send[@]}")
|
||||
if [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: dropped; --no-send was given"
|
||||
|
||||
Reference in new issue
Block a user