Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0048911e | ||
|
|
7965efd0fb | ||
|
|
77dd43c9e3 | ||
|
|
8bf442be90 | ||
|
|
d73ea24ead | ||
|
|
69bf7f5ca7 |
@@ -5220,62 +5220,4 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
|
||||
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-20 18:51:10 UTC | Row 191 #1689 design phase | Before: per-business launch revoke and per-binding Discord STOP leave task containers, active sessions and background work without one switch. Proposed per-data-root durable halt with human-only resume, generation-fenced starts, bounded concurrent termination, write-once stop receipts and existing event mirror; no schema migration. DESIGN.md inventories starter/continuation paths and container control-mount restriction. Sent to Dewey with Sage copied; implementation waits independent design approval. No tests, commits or live actions.
|
||||
|
||||
2026-10-20 18:57:51 UTC | Row 191 design revision | Dewey r1 changes: interim Console --send absence, reserved processing events and missing-state semantics required correction. DESIGN-r2.md now names CLI interim path, unavailable UI controls, trusted initialization and fail-closed missing leaf, container delete/replace mount tests, reservations and id-bound reconciliation. Small notes addressed including diagnostic seats and aliases. Recommended smaller Docker scope to Sage; design and scope approval pending, no code or test runs.
|
||||
|
||||
2026-10-20T19:50:56Z | Rocko | row 191 #1689 implementation stage: from independent brakes to shared root state, generation-fenced launcher and tracked stops, Discord/task guards and control HTTP/UI. Evidence is agents/rocko/work/queue-191/logs/ and PROGRESS.md. Fixture single files only so far; heavy verification and candidate not complete. Scope additions sage-1367 (one read-only CODE file) and sage-1392 (processing-only client socket timeout); no schema, proof baseline, live file/unit, commit or push change.
|
||||
|
||||
2026-10-20 22:58:45 UTC | Rocko | row 191 #1689 rebase inspection candidate | Rebuilt uncommitted kill-switch tree from da230ca48 onto landed row 190 base 00d4cc1f6 under new branch rocko/row191-r2-00d4. Hand merges retained instance flags and SSO/sign-out changes; fixture tests exposed and fixed SSO signer compatibility and two unknown-runtime cleanup defects. Final touched singles 249/249 across 26 files; 15 overlapping Client reader files 102/102. Manifest a4f780c2 has 78 paths; packet 185 files verified with scan clear. Mutants and owner gate remain pending; no review-ready or activation claim, no commit/push/live action.
|
||||
|
||||
2026-10-21 23:39:21 UTC | Rocko | row 191 #1689 inspection snapshot | Sage-2242 authorized a local inspection commit for Honker's row 194 S6. Created d52b4db1c on new rocko/row191-inspection-20261021, parent 00d4cc1f6, preserving the reviewed-design build and candidate a4f780c2 (78 paths). Static manifest/index/source scan and diff check verified; no tests run, no push credential or push attempt. Existing single-file evidence is historical, 249/249 and overlapping Client readers 102/102; suites/mutants/gate remain pending. Current code has verified runtime signalling and instance halt, but no dedicated per-run stop API yet; Honker and Sage notified of that seam directly. Snapshot is not review-ready or approved for live use.
|
||||
|
||||
2026-10-22 00:04:05 UTC | Rocko | row 191 #1689 per-run stop inspection | Implemented agreed Honker-0079/0081 stopRun contract with verified per-signal identity, fixed 5s+2s, durable first-owner join and immutable result plus later-exit observations. Focused CLEAR sage-2250: final 9 files 62/62, no skipped, after correcting host guard placement and peer-discovery fixture timeout isolation; initial failures and failed teardown logs retained, only own failed group stopped. Slot returned with cwd sweep clear. Sage published f7bf7dfec; sage-2265 clean rebase creates 7aa18b0f6 on dcd8d4dda under new branch, explicit Rocko identity, all 79 manifest product blobs identical so 62/62 evidence carried. Direct S6 handoff sent to Honker and Sage. Wider suites, mutants, review and activation remain pending.
|
||||
|
||||
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.
|
||||
|
||||
2026-10-22 00:24:34 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 full gate on c78f48946 | stopped on second integration failure per sage-2275; singles 269/269, processing-control 61/61, bus 646/646; CLI interrupted with 26 observed failures; no further edits; cwd sweep empty and slot returned to Sage.
|
||||
|
||||
2026-10-22 00:48:20 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eb3abba12 full gate | stopped at Discord 190/192; 32 singles 304/304, processing-control 61/61, bus 646/646, cli 761/761; two uninitialized Discord fixtures reported to Sage; no later run; cwd sweep empty, slot clear.
|
||||
|
||||
2026-10-22 00:58:27 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2294 fixture fix | e6800380d on eb3abba12, two test files/four additions, assertions preserved; 338-entry static all-package sweep recorded; no tests; sent Sage for publication and Honker for dependent gate.
|
||||
|
||||
2026-10-22 01:30:04 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 e6800380d resume | binding15/15, recover5/5, Discord192/192; stopped WebUI314 passed/4 failed/1 esbuild skip; processing polling violates sign-out/manual redirect and bar changes layout; no fixes/retries, cwd sweep empty, slot returned.
|
||||
|
||||
2026-10-22 01:38:08 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2317 Console integration fix | 8e1f55c1d on e6800380d; shared sign-out/manual redirect/interval and desktop frame fixes, assertions retained, six browser cases, 73 mutant sites preflight; syntax only, no tests; sent Sage/Honker.
|
||||
|
||||
2026-10-22 02:06:38 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 rebase sage-2329 | eafcbd3e7 on ddfb29af7, new branch, dispatcher retains processing and access; CLI/readme auto-merges checked; 87-path fd0f2740 manifest and 73-site preflight; Sage2333 published, awaiting CLEAR; no tests.
|
||||
|
||||
2026-10-22 02:20:51 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eafcbd3e7 full gate | stopped singles313/6: six new browser cases count app freshness timer as processing timer; diagnosis sent Sage, no fix/retry; no suites/shells/mutants; cwd sweep empty, slot clear.
|
||||
|
||||
2026-10-22 02:23:13 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2345 timer fixture | 4db8b5778 on eafcbd3e7, one line attributes timer to processing.js; all assertions retained, no product change or tests; sent Sage and Honker; awaiting publication/CLEAR.
|
||||
|
||||
2026-10-22 02:25:29 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2349 record commit | 0f8235277 on 4db8b5778 adds packet BUILD.md cause record; product manifest remains ffb46fbd/87 paths; no amend, product change or tests; Sage/Honker notified.
|
||||
|
||||
2026-10-22 02:48:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 0f8235277 full gate second red | singles330/330, processing61/61, bus646/646, cli783/783, discord192/192; WebUI323/1/1 host esbuild skip; shell geometry1062vs1000; stopped/no retry, no later suites/shells/mutants; cwd sweep empty, slot returned, Sage escalates Mos.
|
||||
|
||||
2026-10-22 02:57:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 mos-1248 desktop layout restart | FF 5cd32f4c1 on 0f8235277: desktop grid allocates processing bar, existing shell assertions kept, visibility/no-scroll assertions added; 74 static mutant sites; candidate 2f93ef19; sent Webby/Sage for static review; no runs, awaiting review and CLEAR.
|
||||
|
||||
2026-10-22 03:11:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 static nav layout, mos-1249/1250 | FF da4334806 on 5cd32f4c1; controls first in nav, non-sticky per sage-2386/webby-0108; candidate-only placement assertion changed, landed shell assertions preserved; 77 mutants statically preflighted; manifest 245dceb1; Webby/Sage handoff sent, no runs, awaiting static look/publish/debug CLEAR.
|
||||
|
||||
2026-10-22 03:15:11 UTC | Rocko (d99326cc) | row 191 sage-2394 debug | da4334806 shell5/5 and processing-browser9/9, awk14/0/0; geometry heights reported with read-only distinction; cwd sweep[] and slot returned; full window awaits separate CLEAR.
|
||||
|
||||
2026-10-22 02:01:30 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 202 #1700 FED R5 landing | ddfb29af7 on 9c9732983: 6 manifest paths from a74cd9f87 (digests match CANDIDATE.sha256, patch-id 9d89077b equal); Duckworth APPROVE r5 rev 3026; C1 gate by Quackerjack on merge 3b86180b9: cli 771/771 skip 0 (run 2, run 1 red on gate TMPDIR length), 10 test-*.sh rc 0, M1-M13 killed; done rev 3108; landing note refused for length before the done move, so the record is the commit body and #1700 c30265; 191 and 194 rebased onto it (one scripts/mosaic case line)
|
||||
|
||||
2026-10-22 03:35:55 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 273 #1778 activate --send/--no-sso landing | 0bbf0f6d1 landed on 378a76b85, a rebase of cac7d0f67 (0042f713d) with all 6 range-diff pairs = and equal patch-ids; 4-path manifest e1031023 unchanged; Filbert APPROVE rev 3111 (c30308) carried merge-scoped under mos-1160, carry comment c30319 names 0bbf0f6d1. Gate on 0bbf0f6d1 by Darkwing: stack-release.test.mjs 218/218 skip 0, stack-release-external.test.mjs 25/25 skip 0, test-release.sh rc 0 (selftest 14/0, no skips), T58 controls c0 11/11 pass and c1 11/11 fail, M1-M7 killed. Rerun because row 202 changed scripts/mosaic, which stack-release.test.mjs copies. Red 1 was 978143bf4 (T53 test 6 usage pin). This doc commit adds the TOOLS.md usage line and F39 rules (Filbert N1).
|
||||
|
||||
2026-10-22 04:02:12 UTC | Rocko (d99326cc) | row191 full window0fa7bf421 | touched35files336pass, packages2403pass/1esbuildskip, shells425selftests+156queue node pass; SR01–07killed, SR08timeout,69unrun; stopped/no retry;88blobs restored,cwd sweep[],owned network removed,slot returned Sage; await Mos ruling.
|
||||
|
||||
2026-10-22 04:14:56 UTC | Rocko (d99326cc) | row191 control/resume sage-2427 | control12/12;SR08separate timeoutkill;49resumed assertionkills thenKS43survived;total56assertionkills+1timeout,1survivor,19unrun;88blobs restored,cwd sweep[],slot returned,Sage/Mos informed;no retry/edit.
|
||||
|
||||
2026-10-22 04:21:30 UTC | Rocko (d99326cc) | row191 KS43+19 retarget run | KS43assertionkill thenKS44invalid mutation syntax;57assertionkills+1timeout,1invalid,18unrun;stopped,no edit/retry;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified.
|
||||
|
||||
2026-10-22 04:30:06 UTC | Rocko (d99326cc) | row191 KS44+18 resume | 10assertionkills thenKS54timeout-without-control despite dependency assertion;total67assertionkills+1timeoutkill,1unqualifiedtimeout,8unrun;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified,no retry/edit.
|
||||
|
||||
2026-10-22 04:41:46 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | row191 #1689 builder complete/in-review | published0fa7bf421,manifest0bcbf337;35singles336pass,9packages2403pass/1knownskip,10shells425selftests+156queue nodepass;75assertionkills+2controlledtimeoutkills=77;88hashrestore,cwd sweep[],slotreturned;in-review rev3114,expected no-token failure3115(exit1);Sage/Dewey handoff,no live action.
|
||||
|
||||
2026-10-22 05:03:18 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | Vikunja502 processing test/runner hardening | brief+isolatedrocko-v502;expected wordingred→green,Node27/27,Python4/4,5/5assertionkills(includingSR08andKS54nohang),0skips/timeouts;sweep[],candidate5hashrestored,slotreturned05:01:29Z;candidate968d6ee3,runnerc5bdc764;packetready,no commit/push/liveaction.
|
||||
|
||||
2026-10-22 05:07:34 UTC | Rocko (d99326cc) | Vikunja502 committed/review handoff sage-2482 | newbranchrocko/vikunja502-ce6a-20261022,source8e3c6a79b+packet9bf4683c6 on ce6a96ad6;5paths no base drift,range-diff=,patch-id37135ebe unchanged;manifest968d6ee3,packet93889519 verifies39/39;focused runs carry;Sage publish/Dewey review handoff sent,no push/liveaction.
|
||||
|
||||
2026-10-22 05:10:00 UTC | Sage (1ef1e4f8) | serial landing 205 then 268 | 205: 3 commits, 4 paths, digest 858463b1, every scripts/test-*.sh green on ce6a96ad6, Duckworth N1-N4 carried (N1/N3 to 205b or R3, N2 to the 193b2 brief, N4 #1782 before any user thread goes live). 268 r4: c27207f6e on 205, 9 paths, digest caa0fa4e, patch-id 2942acc8 equal to r3. Overlap checks clean before both pushes. No live action; LIVE stays 86f95bb27.
|
||||
|
||||
@@ -22,7 +22,6 @@ RUN npm ci --ignore-scripts
|
||||
# runtime adapters.
|
||||
COPY contracts /opt/mosaic/contracts
|
||||
COPY src /opt/mosaic/src
|
||||
COPY packages/processing-control/src/state.mjs packages/processing-control/src/entrypoint.mjs /opt/mosaic/processing-control/
|
||||
COPY adapters /opt/mosaic/adapters
|
||||
RUN chmod 0555 /opt/mosaic/contracts /opt/mosaic/contracts/* \
|
||||
&& chmod 0555 /opt/mosaic/src /opt/mosaic/src/*.sh \
|
||||
|
||||
@@ -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.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Row 268 build notes (Gizmoduck)
|
||||
|
||||
Case 2 C2-01c, playground levels. Gitea #1773, Vikunja 464. Brief: docs/plans/2026-10-20_case2-rows.md,
|
||||
"C2-01c playground levels: universe-level prefixes and draft universes" (blob a6ac627c8). The ruling is
|
||||
agents/gosalyn/work/case2/DESIGN.md "Ruling after r11: playgrounds at three levels" (:49-88 at
|
||||
db1e7732a), and it wins where the two differ. Reviewer Webby, gate owner Sage. Branch gizmoduck/row268
|
||||
from refactor db1e7732a. Line numbers are on this branch unless a commit is named.
|
||||
|
||||
## mapping.mjs
|
||||
|
||||
- `kind` (:71). An entry with a `kind` key must say `"hybrid"` or `"draft"`. Before, a plain universe
|
||||
with `kind` refused as an unknown key, and `kind: "draft"` did the same.
|
||||
- A draft (:73-86) refuses `books` with its own message before the key check, so the cause reads
|
||||
plainly. It takes `kind`, `name` and `playground`, needs one prefix or more, and comes back as
|
||||
`{ kind: 'draft', name, books: [], playground }`, frozen with the rest.
|
||||
- The prefix level check moved out of the per-entry loop to :127-140, after every entry is read,
|
||||
because it needs every entry's books. It runs after the book-holds-book, prefix-holds-prefix and
|
||||
listed-twice checks, so an overlap still reports as before. For each prefix and each book folder:
|
||||
- equal to any book folder: `library-mapping-invalid` (brief, What ships, "or equal to a book folder");
|
||||
- inside or holding another entry's book: `library-playground-other-universe`, and the message says
|
||||
which, with the book and its entry;
|
||||
- holding its own entry's book: `library-mapping-invalid`.
|
||||
A prefix inside its own book is book level, and one that touches no book is universe level. A draft
|
||||
has no books, so its prefixes are either outside every book or refused as another entry's.
|
||||
- `sources` (:117-120) now refuses any entry with a `kind`, so a draft can't be a source either. The
|
||||
message changed from "not a non-hybrid universe" to "not a plain universe". mapping.test.mjs:61 on
|
||||
db1e7732a pinned the old words, and that line moves with it.
|
||||
- `library-playground-outside-book` is gone from packages and docs/case2.
|
||||
|
||||
## scope.mjs
|
||||
|
||||
`classify` (:12-24) now runs two passes. The first finds a prefix in any entry, and the file is
|
||||
`playground` with that entry's id and `book` set to the entry's book folder that holds the prefix, or
|
||||
`null`. The second finds a book folder, and the file is `canon`. `place` (:26-32) works out `type` and
|
||||
`folder` from the first folder under the prefix or the book, as before. The validator keeps prefixes
|
||||
from overlapping, so the first pass finds at most one.
|
||||
|
||||
## Readers on this base
|
||||
|
||||
- reader.mjs:182 and :194 spread `classify`'s answer into each file record, so a universe-level or
|
||||
draft file carries `book: null`. Nothing in the reader reads `book`.
|
||||
- reader.mjs:206-209. R5 fires for a canon file linking into any playground. R6 fires when the target's
|
||||
universe differs and isn't a source. A draft can't be a source, so a link into one gives R5 and R6.
|
||||
A link into the file's own universe-level prefix gives R5 only. No code change was needed. The new
|
||||
test covers both.
|
||||
- survey.mjs:66 and mirror.mjs:246 read only `scope`. mirror.mjs:251 calls `readLibrary` and checks
|
||||
only `path`.
|
||||
- No other package calls this `classify` or reads `universes` (git grep over packages/*/src).
|
||||
- C2-05a (row 216) and C2-06 (row 217) have no candidate branch on origin at the time of writing. I
|
||||
check again before in-review.
|
||||
|
||||
## Tests
|
||||
|
||||
- mapping.test.mjs: the old outside-book test splits into the other-universe refusals (inside another
|
||||
entry's book, holding one, a draft prefix inside a book), the mapping-invalid ones (equal to a book,
|
||||
own or another's, and holding its own), universe level in a plain universe and in a hybrid with
|
||||
`books: []`, and the draft shape (frozen with `books: []`, with `books`, with none or empty
|
||||
prefixes, an extra key, and five bad `kind` values). The overlap test gains a cross-entry hold and a
|
||||
cross-entry duplicate. The hybrid test gains a draft source. The caller-supplied `fail` test now
|
||||
uses `Second World/Playground/` and sees the new code, since `Elsewhere/` is valid now.
|
||||
- scope.test.mjs, new: one case per level (book, universe in a plain universe, universe in a hybrid,
|
||||
draft), `.canvas` under each new prefix staying unmapped, prefixes first inside a book that holds a
|
||||
prefix, and the reader case for R5 and R6, with `book: null` on the read records.
|
||||
- business.test.mjs: `Elsewhere/Playground/` now loads, and `Second World/Playground/` refuses with
|
||||
`library-playground-other-universe` through `validateBusinessDocument`.
|
||||
|
||||
## Docs
|
||||
|
||||
- packages/library/README.md: the universe keys, a line on the three levels, the refusal table and
|
||||
the Scope section.
|
||||
- docs/case2/SETUP-SHEET.md "Your playground": one line per level and the draft entry.
|
||||
- docs/case2/SURVEY-RUN.md:114: the new code and message.
|
||||
|
||||
## Hash pins
|
||||
|
||||
`git grep -E '[0-9a-f]{64}'` over packages/*/tests finds no pin on any changed file.
|
||||
|
||||
## Runs
|
||||
|
||||
None yet. Every run waits for Sage's CLEAR.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Row 236 build notes: #436b Gitea grant path (#1742, schema 3j-3k)
|
||||
|
||||
Builder Megavolt, reviewer Dewey, gate owner Sage. Draft; the results sections are filled in after the
|
||||
runs on Sage's gate.
|
||||
|
||||
## Sources
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_agent-credentials.md`, "Brief: #436b Gitea grant path, build (schema 3j-3k)",
|
||||
blob 9259ba2a1.
|
||||
- Design: `agents/megavolt/work/436b/DESIGN.md` r5 at 1be535aca (sha256 68df02cd…f4), unedited. Scope:
|
||||
sections 2 to 7, and section 9's X-G1 to X-G15 and mutants. Section 10 (GitHub half) is out of scope.
|
||||
- Build carry: `agents/megavolt/work/436b/BUILD-CARRY.md` at 3330c901b (N9, N10).
|
||||
- Schema label 3j-3k per sage-2237. If 264a or 206 lands first, this rebases onto a new branch name and reruns
|
||||
the whole bus suite, the schema.test counts and the T46 lists.
|
||||
|
||||
## What changed
|
||||
|
||||
- `packages/bus/src/gitea-grant.mjs` (new): the grant body check, the admin slot (set, rotate, remove), the
|
||||
admin and seat API calls, the start-time scan (`giteaStart`, `openGiteaGrants`, `tokenUnknown`).
|
||||
- `packages/bus/src/broker.mjs`: the gitea executor (steps 0 to 8), settle, restart reconcile, revoke and fire,
|
||||
sweep, status, and the `grants.*` verbs.
|
||||
- `packages/bus/src/credentials.mjs`: `held` pairs at start; the register and claim paths the executor uses.
|
||||
- `packages/bus/src/migrations/3j-3k.sql`, `schema.sql`, `store.mjs`: `gitea_users` and `gitea_grants`, new
|
||||
tables only.
|
||||
- `packages/bus/src/runtime.mjs`, `server.mjs`, `human-cli.mjs`: wiring of the start scan and the verbs.
|
||||
- `packages/cli/src/grants.mjs` (new), `cli.mjs`, `scripts/mosaic`: `mosaic grants`.
|
||||
- Tests: `gitea-grant.test.mjs`, `gitea-migration.test.mjs`, `gitea-grant-docker.test.mjs` (X-G3),
|
||||
`fake-gitea-api.mjs`, `fixtures/schema-3j.sql` (02da6a2ae's schema.sql), `cli/tests/grants.test.mjs`, and the
|
||||
pin updates in the migration tests, `schema.test.mjs`, `channel-proof.test.mjs` and `stack-release.test.mjs`
|
||||
(T46).
|
||||
- READMEs of bus and cli.
|
||||
|
||||
## Choices and assumptions
|
||||
|
||||
Each is a reading of the design where it left room. Dewey may rule otherwise.
|
||||
|
||||
1. Admin rotation is due once today is past `rotateBy` (the date itself is still good).
|
||||
2. A seat-token rotation moves the record at step 8, after the old token's delete (step 7).
|
||||
3. A step 7 failure leaves the grant applied, with the old id in `serviceLeft` and the notice "The previous
|
||||
token X is still live".
|
||||
4. A failure after the commit step's rename returns `settle-pending`; the next start settles it.
|
||||
5. tester-cli keeps its 5 s timeout; the grant verbs answer at once and the executor runs after.
|
||||
6. A revoke of a token-unknown agent takes both ids from that decision's last commit step.
|
||||
7. A user adopted at restart is upserted in the same transaction as its `grant.failed interrupted`.
|
||||
8. `#giteaUnlink` (first-grant commit undo) uses a temporary reference dated `9999-12-31`, so it is never due
|
||||
while it lives.
|
||||
9. X-G12's org-team case: the fake models team membership as collaborator reach on the repository, which is
|
||||
what `/user/repos` shows (F11, F12).
|
||||
10. X-G15's look-alikes are stored under `action.refused` and under a non-grant kind, since the 3j trigger
|
||||
refuses the human's NULL pair on `action.allowed`. The trigger cases of X-G13 (session, NULL pair,
|
||||
`grant.host.enrolled`) are in `grant-migration.test.mjs`.
|
||||
11. Step 6 faults: a pre-rename failure is injected by making `gitea.tmp` a directory; a step 8 store fault by
|
||||
a TEMP trigger on `gitea_grants`.
|
||||
12. The credentials-verb revoke close is tracked, so `giteaIdle()` waits for it.
|
||||
13. `openGiteaGrants` gives `agent: null` for a spend with no step yet, so `status.running` can show a null
|
||||
agent; the CLI prints it empty.
|
||||
14. N9: a restart ignores a settle-pending commit with a `grant.revoked` after it and ends it `revoked` with
|
||||
no message (notify false). A 404 on a delete by id counts as done.
|
||||
15. N10: `serviceLeft` is a list in every body and answer.
|
||||
16. A restart claims the agent's key for each open grant it undoes. One executor runs per agent
|
||||
(grant-busy), so a killed child leaves at most one open grant per agent. If a store ever held two, the
|
||||
second's claim fails and the undo runs with no admin: its token is left and named in `serviceLeft`,
|
||||
never deleted unclaimed.
|
||||
17. X-G7 changes the binding with a fixture decision whose hash differs (row 234's G9 pattern), since the
|
||||
broker keeps its own copy of the business. The admin url check takes row 199's `parseGiteaUrl`, which
|
||||
accepts the origin with or without a trailing slash.
|
||||
18. r2, Dewey B1 (dewey-0781): step 8 and both settle branches (forward, back) check for a `grant.revoked`
|
||||
of the agent after the decision's raise, in the same transaction as their write. A revoke that came
|
||||
while step 7's or the settle's delete awaited took the record and both tokens, so the grant ends
|
||||
`revoked` with the human's notice, as a revoke of a running executor ends it (X-G11), and no record is
|
||||
written back. N9's restart case stays silent: a restart never settles a revoked rotation.
|
||||
19. r2, Dewey B2: a sweep retry of `tokens` or `login` checks the user's id first, as the revoke close does,
|
||||
against the `gitea_users` row (it survives a fire). A 404 skips with `gitea-user-gone`, a 200 with
|
||||
another id with `gitea-user-foreign`, any other status leaves the entry. Skips go in `grant.swept` and
|
||||
the sweep answer as `skipped: [{agent, user, id, reason}]`; a skipped entry is not left, so the next
|
||||
sweep does not retry it.
|
||||
20. r3, Dewey N5 (dewey-0783): the settle's 401 branch and its grant-token-unknown branch take the same
|
||||
revoke check. A revoke during the whoami ends the grant `revoked`, not `grant-lost`, and the
|
||||
token-unknown branch neither holds the agent again nor writes `grant-token-unknown`, so no start
|
||||
names a file the revoke unlinked.
|
||||
|
||||
## Named for DEFERRED (r2, Dewey N1-N4)
|
||||
|
||||
None is a small local change, so none is in r2. sage-2388: all four DEFERRED; N3 and N4 get a bus README
|
||||
sentence each in r3 (a left admin token is deleted by hand at Gitea; a running executor keeps the admin
|
||||
api it started with).
|
||||
|
||||
- N1: an admin rotate cut between the mint and the slot write leaves a token at Gitea that no event names.
|
||||
A `grant.admin rotate-begin {oldName}` before the mint would close it; that is a new event shape.
|
||||
- N2: `adminTokenLeft` is `unknown` when `/api/v1/token` gives no name. Which 1.27 F-fact shows the name
|
||||
needs a check against the container, not a code change.
|
||||
- N3: admin set or remove records `adminTokenLeft: null`, so a left token drops out of status. Carrying it
|
||||
forward lets `rotate --finish` delete by name under a slot of another account or url; refusing remove
|
||||
while one is left blocks the human's remove when Gitea is down. Either needs a ruling.
|
||||
- N4: rotate or remove while a gitea claim is held. Refusing it blocks the human's remove during a run;
|
||||
the running executor keeps the api it began with. Needs a ruling or a line in the README.
|
||||
|
||||
## Q188
|
||||
|
||||
No test creates a grant except through fixtures or the human's yes on a scratch store. No test reaches
|
||||
git.mosaicstack.dev, tasks.woltje.com or any provider. Unit tests use the fake Gitea on 127.0.0.1; X-G3 uses a
|
||||
throwaway gitea/gitea:1.27.1 container from the local image (never pulled), removed after. Every token is a
|
||||
fake or a scratch-container token; values are compared with `assert.ok`, never printed.
|
||||
|
||||
## Runs
|
||||
|
||||
(filled in after Sage's CLEAR)
|
||||
|
||||
## Mutants
|
||||
|
||||
(section 9's list, each with the test that kills it; filled in after the runs)
|
||||
|
||||
A static pass over the 39 mutants (all apply, problems=0) found five with no test to kill them. Four tests
|
||||
were added before any mutant run:
|
||||
|
||||
- M8, M9 (user or mint step written after its call) and M16 (grant.result written before its call): "X-G6
|
||||
and X-G8: the user and mint steps are written before their calls, and each result after its call". It
|
||||
reads the store from the fake while user-create and token-create are at Gitea, and checks every result's
|
||||
status.
|
||||
- M29 (fork parent not checked): "X-G4: a fork by the seat user of another repository than the project
|
||||
refuses fork-foreign".
|
||||
- M28 (memory and file revoked after the Gitea delete): "X-G11: a revoke unloads memory and removes the file
|
||||
before its Gitea delete", which holds the DELETE open and reads the file and state.
|
||||
- M37 (evidence without its kind filter): "X-G15: a look-alike spend under another kind with the grant writer
|
||||
pair is not an open grant".
|
||||
|
||||
r2 tests, each red on 8bf442be9 by reading:
|
||||
|
||||
- B1: "X-G11: a revoke during a rotation step 7 ends it revoked, and step 8 writes no record back" and
|
||||
"X-G11: a revoke while a sweep settles a rotation, forward or back, ends it revoked with no record".
|
||||
On 8bf442be9 step 8 and the settle write the record and `grant.applied` (forward) or `grant.failed
|
||||
interrupted settled:back` after the revoke deleted it.
|
||||
- B2: "X-G14: a sweep retries a fire only for the user it fired: a remade or gone user is skipped and
|
||||
named". On 8bf442be9 the retry fires the remade user (token list, delete, PATCH) and has no `skipped`.
|
||||
- N5 (r3, sage-2388): "X-G11: a revoke during a settle whoami that finds a foreign token ends it revoked, not
|
||||
held" and "X-G11: a revoke during a settle whoami that gets a 401 ends it revoked, not lost". On 77dd43c9e
|
||||
they end `grant-token-unknown` (the agent back in tokenUnknown) and `grant-lost`.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Row 205a (193b1a, #1703): the trail and replies message filter
|
||||
|
||||
Design: DESIGN.md d2 in this directory, with rulings in section 7 (sage-2288). Base: refactor 02da6a2ae. No schema
|
||||
change. Nothing has run yet: every test run waits for Sage's CLEAR.
|
||||
|
||||
## Changes
|
||||
|
||||
`packages/bus/src/broker.mjs`:
|
||||
- `#hiddenMessage(s, id)`: the message's thread (`message_threads` joined to `threads`) is owned by a `user:`
|
||||
other than `#owner(s)`, and `#readable(s, t)` is false. A message with no thread, or in the business human's
|
||||
or the tester's thread, is never hidden. A reader session owns nothing, so every user thread is hidden from it.
|
||||
The tester reads through its reader view, and `#owner` still gives `tester`.
|
||||
- `#readable(s, t)` returns false. Rule 1 (a share in force) can't hold before 193b2's schema 3j, so nobody
|
||||
reads another's thread. 193b2 adds the share rule and rules 2-4 here.
|
||||
- `#trail`: a hidden subject returns `[]` before anything is read, exactly as an unknown id does (ruling 3).
|
||||
After the rows are collected, it drops hidden `messages` rows, `deliveries` rows of a hidden message, and
|
||||
`events` rows whose subject is a hidden message. Decisions, decision events, task snapshots, run events and
|
||||
claims stay.
|
||||
- `replies`: a hidden id fails `message-not-found`, as an unknown id does. Hidden replies are left out in SQL
|
||||
(`NOT EXISTS` a thread whose owner starts with `user:` and isn't the caller), so the `REPLIES` limit counts
|
||||
only visible rows.
|
||||
- The SQL clause doesn't call `#readable`. That is exact while `#readable` is false. 193b2 must add the share
|
||||
exception to the clause and to `#readable` together.
|
||||
|
||||
`packages/bus/tests/message-filter.test.mjs` (new). It writes a `user:alice` thread and its messages
|
||||
straight to the store, as R3's `thread.send` and the run's `to: 'user'` reply will write them, because nothing
|
||||
makes a user session before R3.
|
||||
- `trail` on a hidden id equals `trail` on an unknown id, byte for byte, for the owner, a reader and the tester.
|
||||
- `replies` on a hidden id equals `replies` on an unknown id (code and message), for all three.
|
||||
- `trail` on a user-thread run's decision shows the decision and the owner's message, and drops the user
|
||||
thread message, its delivery and its event.
|
||||
- `replies` with `REPLIES` hidden replies ahead of a visible one returns the visible one.
|
||||
- Messages in the business human's and the tester's threads stay visible on both verbs (row 177 control).
|
||||
- The governance pin, one assertion per item in the user sentence: inbox entry, claim, task, project file and
|
||||
the closed decision in `decisions {list: 'recent'}` show to the owner. `trail` and `replies` hide the user
|
||||
message, and the decision's trail carries no user body.
|
||||
|
||||
## Mutants (each must fail its named test in message-filter.test.mjs)
|
||||
|
||||
| Mutant | Edit | Named test |
|
||||
|---|---|---|
|
||||
| M1 | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user messages |
|
||||
| M11 | the trail drop loop iterates nothing | trail on a decision keeps the decision and drops the user thread messages, their deliveries and their events |
|
||||
| M12 | the replies `NOT EXISTS` clause removed | replies leaves out other users thread messages and still fills a page |
|
||||
| M13 | trail fails `message-not-found` for a hidden subject | trail on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester |
|
||||
| M14 | replies returns `[]` for a hidden id | replies on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester |
|
||||
|
||||
## Gate
|
||||
|
||||
The kit is /mnt/storage/scratch/morgana-r205 (`slot205.sh`, `mutate.py`, `busy.sh`), run as
|
||||
`bash busy.sh && CAND=<sha> bash slot205.sh` on Sage's CLEAR. It runs these files whole:
|
||||
- message-filter;
|
||||
- the bus files that call trail or replies: replies, tester, tester-threads, tester-writes, broker, decisions,
|
||||
decision-close, end-launch and task-read.
|
||||
|
||||
Then it runs the bus suite, then M1 and M11-M14, each on its named test and restored after each. It uses umask
|
||||
022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/g205XXXX`. It refuses unless HEAD is the candidate
|
||||
and the tree is clean, and it stops at the first unexpected failure (sage-2371).
|
||||
|
||||
Sage's gate adds every `scripts/test-*.sh`. The webui tests that read trail or replies run there.
|
||||
|
||||
Static check: `node --check` on broker.mjs and the new test, run from `/`. Both parse. All five mutant anchors
|
||||
match exactly once.
|
||||
|
||||
## r2 (after the 742cba805 slot, failure 1 of the window, sage-2428)
|
||||
|
||||
slot205 on 742cba805 stopped red in message-filter, 4 pass and 2 fail. Both fixes are in the test only.
|
||||
broker.mjs is unchanged (sage-2430).
|
||||
|
||||
- "trail on a decision ..." (:124, its event shows). `#trail` reaches events on a decision trail by
|
||||
`subject` = the decision or `body.$.decision` = the decision. `put()` wrote every event body as `{}`, so the
|
||||
shown message's event was out of reach. That also made the "no hidden event" check (:127) vacuous: the hidden
|
||||
event was never collected, so dropping it proved nothing. Fix: `put()` writes `{decision}` as the event body
|
||||
when the message has a decision. Both events are now collected; the filter keeps one and drops the other.
|
||||
- Governance pin, claims item (:175). A thread run's `role.claim` writes `thread_claims` only and returns
|
||||
(broker.mjs:2682). `agents` (:3309) reads `role_claims` only. No owner verb shows a thread claim. The gap
|
||||
predates 205. Ruling sage-2430, option (a): the pin checks through `threadsState` (the launcher view) that
|
||||
the claim is recorded with holder `assistant-run` and isn't filtered. `agents` is unchanged in 205.
|
||||
Follow-up: Sage adds a row for an owner-visible thread-claim read. Like the filter, it lands before any
|
||||
user thread goes live, since PRD :1084 and the FED R6 sentence promise that the owner sees claims.
|
||||
|
||||
M1 still fails the pin: with `#readable` true, `trail` on the user message returns rows instead of `[]`.
|
||||
@@ -1,173 +0,0 @@
|
||||
# Row 205 (193b1, #1703): viewer verbs, board refusals and the message filter
|
||||
|
||||
Owner Morgana, reviewer Duckworth, gate owner Sage (sage-2280). Brief: `docs/plans/2026-10-20_human-users-build.md`,
|
||||
"Brief: 193b1, viewer verbs, board refusals and the message filter" (blob a73e1bba0, rev 3088). Design:
|
||||
row 193 DESIGN.md r5 (e2d161e2e, Duckworth approved), sections 4.3, 5.4, 5.5, 5.7, 8 and 9. Base: refactor
|
||||
02da6a2ae. Draft 2: Sage's rulings (sage-2288) are in section 7. Sections 1-6 are d1 as sent (morgana-0063).
|
||||
|
||||
## 1. The dependency gap
|
||||
|
||||
The brief chains 193b1 on FED R3, FED R4 and row 196's verifier. Only row 196 exists (7247d80c7). On 02da6a2ae:
|
||||
|
||||
- R3 and R4 aren't filed. The FED brief (`docs/plans/2026-10-20_federation-r1-r2-r5.md`:12-13) files R3 "after
|
||||
R1 and row 196" and R4 after R3, R5 and 193a. No `*/fed-r3` branch is on origin.
|
||||
- Row 196's `verifyPrincipal` returns the `user` kind, and server.mjs accepts the `user` envelope, but
|
||||
runtime.mjs wires only `authenticateHuman` and `authenticateTester`. The broker has no `bindUser`, no
|
||||
`s.user`, no per-request grant read and no collaborator verb list. FED 3.6 and row R3 own all of these.
|
||||
- So nothing makes a user session today, and no thread is owned by `user:<id>` except through a direct
|
||||
store write.
|
||||
|
||||
193b1 has two halves that depend on R3 in different ways.
|
||||
|
||||
| Item | Needs a user session (R3) | Testable on 02da6a2ae |
|
||||
|---|---|---|
|
||||
| Viewer verb list (4.3) | yes: it extends R3's collaborator list | no |
|
||||
| Per-grant level checks via `covers(..., {level})` | yes: R3's grant read | no |
|
||||
| Board reads refused to users (5.5) | yes | no |
|
||||
| `trail` and `replies` filter (5.7) | no: it filters owner, reader and tester sessions | yes, with seeded `user:<id>` threads |
|
||||
| `#readable` owner-only | no | yes |
|
||||
| Governance pin test (owner side) | no: the owner reads decisions, inbox, agents, tasks and `project.file` | yes, with a seeded user-thread run |
|
||||
| Mutants 6 (viewer `thread.send`), 10 (user allowed `trail`) | yes | no |
|
||||
| Mutants 11, 12 (trail, replies filter removed) | no | yes |
|
||||
| Mutants 1, 3 (`#readable` rules 1 and 3 dropped) | see 4 | see 4 |
|
||||
|
||||
## 2. Recommendation: split 205 into 205a now and 205b after R3
|
||||
|
||||
- **205a, the message filter.** `trail` and `replies` hide messages in other users' threads from the owner,
|
||||
reader sessions and the tester, with `#readable` owner-only. Plus the owner-side governance pin test and
|
||||
mutants 11 and 12. No R3 dependency, no schema change.
|
||||
- **205b, viewer verbs and board refusals.** Built on R3's candidate, as Q177 chains a row on its parent's
|
||||
candidate. The viewer list, the level checks, users refused on board reads, the user-session half of the
|
||||
pin test, mutants 6 and 10.
|
||||
|
||||
Why split. The filter is the item the boundary orders first: "No user thread goes live before 193b1's
|
||||
message filter has landed" (mos-1021 B2, FED R8). Landing it before R3 means R3 and every later row can't
|
||||
create a user thread that the owner can read on `trail`. Waiting for R3 holds the filter behind a row that
|
||||
isn't filed. The other option, building all of 193b1 against R3's named interface as 193a did with R1,
|
||||
doesn't work here: 193a only called R1's exported `covers`, while 193b1 would have to write `bindUser`, the
|
||||
grant read and the collaborator list itself, which is R3's scope and owner.
|
||||
|
||||
Tradeoff: two rows and two reviews in place of one, and 205b rebases onto R3. The split is a brief change,
|
||||
so it's Sage's to rule.
|
||||
|
||||
## 3. 205a design
|
||||
|
||||
### 3.1 The hidden-message check
|
||||
|
||||
One broker method, `#hiddenMessage(s, id)`:
|
||||
|
||||
```
|
||||
SELECT t.owner FROM message_threads mt JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = ? AND t.business = ?
|
||||
```
|
||||
|
||||
A message is hidden when that owner starts with `user:`, isn't `#owner(s)`, and `#readable(s, t)` is false.
|
||||
A message with no thread row, or in a thread owned by the business human or `tester`, is never hidden, so
|
||||
row 177's behaviour is unchanged. A reader session has no `#owner`, so every user thread's messages are
|
||||
hidden from it.
|
||||
|
||||
`#readable(s, t)` in 205a returns false. Before 193b2's schema 3j there is no `thread_shares` table, so rule 1
|
||||
(a share in force) can't hold, and the function is false for anyone but the thread's owner (5.7: "b1 hides
|
||||
every message in another user's thread"). It is written as its own method so 193b2 adds the share rule and
|
||||
rules 2-4 in one place.
|
||||
|
||||
### 3.2 `trail`
|
||||
|
||||
- If `subject` is a hidden message id, return `[]`. Design 5.7 says `message-not-found`, "as an unknown id
|
||||
does", but `trail` on an unknown id returns `[]` today (it has no not-found check). A different code for a
|
||||
hidden id would confirm the id exists, which is what 5.7 wants to prevent, so I follow its reason over its
|
||||
code. Question 3.
|
||||
- After `#trail` collects its rows, drop: `messages` rows that are hidden, `deliveries` rows whose `message`
|
||||
is hidden, and `events` rows whose `subject` is a hidden message id. This covers each way `#trail` reaches
|
||||
a message: by id, by `decision`, and through `task.created` → `human.input` → `body.message`.
|
||||
- Decision rows, decision events, task snapshots, run events and role claims stay. 5.7 puts them on the
|
||||
governance side.
|
||||
|
||||
### 3.3 `replies`
|
||||
|
||||
A hidden `id` fails `message-not-found`, and hidden messages are dropped from the result. The `LIMIT`
|
||||
applies before the filter today; the filter moves into the SQL (`AND NOT EXISTS` a thread owned by another
|
||||
`user:`) so a page of hidden replies doesn't return short. For 205a that SQL form is exact because
|
||||
`#readable` is false; 193b2 adds the share exception to the same clause.
|
||||
|
||||
### 3.4 Tests (packages/bus/tests/message-filter.test.mjs, new)
|
||||
|
||||
Fixtures seed a project, a thread owned by `user:alice` with messages in `message_threads`, a delivery and an
|
||||
event on one of them, and a decision raised by that thread's run that cites the message. Sessions: the
|
||||
business human, a reader session and the tester.
|
||||
|
||||
- `trail` on a hidden message id: `[]` for all three sessions, the same as an unknown id.
|
||||
- `trail` on the decision: the decision row and its events show; the user's message, its delivery and its
|
||||
events don't.
|
||||
- `trail` on a human-owned thread's message: unchanged (row 177 control).
|
||||
- `replies` on a hidden id: `message-not-found`. `replies` on a visible id with hidden and visible replies:
|
||||
only the visible ones, and a full page when hidden replies come first.
|
||||
- Governance pin, owner side: for the user-thread run, `decisions {list: 'recent'}`, `inbox`, `agents`,
|
||||
`tasks` and `project.file` each show its item, and `trail` and `replies` hide the user's message body.
|
||||
Each item in the user sentence is one named assertion.
|
||||
|
||||
### 3.5 Mutants
|
||||
|
||||
| Mutant | Edit | Named test |
|
||||
|---|---|---|
|
||||
| 11 | the `trail` filter removed | trail on the decision hides the user's message, delivery and events |
|
||||
| 12 | the `replies` filter removed | replies hides other users' thread messages |
|
||||
| 11b | the hidden-subject early return removed | trail on a hidden message id returns [] |
|
||||
| 1 | `#readable` returns true | governance pin: trail and replies hide the user body |
|
||||
|
||||
## 4. Mutants 1 and 3
|
||||
|
||||
Design section 8 lists mutants 1 (rule 1 dropped) and 3 (rule 3 dropped) for b1. With no shares table,
|
||||
rule 1 is always false, so `#readable` is false whatever rules 2-4 say. Dropping rule 3 changes nothing a
|
||||
test can see until 193b2 adds shares, and rules 2 and 3 need R3's grant read. So:
|
||||
|
||||
- Mutant 1 in 205a is "`#readable` returns true", killed by the pin test above.
|
||||
- Mutant 3 moves to 193b2, where rule 1 can hold and rule 3 decides.
|
||||
|
||||
This is a second brief change for Sage to rule on.
|
||||
|
||||
## 5. Gate (Sage's slot, on CLEAR only)
|
||||
|
||||
The brief's gate: Duckworth approves the manifest; the bus suite and every `scripts/test-*.sh` green on
|
||||
Sage's gate; the mutants killed. The new test file whole, the touched existing files whole, then the bus
|
||||
suite, then each mutant on its named test with a restore check. umask 022, TMPDIR from
|
||||
`mktemp -d /mnt/storage/scratch/tmp/gXXXXXX`. No test touches the live data root, config, units, slot
|
||||
files, any live access.json or `~/.mosaic`.
|
||||
|
||||
## 6. Questions for Sage
|
||||
|
||||
1. Split 205 into 205a (filter, now) and 205b (viewer verbs and board refusals, after R3's candidate)?
|
||||
2. Mutant 1 as "`#readable` returns true" in 205a, and mutant 3 moved to 193b2?
|
||||
3. A hidden `trail` subject returns `[]`, the same as an unknown id, in place of 5.7's `message-not-found`
|
||||
(3.2)? `replies` already fails `message-not-found` on an unknown id, so there the code stays.
|
||||
|
||||
## 7. Rulings (sage-2288, re morgana-0063)
|
||||
|
||||
1. **Split approved.** 205a is built now: the 5.7 `trail` and `replies` filter over owner, reader and tester
|
||||
sessions, `#readable` owner-only, the owner-side governance pin test, and mutants 11 and 12. 205b comes
|
||||
after R3's candidate and R4: viewer verbs, `covers` level checks, board reads refused to users, the
|
||||
user-side pin, and mutants 6 and 10. 205a's base is origin/refactor 02da6a2ae or later. R3 builds on
|
||||
205a's candidate and extends the filter to user sessions, so the filter isn't written twice.
|
||||
2. **Mutant 1** in 205a is "`#readable` returns true", killed by the pin test. **Mutant 3** moves to 193b2
|
||||
(row 206). Sage changes the brief to match.
|
||||
3. **Hidden equals unknown.** A hidden `trail` subject returns `[]`, exactly as an unknown id does. `replies`
|
||||
keeps `message-not-found` for both. The point of 5.7 is that a caller can't tell hidden from unknown.
|
||||
So 205a adds:
|
||||
- one test per verb comparing the hidden response with the unknown-id response byte for byte
|
||||
(`JSON.stringify` of the result, or of the error's code and message), for each of the three sessions;
|
||||
- a mutant M13 that makes the hidden path distinguishable: `trail` fails `message-not-found` for a
|
||||
hidden subject. The `trail` equality test must kill it. M14 does the same for `replies`: a hidden id
|
||||
returns `[]` instead of failing. The `replies` equality test must kill it.
|
||||
|
||||
### 7.1 205a mutant table (replaces 3.5)
|
||||
|
||||
| Mutant | Edit | Named test |
|
||||
|---|---|---|
|
||||
| M1 (design 1) | `#readable` returns true | governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user's messages |
|
||||
| M11 (design 11) | the `trail` filter of hidden rows removed | trail on the decision hides the user's message, its delivery and its events |
|
||||
| M12 (design 12) | the `replies` filter removed | replies drops other users' thread messages and still fills a page |
|
||||
| M13 | `trail` fails `message-not-found` for a hidden subject | trail on a hidden id answers exactly as on an unknown id |
|
||||
| M14 | `replies` returns `[]` for a hidden id | replies on a hidden id answers exactly as on an unknown id |
|
||||
|
||||
Queue: Sage re-pins 205 to the split brief and adds 205b and the R3 row at the next gap. I claim after
|
||||
that. No run before Sage's CLEAR.
|
||||
@@ -1,814 +0,0 @@
|
||||
# Row 191 stopRun inspection round
|
||||
|
||||
Design: ../DESIGN-r2.md, approved by Dewey in 29473; scope sage-2244;
|
||||
per-run contract ../STOP-RUN-CONTRACT.md, honker-0079/0081. Not review-ready:
|
||||
full package/shell/Docker gate and acceptance/guard mutants remain pending.
|
||||
|
||||
Current commit 7aa18b0f6e1a09188fb602853ebb02815b642ac9, new branch
|
||||
rocko/row191-stoprun-dcd8-20261022, parent dcd8d4dda. Worktree
|
||||
/mnt/storage/scratch/rocko-r191-r3. Earlier inspection f7bf7dfec is preserved.
|
||||
All 79 manifest paths have identical blobs at f7bf7dfec and 7aa18b0f6;
|
||||
rebase-product-diff.stat is empty, rebase-product-check.txt names the check.
|
||||
The manifest SHA-256 stays
|
||||
9971ac5fe56d0d36eb914ecaf2c282e14ab3cd29860fd27b268ec0baadaf0e52.
|
||||
Rocko author and committer identity were selected explicitly. No attribution
|
||||
lines, force-push or branch deletion. Sage publishes through jarvis.
|
||||
|
||||
## Changes Dewey must review: two focused-run fixes
|
||||
|
||||
1. packages/bus/src/broker.mjs, requestHost: the new processingWork guard was
|
||||
initially inserted into the inner refusal logger's try block. That let the
|
||||
original request reach validation, then prevented its refusal being logged
|
||||
while halted, returning storage-unavailable. Move it to the main admission
|
||||
try, before keys/credentials/authority/effects. The refusal logger retains
|
||||
its existing unconditional audit behavior. The new host-admission test now
|
||||
expects processing-halted and proves neither executor method was called.
|
||||
Initial evidence: ../logs/stoprun-focused-packages_bus_tests_processing.test.mjs.log.
|
||||
|
||||
2. packages/bus/tests/processing.test.mjs, real socket await test: row 196's
|
||||
asynchronous kernel-peer subprocess now precedes dispatch. The old 5 ms
|
||||
server deadline could fire during that subprocess, before the processing
|
||||
await branch extends it. This test already stubs authenticateHuman; give it
|
||||
a deterministic peerReader too, with read and close, and assert exactly two
|
||||
proof reads for halt/resume. Keep the 5 ms ordinary timeout and 30 ms halt
|
||||
delay. Production peer discovery, proof/transport and timeout behavior are
|
||||
unchanged. The test still fails if the async branch timeout is removed.
|
||||
The first fixture retry omitted close(); its teardown failed and left its
|
||||
own server running. Only that verified test group 2831504 was killed. Both
|
||||
unsuccessful logs remain. Corrected evidence:
|
||||
../logs/stoprun-focused-bus-processing-fixed-2.log (14/14).
|
||||
|
||||
These are diagnosed code/fixture mistakes, not declared flakes. No failed log
|
||||
was removed and no existing security assertion was weakened.
|
||||
|
||||
## Focused evidence, sage-2250
|
||||
|
||||
Ran singly on 41b0cf755 because none of row 254's seven paths overlaps ours.
|
||||
Sage-2265 carries this evidence to dcd8d4dda after the empty 79-path diff.
|
||||
Scripted busy check exited 0 before the first file. Every test runner used its
|
||||
own process group, 180 s timeout, scratch TMPDIR
|
||||
/mnt/storage/scratch/rocko-r191-tmp and DOCKER_HOST=unix:///nonexistent.sock.
|
||||
There was no Docker, browser, package suite, shell suite or mutant run.
|
||||
|
||||
| File | Final passed | Failed | Skipped |
|
||||
|---|---:|---:|---:|
|
||||
| processing-control/tests/stop-run.test.mjs | 12 | 0 | 0 |
|
||||
| processing-control/tests/runtimes.test.mjs | 10 | 0 | 0 |
|
||||
| processing-control/tests/coordinator.test.mjs | 3 | 0 | 0 |
|
||||
| processing-control/tests/containers.test.mjs | 6 | 0 | 0 |
|
||||
| processing-control/tests/effects.test.mjs | 5 | 0 | 0 |
|
||||
| processing-control/tests/task.test.mjs | 7 | 0 | 0 |
|
||||
| bus/tests/processing.test.mjs | 14 | 0 | 0 |
|
||||
| bus/tests/processing-runtime.test.mjs | 2 | 0 | 0 |
|
||||
| bus/tests/project-runtime.test.mjs | 3 | 0 | 0 |
|
||||
| Script sum | 62 | 0 | 0 |
|
||||
|
||||
All final runners exited 0. The batch stopped at the initial bus failure,
|
||||
then that file was diagnosed/rerun before the final two files. Counts are from
|
||||
focused-run-table-final.json and include only each final successful file log.
|
||||
Earlier bus failures are reported above, not silently included as green.
|
||||
Approximate run span 23:59Z–00:00:41Z; start second was not recorded.
|
||||
Cwd-scoped node/chromium sweep was empty at 00:00:41Z; slot returned to Sage.
|
||||
|
||||
## stopRun implementation
|
||||
|
||||
The export resolves host-owned run identity even without a bound bus claim.
|
||||
It checks boot/start/group identity immediately before each signal, preserves
|
||||
verified member anchors, waits 5 s before KILL and observes for 2 s. Signals
|
||||
are acknowledgements only, never invented. Root/business/run cache joins the
|
||||
same Promise. Atomic hard-link publication plus durable exclusive claim
|
||||
coordinates other host processes. First owner/reason/deadline/result persists;
|
||||
completed retries never re-escalate or overwrite that result. Halt links its
|
||||
generation to the first fire receipt without relabelling the runtime's stop
|
||||
cause. A later verified exit is separate immutable observation evidence.
|
||||
The seam writes no bus events. Honker maps killed/alive to lifecycle events.
|
||||
|
||||
No global unit, live config, binding, token, slot file or live store touched.
|
||||
No schema migration. First activation still requires stopping old processing
|
||||
before trusted initialization and aligning the copy/image/read-only mount in
|
||||
Mos's window. Existing result.json evidence remains write-once.
|
||||
|
||||
## Integration and outstanding work
|
||||
|
||||
The 12 latest-base conflict resolutions are listed in STATUS.md. New landed
|
||||
host execution, publish and library sync paths are now wrapped in processing
|
||||
scopes; host credential cancellation composes with its existing revocation
|
||||
signal. This changes only files already owned by 191. Those wider integrations
|
||||
still need the full owning-file/package gate and mutant evidence.
|
||||
|
||||
Fifteen proposed per-run mutants preflight exact once in stoprun-mutants.json;
|
||||
none has been applied or run. The broader kill-switch acceptance/guard table
|
||||
and executable mutant runner are still being completed. No kill count or
|
||||
review-ready claim is made. Ask Sage for the later CLEAR when the packet is
|
||||
complete, as sage-2259/2262 requires.
|
||||
|
||||
### Gate preparation after the focused slot
|
||||
|
||||
The published product implementation is unchanged. Three uncommitted test-file
|
||||
strengthenings are saved in post-inspection-tests.patch: effects.test.mjs adds a
|
||||
4-second bounded abort expectation for the watcher mutant; Discord's early
|
||||
start fence is also checked before a malformed journal can be read; WebUI's
|
||||
body limit now uses oversized valid JSON, and a direct-handler test isolates
|
||||
its own origin/site guards from serve's outer fence. These edits are unrun and
|
||||
are not included in the published 9971ac5f manifest. They must pass before a
|
||||
new candidate is cut. Thus 62/62 is prior focused evidence, not a claim for
|
||||
these changed test bytes.
|
||||
|
||||
The complete proposed table has 70 exact-once sites in all-mutants.json,
|
||||
including expected killing test names. Static preflight is clean; no mutant
|
||||
has been applied. run-mutants.py validates preflight, uses new process groups,
|
||||
tracks only its own verified descendant identities, kills those groups on
|
||||
timeout, checks named TAP failures, and restores all source bytes in finally.
|
||||
A timeout or wrong-test failure is not counted as a valid kill. The table and
|
||||
runner are prepared evidence, not a kill result. The requested nine-package,
|
||||
ten-shell, seventy-mutant gate is in gate-plan.json; Sage CLEAR is pending.
|
||||
|
||||
## Published test-strengthening commit and full gate (sage-2269/2271)
|
||||
|
||||
81889b58d57889b12a584aa225e1ac26e90e5700 is one fast-forward commit on
|
||||
7aa18b0f6, same branch rocko/row191-stoprun-dcd8-20261022. It commits the
|
||||
three test changes saved in post-inspection-tests.patch; that patch remains
|
||||
historical evidence, not an uncommitted candidate. Product implementation is
|
||||
unchanged. Earlier manifest 9971ac5f belongs to 7aa18b0f6; candidate.txt now
|
||||
names the manifest for 81889b58d. Sage published this tip before granting CLEAR.
|
||||
|
||||
The full gate checks HEAD equality and clean product bytes before its first
|
||||
file. Changed test files run singly, then nine packages, all ten shell suites,
|
||||
then seventy mutants. Each step gates on its exit status and retains its full
|
||||
log. run-gate.sh uses pipefail and an EXIT trap for the label-verified Compose
|
||||
network rocko-r191-gate_default, project rocko-r191-gate. TMPDIR is
|
||||
/mnt/storage/scratch/tmp/rocko-r191-gate. Docker is available only for test-task
|
||||
and test-release; other steps use unix:///nonexistent.sock. Live provider calls
|
||||
are disabled. No daemon, socket, volume or live configuration changes.
|
||||
|
||||
Gate start: 2026-10-22T00:18:00Z. Results remain pending until gate-results.json
|
||||
and mutant-results.json provide actual exit statuses and counts.
|
||||
|
||||
### First full-gate stop and fixture correction (sage-2274)
|
||||
|
||||
The 81889b58d run stopped at its first failure, 00:18:00Z–00:19:21Z.
|
||||
27 files completed: 268 passed, 1 failed, 0 skipped. Packages, shells and
|
||||
mutants did not start. Full logs/results are in gate-attempt-81889/.
|
||||
The tester refusal test's fixture requested tester:true over TCP, which row
|
||||
196 now refuses before route handling. This is a stale fixture, not a flake.
|
||||
The correction uses the existing startTesterSocket helper, a private fixture
|
||||
root, socketPath requests, TESTER_ORIGIN and no human identity header. Every
|
||||
assertion remains; the request now reaches the intended processing refusal
|
||||
rather than failing listener setup or the outer identity fence. A grep of all
|
||||
changed test files found no other tester:true TCP setup. The cwd node/chromium
|
||||
sweep returned []; no Docker test workload ran. Commit c78f48946 is a single
|
||||
fast-forward test-only fix on 81889b58d, authorized by sage-2274. The complete
|
||||
gate restarts from its first single file only after Sage publishes this tip.
|
||||
|
||||
Sage-2275 published c78f48946 and granted full restart. Second attempt begins
|
||||
2026-10-22T00:21:03Z; any further failure stops before additional edits.
|
||||
Candidate manifest b49fd493dc75ef2484ffc255213dc8eeaaaa348dee416c8d26d1c2bab8e20067.
|
||||
|
||||
### Second attempt stopped; no further changes (sage-2275)
|
||||
|
||||
Run 00:21:03Z–00:23:50Z on published c78f48946: all 27 changed singles
|
||||
269/269, processing-control 61/61, bus 646/646. The CLI package emitted 26
|
||||
failed subtests before its verified own runner group 3119310 was stopped.
|
||||
Its result is interrupted, not a complete package count. Six gate-lane image
|
||||
checks refuse because scripts/testing/lane-image.mjs pins the old four COPY
|
||||
lines and 191 adds processing-control to Containerfile. Twenty launch-account,
|
||||
launch-limit-checks, launch-limits and launch-routes cases hit
|
||||
processing-uninitialized on fixture roots with no explicit control init.
|
||||
No edits, retries, later packages, shells, Docker test workloads or mutants
|
||||
followed this second failure. Parent runner exited; cwd node/chromium sweep
|
||||
returned [] at 00:23:50Z. Slot returned to Sage at 00:24:05Z with the exact
|
||||
failures and a request for scope on image-manifest and fixture initialization.
|
||||
This is an integration failure, not a declared flake or a passing gate.
|
||||
|
||||
## Image-input manifest and launcher fixture scope (sage-2278, sage-2282)
|
||||
|
||||
Commit eb3abba12 is one fast-forward commit on c78f48946. This is an image-input
|
||||
manifest change: scripts/testing/lane-image.mjs now pins the exact Containerfile
|
||||
COPY of packages/processing-control/src/state.mjs and entrypoint.mjs and hashes
|
||||
both named source paths, without a wildcard. Its only two changed lines add
|
||||
those inputs. Without them, a lane could reuse a stale image after a reader or
|
||||
entrypoint change. The image test fixtures copy both files and each has its
|
||||
own receipt-invalidating test. Mutant KS56 drops state.mjs from the hash list;
|
||||
its named killer is the state.mjs receipt-invalidating test. Static preflight
|
||||
is 71 sites / 0 errors. No new run result is claimed before CLEAR.
|
||||
|
||||
The launch-account, launch-limit-checks, launch-limits and launch-routes fixtures
|
||||
now explicitly call initializeControl(f.dataRoot) before constructing their
|
||||
launchers. Existing assertion lines are byte-identical. A new launch-limit-checks
|
||||
test leaves initialization off and asserts processing-uninitialized, no
|
||||
mayLaunch/authorizeLaunch, and no registered session. A live root gets control
|
||||
initialized by the bus broker. The re-pin order is bus start before any seat
|
||||
launch; a launcher started before that refuses. This supplements the existing
|
||||
window precondition to stop old processing before the first trusted init.
|
||||
|
||||
Sage-2282 supersedes the test-only image ruling: lane-image.mjs is a product
|
||||
blob, so mos-1200 carries no earlier results. The complete plan restarts from
|
||||
the first single file, all nine packages, all ten shells and all 71 mutants
|
||||
only after publication and Sage CLEAR. Earlier attempts remain in their
|
||||
separate gate-attempt directories; neither is a passing full gate.
|
||||
|
||||
Sage-2283 published eb3abba12. Current 85-path candidate manifest is
|
||||
01a2b33651414c9b0cb094d2108f9e39c48a41b79974dcc16bdec374f8371091.
|
||||
The gate prints HEAD and this digest, verifies every manifest blob before its
|
||||
first test, and carries no old results. Awaiting CLEAR after Gander 253.
|
||||
|
||||
## Full restart on eb3abba12 stopped at Discord (sage-2286)
|
||||
|
||||
Verified HEAD and all 85 manifest blobs before tests. Run span:
|
||||
2026-10-22T00:34:50Z–00:47:33Z. No old result carried.
|
||||
|
||||
| Step | Passed | Failed | Skipped | Exit |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 32 changed test files singly | 304 | 0 | 0 | 0 |
|
||||
| processing-control | 61 | 0 | 0 | 0 |
|
||||
| bus | 646 | 0 | 0 | 0 |
|
||||
| cli | 761 | 0 | 0 | 0 |
|
||||
| discord | 190 | 2 | 0 | 1 |
|
||||
|
||||
Singles sum independently confirmed by awk over all 32 TAP logs. Discord's
|
||||
binding.test.mjs:201 STOP check and recover.test.mjs:145 ready recovery check
|
||||
both get processing-uninitialized before the expected behavior. Those older
|
||||
fixture roots do not explicitly initialize control. The gate stopped at this
|
||||
step; no subsequent package, shell suite, Docker test workload or mutant ran.
|
||||
Docker availability was checked read-only (29.7.2); no daemon/volume changes.
|
||||
The EXIT trap completed. Cwd node/chromium sweep returned [] at 00:47:49Z,
|
||||
source tree remains clean, and the slot was handed back at 00:47:58Z.
|
||||
No test or product edits or retries followed this failure; Sage has the table
|
||||
and the fixture-initialization recommendation. This is not a green full gate.
|
||||
|
||||
## Discord fixture init and all-package static sweep (sage-2294)
|
||||
|
||||
Commit e6800380dc0765ce3f1477044e2ce4f773ebb43c is test-only and fast-forwards eb3abba12.
|
||||
Only binding.test.mjs and recover.test.mjs change: one initializeControl import
|
||||
and one initializeControl(dep.dataRoot) call in each affected CLI fixture.
|
||||
Every original assertion line is retained. Product blobs are unchanged.
|
||||
FIXTURE-SWEEP.md and fixture-sweep.json enumerate 338 source call-site hits,
|
||||
with the two fixes and the already-initialized chains, plus the screens for
|
||||
rootless optional contexts and read-only/usage CLI calls. The generator reads
|
||||
repository source only and invokes no fixture. Deliberate missing/deleted/
|
||||
corrupt-state refusal tests remain negative; no shared helper masks them.
|
||||
|
||||
Current manifest be36af8f0855f57336e6a33283aa0a8e94297f338b1f8180936cbccf1119c60a, 87 paths. Sage-2294 carries only unchanged
|
||||
processing-control 61/61, bus 646/646, CLI 761/761 and unchanged singles from
|
||||
gate-attempt-eb3/. The resume must run both touched files wholly, whole Discord,
|
||||
then the never-run WebUI/tasks/seat/project/library packages, every shell suite
|
||||
and all 71 mutants. No result is claimed for the new test bytes before CLEAR.
|
||||
The live initialization order remains bus start before any seat or Discord
|
||||
start; no fixture change initializes or repairs a live root.
|
||||
|
||||
## Resume on e6800380d stopped at WebUI (sage-2311)
|
||||
|
||||
Start 2026-10-22T01:27:40Z; failed-step exit 01:28:45Z. Startup verified HEAD,
|
||||
manifest be36af8f and all 87 blobs, and read-only Docker availability (29.7.2).
|
||||
|
||||
| New step | Passed | Failed | Skipped | Exit |
|
||||
|---|---:|---:|---:|---:|
|
||||
| binding.test.mjs whole | 15 | 0 | 0 | 0 |
|
||||
| recover.test.mjs whole | 5 | 0 | 0 | 0 |
|
||||
| Discord whole | 192 | 0 | 0 | 0 |
|
||||
| WebUI whole | 314 | 4 | 1 | 1 |
|
||||
|
||||
Carried unchanged under sage-2294: 32 singles 304/304, processing-control 61/61,
|
||||
bus 646/646 and CLI 761/761, from gate-attempt-eb3/. These are explicitly carried
|
||||
results, not fresh runs on e6800380d.
|
||||
|
||||
Four WebUI failures are retained in gate-logs/package-webui.log:
|
||||
- board-held.test.mjs:91: processing's config/state GETs omit redirect:manual.
|
||||
- board-held.test.mjs:194 and send-browser.test.mjs:58: processing's independent
|
||||
hash/focus/interval refresh continues to read after the shell signs out.
|
||||
- shell.test.mjs:104: the added control bar makes the short desktop page 1095px
|
||||
high at a 1000px viewport. Its new height is not accounted for by the existing
|
||||
frame/sidebar/footer layout.
|
||||
|
||||
Static diagnosis: processing.js ignores the existing body.signed-out state and
|
||||
mosaic-signed-out event that app.js and bus.js already share. Its GETs also lack
|
||||
manual redirect handling. processing.css adds an external bar while shell.css's
|
||||
frame minimum and sidebar heights still subtract only bar-h and foot-h. These
|
||||
are product integration defects; no assertions were weakened and no fix was
|
||||
made after the failed step. Sage received the table and recommendations.
|
||||
|
||||
One TAP skip: the editor rebuild requires exactly esbuild 0.28.2; installed
|
||||
shared node_modules has 0.28.1. Full skip line is in tap-skips.json. No shell
|
||||
suite ran, so there are no ^skip shell lines (skip-lines.txt remains empty).
|
||||
No tasks/seat/project/library suite, shell Docker workload or mutant ran after
|
||||
WebUI failed. Parent runner exited, EXIT cleanup completed, cwd node/chromium
|
||||
sweep returned [] at 01:29:00Z, source tree is clean, and slot returned to Sage.
|
||||
No retries or source edits were made. This is not a passing gate.
|
||||
|
||||
## Console shared sign-out and bounded desktop layout (sage-2317)
|
||||
|
||||
Commit 8e1f55c1db6a096e227b5114cd13a7142493b112, fast-forward on e6800380d, changes only the
|
||||
processing assets and their owning two tests. Current 87-path manifest:
|
||||
d3b014e6a0e9e547a29ae780eb27b1c0e57cc7b0e73dc49954abbff623929f41.
|
||||
|
||||
All three processing fetch sites use redirect:manual. responseBody dispatches
|
||||
the existing mosaic-signed-out event for an opaque redirect, or its signIn
|
||||
variant for exactly 401/sign-in required. No independent sign-out state is
|
||||
added: refresh/action guards read body.signed-out. The existing event cancels
|
||||
the interval, invalidates pending reads, clears stale state and disables
|
||||
writes. In-flight answers cannot restore state after sign-out. Initial work
|
||||
waits for DOMContentLoaded because this deferred asset precedes app.js in the
|
||||
HTML and needs its shared sign-out listener registered first. app.js/bus.js
|
||||
and their proof baselines remain untouched.
|
||||
|
||||
The CSS keeps the control bar in normal document flow. On desktop, the frame
|
||||
flexes into the remaining viewport rather than imposing another full-height
|
||||
minimum below the bar. Fixed navigation retains the shell's command/footer
|
||||
bounds and existing responsive column widths. Mobile retains natural flow.
|
||||
No short-page, sidebar or footer assertion is changed; rendering remains to
|
||||
be verified on the next authorized gate, not claimed from static checks.
|
||||
|
||||
The VM fixture models the existing body class, document events and DOM ready;
|
||||
its original assertion lines are unchanged. Six new browser cases cover config,
|
||||
state and POST opaque redirects and sign-in-required replies, manual redirect
|
||||
requests, interval cancellation and no subsequent fetch from forced refreshes
|
||||
or clicks. KS57 removes manual redirect from the config GET; KS58 removes the
|
||||
refresh signed-out guard. Both name the config-redirect browser case as their
|
||||
killer. Static preflight: 73 exact-once sites / 0 errors. Syntax checks pass;
|
||||
no owning test or mutant has run on this commit.
|
||||
|
||||
A product blob changes, so sage-2317/mos-1200 carries nothing. The next CLEAR
|
||||
runs all changed files singly, all nine package suites, all ten shell suites
|
||||
with Docker available and all 73 mutants. The editor rebuild's esbuild0.28.1
|
||||
vs pinned0.28.2 skip remains host drift and is reported separately. Earlier
|
||||
failed attempts remain immutable in gate-attempt-* directories.
|
||||
|
||||
## Rebase onto row 202 (sage-2329)
|
||||
|
||||
New branch rocko/row191-stoprun-ddfb-20261022, tip eafcbd3e7d6ef6e259fb59d87a7b0857b42337aa,
|
||||
base ddfb29af7. The prior published branch is preserved; no force-push, delete
|
||||
or old-name reuse. Six Rocko commits rebased, with one manual conflict in
|
||||
scripts/mosaic's human CLI case: prepend halt|resume|processing and retain
|
||||
row 202's appended access. packages/cli/src/cli.mjs auto-merges retain the
|
||||
access import/branch and all three processing commands. CLI README retains
|
||||
both the Access and Instance processing switch sections.
|
||||
|
||||
The full old/new tip diff has 15 paths (base plans/queue/logs, row 202's new
|
||||
access files and its three shared CLI paths), 2327 insertions/32 deletions.
|
||||
Within row 191's owned paths only CLI README, cli.mjs and scripts/mosaic differ,
|
||||
with the row 202 additions. rebase-ddfb-log.txt, rebase-ddfb-tip-diff.stat,
|
||||
rebase-ddfb-owned-diff.stat and rebase-ddfb-range-diff.txt retain exact evidence.
|
||||
|
||||
Manifest fd0f2740b1cccadaaad8c613640fdddb0497cdab33ec2c0fd11debf3cae6c4ee, 87 candidate paths. The package command lists were
|
||||
regenerated from this base; CLI includes access.test.mjs. All 73 mutant sites
|
||||
must preflight again. No tests ran, no result carries, and the complete gate
|
||||
waits for publication and Sage CLEAR after Megavolt's debug run.
|
||||
|
||||
Sage-2333 published eafcbd3e7. Their per-file patch-id comparison matches
|
||||
the prior six-commit range except the exactly ruled scripts/mosaic dispatch
|
||||
line; binary/secrets/attribution checks pass. Gate remains unrun pending CLEAR.
|
||||
|
||||
## eafcbd3e7 full gate stopped on timer fixture attribution (sage-2339)
|
||||
|
||||
Run 2026-10-22T02:18:04Z–02:19:25Z. HEAD, 87 blobs and fd0f2740 manifest
|
||||
verified. Short mktemp TMPDIR /mnt/storage/scratch/tmp/gxbC; named Compose
|
||||
project rocko-r191-gate. Nothing carried. 32 changed files completed:
|
||||
313 passed, 6 failed, 0 skipped. All six new processing-browser sign-out
|
||||
cases stop at their common precondition, processingTimers length2 vs1.
|
||||
The original two browser cases pass. Their recorder attributes every 5000ms
|
||||
interval to processing, but app.js:307 already arms its own freshness timer.
|
||||
The intended processing interval is at processing.js:87. These new cases have
|
||||
not yet exercised sign-out or killed KS57/KS58, so no behavior proof is claimed.
|
||||
|
||||
The gate stopped at this first red step. No package, shell, Docker workload
|
||||
or mutant ran; the esbuild rebuild was not reached. Runner exited, EXIT cleanup
|
||||
completed and cwd node/chromium sweep returned [] at 02:19:34Z. No source edit
|
||||
or retry followed the failure. Sage received the exact failure and a proposed
|
||||
test-only caller-attribution filter with the assertions unchanged. This is a
|
||||
fixture error, not a passing gate or a declared flake.
|
||||
|
||||
## Timer recorder attribution correction (sage-2345)
|
||||
|
||||
Commit 4db8b57785895bc62f5f015468a69515da7fc77f, fast-forward on eafcbd3e7, is a one-line
|
||||
test-only correction in processing-browser.test.mjs. Cause: app.js:307's
|
||||
freshness interval is also 5000ms, and the recorder incorrectly counted it as
|
||||
a processing interval. The recorder now requires the processing.js caller
|
||||
stack. Every assertion remains byte-identical, including exactly one timer
|
||||
before sign-out and zero after it. A second 5000ms interval from processing.js
|
||||
would still be counted and fail the exactly-one assertion. No product byte
|
||||
changes, and no test or mutant ran for this correction.
|
||||
|
||||
Manifest ffb46fbdb875f97ff2eb7a7bed8bf6d5c4df53b8a63c738ffcde5fc5f6b7f6ad, 87 paths. Full restart after publication and CLEAR,
|
||||
with nothing carried. Sage-2345 counts the timer-fixture red as the first in
|
||||
this 191 window; any second red stops and is escalated to Mos. Previous logs
|
||||
are retained in gate-attempt-eaf/.
|
||||
|
||||
## Full gate on 0f8235277: second red (sage-2351)
|
||||
|
||||
Candidate: 0f8235277e1fc21588edb6f2931d66c8e154a400 on ddfb29af7.
|
||||
Manifest: ffb46fbdb875f97ff2eb7a7bed8bf6d5c4df53b8a63c738ffcde5fc5f6b7f6ad.
|
||||
No prior result carried. Start 2026-10-22T02:33:41Z; failed-step exit
|
||||
2026-10-22T02:46:17Z. Fresh TMPDIR /mnt/storage/scratch/tmp/giNF.
|
||||
Compose project rocko-r191-gate, trapped ownership-checked network cleanup.
|
||||
|
||||
| Step | Passed | Failed | Skipped | Exit |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 34 changed test files singly | 330 | 0 | 0 | 0 |
|
||||
| processing-control | 61 | 0 | 0 | 0 |
|
||||
| bus | 646 | 0 | 0 | 0 |
|
||||
| cli (including access) | 783 | 0 | 0 | 0 |
|
||||
| discord | 192 | 0 | 0 | 0 |
|
||||
| webui | 323 | 1 | 1 | 1 |
|
||||
| tasks, seat, project, library | not run | — | — | — |
|
||||
| all ten scripts/test-*.sh | not run | — | — | — |
|
||||
| all 73 mutants | not run | — | — | — |
|
||||
|
||||
Singles sum independently confirmed with awk over all 34 TAP logs.
|
||||
The sole failure is shell.test.mjs:380 in the full shell-browser test:
|
||||
scrollHeight 1062 vs innerHeight 1000. Full evidence is
|
||||
gate-logs/package-webui.log (not ok 271, line 1626).
|
||||
All prior manual-redirect/sign-out failures and eight processing-browser cases
|
||||
pass, but the desktop short-page geometry is still wrong. No passing layout
|
||||
or full-gate claim is made.
|
||||
|
||||
Known host-drift TAP skip: editor rebuild requires exactly esbuild 0.28.2,
|
||||
while shared node_modules has 0.28.1. Full line is in tap-skips.json. This skip
|
||||
is not counted as a red. No shell suite ran, so skip-lines.txt has no ^skip
|
||||
shell line. No Docker test workload or mutant started.
|
||||
|
||||
Stopped at the first failed step without retry or product edits, per sage-2351.
|
||||
This is the second red in the 191 window and is with Sage for Mos escalation
|
||||
under mos-0823. Parent runner exited; EXIT cleanup completed. Cwd-scoped node
|
||||
and chromium sweep returned [] at 02:47:14Z, product worktree is clean, and the
|
||||
slot was returned. No in-review move was made.
|
||||
|
||||
|
||||
### mos-1248 / sage-2363: desktop viewport allocation (unrun static candidate)
|
||||
|
||||
The second red on 0f8235277 was shell.test.mjs:380 (1062 versus 1000).
|
||||
The shell gives .frame a viewport-based minimum at packages/webui/src/public/shell.css:135;
|
||||
processing.css:12-14 replaced that minimum but used a column flex body with only
|
||||
min-height:100vh, so the new bar was not allocated a bounded share of the viewport.
|
||||
The desktop body now uses explicit grid rows for the command bar, controls, optional
|
||||
banner, frame and footer. The frame row is minmax(min-content,1fr): it takes the
|
||||
remaining short-page space while long content may grow the document. No clipping,
|
||||
overflow suppression, fixed body height, test viewport change or assertion removal.
|
||||
The existing shell.test.mjs:380 equality stays byte-identical. Added assertions
|
||||
check actual processing-bar visibility, positive height, viewport placement and
|
||||
no document scroll at 1440x1000; the existing 1440x420 long-page checks remain.
|
||||
KS59 reverts processing.css to 0f8235277's bytes and must fail that same shell
|
||||
browser test. Total: 74 planned mutants. No test or mutant has run on this fix.
|
||||
Webby must statically review this delta before Sage opens the full no-carry gate.
|
||||
|
||||
|
||||
### Correction after Webby B1 (webby-0104, 2026-10-22 03:00Z)
|
||||
|
||||
The cause stated in the mos-1248/sage-2363 note above is withdrawn.
|
||||
processing.css loads after shell.css and already overrides the frame minimum.
|
||||
With the tree fixed and inspector hidden, the remaining floor is the Board's
|
||||
main content. The grid min-content row preserves that floor, so 5cd32f4c1
|
||||
is not an accepted layout fix. Its static review is CHANGES at Webby's
|
||||
REVIEW-layout.md, commit e2e7c60e5. No run established a new layout measurement.
|
||||
Next step requires a granted fixture probe for main/control heights and an
|
||||
owner layout ruling; no further product change or gate is authorized yet.
|
||||
Existing shell assertions remain unchanged.
|
||||
|
||||
|
||||
### Static nav placement: mos-1249/mos-1250, sage-2383/2385/2386, webby-0106/0108
|
||||
|
||||
Fast-forward on 5cd32f4c1; the rejected grid is removed. The unchanged labeled
|
||||
region is now the first child of nav.tree, before its Console h2. The banner
|
||||
stays at its original body location with every id, role, label and shipped
|
||||
string unchanged. No processing.js change and no runtime reparenting.
|
||||
At >=760 the tree is the same fixed box, with the same width breakpoints and
|
||||
ordinary overflow:auto. The controls are non-shrinking and non-sticky; they
|
||||
scroll with overflowing nav links (sage-2386 withdraws persistent visibility).
|
||||
Below760 the region takes a full flex line before Console/Projects. Its mobile
|
||||
location changes from before .frame to first in the static nav; the banner
|
||||
therefore precedes it in a halted mobile view. Banner behavior itself is unchanged.
|
||||
Specificity (0,3,0) resets .btn margin/order and .small margin/basis, leaving
|
||||
the shell rules unchanged. No content is shortened and no overflow is hidden.
|
||||
|
||||
Correct height arithmetic (Webby B1): page=max(innerHeight,86+C), where C is
|
||||
main content height. Controls consume space within the existing nav box, not
|
||||
another body row. The old failing layout was max(innerHeight,86+barHeight+C).
|
||||
The halted banner remains a body row; Sage tracks that separately.
|
||||
|
||||
Candidate-test design change at processing-ui.test.mjs:67 under mos-1250.
|
||||
Old assertion:
|
||||
```js
|
||||
assert.ok(html.indexOf('id="processing-controls"') < html.indexOf('<div class="frame">'));
|
||||
```
|
||||
New assertion text:
|
||||
```js
|
||||
const controlsAt = html.indexOf('id="processing-controls"');
|
||||
const mainAt = html.indexOf('<main id="main"'), inspectorAt = html.indexOf('<aside class="inspector"');
|
||||
assert.ok(controlsAt >= 0 && controlsAt < mainAt, 'controls are outside and before #main');
|
||||
assert.ok(!(inspectorAt < controlsAt && controlsAt < html.indexOf('</aside>', inspectorAt)),
|
||||
'controls are outside aside.inspector');
|
||||
```
|
||||
Reason: the bar moves into the frame's first column under mos-1249 while
|
||||
remaining outside route-specific #main and aside.inspector. This is a change
|
||||
to our unlanded candidate assertion, not a landed assertion.
|
||||
|
||||
All existing shell assertions stay unchanged. Added bar-inside-tree assertion
|
||||
and diagnostic height output. New processing browser coverage checks 400px
|
||||
Stop order=0, visibility, focus and position above Projects; also on-load
|
||||
visibility/focus at1440x1000,1440x420,800x420. Debug logs report controls/tree
|
||||
heights at those desktop sizes and main height; Sage judges controls over half
|
||||
the 334px nav at1440x420 before the window. No run yet.
|
||||
|
||||
Mutant delta: KS59 now reverts the complete layout (HTML and CSS together);
|
||||
KS60 hides the controls; KS61 moves them into #main; KS62 drops the (0,3,0)
|
||||
button reset. Total77. Every new site has its named killing test.
|
||||
|
||||
|
||||
### sage-2394 debug on published da4334806 (2026-10-22)
|
||||
|
||||
03:14:37Z–03:14:53Z: shell.test.mjs whole 5/5, processing-browser.test.mjs
|
||||
whole 9/9, 0 failures/skips. awk sum14/0/0. Original shell short-page and
|
||||
long-page assertions pass unchanged. Logs/results and empty cwd sweep are in
|
||||
debug-da4334806/. Slot clear sent Sage/Webby03:15:11Z after sweep.
|
||||
Controls/tree/main heights: read-only shell1440x1000 212.5/914/914; writable
|
||||
processing fixture1440x1000 127/914/914;1440x420 127/334/907.734375;800x420
|
||||
127/334/1131.65625. Stop visible/order0/focusable/aboveProjects at400x1000.
|
||||
Writable127 is below half the334 nav. The read-only212.5 height at1000 was
|
||||
flagged for Sage's usability call; that state was not measured at420, so no
|
||||
claim is made that all states use less than half the short nav.
|
||||
No mutants or full gate run under this debug grant. Await separate CLEAR.
|
||||
|
||||
|
||||
### R6 accepted: sage-2399 / webby-0111 (2026-10-22 03:16Z)
|
||||
|
||||
Sage accepts the short-nav usability tradeoff. Writable controls occupy38%
|
||||
of the334px nav at1440x420. The read-only recovery note makes that about64%,
|
||||
inferred from the measured212.5px height at the same width. The region is
|
||||
non-sticky and scrolls away; Projects was already below the fold at that size
|
||||
before191. Webby confirmed the debug TAP and has no window blocker. Sage
|
||||
tracks the read-only note length with the halt-banner follow-up; no change
|
||||
to this row.
|
||||
|
||||
Do not rebase or run now. Row273 lands first. On Sage's landing message,
|
||||
rebase the candidate including the two latest layout commits onto the new
|
||||
refactor under a new branch name (mos-1086), then provide range-diff,
|
||||
patch-ids and the refreshed88-product-path manifest. The full no-carry
|
||||
window requires a separate Sage CLEAR on that published tip.
|
||||
|
||||
|
||||
### Rebase after row273: sage-2402 (2026-10-22 03:42Z)
|
||||
|
||||
New branch rocko/row191-stoprun-86f9-20261022, tip
|
||||
0fa7bf421fc153a226b5f06ed484e089e48e23ba, on86f95bb27. All10 commits
|
||||
rebase cleanly and range-diff pairs each with '='; no conflict resolution.
|
||||
All88 per-file patch-ids match the old candidate. Whole product diff stable
|
||||
patch-id before/after:0846823aefe165363601a6c68134ad46cc701c73.
|
||||
87 product blobs are identical; only docs/TOOLS.md changes because the new
|
||||
base carries row273's --send/--no-sso usage and three explanatory F39 lines.
|
||||
191's TOOLS hunks stay unchanged. New88-path manifestSHA256:
|
||||
0bcbf337433f6d46d809ee1dcaf8a5a8503160403d273cf73d6dac64adef3c02.
|
||||
Range-diff, per-path patch-ids and TOOLS comparison are rebase-86f9-* files.
|
||||
Static77-site exact-once preflight has0errors. No tests run, nothing carried
|
||||
into the required full window; await Sage publication and separate CLEAR.
|
||||
|
||||
|
||||
### Full no-carry window on0fa7bf421: stopped at SR08 (sage-2407/2418)
|
||||
|
||||
03:44:56Z–04:01:29Z. All35touched files passed336/0/0; nine packages
|
||||
passed2403/0 with1known editor/esbuild skip. Every shell exited0:425selftests
|
||||
plus156queue node tests passed; the canonical-root queue check and two
|
||||
explicit live-provider exclusions are saved in skip-lines.txt. See
|
||||
GATE-RESULTS.md for per-suite counts and mutant table.
|
||||
SR01–SR07 killed. SR08 timed out at90seconds without named assertion failure
|
||||
(TAP header only); runner restored all88candidate blobs and stopped. The69
|
||||
later mutants did not run. SR08 is not counted as killed. Cwd sweep[] and
|
||||
owned network cleanup completed; slot clear sent Sage04:02:12Z.
|
||||
Saved mutation, timeout policy lines64–89 and shell/skip table sent Sage in
|
||||
response to sage-2418. No source edit or retry; no in-review move. Await
|
||||
Mos ruling and explicit Sage CLEAR before any control/replay/resume.
|
||||
|
||||
|
||||
### SR08 timeout policy: mos-1259 / sage-2423 (2026-10-22 04:07Z)
|
||||
|
||||
Cause confirmed by Mos: runtimes.mjs:333 loops on now() <= deadline, while
|
||||
stop-run.test.mjs:106-108 gives the second host a fixed fake clock with real
|
||||
sleep. Removing the join branch enters the stop loop without advancing that
|
||||
clock. Original SR08 log contains TAP header only; original timeout record
|
||||
stays immutable. Mos permits separate classification as killed-by-timeout
|
||||
only after the unmutated same whole test file passes the same90second
|
||||
harness in this resumed run. **Control result: not run; awaiting CLEAR.**
|
||||
No assertion kill is claimed for SR08.
|
||||
|
||||
Runner review requested from Dewey, Sage copied. SHA256 of run-mutants.py:
|
||||
fe0d360f86d86ab2df967da5338479c593c27c88d06eb38a1389d99a08489369. Diff:runner-mos1259.diff; baseline:
|
||||
run-mutants.before-mos1259.py. Shared execution harness uses90seconds, own
|
||||
process groups, verified descendant cleanup and restored source bytes. A
|
||||
control must pass every test with exit0, no not-ok/skip/cancel/fail. Its record
|
||||
binds invocation id, candidate HEAD/manifest and test hash; all88candidate
|
||||
blobs verified before and after control, and before each mutation. The
|
||||
approved historical SR08 classification and69-site SR09+resume use separate
|
||||
files so the original seven kills and timeout evidence remain intact.
|
||||
Assertion kills and timeout kills have separate counters. No control for the
|
||||
same file in this invocation means a timeout stops; survivors and invalid
|
||||
failures stop too. Every timeout gets a cause/control record before review.
|
||||
|
||||
No product/test change, no control/mutant execution. Wait for independent
|
||||
runner approval and Sage CLEAR. Green suites carry only on unchanged0fa7bf421
|
||||
and manifest0bcbf337. Test clock fix is deferred to Vikunja502 as Mos ruled.
|
||||
|
||||
|
||||
### Independent runner approval: dewey-0789 (2026-10-22 04:11:20Z)
|
||||
|
||||
Dewey APPROVES the frozen fe0d360f86d86ab2df967da5338479c593c27c88d06eb38a1389d99a08489369
|
||||
runner, static only. Use only the proposed --control-file stop-run.test.mjs
|
||||
--adjudicate-timeout SR08 --resume-from SR09 invocation. A positional-id or
|
||||
full run would overwrite the original evidence and is not this resume.
|
||||
|
||||
Report the complete tally as7 prior assertion kills (old status killed),
|
||||
plus SR08 separately adjudicated by timeout after control passes, plus69
|
||||
resumed sites. The summary file counts only this invocation. No control or
|
||||
resume result exists yet; Sage CLEAR remains required.
|
||||
|
||||
Classification change: the reviewed runner recognizes indented not-ok lines
|
||||
with ^\s* rather than only top-level lines. Controls therefore reject nested
|
||||
failures too, and mutant assertion kills still require the named pattern in
|
||||
a failing test name. SR01–SR07 evidence was classified by the original runner.
|
||||
|
||||
Historical SR08 has no HEAD field. Its binding to0fa7bf421 relies on the saved
|
||||
commit.txt/log timestamps and the new candidate HEAD+88blob check, not a
|
||||
retroactively invented HEAD field in the old result. Preserve this provenance
|
||||
in the adjudication report. Any newly detected timeout needs a concrete cause
|
||||
before review; the generic placeholder is not accepted review evidence.
|
||||
|
||||
|
||||
### Control and resume: sage-2427, stopped on KS43
|
||||
|
||||
Control/resume began04:13:44Z; last saved mutant result2026-10-22T04:14:08Z; process
|
||||
absence confirmed04:14:56Z and slot returned to Sage. Whole unmutated
|
||||
stop-run.test.mjs control passed12/12, exit0, no not-ok, no skips/cancels,
|
||||
in0.459seconds. The reviewed runner adjudicated original SR08 separately
|
||||
as killed-by-timeout under mos-1259. The immutable original SR08 row/log
|
||||
stay unchanged; adjudication binds the control, exact cause, current HEAD
|
||||
and manifest. Historical HEAD provenance remains as Dewey N4 described.
|
||||
|
||||
Resume stopped at KS43, not retried. It removes the inner processingHTTP
|
||||
origin check and its selected outer-route test passed1/1(exit0). Saved
|
||||
evidence:resume-mutant-logs/KS43.tap and resume-mutant-results.json.
|
||||
Tally:7prior assertion kills +49resumed assertion kills =56assertion kills;
|
||||
1historical timeout kill;1survivor(KS43);19unrun. No new timeouts or placeholder
|
||||
causes. All88candidate blobs restored and resume cwd sweep[]; no product or
|
||||
test change. No in-review move. Sage and Mos notified directly.
|
||||
|
||||
Static diagnosis only: KS43 selects processing.test.mjs:52's outer-server
|
||||
fence test. The unchanged direct-handler test at:95 already isolates this
|
||||
origin guard from serve's outer refusal. Its pattern was not selected. Sent
|
||||
recommendation to Sage/Mos to correct only the mutant's named killer after
|
||||
their ruling, then run KS43+19remaining. No metadata edit or rerun authorized
|
||||
yet; no claim the direct-handler test kills it until the run proves it.
|
||||
|
||||
|
||||
### KS43 metadata retarget: mos-1261 (2026-10-22 04:16:41Z)
|
||||
|
||||
Original pattern `processing write fences refuse` selected processing.test:52.
|
||||
KS43 survived with1passing test and no writer call, preserved in
|
||||
resume-mutant-results.json and resume-mutant-logs/KS43.tap. The serve-level
|
||||
test cannot isolate the handler guard: outer fences and later refusal can
|
||||
mask its removal. No product defect or new kill is claimed from static read.
|
||||
New pattern: `processing handler independently refuses wrong origin and cross-site before its writer`,
|
||||
the existing direct processingHTTP test at:95-107 with valid user/site/content
|
||||
headers, asserted403 and no writer call. Only KS43's pattern changed in the
|
||||
77-site table; product/test/mutation bytes unchanged. Generator mirrors it.
|
||||
|
||||
TableSHA256:5dbc8308c995e1974d6da63b3daa967896d822154eab38a8bb96154be667edf5. Diff:mutant-table-mos1261.diff,
|
||||
baseline:all-mutants.before-mos1261.json. Dewey must independently review
|
||||
this metadata diff before the next run. Static audit of all19unrun sites:
|
||||
mutant-pattern-audit-mos1261.md; no other serve-level/direct-handler mismatch
|
||||
with an existing direct test warrants retargeting. KS44 body-bound case has
|
||||
no direct-handler counterpart; its oversized whitespace+{} case is valid
|
||||
JSON without the bound, so it retains the existing pattern.
|
||||
|
||||
Carry56assertion kills+1timeout kill and green suites only on unchanged
|
||||
0fa7bf421. Next approved scope is KS43+19sites after review and Sage CLEAR.
|
||||
No run authorized by this metadata change.
|
||||
|
||||
|
||||
### Retarget review APPROVE and C1 archive: dewey-0791 (2026-10-22 04:19:55Z)
|
||||
|
||||
Dewey approves table5dbc8308 and the19-site audit, conditional on preserving
|
||||
the first resume evidence before any next run. Archive:mos-1261-archive/,
|
||||
full per-file inventory:mos-1261-archive/sha256-manifest.txt.
|
||||
|
||||
- `resume-mutant-results.json` SHA256 `f5725ef26cdb488441285081fadaa9ce4a3e7d390ab2a1c6d04acfd27f105fc6`
|
||||
|
||||
- `resume-mutant-logs/KS43.tap` SHA256 `c778ee33edb667f795ab560a328fd5c1c04ef6acf99d7b79c364f6302cf29864`
|
||||
|
||||
- `unmutated-control.json` SHA256 `ee9ddcd69d24276165abe1185f159a2737157fcae8fd37f731768e9324b765e5`
|
||||
|
||||
- `SR08-timeout-adjudication.json` SHA256 `214a04696d3329ef67bf10578daf4d251599816fd246bc31abecae1a6230e1e0`
|
||||
|
||||
The50-row result, all first-resume logs and control/adjudication records
|
||||
were copied byte-for-byte. Original paths remain untouched too. A separate
|
||||
retarget-mos1261/ directory holds byte-identical approved runnerfe0d360f,
|
||||
reviewed table5dbc8308 and identical candidate/commit files. The proposed
|
||||
--resume-from KS43 invocation there selects20sites and writes its own logs
|
||||
and results without overwriting either original or archive. No control flag
|
||||
is needed for these tests; any timeout without a same-file control stops.
|
||||
Final tally will combine7old assertionkills +49first-resume assertionkills +
|
||||
SR08separate timeout adjudication +20retarget results. Still no run; awaiting
|
||||
Sage CLEAR. Product/test bytes remain unchanged.
|
||||
|
||||
|
||||
### KS43 retarget run: sage-2436, stopped on KS44 invalid mutation
|
||||
|
||||
Run began04:21:06Z; KS43 is killed by the named direct-handler assertion.
|
||||
KS44 then fails import with SyntaxError Unexpected token catch at
|
||||
processing-http.mjs:40. The failing TAP name is the test file, not the
|
||||
selected test, so runner records invalid-failure and stops without a kill.
|
||||
Evidence remains in retarget-mos1261/console.log,resume-mutant-results.json
|
||||
and resume-mutant-logs/KS43.tap,KS44.tap. No original evidence overwritten.
|
||||
|
||||
Cause: our KS44 mutation uses `// no body bound` to replace an inline
|
||||
`if (size > 1024) throw Error();` in a one-line for-await body. The comment
|
||||
swallows the remaining chunks.push and closing brace. This is malformed
|
||||
mutation metadata, not an observed product failure. No metadata correction
|
||||
or retry yet. Proposed to Sage/Mos: replace only the new text with `void 0;`,
|
||||
then independent review and a new CLEAR for KS44+18 on unchanged candidate.
|
||||
|
||||
Current tally57assertionkills (7+49+1),1SR08timeoutkill,1invalid(KS44),18unrun.
|
||||
Cwd sweep[] and all88candidate hashes restored04:21:30Z; slot returned and
|
||||
Sage/Mos notified. No in-review move. Wait for ruling before any edit or run.
|
||||
|
||||
|
||||
### KS44 syntax metadata correction: mos-1264 (2026-10-22 04:22:16Z)
|
||||
|
||||
Only KS44.new changes from `// no body bound` to `void 0;`. Old site,
|
||||
test and pattern stay unchanged. The invalid import result remains in
|
||||
retarget-mos1261/resume-mutant-results.json and resume-mutant-logs/KS44.tap.
|
||||
TableSHA256:85d9a7b13ad510e840f258d892afbfce393cefbc4006e1f808605b83adc84570. Diff:mutant-table-mos1264.diff;
|
||||
baseline:all-mutants.before-mos1264.json. Generator mirrors the one field.
|
||||
|
||||
Authorized syntax-class preflight applied KS44–KS62 individually in scratch,
|
||||
ran node --check on each JavaScript file, and restored before advancing.
|
||||
All15JS checks passed; no other replacement fix was needed. KS59–KS62
|
||||
change only HTML/CSS and are explicitly asset-only/not-applicable for the
|
||||
Node parser; Mos/Sage informed, no false JS syntax pass claimed. All19
|
||||
mutations restored and88candidate hashes match. Per-site logs/results and
|
||||
restoration record:syntax-mos1264/. No behavioral test or assertion-mutant
|
||||
run occurred. Dewey must review table diff and syntax results before next
|
||||
Sage CLEAR for KS44+18. Carry57assertionkills+1timeoutkill only on unchanged
|
||||
0fa7bf421. No product or test change.
|
||||
|
||||
|
||||
### dewey-0795 approval: syntaxfixed-mos1264 staged (unrun)
|
||||
|
||||
Dewey APPROVES table85d9a7b1 and syntax preflight, static only. Conditions
|
||||
met:syntaxfixed-mos1264/ holds byte copies of runnerfe0d360f, table85d9a7b1,
|
||||
commit.txt,candidate.txt,candidate-manifest.sha256. Full SHA256 and byte-cmp
|
||||
list is syntaxfixed-mos1264/byte-copy-check.txt. Invocation after Sage CLEAR:
|
||||
python3 syntaxfixed-mos1264/run-mutants.py --resume-from KS44 (19sites).
|
||||
All previous invalid/survivor evidence stays untouched in its original dirs.
|
||||
No behavioral run occurred during preparation.
|
||||
|
||||
|
||||
### Final19 resume stopped at KS54: sage-2444 (2026-10-22)
|
||||
|
||||
Run began04:27:45Z; KS44–KS53 are10named assertion kills. KS54 then timed out
|
||||
at90.073seconds. Its log contains the expected dependency-list assertion:
|
||||
"the runtime refuses an invalid adapter and closes a valid one" at
|
||||
bus/tests/tasks.test.mjs:263. The mutation drops processing from runtime.mjs
|
||||
tasks factory deps. The test opens runtime then asserts before close at:265;
|
||||
a failing assertion skips the close and leaves Node alive. No same-file
|
||||
control ran in this invocation, so this is timeout-without-control, not a
|
||||
kill. No assertion or timeout kill is claimed for KS54.
|
||||
|
||||
Raw resume result's generic cause sentence says a control passed, which is
|
||||
incorrect: its control:null and timeout-without-control status establish the
|
||||
opposite. The raw evidence remains unchanged; the concrete cause and
|
||||
correction are in syntaxfixed-mos1264/KS54-timeout-report.json. This explicit
|
||||
report replaces the placeholder as explanation without altering the log.
|
||||
|
||||
Current tally67assertionkills(7+49+1+10),1adjudicatedSR08timeoutkill,
|
||||
1unqualifiedKS54timeout,8unrun. All88candidate blobs restored; cwd sweep[]
|
||||
and slot returned04:30:06Z to Sage, Mos directly copied as instructed. No
|
||||
retry, product/test edit or in-review move. Sage's disclosed syntaxcheck
|
||||
caused no busy refusal or foreign-node report. Await owner ruling/CLEAR.
|
||||
|
||||
|
||||
### Final controls/mutants GREEN: mos-1267, sage-2448, dewey-0798
|
||||
|
||||
On unchanged0fa7bf421, whole-file tasks control9/9 passed in0.815s; KS54
|
||||
rerun killed-by-timeout with the exact dependency not-ok at tasks.test:263.
|
||||
KS55–KS62 are8assertionkills. Final run04:35:32Z–04:37:17Z, cwd sweep[] and
|
||||
88hashrestore04:37:48Z; slot returned to Sage and direct handoff to Dewey/Mos.
|
||||
Final77-site tally:75assertionkills(7+49+1+10+8),2separate timeoutkills
|
||||
(SR08adjudication,KS54control+rerun). final-mutant-results.json consolidates
|
||||
latest accepted evidence by id; no prior record or log overwritten.
|
||||
GATE-RESULTS.md is the final per-suite/mutant table; prior narrative saved
|
||||
as GATE-RESULTS-before-final.md.
|
||||
|
||||
KS54 raw placeholder result saved as resume-mutant-results.raw.json before
|
||||
filling concrete cause in its review result. Same-invocation control id
|
||||
1792643732624962173; prior syntaxfixed eleven-row result file remains exact
|
||||
0ede036954ff77c21f29e1a744b21d1d9b18eb75736f78ecd1cf4ae559358911.
|
||||
Dewey independently read logs/control/restoration and confirmed tally in
|
||||
dewey-0798, explicitly not yet a manifest verdict. N6test cleanup stays
|
||||
deferred under mos-1267/Vikunja502; no candidate byte changes permitted.
|
||||
|
||||
|
||||
### Claimant review handoff
|
||||
|
||||
Queue move rocko-191-review-r1 entered in-review at revision3114, round1
|
||||
on#1689, manifest0bcbf337. Revision3115 records the expected missing-token
|
||||
request failure; command exit1. Receipt:in-review-receipt.txt. Sage posts
|
||||
the request as jarvis; Dewey owns the manifest verdict. Candidate source
|
||||
remains published0fa7bf421 with88product paths, base86f95bb27. No live
|
||||
action, push, source change or further gate authorized by this handoff.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Row279: stopRun deadline final observation
|
||||
|
||||
Scope: sage-2512, mos-1269; brief docs/plans/2026-10-22_stoprun-deadline.md.
|
||||
Base f40f2e770. Reviewer Dewey; gate owner Sage. V502 stays separate.
|
||||
|
||||
## Cause and smallest change
|
||||
|
||||
The loop's precondition now()<=deadline can skip the existing final status
|
||||
read when a sleep wakes beyond deadline. Use for(;;) so the existing
|
||||
now()>=deadline branch is the only time exit. Change that loop header only;
|
||||
all record/identity/ended exits and result mapping remain byte-identical.
|
||||
The old trailing fallback remains unreachable in the fixed loop. M1 restores
|
||||
only the old header and thereby restores the complete old fall-through path.
|
||||
|
||||
## Regression and evidence plan
|
||||
|
||||
Add one test to stop-run.test.mjs, preserving existing tests. Its fake sleep
|
||||
advances requested duration plus1ms, so the last sleep crosses the deadline.
|
||||
Its signal stub records TERM/KILL and returns success without killing the
|
||||
always-live fixture process. Expect both signals, time>7000ms and alive.
|
||||
The old loop should fail only the alive assertion with unknown.
|
||||
|
||||
Ask Sage CLEAR before any run, including the expected regression red. Then
|
||||
apply the one-line fix, run the touched whole file and processing-control
|
||||
package as granted, and kill M1 with that same named regression. Busy check,
|
||||
short scratch TMPDIR,deadDocker,owned process groups,timeouts,restore,sweep.
|
||||
No live state,units,store,provider,browser,Docker or V502 changes.
|
||||
|
||||
Packet includes base,patch,candidate hashes,regression/gate/mutant logs and
|
||||
exact source-diff check. Commit candidate and packet on the new branch only
|
||||
after authorized checks. No token is available; Sage publishes as jarvis.
|
||||
The business/CLI suites remain Sage's landing gate unless separately granted.
|
||||
|
||||
## Authorized run and result
|
||||
|
||||
Claim rev 3138, op rocko-279-start-r1. Sage granted this exact sequence in
|
||||
sage-2516 (2026-10-22T05:55:21Z). Runs: 05:56:03Z to 05:58:03Z.
|
||||
|
||||
| Step | Pass | Fail | Skip | Result |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| Regression on unfixed base | 0 | 1 | 0 | Expected assertion: unknown versus alive |
|
||||
| stop-run.test.mjs whole | 13 | 0 | 0 | Green |
|
||||
| processing-control package whole | 62 | 0 | 0 | Green |
|
||||
| M1 old loop | 0 | 1 | 0 | Killed by the named regression, unknown versus alive |
|
||||
|
||||
M1's killer is `a sleep past the stop deadline still observes an alive run
|
||||
after both signals`. It exits 1 with that assertion, never a syntax error or
|
||||
timeout. Logs and results.json retain the intentional failures separately.
|
||||
Green runs total 75 passes (the whole-file tests are also part of the package).
|
||||
No skips. All commands used a short scratch TMPDIR, a dead Docker socket and
|
||||
fixture-only configuration. Each node command ran in its own process group
|
||||
with a 120-second timeout. No timeout occurred. Final cwd sweep: empty.
|
||||
The source restoration and exact one-line product scope are checked in
|
||||
source-scope-check.json. The trailing unknown return stays unchanged and is
|
||||
unreachable after the fix. Business and CLI suites remain Sage's owner gate.
|
||||
Slot returned to Sage at 05:58:03Z. No live action or V502 change.
|
||||
@@ -1 +0,0 @@
|
||||
f40f2e770e0d8be394f1ef53b404045fd6a49c0e
|
||||
@@ -1,35 +0,0 @@
|
||||
diff --git a/packages/processing-control/src/runtimes.mjs b/packages/processing-control/src/runtimes.mjs
|
||||
index 05e439c1a..a11cb8e52 100644
|
||||
--- a/packages/processing-control/src/runtimes.mjs
|
||||
+++ b/packages/processing-control/src/runtimes.mjs
|
||||
@@ -330,7 +330,7 @@ async function ownStop(args, key, options) {
|
||||
};
|
||||
const termEnd = started + grace, deadline = termEnd + observe;
|
||||
let termTried = false, killTried = false;
|
||||
- while (now() <= deadline) {
|
||||
+ for (;;) {
|
||||
try { record = runtime(root, record.id); }
|
||||
catch {
|
||||
const present = readdirSync(directory(root, 'runtimes')).includes(record.id + '.json');
|
||||
diff --git a/packages/processing-control/tests/stop-run.test.mjs b/packages/processing-control/tests/stop-run.test.mjs
|
||||
index ed69bc111..5277ea7fe 100644
|
||||
--- a/packages/processing-control/tests/stop-run.test.mjs
|
||||
+++ b/packages/processing-control/tests/stop-run.test.mjs
|
||||
@@ -61,6 +61,17 @@ test('failed signal delivery observes alive, records no invented signals and nev
|
||||
assert.equal(f.sleeps.reduce((a,b)=>a+b,0), 7000);
|
||||
assert.deepEqual(await stopRun(f.args, f.options), result);
|
||||
});
|
||||
+test('a sleep past the stop deadline still observes an alive run after both signals', async t => {
|
||||
+ const f = setup(t); let time = 0;
|
||||
+ f.options.now = () => time;
|
||||
+ f.options.sleep = async ms => { time += ms + 1; };
|
||||
+ f.options.signal = (_r, sig) => { f.signals.push(sig); return true; };
|
||||
+ const result = await stopRun(f.args, f.options);
|
||||
+ assert.ok(time > 7000, 'the last sleep wakes past the complete stop deadline');
|
||||
+ assert.deepEqual(f.signals, ['SIGTERM', 'SIGKILL']);
|
||||
+ assert.equal(result.outcome, 'alive');
|
||||
+ assert.deepEqual(result.signals, ['SIGTERM', 'SIGKILL']);
|
||||
+});
|
||||
test('missing run and another business never borrow a pid or signal', async t => {
|
||||
const f = setup(t);
|
||||
for (const args of [{ ...f.args, run: 'missing' }, { ...f.args, business: 'other' }]) {
|
||||
@@ -1,2 +0,0 @@
|
||||
7145420aa354180689a5c84423928fc60c8123476dcfed3364c9cf4542f8c7ea packages/processing-control/src/runtimes.mjs
|
||||
7ce7a99363b987f8525f8992fd57940f4305597e870c6162fc158962f2101ff6 packages/processing-control/tests/stop-run.test.mjs
|
||||
@@ -1 +0,0 @@
|
||||
1016b85846c60dc159d49373f99ca36794a51fd9
|
||||
@@ -1,2 +0,0 @@
|
||||
packages/processing-control/src/runtimes.mjs
|
||||
packages/processing-control/tests/stop-run.test.mjs
|
||||
@@ -1 +0,0 @@
|
||||
1016b85846c60dc159d49373f99ca36794a51fd9
|
||||
@@ -1,32 +0,0 @@
|
||||
TAP version 13
|
||||
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
|
||||
not ok 1 - a sleep past the stop deadline still observes an alive run after both signals
|
||||
---
|
||||
duration_ms: 40.278473
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:64:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly equal:
|
||||
|
||||
'unknown' !== 'alive'
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 'alive'
|
||||
actual: 'unknown'
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:72:10)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 89.021437
|
||||
@@ -1,382 +0,0 @@
|
||||
TAP version 13
|
||||
# Subtest: container admission publishes its generated name and root/runtime labels before dispatch
|
||||
ok 1 - container admission publishes its generated name and root/runtime labels before dispatch
|
||||
---
|
||||
duration_ms: 9.883733
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: container sweep sends TERM then KILL only after verifying exact id and both labels
|
||||
ok 2 - container sweep sends TERM then KILL only after verifying exact id and both labels
|
||||
---
|
||||
duration_ms: 66.167157
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: foreign labels and a replaced container id stay unknown and are never signalled
|
||||
ok 3 - foreign labels and a replaced container id stay unknown and are never signalled
|
||||
---
|
||||
duration_ms: 5.105671
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: an unverified missing container remains unknown rather than claiming a pending start ended
|
||||
ok 4 - an unverified missing container remains unknown rather than claiming a pending start ended
|
||||
---
|
||||
duration_ms: 15.538468
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a removed --rm container is settled only by its trusted owner completion receipt
|
||||
ok 5 - a removed --rm container is settled only by its trusted owner completion receipt
|
||||
---
|
||||
duration_ms: 28.897791
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: after signalling a verified container, successful empty Docker listing proves --rm removed that id
|
||||
ok 6 - after signalling a verified container, successful empty Docker listing proves --rm removed that id
|
||||
---
|
||||
duration_ms: 24.215826
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt publishes before awaiting stops, resume refuses pending, and only clear permits new work
|
||||
ok 7 - halt publishes before awaiting stops, resume refuses pending, and only clear permits new work
|
||||
---
|
||||
duration_ms: 161.569582
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a new coordinator replays a persisted halt and an unresolved start keeps clear refused
|
||||
ok 8 - a new coordinator replays a persisted halt and an unresolved start keeps clear refused
|
||||
---
|
||||
duration_ms: 1102.680874
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a new halt generation never inherits a previous quiescent status before its sweep
|
||||
ok 9 - a new halt generation never inherits a previous quiescent status before its sweep
|
||||
---
|
||||
duration_ms: 4.450121
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt aborts an active local request and prevents a subsequent effect after halt/resume
|
||||
ok 10 - halt aborts an active local request and prevents a subsequent effect after halt/resume
|
||||
---
|
||||
duration_ms: 35.831508
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: processing scopes isolate roots and cancel only the registered child group
|
||||
ok 11 - processing scopes isolate roots and cancel only the registered child group
|
||||
---
|
||||
duration_ms: 59.946603
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halted scopes never invoke their body and unscoped human work has no processing context
|
||||
ok 12 - halted scopes never invoke their body and unscoped human work has no processing context
|
||||
---
|
||||
duration_ms: 3.067699
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: host git is stopped promptly by the active scope rather than waiting for its long deadline
|
||||
ok 13 - host git is stopped promptly by the active scope rather than waiting for its long deadline
|
||||
---
|
||||
duration_ms: 151.711241
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a child leader exiting does not erase an unverified surviving group receipt
|
||||
ok 14 - a child leader exiting does not erase an unverified surviving group receipt
|
||||
---
|
||||
duration_ms: 117.914283
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the actual nested read-only control mount refuses unlink, replacement and parent removal
|
||||
ok 15 - the actual nested read-only control mount refuses unlink, replacement and parent removal
|
||||
---
|
||||
duration_ms: 14.941666
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the image entrypoint refuses halted and stale generations using the shared reader
|
||||
ok 16 - the image entrypoint refuses halted and stale generations using the shared reader
|
||||
---
|
||||
duration_ms: 89.020485
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt terminates a tracked stand-in and leaves honest write-once stop evidence
|
||||
ok 17 - halt terminates a tracked stand-in and leaves honest write-once stop evidence
|
||||
---
|
||||
duration_ms: 95.55672
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a TERM-resistant workload is killed after the bounded wait
|
||||
ok 18 - a TERM-resistant workload is killed after the bounded wait
|
||||
---
|
||||
duration_ms: 146.00258
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: wrong start ticks or boot identity cannot signal a live replacement
|
||||
ok 19 - wrong start ticks or boot identity cannot signal a live replacement
|
||||
---
|
||||
duration_ms: 65.844444
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a pending start cannot be reported quiescent and a second root is never signalled
|
||||
ok 20 - a pending start cannot be reported quiescent and a second root is never signalled
|
||||
---
|
||||
duration_ms: 93.749051
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: escalation still kills the tracked group after its leader exits
|
||||
ok 21 - escalation still kills the tracked group after its leader exits
|
||||
---
|
||||
duration_ms: 192.463864
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: session TERM targets its verified runner before forcing the outer namespace
|
||||
ok 22 - session TERM targets its verified runner before forcing the outer namespace
|
||||
---
|
||||
duration_ms: 100.923976
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: new runtime and stop directories sync their containing directories
|
||||
ok 23 - new runtime and stop directories sync their containing directories
|
||||
---
|
||||
duration_ms: 42.560646
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a vanished group leader with unverified surviving members stays unknown, never quiescent
|
||||
ok 24 - a vanished group leader with unverified surviving members stays unknown, never quiescent
|
||||
---
|
||||
duration_ms: 130.848284
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: persisted member identity anchors a group after the leader exited before the sweep
|
||||
ok 25 - persisted member identity anchors a group after the leader exited before the sweep
|
||||
---
|
||||
duration_ms: 124.574935
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: an unconfirmed secondary brake does not leave a verified workload running or claim success
|
||||
ok 26 - an unconfirmed secondary brake does not leave a verified workload running or claim success
|
||||
---
|
||||
duration_ms: 75.057063
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt persists across readers and resume needs the current generation
|
||||
ok 27 - halt persists across readers and resume needs the current generation
|
||||
---
|
||||
duration_ms: 9.437871
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: first initialization is explicit; missing state after control exists fails closed and halt repairs it
|
||||
ok 28 - first initialization is explicit; missing state after control exists fails closed and halt repairs it
|
||||
---
|
||||
duration_ms: 5.039685
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: refuses invalid JSON instead of enabling work
|
||||
ok 29 - refuses invalid JSON instead of enabling work
|
||||
---
|
||||
duration_ms: 1.867281
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: refuses unknown keys instead of enabling work
|
||||
ok 30 - refuses unknown keys instead of enabling work
|
||||
---
|
||||
duration_ms: 2.65956
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: refuses bad timestamp instead of enabling work
|
||||
ok 31 - refuses bad timestamp instead of enabling work
|
||||
---
|
||||
duration_ms: 2.144084
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: refuses nonboolean state instead of enabling work
|
||||
ok 32 - refuses nonboolean state instead of enabling work
|
||||
---
|
||||
duration_ms: 2.039112
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: refuses bad generation instead of enabling work
|
||||
ok 33 - refuses bad generation instead of enabling work
|
||||
---
|
||||
duration_ms: 2.085806
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: refuses loose mode instead of enabling work
|
||||
ok 34 - refuses loose mode instead of enabling work
|
||||
---
|
||||
duration_ms: 2.34307
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: refuses oversized file instead of enabling work
|
||||
ok 35 - refuses oversized file instead of enabling work
|
||||
---
|
||||
duration_ms: 2.916273
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a symlink leaf never follows its target; human halt replaces only the leaf
|
||||
ok 36 - a symlink leaf never follows its target; human halt replaces only the leaf
|
||||
---
|
||||
duration_ms: 4.530858
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: unsafe parent and interrupted initialization never get repaired as off
|
||||
ok 37 - unsafe parent and interrupted initialization never get repaired as off
|
||||
---
|
||||
duration_ms: 0.385291
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: repeated halt is a no-op and a different root keeps running
|
||||
ok 38 - repeated halt is a no-op and a different root keeps running
|
||||
---
|
||||
duration_ms: 7.00566
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: transition rejects nonstring identity before publishing state
|
||||
ok 39 - transition rejects nonstring identity before publishing state
|
||||
---
|
||||
duration_ms: 3.121939
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bootstrap syncs the containing root as well as the state and control directory
|
||||
ok 40 - bootstrap syncs the containing root as well as the state and control directory
|
||||
---
|
||||
duration_ms: 36.144072
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: state ownership and bounded read guards refuse before trusting a document
|
||||
ok 41 - state ownership and bounded read guards refuse before trusting a document
|
||||
---
|
||||
duration_ms: 101.892703
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: canonical root aliases address one switch and never create independent control state
|
||||
ok 42 - canonical root aliases address one switch and never create independent control state
|
||||
---
|
||||
duration_ms: 4.179236
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
|
||||
ok 43 - unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
|
||||
---
|
||||
duration_ms: 38.851257
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: same run joins the identical promise and completed repeats preserve owner and send no signals
|
||||
ok 44 - same run joins the identical promise and completed repeats preserve owner and send no signals
|
||||
---
|
||||
duration_ms: 27.976805
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: identity changed before TERM or between TERM and KILL refuses the changed signal
|
||||
ok 45 - identity changed before TERM or between TERM and KILL refuses the changed signal
|
||||
---
|
||||
duration_ms: 15.642865
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: failed signal delivery observes alive, records no invented signals and never restarts escalation
|
||||
ok 46 - failed signal delivery observes alive, records no invented signals and never restarts escalation
|
||||
---
|
||||
duration_ms: 32.602087
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
|
||||
ok 47 - a sleep past the stop deadline still observes an alive run after both signals
|
||||
---
|
||||
duration_ms: 31.045314
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: missing run and another business never borrow a pid or signal
|
||||
ok 48 - missing run and another business never borrow a pid or signal
|
||||
---
|
||||
duration_ms: 10.113655
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt joins an in-progress fire without another TERM or deadline and links its first receipt
|
||||
ok 49 - halt joins an in-progress fire without another TERM or deadline and links its first receipt
|
||||
---
|
||||
duration_ms: 30.224351
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a separate host joins durable first ownership without duplicate signals
|
||||
ok 50 - a separate host joins durable first ownership without duplicate signals
|
||||
---
|
||||
duration_ms: 99.128156
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: corrupt and symlinked final receipts refuse as unknown rather than signalling again
|
||||
ok 51 - corrupt and symlinked final receipts refuse as unknown rather than signalling again
|
||||
---
|
||||
duration_ms: 22.649789
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a later exit gets separate halt evidence while the original alive result stays immutable
|
||||
ok 52 - a later exit gets separate halt evidence while the original alive result stays immutable
|
||||
---
|
||||
duration_ms: 55.85559
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: root aliases join but a different root never shares the run stop
|
||||
ok 53 - root aliases join but a different root never shares the run stop
|
||||
---
|
||||
duration_ms: 45.6493
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a changed runtime record cannot replace the original stop identity after TERM
|
||||
ok 54 - a changed runtime record cannot replace the original stop identity after TERM
|
||||
---
|
||||
duration_ms: 8.507771
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: identity is rechecked at actual signal delivery, not just at the loop observation
|
||||
ok 55 - identity is rechecked at actual signal delivery, not just at the loop observation
|
||||
---
|
||||
duration_ms: 6.500324
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: task halt keeps the true Docker exit status, writes kill-switch once and leaves snapshots unchanged
|
||||
ok 56 - task halt keeps the true Docker exit status, writes kill-switch once and leaves snapshots unchanged
|
||||
---
|
||||
duration_ms: 201.141886
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt refuses direct task launch before Docker or a run record; clear allows a fresh normal result
|
||||
ok 57 - halt refuses direct task launch before Docker or a run record; clear allows a fresh normal result
|
||||
---
|
||||
duration_ms: 167.652567
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt refuses hello.sh before Docker, credentials or release alignment
|
||||
ok 58 - halt refuses hello.sh before Docker, credentials or release alignment
|
||||
---
|
||||
duration_ms: 122.654442
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt refuses verify.sh before Docker, credentials or release alignment
|
||||
ok 59 - halt refuses verify.sh before Docker, credentials or release alignment
|
||||
---
|
||||
duration_ms: 122.064969
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt refuses agent.sh before Docker, credentials or release alignment
|
||||
ok 60 - halt refuses agent.sh before Docker, credentials or release alignment
|
||||
---
|
||||
duration_ms: 120.632718
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a nonzero Docker client exit retains its unresolved runtime receipt (task)
|
||||
ok 61 - a nonzero Docker client exit retains its unresolved runtime receipt (task)
|
||||
---
|
||||
duration_ms: 93.3117
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a nonzero Docker client exit retains its unresolved runtime receipt (container-cli)
|
||||
ok 62 - a nonzero Docker client exit retains its unresolved runtime receipt (container-cli)
|
||||
---
|
||||
duration_ms: 97.234461
|
||||
type: 'test'
|
||||
...
|
||||
1..62
|
||||
# tests 62
|
||||
# suites 0
|
||||
# pass 62
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 1323.026518
|
||||
@@ -1,32 +0,0 @@
|
||||
TAP version 13
|
||||
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
|
||||
not ok 1 - a sleep past the stop deadline still observes an alive run after both signals
|
||||
---
|
||||
duration_ms: 53.588908
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:64:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly equal:
|
||||
|
||||
'unknown' !== 'alive'
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 'alive'
|
||||
actual: 'unknown'
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r279/packages/processing-control/tests/stop-run.test.mjs:72:10)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 100.649138
|
||||
@@ -1,88 +0,0 @@
|
||||
TAP version 13
|
||||
# Subtest: unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
|
||||
ok 1 - unbound run stops by verified registry identity, fixed 5s grace and 2s observe policy
|
||||
---
|
||||
duration_ms: 36.122553
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: same run joins the identical promise and completed repeats preserve owner and send no signals
|
||||
ok 2 - same run joins the identical promise and completed repeats preserve owner and send no signals
|
||||
---
|
||||
duration_ms: 24.270208
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: identity changed before TERM or between TERM and KILL refuses the changed signal
|
||||
ok 3 - identity changed before TERM or between TERM and KILL refuses the changed signal
|
||||
---
|
||||
duration_ms: 13.289288
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: failed signal delivery observes alive, records no invented signals and never restarts escalation
|
||||
ok 4 - failed signal delivery observes alive, records no invented signals and never restarts escalation
|
||||
---
|
||||
duration_ms: 27.743499
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a sleep past the stop deadline still observes an alive run after both signals
|
||||
ok 5 - a sleep past the stop deadline still observes an alive run after both signals
|
||||
---
|
||||
duration_ms: 26.252224
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: missing run and another business never borrow a pid or signal
|
||||
ok 6 - missing run and another business never borrow a pid or signal
|
||||
---
|
||||
duration_ms: 9.103416
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt joins an in-progress fire without another TERM or deadline and links its first receipt
|
||||
ok 7 - halt joins an in-progress fire without another TERM or deadline and links its first receipt
|
||||
---
|
||||
duration_ms: 25.918671
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a separate host joins durable first ownership without duplicate signals
|
||||
ok 8 - a separate host joins durable first ownership without duplicate signals
|
||||
---
|
||||
duration_ms: 99.095272
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: corrupt and symlinked final receipts refuse as unknown rather than signalling again
|
||||
ok 9 - corrupt and symlinked final receipts refuse as unknown rather than signalling again
|
||||
---
|
||||
duration_ms: 23.700708
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a later exit gets separate halt evidence while the original alive result stays immutable
|
||||
ok 10 - a later exit gets separate halt evidence while the original alive result stays immutable
|
||||
---
|
||||
duration_ms: 55.938599
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: root aliases join but a different root never shares the run stop
|
||||
ok 11 - root aliases join but a different root never shares the run stop
|
||||
---
|
||||
duration_ms: 42.386018
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a changed runtime record cannot replace the original stop identity after TERM
|
||||
ok 12 - a changed runtime record cannot replace the original stop identity after TERM
|
||||
---
|
||||
duration_ms: 7.168469
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: identity is rechecked at actual signal delivery, not just at the loop observation
|
||||
ok 13 - identity is rechecked at actual signal delivery, not just at the loop observation
|
||||
---
|
||||
duration_ms: 6.715559
|
||||
type: 'test'
|
||||
...
|
||||
1..13
|
||||
# tests 13
|
||||
# suites 0
|
||||
# pass 13
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 445.396108
|
||||
@@ -1 +0,0 @@
|
||||
{"id":"M1","file":"packages/processing-control/src/runtimes.mjs","old":" for (;;) {\n try { record = runtime(root, record.id); }","new":" while (now() <= deadline) {\n try { record = runtime(root, record.id); }","test":"packages/processing-control/tests/stop-run.test.mjs","pattern":"a sleep past the stop deadline still observes an alive run after both signals","required":"named assertion kill, never syntax or timeout"}
|
||||
@@ -1,20 +0,0 @@
|
||||
6d33d35302499d66036145976f8f777a4c438c6b3f5c7b613fcfa5651c49faab BUILD.md
|
||||
8521af77deed1d3b2c722f30b776159eb373df407a2a698ee116ef4174125f01 base.txt
|
||||
74efdcea480e6c089f586b7330591b66164a3aa338fb1e1a60b74f6b7b9f3b0a build.patch
|
||||
312ed511d8f1cff826ee2fd547de8368923130c7bfe06236d8e29d8655b80aaa candidate-manifest.sha256
|
||||
960c366a732e4dc20133bf490d842cc42a345fefad1432ca6eba6dbd0058f337 candidate.txt
|
||||
3099049a5acd051207fb5631b7bb6362dd84c246191009a44eca1ba696d27d73 changed-paths.txt
|
||||
960c366a732e4dc20133bf490d842cc42a345fefad1432ca6eba6dbd0058f337 commit.txt
|
||||
98a4da4e1c62f7e4ed5f50adc16cf99462a5e012e9736e658441e8eda3db894c logs/M1.log
|
||||
7f878c13be43640f501b72d36e8d9e0c8d844bc5451f969154cd75d8fab0ee42 logs/processing-control-whole.log
|
||||
62f83e511ad860d0f7da42d87e063eb691a95f29a0806b88784912374d2e7c47 logs/regression-red.log
|
||||
4de8028674472cc75f61dc78cac624c336c8db785580a189e0b705584f80d636 logs/stop-run-whole.log
|
||||
7669ce9f04189f659dbc23dae6b635d5ffac354e03d00fbae10e6fadc7e98791 mutant-plan.json
|
||||
84f1080a9689374f7000197119074e53b459641cd709745f95eb0ebe37b5ef2f reader-search.txt
|
||||
dd6b2ea95782154e1c9839bae7a6d76a60566762a2374901ae64b22f9a7f54f7 results.json
|
||||
b9408890f30a72451d9171d8ceec40eb4f4f671811b30ba74f50f178e0eb8350 run-step.py
|
||||
b8140e3919a5d342eb92c1cd34b13bbb62d6419c6a5a01a9cb4870e9a94c64ed scan-result.json
|
||||
9e5c2e8cd2b59ac4559b0d67348a2a98e59b44a71eec7359018ea7a84d7a4082 skips.txt
|
||||
43d77fe5bffa916f444a77bacda5b4ddbe78035b6ca7631378056c3158aa3253 source-scope-check.json
|
||||
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 sweep.json
|
||||
74b3eae937fdcc92cf2b0dd15fb67f4f6addd4a046f74e934febc565e4b5d2ed tmpdir.txt
|
||||
@@ -1,12 +0,0 @@
|
||||
packages/processing-control/tests/runtimes.test.mjs:9:const api = await import('../src/runtimes.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; });
|
||||
packages/processing-control/tests/runtimes.test.mjs:139: new URL('../src/runtimes.mjs', import.meta.url).href, new URL('../src/state.mjs', import.meta.url).href, root], { encoding: 'utf8' });
|
||||
packages/processing-control/tests/effects.test.mjs:60: const { listRuntimes } = await import('../src/runtimes.mjs');
|
||||
packages/processing-control/tests/coordinator.test.mjs:9:import { registerRuntime, updateRuntime, processIdentity, unregisterRuntime } from '../src/runtimes.mjs';
|
||||
packages/processing-control/tests/containers.test.mjs:62: const { updateRuntime } = await import('../src/runtimes.mjs');
|
||||
packages/processing-control/tests/task.test.mjs:12: const runtimes = new URL('../src/runtimes.mjs', import.meta.url).href;
|
||||
packages/processing-control/tests/stop-run.test.mjs:7:import { registerRuntime, updateRuntime, runtime, stopRun, sweepRuntimes, readStops } from '../src/runtimes.mjs';
|
||||
packages/processing-control/tests/stop-run.test.mjs:104: const f = setup(t), source = new URL('../src/runtimes.mjs', import.meta.url).href;
|
||||
packages/processing-control/tests/stop-run.test.mjs:148: const { processIdentity } = await import('../src/runtimes.mjs');
|
||||
packages/bus/tests/processing-runtime.test.mjs:10:import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
|
||||
packages/discord/tests/processing-stop.test.mjs:35: const { registerRuntime, updateRuntime } = await import('../../processing-control/src/runtimes.mjs');
|
||||
packages/discord/tests/processing.test.mjs:68: const { listRuntimes, sweepRuntimes, processIdentity } = await import('../../processing-control/src/runtimes.mjs');
|
||||
@@ -1,62 +0,0 @@
|
||||
[
|
||||
{
|
||||
"step": "regression-red",
|
||||
"start": "2026-10-22T05:56:03Z",
|
||||
"end": "2026-10-22T05:56:03Z",
|
||||
"exit": 1,
|
||||
"counts": {
|
||||
"tests": 1,
|
||||
"pass": 0,
|
||||
"fail": 1,
|
||||
"skipped": 0
|
||||
},
|
||||
"failing": [
|
||||
"a sleep past the stop deadline still observes an alive run after both signals"
|
||||
],
|
||||
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/regression-red.log"
|
||||
},
|
||||
{
|
||||
"step": "stop-run-whole",
|
||||
"start": "2026-10-22T05:57:47Z",
|
||||
"end": "2026-10-22T05:57:47Z",
|
||||
"exit": 0,
|
||||
"counts": {
|
||||
"tests": 13,
|
||||
"pass": 13,
|
||||
"fail": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"failing": [],
|
||||
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/stop-run-whole.log"
|
||||
},
|
||||
{
|
||||
"step": "processing-control-whole",
|
||||
"start": "2026-10-22T05:57:47Z",
|
||||
"end": "2026-10-22T05:57:49Z",
|
||||
"exit": 0,
|
||||
"counts": {
|
||||
"tests": 62,
|
||||
"pass": 62,
|
||||
"fail": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"failing": [],
|
||||
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/processing-control-whole.log"
|
||||
},
|
||||
{
|
||||
"step": "M1",
|
||||
"start": "2026-10-22T05:58:03Z",
|
||||
"end": "2026-10-22T05:58:03Z",
|
||||
"exit": 1,
|
||||
"counts": {
|
||||
"tests": 1,
|
||||
"pass": 0,
|
||||
"fail": 1,
|
||||
"skipped": 0
|
||||
},
|
||||
"failing": [
|
||||
"a sleep past the stop deadline still observes an alive run after both signals"
|
||||
],
|
||||
"log": "/mnt/storage/scratch/rocko-r279/agents/rocko/work/queue-279/logs/M1.log"
|
||||
}
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
from pathlib import Path
|
||||
import subprocess,signal,os,json,time,sys,re
|
||||
root=Path('/mnt/storage/scratch/rocko-r279');p=root/'agents/rocko/work/queue-279';logs=p/'logs';logs.mkdir(exist_ok=True);name=sys.argv[1];command=sys.argv[2:]
|
||||
start=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime());log=logs/(name+'.log')
|
||||
with log.open('w') as out:
|
||||
child=subprocess.Popen(command,cwd=root,env=os.environ,stdout=out,stderr=subprocess.STDOUT,start_new_session=True)
|
||||
try:rc=child.wait(timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid,signal.SIGKILL);child.wait();rc=124
|
||||
text=log.read_text();counts={k:int(v) for k,v in re.findall(r'^# (tests|pass|fail|skipped) (\d+)\s*$',text,re.M)};failing=re.findall(r'^not ok \d+ - (.*)$',text,re.M)
|
||||
row=dict(step=name,start=start,end=time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),exit=rc,counts=counts,failing=failing,log=str(log));result=p/'results.json';rows=json.loads(result.read_text()) if result.exists() else [];rows.append(row);result.write_text(json.dumps(rows,indent=2)+'\n');print(json.dumps(row));print(text[-3200:])
|
||||
if name in ['regression-red','M1']:
|
||||
ok=rc==1 and failing==['a sleep past the stop deadline still observes an alive run after both signals'] and "actual: 'unknown'" in text and "expected: 'alive'" in text
|
||||
print('EXPECTED_UNKNOWN_VS_ALIVE',ok);raise SystemExit(0 if ok else 1)
|
||||
raise SystemExit(rc)
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"secret_matches": [],
|
||||
"binary_files": []
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
None. All four TAP records report skipped 0.
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"base": "f40f2e770e0d8be394f1ef53b404045fd6a49c0e",
|
||||
"only_product_change": "while (now() <= deadline) -> for (;;)",
|
||||
"other_product_bytes_unchanged": true,
|
||||
"trailing_unknown_fallback": "unchanged; unreachable"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1 +0,0 @@
|
||||
/mnt/storage/scratch/tmp/gwxq
|
||||
@@ -1,50 +0,0 @@
|
||||
// Sage, 2026-10-22: row 191 (kill switch) landed, comment on P19 414 (Q174 landing comment). Sage's own key (bot user 13); the sync key reads back. Comments only: no task edits.
|
||||
// Checks each task's title before posting. Prints no secret.
|
||||
// Usage: node comment-r191-landed.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 414, title: /^MVP platform: the Given list for Cases 1 and 2/, body: [
|
||||
"Row 191 (kill switch, stopRun) landed on refactor as 0fa7bf421 on 10-22 (recorded 04:49Z), a fast-forward on 86f95bb27. An owner can halt an instance: agent verbs that start or continue work refuse with a fixed code, reads and the human's own verbs (including the clear) still work, and running runs are stopped. Gitea #1689 is closed (comment 30360). Queue row 191 is done (rev 3120).",
|
||||
"Rocko built it, and Dewey approved the manifest (88 paths, digest 0bcbf337, comment 30359, rev 3118). Gate on 0fa7bf421: 35 touched files whole, every package suite, the ten scripts/test-*.sh, and 77/77 mutants checked. Sage ran the runs suite 43/0/0 for Dewey's N7. Follow-ups are on Vikunja 502 (real clock, exact HTTP refusals, runtime close in finally, runner cause text).",
|
||||
"It isn't live yet. Live is still 86f95bb27. The switch reaches dragon-lin at the next re-pin, through the window plan and Mos.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -1,61 +0,0 @@
|
||||
// Sage, 2026-10-22: files the 10-27 owner thread-claim row (mos-1262) as a P19 task. No relation, labels, assignees,
|
||||
// due dates or bucket moves. Sage's own key (bot user 13); the sync key reads back. Refuses if the title exists.
|
||||
// Prints no secret.
|
||||
// Usage: node file-thread-claims.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON ROW ISSUE
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT, ROW, ISSUE] = process.argv.slice(2);
|
||||
if (!/^[0-9]+$/.test(ROW ?? "") || !/^[0-9]+$/.test(ISSUE ?? "")) throw new Error("ROW and ISSUE must be numbers");
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
|
||||
const TASKS = [
|
||||
{
|
||||
title: `The owner sees a thread agent's claims: agents lists held thread claims (row ${ROW}, #${ISSUE}, 10-27)`,
|
||||
description: ps(SIG, [
|
||||
"Filed by Mos's ruling mos-1262 as a 10-27 row, not post-v1. Row 205's message filter found it (morgana-0072, Sage ruling sage-2430).",
|
||||
"A thread run's role.claim writes thread_claims only (broker.mjs:2682), and the owner's agents verb reads role_claims only (:3309). So the owner can't see a thread agent's claim, which PRD :1083-1084 and the FED R6 user sentence promise. No user thread goes live before this row lands, the same bind as the 193b1a filter. Q176 Case 1 needs it.",
|
||||
`Queue row ${ROW}, after row 205, owner Morgana, reviewer Duckworth, gate Sage. Brief: docs/plans/2026-10-22_owner-thread-claims.md. If it can't make 10-27, that's a scope question for Jason, and it goes to Mos first.`,
|
||||
]),
|
||||
},
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
async function existing() {
|
||||
const out = [];
|
||||
for (let page = 1; page < 20; page++) {
|
||||
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
|
||||
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
|
||||
out.push(...list(r.json));
|
||||
if (list(r.json).length < 50) return out;
|
||||
}
|
||||
throw new Error("more than 19 pages");
|
||||
}
|
||||
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
const have = await existing();
|
||||
for (const T of TASKS) if (have.some((t) => t.title === T.title)) throw new Error("title exists; refusing");
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
rec.created = [];
|
||||
for (const T of TASKS) {
|
||||
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
|
||||
const c = { status: r.status, id: r.json?.id ?? null };
|
||||
rec.created.push(c);
|
||||
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) { rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
|
||||
Object.assign(c, { readBack: back.status, done: back.json?.done });
|
||||
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push(`read-back differs for ${r.json.id}`);
|
||||
}
|
||||
save();
|
||||
console.log(JSON.stringify(rec));
|
||||
@@ -1,69 +0,0 @@
|
||||
// Sage, 2026-10-22: re-pin window 7247d80c7 to 86f95bb27 done (mos-1253, mos-1263). Comments on P19 386 and 435 (Mos posted 412 himself, mos-1266),
|
||||
// text from Darkwing (darkwing-0160). No task is closed. Sage's own key (bot user 13); the sync key reads back.
|
||||
// Checks each task's title before anything is posted. Prints no secret.
|
||||
// Usage: node post-w12-86f95bb27.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13). Text from Darkwing, the window operator.";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const WIN = "agents/darkwing/work/window-repin-86f95bb27/";
|
||||
const POSTS = [
|
||||
{ id: 386, title: /^Q173 test system/, close: false, body: [
|
||||
"Re-pin 7247d80c7 to 86f95bb27 done on dragon-lin, 2026-10-22, with Darkwing operating. Activation 20261022T040256Z-86f95bb27f6c, pointer at 04:03:00Z UTC, schema 3j with no migration. P1 to P6, B0, W0 to W5 and W9 to W11 passed. Nothing was rolled back, and there's no counted failure (mos-1260). W5 backup SHA256SUMS 45b18429.",
|
||||
"S1 to S2 (03:55:33Z to 04:24:42Z UTC): schema 3j at both, events 1798 to 1817 with no grant.* kind, and hosts and host_grants at 0 rows.",
|
||||
"W12 (w12c.sh, mos-1263) W12_PASS at 04:26:43Z. Backup w12-20261022T042555Z first (SHA256SUMS 210ce775). The chief relayed the pm read and launched pm r2cf8536da2c3, which answered PRD 1.14 with 85 ids. Row 66 on chief rd0b8fdb4d62d: turn 1 drew 4 banned words and the [unslop: note, turn 2 got the notice, and counts went from 1/0/0 to checked 6, flagged 1, notices 1, broken null.",
|
||||
`STATE: ${WIN}STATE.md`,
|
||||
] },
|
||||
{ id: 435, title: /^Console inbox: Approve\/Deny/, close: false, body: [
|
||||
"Re-pin to 86f95bb27 (activation 20261022T040256Z-86f95bb27f6c). Row 202's access verbs are live, and nobody created a grant. The bus journal since W5 has no grant.* event, and hosts and host_grants hold 0 rows.",
|
||||
`STATE: ${WIN}STATE.md`,
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], closes: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
const before = {};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
if (P.close && t.json.done) throw new Error(`task ${P.id} is already done; nothing posted`);
|
||||
before[P.id] = { title: t.json.title, description: t.json.description };
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) { rec.problems.push(`comment ${P.id} read-back: ${back.status}`); save(); continue; }
|
||||
if (P.close) {
|
||||
const full = await api("GET", `/api/v1/tasks/${P.id}`, undefined, key);
|
||||
if (full.status !== 200 || full.json?.id !== P.id) { rec.problems.push(`v1 get ${P.id}: ${full.status}`); save(); continue; }
|
||||
const u = await api("POST", `/api/v1/tasks/${P.id}`, { ...full.json, done: true }, key);
|
||||
const b2 = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
const x = { task: P.id, status: u.status, readBack: b2.status, done: b2.json?.done, titleSame: b2.json?.title === before[P.id].title, descriptionSame: b2.json?.description === before[P.id].description, inverse: `mark undone: POST /api/v1/tasks/${P.id} done false (sage key)` };
|
||||
rec.closes.push(x);
|
||||
if (u.status >= 300 || b2.status !== 200 || b2.json?.done !== true || !x.titleSame || !x.descriptionSame) rec.problems.push(`close ${P.id}: update or read-back differs`);
|
||||
}
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -1,118 +0,0 @@
|
||||
# Re-pin 7247d80c7 to 86f95bb27 (rows 251, 244, 254, 202, 273, PRD 1.11-1.14), no migration
|
||||
|
||||
Sage, 2026-10-22, for mos-1253 under DECISIONS Q173 and mos-1246. The step layout is
|
||||
`repin-1024-draft1.md`, with the copy changed and `--migrate` dropped. Where a step is unchanged I name it
|
||||
and say only what differs. Mos checks this file's sha256 before P0, and any change after that means a new
|
||||
check (mos-0849/0850). This file stays uncommitted until row 191's window ends, because refactor doesn't
|
||||
move while a rebased candidate's gate runs.
|
||||
|
||||
## Who, and what nobody does
|
||||
|
||||
- Darkwing operates if free. Otherwise I operate. Mos verifies each step's output before the next one,
|
||||
runs P3, and gives the W5 go. Row 191's full window keeps first claim on the slot (mos-1225). Steps that
|
||||
start no test and no stub run alongside it: P1, W0, P4, P5, P6, B0, W1-W4. P2 waits for a gap or CLEAR,
|
||||
because it starts node stubs that a busy check counts.
|
||||
- Jason has no step. Nothing is pruned. Nobody acts as Jason: no `--by jason`, no human shell or tmux
|
||||
pane, every bus read and write carries `--tester`, and `bus.tester` stays as it is. If it isn't `true`,
|
||||
stop at P1.
|
||||
- No pi (#383). No Save in the human Console, no `mosaic name`.
|
||||
- Outside reach doesn't change. No `--send`, no `--sso`, no proof pair. Both go on together in the phase 2
|
||||
window, which still waits for Jason's start (Vikunja 412). The tunnel forwards 7332 only.
|
||||
- dragon-lin only. .53 and .54 take nothing (mos-1198, mos-1167).
|
||||
|
||||
## Names
|
||||
|
||||
- OLD, live: `7247d80c7af7ffa70fc61557406bf491cdf3bfc7`, schema 3j, pointer `pm: true`, activation
|
||||
`20261021T215926Z-7247d80c7af7`. Console flags at W5 last time: `--port 7332 --public-host
|
||||
mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-socket`. P1
|
||||
re-reads them from `status`. I don't copy them from this file.
|
||||
- NEW: `86f95bb27f6cb237bf9fa9ba53320bace6c4de0b`, 59 commits past OLD (`git rev-list --count`). The last
|
||||
code landing in it is 0bbf0f6d1 (row 273). The two commits after that are docs and queue files.
|
||||
- `WIN=agents/darkwing/work/window-repin-86f95bb27/` if Darkwing operates, else
|
||||
`agents/sage/work/window-repin-86f95bb27/`. The scripts adapt last window's, in
|
||||
`agents/sage/work/window-repin-7247d80c7/`, with NEW and OLD changed and every `--migrate` removed.
|
||||
|
||||
## What NEW carries over OLD
|
||||
|
||||
Non-test paths changed from OLD to NEW (`git diff --name-only`, tests and READMEs left out):
|
||||
|
||||
| Row | Commit | Paths | Live effect to check |
|
||||
|---|---|---|---|
|
||||
| 251, 244 | d37210288, e69260a3c | scripts/test-release.sh, test-task.sh, scripts/testing/* | Gate tooling. Not in the live path. |
|
||||
| PRD 1.11-1.14 | ea0b2a16d..db1e7732a | packages/tasks/requirements.json | W9: the pm's requirements read gives 1.14 with 85 ids. |
|
||||
| 254 | 331597709 | packages/bus/src/human-cli.mjs (library.sync client timeout 130 s), packages/webui/src/bus.mjs (six `library-*` STATUS codes) | No library entry is live, so neither path runs. |
|
||||
| 202 | ddfb29af7 | packages/cli/src/access.mjs, cli.mjs, scripts/mosaic | `mosaic access list/grant/revoke`. Nobody runs them in this window (Q188, mos-1141). |
|
||||
| 273 | 7991662dc..0bbf0f6d1 | scripts/stack-release.sh | `--send` and `--no-sso`. With neither given, the plan keeps the pointer's Console flags. W3 checks that. |
|
||||
|
||||
Nothing else in packages/bus/src or packages/webui/src changed (`git diff --quiet` with those two files left
|
||||
out). The four service scripts and scripts/stack-copy.sh are unchanged from OLD (`git diff --quiet 7247d80c7
|
||||
86f95bb27 --` on them). package-lock.json is unchanged.
|
||||
|
||||
## Schema and backups
|
||||
|
||||
- `SCHEMA_VERSION` is 3j at both commits (`packages/bus/src/store.mjs:17`). No migration step and no
|
||||
schema.sql change in the range. W3's plan must show no migration line. If it asks for `--migrate`, stop.
|
||||
- B0 is the backup taken first. It's an online copy of the live store through sqlite's backup API, read only
|
||||
on the source, as in last window's W12. It is laid out the way `rehearse --backup PATH` reads it
|
||||
(`stack-release.sh:2232-2235`): `$DATA/backups/b0-<STAMP>/bus/bus.sqlite`, `SHA256SUMS` written from
|
||||
inside `bus/`, `SCHEMA` reading 3j, and `CHECK` holding the integrity_check result, the event count and
|
||||
maxSeq. The operator sends Mos the path and the SHA256SUMS sha256 before P3.
|
||||
- W5 takes the stopped-store backup every activate takes, plus the rollback set. That one is the rollback
|
||||
point.
|
||||
- The grant look-alike count from draft 1 is dropped. It guarded the 3i-3j migration, and there is none.
|
||||
- Rollback is a pointer flip. OLD and NEW share 3j, so `rollback --plan` then the real run, with no
|
||||
`--restore`. Any restore goes to Mos and ends the window (mos-1226, mos-1253 item 5).
|
||||
|
||||
## Before the window (nothing stopped)
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| P0 | Mos | The sha check of NEW and of this file. |
|
||||
| P1 | operator | `status`: pointer, activation, 3j, Console flags, unit flags match. Business file sha256 and `business-check.mjs` LOAD_OK for OLD now and for NEW after W0. `ls -ld $DATA/console` is 0700 and `tester.sock` is 0600 (row 196 made them at the last W5). `bus.tester` is `true`. |
|
||||
| W0 | operator | `scripts/stack-copy.sh copy $NEW`. The copy has `tools/unslop-hook` and `packages/library/bin/askpass` with NEW's mode. |
|
||||
| P2 | operator, in a gap | The two stub runs from last window's p2.sh against NEW's copy, then render both units from REPO's service scripts and diff them against the live units. The only difference allowed is the copy path. |
|
||||
| B0 | operator | The backup above. Path and SHA256SUMS sha256 to Mos. |
|
||||
| P3 | Mos | `stack-release.sh rehearse $NEW mosaic-stack --backup <B0 path> --port <scratch>`, no `--migrate`. It must show no migration, the interpreter preflight passing, one receipt line, no `library:` line, and one pm reply. Mos also reads healthz, `/api/config` and the two tester-http reads on NEW's Console with the pointer's flags, as in mos-1107. |
|
||||
| P4 | operator | The tunnel read. |
|
||||
| P5 | operator | The four service scripts held at NEW, and empty porcelain on them. |
|
||||
| P6 | operator | The 7332 matrix (a)-(g) on OLD as `$WIN/matrix-old.txt`, plus the two tester-http reads, for W10. |
|
||||
|
||||
## The window
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| W0b | operator | The business file copy. |
|
||||
| W1 | operator | Idle check, start stamp from `date -u`, S1 with `$SNAP --held`. Open pm tasks: the pm finishes or parks them, or the operator records which piece crosses the window. |
|
||||
| W2 | operator | `status`: pointer on OLD, `pm: true`, 3j, unit flags match. |
|
||||
| W3 | operator | `stack-release.sh activate $NEW mosaic-stack --pm --tester-socket --plan`. It must show OLD to NEW, no migration, the backup line, and a Console line with OLD's flags exactly: `--tester-socket`, no `--send`, no `--sso`, no proof pair, no drop line. Any other difference stops here. |
|
||||
| W4 | operator | The unit reads, as last window. W3 and W4 go to Mos for the W5 go. |
|
||||
| W5 | operator, on Mos's go | The same command without `--plan`. Exit 0, `result ok`, every gate check passing, `pm_launched` included. Record the activation id, backup path, STAMP and the backup SHA256SUMS sha256. Then `status`. |
|
||||
| W9 | operator, after Mos verifies W5 | Last window's W9 list: no `Sign-in:` line in the webui journal since the W5 stamp (with `UTC`), `/proc/<Console pid>/cwd` names NEW's copy, `tester.sock` 0600 in a 0700 `console/`, one new `peercred-starts.jsonl` line, no `library:` line in the bus journal. The pm's requirements read gives 1.14 with 85 ids. If the pm isn't relaunched by the restart (mos-1230), the read moves to W12, as last time. |
|
||||
| W10 | operator | The matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt` apart from named lines. The two tester-http reads match P6's. No listener on 7333 (`ss -ltn`). P4 again. A Console POST on the main port with no signed-in session is refused (the mos-1195 baseline). |
|
||||
| W11 | operator | STATE report in `$WIN/STATE.md`, posted on P19 386, 412 and 435, and on the Gitea issues for 202 (#1700), 254 (#1759) and 273 (#1778). |
|
||||
| W12 | operator, after W11 | Row 66's live check as last time: backup first, one Claude Code managed session with a known tell, the RESULT note, the next-turn notice and the `unslop.jsonl` counts. Pi isn't run (#383). |
|
||||
|
||||
After W12 I write draft 3 of the phase 2 plan naming 86f95bb27. That window still waits for Jason.
|
||||
|
||||
## Steps skipped, and why
|
||||
|
||||
Mos allowed skipping a step that only repeats a test row 273's or row 202's gate already ran on the same
|
||||
code. The blob ids at the gated commit and at NEW are equal for every one of those files (`git rev-parse`):
|
||||
scripts/stack-release.sh `7c7676c71` at 0bbf0f6d1 and NEW, and access.mjs `a0a7c9364`, cli.mjs `b14b8fd79`,
|
||||
scripts/mosaic `91c79ab4b` and human-cli.mjs `27b67fbf7` at ddfb29af7 and NEW.
|
||||
|
||||
- No separate run of the `--send`/`--no-sso` walk or the rollback flag carry. Darkwing's gate on 0bbf0f6d1
|
||||
ran stack-release.test.mjs 218/218 (T53, T57, T58 c0/c1) and test-release.sh rc 0. W3's live plan line is
|
||||
the only check left.
|
||||
- No live `mosaic access` run. Row 202's gate ran access.test.mjs on ddfb29af7. A live grant would also
|
||||
break Q188.
|
||||
- The grant look-alike count, for the reason under Schema. It's a scope drop, not a gate repeat.
|
||||
|
||||
Nothing else is skipped. P2 and P3 aren't gate repeats: they check NEW's copy as built on dragon-lin.
|
||||
|
||||
## Stop conditions and rollback
|
||||
|
||||
As in draft 1. The first failure stops the step and goes to Mos. A second failure, or any restore, ends
|
||||
the window (mos-0823, mos-1253 item 5). Before W5 there's nothing to undo. After W5, rollback is
|
||||
`rollback --plan` then the real run, which flips the pointer to OLD with the same Console flags. Keep every
|
||||
copy. Prune nothing.
|
||||
@@ -38,8 +38,6 @@ services:
|
||||
volumes:
|
||||
# Configured runtime state root (from config.json dataRoot).
|
||||
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}:/var/lib/mosaic
|
||||
# The whole control directory is protected against unlink and rename-over replacement.
|
||||
- ${MOSAIC_DATA_ROOT:?MOSAIC_DATA_ROOT must be set by scripts/load_config (run via scripts/*.sh)}/control:/var/lib/mosaic/control:ro
|
||||
# Runtime credential only: pi auth file mounted READ-ONLY.
|
||||
# Never copied into the image.
|
||||
- ${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}:/home/node/.pi/agent/auth.json:ro
|
||||
|
||||
@@ -1455,42 +1455,4 @@ 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-21 23:39:21 UTC | Rocko (d99326cc) | row 191 inspection handoff, sage-2242 | authorized local snapshot d52b4db1c on new rocko/row191-inspection-20261021; 78 blobs match a4f780c2; no push credential, no push or tests; Sage/Honker messaged directly; per-run API seam and mutants/gate pending.
|
||||
2026-10-22 00:04:05 UTC | Rocko (d99326cc) | row 191 stopRun focused stage and S6 handoff | 9 files final 62/62; two bus failures diagnosed/fixed and logged, own failed fixture group cleaned; no runners remain, slot returned. Export in f7bf7dfec, rebased on new branch to 7aa18b0f6 (dcd8d4dda), 79 product blobs identical. Honker/Sage handed the sha directly; broader gate/mutants pending, no live action.
|
||||
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
|
||||
2026-10-22 00:24:34 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 full gate on c78f48946 | stopped on second integration failure per sage-2275; singles 269/269, processing-control 61/61, bus 646/646; CLI interrupted with 26 observed failures; no further edits; cwd sweep empty and slot returned to Sage.
|
||||
2026-10-22 00:48:20 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eb3abba12 full gate | stopped at Discord 190/192; 32 singles 304/304, processing-control 61/61, bus 646/646, cli 761/761; two uninitialized Discord fixtures reported to Sage; no later run; cwd sweep empty, slot clear.
|
||||
2026-10-22 00:58:27 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2294 fixture fix | e6800380d on eb3abba12, two test files/four additions, assertions preserved; 338-entry static all-package sweep recorded; no tests; sent Sage for publication and Honker for dependent gate.
|
||||
2026-10-22T01:04:44Z | Dewey (Opus 5.5, 84fb346c) | FED R3 early look, e7d2dc3e6 (morgana-0065) | static only, no queue record; mutant rulings: M3x stays plus reply-chain under M3x, #threadFor user clause equivalent, 193b1a filter mutant n/a; N1 role key user to fix in R3 (:359 and instance-settings:90), Sage rules on widening; dewey-0777 to Morgana, dewey-0778 to Sage
|
||||
2026-10-22T01:08:21Z | Dewey (Opus 5.5, 84fb346c) | FED R3 r2 62de33760 delta (morgana-0066) | static, no findings; N1 fixed at broker :361 and instance-settings :91 with tests; Sage rules instance-settings scope; dewey-0779 Morgana, dewey-0780 Sage
|
||||
2026-10-22 01:30:04 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 e6800380d resume | binding15/15, recover5/5, Discord192/192; stopped WebUI314 passed/4 failed/1 esbuild skip; processing polling violates sign-out/manual redirect and bar changes layout; no fixes/retries, cwd sweep empty, slot returned.
|
||||
2026-10-22 01:38:08 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2317 Console integration fix | 8e1f55c1d on e6800380d; shared sign-out/manual redirect/interval and desktop frame fixes, assertions retained, six browser cases, 73 mutant sites preflight; syntax only, no tests; sent Sage/Honker.
|
||||
2026-10-22 02:06:38 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 rebase sage-2329 | eafcbd3e7 on ddfb29af7, new branch, dispatcher retains processing and access; CLI/readme auto-merges checked; 87-path fd0f2740 manifest and 73-site preflight; Sage2333 published, awaiting CLEAR; no tests.
|
||||
2026-10-22 02:20:51 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 eafcbd3e7 full gate | stopped singles313/6: six new browser cases count app freshness timer as processing timer; diagnosis sent Sage, no fix/retry; no suites/shells/mutants; cwd sweep empty, slot clear.
|
||||
2026-10-22 02:23:13 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2345 timer fixture | 4db8b5778 on eafcbd3e7, one line attributes timer to processing.js; all assertions retained, no product change or tests; sent Sage and Honker; awaiting publication/CLEAR.
|
||||
2026-10-22 02:25:29 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 sage-2349 record commit | 0f8235277 on 4db8b5778 adds packet BUILD.md cause record; product manifest remains ffb46fbd/87 paths; no amend, product change or tests; Sage/Honker notified.
|
||||
2026-10-22 02:48:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 0f8235277 full gate second red | singles330/330, processing61/61, bus646/646, cli783/783, discord192/192; WebUI323/1/1 host esbuild skip; shell geometry1062vs1000; stopped/no retry, no later suites/shells/mutants; cwd sweep empty, slot returned, Sage escalates Mos.
|
||||
2026-10-22 02:57:36 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 #1689 mos-1248 desktop layout restart | FF 5cd32f4c1 on 0f8235277: desktop grid allocates processing bar, existing shell assertions kept, visibility/no-scroll assertions added; 74 static mutant sites; candidate 2f93ef19; sent Webby/Sage for static review; no runs, awaiting review and CLEAR.
|
||||
2026-10-22T02:58:24Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) early static look at 8bf442be9 (megavolt-0112) | B1 step 7 revoke undone by step 8 write, B2 sweep fire retry skips D1 id check, N1-N4; dewey-0781 Megavolt, dewey-0782 Sage; no queue verdict
|
||||
2026-10-22 03:01:02 UTC | Rocko (d99326cc) | row 191 Webby static review | B1 accepted: 5cd32f4c1 preserves the content floor; earlier root-cause claim withdrawn in BUILD.md; no runs; requesting fixture measurement slot and owner layout ruling.
|
||||
2026-10-22T03:08:58Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r2 77dd43c9e delta look (megavolt-0115) | B1 and B2 closed by reading; N5 (settle unknown/401 branches unguarded) not blocking; N1-N4 DEFERRED agreed with README lines; dewey-0783 Megavolt, dewey-0784 Sage; no queue verdict
|
||||
2026-10-22T03:10:55Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r3 7965efd0f delta look (megavolt-0119) | N5 closed by reading; r3 is the run candidate; dewey-0785 Megavolt, dewey-0786 Sage; no queue verdict
|
||||
2026-10-22 03:11:09 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 191 static nav layout, mos-1249/1250 | FF da4334806 on 5cd32f4c1; controls first in nav, non-sticky per sage-2386/webby-0108; candidate-only placement assertion changed, landed shell assertions preserved; 77 mutants statically preflighted; manifest 245dceb1; Webby/Sage handoff sent, no runs, awaiting static look/publish/debug CLEAR.
|
||||
2026-10-22T03:11:36Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) r4 1c0048911 static look (sage-2390) | README N3/N4 sentences match the rulings; N5 test split per branch, same asserts; r4 is the run candidate; dewey-0787 Sage, dewey-0788 Megavolt
|
||||
2026-10-22 03:15:11 UTC | Rocko (d99326cc) | row 191 sage-2394 debug | da4334806 shell5/5 and processing-browser9/9, awk14/0/0; geometry heights reported with read-only distinction; cwd sweep[] and slot returned; full window awaits separate CLEAR.
|
||||
2026-10-22T03:36:37Z | Filbert (Opus 5.5, sage-2398) | row 273 (#1778) static review, darkwing/row273 978143bf4 to cac7d0f67, carried to 0bbf0f6d1 | APPROVE c30308, queue rev 3111 (filbert-273-record-r1), digest e1031023 rebuilt and matched; B1/N1 fixed, T57 item 3 change approved, N1 TOOLS.md by Sage at landing. Posted before sage-2396 hold; my drift check missed row 202 scripts/mosaic (test-copied). Merge-scoped carry c30319 on 0bbf0f6d1: range-diff 6/6 =, patch-ids equal; rerun green per darkwing-0147 (not mine). Line left uncommitted: HEAD 378a76b85 behind origin 0bbf0f6d1.
|
||||
2026-10-22T03:42:29Z | Darkwing (claude-opus-5-5) | row 273 (#1778) activate --send/--no-sso, sage-2301/mos-1246 F39 | LANDED 0bbf0f6d1 (docs 5365adaef, done rev 3113, #1778 closed c30324); 978143bf4 run red (stale T53 usage pin, fixed cac7d0f67), cac7d0f67 green on old base, rebased darkwing/row273-b2 (range-diff all =, digest e1031023) green 218/218, 25/25, 14/0, M1-M7 killed; in-review rev 3109/3110 c30307; TOOLS.md F39 lines match darkwing-0137
|
||||
2026-10-22 04:02:12 UTC | Rocko (d99326cc) | row191 full window0fa7bf421 | touched35files336pass, packages2403pass/1esbuildskip, shells425selftests+156queue node pass; SR01–07killed, SR08timeout,69unrun; stopped/no retry;88blobs restored,cwd sweep[],owned network removed,slot returned Sage; await Mos ruling.
|
||||
2026-10-22T04:11:48Z | Dewey (Opus 5.5, 84fb346c) | row 191 runner review (sage-2424, mos-1259 item 3): run-mutants.py fe0d360f static | APPROVE to Rocko dewey-0789 and Sage dewey-0790; N1-N5 non-blocking (placeholder timeout cause, 7+1+69 tally, indented not-ok match, SR08 time binding, resume-only invocation)
|
||||
2026-10-22 04:14:56 UTC | Rocko (d99326cc) | row191 control/resume sage-2427 | control12/12;SR08separate timeoutkill;49resumed assertionkills thenKS43survived;total56assertionkills+1timeout,1survivor,19unrun;88blobs restored,cwd sweep[],slot returned,Sage/Mos informed;no retry/edit.
|
||||
2026-10-22T04:20:27Z | Dewey (Opus 5.5, 84fb346c) | row 191 mos-1261 mutant table review: all-mutants.json 5dbc8308 KS43 pattern retarget + 19-site audit, static | APPROVE to Rocko dewey-0791 and Sage dewey-0792; C1 archive resume-mutant-results.json (f5725ef2) and KS43.tap (c778ee33) before a --resume-from KS43 run
|
||||
2026-10-22 04:21:30 UTC | Rocko (d99326cc) | row191 KS43+19 retarget run | KS43assertionkill thenKS44invalid mutation syntax;57assertionkills+1timeout,1invalid,18unrun;stopped,no edit/retry;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified.
|
||||
2026-10-22T04:25:19Z | Dewey (Opus 5.5, 84fb346c) | row 191 mos-1264 KS44 table fix (85d9a7b1, // -> void 0) + 19-site syntax preflight, static; also C1 archive check (dewey-0793/0794) | APPROVE to Rocko dewey-0795 and Sage dewey-0796; run condition: new subdir with byte copies, --resume-from KS44 (19 sites); tally 57 kills + SR08
|
||||
2026-10-22 04:30:06 UTC | Rocko (d99326cc) | row191 KS44+18 resume | 10assertionkills thenKS54timeout-without-control despite dependency assertion;total67assertionkills+1timeoutkill,1unqualifiedtimeout,8unrun;88hashes restored,cwd sweep[],slot returned,Sage/Mos notified,no retry/edit.
|
||||
2026-10-22T04:38:59Z | Dewey (Opus 5.5, 84fb346c) | row 191 final mutant evidence check (control-ks54-mos1267, syntaxfixed-mos1264), static | tally 75 assertion + 2 timeout kills = 77 confirmed (dewey-0798 Rocko, dewey-0799 Sage); N6 tasks.test.mjs:262-266 runtime not closed on failure, Sage rules
|
||||
2026-10-22 04:41:46 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | row191 #1689 builder complete/in-review | published0fa7bf421,manifest0bcbf337;35singles336pass,9packages2403pass/1knownskip,10shells425selftests+156queue nodepass;75assertionkills+2controlledtimeoutkills=77;88hashrestore,cwd sweep[],slotreturned;in-review rev3114,expected no-token failure3115(exit1);Sage/Dewey handoff,no live action.
|
||||
2026-10-22T04:46:51Z | Dewey (Opus 5.5, 84fb346c) | row 191 manifest review round 1 (#1689, candidate 0bcbf337, source 0fa7bf421 on 86f95bb27) | APPROVE comment 30359, queue rev 3118; manifest, gate logs (2739/0/1 named), 77 mutants and 712-file packet checked; N6 carried to Vikunja 502; N7/N8 non-blocking; dewey-0800 Sage, dewey-0801 Rocko
|
||||
2026-10-22T04:52:40Z | Dewey (Opus 5.5, 84fb346c) | row 236 static look at rebase 08840f3c1 on 0fa7bf421 (#1742) | clean, not a verdict: commit 1 only the three 191 union lines, 2-6 =, commit 7 grants.status read with a test; manifest 32 paths digest 1e40433b matches; dewey-0802 Megavolt, dewey-0803 Sage
|
||||
2026-10-22 05:03:18 UTC | Rocko (Codex gpt-6.1-sol,d99326cc) | Vikunja502 processing test/runner hardening | brief+isolatedrocko-v502;expected wordingred→green,Node27/27,Python4/4,5/5assertionkills(includingSR08andKS54nohang),0skips/timeouts;sweep[],candidate5hashrestored,slotreturned05:01:29Z;candidate968d6ee3,runnerc5bdc764;packetready,no commit/push/liveaction.
|
||||
2026-10-22 05:07:34 UTC | Rocko (d99326cc) | Vikunja502 committed/review handoff sage-2482 | newbranchrocko/vikunja502-ce6a-20261022,source8e3c6a79b+packet9bf4683c6 on ce6a96ad6;5paths no base drift,range-diff=,patch-id37135ebe unchanged;manifest968d6ee3,packet93889519 verifies39/39;focused runs carry;Sage publish/Dewey review handoff sent,no push/liveaction.
|
||||
2026-10-22 05:10:00 UTC | Sage (Opus 5.5, 1ef1e4f8) | landings 191, 205, 268 (MVP 10-27) | 191 LANDED 0fa7bf421 (P19 414 c595); 205 LANDED ce6a96ad6 (gate GATE_PASSED 04:56:43Z, Duckworth APPROVE rev 3125, done rev 3127, #1703 closed c30379); 268 r4 LANDED c27207f6e as a carry on 205 (rerun 14/83/209, Webby 0112/0113, done rev 3129, #1773 closed c30384); doc commit adds the #1782 brief, re-pin plan, phase 2 draft 3
|
||||
|
||||
+1
-18
@@ -276,25 +276,11 @@ scripts/mosaic follow --thread <thread> [--tester]
|
||||
scripts/mosaic stop <run>
|
||||
scripts/mosaic launches off | on [--business <id>]
|
||||
scripts/mosaic launches list [--json]
|
||||
scripts/mosaic halt | resume [--business <id>]
|
||||
scripts/mosaic processing status [--business <id>] [--json]
|
||||
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
|
||||
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
|
||||
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
|
||||
```
|
||||
|
||||
The instance processing switch is separate from per-business launches off/on.
|
||||
`mosaic halt` durably stops managed processing across every business in the
|
||||
selected configuration's data root. The bus stays up for reads and human
|
||||
recovery. `mosaic processing status --json` reports settled, pending or unknown
|
||||
stops; `mosaic resume` checks the current generation and refuses pending stops.
|
||||
Only the existing human proof can write it, independently of channel settings.
|
||||
A missing/invalid state is never off: use halt, then resume; an unsafe control
|
||||
parent needs operator repair. Unknown task/remote outcomes are not retried.
|
||||
Native diagnostic seats remain available. First activation must stop old
|
||||
processing before trusted initialization and align the image/read-only control
|
||||
mount. This is a separate approved window, never a build step.
|
||||
|
||||
The human commands read and resolve through the broker's human transport
|
||||
(`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside
|
||||
an agent run, except the forms that take `--tester` (rows 110, 175 and 184), which
|
||||
@@ -480,7 +466,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
|
||||
```
|
||||
scripts/stack-release.sh adopt BUSINESS
|
||||
scripts/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]
|
||||
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh status BUSINESS
|
||||
@@ -490,9 +476,6 @@ scripts/stack-release.sh gate ID BUSINESS --units external --since EPOCH [--time
|
||||
common options: --copies DIR, --bin DIR, --instance NAME, --units user|external
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
--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.
|
||||
--no-sso drops the pointer's --send, and so does a rollback to a Console without --sso.
|
||||
```
|
||||
|
||||
This is an operator plan, not an action agents run. Both reviews and the
|
||||
|
||||
@@ -22,15 +22,9 @@ edits it by hand, and the agents can't change it. He will ask you to confirm it
|
||||
|
||||
## Your playground
|
||||
|
||||
A folder named in the `playground` list of that entry is your playground. A playground folder can sit
|
||||
at one of three levels:
|
||||
- Book level: inside one book folder, for ideas about that book, for example `<book>/playground/`.
|
||||
- Universe level: outside every book folder, for ideas about a universe that aren't tied to one book.
|
||||
- A new universe: a draft universe entry with a name and its own playground folders and no books, for a
|
||||
world you are only starting. When it is real, Jason turns it into a plain universe with books and you
|
||||
move the notes in Obsidian.
|
||||
|
||||
You choose the names and tell Jason. Until you have one, every note in a book folder is canon.
|
||||
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
|
||||
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
|
||||
names and tell Jason. Until you have one, every note in a book folder is canon.
|
||||
|
||||
What the playground means for the agents:
|
||||
- They search canon unless they ask for the playground on purpose.
|
||||
|
||||
@@ -111,7 +111,7 @@ repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
|
||||
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
|
||||
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
|
||||
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
|
||||
| 3 | `library survey: library-playground-other-universe: <file> universes.<u>.playground "<prefix>" lies inside book folder "<book>" of universes.<v>`, or `holds book folder` | a playground prefix inside, or holding, another universe's book folder | Give it to Jason for the draft |
|
||||
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
|
||||
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
|
||||
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
|
||||
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# The owner sees a thread agent's claims (10-27 row)
|
||||
|
||||
Sage, 2026-10-22, for mos-1262. Row 205's message filter found the gap (morgana-0072, ruling sage-2430).
|
||||
|
||||
## Why
|
||||
|
||||
PRD :1083-1084 promises that what a thread's agent does with the owner's authority stays visible to the
|
||||
owner: its decisions, inbox entries, claims, tasks and project files. FED R6 shows users the same list word
|
||||
for word. Today a thread run's `role.claim` writes `thread_claims` only and returns at broker.mjs:2682. The
|
||||
`agents` verb (:3309) reads `role_claims` only, and no other owner verb shows thread claims. So the owner
|
||||
can't see a thread agent's claim.
|
||||
|
||||
Mos ruled this a 10-27 row, not post-v1 (mos-1262). No user thread goes live before it lands, the same
|
||||
bind as the 193b1a filter. Q176 Case 1 ("Shared and private agent sessions available per user; RBAC")
|
||||
needs it. If it can't make 10-27, that's a scope question for Jason, and it goes to Mos first.
|
||||
|
||||
## Brief: the owner sees thread claims
|
||||
|
||||
Chained on row 205 (193b1a). Start from refactor after 205 lands.
|
||||
|
||||
What to build:
|
||||
|
||||
1. A human or reader session's `agents` result also lists every thread claim currently held in the
|
||||
business: thread id, holder run, harness and claim time. A claim counts as held when its thread's
|
||||
latest `thread_claims` op is `claim`. Role claim rows keep their current shape and order. Thread
|
||||
claim rows are added after them, marked so a reader can tell the two apart.
|
||||
2. A role session's `agents` result is unchanged.
|
||||
3. The rows carry no message content and no thread name a user wrote. The 205 filter still hides user
|
||||
messages. This row adds claims only.
|
||||
4. The CLI's `agents` output (packages/cli format.mjs reads `c.role` and `holder_run`) shows the thread
|
||||
rows without breaking the role rows.
|
||||
5. Row 205's governance pin (message-filter.test.mjs, the "claims" item) moves from `threadsState` to the
|
||||
owner's `agents` call. Its comment naming this row goes away.
|
||||
|
||||
If the builder finds a cleaner shape, for example a separate owner verb, it goes in BUILD.md to Duckworth
|
||||
before code. A widening beyond owner and reader sessions goes to Mos (mos-1202).
|
||||
|
||||
Tests:
|
||||
|
||||
- Owner `agents` shows a held thread claim. After revoke or release, it doesn't.
|
||||
- A role session's `agents` result is byte-equal to before for the same store.
|
||||
- No message body or user-written thread text appears in the output.
|
||||
- The CLI renders a mixed list.
|
||||
|
||||
Mutants, each killed by a named test:
|
||||
|
||||
- M1. Thread rows dropped from the owner result.
|
||||
- M2. The held check reads the first op instead of the latest, so a released claim shows.
|
||||
- M3. Thread rows added to a role session's result.
|
||||
|
||||
Gate: Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every
|
||||
scripts/test-*.sh are green on Sage's gate, and the mutants are killed.
|
||||
|
||||
## Ruling on design r1 (sage-2487)
|
||||
|
||||
Duckworth approved Morgana's design r1 (3e9939b7b) as D1: a separate human and reader verb `thread.claims`,
|
||||
with `agents` unchanged, under conditions C1-C3 (duckworth-0118, agents/duckworth/work/row1782/DESIGN-r1.md
|
||||
at 13fb9abc5). The brief allowed that shape. Brief item 5's pin moves to `thread.claims`, as C3 records.
|
||||
Row 277 builds it.
|
||||
|
||||
The Console doesn't show thread claims under D1, and an owner who uses only the Console won't run
|
||||
`mosaic agents`. So PRD :1083-1084 isn't met until the Console shows them too. That's the follow-up brief
|
||||
below, with the same bind as row 277.
|
||||
|
||||
## Brief: the Console shows thread claims
|
||||
|
||||
Chained on row 277. Start from refactor after 277 lands.
|
||||
|
||||
What to build:
|
||||
|
||||
1. The Console's Agents view gets a "Thread claims" section for a human or reader session. It reads
|
||||
`thread.claims` and lists each held claim: thread id, holder run, harness and claim time.
|
||||
2. The existing role rows (webui public/bus.js :447, :462, :474) keep their shape, counts and order.
|
||||
3. No message body and no thread name a user wrote appears in the section.
|
||||
4. An empty result shows an empty-state line, not an error. A refusal from `thread.claims` shows the
|
||||
refusal and leaves the role rows intact.
|
||||
|
||||
Tests:
|
||||
|
||||
- The section lists a held claim and drops it after release.
|
||||
- Role rows and their counts are byte-equal to before for the same store.
|
||||
- No message body or user-written thread text in the rendered section.
|
||||
- A refused `thread.claims` call leaves the role rows rendered.
|
||||
|
||||
Mutants, each killed by a named test:
|
||||
|
||||
- M1. The section is not rendered.
|
||||
- M2. Thread claims are counted into the role totals.
|
||||
|
||||
Gate: Webby approves the manifest. The touched test files run whole, the webui and bus suites and every
|
||||
scripts/test-*.sh are green on Sage's gate, and the mutants are killed. No user thread goes live before this
|
||||
row lands.
|
||||
@@ -1,24 +1,36 @@
|
||||
# Plan: Q175 phase 2 window, the live Console switches to --sso
|
||||
|
||||
Sage, 2026-10-22. Draft 3 for Mos, after mos-1266. It names 86f95bb27, live since 04:03:00Z on 10-22. Sources: `agents/sage/work/q175-sso/DESIGN.md` sections 5
|
||||
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`),
|
||||
its N1 follow-up and 273 (`activate --send` and `--no-sso`) have landed. Vikunja #412, Gitea #385.
|
||||
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.
|
||||
|
||||
## The tool gap is closed
|
||||
## Blocked on a tool gap
|
||||
|
||||
Draft 2 found two missing `scripts/stack-release.sh` flags: `activate --send` and `activate --no-sso`. Row 273
|
||||
added both (mos-1246, F39). It landed as 0bbf0f6d1 (Gitea #1778), and the 86f95bb27 re-pin put it live
|
||||
(activation 20261022T040256Z-86f95bb27f6c). `--send` refuses unless the run's Console flags hold `--sso`,
|
||||
given or carried. `--no-sso` takes the Console flags from the rollback set of the activation that turned
|
||||
`--sso` on, and it drops `--send`. Neither flag ran in that re-pin, so step 5 and rollback 1 are their first
|
||||
live use. Darkwing's gate on 0bbf0f6d1 ran stack-release.test.mjs 218/218, including T53, T57 and T58.
|
||||
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). The cases for both go in
|
||||
`packages/cli/tests/stack-release.test.mjs`. F39 named test-release.sh, but that suite tests `release.sh`
|
||||
and never calls `stack-release.sh`, so it runs whole and unchanged. 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 re-pin condition is met:
|
||||
W12 passed on 86f95bb27 at 04:26:43Z on 10-22, and Mos closed that window in mos-1266. This plan names
|
||||
86f95bb27, which carries phase 1 and row 273, so its loader accepts a config with or without `console`. If another re-pin lands before
|
||||
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
|
||||
@@ -52,12 +64,12 @@ Jason starts, the plan names the copy live at that point, and step 3 runs agains
|
||||
`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 86f95bb27, or be shown never to re-read config. Step 4 waits until the list is clean.
|
||||
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 86f95bb27 mosaic-stack --sso --send --plan`. Its from line must
|
||||
name 86f95bb27 (`--expect-from` is for `--units external` only, so the live user units refuse it as a
|
||||
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.
|
||||
@@ -96,16 +108,15 @@ Jason starts, the plan names the copy live at that point, and step 3 runs agains
|
||||
|
||||
## Rollback
|
||||
|
||||
1. Same-copy re-activate (mos-1245 R1): `scripts/stack-release.sh activate 86f95bb27 mosaic-stack --no-sso
|
||||
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
|
||||
86f95bb27 instead. The `--plan` output must show the pre-window Console flags from the step 1 pointer backup: `--port 7332 --public-host
|
||||
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`. 86f95bb27 loads config with or
|
||||
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 goes to Mos first: `scripts/stack-release.sh rollback` goes to the previous
|
||||
distinct copy, 7247d80c7. Both copies are at schema 3j and 7247d80c7 carries phase 1, so it's a pointer
|
||||
flip with no `--restore`. Its `--plan` must show the pre-window Console flags above. Any restore goes to
|
||||
Mos and ends the window (mos-1226).
|
||||
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).
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# stopRun reads a live run's status at the deadline (191 follow-up, MVP)
|
||||
|
||||
Sage, 2026-10-22, for mos-1269. Row 194 b15's gate found it (honker-0121, sage-2510).
|
||||
|
||||
## Why
|
||||
|
||||
`stopRun` in packages/processing-control/src/runtimes.mjs (row 191, 4bdc7e0e8) waits in
|
||||
`while (now() <= deadline)` (:333). Its final status read is inside `if (now() >= deadline)` (:356-359),
|
||||
and the loop sleeps `Math.min(20, Math.max(1, deadline - now()))` (:361). A real sleep can wake past the
|
||||
deadline. When it does, the loop condition fails before the final read runs, and :363 returns `'unknown'`
|
||||
for a process that is still alive and ignoring SIGTERM and SIGKILL. The expected outcome is `'alive'`.
|
||||
|
||||
Row 194's T9a (cli lifecycle.test.mjs:562) caught it in the loaded cli suite: 'unknown' where 'alive' was
|
||||
expected, while the same file passed alone. Row 191's tests missed it because their fake clock advances by
|
||||
exactly the sleep, so it never overshoots. Live is 86f95bb27, which doesn't carry 191, so nothing running
|
||||
is affected.
|
||||
|
||||
## Brief: stopRun's deadline branch is the only time exit
|
||||
|
||||
No `--after`. 191 has landed. This row lands before row 194 b16, which rebases onto it (mos-1269 item 4).
|
||||
|
||||
What to build:
|
||||
|
||||
1. The time loop's only exit on time is the existing deadline branch, so the final status read always runs
|
||||
once the deadline passes. One shape is `for (;;)` with the `now() >= deadline` branch as the time exit.
|
||||
The builder picks the shape and records it in BUILD.md.
|
||||
2. The other exits stay as they are: record gone (the catch), identity changed, ended or not alive, and the
|
||||
deadline branch's own mapping of `alive`, `killed`, `stopped`, `identity-changed` and `unknown`.
|
||||
3. Nothing else in runtimes.mjs changes. Vikunja 502's four items stay out (mos-1269 item 3). They follow
|
||||
row 194.
|
||||
|
||||
Tests:
|
||||
|
||||
- A fake sleep advances the clock past the deadline while the process stays alive and ignores both
|
||||
signals. stopRun reports `'alive'`.
|
||||
- The existing stop-run tests pass unchanged.
|
||||
|
||||
Mutants, each killed by a named test:
|
||||
|
||||
- M1. The old loop condition `while (now() <= deadline)` with the fall-through `return finish('unknown')`.
|
||||
|
||||
Gate: Dewey approves the manifest. The processing-control package's tests (runtimes.test.mjs and
|
||||
stop-run.test.mjs among them) and every other touched test file run whole, the business and cli suites are
|
||||
green on Sage's gate, and M1 is killed. Row 194 b16's full gate then runs on top of this row. Any red goes to
|
||||
Mos (mos-1269).
|
||||
+5
-8
@@ -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 3137 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3108 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -227,7 +227,7 @@ Generated from `docs/plans/queue.json` revision 3137 by `queue render`. Do not e
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | done | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | Landed 00d4cc1f6 on refactor (base 99a406ed3). Patch-id 5ec610a7 matches git diff 176a6bf4b..df65a3d4c on the 25 manifest paths. Manifest cc0ea9e8, Filbert approve r3 29713. Gate on d0bbc5c1c: packages 2664/0, scripts 429/0. Mutants 20/20. Unpushed. |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | done | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | Landed 0fa7bf421 fast-forward on 86f95bb27 (10 commits, 89 paths; manifest 0bcbf337 = 88 paths without BUILD.md). Dewey manifest APPROVE c30359 rev 3118. Gate: 77 mutants killed (75 assert + SR08, KS54 timeout with controls), suites green on Sage's window; N7 closed by runs suite 43/0/0 on 0fa7bf421. N6, clock fix, :52 assertion, generic cause go to Vikunja 502 (mos-1267/1268). Rebase next: 205, 236, 194, 253, 274, 268. |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | sage-1610 (honker-0049): a human add takes tracker {bot, botId}, required for a vikunja definition, refused otherwise, checked by checkBot and bot uniqueness; CLI and API only. Delete accepts a tracker bot when all credentials are store refs, and its result names the bot for removal by hand in Vikunja. No Vikunja account is created (Q188 untouched). Mutants: drop the requirement, refuse the bot on delete, skip checkBot on add. |
|
||||
@@ -241,7 +241,7 @@ Generated from `docs/plans/queue.json` revision 3137 by `queue render`. Do not e
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | done | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | done | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | Landed ce6a96ad6 fast-forward on 0fa7bf421 (3 commits, 4 paths, manifest 858463b1). Duckworth APPROVE r1 c30375 (signed :124 test diff, mos-1262). Gate: Morgana slot205 10 files, bus 652/0, M1 M11-M14 killed, 0 skips; Sage test-*.sh 10/10, 3 by-design skips. N1/N3 to 205b or R3, N2 to 193b2 brief, N4 = #1782 before any user thread. Rebase next: 268 r4, 194, 236, 253, 274. |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | in-progress | 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) | — |
|
||||
| 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 | — |
|
||||
@@ -304,18 +304,15 @@ Generated from `docs/plans/queue.json` revision 3137 by `queue render`. Do not e
|
||||
| 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 | done | — | Webby approves the manifest. The brief's tests run whole plus the library suite and every touched suite on Sage's slot; the brief's gate grep over packages and docs/case2 finds no library-playground-outside-book. Grep for hash pins on changed files before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01c playground levels: universe-level prefixes and draft universes | Landed c27207f6e fast-forward on ce6a96ad6 (1 commit, 9 paths, digest caa0fa4e). r4 is a carry of r3 aca53c4f0: range-diff =, patch-id 2942acc8 equal, blobs equal. Webby APPROVE round 2 on ecd9b35eb stands; carries webby-0112 (r3) and webby-0113 (r4, notes 34e1ab2bf). Gate: Gizmoduck r4 rerun brief 14/14, library 83/83, business 209/209, 0 skips; gate grep empty. 194 rebases next (business.test.mjs). |
|
||||
| 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 | done | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | landed 0bbf0f6d1 03:35:55Z, FF of 378a76b85; rebase of cac7d0f67, 6 range-diff pairs =, patch-ids equal, manifest e1031023 unchanged. Gate by Darkwing on 0bbf0f6d1: stack-release.test 218/218, external 25/25, test-release.sh rc 0, T58 c0 pass c1 fail, M1-M7 killed. Filbert APPROVE rev 3111 c30308, carry c30319. Docs 5365adaef (TOOLS.md N1 + BUILD-LOG). #1778. |
|
||||
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | in-progress | — | 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 | in-progress | — | 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 | — |
|
||||
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
|
||||
| 277 | The owner sees a thread agent's claims: an owner or reader read lists held thread claims (10-27, mos-1262; no user thread live before it) | morgana; reviewers duckworth | #1782 | in-progress | 205 done | Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the owner sees thread claims | — |
|
||||
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | briefed | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | — |
|
||||
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | briefed | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+33
-1029
File diff suppressed because one or more lines are too long
+15
-26
@@ -225,6 +225,11 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
|
||||
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
|
||||
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
|
||||
| `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. |
|
||||
| `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. |
|
||||
| `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). |
|
||||
| `grants.sweep` | `{service:"gitea"}`; the human or the tester. Closes open revokes, settles open rotations again, and retries each `serviceLeft` entry. A fire retry (`tokens`, `login`) first checks the user's id as the revoke close does, and a gone or remade user is skipped with `gitea-user-gone` or `gitea-user-foreign`. Writes `grant.sweep` and `grant.swept {left,skipped,settled,closed}`. |
|
||||
| `grants.admin.set`, `grants.admin.rotate`, `grants.admin.remove` | The human only (#436b r5 section 3). set takes `{service,url,account,rotateBy,token}`, checks C1 to C4 with the token, then writes the slot under `<configDir>/secrets/grants/<business>/gitea`; a slot already there is `grant-admin-exists`. rotate `{service,rotateBy}` mints a successor admin token and names the old one in `adminTokenLeft`; `{service,finish:true}` deletes it. Rotation voids the approved decisions not yet run, as their `adminSha256` no longer matches. remove drops the slot. Each writes `grant.admin` with no secret. A left admin token that set or remove drops from `adminTokenLeft`, or one shown as `unknown`, has to be deleted by hand at Gitea. A running executor, revoke close or sweep keeps the admin api it started with: rotate voids only decisions not yet run, and remove takes effect from the next run. |
|
||||
|
||||
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
|
||||
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
|
||||
@@ -711,10 +716,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
|
||||
`aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0`.
|
||||
It has 20 tables, 72 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3k`, WAL,
|
||||
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.
|
||||
@@ -882,6 +887,12 @@ step runs in one transaction, and a failure leaves the store at 3i. The 3i fixtu
|
||||
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
|
||||
covers the step and the trigger.
|
||||
|
||||
Schema 3k (row 236, #1742, #436b r5 section 7) adds only two tables: `gitea_users`, the Gitea
|
||||
users the stack made (user, id and the nonce in their full name), and `gitea_grants`, the live Gitea
|
||||
grants (user, token id and name, projects, rotate-by date and decision), whose key references its
|
||||
`gitea_users` row. Each write goes in one transaction with a `grant.*` event. The step runs in one
|
||||
transaction, and a failure leaves the store at 3j. `tests/gitea-migration.test.mjs` covers it.
|
||||
|
||||
The managed runner calls usage.begin before an actual harness turn and usage.end
|
||||
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
|
||||
The broker requires the bound role holder and its delivery, binds run identity,
|
||||
@@ -935,25 +946,3 @@ narrow cross-instance reach, not reach within one instance.
|
||||
|
||||
The Console provenance option trusts verified code and process metadata, not an independent
|
||||
bus-side OIDC exchange. That trade must be approved, not hidden behind the phrase zero trust.
|
||||
|
||||
## Durable instance processing control
|
||||
|
||||
`processing.status` is an authorized read. `processing.halt` ({}) and
|
||||
`processing.resume` ({generation}) require the existing human proof before
|
||||
validation or effects. They are management verbs independent of channels and
|
||||
never tester or role grants. The strict state under the resolved dataRoot's
|
||||
control/ directory is authoritative; reserved processing.halt/resume/stop
|
||||
actions mirror its durable transitions and write-once stop receipts with
|
||||
verified human and generation. Generic event writers cannot forge them. No
|
||||
schema change is needed. Invalid state refuses work and leaves control reads
|
||||
and human recovery reachable. Halt then resume repairs an invalid leaf; an
|
||||
unsafe parent is not automatically repaired.
|
||||
|
||||
The bus remains a read/control service while new agent work, tracker polling,
|
||||
notifications and launches pause. Narrow exit cleanup remains possible. Every
|
||||
managed starter carries a generation and publishes identity before releasing
|
||||
work. Stops inspect root/boot/start identity or generated container id/labels,
|
||||
TERM concurrently, then KILL after five seconds and observe for two more.
|
||||
Unknown targets remain visible and prevent resume. Existing run evidence is
|
||||
not synthesized or rewritten. First activation stops old processing before
|
||||
trusted initialization and aligns the copy and image in the human's window.
|
||||
|
||||
@@ -394,3 +394,24 @@ CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
|
||||
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
|
||||
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
|
||||
CREATE TABLE gitea_users (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
id INTEGER NOT NULL CHECK (id > 0),
|
||||
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent)
|
||||
) STRICT;
|
||||
CREATE TABLE gitea_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
token_id INTEGER NOT NULL CHECK (token_id > 0),
|
||||
token_name TEXT NOT NULL,
|
||||
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent),
|
||||
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
|
||||
) STRICT;
|
||||
|
||||
+1007
-145
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ export class Client {
|
||||
socket.destroy();
|
||||
error ? reject(error) : resolve(value);
|
||||
};
|
||||
socket.setTimeout(['processing.halt', 'processing.resume'].includes(verb) ? Math.max(this.timeout, 20000) : this.timeout, () => end(new BusError('outcome-unknown')));
|
||||
socket.setTimeout(this.timeout, () => end(new BusError('outcome-unknown')));
|
||||
socket.on('connect', () => socket.write(request));
|
||||
socket.on('error', () => end(new BusError('outcome-unknown')));
|
||||
socket.on('end', () => end(new BusError('outcome-unknown')));
|
||||
|
||||
@@ -116,20 +116,26 @@ export class Credentials {
|
||||
#revoked = new Set();
|
||||
// Row 210 (design D5, D9): instance:host.<id> -> the AbortController of the call in flight.
|
||||
#inflight = new Map();
|
||||
// Row 236 (#436b section 5): instance:service entries whose file memory never loads, the
|
||||
// grant-token-unknown pairs. A revoke or a new issue clears one.
|
||||
#held = new Set();
|
||||
#storeRoot;
|
||||
#repoRoots;
|
||||
#dataRoot;
|
||||
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
|
||||
// path under it for its own business, agent and service.
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
|
||||
// path under it for its own business, agent and service. `held` lists "instance:service" keys whose
|
||||
// reference loads and whose file doesn't (#436b section 5).
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [] }) {
|
||||
this.#storeRoot = storeRoot;
|
||||
this.#repoRoots = repoRoots;
|
||||
this.#dataRoot = dataRoot;
|
||||
for (const k of held) this.#held.add(k);
|
||||
try {
|
||||
for (const [instance, services] of Object.entries(references))
|
||||
for (const [service, ref] of Object.entries(services)) {
|
||||
const date = this.#checkRef(instance, service, ref);
|
||||
this.#refs.set(instance + ':' + service, { ref, date });
|
||||
if (this.#held.has(instance + ':' + service)) continue;
|
||||
const value = this.#read(ref, env, service);
|
||||
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
}
|
||||
@@ -319,14 +325,66 @@ export class Credentials {
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
|
||||
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
|
||||
reload(instance, service, date) {
|
||||
const k = instance + ':' + service;
|
||||
const { ref } = this.#known(instance, service);
|
||||
if (this.#held.has(k) || !ref.store) deny('credential-reference');
|
||||
if (!day(date)) deny('credential-date');
|
||||
const value = this.#read(ref, {}, service);
|
||||
if (value === null) deny('credential-file');
|
||||
this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date });
|
||||
this.#tokens.set(k, { ...value, date });
|
||||
this.#revoked.delete(k);
|
||||
}
|
||||
// Row 236 (#436b section 5, grant-token-unknown): memory drops the token, as revoke's memory step,
|
||||
// and a call in flight aborts. The file stays for the human to look at, and nothing loads it again
|
||||
// until a revoke or an issue.
|
||||
hold(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
this.#known(instance, service);
|
||||
this.#tokens.delete(k);
|
||||
this.#held.add(k);
|
||||
this.#inflight.get(k)?.abort();
|
||||
}
|
||||
held(instance, service) {
|
||||
return this.#held.has(instance + ':' + service);
|
||||
}
|
||||
// Row 236 (#436b section 5, settle): `operation` gets the token the store file holds now, read with
|
||||
// the constructor's checks, or null when there is no file. Memory is untouched. The result is checked
|
||||
// as use() checks it.
|
||||
async useFile(instance, service, operation) {
|
||||
const { ref } = this.#known(instance, service);
|
||||
if (!ref.store) deny('credential-reference');
|
||||
const value = this.#read(ref, {}, service);
|
||||
const result = await operation(value?.token ?? null);
|
||||
this.assertClean(result);
|
||||
if (value && JSON.stringify(result)?.includes(value.token)) deny('credential-leak');
|
||||
return result;
|
||||
}
|
||||
// Row 236 (#436b section 5, a first grant's open commit at restart): the store file goes, and with it
|
||||
// a token no record backs. An entry with a token in memory refuses: only a held entry is undone so.
|
||||
unlinkStore(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
const { ref } = this.#known(instance, service);
|
||||
if (this.#tokens.has(k) || !ref.store) deny('credential-reference');
|
||||
try {
|
||||
unlinkSync(ref.file);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
}
|
||||
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
|
||||
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
|
||||
revoke(instance, service) {
|
||||
const { ref } = this.#known(instance, service);
|
||||
this.#tokens.delete(instance + ':' + service);
|
||||
this.#revoked.add(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
// Row 210 (design D9): a host.run in flight for this agent and host is killed first.
|
||||
this.#inflight.get(instance + ':' + service)?.abort();
|
||||
if (ref.store) {
|
||||
@@ -425,9 +483,11 @@ export class Credentials {
|
||||
date,
|
||||
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
|
||||
state: !this.#tokens.has(key)
|
||||
? this.#revoked.has(key)
|
||||
? 'revoked'
|
||||
: 'not-issued'
|
||||
? this.#held.has(key)
|
||||
? 'held'
|
||||
: this.#revoked.has(key)
|
||||
? 'revoked'
|
||||
: 'not-issued'
|
||||
: service !== 'vikunja'
|
||||
? left <= 0
|
||||
? 'rotation-due'
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
// Row 236 (#1742, #436b design r5 at 1be535aca): the trusted half of the Gitea grant path, run in the bus
|
||||
// child. The broker gets it as `giteaGrant` and holds every store write and every step's order. This module
|
||||
// holds what touches the network or a secret file: the HTTPS calls to Gitea (each made through the
|
||||
// broker's `log`, which writes grant.call before and grant.result after), the admin slot (section 3c), the
|
||||
// binding's shape check (section 2), the admin checks C1 to C4 (section 3a), the start pre-load of held
|
||||
// pairs (section 5) and `giteaKeys`, the broker's narrow handle on Credentials.
|
||||
//
|
||||
// The admin token lives at <configDir>/secrets/grants/<business>/gitea/admin, 0600 in 0700 dirs, with
|
||||
// admin.json next to it ({url, account, rotateBy, setAt}, no secret). Both are written tmp, fsync, rename,
|
||||
// as row 199's issue writes. Neither value reaches an event, a message or a log.
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
|
||||
import { day, privateDir } from './credentials.mjs';
|
||||
import { definitionSha256, grantTag } from './host-grant.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const sha256 = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex');
|
||||
// Section 2: the seat token's scopes, fixed by code (row 199 A2). Section 3b: the admin token's, exactly.
|
||||
// Both sorted, as Gitea reports scopes.
|
||||
export const SEAT_SCOPES = Object.freeze(['read:user', 'write:repository']);
|
||||
export const ADMIN_SCOPES = Object.freeze(['write:admin', 'write:repository', 'write:user']);
|
||||
// F6: the child-scope cap first ships in v1.27.0.
|
||||
export const MIN_VERSION = Object.freeze([1, 27, 0]);
|
||||
// Step 5: pages of 50, at most 20 of them.
|
||||
export const REPO_PAGE = 50;
|
||||
export const REPO_PAGES = 20;
|
||||
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
|
||||
const MAX_BODY = 4 * 1024 * 1024;
|
||||
const CALL_MS = 15000;
|
||||
const META_KEYS = ['url', 'account', 'rotateBy', 'setAt'];
|
||||
const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
||||
|
||||
// C1: numbers, never strings, so 1.9.0 is below 1.27.0. A prerelease is below its release, so
|
||||
// 1.27.0-rc0 refuses (F6). Build metadata after "+" is ignored. Anything that doesn't parse refuses.
|
||||
const VERSION = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
||||
export function versionAtLeast(value, floor = MIN_VERSION) {
|
||||
const m = typeof value === 'string' ? VERSION.exec(value) : null;
|
||||
if (!m) return false;
|
||||
const v = [m[1], m[2], m[3]].map(Number);
|
||||
for (let i = 0; i < 3; i++) if (v[i] !== floor[i]) return v[i] > floor[i];
|
||||
return !m[4];
|
||||
}
|
||||
|
||||
// Section 2's binding. Anything off refuses invalid-request; the broker checks the agent, its definition,
|
||||
// identity, publish list and the admin slot inside the raise transaction. rotateBy is a real calendar day
|
||||
// after `today`, the bus clock's UTC day: the rule row 199's host grants use (hostGrantCheck), since 199
|
||||
// names no other rotation window. Returns a fresh object with only the bound keys.
|
||||
export function giteaGrantCheck(grant, today) {
|
||||
const allowed = ['method', 'agent', 'rotateBy', 'target'];
|
||||
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
|
||||
const own = Object.keys(grant);
|
||||
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
|
||||
if (grant.method !== 'gitea') deny('invalid-request');
|
||||
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
|
||||
const target = grant.target;
|
||||
if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).join() !== 'projects') deny('invalid-request');
|
||||
const projects = target.projects;
|
||||
if (!Array.isArray(projects) || projects.length < 1 || projects.length > 50) deny('invalid-request');
|
||||
if (projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) || new Set(projects).size !== projects.length) deny('invalid-request');
|
||||
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
|
||||
return { method: 'gitea', agent: grant.agent, target: { projects: [...projects] }, rotateBy: grant.rotateBy };
|
||||
}
|
||||
|
||||
// Section 2: over the agent's identity, its publish list and the remote of every project in it, since
|
||||
// step 5 checks against all of them. `projects` is the business's project map.
|
||||
export const identitySha256 = (role, projects) =>
|
||||
sha256({
|
||||
identity: role.identity ?? null,
|
||||
publish: role.publish ?? [],
|
||||
remotes: Object.fromEntries((role.publish ?? []).map((p) => [p, projects?.[p]?.remote ?? null])),
|
||||
});
|
||||
// Section 2: over the admin slot's metadata, never the token.
|
||||
export const adminSha256 = (meta) => sha256({ url: meta.url, account: meta.account, setAt: meta.setAt });
|
||||
// Step 3: `mosaic-<tag>-<decision>`. grantTag already starts with "mosaic-".
|
||||
export const tokenName = (business, agent, decision) => `${grantTag(business, agent, 'gitea')}-${decision}`;
|
||||
export { definitionSha256 };
|
||||
|
||||
// Section 3d: one HTTPS call. Never rejects: no answer (refused, reset, TLS failure, timeout, a body over
|
||||
// the cap) is status 0. A redirect is returned as its status, never followed. `auth` is {token} for the
|
||||
// `token` header or {basic: [account, token]} (F1). The result is {status, json, total}, total from
|
||||
// X-Total-Count; it holds no header and no request detail.
|
||||
export function send({ url, method, path, auth, body, ca, timeoutMs = CALL_MS }) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false,
|
||||
req = null;
|
||||
const end = (r) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve(r);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
req?.destroy();
|
||||
end({ status: 0, json: null, total: null });
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const headers = {
|
||||
accept: 'application/json',
|
||||
authorization: auth.basic ? 'Basic ' + Buffer.from(auth.basic.join(':')).toString('base64') : 'token ' + auth.token,
|
||||
};
|
||||
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
||||
if (data) {
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = String(data.length);
|
||||
}
|
||||
req = httpsRequest(new URL(path, url), { method, headers, ca, agent: false }, (res) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
res.on('data', (c) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
req.destroy();
|
||||
end({ status: 0, json: null, total: null });
|
||||
} else chunks.push(c);
|
||||
});
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
|
||||
} catch {}
|
||||
const t = res.headers['x-total-count'];
|
||||
end({ status: res.statusCode, json, total: t === undefined || !/^\d+$/.test(t) ? null : Number(t) });
|
||||
});
|
||||
res.on('error', () => end({ status: 0, json: null, total: null }));
|
||||
});
|
||||
req.on('error', () => end({ status: 0, json: null, total: null }));
|
||||
req.end(data ?? undefined);
|
||||
} catch {
|
||||
end({ status: 0, json: null, total: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const segment = (value) => encodeURIComponent(String(value));
|
||||
|
||||
// C1 to C4 (section 3a), each call through the api's log. Throws the refusal code. Returns the admin
|
||||
// token's own name from C3 (F7), which rotate deletes by.
|
||||
export async function adminChecks(api, account) {
|
||||
const v = await api.version();
|
||||
if (v.status !== 200) deny('gitea-failed');
|
||||
if (!versionAtLeast(v.json?.version)) deny('gitea-too-old');
|
||||
const self = await api.self();
|
||||
if (self.status === 401 || self.status === 403) deny('grant-admin-account');
|
||||
if (self.status !== 200) deny('gitea-failed');
|
||||
if (self.json?.login !== account || self.json?.is_admin !== true) deny('grant-admin-account');
|
||||
const t = await api.tokenScopes();
|
||||
if (t.status !== 200) deny(t.status === 401 || t.status === 403 ? 'grant-admin-account' : 'gitea-failed');
|
||||
const scopes = t.json?.scopes;
|
||||
if (!Array.isArray(scopes) || [...scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
|
||||
const r = await api.reposCheck();
|
||||
if (r.status !== 200 || !Array.isArray(r.json)) deny('gitea-failed');
|
||||
if (r.json.length !== 0 || (r.total !== null && r.total !== 0)) deny('grant-admin-not-dedicated');
|
||||
return typeof t.json?.name === 'string' ? t.json.name : null;
|
||||
}
|
||||
|
||||
// One token file's text, read with Credentials' checks: no symlink, a regular file the stack's user owns,
|
||||
// 0600, at most 16 KiB, then the token format. null if `missing` and there is no file.
|
||||
function readSecretFile(file, { missing = false } = {}) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
} catch (e) {
|
||||
if (missing && e.code === 'ENOENT') return null;
|
||||
deny('credential-file');
|
||||
}
|
||||
try {
|
||||
const s = fstatSync(fd);
|
||||
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16384) deny('credential-file');
|
||||
return readFileSync(fd, 'utf8');
|
||||
} catch (e) {
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('credential-file');
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
export function checkToken(token) {
|
||||
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
|
||||
const t = token.replace(/\n$/, '');
|
||||
if (t.length < 16) deny('credential-format');
|
||||
return t;
|
||||
}
|
||||
function writeFileAtomic(dir, name, text) {
|
||||
const file = join(dir, name);
|
||||
const tmp = file + '.tmp';
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, text);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(dir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
}
|
||||
|
||||
// The trusted Gitea half. `ca` is a PEM the HTTPS calls trust instead of the system's, for the tests'
|
||||
// fake Gitea; production passes none. `send` is replaceable only for the tests' failure rows.
|
||||
export function giteaGrant({ configDir, ca = undefined, timeoutMs = CALL_MS, transport = send }) {
|
||||
const grantsRoot = join(configDir, 'secrets', 'grants');
|
||||
const slotDir = (business) => join(grantsRoot, business, 'gitea');
|
||||
const slotDirs = (business) => [grantsRoot, join(grantsRoot, business), slotDir(business)];
|
||||
// Section 3c: the slot's metadata, or null when there is no slot. A slot dir or file that isn't the
|
||||
// stack user's own and private refuses credential-file, as does metadata of any other shape.
|
||||
const adminMeta = (business) => {
|
||||
for (const dir of slotDirs(business)) if (!privateDir(dir, { missing: true })) return null;
|
||||
const text = readSecretFile(join(slotDir(business), 'admin.json'), { missing: true });
|
||||
if (text === null) return null;
|
||||
let meta;
|
||||
try {
|
||||
meta = JSON.parse(text);
|
||||
} catch {
|
||||
deny('credential-file');
|
||||
}
|
||||
if (
|
||||
!meta || typeof meta !== 'object' || Array.isArray(meta) ||
|
||||
Object.keys(meta).sort().join() !== [...META_KEYS].sort().join() ||
|
||||
!parseGiteaUrl(meta.url).url || !giteaName(meta.account) || !day(meta.rotateBy) ||
|
||||
typeof meta.setAt !== 'string' || !AT.test(meta.setAt)
|
||||
)
|
||||
deny('credential-file');
|
||||
return { url: meta.url, account: meta.account, rotateBy: meta.rotateBy, setAt: meta.setAt };
|
||||
};
|
||||
const adminToken = (business) => {
|
||||
for (const dir of slotDirs(business)) privateDir(dir);
|
||||
return checkToken(readSecretFile(join(slotDir(business), 'admin')));
|
||||
};
|
||||
// The admin api over one token. Basic on the token routes (F1), the `token` header elsewhere. Every
|
||||
// call goes through log(op, run, after).
|
||||
const adminApi = (url, account, token, log) => {
|
||||
const header = { token };
|
||||
const basic = { basic: [account, token] };
|
||||
const go = (op, method, path, auth, body, after) => log(op, () => transport({ url, method, path, auth, body, ca, timeoutMs }), after);
|
||||
return {
|
||||
version: () => go('version', 'GET', '/api/v1/version', header),
|
||||
self: () => go('self', 'GET', '/api/v1/user', header),
|
||||
tokenScopes: () => go('token-scopes', 'GET', '/api/v1/token', header),
|
||||
reposCheck: () => go('repos-check', 'GET', '/api/v1/user/repos?limit=1&page=1', header),
|
||||
userGet: (user) => go('user-get', 'GET', `/api/v1/users/${segment(user)}`, header),
|
||||
userCreate: (body, after) => go('user-create', 'POST', '/api/v1/admin/users', header, body, after),
|
||||
userPatch: (user, body) => go('user-patch', 'PATCH', `/api/v1/admin/users/${segment(user)}`, header, body),
|
||||
tokenList: (user, page) => go('token-list', 'GET', `/api/v1/users/${segment(user)}/tokens?limit=50&page=${page}`, basic),
|
||||
tokenCreate: (user, name, scopes = SEAT_SCOPES, op = 'token-create') =>
|
||||
go(op, 'POST', `/api/v1/users/${segment(user)}/tokens`, basic, { name, scopes: [...scopes] }),
|
||||
tokenDelete: (user, idOrName) => go('token-delete', 'DELETE', `/api/v1/users/${segment(user)}/tokens/${segment(idOrName)}`, basic),
|
||||
};
|
||||
};
|
||||
return {
|
||||
giteaGrantCheck,
|
||||
identitySha256,
|
||||
adminSha256,
|
||||
definitionSha256,
|
||||
tokenName,
|
||||
adminMeta,
|
||||
openGrants: openGiteaGrants,
|
||||
tokenUnknown,
|
||||
checkToken,
|
||||
forkUrl,
|
||||
repoPage: REPO_PAGE,
|
||||
repoPages: REPO_PAGES,
|
||||
// The admin api over the slot's token, for a grant, an undo, a revoke or a sweep. Refuses
|
||||
// grant-admin-missing with no slot.
|
||||
admin(business, log) {
|
||||
const meta = adminMeta(business);
|
||||
if (!meta) deny('grant-admin-missing');
|
||||
return { meta, api: adminApi(meta.url, meta.account, adminToken(business), log) };
|
||||
},
|
||||
adminChecks,
|
||||
// The seat token's calls, made with the minted token (steps 4 and 5) or the store file's (settle).
|
||||
seat(url, token, log) {
|
||||
const header = { token };
|
||||
const go = (op, method, path, body) => log(op, () => transport({ url, method, path, auth: header, body, ca, timeoutMs }));
|
||||
return {
|
||||
self: () => go('seat-self', 'GET', '/api/v1/user'),
|
||||
repos: (page) => go('seat-repos', 'GET', `/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`),
|
||||
forkGet: (owner, repo) => go('fork-get', 'GET', `/api/v1/repos/${segment(owner)}/${segment(repo)}`),
|
||||
fork: (owner, repo) => go('fork', 'POST', `/api/v1/repos/${segment(owner)}/${segment(repo)}/forks`, {}),
|
||||
whoami: () => go('token-whoami', 'GET', '/api/v1/token'),
|
||||
};
|
||||
},
|
||||
// Section 3e: `set` checks C1 to C4 with the given token before it writes anything. A slot already
|
||||
// there refuses grant-admin-exists, whatever its url (remove first). Returns the metadata written.
|
||||
async setAdmin(business, { url, account, rotateBy, token, setAt }, log) {
|
||||
const t = checkToken(token);
|
||||
if (adminMeta(business)) deny('grant-admin-exists');
|
||||
await adminChecks(adminApi(url, account, t, log), account);
|
||||
if (adminMeta(business)) deny('grant-admin-exists');
|
||||
const meta = { url, account, rotateBy, setAt };
|
||||
this.writeAdmin(business, t, meta);
|
||||
return meta;
|
||||
},
|
||||
// The slot's write: the token, then admin.json, each tmp, fsync, rename. Dirs are made 0700.
|
||||
writeAdmin(business, token, meta) {
|
||||
mkdirSync(join(configDir, 'secrets'), { recursive: true, mode: 0o700 });
|
||||
for (const dir of slotDirs(business)) {
|
||||
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
|
||||
privateDir(dir);
|
||||
}
|
||||
writeFileAtomic(slotDir(business), 'admin', checkToken(token) + '\n');
|
||||
writeFileAtomic(slotDir(business), 'admin.json', JSON.stringify(meta) + '\n');
|
||||
},
|
||||
// Section 3d rotate: C1 to C4, the successor minted for the admin account itself and named by the
|
||||
// UTC second, C2 and C3 with it, then the write. The old token is deleted by name with the new one;
|
||||
// `left` names it if that fails. A name already taken (two rotations in one second) refuses before
|
||||
// anything is written. `stamp` is yyyymmddThhmmssZ.
|
||||
async rotateAdmin(business, { rotateBy, setAt, stamp }, log) {
|
||||
const { meta, api } = this.admin(business, log);
|
||||
const oldName = await adminChecks(api, meta.account);
|
||||
const name = `mosaic-grant-${stamp}`;
|
||||
if (name === oldName) deny('grant-admin-name-taken');
|
||||
const r = await api.tokenCreate(meta.account, name, ADMIN_SCOPES, 'admin-rotate');
|
||||
if (r.status === 400 || r.status === 422) deny('grant-admin-name-taken');
|
||||
if (r.status !== 201 || typeof r.json?.sha1 !== 'string') deny('gitea-failed');
|
||||
const successor = checkToken(r.json.sha1);
|
||||
const next = adminApi(meta.url, meta.account, successor, log);
|
||||
const self = await next.self();
|
||||
if (self.status !== 200 || self.json?.login !== meta.account || self.json?.is_admin !== true) deny('grant-admin-account');
|
||||
const t = await next.tokenScopes();
|
||||
if (t.status !== 200 || !Array.isArray(t.json?.scopes) || [...t.json.scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
|
||||
const written = { url: meta.url, account: meta.account, rotateBy, setAt };
|
||||
this.writeAdmin(business, successor, written);
|
||||
let left = null;
|
||||
if (oldName === null) left = 'unknown';
|
||||
else {
|
||||
const d = await next.tokenDelete(meta.account, oldName);
|
||||
if (d.status !== 204 && d.status !== 404) left = oldName;
|
||||
}
|
||||
return { meta: written, tokenName: name, adminTokenLeft: left };
|
||||
},
|
||||
// rotate --finish: the old token by name, with the slot's token.
|
||||
async finishRotate(business, oldName, log) {
|
||||
const { meta, api } = this.admin(business, log);
|
||||
await adminChecks(api, meta.account);
|
||||
const d = await api.tokenDelete(meta.account, oldName);
|
||||
if (d.status !== 204 && d.status !== 404) deny('gitea-failed');
|
||||
},
|
||||
// Section 3d remove: the slot's files and dir go. No network call.
|
||||
removeAdmin(business) {
|
||||
if (!adminMeta(business)) deny('grant-admin-missing');
|
||||
for (const name of ['admin', 'admin.json', 'admin.tmp', 'admin.json.tmp'])
|
||||
try {
|
||||
unlinkSync(join(slotDir(business), name));
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
try {
|
||||
rmdirSync(slotDir(business));
|
||||
} catch {
|
||||
deny('credential-file');
|
||||
}
|
||||
},
|
||||
// Step 2: the never-stored password of a new user and the nonce in its full_name.
|
||||
password: () => randomBytes(24).toString('base64url'),
|
||||
nonce: () => randomBytes(8).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
// The store reference for an agent's gitea token, as Credentials checks it.
|
||||
export const giteaReference = (storeRoot, business, agent, rotateBy) => ({
|
||||
store: true,
|
||||
file: storeRoot ? join(storeRoot, business, agent, 'gitea') : null,
|
||||
rotateBy,
|
||||
});
|
||||
|
||||
// The grant executor's calls on Credentials, keyed by (business, agent). The broker gets this and never
|
||||
// Credentials itself, so it still has no use(); useFile hands the file's token only to a settle call.
|
||||
export function giteaKeys({ credentials, storeRoot }) {
|
||||
const at = (business, agent) => business + '/' + agent;
|
||||
return {
|
||||
claim: (business, agent) => credentials.claim(at(business, agent), 'gitea'),
|
||||
release: (business, agent, signal) => credentials.release(at(business, agent), 'gitea', signal),
|
||||
abortClaim: (business, agent) => credentials.abortClaim(at(business, agent), 'gitea'),
|
||||
known: (business, agent) => credentials.status().some((e) => e.instance === at(business, agent) && e.service === 'gitea'),
|
||||
register: (business, agent, rotateBy) => credentials.register(at(business, agent), 'gitea', giteaReference(storeRoot, business, agent, rotateBy)),
|
||||
unregister: (business, agent) => credentials.unregister(at(business, agent), 'gitea'),
|
||||
issue: (business, agent, token, rotateBy, commit) => credentials.issue(at(business, agent), 'gitea', token, rotateBy, commit),
|
||||
revoke: (business, agent) => credentials.revoke(at(business, agent), 'gitea'),
|
||||
reload: (business, agent, rotateBy) => credentials.reload(at(business, agent), 'gitea', rotateBy),
|
||||
hold: (business, agent) => credentials.hold(at(business, agent), 'gitea'),
|
||||
held: (business, agent) => credentials.held(at(business, agent), 'gitea'),
|
||||
useFile: (business, agent, fn) => credentials.useFile(at(business, agent), 'gitea', fn),
|
||||
unlinkStore: (business, agent) => credentials.unlinkStore(at(business, agent), 'gitea'),
|
||||
};
|
||||
}
|
||||
|
||||
// Section 5, before Credentials loads, read-only on the store: each gitea_grants record gives its agent
|
||||
// a store reference to gitea, and `held` lists the pairs whose file memory must not load. Those are an
|
||||
// agent whose last gitea grant.failed is grant-token-unknown with no grant.revoked after it, and an
|
||||
// agent with an open first grant whose last step is commit: its file may hold a token no record backs,
|
||||
// and the broker's reconcile unlinks it. Only grant evidence counts (duckworth-0067 N1). `references`
|
||||
// is changed in place: a record's reference replaces a business-file gitea reference, which names the
|
||||
// same store path or a file the record supersedes.
|
||||
export function giteaStart(store, storeRoot, businesses, references) {
|
||||
const held = [];
|
||||
for (const g of store.all('SELECT business, agent, rotate_by FROM gitea_grants ORDER BY business, agent')) {
|
||||
if (!Object.hasOwn(businesses, g.business)) continue;
|
||||
const refs = (references[g.business + '/' + g.agent] = { ...(references[g.business + '/' + g.agent] ?? {}) });
|
||||
refs.gitea = giteaReference(storeRoot, g.business, g.agent, g.rotate_by);
|
||||
}
|
||||
for (const business of Object.keys(businesses)) {
|
||||
for (const agent of tokenUnknown(store, business).keys()) held.push(business + '/' + agent + ':gitea');
|
||||
for (const o of openGiteaGrants(store, business))
|
||||
if (o.step?.step === 'commit' && o.step.previousTokenId === null) held.push(business + '/' + o.agent + ':gitea');
|
||||
}
|
||||
return { held: [...new Set(held)] };
|
||||
}
|
||||
|
||||
const evidence = (store, business, where, ...args) =>
|
||||
store.all(`SELECT seq, body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND ${where} ORDER BY seq`, business, ...args)
|
||||
.map((e) => ({ seq: e.seq, body: JSON.parse(e.body) }));
|
||||
|
||||
// agent -> {decision, seq}: the agents whose last gitea grant.failed is grant-token-unknown with no gitea
|
||||
// grant.revoked after it (section 5's held row).
|
||||
export function tokenUnknown(store, business) {
|
||||
const last = new Map();
|
||||
for (const e of evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action') IN ('grant.failed','grant.revoked')"))
|
||||
if (e.body.action === 'grant.revoked') last.delete(e.body.agent);
|
||||
else if (e.body.code === 'grant-token-unknown') last.set(e.body.agent, { decision: e.body.grantDecision, seq: e.seq });
|
||||
return last;
|
||||
}
|
||||
|
||||
// Every gitea grant spent with no grant.applied or grant.failed after its spend: {decision, agent, step,
|
||||
// seq, revokedAfter}. step is the last grant.step body or null, seq that step's or the spend's, and
|
||||
// revokedAfter says a gitea grant.revoked of the agent came after it (N9). A step 0 grant.failed comes
|
||||
// before the spend, so only ends after it count.
|
||||
export function openGiteaGrants(store, business) {
|
||||
const out = [];
|
||||
for (const spend of evidence(store, business, "json_extract(body,'$.operation')='grant.gitea'")) {
|
||||
const decision = spend.body.decision;
|
||||
const mine = evidence(store, business, "json_extract(body,'$.grantDecision')=? AND seq > ?", decision, spend.seq);
|
||||
if (mine.some((e) => e.body.action === 'grant.applied' || e.body.action === 'grant.failed')) continue;
|
||||
const last = mine.filter((e) => e.body.action === 'grant.step').at(-1) ?? null;
|
||||
out.push({ decision, agent: last?.body.agent ?? null, step: last?.body ?? null, seq: last?.seq ?? spend.seq });
|
||||
}
|
||||
for (const o of out)
|
||||
o.revokedAfter =
|
||||
o.agent !== null &&
|
||||
evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.agent')=? AND seq > ?", o.agent, o.seq)
|
||||
.length > 0;
|
||||
return out;
|
||||
}
|
||||
@@ -25,8 +25,9 @@ try {
|
||||
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,
|
||||
// so wait past that for the server's own answer. Row 236: the grants verbs call Gitea and get the
|
||||
// same wait. Every other verb keeps the 5 s default.
|
||||
timeout: request.verb === 'library.sync' || request.verb.startsWith('grants.') ? 130_000 : undefined,
|
||||
human: {
|
||||
business: request.business,
|
||||
pid: me.pid,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Row 236 (#436b, #1742, design r5 section 7): the Gitea users this stack made and the Gitea grants
|
||||
-- live in the store. The grant executor, revoke and sweep are the only writers, each in the
|
||||
-- transaction of its event. Only new tables; the grant event trigger from 3j covers their events.
|
||||
CREATE TABLE gitea_users (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
id INTEGER NOT NULL CHECK (id > 0),
|
||||
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent)
|
||||
) STRICT;
|
||||
CREATE TABLE gitea_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
token_id INTEGER NOT NULL CHECK (token_id > 0),
|
||||
token_name TEXT NOT NULL,
|
||||
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent),
|
||||
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
|
||||
) STRICT;
|
||||
@@ -18,14 +18,12 @@ import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { hostExecutor, sweepRunDir } from './host-run.mjs';
|
||||
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
|
||||
import { verifyPrincipal } from './principal.mjs';
|
||||
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
import { createLimits } from './limits.mjs';
|
||||
import { createProcessing } from '../../processing-control/src/coordinator.mjs';
|
||||
import { discordProcessingHooks } from '../../discord/src/processing.mjs';
|
||||
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
|
||||
// Human limits writes use the shared organization writer behind the verified transport.
|
||||
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
|
||||
@@ -94,7 +92,7 @@ export async function startBroker({
|
||||
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
||||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||||
throw new BusError('invalid-host-request');
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null;
|
||||
let store, credentials, server, adapter, syncTimer = null, syncPass = null;
|
||||
try {
|
||||
const references = {};
|
||||
for (const [business, b] of Object.entries(businesses)) {
|
||||
@@ -123,7 +121,10 @@ export async function startBroker({
|
||||
// Row 235 (r6 section 6, N1): a host.<id> key file with no record goes before Credentials loads.
|
||||
for (const entry of sweepHostKeys(storeRoot, Object.keys(businesses), granted))
|
||||
process.stderr.write(`hosts: ${JSON.stringify({ event: 'host-key-swept', ...entry })}\n`);
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
|
||||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
||||
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held });
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
@@ -141,20 +142,11 @@ export async function startBroker({
|
||||
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
|
||||
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
|
||||
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
|
||||
let broker;
|
||||
processing = createProcessing({ dataRoot, ...discordProcessingHooks(dataRoot), containerStop: createContainerStopper({ dataRoot }),
|
||||
onTransition: () => broker.reconcileProcessing(),
|
||||
onStop: stop => broker.recordProcessingStop(stop),
|
||||
log: line => process.stderr.write(`${line}\n`),
|
||||
});
|
||||
// C2-04: mirrors are host state, never session checkouts. An unsafe
|
||||
// existing mirror refuses startup before a socket can become ready.
|
||||
for (const business of Object.values(businesses))
|
||||
for (const [project, entry] of Object.entries(business.projects ?? {}))
|
||||
if (entry.kind === 'library') {
|
||||
try { await processing.work(() => libraryModule.ensureMirror({ business, project, dataRoot })); }
|
||||
catch (e) { if (!e.code?.startsWith('processing-')) throw e; }
|
||||
}
|
||||
if (entry.kind === 'library') await libraryModule.ensureMirror({ business, project, dataRoot });
|
||||
// C2-03a: the slot is <configDir>/secrets/<business>/library-gitea (design 10 step 4). Sync only
|
||||
// checks that it is there; the askpass helper reads it.
|
||||
const slotDir = librarySync?.configDir ?? organization?.dir ?? null;
|
||||
@@ -162,11 +154,13 @@ export async function startBroker({
|
||||
slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null,
|
||||
...(librarySync ? { command: librarySync.command } : {}),
|
||||
});
|
||||
broker = new Broker({
|
||||
const broker = new Broker({
|
||||
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
|
||||
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
|
||||
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
|
||||
processing,
|
||||
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
|
||||
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
|
||||
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -174,12 +168,12 @@ export async function startBroker({
|
||||
library: {
|
||||
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
sync: (context, { accept }) => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept }),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
},
|
||||
project: {
|
||||
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
||||
writeRecord: context => processing.work(() => projectModule.writeRecord({ ...context, dataRoot })),
|
||||
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
|
||||
listFiles: context => projectModule.listProjectFiles({ ...context, dataRoot }),
|
||||
readFile: context => projectModule.readProjectFile({ ...context, dataRoot }),
|
||||
writeFile: context => projectModule.writeHumanFile({ ...context, dataRoot }),
|
||||
@@ -218,13 +212,6 @@ export async function startBroker({
|
||||
}
|
||||
},
|
||||
});
|
||||
broker.reconcileProcessing();
|
||||
processing.start();
|
||||
const processingWork = () => {
|
||||
const state = processing.read();
|
||||
if (state.invalid) throw new BusError(state.code);
|
||||
if (state.halted) throw new BusError('processing-halted');
|
||||
};
|
||||
// Process identities must be supplied for every managed run when using the human transport.
|
||||
const launchRecords = [];
|
||||
function bindLaunch(r) {
|
||||
@@ -242,7 +229,7 @@ export async function startBroker({
|
||||
const bound = launches.map(bindLaunch);
|
||||
const readCaps = readers.map((business) => ({ business, cap: broker.bindReader({ business }) }));
|
||||
if (tasks) {
|
||||
adapter = await tasks({ broker, credentials, businesses, processing });
|
||||
adapter = await tasks({ broker, credentials, businesses });
|
||||
if (typeof adapter?.handle !== 'function' || !Number.isSafeInteger(adapter.timeout) || adapter.timeout < 1)
|
||||
throw new BusError('invalid-adapter');
|
||||
}
|
||||
@@ -261,18 +248,9 @@ export async function startBroker({
|
||||
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
|
||||
};
|
||||
const path = join(store.directory, 'broker.sock');
|
||||
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
|
||||
// signal with processing cancellation without changing its proof or credential entry points.
|
||||
const host = runRoot ? hostExecutor({ root: runRoot, credentials: {
|
||||
busy: (...args) => credentials.busy(...args),
|
||||
useFor: (session, name, fn, options) => credentials.useFor(session, name, (token, meta) =>
|
||||
fn(token, { ...meta, signal: meta.signal
|
||||
? AbortSignal.any([meta.signal, processing.signal()]) : processing.signal() }), options),
|
||||
} }) : null;
|
||||
server = await serve({
|
||||
web: (business) => web.get(business) ?? null,
|
||||
host: host ? { busy: (...args) => host.busy(...args),
|
||||
run: (...args) => processing.work(() => host.run(...args)) } : null,
|
||||
host: runRoot ? hostExecutor({ credentials, root: runRoot }) : null,
|
||||
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
|
||||
broker,
|
||||
path,
|
||||
@@ -289,6 +267,8 @@ export async function startBroker({
|
||||
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
|
||||
// wait on the network.
|
||||
broker.reconcileHosts();
|
||||
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
|
||||
broker.reconcileGitea();
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
// A refusal is recorded against the caller, as request() records one.
|
||||
@@ -326,11 +306,11 @@ export async function startBroker({
|
||||
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
|
||||
const entry = { state: 'running' };
|
||||
remotes.set(ticket, entry);
|
||||
processing.work(() => withGitea(ticket, (token) =>
|
||||
withGitea(ticket, (token) =>
|
||||
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
|
||||
throw e instanceof WorkingError ? new BusError(e.code) : e;
|
||||
}),
|
||||
)).then(
|
||||
).then(
|
||||
(r) => Object.assign(entry, { state: 'pushed', ...r }),
|
||||
(e) => {
|
||||
// Not a refusal: the error's name and code only, never a message that could carry git's output.
|
||||
@@ -403,13 +383,7 @@ export async function startBroker({
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = () => {
|
||||
const state = processing.read();
|
||||
if (!syncPass && !state.halted && !state.invalid)
|
||||
syncPass = processing.work(syncAll).catch(e => {
|
||||
if (!e.code?.startsWith('processing-')) syncLog({ event: 'library-sync-refused', code: 'library-sync-failed' });
|
||||
}).finally(() => { syncPass = null; });
|
||||
};
|
||||
const tick = () => { if (!syncPass) syncPass = syncAll().finally(() => { syncPass = null; }); };
|
||||
if (timed.length) {
|
||||
tick();
|
||||
syncTimer = setInterval(tick, librarySync?.intervalMs ?? libraryModule.SYNC_INTERVAL_MS);
|
||||
@@ -425,10 +399,7 @@ export async function startBroker({
|
||||
threadsState: (business) => broker.threadsState(business),
|
||||
threadIdle: (r) => broker.threadIdle(r),
|
||||
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
|
||||
mayLaunch: async ({ business, instance, provider, model, account = null }) => {
|
||||
processingWork();
|
||||
return limits.mayLaunch(business, { instance, provider, model, account });
|
||||
},
|
||||
mayLaunch: ({ business, instance, provider, model, account = null }) => limits.mayLaunch(business, { instance, provider, model, account }),
|
||||
identity: (cap) => broker.identity(cap),
|
||||
authorizeLaunch,
|
||||
checkPush,
|
||||
@@ -455,7 +426,6 @@ export async function startBroker({
|
||||
await stopSync();
|
||||
await server.close();
|
||||
await adapter?.close?.();
|
||||
await processing?.close();
|
||||
store.close();
|
||||
credentials.close();
|
||||
},
|
||||
@@ -463,7 +433,6 @@ export async function startBroker({
|
||||
} catch (e) {
|
||||
await server?.close();
|
||||
await adapter?.close?.();
|
||||
await processing?.close();
|
||||
store?.close();
|
||||
credentials?.close();
|
||||
throw e;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
import { inspectInterpreter } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
const LIMIT = 65536;
|
||||
@@ -134,12 +134,13 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Async effects retain the synchronous path for every other verb.
|
||||
if (r.verb === 'processing.halt' || r.verb === 'processing.resume' ||
|
||||
r.verb === 'usage.begin' || r.verb === 'usage.end' ||
|
||||
// Row138: only project Git effects need an await; preserve every other sync path below.
|
||||
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
|
||||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) || LIBRARY_VERBS.includes(r.verb) ||
|
||||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
|
||||
HOSTS_VERBS.includes(r.verb) ||
|
||||
// Row 236 (#1742): the grants verbs may call Gitea several times, 15 s each at most.
|
||||
GRANTS_VERBS.includes(r.verb) ||
|
||||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit') ||
|
||||
Object.hasOwn(r.args ?? {}, 'grant')))) {
|
||||
const own = transient;
|
||||
|
||||
@@ -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 = 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0';
|
||||
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,11 @@ const migrations = [{
|
||||
from: '3i',
|
||||
to: '3j',
|
||||
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 236 (#1742, #436b section 7): the Gitea users the stack made and the Gitea grants. Only new tables.
|
||||
from: '3j',
|
||||
to: '3k',
|
||||
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
// every call and labels every reply untrusted (broker.mjs, requestWeb).
|
||||
|
||||
import { SEARCH_MAX_QUERY_CHARS, WEB_DEFAULTS, WEB_REFUSAL, WebRefusal, webFetch } from "../../discord/src/web.mjs";
|
||||
import { currentProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
export const WEB_SEARCH_NOT_CONFIGURED = "search is not configured for this business";
|
||||
// Under the runner's own 30 s wait for a tool call, so a slow source is a refusal, not a lost reply.
|
||||
@@ -29,7 +28,6 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
|
||||
// so a stalled backend is cut off, not just ignored: web.mjs destroys its request on abort, and a
|
||||
// search-source module gets the signal as the `signal` of its last argument.
|
||||
const within = (start) => {
|
||||
const processing = currentProcessing(); processing?.guard();
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const late = new Promise((_, reject) => {
|
||||
@@ -38,8 +36,7 @@ export function webService({ search = null, maxFetchBytes = WEB_DEFAULTS.maxFetc
|
||||
reject(new WebRefusal(WEB_REFUSAL.TIMEOUT));
|
||||
}, deadlineMs);
|
||||
});
|
||||
const signal = processing ? AbortSignal.any([processing.signal, controller.signal]) : controller.signal;
|
||||
return Promise.race([start(signal), late]).finally(() => clearTimeout(timer));
|
||||
return Promise.race([start(controller.signal), late]).finally(() => clearTimeout(timer));
|
||||
};
|
||||
async function run(verb, args) {
|
||||
if (verb === "web.fetch") {
|
||||
|
||||
@@ -70,6 +70,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 +78,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]);
|
||||
@@ -86,7 +87,9 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const old = ' account TEXT CHECK (account IS NULL),\n';
|
||||
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'];
|
||||
// Row 236 (#1742) adds 3j→3k's; its test is gitea-migration.test.mjs.
|
||||
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1',
|
||||
'gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
|
||||
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
|
||||
const store = new Store(root);
|
||||
@@ -125,7 +128,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 +140,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);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// 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.
|
||||
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
|
||||
// transport change.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -14,7 +16,7 @@ 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": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
|
||||
"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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Row 236 (#1742, #436b r5 section 9): a fake Gitea API for the grant executor, on 127.0.0.1 over TLS
|
||||
// with row 199's scratch CA. It keeps users, their tokens and repositories in memory and answers the
|
||||
// routes gitea-grant.mjs calls, with Gitea's auth rules as section 1 found them: the token routes take
|
||||
// Basic auth as the account itself or a site admin, the rest the `token` header. Every token is a fake
|
||||
// made here. The request log holds the method, the path, the auth kind and the login, never a token.
|
||||
// `rule(match, answer)` makes one route answer otherwise: a status and body, `hang` (no answer, so the
|
||||
// caller's timeout runs) or `drop` (the socket closes). Nothing here leaves 127.0.0.1.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:https';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const fakeToken = () => randomBytes(20).toString('hex');
|
||||
|
||||
export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic-grant', adminScopes = ['write:admin', 'write:repository', 'write:user'] } = {}) {
|
||||
const tls = scratchCa(dir);
|
||||
let nextId = 1;
|
||||
const users = new Map();
|
||||
const repos = [];
|
||||
const rules = [];
|
||||
const requests = [];
|
||||
const state = { version };
|
||||
const lower = (x) => String(x).toLowerCase();
|
||||
const userJson = (u) => ({ id: u.id, login: u.login, email: u.email, full_name: u.full_name, is_admin: u.is_admin, prohibit_login: u.prohibit_login });
|
||||
const repoJson = (r) => ({
|
||||
id: r.id, name: r.name, full_name: `${r.owner}/${r.name}`, owner: { login: r.owner }, fork: Boolean(r.parent),
|
||||
parent: r.parent ? { full_name: r.parent } : null,
|
||||
});
|
||||
const api = {
|
||||
url: null,
|
||||
ca: readFileSync(tls.ca, 'utf8'),
|
||||
state,
|
||||
requests,
|
||||
users,
|
||||
repos,
|
||||
// Every token value this fake made, for the tests' leak checks. Never printed.
|
||||
issued: [],
|
||||
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
|
||||
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
|
||||
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
|
||||
users.set(lower(login), u);
|
||||
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
|
||||
},
|
||||
addToken(login, name, scopes) {
|
||||
const u = users.get(lower(login));
|
||||
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
|
||||
u.tokens.push(tok);
|
||||
api.issued.push(tok.sha1);
|
||||
return tok;
|
||||
},
|
||||
removeUser(login) {
|
||||
users.delete(lower(login));
|
||||
},
|
||||
addRepo(owner, name, { parent = null, collaborators = [] } = {}) {
|
||||
const r = { id: nextId++, owner, name, parent, collaborators: collaborators.map(lower) };
|
||||
repos.push(r);
|
||||
return r;
|
||||
},
|
||||
tokens: (login) => users.get(lower(login))?.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes })) ?? null,
|
||||
rule(match, answer) {
|
||||
const r = { match, answer };
|
||||
rules.push(r);
|
||||
return () => rules.splice(rules.indexOf(r), 1);
|
||||
},
|
||||
// The ops of the log, as "METHOD path" lines without the query.
|
||||
calls: () => requests.map((r) => `${r.method} ${r.path.split('?')[0]}`),
|
||||
};
|
||||
const adminUser = api.addUser(admin, { is_admin: true, scopes: adminScopes, tokenName: 'mosaic-grant-bootstrap' });
|
||||
api.admin = adminUser.user;
|
||||
api.adminToken = adminUser.token.sha1;
|
||||
|
||||
const byToken = (sha1) => {
|
||||
for (const u of users.values()) {
|
||||
const tok = u.tokens.find((x) => x.sha1 === sha1);
|
||||
if (tok) return { user: u, token: tok };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const authOf = (req) => {
|
||||
const h = req.headers.authorization ?? '';
|
||||
if (h.startsWith('token ')) return { kind: 'token', who: byToken(h.slice(6)) };
|
||||
if (h.startsWith('Basic ')) {
|
||||
const [login, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
|
||||
const who = byToken(rest.join(':'));
|
||||
return { kind: 'basic', who: who && lower(who.user.login) === lower(login) ? who : null };
|
||||
}
|
||||
return { kind: 'none', who: null };
|
||||
};
|
||||
const reachable = (u) => repos.filter((r) => lower(r.owner) === lower(u.login) || r.collaborators.includes(lower(u.login)));
|
||||
|
||||
function route(method, path, query, auth, body) {
|
||||
const who = auth.who;
|
||||
const p = path.split('/').slice(3).map(decodeURIComponent);
|
||||
if (method === 'GET' && path === '/api/v1/version') return [200, { version: state.version }];
|
||||
if (!who) return [401, { message: 'unauthorized' }];
|
||||
const me = who.user;
|
||||
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
|
||||
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
|
||||
if (method === 'GET' && path === '/api/v1/user/repos') {
|
||||
const all = reachable(me);
|
||||
const limit = Number(query.get('limit') ?? 30);
|
||||
const page = Number(query.get('page') ?? 1);
|
||||
return [200, all.slice((page - 1) * limit, page * limit).map(repoJson), all.length];
|
||||
}
|
||||
if (p[0] === 'users' && p.length === 2 && method === 'GET') {
|
||||
const u = users.get(lower(p[1]));
|
||||
return u ? [200, userJson(u)] : [404, { message: 'user does not exist' }];
|
||||
}
|
||||
if (p[0] === 'admin' && p[1] === 'users') {
|
||||
if (!me.is_admin || !who.token.scopes.includes('write:admin')) return [403, { message: 'forbidden' }];
|
||||
if (method === 'POST' && p.length === 2) {
|
||||
if (users.has(lower(body.username))) return [422, { message: 'user already exists' }];
|
||||
const u = { id: nextId++, login: body.username, email: body.email, full_name: body.full_name, is_admin: false, prohibit_login: false, tokens: [] };
|
||||
users.set(lower(u.login), u);
|
||||
return [201, userJson(u)];
|
||||
}
|
||||
if (method === 'PATCH' && p.length === 3) {
|
||||
const u = users.get(lower(p[2]));
|
||||
if (!u) return [404, { message: 'user does not exist' }];
|
||||
if (typeof body.prohibit_login === 'boolean') u.prohibit_login = body.prohibit_login;
|
||||
return [200, userJson(u)];
|
||||
}
|
||||
}
|
||||
if (p[0] === 'users' && p[2] === 'tokens') {
|
||||
if (auth.kind !== 'basic') return [401, { message: 'basic auth required' }];
|
||||
const u = users.get(lower(p[1]));
|
||||
if (lower(me.login) !== lower(p[1]) && !me.is_admin) return [403, { message: 'forbidden' }];
|
||||
if (!u) return [404, { message: 'user does not exist' }];
|
||||
if (method === 'GET' && p.length === 3) {
|
||||
const limit = Number(query.get('limit') ?? 30);
|
||||
const page = Number(query.get('page') ?? 1);
|
||||
const list = u.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes }));
|
||||
return [200, list.slice((page - 1) * limit, page * limit), list.length];
|
||||
}
|
||||
if (method === 'POST' && p.length === 3) {
|
||||
if (u.tokens.some((x) => x.name === body.name)) return [400, { message: 'access token name has been used already' }];
|
||||
const tok = api.addToken(u.login, body.name, body.scopes);
|
||||
return [201, { id: tok.id, name: tok.name, sha1: tok.sha1, scopes: tok.scopes }];
|
||||
}
|
||||
if (method === 'DELETE' && p.length === 4) {
|
||||
const i = u.tokens.findIndex((x) => String(x.id) === p[3] || x.name === p[3]);
|
||||
if (i < 0) return [404, { message: 'token not found' }];
|
||||
u.tokens.splice(i, 1);
|
||||
return [204, null];
|
||||
}
|
||||
}
|
||||
if (p[0] === 'repos' && p.length === 3 && method === 'GET') {
|
||||
const r = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
|
||||
return r ? [200, repoJson(r)] : [404, { message: 'not found' }];
|
||||
}
|
||||
if (p[0] === 'repos' && p.length === 4 && p[3] === 'forks' && method === 'POST') {
|
||||
const up = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
|
||||
if (!up) return [404, { message: 'not found' }];
|
||||
if (repos.some((x) => lower(x.owner) === lower(me.login) && lower(x.name) === lower(up.name))) return [409, { message: 'exists' }];
|
||||
return [202, repoJson(api.addRepo(me.login, up.name, { parent: `${up.owner}/${up.name}` }))];
|
||||
}
|
||||
return [404, { message: 'no route' }];
|
||||
}
|
||||
|
||||
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', async () => {
|
||||
const u = new URL(req.url, 'https://127.0.0.1');
|
||||
const auth = authOf(req);
|
||||
let body = null;
|
||||
try {
|
||||
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
|
||||
} catch {}
|
||||
const entry = { method: req.method, path: u.pathname + u.search, auth: auth.kind, as: auth.who?.user.login ?? null };
|
||||
requests.push(entry);
|
||||
const rule = rules.find((r) => r.match(entry, body));
|
||||
// An answer function may be async, to hold a call open until the test lets it go.
|
||||
const answer = rule ? (typeof rule.answer === 'function' ? await rule.answer(entry, body) : rule.answer) : null;
|
||||
if (answer?.hang) return;
|
||||
if (answer?.drop) return req.socket.destroy();
|
||||
const [status, json, total] = answer && !answer.pass ? [answer.status, answer.json ?? null, answer.total] : route(req.method, u.pathname, u.searchParams, auth, body ?? {});
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
if (total !== undefined && total !== null) headers['x-total-count'] = String(total);
|
||||
res.writeHead(status, headers);
|
||||
res.end(json === null || status === 204 ? undefined : JSON.stringify(json));
|
||||
});
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
t.after(() => new Promise((r) => {
|
||||
server.closeAllConnections?.();
|
||||
server.close(() => r());
|
||||
}));
|
||||
api.url = `https://127.0.0.1:${server.address().port}`;
|
||||
return api;
|
||||
}
|
||||
+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;
|
||||
@@ -0,0 +1,200 @@
|
||||
// Row 236 (#1742, #436b r5 section 9, X-G3): the grant path end to end on a scratch gitea/gitea:1.27.1, as
|
||||
// section 1's facts were found: a throwaway container on 127.0.0.1 over TLS with row 199's scratch CA, its
|
||||
// data inside the container, removed after. The test never pulls an image; without Docker or the image it
|
||||
// skips. The admin and seat tokens are scratch tokens this container made; none is printed, and no test
|
||||
// here reaches git.mosaicstack.dev or any provider.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { request } from 'node:https';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { SEAT_SCOPES, giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const IMAGE = 'gitea/gitea:1.27.1';
|
||||
const USER = 'demo-coder';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
||||
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
||||
|
||||
const freePort = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
// One HTTPS call to the scratch container, trusting only the scratch CA.
|
||||
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
||||
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
resolve({ status: res.statusCode, json });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0, json: null }));
|
||||
req.end(data ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('X-G3: on scratch 1.27.1, a grant makes the user, mints, forks, checks reach, writes the file, and revoke deletes the token', { skip, timeout: 300000 }, async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'g236-docker-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tlsDir = join(dir, 'tls');
|
||||
const tls = scratchCa(tlsDir);
|
||||
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
||||
chmodSync(tlsDir, 0o755);
|
||||
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
||||
const ca = readFileSync(tls.ca, 'utf8');
|
||||
const port = await freePort();
|
||||
const url = `https://127.0.0.1:${port}`;
|
||||
const name = 'g236-' + randomBytes(6).toString('hex');
|
||||
t.after(() => docker('rm', '-f', name));
|
||||
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
||||
'-e', 'GITEA__security__INSTALL_LOCK=true',
|
||||
'-e', 'GITEA__database__DB_TYPE=sqlite3',
|
||||
'-e', 'GITEA__service__DISABLE_REGISTRATION=true',
|
||||
'-e', 'GITEA__server__PROTOCOL=https',
|
||||
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem',
|
||||
'-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
||||
'-e', `GITEA__server__ROOT_URL=${url}/`,
|
||||
IMAGE);
|
||||
assert.equal(started.status, 0, started.stderr);
|
||||
const end = Date.now() + 120000;
|
||||
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
||||
if (Date.now() > end) assert.fail('the scratch Gitea did not answer in 120 s: ' + docker('logs', '--tail', '20', name).stdout);
|
||||
await sleep(500);
|
||||
}
|
||||
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
||||
const password = () => randomBytes(18).toString('base64url');
|
||||
for (const [user, admin] of [['mosaic-grant', true], ['acme', false]]) {
|
||||
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`, '--password', password(), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
const minted = gitea('admin', 'user', 'generate-access-token', '--username', 'mosaic-grant', '--token-name', 'mosaic-grant-bootstrap',
|
||||
'--scopes', 'write:admin,write:repository,write:user', '--raw');
|
||||
assert.equal(minted.status, 0, 'generate-access-token failed');
|
||||
const adminToken = minted.stdout.trim();
|
||||
assert.match(adminToken, /^[0-9a-f]{40}$/, 'the admin token has the shape of a Gitea token');
|
||||
const basic = 'Basic ' + Buffer.from(`mosaic-grant:${adminToken}`).toString('base64');
|
||||
for (const repo of ['app', 'lib']) {
|
||||
const r = await call(url, ca, 'POST', '/api/v1/admin/users/acme/repos', { auth: 'token ' + adminToken, body: { name: repo, auto_init: true } });
|
||||
assert.equal(r.status, 201);
|
||||
}
|
||||
|
||||
const configDir = join(dir, 'cfg');
|
||||
mkdirSync(configDir, { mode: 0o700 });
|
||||
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
||||
const storeRoot = join(configDir, 'secrets', 'agents');
|
||||
const store = new Store(join(dir, 'data'));
|
||||
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
|
||||
t.after(() => {
|
||||
credentials.close();
|
||||
store.close();
|
||||
});
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url, user: USER } }, publish: ['app', 'lib'] },
|
||||
},
|
||||
definitions: {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
||||
},
|
||||
projects: { app: { remote: `${url}/acme/app` }, lib: { remote: `${url}/acme/lib` } },
|
||||
vars: {},
|
||||
};
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses: { demo: business },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
giteaGrant: giteaGrant({ configDir, ca, timeoutMs: 20000 }),
|
||||
giteaKeys: giteaKeys({ credentials, storeRoot }),
|
||||
});
|
||||
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
||||
broker.request(chief, { verb: 'role.claim' });
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
// C1 to C4 on the real server, then the slot is written.
|
||||
await broker.request(human, { verb: 'grants.admin.set', args: { service: 'gitea', url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: adminToken + '\n' } });
|
||||
const d = await broker.request(chief, {
|
||||
verb: 'decision.raise',
|
||||
args: {
|
||||
action: 'credential.mint', question: 'Grant coder Gitea?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
||||
},
|
||||
});
|
||||
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||
const ended = () => events().find((e) => e.grantDecision === d.id && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||
const until = Date.now() + 120000;
|
||||
while (!ended() && Date.now() < until) await sleep(100);
|
||||
await broker.giteaIdle();
|
||||
const applied = ended();
|
||||
assert.equal(applied?.action, 'grant.applied', JSON.stringify(applied));
|
||||
|
||||
// The user, made with the nonce, and its id recorded.
|
||||
const nonce = events().find((e) => e.grantDecision === d.id && e.step === 'user').nonce;
|
||||
const u = await call(url, ca, 'GET', `/api/v1/users/${USER}`, { auth: 'token ' + adminToken });
|
||||
assert.equal(u.status, 200);
|
||||
assert.equal(u.json.full_name, `Coder [mosaic ${nonce}]`);
|
||||
assert.equal(u.json.is_admin, false);
|
||||
const row = store.get("SELECT id, nonce FROM gitea_users WHERE business='demo' AND agent='coder'");
|
||||
assert.deepEqual({ ...row }, { id: u.json.id, nonce });
|
||||
// One token, with the two scopes, at the id the record names.
|
||||
const list = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal(list.json.length, 1);
|
||||
assert.deepEqual([...list.json[0].scopes].sort(), [...SEAT_SCOPES]);
|
||||
const grant = store.get("SELECT token_id FROM gitea_grants WHERE business='demo' AND agent='coder'");
|
||||
assert.equal(list.json[0].id, grant.token_id);
|
||||
// The fork, with its parent.
|
||||
const fork = await call(url, ca, 'GET', `/api/v1/repos/${USER}/app`, { auth: 'token ' + adminToken });
|
||||
assert.equal(fork.status, 200);
|
||||
assert.equal(fork.json.parent?.full_name, 'acme/app');
|
||||
// The 0600 file, whose token Gitea knows as the recorded id; the value is compared, never printed.
|
||||
const file = join(storeRoot, 'demo', 'coder', 'gitea');
|
||||
assert.equal(statSync(file).mode & 0o777, 0o600);
|
||||
const who = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + readFileSync(file, 'utf8').trim() });
|
||||
assert.equal(who.status, 200);
|
||||
assert.equal(who.json.id, grant.token_id);
|
||||
|
||||
// Revoke deletes the token at Gitea: gone from the list, the file and the record gone.
|
||||
const out = await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'X-G3' } });
|
||||
assert.deepEqual(out.serviceLeft, []);
|
||||
await broker.giteaIdle();
|
||||
const after = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
|
||||
assert.equal(after.status, 200);
|
||||
assert.deepEqual(after.json, []);
|
||||
assert.equal(existsSync(file), false);
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM gitea_grants WHERE business='demo'").n, 0);
|
||||
// No scratch token reached the store.
|
||||
const text = store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body).join('\n');
|
||||
assert.ok(!text.includes(adminToken), 'the admin token reached the store');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
// Row 236 (#1742, #436b r5 section 7): the 3j→3k step adds the gitea_users and gitea_grants tables and
|
||||
// nothing else. It runs on a 3j store built here from the pinned 3j schema (02da6a2ae's schema.sql) and
|
||||
// fixture rows. No live store is read or copied.
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const ddl3j = readFileSync(new URL('./fixtures/schema-3j.sql', import.meta.url), 'utf8');
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const NEW = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
|
||||
function source3j(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gitea-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 decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', null, 'grant', '{"operation":"grant.host-enroll","decision":"d1"}');
|
||||
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'e2', AT, 'demo', 'action.allowed', '{}');
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants')").all()
|
||||
.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(); }
|
||||
}
|
||||
function 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 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(); }
|
||||
};
|
||||
// A copy of the bus module whose store.mjs, schema.sql and 3j→3k step the caller edits.
|
||||
async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
const moduleRoot = join(root, name);
|
||||
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
|
||||
if (store) assert.notEqual(edited, source);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gitea-store-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
store.run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
return store;
|
||||
}
|
||||
const user = (store, values = {}) => store.run(
|
||||
'INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(:business,:agent,:user,:id,:nonce,:decision)',
|
||||
{ business: 'demo', agent: 'coder', user: 'demo-coder', id: 7, nonce: '0123456789abcdef', decision: 'd1', ...values });
|
||||
const grant = (store, values = {}) => store.run(
|
||||
'INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(:business,:agent,:user,:token,:name,:projects,:rotate,:decision)',
|
||||
{ business: 'demo', agent: 'coder', user: 'demo-coder', token: 9, name: 'mosaic-demo-coder-gitea-d1', projects: '["app"]', rotate: '2026-12-01', decision: 'd1', ...values });
|
||||
|
||||
test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the two tables and their keys', t => {
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(rows(path), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
try {
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM gitea_users').n, 0);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM gitea_grants').n, 0);
|
||||
user(store);
|
||||
grant(store);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a 3j→3k step that fails partway leaves the store at 3j with every row and the schema intact', async t => {
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
// The real step plus a statement that fails only when events has a row, so the empty replay at
|
||||
// import passes and the store's run fails after both tables exist.
|
||||
const Failing = await engine(root, 'engine-failing', { step: (sql) => sql + 'INSERT INTO events SELECT * FROM events LIMIT 1;\n' });
|
||||
assert.throws(() => new Failing(root, { migrateSchema: true }), /events is append-only/);
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3k');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k', async t => {
|
||||
const { root, path } = source3j(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = rows(path), was = master(path);
|
||||
const Store3j = await engine(root, 'engine-3j', {
|
||||
schema: ddl3j,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
|
||||
assert.throws(() => new Store3j(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
test('gitea_users: one user per business and agent, an id above 0, a 16-hex nonce and a real decision', t => {
|
||||
const store = fresh(t);
|
||||
user(store);
|
||||
assert.throws(() => user(store, { user: 'other' }), /UNIQUE constraint failed: gitea_users\.business, gitea_users\.agent/);
|
||||
user(store, { business: 'other' });
|
||||
for (const nonce of ['0123456789abcde', '0123456789abcdef0', '0123456789ABCDEF', '0123456789abcdeg'])
|
||||
assert.throws(() => user(store, { agent: 'x', nonce }), /CHECK constraint failed/, nonce);
|
||||
assert.throws(() => user(store, { agent: 'x', id: 0 }), /CHECK constraint failed/);
|
||||
assert.throws(() => user(store, { agent: 'x', id: 'seven' }), /cannot store TEXT value in INTEGER column/);
|
||||
assert.throws(() => user(store, { agent: 'x', decision: 'nope' }), /FOREIGN KEY constraint failed/);
|
||||
});
|
||||
|
||||
test('gitea_grants: one grant per agent, a projects array, a token id above 0, and only for a recorded user', t => {
|
||||
const store = fresh(t);
|
||||
assert.throws(() => grant(store), /FOREIGN KEY constraint failed/);
|
||||
user(store);
|
||||
for (const projects of ['[]', '{}', '"app"', 'not json'])
|
||||
assert.throws(() => grant(store, { projects }), /CHECK constraint failed/, projects);
|
||||
assert.throws(() => grant(store, { token: 0 }), /CHECK constraint failed/);
|
||||
assert.throws(() => grant(store, { decision: 'nope' }), /FOREIGN KEY constraint failed/);
|
||||
grant(store);
|
||||
assert.throws(() => grant(store, { token: 10 }), /UNIQUE constraint failed/);
|
||||
assert.throws(() => store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'"), /FOREIGN KEY constraint failed/);
|
||||
store.run("DELETE FROM gitea_grants WHERE business='demo' AND agent='coder'");
|
||||
store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'");
|
||||
});
|
||||
@@ -16,6 +16,8 @@ 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 236 (#1742): 3j→3k's objects, after this step. Their test is gitea-migration.test.mjs.
|
||||
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -35,7 +37,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','gitea_users','gitea_grants')").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 +63,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,9 +87,9 @@ 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);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
@@ -109,7 +112,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
|
||||
@@ -120,14 +123,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);
|
||||
});
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
// Row 205 (193b1a, #1703, row 193 design 5.7, sage-2288): a user's messages are private from the
|
||||
// business owner, reader sessions and the tester on trail and replies; the run's governance is not.
|
||||
// Nothing makes a user session before FED R3, so a user's thread and its messages are written to the
|
||||
// store as R3's thread.send and the run's reply will write them.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, REPLIES } from '../src/broker.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
|
||||
// A thread owned by `owner`, on the business's assistant instance.
|
||||
function threadOf(store, project, owner, name) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
|
||||
id, AT, 'demo', project, 'assistant', owner, name,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
// A message with its thread row, its routed delivery and one event about it. The event names the
|
||||
// message's decision in its body, which is how #trail reaches it from the decision.
|
||||
function put(store, { thread = null, from_role, from_run, to_role, in_reply_to = null, decision = null, body }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, AT, 'demo', from_role, from_run, to_role, 'INFO', in_reply_to, decision, null, body,
|
||||
);
|
||||
if (thread) store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', id, thread);
|
||||
store.run('INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)', id, AT, 'routed', null, null, null);
|
||||
store.run(
|
||||
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,?,?,?,?,?)',
|
||||
randomUUID(), AT, 'demo', 'action.allowed', from_role, from_run, id, JSON.stringify(decision ? { decision } : {}),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
// From alice to the instance, as R3's thread.send writes it.
|
||||
const fromAlice = (store, thread, body, extra = {}) =>
|
||||
put(store, { thread, from_role: 'user', from_run: 'user:alice', to_role: 'assistant', body, ...extra });
|
||||
// The run's reply to alice, as R3's `to: 'user'` path writes it.
|
||||
const toAlice = (store, thread, body, extra = {}) =>
|
||||
put(store, { thread, from_role: 'assistant', from_run: 'assistant-run', to_role: 'user', body, ...extra });
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-message-filter-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: { chief: { definition: 'chief-of-staff', ...within(['message.send']) }, assistant: { definition: 'assistant', ...within(['message.send']) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
reader: b.bindReader({ business: 'demo' }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
};
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const project = call('human', 'project.create', { name: 'shared work' }).id;
|
||||
const U = threadOf(store, project, 'user:alice', 'alice');
|
||||
return { b, store, caps, call, project, U };
|
||||
}
|
||||
const sessions = ['human', 'reader', 'tester'];
|
||||
// What a caller sees of a call: its result, or its refusal's code and message.
|
||||
const answer = (fn) => {
|
||||
try {
|
||||
return JSON.stringify({ result: fn() });
|
||||
} catch (e) {
|
||||
return JSON.stringify({ code: e.code, message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
test('trail on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester', (t) => {
|
||||
const { store, call, U } = setup(t);
|
||||
const A = fromAlice(store, U, 'alice: my salary is private');
|
||||
const unknown = randomUUID();
|
||||
for (const who of sessions) {
|
||||
const hidden = answer(() => call(who, 'trail', { subject: A }));
|
||||
assert.equal(hidden, answer(() => call(who, 'trail', { subject: unknown })), who);
|
||||
assert.equal(hidden, JSON.stringify({ result: [] }), who);
|
||||
}
|
||||
});
|
||||
|
||||
test('replies on a hidden message id answers exactly as on an unknown id, for the owner, a reader and the tester', (t) => {
|
||||
const { store, call, U } = setup(t);
|
||||
const A = fromAlice(store, U, 'alice: hello');
|
||||
toAlice(store, U, 'hello alice', { in_reply_to: A });
|
||||
const unknown = randomUUID();
|
||||
for (const who of sessions) {
|
||||
const hidden = answer(() => call(who, 'replies', { id: A }));
|
||||
assert.equal(hidden, answer(() => call(who, 'replies', { id: unknown })), who);
|
||||
assert.equal(JSON.parse(hidden).code, 'message-not-found', who);
|
||||
}
|
||||
});
|
||||
|
||||
test('trail on a decision keeps the decision and drops the user thread messages, their deliveries and their events', (t) => {
|
||||
const { b, store, call, U } = setup(t);
|
||||
const run = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: U });
|
||||
b.request(run, { verb: 'role.claim' });
|
||||
const d = b.request(run, {
|
||||
verb: 'decision.raise',
|
||||
args: { action: 'deploy', question: 'Ship it?', options, recommendation: 'yes', blocking: false },
|
||||
}).id;
|
||||
const hidden = toAlice(store, U, 'alice, I asked the owner about shipping', { decision: d });
|
||||
const shown = put(store, { from_role: 'assistant', from_run: 'assistant-run', to_role: 'human', body: 'Ship it, owner?', decision: d });
|
||||
for (const who of sessions) {
|
||||
const rows = call(who, 'trail', { subject: d });
|
||||
assert.ok(rows.some((r) => r.table === 'decisions' && r.id === d), `${who}: the decision row shows`);
|
||||
assert.ok(rows.some((r) => r.table === 'messages' && r.id === shown), `${who}: the owner's message shows`);
|
||||
assert.ok(rows.some((r) => r.table === 'deliveries' && r.message === shown), `${who}: its delivery shows`);
|
||||
assert.ok(rows.some((r) => r.table === 'events' && r.subject === shown), `${who}: its event shows`);
|
||||
assert.equal(rows.filter((r) => r.table === 'messages' && r.id === hidden).length, 0, `${who}: no hidden message`);
|
||||
assert.equal(rows.filter((r) => r.table === 'deliveries' && r.message === hidden).length, 0, `${who}: no hidden delivery`);
|
||||
assert.equal(rows.filter((r) => r.table === 'events' && r.subject === hidden).length, 0, `${who}: no hidden event`);
|
||||
assert.equal(JSON.stringify(rows).includes('alice, I asked'), false, `${who}: the body appears nowhere`);
|
||||
}
|
||||
});
|
||||
|
||||
test('replies leaves out other users thread messages and still fills a page', (t) => {
|
||||
const { store, call, U } = setup(t);
|
||||
const P = put(store, { from_role: 'human', from_run: 'human-cli', to_role: 'chief', body: 'Status, everyone?' });
|
||||
for (let i = 0; i < REPLIES; i++) toAlice(store, U, `hidden ${i}`, { in_reply_to: P });
|
||||
const shown = put(store, { from_role: 'chief', from_run: 'chief-run', to_role: 'human', body: 'All green.', in_reply_to: P });
|
||||
for (const who of sessions) assert.deepEqual(call(who, 'replies', { id: P }).map((m) => m.id), [shown], who);
|
||||
});
|
||||
|
||||
test('messages in the business human and tester threads are not hidden (row 177 unchanged)', (t) => {
|
||||
const { store, call, project } = setup(t);
|
||||
const H = threadOf(store, project, 'jason', 'mine');
|
||||
const T = threadOf(store, project, 'tester', 'probe');
|
||||
const h = put(store, { thread: H, from_role: 'human', from_run: 'human-cli', to_role: 'assistant', body: 'from jason' });
|
||||
const x = put(store, { thread: T, from_role: 'tester', from_run: 'tester-cli', to_role: 'assistant', body: 'from tester' });
|
||||
const hr = put(store, { thread: H, from_role: 'assistant', from_run: 'r', to_role: 'human', body: 'to jason', in_reply_to: h });
|
||||
const xr = put(store, { thread: T, from_role: 'assistant', from_run: 'r', to_role: 'tester', body: 'to tester', in_reply_to: x });
|
||||
for (const who of sessions) {
|
||||
for (const id of [h, x]) assert.ok(call(who, 'trail', { subject: id }).some((r) => r.table === 'messages' && r.id === id), `${who} ${id}`);
|
||||
assert.deepEqual(call(who, 'replies', { id: h }).map((m) => m.id), [hr], who);
|
||||
assert.deepEqual(call(who, 'replies', { id: x }).map((m) => m.id), [xr], who);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 193's user sentence, one assertion per item: "The business owner sees your thread's agent's
|
||||
// decisions, inbox entries, claims, tasks and project files, but not your messages."
|
||||
test('governance pin: the owner sees decisions, inbox entries, claims, tasks and project files, but not the user messages', async (t) => {
|
||||
const f = await fixture(t, { roles: { assistant: { definition: 'assistant', ...within(['message.send', 'record.write']) } } });
|
||||
const project = (await f.call(f.human, 'project.create', { name: 'alice work' })).id;
|
||||
const U = threadOf(f.store, project, 'user:alice', 'alice');
|
||||
const run = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: U });
|
||||
await f.call(run, 'role.claim');
|
||||
const A = fromAlice(f.store, U, 'alice: please write up my plan');
|
||||
toAlice(f.store, U, 'alice, done', { in_reply_to: A });
|
||||
await f.call(run, 'record.write', { project: 'stack', path: 'docs/alice-plan.md', record: '# Plan\n' });
|
||||
const d = (await f.call(run, 'decision.raise', { action: 'deploy', question: 'Ship the plan?', options, recommendation: 'yes', blocking: false })).id;
|
||||
f.store.run(
|
||||
'INSERT INTO task_snapshots(at,business,task_ref,updated,digest,fields,source,via,read_at,role,run) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
AT, 'demo', 'vikunja:3/47', AT, 'a'.repeat(64), JSON.stringify({ bucket: 1, done: false }), 'self', null, null, 'assistant', 'assistant-run',
|
||||
);
|
||||
|
||||
const inbox = await f.call(f.human, 'inbox');
|
||||
assert.ok(inbox.some((x) => x.id === d), 'inbox entries: the run raise is in the owner inbox');
|
||||
// A thread run's claim is in thread_claims, and `agents` reads role_claims only, so no owner verb
|
||||
// shows it yet (sage-2430). This is the launcher view: the claim is recorded and the filter leaves it
|
||||
// alone. The follow-up row Sage adds for an owner-visible thread-claim read moves this to an owner verb.
|
||||
const claim = f.broker.threadsState('demo').find((x) => x.thread === U);
|
||||
assert.equal(claim?.holder, 'assistant-run', 'claims: the run claim is recorded');
|
||||
const tasks = await f.call(f.human, 'tasks');
|
||||
assert.ok(tasks.some((x) => x.task_ref === 'vikunja:3/47' && x.run === 'assistant-run'), 'tasks: the run task write shows');
|
||||
const file = await f.call(f.human, 'project.file', { project: 'stack', path: 'docs/alice-plan.md' });
|
||||
assert.equal(file.content, '# Plan\n', 'project files: the run record shows');
|
||||
await f.call(f.human, 'decision.resolve', { id: d, choice: 'yes' });
|
||||
const recent = await f.call(f.human, 'decisions', { list: 'recent' });
|
||||
assert.ok(recent.some((x) => x.id === d), 'decisions: the closed raise shows in recent');
|
||||
|
||||
assert.deepEqual(await f.call(f.human, 'trail', { subject: A }), [], 'messages: trail hides the user message');
|
||||
await assert.rejects(Promise.resolve().then(() => f.call(f.human, 'replies', { id: A })), { code: 'message-not-found' }, 'messages: replies hides it');
|
||||
const trail = await f.call(f.human, 'trail', { subject: d });
|
||||
assert.ok(trail.some((r) => r.table === 'decisions' && r.id === d), 'decisions: the trail keeps the decision');
|
||||
assert.equal(JSON.stringify(trail).includes('alice'), false, 'messages: no user body on the decision trail');
|
||||
});
|
||||
@@ -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) });
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { registerRuntime, updateRuntime, processIdentity } from '../../processing-control/src/runtimes.mjs';
|
||||
import { readControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
async function broker(root) {
|
||||
const r = await startBroker({ dataRoot: root, businesses, readers: ['demo'] });
|
||||
const cap = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
return { r, client: new Client({ path: r.path, cap, timeout: 15000 }) };
|
||||
}
|
||||
|
||||
test('real runtime injects the coordinator, stops its workload and mirrors stable stop events across restart', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-runtime-bus-'));
|
||||
let live;
|
||||
const p = spawn(process.execPath, ['-e', "console.log('ready');setInterval(()=>{},1000)"], { detached: true, stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const exit = once(p, 'exit');
|
||||
t.after(async () => {
|
||||
await live?.close();
|
||||
if (p.exitCode === null && p.signalCode === null) { try { process.kill(-p.pid, 'SIGKILL'); } catch {} await exit; }
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
await once(p.stdout, 'data');
|
||||
let current = await broker(root); live = current.r;
|
||||
const receipt = registerRuntime(root, { kind: 'session', business: 'demo', run: 'runtime-fixture' });
|
||||
updateRuntime(root, receipt.id, { ...processIdentity(p.pid), phase: 'running', group: true });
|
||||
const halted = await current.client.call('processing.halt');
|
||||
assert.equal(halted.halted, true);
|
||||
assert.equal(halted.quiescent, true);
|
||||
await exit;
|
||||
assert.equal(processIdentity(p.pid), null);
|
||||
await assert.rejects(live.mayLaunch({ business: 'demo', instance: 'coder', provider: 'fixture', model: 'fixture' }), { code: 'processing-halted' });
|
||||
await live.close(); live = null;
|
||||
current = await broker(root); live = current.r;
|
||||
assert.equal((await current.client.call('processing.status')).halted, true);
|
||||
const events = [halted.generation, receipt.id].flatMap(subject =>
|
||||
live.broker.request(live.readers[0].cap, { verb: 'trail', args: { subject } }));
|
||||
assert.equal(events.filter(e => e.body?.action === 'processing.halt').length, 1);
|
||||
assert.equal(events.filter(e => e.body?.action === 'processing.stop').length, 1);
|
||||
assert.equal((await current.client.call('processing.resume', { generation: halted.generation })).halted, false);
|
||||
});
|
||||
|
||||
test('invalid state still starts the control bus and human halt then resume repairs it', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-repair-bus-'));
|
||||
let live;
|
||||
t.after(async () => { await live?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
let current = await broker(root); live = current.r;
|
||||
await live.close(); live = null;
|
||||
writeFileSync(join(root, 'control', 'processing.json'), '{');
|
||||
current = await broker(root); live = current.r;
|
||||
assert.equal((await current.client.call('processing.status')).code, 'processing-state-invalid');
|
||||
await assert.rejects(current.client.call('processing.resume', { generation: 'bad' }), { code: 'processing-state-invalid' });
|
||||
const halted = await current.client.call('processing.halt');
|
||||
assert.equal(halted.recovered, true);
|
||||
assert.equal((await current.client.call('processing.resume', { generation: halted.generation })).halted, false);
|
||||
assert.equal(readControl(root).halted, false);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:net';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
test('processing halt and resume extend only the socket timeout while ordinary status retains the explicit timeout', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-client-')), path = join(root, 'fixture.sock');
|
||||
const seen = [], sockets = new Set();
|
||||
const server = createServer(socket => {
|
||||
sockets.add(socket); let buffer = '', timer;
|
||||
socket.on('error', () => {});
|
||||
socket.on('close', () => { sockets.delete(socket); clearTimeout(timer); });
|
||||
socket.on('data', data => {
|
||||
buffer += data;
|
||||
if (!buffer.includes('\n')) return;
|
||||
seen.push(JSON.parse(buffer));
|
||||
timer = setTimeout(() => socket.end(JSON.stringify({ ok: true, result: { real: 'completion' } }) + '\n'), 80);
|
||||
});
|
||||
});
|
||||
await new Promise(resolve => server.listen(path, resolve));
|
||||
t.after(async () => { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)); rmSync(root, { recursive: true, force: true }); });
|
||||
const client = new Client({ path, cap: 'a'.repeat(64), timeout: 15 });
|
||||
for (const verb of ['processing.halt', 'processing.resume']) {
|
||||
assert.deepEqual(await client.call(verb, {}), { real: 'completion' });
|
||||
assert.equal(client.timeout, 15, 'the constructor setting stays unchanged');
|
||||
}
|
||||
await assert.rejects(client.call('processing.status'), { code: 'outcome-unknown' });
|
||||
assert.deepEqual(seen, ['processing.halt', 'processing.resume', 'processing.status'].map(verb => ({ cap: 'a'.repeat(64), verb, args: {} })));
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initializeControl, transition } from '../../processing-control/src/state.mjs';
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
import { webService } from '../src/web.mjs';
|
||||
test('a root halt aborts the active search-source signal before the normal web deadline', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-web-')); initializeControl(root);
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
let ready, observed;
|
||||
const started = new Promise(resolve => { ready = resolve; });
|
||||
const service = webService({ deadlineMs: 3000, search: { search: (_query, { signal }) => new Promise((_resolve, reject) => {
|
||||
observed = signal; signal.addEventListener('abort', () => reject(signal.reason), { once: true }); ready();
|
||||
}) } });
|
||||
const call = withProcessing(root, () => service.handle('web.search', { query: 'fixture' }));
|
||||
await started; const began = Date.now(); transition(root, { business: 'demo', human: 'jason', halted: true });
|
||||
await call.catch(() => {});
|
||||
assert.equal(observed.aborted, true); assert.equal(observed.reason.name, 'ProcessingError');
|
||||
assert.ok(Date.now() - began < 1500);
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
const state = await import('../../processing-control/src/state.mjs').catch(e => {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND') return {};
|
||||
throw e;
|
||||
});
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', vars: { 'bus.tester': true },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
coder: { definition: 'coder', authority: { withinRole: ['message.send', 'record.write'], crossRole: [] } },
|
||||
} } };
|
||||
function fixture(t, extra = {}) {
|
||||
assert.equal(typeof state.initializeControl, 'function');
|
||||
const root = mkdtempSync(join(tmpdir(), 'processing-bus-'));
|
||||
state.initializeControl(root);
|
||||
const store = new Store(root);
|
||||
const processing = {
|
||||
read: () => state.readControl(root),
|
||||
async halt(actor) { return state.transition(root, { ...actor, halted: true }); },
|
||||
async resume(actor) { return state.transition(root, { ...actor, halted: false }); },
|
||||
};
|
||||
const b = new Broker({ store, businesses, processing, ...extra });
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'coder', run: 'run', harness: 'pi' });
|
||||
b.request(cap, { verb: 'role.claim' });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { root, b, store, cap, human, reader, tester, processing,
|
||||
call: (who, verb, args = {}) => b.request(who, { verb, args }) };
|
||||
}
|
||||
|
||||
// Dropping #human before either effect would allow a role/tester to clear the switch.
|
||||
for (const verb of ['processing.halt', 'processing.resume']) test(`${verb} refuses agent reader and tester before any transition`, async t => {
|
||||
const f = fixture(t), before = state.readControl(f.root);
|
||||
for (const cap of [f.cap, f.reader, f.tester]) {
|
||||
await assert.rejects(async () => f.call(cap, verb, { generation: before.generation }), e => ['human-required', 'read-only', 'tester-refused'].includes(e.code));
|
||||
}
|
||||
assert.deepEqual(state.readControl(f.root), before);
|
||||
});
|
||||
|
||||
test('human halt and clear each have one event with verified actor; launch.restore cannot clear', async t => {
|
||||
const f = fixture(t);
|
||||
const halted = await f.call(f.human, 'processing.halt');
|
||||
assert.equal(halted.halted, true);
|
||||
await f.call(f.human, 'processing.halt');
|
||||
f.call(f.human, 'launch.restore');
|
||||
assert.equal(f.call(f.reader, 'processing.status').halted, true);
|
||||
const resumed = await f.call(f.human, 'processing.resume', { generation: halted.generation });
|
||||
assert.equal(resumed.halted, false);
|
||||
const events = f.store.all("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') IN ('processing.halt','processing.resume')").map(r => JSON.parse(r.body));
|
||||
assert.deepEqual(events.map(e => [e.action, e.human]), [['processing.halt', 'jason'], ['processing.resume', 'jason']]);
|
||||
assert.equal(new Set(events.map(e => e.operation)).size, 2);
|
||||
});
|
||||
|
||||
test('all ordinary work verbs refuse before dispatch while pure reads remain available', async t => {
|
||||
const f = fixture(t); await f.call(f.human, 'processing.halt');
|
||||
for (const verb of ['role.claim', 'role.revoke', 'decision.raise', 'decision.resolve', 'message.send', 'message.receive', 'message.read', 'staff.request', 'review.request', 'review.verdict', 'event.emit', 'organization.set', 'record.write', 'usage.begin', 'unknown.future.work']) {
|
||||
await assert.rejects(async () => f.call(f.cap, verb), { code: 'processing-halted' }, verb);
|
||||
}
|
||||
assert.equal(f.call(f.cap, 'state').launch.state, 'allowed');
|
||||
assert.ok(Array.isArray(f.call(f.reader, 'agents')));
|
||||
assert.equal(f.call(f.reader, 'processing.status').halted, true);
|
||||
// A human can still queue intent and manage settings under their existing permissions.
|
||||
assert.doesNotThrow(() => f.call(f.human, 'message.send', { to: 'coder', body: 'wait for resume' }));
|
||||
assert.doesNotThrow(() => f.call(f.cap, 'role.release'));
|
||||
});
|
||||
|
||||
test('task web authorization and bind paths refuse before external work', async t => {
|
||||
const f = fixture(t); await f.call(f.human, 'processing.halt');
|
||||
let effects = 0;
|
||||
await assert.rejects(f.b.requestTask(f.cap, { verb: 'task.get', args: {} }, () => { effects++; }), { code: 'processing-halted' });
|
||||
await assert.rejects(f.b.requestWeb(f.cap, { verb: 'web.fetch', args: { url: 'https://example.com' } }, { handle() { effects++; } }), { code: 'processing-halted' });
|
||||
assert.throws(() => f.b.authorize(f.cap, 'role.launch', { target: 'coder' }), { code: 'processing-halted' });
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'coder', run: 'later', harness: 'pi' }), { code: 'processing-halted' });
|
||||
assert.equal(effects, 0);
|
||||
});
|
||||
|
||||
for (const action of ['processing.halt', 'processing.resume', 'processing.stop']) test(`recordEvent cannot forge ${action}`, t => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => f.b.recordEvent(f.cap, { kind: 'action.allowed', body: { action, human: 'jason' } }), { code: 'reserved-event' });
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed'").n, 0);
|
||||
});
|
||||
|
||||
for (const own of [false, true]) test(`recordTask ${own ? 'self' : 'poll'} cannot forge a processing action through its kinds filter`, t => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => f.b.recordTask({ business: 'demo', ...(own ? { cap: f.cap } : {}), events: [
|
||||
{ kind: 'action.allowed', body: { action: 'processing.halt', human: 'jason' } },
|
||||
] }), { code: 'reserved-event' });
|
||||
});
|
||||
|
||||
test('spec read remains a read but cannot release new write work after an await', async t => {
|
||||
let finish;
|
||||
const ready = new Promise(resolve => { finish = resolve; });
|
||||
const f = fixture(t, { project: { async writeRecord() { await ready; return { path: 'docs/a.md', commit: 'a'.repeat(40) }; } } });
|
||||
const writing = f.call(f.cap, 'record.write', { project: 'stack', path: 'docs/a.md', record: '# A' });
|
||||
await f.call(f.human, 'processing.halt'); finish();
|
||||
// An already committed external effect must remain outcome-unknown, not a fake rollback.
|
||||
await assert.rejects(writing, { code: 'outcome-unknown' });
|
||||
});
|
||||
|
||||
test('the real socket awaits halt and resume beyond the ordinary idle deadline', async t => {
|
||||
const f = fixture(t), path = join(f.root, 'control.sock');
|
||||
const halt = f.processing.halt;
|
||||
f.processing.halt = async args => { await new Promise(r => setTimeout(r, 30)); return halt(args); };
|
||||
let peers = 0;
|
||||
const server = await serve({ broker: f.b, path, timeout: 5,
|
||||
// Row 196 added asynchronous kernel-peer discovery before the dispatch timeout.
|
||||
// This test isolates the post-auth await; peer proof is exercised in its owning files.
|
||||
peerReader: { close: async () => {}, read: async () => { peers++; return { pid: process.pid, uid: process.getuid(), gid: process.getgid() }; } },
|
||||
authenticateHuman: () => f.b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) });
|
||||
try {
|
||||
const client = new Client({ path, human: { fixture: true }, timeout: 1000 });
|
||||
const halted = await client.call('processing.halt');
|
||||
assert.equal(halted.halted, true);
|
||||
assert.equal((await client.call('processing.resume', { generation: halted.generation })).halted, false);
|
||||
assert.equal((await new Client({ path, cap: f.reader }).call('processing.status')).halted, false);
|
||||
assert.equal(peers, 2);
|
||||
} finally { await server.close(); }
|
||||
});
|
||||
|
||||
test('a malformed processing reader never silently admits agent work', t => {
|
||||
const f = fixture(t);
|
||||
for (const answer of [null, {}, { halted: false }, { version: 1, generation: 'bad', halted: false }, { halted: 'false' }]) {
|
||||
f.processing.read = () => answer;
|
||||
assert.throws(() => f.call(f.cap, 'message.send', { to: 'human', body: 'no' }), { code: 'processing-state-invalid' });
|
||||
}
|
||||
});
|
||||
|
||||
test('halt refuses the newly landed host executor path before credentials or effects', async t => {
|
||||
const f = fixture(t); await f.call(f.human, 'processing.halt');
|
||||
let called = false;
|
||||
await assert.rejects(f.b.requestHost(f.cap, { verb: 'host.run', args: {} }, {
|
||||
busy: () => { called = true; return false; }, run: async () => { called = true; },
|
||||
}), e => e.code === 'processing-halted');
|
||||
assert.equal(called, false);
|
||||
});
|
||||
@@ -15,9 +15,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
||||
"import { isAbsolute, dirname } from 'node:path';",
|
||||
// Row 191 / sage-1367: the one additional read-only CODE source, with no project/git imports.
|
||||
"import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';",
|
||||
"import { isAbsolute } from 'node:path';",
|
||||
// Row 65: the plug-in name pattern a harness takes. kinds.mjs is in RUNNER_FILES and imports nothing.
|
||||
"import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';",
|
||||
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
|
||||
|
||||
@@ -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);
|
||||
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 20);
|
||||
assert.equal(count('trigger'), 71);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
|
||||
@@ -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'), []);
|
||||
|
||||
@@ -249,19 +249,16 @@ test('the runtime refuses an invalid adapter and closes a valid one', async (t)
|
||||
// Each refusal closed what it had opened, the adapter included; the writer lock is free.
|
||||
assert.equal(closed, 4);
|
||||
assert.equal(existsSync(join(root, 'bus/writer.lock')), false);
|
||||
let given = null, processing = null;
|
||||
let given = null;
|
||||
const runtime = await startBroker({
|
||||
dataRoot: root,
|
||||
businesses,
|
||||
tasks: async (deps) => {
|
||||
given = Object.keys(deps).sort();
|
||||
processing = deps.processing;
|
||||
return { handle: async () => ({ ok: 1 }), timeout: 1000, close: async () => closed++ };
|
||||
},
|
||||
});
|
||||
// Row 191: runtime injects cancellation alongside the existing factory dependencies.
|
||||
assert.deepEqual(given, ['broker', 'businesses', 'credentials', 'processing']);
|
||||
assert.equal(typeof processing.work, 'function');
|
||||
assert.deepEqual(given, ['broker', 'businesses', 'credentials']);
|
||||
await runtime.close();
|
||||
assert.equal(closed, 5);
|
||||
await assert.rejects(
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old
|
||||
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');
|
||||
|
||||
@@ -21,12 +21,8 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
|
||||
assert.ok(Object.isFrozen(loaded.projects.books.universes.harbor.books));
|
||||
const only = structuredClone(doc); delete only.projects.stack;
|
||||
assert.deepEqual(Object.keys(validateBusinessDocument(only, file, { rolesDir: REPO_ROLES }).projects), ['books']);
|
||||
// C2-01c: a prefix outside every book is a universe-level playground; one inside another
|
||||
// universe's book refuses.
|
||||
doc.projects.books.universes.harbor.playground = ['Elsewhere/Playground/'];
|
||||
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
|
||||
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-outside-book/);
|
||||
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
|
||||
});
|
||||
|
||||
+11
-11
@@ -1061,6 +1061,17 @@ A status line shows `revoking:` with the tags of a revoke still in progress.
|
||||
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
|
||||
command. A usage error exits 4 and a bus refusal 2.
|
||||
|
||||
## grants (row 236)
|
||||
|
||||
`mosaic grants status gitea [--json] [--tester]` prints the Gitea grant path's state: the admin
|
||||
slot, each grant and open decision, settle-pending rotations, held agents, service left behind and
|
||||
revokes in progress. `run DECISION` starts the executor of a Gitea grant the human approved.
|
||||
`revoke AGENT gitea --reason R` takes the agent's Gitea token away and deletes it at Gitea. `sweep
|
||||
gitea` retries what a failure left. `admin set gitea --url URL --account NAME --rotate-by DATE`
|
||||
reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE`, `admin rotate
|
||||
gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing
|
||||
prints a token. A usage error exits 4 and a bus refusal 2.
|
||||
|
||||
## Access (FED R5, #414)
|
||||
|
||||
```
|
||||
@@ -1107,14 +1118,3 @@ The helper supplies the fixed logical origin, reports status/text/body as JSON,
|
||||
and never retries an uncertain write. It cannot navigate a browser or upgrade the
|
||||
tester to an owner. `mosaic ... --tester` continues using broker.sock directly.
|
||||
See docs/TOOLS.md for explicit activation conversion and rollback behavior.
|
||||
|
||||
## Instance processing switch
|
||||
|
||||
`mosaic halt [--business ID]` and `mosaic resume [--business ID]` use the human
|
||||
management path even when the CLI channel is disabled. The business chooses
|
||||
proof context; the affected scope is every managed workload in the resolved
|
||||
data root. Read `mosaic processing status --json` after an unknown or partial
|
||||
result. Resume refuses unconfirmed stops and a stale generation. A restarted
|
||||
`bus start --pm` keeps its control bus up while halted and skips the chief.
|
||||
The switch does not clear other launch/Discord brakes or retry tasks. Native
|
||||
diagnostic launch stays available. See packages/processing-control/README.md.
|
||||
|
||||
@@ -19,6 +19,7 @@ import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
@@ -43,6 +44,7 @@ export const USAGE = `usage:
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
${HOSTS_USAGE}
|
||||
${GRANTS_USAGE}
|
||||
${INSTANCES_USAGE}
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
@@ -59,9 +61,6 @@ export const USAGE = `usage:
|
||||
${THREAD_TALK_USAGE}
|
||||
mosaic name <instance> <name> [--business <id>] [--tester]
|
||||
mosaic names [--business <id>] [--json] [--tester]
|
||||
mosaic halt [--business <id>]
|
||||
mosaic resume [--business <id>]
|
||||
mosaic processing status [--business <id>] [--json]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
@@ -461,36 +460,6 @@ async function stop(rest, io, deps) {
|
||||
io.stdout.write(r.stopped ? `stopped ${r.business}/${r.instance} run ${r.run}\n` : `${r.reason}\n`);
|
||||
}
|
||||
|
||||
async function processingCommand(verb, rest, io, deps) {
|
||||
const parsed = parse(rest, { flags: ["--json"], values: ["--business"] });
|
||||
if (verb === 'processing' ? parsed.positional.length !== 1 || parsed.positional[0] !== 'status' : parsed.positional.length !== 0)
|
||||
throw usage('use mosaic halt, mosaic resume or mosaic processing status');
|
||||
refuseInsideAgent(io.env);
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
const call = callFor(parsed, io, { ...deps, system });
|
||||
try {
|
||||
let result;
|
||||
if (verb === 'resume') {
|
||||
const current = await call('processing.status');
|
||||
if (current?.invalid) throw new CliError(`processing state is invalid; use mosaic halt, then mosaic resume (${current.code})`, 3);
|
||||
result = await call('processing.resume', { generation: current?.generation });
|
||||
} else result = await call(verb === 'halt' ? 'processing.halt' : 'processing.status', {});
|
||||
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
|
||||
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
||||
if (verb === 'halt' && result.quiescent !== true)
|
||||
throw new CliError(`halt gate is on for instance ${system.dataRoot}; ${result.remaining ?? 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
|
||||
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
|
||||
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
|
||||
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
|
||||
if (result.invalid) io.stdout.write('state is invalid; use mosaic halt, then mosaic resume\n');
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
const code = typeof e.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'outcome-unknown';
|
||||
if (code === 'outcome-unknown') throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
||||
throw new CliError(`processing refused: ${code}`, 3);
|
||||
}
|
||||
}
|
||||
|
||||
async function launches(rest, io, deps) {
|
||||
const [sub, ...args] = rest;
|
||||
if (sub === "off" || sub === "on") {
|
||||
@@ -545,13 +514,8 @@ async function bus(rest, io, deps) {
|
||||
await launcher.listen();
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
|
||||
if (parsed.flags.has("--pm")) {
|
||||
try {
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
} catch (e) {
|
||||
if (e.code !== 'processing-halted') throw e;
|
||||
io.stdout.write('processing halted: chief launch skipped; control bus remains available\n');
|
||||
}
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
}
|
||||
} catch (e) {
|
||||
await host.close(1);
|
||||
@@ -632,13 +596,14 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 234 (#1740, r6 E4): the host records.
|
||||
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 236 (#1742, #436b r5): the Gitea grant path; admin set reads the admin token on stdin.
|
||||
if (verb === "grants") return grantsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
|
||||
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
if (verb === "follow") return talk(rest, io, deps, true);
|
||||
if (verb === "name" || verb === "names") return names(verb, rest, io, deps);
|
||||
if (verb === "stop") return stop(rest, io, deps);
|
||||
if (["halt", "resume", "processing"].includes(verb)) return processingCommand(verb, rest, io, deps);
|
||||
if (verb === "launches") return launches(rest, io, deps);
|
||||
if (verb === "bus") return bus(rest, io, deps);
|
||||
if (verb === "-h" || verb === "--help") return io.stdout.write(`${USAGE}\n`);
|
||||
|
||||
@@ -85,7 +85,6 @@ import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
|
||||
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const HEADER_MAX = 4096;
|
||||
const HEADER_TIMEOUT_MS = 10000;
|
||||
@@ -373,7 +372,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
const p = project(facts.project);
|
||||
const where = { dataRoot, business: b.id, project: facts.project, env };
|
||||
const refused = async (e, notify) => {
|
||||
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError || e.name === 'ProcessingError' ? e.code : "git-failed";
|
||||
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
|
||||
if (code === "git-failed" || code === "rollback-failed") log(`git socket: ${id}: ${code}: ${e.message}`);
|
||||
// The host's own faults stay in its log; the role gets the code.
|
||||
const detail = notify && code !== "git-failed" && e.message !== code ? e.message.slice(0, 1000) : null;
|
||||
@@ -393,19 +392,20 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
}
|
||||
const { commit, base, reviewed, patch_id } = prepared;
|
||||
socket.end(`${JSON.stringify({ ok: true, result: { landing: "started", commit, base, reviewed, patch_id } })}\n`);
|
||||
slot.done = withProcessing(dataRoot, async scope => {
|
||||
slot.done = (async () => {
|
||||
try {
|
||||
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
|
||||
scope.guard();
|
||||
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: slot.abort.signal });
|
||||
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
|
||||
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
|
||||
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
|
||||
} catch (e) {
|
||||
await refused(e, true);
|
||||
} finally {
|
||||
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
|
||||
await gate.dropNetwork(`${id}_default`, gateEnv(env));
|
||||
landing = null;
|
||||
}
|
||||
}).catch(e => refused(e, true));
|
||||
})();
|
||||
}
|
||||
|
||||
async function doPublish(socket, cap, req) {
|
||||
@@ -479,17 +479,11 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
if (typeof req.task_ref === "string") subject = req.task_ref;
|
||||
if (!who.role || who.business !== b.id) throw new Refusal("not-a-role");
|
||||
file = join(dir, `${randomBytes(8).toString("hex")}.bundle`);
|
||||
await withProcessing(dataRoot, async scope => {
|
||||
const abort = () => socket.destroy();
|
||||
scope.signal.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
if (req.op === "push") await doPush(socket, cap, req, rest, file);
|
||||
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
|
||||
else if (req.op === "land") await doLand(socket, cap, req);
|
||||
else if (req.op === "publish") await doPublish(socket, cap, req);
|
||||
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
|
||||
} finally { scope.signal.removeEventListener('abort', abort); }
|
||||
});
|
||||
if (req.op === "push") await doPush(socket, cap, req, rest, file);
|
||||
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
|
||||
else if (req.op === "land") await doLand(socket, cap, req);
|
||||
else if (req.op === "publish") await doPublish(socket, cap, req);
|
||||
else throw new Refusal("invalid-request", "op is push, fetch, land or publish");
|
||||
} catch (e) {
|
||||
if (e instanceof Recorded) return fail(e.code);
|
||||
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// `mosaic grants` (row 236, #1742, #436b r5): the broker's grants verbs for the Gitea grant path. run
|
||||
// starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the
|
||||
// admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke
|
||||
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
|
||||
// argv, and nothing here prints a token: the answers carry ids and names only.
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const GRANTS_USAGE =
|
||||
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
|
||||
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
|
||||
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
|
||||
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
|
||||
const MAX_TOKEN = 16384;
|
||||
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--json" || arg === "--tester" || arg === "--finish") {
|
||||
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
|
||||
out.flags.add(arg);
|
||||
} else if (VALUES.includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(GRANTS_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
|
||||
else out.positional.push(arg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The whole of stdin, one token, as `mosaic credentials issue` reads it.
|
||||
async function readToken(stdin) {
|
||||
let text = "";
|
||||
for await (const chunk of stdin) {
|
||||
text += chunk;
|
||||
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
|
||||
}
|
||||
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
|
||||
return text;
|
||||
}
|
||||
|
||||
// Each shape: the positional count after the verb, the flags and values it takes and needs.
|
||||
const SHAPES = {
|
||||
status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] },
|
||||
run: { n: 1, flags: [], need: [], may: ["--business"] },
|
||||
revoke: { n: 2, flags: ["--tester"], need: ["--reason"], may: ["--business"] },
|
||||
sweep: { n: 1, flags: ["--tester"], need: [], may: ["--business"] },
|
||||
"admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] },
|
||||
"admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] },
|
||||
"admin remove": { n: 1, flags: [], need: [], may: ["--business"] },
|
||||
};
|
||||
|
||||
const lines = (s) => {
|
||||
const out = [];
|
||||
out.push(s.admin?.error ? `admin: ${s.admin.error}` : s.admin ? `admin: ${s.admin.account} on ${s.admin.url}, rotate by ${s.admin.rotateBy}${s.admin.rotationDue ? " (due)" : ""}` : "admin: none");
|
||||
if (s.adminTokenLeft) out.push(`admin token left: ${s.adminTokenLeft}`);
|
||||
for (const g of s.grants) out.push(["grant", g.agent, g.user, `token ${g.tokenId}`, g.projects.join(","), `rotate by ${g.rotateBy}`, g.decision].join("\t"));
|
||||
for (const o of s.open) out.push(["open", o.decision, o.agent].join("\t"));
|
||||
for (const o of s.running) out.push(["running", o.decision, o.agent, o.step ?? "spent"].join("\t"));
|
||||
for (const o of s.settlePending) out.push(["settle-pending", o.decision, o.agent].join("\t"));
|
||||
for (const o of s.tokenUnknown) out.push(["token-unknown", o.agent, o.decision].join("\t"));
|
||||
for (const e of s.serviceLeft) out.push(["left", e.agent, e.user, String(e.id)].join("\t"));
|
||||
for (const r of s.revoking) out.push(["revoking", r.agent, r.revoke].join("\t"));
|
||||
return out.join("\n") + "\n";
|
||||
};
|
||||
|
||||
export async function grantsCommand(args, io, callFor) {
|
||||
const parsed = parse(args);
|
||||
let [verb, ...rest] = parsed.positional;
|
||||
if (verb === "admin") {
|
||||
verb = `admin ${rest[0] ?? ""}`;
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
// Own keys only, so `constructor` is a usage error, not a verb.
|
||||
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
|
||||
if (
|
||||
!shape || rest.length !== shape.n ||
|
||||
[...parsed.flags].some((f) => !shape.flags.includes(f)) ||
|
||||
Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) ||
|
||||
shape.need.some((v) => !Object.hasOwn(parsed.values, v)) ||
|
||||
(verb !== "run" && rest.at(-1) !== "gitea") ||
|
||||
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
|
||||
)
|
||||
throw new CliError(GRANTS_USAGE, 4);
|
||||
try {
|
||||
const call = callFor(parsed);
|
||||
const v = parsed.values;
|
||||
let request;
|
||||
if (verb === "status") request = ["grants.status", { service: "gitea" }];
|
||||
else if (verb === "run") request = ["grants.run", { decision: rest[0] }];
|
||||
else if (verb === "revoke") request = ["grants.revoke", { service: "gitea", agent: rest[0], reason: v["--reason"] }];
|
||||
else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }];
|
||||
else if (verb === "admin set")
|
||||
request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }];
|
||||
else if (verb === "admin rotate")
|
||||
request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }];
|
||||
else request = ["grants.admin.remove", { service: "gitea" }];
|
||||
const result = await call(...request);
|
||||
io.stdout.write(verb === "status" && !parsed.flags.has("--json") ? lines(result) : JSON.stringify(result, null, 2) + "\n");
|
||||
} catch (error) {
|
||||
if (error instanceof CliError) throw error;
|
||||
throw new CliError(error.message, 2);
|
||||
}
|
||||
}
|
||||
@@ -127,9 +127,6 @@ import { createGitSocket } from "./git-socket.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, runtime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
@@ -182,13 +179,6 @@ export function createLauncher({
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
}) {
|
||||
const dataRoot = system.dataRoot;
|
||||
function admission(generation = null) {
|
||||
try { return assertProcessing(dataRoot, generation); }
|
||||
catch (e) {
|
||||
if (e instanceof ProcessingError) throw new Refusal(e.code);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
|
||||
const skillsDir = join(dataRoot, "skills-enabled");
|
||||
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
@@ -241,7 +231,6 @@ export function createLauncher({
|
||||
// launch.by itself. A thread instance launches only for a thread, and
|
||||
// nothing else launches for one.
|
||||
async function check(instance, { pm = false, thread = null } = {}) {
|
||||
const admissionGeneration = admission().generation;
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
|
||||
@@ -335,13 +324,11 @@ export function createLauncher({
|
||||
if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; }
|
||||
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
|
||||
}
|
||||
admission(admissionGeneration);
|
||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration };
|
||||
return { resolved, model, account, harness, route, routeReason };
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, admissionGeneration }) {
|
||||
admission(admissionGeneration);
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
|
||||
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
|
||||
// A thread's store is checked first, so a bad id or an unsafe directory
|
||||
@@ -355,7 +342,6 @@ export function createLauncher({
|
||||
}
|
||||
}
|
||||
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
|
||||
admission(admissionGeneration);
|
||||
// The clones come before the run, so one that can't be made leaves no
|
||||
// run behind. The host's git treats each clone as hostile, and its
|
||||
// checks hold only while no session writes the workspace: a workspace
|
||||
@@ -365,10 +351,7 @@ export function createLauncher({
|
||||
mkdirSync(workspace, { recursive: true, mode: 0o700 });
|
||||
let checkouts;
|
||||
try {
|
||||
checkouts = await withProcessing(dataRoot, () => {
|
||||
admission(admissionGeneration);
|
||||
return ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
|
||||
});
|
||||
checkouts = await ensureCheckouts({ workspace, projects: Object.fromEntries(Object.entries(b.projects).filter(([, entry]) => entry.kind !== 'library')), env });
|
||||
} catch (e) {
|
||||
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
|
||||
throw e;
|
||||
@@ -379,7 +362,6 @@ export function createLauncher({
|
||||
}
|
||||
let harness, route, routeReason;
|
||||
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread}));
|
||||
admission(admissionGeneration);
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
@@ -454,16 +436,7 @@ export function createLauncher({
|
||||
threadStore: store,
|
||||
readOnly,
|
||||
};
|
||||
admission(admissionGeneration);
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
try {
|
||||
admission(admissionGeneration);
|
||||
child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
|
||||
} catch (e) {
|
||||
unregisterRuntime(dataRoot, managed.id);
|
||||
throw e;
|
||||
}
|
||||
const child = spawnSession({ runDir, env: sessionEnv(env, run), paths, business: b.id, role: instance, bwrap });
|
||||
let bound = false;
|
||||
let entry = null;
|
||||
const exited = new Promise((resolve) => child.once("close", (code, signal) => resolve(sessionExit(code, signal))));
|
||||
@@ -472,16 +445,10 @@ export function createLauncher({
|
||||
try {
|
||||
if (child.pid === undefined) throw new Error("the session process did not start");
|
||||
const outerStart = startTimeOf(child.pid);
|
||||
const identity = processIdentity(child.pid);
|
||||
if (!identity) throw new Error("the session process exited at once");
|
||||
updateRuntime(dataRoot, managed.id, { ...identity, group: true, phase: 'running' });
|
||||
const runner = await runnerOf(child);
|
||||
updateRuntime(dataRoot, managed.id, { runnerPid: runner.pid, runnerStartTime: runner.startTime });
|
||||
admission(admissionGeneration);
|
||||
if (!outerStart) throw new Error("the session process exited at once");
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
|
||||
bound = true;
|
||||
admission(admissionGeneration);
|
||||
entry = {
|
||||
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
|
||||
pid: child.pid, startTime: outerStart, runnerPid: runner.pid, runnerStartTime: runner.startTime, runDir, startedAt: new Date().toISOString(),
|
||||
@@ -495,29 +462,20 @@ export function createLauncher({
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
if (bound) await endRun(run, "failed", null);
|
||||
unregisterRuntime(dataRoot, managed.id);
|
||||
if (e instanceof Refusal) throw e;
|
||||
throw new Refusal("launch-failed", e.message);
|
||||
}
|
||||
children.set(run, exited);
|
||||
exited.then(async ({ code, signal }) => {
|
||||
let receipt;
|
||||
try { receipt = runtime(dataRoot, managed.id); }
|
||||
catch { log(`run ${run}: processing receipt unavailable at exit`); }
|
||||
const state = processingStatus(dataRoot);
|
||||
const haltGeneration = receipt?.haltGeneration ?? (state.halted && !state.invalid ? state.generation : null);
|
||||
const reason = haltGeneration ? 'kill-switch' : endReason(code, signal);
|
||||
const reason = endReason(code, signal);
|
||||
try {
|
||||
const ended = await endRun(run, reason, signal ? null : code);
|
||||
registry.remove(b.id, run);
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit check stopped names the limit, from the claim row.
|
||||
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(haltGeneration ? { haltGeneration } : {}), ...(ended?.limit ? { limit: ended.limit } : {}) });
|
||||
record({ event: "end", instance, ...(thread ? { thread } : {}), run, reason, exitCode: code, signal, ...(ended?.limit ? { limit: ended.limit } : {}) });
|
||||
log(`run ${run} (${instance}) ended: ${reason}`);
|
||||
} catch (e) {
|
||||
log(`run ${run}: ${e.message}`);
|
||||
} finally {
|
||||
try { unregisterRuntime(dataRoot, managed.id); }
|
||||
catch { log(`run ${run}: processing receipt cleanup refused`); }
|
||||
children.delete(run);
|
||||
stopping.delete(run);
|
||||
landingOf.delete(run);
|
||||
@@ -532,7 +490,6 @@ export function createLauncher({
|
||||
// landing pass. A tick that is still running when the next is due skips it.
|
||||
async function tick() {
|
||||
if (ticking || closed) return;
|
||||
try { admission(); } catch { return; }
|
||||
ticking = true;
|
||||
try {
|
||||
await threadPass();
|
||||
@@ -722,9 +679,7 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
if (!(e instanceof Refusal)) throw e;
|
||||
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
const error = new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
|
||||
error.code = e.code;
|
||||
throw error;
|
||||
throw new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { openDirect } from "../../discord/src/notify.mjs";
|
||||
import { createNotifier, runLoop } from "./notifier.mjs";
|
||||
import { withProcessing, currentProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const log = (line) => process.stderr.write(`mosaic-notify: ${line}\n`);
|
||||
let loop = null;
|
||||
@@ -38,13 +37,8 @@ if (!process.send) {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
const client = new Client({ path: m.path, cap: m.cap });
|
||||
const processing = { work: fn => withProcessing(m.dataRoot, fn), guard: () => currentProcessing()?.guard() };
|
||||
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}),
|
||||
fetch: (url, init) => {
|
||||
const scope = currentProcessing(); scope?.guard();
|
||||
return fetch(url, { ...init, ...(scope ? { signal: scope.signal } : {}) });
|
||||
}, log });
|
||||
const notifier = createNotifier({ business: m.business, dataRoot: m.dataRoot, inbox: () => client.call("inbox"), direct, processing, log });
|
||||
const direct = openDirect({ dataRoot: m.dataRoot, name: m.binding, ...(m.base ? { base: m.base } : {}), log });
|
||||
const notifier = createNotifier({ business: m.business, dataRoot: m.dataRoot, inbox: () => client.call("inbox"), direct, log });
|
||||
loop = runLoop(notifier, { pollMs: m.pollMs ?? undefined, log });
|
||||
process.send({ ok: true });
|
||||
} catch (e) {
|
||||
|
||||
@@ -251,7 +251,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
|
||||
// inbox(): the broker's inbox through the reader capability.
|
||||
// direct.send({content, nonce}) resolves {messageId} or throws a RestOutcome.
|
||||
export function createNotifier({ business, dataRoot, inbox, direct, now = () => new Date(), zone = ZONE, processing = null, log = () => {} }) {
|
||||
export function createNotifier({ business, dataRoot, inbox, direct, now = () => new Date(), zone = ZONE, log = () => {} }) {
|
||||
zoned(now(), zone);
|
||||
const journal = openJournal(journalPath(dataRoot, business), { log, now });
|
||||
const backoff = new Map();
|
||||
@@ -269,7 +269,6 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
|
||||
}
|
||||
|
||||
async function attempt(key, record, message) {
|
||||
processing?.guard();
|
||||
const t = now().getTime();
|
||||
try {
|
||||
const { messageId } = await direct.send(message);
|
||||
@@ -329,11 +328,7 @@ export function createNotifier({ business, dataRoot, inbox, direct, now = () =>
|
||||
return done;
|
||||
}
|
||||
|
||||
const poll = () => processing ? processing.work(tick).catch(e => {
|
||||
if (e.name !== 'ProcessingError') throw e;
|
||||
return { dms: 0, digest: false, failed: 0, halted: true };
|
||||
}) : tick();
|
||||
return Object.freeze({ tick: poll, journal: journalPath(dataRoot, business) });
|
||||
return Object.freeze({ tick, journal: journalPath(dataRoot, business) });
|
||||
}
|
||||
|
||||
// Runs tick() every pollMs until stop(). Ticks never overlap.
|
||||
|
||||
@@ -3,18 +3,10 @@ import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync,readFileSync,statSync
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
|
||||
const processingInputs=['packages/processing-control/src/state.mjs','packages/processing-control/src/entrypoint.mjs'];
|
||||
function copyProcessingInputs(root){
|
||||
mkdirSync(join(root,'packages/processing-control/src'),{recursive:true});
|
||||
for(const file of processingInputs)cpSync(new URL('../../../'+file,import.meta.url),join(root,file));
|
||||
}
|
||||
|
||||
test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{
|
||||
assert.equal(typeof image.imageFingerprint,'function','image fingerprint required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
copyProcessingInputs(root);
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
|
||||
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
|
||||
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
@@ -33,7 +25,6 @@ test('exclusive image preparation records actual image ID and build wall time an
|
||||
assert.equal(typeof image.prepareImage,'function','prepareImage is required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
copyProcessingInputs(root);
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
|
||||
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
|
||||
@@ -49,7 +40,6 @@ test('cleanup refuses a foreign Docker label and removes only an exact owned pro
|
||||
function imageFixture(t){
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
copyProcessingInputs(root);
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
return {root,receipt};
|
||||
@@ -144,12 +134,3 @@ test('guard owns every Compose file argument and refuses caller configuration ov
|
||||
for(const args of [['compose','-f','foreign.yaml','run','mosaic-agent'],['compose','--project-directory','/tmp','run','mosaic-agent']])
|
||||
assert.throws(()=>docker.guardDocker(args,run),/unowned command/);
|
||||
});
|
||||
|
||||
// Row 191 image-input manifest: either copied reader must invalidate the pinned image.
|
||||
for(const file of processingInputs)test(`changing ${file} invalidates the image receipt`,t=>{
|
||||
const {root,receipt}=imageFixture(t);
|
||||
const inspect=()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'});
|
||||
assert.equal(image.verifyImage(receipt,root,inspect).imageId,receipt.imageId);
|
||||
writeFileSync(join(root,file),readFileSync(join(root,file),'utf8')+'\n// changed fixture input\n');
|
||||
assert.throws(()=>image.verifyImage(receipt,root,inspect),/image inputs differ/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Row 236 (#1742, #436b r5): `mosaic grants status|run|revoke|sweep|admin`. A fake transport records the
|
||||
// calls; no bus runs here. The bus side of each verb is in packages/bus/tests/gitea-grant.test.mjs. The
|
||||
// admin token is a fake made here, read once from stdin and never printed.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { main } from "../src/cli.mjs";
|
||||
import { io, tmp } from "./helpers.mjs";
|
||||
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
const STATUS = {
|
||||
admin: { url: "https://git.example.test", account: "mosaic-grant", rotateBy: "2026-12-20", setAt: "2026-10-21T00:00:00.000Z", rotationDue: false },
|
||||
adminTokenLeft: "mosaic-grant-bootstrap",
|
||||
users: [{ agent: "coder", user: "demo-coder", id: 7 }],
|
||||
grants: [{ agent: "coder", user: "demo-coder", tokenId: 9, projects: ["app", "lib"], rotateBy: "2026-11-20", decision: "d1" }],
|
||||
open: [{ decision: "d2", agent: "coder", spentAtNextStart: true }],
|
||||
running: [{ decision: "d3", agent: null, step: null }],
|
||||
settlePending: [{ decision: "d4", agent: "coder" }],
|
||||
tokenUnknown: [{ agent: "ops", decision: "d5" }],
|
||||
serviceLeft: [{ agent: "coder", user: "demo-coder", id: 11 }],
|
||||
revoking: [{ agent: "coder", revoke: "r1" }],
|
||||
};
|
||||
|
||||
function deps(calls) {
|
||||
const answer = (who) => (business) => async (verb, args) => {
|
||||
calls.push({ who, business, verb, args });
|
||||
if (verb === "grants.status") return STATUS;
|
||||
if (verb === "grants.run") return { decision: args.decision, agent: "coder", started: true };
|
||||
return { ok: true };
|
||||
};
|
||||
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
|
||||
}
|
||||
const withStdin = (x, text) => (x.stdin.end(text), x);
|
||||
|
||||
test("row 236: grants status prints one line per entry, and --json the answer", async () => {
|
||||
const calls = [];
|
||||
const x = io();
|
||||
await main(["grants", "status", "gitea", "--business", "acme"], x, deps(calls));
|
||||
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "grants.status", args: { service: "gitea" } }]);
|
||||
assert.equal(x.out.text, [
|
||||
"admin: mosaic-grant on https://git.example.test, rotate by 2026-12-20",
|
||||
"admin token left: mosaic-grant-bootstrap",
|
||||
"grant\tcoder\tdemo-coder\ttoken 9\tapp,lib\trotate by 2026-11-20\td1",
|
||||
"open\td2\tcoder",
|
||||
"running\td3\t\tspent",
|
||||
"settle-pending\td4\tcoder",
|
||||
"token-unknown\tops\td5",
|
||||
"left\tcoder\tdemo-coder\t11",
|
||||
"revoking\tcoder\tr1",
|
||||
].join("\n") + "\n");
|
||||
const y = io();
|
||||
await main(["grants", "status", "gitea", "--json", "--tester", "--business", "acme"], y, deps(calls));
|
||||
assert.deepEqual(JSON.parse(y.out.text), STATUS);
|
||||
assert.equal(calls[1].who, "tester");
|
||||
const z = io();
|
||||
const due = { ...deps([]), transport: () => async () => ({ ...STATUS, admin: { ...STATUS.admin, rotationDue: true }, adminTokenLeft: null }) };
|
||||
await main(["grants", "status", "gitea", "--business", "acme"], z, due);
|
||||
assert.match(z.out.text, /^admin: mosaic-grant on https:\/\/git\.example\.test, rotate by 2026-12-20 \(due\)\ngrant\t/);
|
||||
const none = { ...deps([]), transport: () => async () => ({ ...STATUS, admin: null, adminTokenLeft: null }) };
|
||||
const w = io();
|
||||
await main(["grants", "status", "gitea", "--business", "acme"], w, none);
|
||||
assert.match(w.out.text, /^admin: none\n/);
|
||||
});
|
||||
|
||||
test("row 236: run, revoke, sweep and admin rotate and remove send their args", async () => {
|
||||
const calls = [];
|
||||
const x = io();
|
||||
await main(["grants", "run", "d1", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "revoke", "coder", "gitea", "--reason", "left the team", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "revoke", "coder", "gitea", "--reason", "r", "--tester", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "sweep", "gitea", "--tester", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "admin", "rotate", "gitea", "--rotate-by", "2027-01-20", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "admin", "rotate", "gitea", "--finish", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "admin", "remove", "gitea", "--business", "acme"], x, deps(calls));
|
||||
assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [
|
||||
{ who: "human", verb: "grants.run", args: { decision: "d1" } },
|
||||
{ who: "human", verb: "grants.revoke", args: { service: "gitea", agent: "coder", reason: "left the team" } },
|
||||
{ who: "tester", verb: "grants.revoke", args: { service: "gitea", agent: "coder", reason: "r" } },
|
||||
{ who: "tester", verb: "grants.sweep", args: { service: "gitea" } },
|
||||
{ who: "human", verb: "grants.admin.rotate", args: { service: "gitea", rotateBy: "2027-01-20" } },
|
||||
{ who: "human", verb: "grants.admin.rotate", args: { service: "gitea", finish: true } },
|
||||
{ who: "human", verb: "grants.admin.remove", args: { service: "gitea" } },
|
||||
]);
|
||||
assert.match(x.out.text, /^\{\n {2}"decision": "d1",\n {2}"agent": "coder",\n {2}"started": true\n\}\n/);
|
||||
});
|
||||
|
||||
test("row 236: admin set sends the stdin token once to grants.admin.set and prints no value", async () => {
|
||||
const calls = [];
|
||||
const token = randomBytes(20).toString("hex");
|
||||
const x = withStdin(io(), token + "\n");
|
||||
await main(["grants", "admin", "set", "gitea", "--url", "https://git.example.test", "--account", "mosaic-grant", "--rotate-by", "2026-12-20", "--business", "acme"], x, deps(calls));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].verb, "grants.admin.set");
|
||||
const { token: sent, ...rest } = calls[0].args;
|
||||
assert.ok(sent === token + "\n", "the stdin token went to the bus as read");
|
||||
assert.deepEqual(rest, { service: "gitea", url: "https://git.example.test", account: "mosaic-grant", rotateBy: "2026-12-20" });
|
||||
assert.ok(!x.out.text.includes(token) && !x.err.text.includes(token), "no token value printed");
|
||||
// An empty stdin, and one too long, refuse before any call.
|
||||
await assert.rejects(main(["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "2026-12-20", "--business", "acme"], withStdin(io(), "\n"), deps(calls)),
|
||||
(e) => (assert.equal(e.exitCode, 4), assert.match(e.message, /no token on stdin/), true));
|
||||
await assert.rejects(main(["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "2026-12-20", "--business", "acme"], withStdin(io(), "x".repeat(20000)), deps(calls)),
|
||||
(e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /too long/), true));
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("row 236: a malformed grants command is a usage error and calls nothing", async () => {
|
||||
const calls = [];
|
||||
for (const argv of [
|
||||
["grants"], ["grants", "status"], ["grants", "status", "host"], ["grants", "status", "gitea", "x"], ["grants", "run"], ["grants", "run", "d1", "d2"],
|
||||
["grants", "run", "d1", "--tester"], ["grants", "revoke", "coder", "gitea"], ["grants", "revoke", "coder", "host", "--reason", "r"],
|
||||
["grants", "revoke", "gitea", "--reason", "r"], ["grants", "sweep"], ["grants", "sweep", "host"], ["grants", "sweep", "gitea", "--reason", "r"],
|
||||
["grants", "admin"], ["grants", "admin", "set", "gitea"], ["grants", "admin", "set", "gitea", "--url", "u", "--account", "a"],
|
||||
["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "d", "--tester"],
|
||||
["grants", "admin", "rotate", "gitea"], ["grants", "admin", "rotate", "gitea", "--finish", "--rotate-by", "2027-01-01"],
|
||||
["grants", "admin", "rotate", "gitea", "--finish", "--finish"], ["grants", "admin", "remove", "gitea", "--tester"], ["grants", "admin", "purge", "gitea"],
|
||||
["grants", "status", "gitea", "--business"], ["grants", "status", "gitea", "--force"], ["grants", "constructor", "gitea"],
|
||||
["grants", "__proto__", "gitea"], ["grants", "admin", "constructor", "gitea"], ["grants", "status", "gitea", "--json", "--json"],
|
||||
])
|
||||
await assert.rejects(main([...argv], io(), deps(calls)), (e) => (assert.equal(e.exitCode, 4, argv.join(" ")), assert.match(e.message, /mosaic grants/), true));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("row 236: a bus refusal exits 2 with its code", async () => {
|
||||
const refusing = { system: { dataRoot: "/nonexistent" }, transport: () => async () => { throw new Error("grant-admin-missing"); } };
|
||||
await assert.rejects(main(["grants", "run", "d1", "--business", "acme"], io(), refusing), (e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /grant-admin-missing/), true));
|
||||
});
|
||||
|
||||
test("row 236: scripts/mosaic sends grants to packages/cli, not the seat CLI", (t) => {
|
||||
const root = tmp(t, "mosaic-cli-grants-route-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
|
||||
const r = spawnSync(MOSAIC, ["grants", "purge", "gitea"], { env, encoding: "utf8" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /^mosaic: mosaic grants status gitea/);
|
||||
assert.doesNotMatch(r.stderr, /mosaic launch </);
|
||||
});
|
||||
@@ -1027,22 +1027,3 @@ test("bus-service.sh refuses a default claude without the execute bit, or in a P
|
||||
assert.ok(r.stderr.includes(join(spaced, "claude")), r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
});
|
||||
|
||||
// Row 191: a restarted halted instance must keep its recovery bus reachable.
|
||||
test('bus start --pm while halted keeps the control host up without launching the chief', { timeout: 30000 }, async t => {
|
||||
const { initializeControl, transition, readControl } = await import('../../processing-control/src/state.mjs');
|
||||
const f = fixture(tmp(t)); notifyConfig(f.dataRoot, 'acme', null);
|
||||
initializeControl(f.dataRoot);
|
||||
transition(f.dataRoot, { business: 'acme', human: 'jason', halted: true });
|
||||
const child = spawn(process.execPath, [CLI, 'bus', 'start', 'acme', '--pm'], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const exited = once(child, 'exit'); let output = '';
|
||||
child.stdout.on('data', b => { output += b; }); child.stderr.on('data', b => { output += b; });
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exited; } });
|
||||
await until(() => /processing halted: chief launch skipped/.test(output) || child.exitCode !== null);
|
||||
assert.match(output, /processing halted: chief launch skipped/);
|
||||
assert.equal(child.exitCode, null, output);
|
||||
const status = spawnSync(process.execPath, [CLI, 'bus', 'status', '--json'], { env: f.env, encoding: 'utf8', timeout: 5000 });
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.equal(JSON.parse(status.stdout).host.live, true);
|
||||
assert.equal(readControl(f.dataRoot).halted, true);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
@@ -20,7 +19,6 @@ function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_
|
||||
if(account)doc.launch.account=account;
|
||||
return doc;
|
||||
}),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
for(const k of SESSION_CREDENTIALS.flat())delete f.env[k];
|
||||
for(const k of set)f.env[k]=VALUE;
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
|
||||
@@ -6,16 +6,14 @@ import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile} from '../../seat/src/session.mjs';
|
||||
|
||||
const HIT={path:['tokens','instances','coder'],limit:100,count:120,unknownTurns:1};
|
||||
|
||||
// answer is what the mayLaunch op returns, or an Error it throws.
|
||||
function setup(t,answer,{initialize=true}={}){
|
||||
function setup(t,answer){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),ops=[];
|
||||
if(initialize)initializeControl(f.dataRoot);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
if(x.op==='identity')return {business:'acme',role:'pm'};
|
||||
@@ -80,11 +78,3 @@ test('REQ-LIMIT-4 the launcher reaches token-limit only through the bus: launche
|
||||
assert.doesNotMatch(src,/modules\(\s*["'](?!harness["'])/);
|
||||
assert.doesNotMatch(src,/\bimport\s*\(/);
|
||||
});
|
||||
|
||||
// Row 191: only trusted bus startup initializes control; an early seat launch refuses.
|
||||
test('a launcher before trusted control initialization refuses processing-uninitialized',async t=>{
|
||||
const {launcher,ops}=setup(t,{allow:true},{initialize:false});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'processing-uninitialized'});
|
||||
assert.equal(ops.some(x=>x.op==='mayLaunch'||x.op==='authorizeLaunch'),false);
|
||||
assert.deepEqual(launcher.registry.sessions,[]);
|
||||
});
|
||||
|
||||
@@ -4,13 +4,11 @@ import assert from 'node:assert/strict';
|
||||
import {join} from 'node:path';
|
||||
import {readFileSync,writeFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {systemFor,writeJson} from '../../business/tests/helpers.mjs';
|
||||
import {launchLogFile} from '../../seat/src/session.mjs';
|
||||
test('each launch rereads limits and model selections; lowered limits keep sessions and log exact hit',async t=>{
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars.model='claude-sonnet-5-5';return doc;}),file=join(root,'config/businesses/acme.json'),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{ops.push(x);if(x.op==='identity')return {business:'acme',role:'pm'};if(x.op==='mayLaunch')return {allow:true};if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});return {};}};
|
||||
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
|
||||
const session={business:'acme',instance:'reviewer',run:'fixture-existing',harness:'pi',provider:'zai',model:'claude-sonnet-5-5'};launcher.registry.add(session);
|
||||
|
||||
@@ -7,7 +7,6 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {initializeControl} from '../../processing-control/src/state.mjs';
|
||||
import {fakeHarnesses,fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
@@ -26,7 +25,6 @@ const ON=['pi','claude-code','down','sparse','metered','broken','throws','absent
|
||||
// vars are the coder's whole agent vars; edit changes the rest of the file.
|
||||
async function setup(t,vars,{on=ON,edit=doc=>doc,env:more={}}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{doc.roles.coder.vars=vars;doc.plugins={...doc.plugins,harness:on};return edit(doc);}),ops=[];
|
||||
initializeControl(f.dataRoot);
|
||||
Object.assign(f.env,more);
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
|
||||
@@ -1075,49 +1075,3 @@ test("a runner that ignores SIGTERM is killed when the host closes", { skip, tim
|
||||
assert.equal(ends.length, 1);
|
||||
assert.equal(ends[0].signal, "SIGKILL");
|
||||
});
|
||||
|
||||
// Row 191: root-scoped processing is stronger than per-business launch.restore.
|
||||
test('kill switch refuses chief and hire before launch work and a clear permits a later request', { skip, timeout: 30000 }, async t => {
|
||||
const { readControl, transition } = await import('../../processing-control/src/state.mjs');
|
||||
const s = await setup(t);
|
||||
const halted = transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: true });
|
||||
await assert.rejects(s.launcher.launchPm(), /PM launch refused: processing-halted/);
|
||||
assert.deepEqual(await s.launcher.fromSession(s.cto.cap, 'coder'), { ok: false, error: 'processing-halted' });
|
||||
assert.deepEqual(readSessions(s.f.dataRoot), []);
|
||||
transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: false, generation: halted.generation });
|
||||
const run = await s.launcher.launchPm();
|
||||
assert.equal(readControl(s.f.dataRoot).halted, false);
|
||||
assert.equal(readSessions(s.f.dataRoot)[0].run, run.run);
|
||||
});
|
||||
|
||||
test('kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle', { skip, timeout: 30000 }, async t => {
|
||||
const { transition } = await import('../../processing-control/src/state.mjs');
|
||||
const s = await setup(t);
|
||||
const run = await s.launcher.launchPm();
|
||||
const entry = readSessions(s.f.dataRoot).find(r => r.run === run.run);
|
||||
const session = readFileSync(join(entry.runDir, 'session.json'));
|
||||
const halted = transition(s.f.dataRoot, { business: 'acme', human: s.f.doc.human, halted: true });
|
||||
await until(() => !readSessions(s.f.dataRoot).some(r => r.run === run.run), 12000);
|
||||
assert.deepEqual(readFileSync(join(entry.runDir, 'session.json')), session);
|
||||
const end = log(s.f).find(r => r.event === 'end' && r.run === run.run);
|
||||
assert.equal(end.reason, 'kill-switch');
|
||||
assert.equal(end.haltGeneration, halted.generation);
|
||||
assert.equal(startTimeOf(entry.runnerPid), null);
|
||||
});
|
||||
|
||||
test('a launch waiting across halt and resume cannot use its stale admission', { skip, timeout: 30000 }, async t => {
|
||||
const { transition } = await import('../../processing-control/src/state.mjs');
|
||||
let root, first = true;
|
||||
const prepared = prepare(t); root = prepared.f.dataRoot;
|
||||
const s = await setup(t, doc => doc, prepared, { wrap: host => ({ ...host, async op(m) {
|
||||
const answer = await host.op(m);
|
||||
if (m.op === 'mayLaunch' && first) {
|
||||
first = false;
|
||||
const state = transition(root, { business: 'acme', human: 'jason', halted: true });
|
||||
transition(root, { business: 'acme', human: 'jason', halted: false, generation: state.generation });
|
||||
}
|
||||
return answer;
|
||||
} }) });
|
||||
await assert.rejects(s.launcher.launchPm(), /processing-generation-changed/);
|
||||
assert.deepEqual(readSessions(root), []);
|
||||
});
|
||||
|
||||
@@ -451,21 +451,3 @@ test("runLoop never overlaps ticks and stops after the one in flight", async ()
|
||||
assert.ok(after >= 2);
|
||||
assert.equal(count, after);
|
||||
});
|
||||
|
||||
// Row 191: the child remains alive for recovery but sends no processing while halted.
|
||||
test('processing halt prevents notifier reads and sends and fences the wait before delivery', async t => {
|
||||
const { initializeControl, transition } = await import('../../processing-control/src/state.mjs');
|
||||
const { createProcessing } = await import('../../processing-control/src/coordinator.mjs');
|
||||
const dataRoot = tmp(t); initializeControl(dataRoot);
|
||||
const bus = broker(t);
|
||||
bus.raise('git.push.protected', { target: 'refactor', blocking: true, task_ref: 'vikunja:1/7' });
|
||||
const processing = createProcessing({ dataRoot }); t.after(() => processing.close());
|
||||
const actor = { business: 'demo', human: 'jason' }; let reads = 0, sends = 0, haltInRead = false;
|
||||
const notifier = createNotifier({ business: 'demo', dataRoot, processing, now: () => new Date(AT),
|
||||
inbox: async () => { reads++; if (haltInRead) transition(dataRoot, { ...actor, halted: true }); return bus.read('inbox'); },
|
||||
direct: { send: async () => { sends++; return { messageId: 'fixture' }; } } });
|
||||
const halted = transition(dataRoot, { ...actor, halted: true });
|
||||
await notifier.tick(); assert.equal(reads, 0); assert.equal(sends, 0);
|
||||
transition(dataRoot, { ...actor, halted: false, generation: halted.generation }); haltInRead = true;
|
||||
await notifier.tick(); assert.equal(reads, 1); assert.equal(sends, 0);
|
||||
});
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { createProcessing } from '../../processing-control/src/coordinator.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { io, BUSINESSES } from './helpers.mjs';
|
||||
function setup(t) {
|
||||
const dataRoot = mkdtempSync(join(tmpdir(), 'processing-cli-')), calls = [];
|
||||
const processing = createProcessing({ dataRoot });
|
||||
const store = new Store(dataRoot);
|
||||
const b = new Broker({ store, businesses: BUSINESSES, processing });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const deps = { system: { dataRoot }, transport: business => async (verb, args = {}) => {
|
||||
assert.equal(business, 'demo'); calls.push(verb); return b.request(human, { verb, args });
|
||||
} };
|
||||
t.after(async () => { await processing.close(); store.close(); rmSync(dataRoot, { recursive: true, force: true }); });
|
||||
return { dataRoot, deps, calls };
|
||||
}
|
||||
|
||||
test('human halt/status/resume uses management transport without loading any channel', async t => {
|
||||
const f = setup(t), x = io();
|
||||
await main(['halt', '--business', 'demo'], x, f.deps);
|
||||
assert.match(x.out.text, /processing halted/);
|
||||
assert.ok(x.out.text.includes(f.dataRoot), 'the affected instance root is named');
|
||||
const status = io();
|
||||
await main(['processing', 'status', '--business', 'demo', '--json'], status, f.deps);
|
||||
assert.equal(JSON.parse(status.out.text).halted, true);
|
||||
const resumed = io(); await main(['resume', '--business', 'demo'], resumed, f.deps);
|
||||
assert.match(resumed.out.text, /processing resumed/);
|
||||
assert.deepEqual(f.calls, ['processing.halt', 'processing.status', 'processing.status', 'processing.resume']);
|
||||
});
|
||||
|
||||
for (const verb of ['halt', 'resume']) test(`${verb} refuses inside an agent before transport`, async t => {
|
||||
const f = setup(t), x = io({ MOSAIC_RUN_ID: 'managed-run' });
|
||||
await assert.rejects(main([verb, '--business', 'demo'], x, f.deps), e => e.exitCode === 3);
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test('unknown and partially stopped outcomes are never reported as a successful halt', async t => {
|
||||
const f = setup(t);
|
||||
const unknown = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], unknown, {
|
||||
...f.deps, transport: () => async () => { throw Object.assign(new Error('outcome-unknown'), { code: 'outcome-unknown' }); },
|
||||
}), e => e.exitCode === 1 && /status/.test(e.message));
|
||||
assert.doesNotMatch(unknown.out.text, /processing halted/);
|
||||
const partial = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], partial, {
|
||||
...f.deps, transport: () => async () => ({ version: 1, generation: '11111111-1111-4111-8111-111111111111', operation: '11111111-1111-4111-8111-111111111111', business: 'demo', human: 'jason', at: '2026-10-20T00:00:00.000Z', recovered: false, halted: true, quiescent: false, remaining: 1, stopped: 0 }),
|
||||
}), e => e.exitCode === 1 && /1.*unconfirmed/.test(e.message));
|
||||
assert.doesNotMatch(partial.out.text, /all.*stopped/);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ test('a copy carries every file the harness modules read from it',async t=>{
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
|
||||
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
|
||||
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
|
||||
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -918,6 +918,8 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
|
||||
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
|
||||
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
|
||||
const untimed={
|
||||
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
host_grants:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
hosts:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
meta:'key and value only; its one UPDATE is the migration step',
|
||||
@@ -1258,7 +1260,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 +1419,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 +1467,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 +1528,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 +1602,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 +1681,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 +1697,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
|
||||
|
||||
@@ -50,8 +50,6 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSyn
|
||||
import { join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
||||
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
|
||||
import { createRest } from "./rest.mjs";
|
||||
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
||||
@@ -60,7 +58,7 @@ import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
|
||||
import { createSetsparkApi } from "./setspark.mjs";
|
||||
import { assembleContext } from "./context.mjs";
|
||||
import { createConnector } from "./connector.mjs";
|
||||
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
||||
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
|
||||
@@ -173,9 +171,6 @@ async function check(opts) {
|
||||
}
|
||||
|
||||
async function run(opts) {
|
||||
const root = loadDataRoot(opts.config);
|
||||
const generation = assertProcessing(root).generation;
|
||||
const processing = () => assertProcessing(root, generation);
|
||||
const { bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir } = prepare(opts);
|
||||
const token = readToken(binding);
|
||||
ensureJournal(journalDir);
|
||||
@@ -185,18 +180,8 @@ async function run(opts) {
|
||||
}
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
|
||||
writePid(journalDir, process.pid);
|
||||
let managed = null;
|
||||
const cleanupPid = () => {
|
||||
clearPid(journalDir, process.pid);
|
||||
if (!managed) return;
|
||||
if (processingStatus(root).halted) updateRuntime(root, managed.id, { phase: 'ended' });
|
||||
else unregisterRuntime(root, managed.id);
|
||||
};
|
||||
const cleanupPid = () => clearPid(journalDir, process.pid);
|
||||
try {
|
||||
processing();
|
||||
managed = registerRuntime(root, { kind: 'discord', business: 'instance', run: binding.name });
|
||||
updateRuntime(root, managed.id, { ...processIdentity(process.pid), phase: 'running', group: false });
|
||||
processing();
|
||||
await runClaimed();
|
||||
} catch (err) {
|
||||
cleanupPid();
|
||||
@@ -216,7 +201,6 @@ async function run(opts) {
|
||||
|
||||
const rest = createRest({ token, log: warn });
|
||||
const gw = await rest.getGatewayBot();
|
||||
processing();
|
||||
const gateway = createGateway({ url: gw.url, token, intents: CONNECTOR_INTENTS });
|
||||
gateway.on("log", (m) => warn(`gateway: ${m}`));
|
||||
gateway.on("ready", (r) => warn(`gateway: READY as ${r.user && r.user.id}, session ${r.sessionId}`));
|
||||
@@ -224,7 +208,6 @@ async function run(opts) {
|
||||
gateway.on("closed", (c) => warn(`gateway: closed ${c.code} ${c.reason} reconnect=${c.willReconnect}`));
|
||||
|
||||
const engine = createEngine({
|
||||
processingRoot: root, binding: binding.name,
|
||||
command: pi,
|
||||
args: buildPiArgs({ ...binding.engine, sessionDir, appendSystemPromptFile: promptFile, continueSession, tools: toolRoots }),
|
||||
cwd: opts.repo,
|
||||
@@ -240,17 +223,15 @@ async function run(opts) {
|
||||
// key as the model's verbs; without a setspark key it has none, and an
|
||||
// approval request from the model is refused.
|
||||
const api = binding.tools && binding.tools.setspark ? createSetsparkApi(binding.tools.setspark) : null;
|
||||
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, processing, log: warn });
|
||||
const connector = createConnector({ binding, journalDir, rest, gateway, engine, api, log: warn });
|
||||
let shuttingDown = false;
|
||||
let exitCode = 0;
|
||||
let processingWatch = null;
|
||||
const shutdown = (code, immediate = false) => {
|
||||
const shutdown = (code) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearInterval(processingWatch);
|
||||
exitCode = code;
|
||||
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
|
||||
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
|
||||
warn("stopping: finishing in-flight turns");
|
||||
connector.stop().catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
|
||||
cleanupPid();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
@@ -259,20 +240,8 @@ async function run(opts) {
|
||||
warn(`gateway fatal close ${f.code}: ${f.reason}`);
|
||||
shutdown(2);
|
||||
});
|
||||
process.on("SIGTERM", () => shutdown(0, processingStatus(root).halted === true));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
processingWatch = setInterval(() => {
|
||||
try { processing(); }
|
||||
catch {
|
||||
const state = processingStatus(root);
|
||||
if (state.halted && !state.invalid) {
|
||||
try { requestProcessingStop(journalDir, state.generation); }
|
||||
catch { warn('processing STOP could not be recorded'); }
|
||||
}
|
||||
shutdown(BRAKE_EXIT, true);
|
||||
}
|
||||
}, 100);
|
||||
processingWatch.unref();
|
||||
|
||||
// Reload on SIGHUP: re-read the file, refuse anything that is not a
|
||||
// reloadable difference, verify new channels over REST, then swap.
|
||||
@@ -281,13 +250,11 @@ async function run(opts) {
|
||||
const reloadNow = async () => {
|
||||
const at = new Date().toISOString();
|
||||
try {
|
||||
processing();
|
||||
const next = loadBinding(bindingFile);
|
||||
if (next.name !== binding.name) throw new DiscordError(`reload: name ${JSON.stringify(next.name)} does not match ${binding.name}`);
|
||||
const diff = reloadDiff(connector.binding, next);
|
||||
for (const id of diff.channels.added) {
|
||||
const ch = await rest.getChannel(id);
|
||||
processing();
|
||||
if (ch.guild_id !== next.guildId) throw new DiscordError(`reload: channel ${id} is in guild ${ch.guild_id}, not ${next.guildId}`);
|
||||
}
|
||||
connector.reload(next);
|
||||
@@ -363,7 +330,6 @@ function reloadCommand(opts) {
|
||||
|
||||
function recoverCommand(opts) {
|
||||
const dataRoot = loadDataRoot(opts.config);
|
||||
assertProcessing(dataRoot);
|
||||
const binding = loadBinding(bindingPath(dataRoot, opts.binding));
|
||||
const journalDir = bindingDataDir(dataRoot, binding.name);
|
||||
ensureJournal(journalDir);
|
||||
@@ -390,7 +356,7 @@ async function main() {
|
||||
main().then((code) => {
|
||||
if (code !== null) process.exit(code);
|
||||
}).catch((err) => {
|
||||
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
|
||||
const code = err instanceof DiscordError ? err.exitCode : 1;
|
||||
warn(err.message);
|
||||
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
|
||||
process.exit(code);
|
||||
|
||||
@@ -66,7 +66,6 @@ export function createConnector({
|
||||
setTimeoutImpl = globalThis.setTimeout, clearTimeoutImpl = globalThis.clearTimeout,
|
||||
typingIntervalMs = 8000,
|
||||
readReceipt = READ_RECEIPT,
|
||||
processing = () => {},
|
||||
log = () => {},
|
||||
} = {}) {
|
||||
for (const [k, v] of Object.entries({ binding: initialBinding, journalDir, rest, gateway, engine })) {
|
||||
@@ -74,22 +73,6 @@ export function createConnector({
|
||||
}
|
||||
ensureJournal(journalDir);
|
||||
let binding = initialBinding;
|
||||
const guarded = object => new Proxy(object, {
|
||||
get(target, key) {
|
||||
const value = Reflect.get(target, key);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...args) => { processing(); return value.apply(target, args); };
|
||||
},
|
||||
});
|
||||
rest = guarded(rest);
|
||||
if (api) api = guarded(api);
|
||||
function processingRefusal() {
|
||||
try { processing(); return null; }
|
||||
catch (e) {
|
||||
if (e.code?.startsWith('processing-')) return e.code;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
inbox: new Set(), channels: new Map(), inFlight: 0, typingTimer: null, typingChannel: null,
|
||||
@@ -157,7 +140,6 @@ export function createConnector({
|
||||
|
||||
function typingTick() {
|
||||
state.typingTimer = null;
|
||||
if (processingRefusal()) return;
|
||||
if (state.inFlight === 0 || state.typingChannel === null) return;
|
||||
rest.typing(state.typingChannel);
|
||||
state.typingTimer = setTimeoutImpl(typingTick, typingIntervalMs);
|
||||
@@ -305,8 +287,6 @@ export function createConnector({
|
||||
// is answered at once with a private line; an approval is deferred, the
|
||||
// service is asked, and the request message is then edited in place.
|
||||
async function handleInteraction(interaction) {
|
||||
const refusal = processingRefusal();
|
||||
if (refusal) return { accepted: false, reason: refusal };
|
||||
if (!interaction || typeof interaction !== "object" || typeof interaction.id !== "string") return { accepted: false, reason: "not-an-interaction" };
|
||||
if (interaction.type !== INTERACTION_TYPE.MESSAGE_COMPONENT) return { accepted: false, reason: "not-a-component" };
|
||||
if (state.interactions.has(interaction.id)) return { accepted: false, reason: "duplicate" };
|
||||
@@ -403,7 +383,6 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
record.engine.turns = result.turns ?? null;
|
||||
if (binding.tools) record.tools = result.tools || [];
|
||||
if (result.model) record.engine.model = result.model;
|
||||
processing();
|
||||
if (result.text.length === 0) throw new DiscordError("engine returned no text", 1, { code: "engine-empty" });
|
||||
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: result.text, noncePrefix: message.id });
|
||||
const opened = (result.tools || []).filter((t) => t && t.name === "open_approval_request" && t.ok && t.request);
|
||||
@@ -413,12 +392,10 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
}
|
||||
} catch (err) {
|
||||
status = "failed";
|
||||
error = { code: err.code || (err.details && err.details.code) || "error", message: err.message };
|
||||
error = { code: (err.details && err.details.code) || "error", message: err.message };
|
||||
log(`turn ${message.id} failed: ${error.code}: ${error.message}`);
|
||||
// Q19: one fixed line, never model output, on failure.
|
||||
if (!processingRefusal()) {
|
||||
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: FIXED_LINES.failed, noncePrefix: `${message.id}-f` });
|
||||
}
|
||||
reply = await deliver({ channelId: targetChannel, replyTo: message.id, text: FIXED_LINES.failed, noncePrefix: `${message.id}-f` });
|
||||
} finally {
|
||||
state.inFlight -= 1;
|
||||
typingStop();
|
||||
@@ -459,8 +436,6 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
}
|
||||
|
||||
async function admit(message) {
|
||||
const refusal = processingRefusal();
|
||||
if (refusal) return drop(message, refusal);
|
||||
// A thread the connector has not seen: one REST lookup, only when the
|
||||
// cheap checks (guild, listed author) would let the message through.
|
||||
const listed = binding.channels.some((c) => c.id === message.channel_id);
|
||||
@@ -472,8 +447,6 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
log(`channel lookup ${message.channel_id} failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const afterLookup = processingRefusal();
|
||||
if (afterLookup) return drop(message, afterLookup);
|
||||
const auth = authorize(binding, message, channelInfo);
|
||||
if (!auth.ok) return drop(message, auth.reason);
|
||||
if (typeof message.content !== "string") return drop(message, "no-content");
|
||||
@@ -594,12 +567,10 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
},
|
||||
|
||||
async start() {
|
||||
processing();
|
||||
if (state.started) throw new DiscordError("connector already started", 1);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, 1);
|
||||
const reconciled = await reconcile();
|
||||
const approvals = await reconcileApprovals();
|
||||
processing();
|
||||
if (approvals.length > 0) log(`approvals: ${approvals.map((a) => `${a.kind} ${a.requestId} ${a.status}`).join("; ")}`);
|
||||
state.inbox = readInboxIds(journalDir);
|
||||
state.started = true;
|
||||
@@ -611,14 +582,13 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
},
|
||||
|
||||
// Finish in-flight turns (each has its own timeout), then close.
|
||||
async stop({ immediate = false } = {}) {
|
||||
async stop() {
|
||||
state.stopping = true;
|
||||
gateway.close(1000, "stop");
|
||||
if (immediate) await engine.stop();
|
||||
await Promise.allSettled([...state.turnPromises]);
|
||||
await state.deliveryChain;
|
||||
typingStop();
|
||||
if (!immediate) await engine.stop();
|
||||
await engine.stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user