Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af85df4be | ||
|
|
04a86cf338 | ||
|
|
407c8d7be2 | ||
|
|
980cec88c2 | ||
|
|
c33cba810c | ||
|
|
01ea1fb11b | ||
|
|
b831733053 | ||
|
|
62f53b1e07 | ||
|
|
bda4ee6138 | ||
|
|
3a5275a9ad | ||
|
|
eccd6e276e | ||
|
|
571ca3d912 | ||
|
|
2f7fc6832f | ||
|
|
1d5d00939a | ||
|
|
46dedf9259 | ||
|
|
cac129e23a | ||
|
|
f12441d4fa | ||
|
|
e617543b8d | ||
|
|
d4dfc79b18 | ||
|
|
b5f1e09d6c | ||
|
|
0165be2661 | ||
|
|
6d6cc0f608 | ||
|
|
4c485c63b9 | ||
|
|
f58cfc3617 | ||
|
|
cee3f25a37 | ||
|
|
1313bfb718 | ||
|
|
618fe0f0b3 | ||
|
|
d20c92bf65 | ||
|
|
47a35bf436 | ||
|
|
6b32424adb |
@@ -5290,3 +5290,8 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
2026-10-22 08:00:02 UTC | Sage (1ef1e4f8) | row 280 #1785 landed | before: the bus tests' scratch sshd helper returned a port on a connect alone, and 194 b16's gate hung at G13 (base hung too, honker-0127). After: b0fd28b6f on refactor, a ff from da5d3651d (b765f5cb8 + Rocko slim packet; full evidence d6c924764 on rocko/row280-da5d-20261022). Duckworth approve rev 3153 (manifest 27806c05). Sage gate bus 656/0/0, ge 41/0/0, hr 26/0/0 (c30431). Rocko repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Two build-time slips recorded in Rocko's packet: a runner root path (no test ran) and the CRLF sshd log line. Overlap 13/257 clean. Note rev 3154, done 3155, #1785 closed c30432, P19 511 comment 623. 194 b17 rebases on b0fd28b6f (sage-2542).
|
||||
|
||||
2026-10-22 08:38:11 UTC | Sage (1ef1e4f8) | row 194 #1692 landed | before: 194 b16 9cdc47d04 went red three times (stopRun deadline, then the bus test sshd port race), fixed by rows 279 and 280. After: fd6dd327c on refactor, a ff on e122aad8e. b18 is b17 3f451772d rebased after my doc and queue push blocked a ff (16 =, series patch-id 64303253f8e6, packages equal b17). Webby approve rev 3160 on manifest 29f131ab. Honker gate on fd6dd327c: runb8 503/0/0, mutants 17/17; 18 packages 3668/0, ten scripts rc=0, Docker up, env skips named. The secret scan hit three fake_*_token fixtures in bus tests (false positive). Note 3161, done 3162; #1692 closed c30435; P19 414 comment 625. LIVE still 86f95bb27.
|
||||
2026-10-22 08:42:04 UTC | Honker (Claude Opus 5.5, ecd17786) | row 194 #1692 agent lifecycle build | before: no Console or CLI lifecycle for agents (create, launch, terminate, regenerate, delete) and no per-agent hire/fire veto. After: 16 commits landed as fd6dd327c (b18, a rebase of b17 3f451772d on e122aad8e: 16 =, series patch-id 64303253f8e6, packages equal b17). Manifest 53 paths, digest 29f131ab. Final run on fd6dd327c: runb8 39 files whole 503/0/0, mutants 17/17 killed, restores matched; gate.sh merge onto e122aad8e, 18 packages 3668/0, ten scripts rc=0, Docker up, four env skips named (webui esbuild pin, queue render outside canonical root, test-task live provider x2); sweep clean. Corrections: b16 went red at G13; my diagnosis (60 runs, base hang on port 38927 sshd bind EADDRINUSE) placed it outside 194, fixed by row 280; the g13diag load kill missed its process group and overran about 8 minutes; one runb8 launch failed rc=126 on a lost exec bit before any test ran; the b17 runb8 was stopped unscored when refactor moved. In-review revs 3156/3157, Webby approve rev 3160.
|
||||
2026-10-22 09:43:34 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 236 #1742 Gitea grant landing, and row 281 filing | 236 landed bd5f11ded at 09:37:31Z, a fast-forward on be2b008cc: 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =), e40b74beb for two stale out-of-manifest pins, bd5f11ded for a gitea-not-configured raise test. Dewey approve rev 3168 on manifest 0b00269b (34 paths). Megavolt gate under my slot: 2c bus 131, cli 224, route 28, docker 1; whole bus 765, business 228, cli 832, ten scripts rc=0; mutants 40/40; skips named, env only. Overlap with 13 and 257 clean. Note 3169, done 3170; #1742 closed c30451; P19 436 comment 639. Correction: the first whole run on ce74477e1 went red 762/764 on the two stale pins; that run is void. Follow-up per mos-1271: row 281 (#1786), a fire or delete revokes host and Gitea grants, Megavolt owns, Dewey reviews; brief docs/plans/2026-10-22_fire-revokes-grants.md; DEFERRED entry points at it.
|
||||
2026-10-22 10:30:51 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 274 #1779 FED R3 broker user principal landing | 274 landed 4c485c63b at 10:28:26Z, a fast-forward on 47a35bf43: six Morgana commits d20c92bf6..4c485c63b. r5 f58cfc361 moved covers, LEVELS and the all marker into business/src/vocabulary.mjs so the broker keeps its sandbox import closure (M16/M16b/M16c). r6 4c485c63b pins USER_VERBS as the ruled seven and widens the R3 off-list set. Dewey approve rev 3178 on manifest 00047b52 (13 paths), comment 30487. Morgana gate under my slot, 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, ten scripts rc=0, mutants 22/22; skips named, env only (test-queue 1, test-task 2). Overlap with 13 and 257 clean. Note 3179, done 3180; #1779 closed c30491; P19 414 comment 646. Corrections: the r5 gate on f58cfc361 went red (M3x/M6x survived the R3 off-list set), void; my gate comment 30490 said 24 touched files, the count is 23; the kit comment guessed human-required for the M3x wall, the run code is not-holder.
|
||||
2026-10-22 10:57:34 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 256 #1761 the human deletes a creative instance, landing | before: row 194's deleteRefusal refused every END_PROTECTED definition for every actor, so nobody could delete a creative instance; the human could only delist one with launch: false. After: 256 landed f12441d4f, a fast-forward on 0165be266 (the 274 landing plus its docs and queue commits): four Honker commits b5f1e09d6..f12441d4f, patch-id b28e63746f8c. One protectedRefusal predicate serves delete and the listing's per-viewer endProtected; any non-human actor gets organization-creative-human-only, fire unchanged. Rebased twice (past 236, then past 274), each range-diff 4/4 =, carries checked by Webby (webby-0126, webby-0128). Webby APPROVE on manifest-r3 01eb75aa (10 paths), comment 30519, posted by Sage as jarvis because Webby has no token; review request c30518 (Sage, revs 3183/3184) after Honker's request failed (no token, revs 3181/3182). Gate by Honker under Sage's slot 10:33Z-10:52Z on merge tree ced6bf5f8: touched files 31/0/0, 18 packages 3757/0 (bus 788, business 231, cli 832, webui 335), ten scripts/test-*.sh rc=0, mutants M1, M2, M3, M4a, M4b killed with blob-checked restores. Skips, named and environment-only: webui editor rebuild byte check (esbuild 0.28.1 installed, pin 0.28.2), test-queue non-canonical root, test-task live provider x2. Overlap with in-review rows 13 and 257 clean. Order change sage-2594: 256 landed before 277 because 277's gate had not run. LIVE is still 86f95bb27. Queue note rev 3186, done rev 3187; #1761 closed c30520; P19 416 comment 649.
|
||||
2026-10-22 11:19:28 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 277 #1782 the owner sees a thread agent's claims, landing | before: a thread run's role.claim wrote thread_claims only, and the agents verb read role_claims only, so the owner had no way to see a thread agent's claim (PRD :1083-1084). After: 277 landed 62f53b1e0, a fast-forward on 46dedf925 (the 256 landing plus its docs and queue commits): seven Morgana commits 1d5d00939..62f53b1e0, range patch-id 7f0533520bd2. A bus verb thread.claims serves human and reader sessions; mosaic agents adds thread lines without --tester or --json, --json keeps the agents array, the tester path makes one call, a refused thread.claims prints the role lines and the refusal code, and an error with no bus code still throws. Rebased onto 0165be266, then onto 46dedf925 after 256 landed first (sage-2594); both range-diffs all =, manifest e281aa2c unchanged (duckworth-0126). Duckworth APPROVE comment 30539, recorded rev 3192, posted by Sage as jarvis; review request c30538 (revs 3190/3191) after Morgana's request failed (no token, revs 3188/3189). Gate by Morgana under Sage's slot 11:01:23Z-11:13:56Z: 7 touched files 0 fail (thread-claims 5, message-filter 6, user-principal 22, format 4, tester 11, other-tree 4, host 43), bus 793, cli 836, harness 151, business 231, seat 167, ten scripts/test-*.sh rc=0, M1-M9 killed on committed bytes. Skips named, environment only: test-queue non-canonical root, test-task live provider x2. Overlap with in-review 13 and 257 clean. Duckworth N1 (M8 dies on a rethrown error) to DEFERRED; 0123 N3 per-session claims binds 264a r7. Note rev 3193, done rev 3194; #1782 closed c30540; P19 506 comment 652. Also landed as doc-only picks: Honker's 256 BUILD.md gate results (80620a3a0 as b83173305) and Megavolt's 236 BUILD.md runs (9c6e1a165 as 01ea1fb11). LIVE is still 86f95bb27.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# Row 256 (#1761) build notes (append only)
|
||||
|
||||
## 2026-10-22 b1 (Honker)
|
||||
|
||||
Design d2 8c50481d2 was accepted by Webby (webby-0123). The code is on honker/row256 from be2b008cc.
|
||||
|
||||
### Correction to the design: the non-creative END_PROTECTED definition is `assistant`
|
||||
|
||||
- DESIGN.md (d1 and d2) says "the chief of staff" for the END_PROTECTED definition that isn't creative,
|
||||
following the brief's wording. In the code that definition is `assistant`:
|
||||
- `THREAD_DEFINITION = "assistant"` (vocabulary.mjs:203).
|
||||
- `END_PROTECTED = THREAD_DEFINITIONS = [assistant, ...CREATIVE_DEFINITIONS]`.
|
||||
- The `chief-of-staff` definition (the `chief` instance) isn't END_PROTECTED. Its delete refuses
|
||||
`organization-instance-referenced`, because it is launch.by, and that is unchanged.
|
||||
- So T3 and M2 use an `assistant` instance. The code is the same either way: protectedRefusal refuses
|
||||
every END_PROTECTED definition that isn't creative.
|
||||
- DESIGN.md isn't edited after approval; this note is the record.
|
||||
|
||||
### Persona file on delete (brief item 4)
|
||||
|
||||
- `<configDir>/personas/<business>/<instance>.md` is left in place. Nothing removes it, so there is no
|
||||
new open and no O_NOFOLLOW test.
|
||||
- Nothing reads it once the entry is gone. bundle.mjs reads it only for a launched creative instance,
|
||||
and the name is retired, so `add` refuses it (instance-settings.mjs, organization-instance-retired)
|
||||
and no later instance picks up the old persona.
|
||||
- The human removes the file by hand if they want it gone.
|
||||
|
||||
### What changed
|
||||
|
||||
- packages/business/src/instance-settings.mjs:
|
||||
- New `protectedRefusal(document, instance, actor)`.
|
||||
- `deleteRefusal(..., actor)`, `planDelete(..., actor)` and `checkDelete(..., actor)` take the
|
||||
actor, and set op delete passes its actor.
|
||||
- `read(business, actor)`. Its `endProtected` is `protectedRefusal(...) !== null`.
|
||||
- commit's and fire's returned listings read with the writer's actor.
|
||||
- The comment at :28-31 is rewritten.
|
||||
- packages/bus/src/runtime.mjs: `instances` and `instances.check` pass the actor. There is no broker
|
||||
change.
|
||||
- packages/webui/src/public/agents.js: comment only.
|
||||
- docs/plans/DEFERRED.md: a closing line on the creative-delete entry.
|
||||
- Tests:
|
||||
- business instance-settings.test: T1-T3 and T5 in one test, T4 in its own test, and the row 213
|
||||
test's `endProtected` for the human's add is now `false`.
|
||||
- business lifecycle-settings.test: checkDelete with the actor; fire unchanged.
|
||||
- bus creative.test: T6 on the real runtime.
|
||||
- webui agents-browser.test: T7/T8. The comment on the existing assertion is reworded.
|
||||
|
||||
### Mutants for the gate (exact-once replacements, runb8 shape)
|
||||
|
||||
- M1 (instance-settings.mjs): ` if (actor === document.human) return null;` becomes ` return null;`.
|
||||
The kill is instance-settings.test "row 256 T1-T3".
|
||||
- M2 (instance-settings.mjs): move the human check ahead of the creative one. Replace
|
||||
` if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };\n if (actor === document.human) return null;`
|
||||
with
|
||||
` if (actor === document.human) return null;\n if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };`.
|
||||
The kill is "row 256 T1-T3" (T3).
|
||||
- M3 (instance-settings.mjs): ` endProtected: protectedRefusal(document, name, actor) !== null,` becomes
|
||||
` endProtected: END_PROTECTED.includes(entry.definition),`. The kills are "row 256 T1-T3" (T5) and
|
||||
bus creative.test T6.
|
||||
- M4a (runtime.mjs): `instances.checkDelete(id,args.instance,actor)` becomes
|
||||
`instances.checkDelete(id,args.instance)`. The kill is creative.test "row 256 T6".
|
||||
- M4b (instance-settings.mjs): `planDelete(old, instance, actor), { revision` becomes
|
||||
`planDelete(old, instance), { revision`. The kills are "row 256 T1-T3" (T1) and creative.test T6.
|
||||
|
||||
### Tests not run
|
||||
|
||||
None of these have run. They wait for Sage's CLEAR, after the rebase onto 236.
|
||||
|
||||
## 2026-10-22 b2 (Honker): Webby's N1 (webby-0124)
|
||||
|
||||
- Two comments said the chief of staff where they mean the assistant. Both now name the assistant:
|
||||
- instance-settings.mjs:30, "The assistant is refused for every actor, the human included."
|
||||
- agents.js:46, "true for the assistant and on the tester's."
|
||||
- Comment-only. The mutant strings above are unchanged and still occur once.
|
||||
|
||||
## 2026-10-22 gate results (Honker, doc-only after landing)
|
||||
|
||||
This entry was added after landing. It records the gate on f12441d4f, which was the candidate, with manifest-r3
|
||||
01eb75aa9d51367337f4eda855ad5271c5e22b14650aa5a2130333d275a7ab90.
|
||||
|
||||
### Rebases
|
||||
|
||||
- r2 1e761962a onto 236's landing bd5f11ded, and r3 f12441d4f onto 274's landing 0165be266. In both
|
||||
range-diffs, every commit is `=` and nothing conflicted.
|
||||
- The landing patch-id, from `git diff BASE TIP | git patch-id --stable`, is
|
||||
b28e63746f8c1bda1c49c021447c61f2df6ea0d0 on both bases.
|
||||
- Before that, Sage granted an early slot on 1e761962a (sage-2567), limited to steps 1-2. It was an early
|
||||
signal, not gate evidence: 31/0/0, and M1-M4b were all killed.
|
||||
|
||||
### Full gate (Sage's slot, sage-2586, 10:33Z to 10:50Z)
|
||||
|
||||
- The gate tested a merge of the candidate onto origin/refactor 0165be266, tree ced6bf5f8, which matches the
|
||||
candidate's own tree. The DEFERRED.md fallback did not fire.
|
||||
- Touched test files, each run whole: instance-settings, lifecycle-settings, bus creative, and webui
|
||||
agents-browser. 31 pass, 0 fail, 0 skip.
|
||||
- Mutants: M1, M2, M3 (on instance-settings and on creative), M4a, and M4b (on instance-settings and on
|
||||
creative). All were killed by the assertions listed above. Every restore matched the original blob.
|
||||
- Package suites, all 18 at rc=0, 3757 pass, 0 fail:
|
||||
|
||||
| Package | Pass |
|
||||
| --- | --- |
|
||||
| business | 231 |
|
||||
| bus | 788 |
|
||||
| cli | 832 |
|
||||
| control-board | 124 |
|
||||
| conversation | 183 |
|
||||
| discord | 192 |
|
||||
| harness | 151 |
|
||||
| ledger | 89 |
|
||||
| library | 83 |
|
||||
| mosaic | 69 |
|
||||
| plugins | 111 |
|
||||
| processing-control | 62 |
|
||||
| project | 41 |
|
||||
| queue | 156 |
|
||||
| runs | 43 |
|
||||
| seat | 167 |
|
||||
| tasks | 100 |
|
||||
| webui | 335 |
|
||||
|
||||
- scripts/test-*.sh, all 10 at rc=0:
|
||||
|
||||
| Script | Pass |
|
||||
| --- | --- |
|
||||
| auth | 15 |
|
||||
| conductor | 17 |
|
||||
| config | 31 |
|
||||
| discord | 81 |
|
||||
| extension-package | 18 |
|
||||
| foundation | 44 |
|
||||
| land-work | 86 |
|
||||
| queue | 28 |
|
||||
| release | 14 |
|
||||
| task | 91 |
|
||||
|
||||
- Skips: 4, none on a touched path.
|
||||
- The webui editor rebuild byte check, because esbuild 0.28.1 is installed and the check needs 0.28.2.
|
||||
- test-queue verify and render --check, because the gate worktree is not the canonical root.
|
||||
- test-task live provider cases, counted twice. Row 244 makes no provider call in the gate.
|
||||
- Leftovers: none.
|
||||
- Evidence is under /mnt/storage/scratch/honker-t/r256/: run-20261022T103355Z, gate-20261022T103414Z,
|
||||
and console-r3.txt.
|
||||
|
||||
### Review and landing
|
||||
|
||||
- Webby approved manifest round 1 in webby-0129 (REVIEW-r3.md, 00502a740). The approval was recorded at
|
||||
rev 3185, comment c30519.
|
||||
- Sage landed f12441d4f as a fast-forward at 10:57Z. #1761 is closed with comment c30520.
|
||||
@@ -0,0 +1,186 @@
|
||||
# Row 256 (#1761) design: the human deletes a creative instance
|
||||
|
||||
Brief: docs/plans/2026-10-21_creative-delete-human.md (REQ-COS-3). Base be2b008cc (row 194 landed as
|
||||
fd6dd327c). Builder Honker, reviewer Webby. Draft 2, 2026-10-22: Webby's d1 review (webby-0122, notes
|
||||
171480e6e) R1, Q1, Q2 and N1-N2 applied; the changes are listed at the end.
|
||||
|
||||
## What changes
|
||||
|
||||
### 1. One predicate in instance-settings.mjs
|
||||
|
||||
Today `deleteRefusal(document, rolesDir, instance)` refuses every END_PROTECTED definition with
|
||||
`organization-definition-refused`, and read()'s `endProtected` field is the same check
|
||||
(`END_PROTECTED.includes(definition)`).
|
||||
|
||||
New, in `packages/business/src/instance-settings.mjs`:
|
||||
|
||||
```js
|
||||
// Row 256 (#1761, REQ-COS-3): the protected-definition part of a delete. A creative instance is
|
||||
// the human's to delete; any other actor is refused. A non-creative END_PROTECTED definition (the
|
||||
// chief of staff) is refused for every actor, the human included.
|
||||
function protectedRefusal(document, definition, actor) {
|
||||
if (!END_PROTECTED.includes(definition)) return null;
|
||||
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
|
||||
return actor === document.human ? null : { code: "organization-creative-human-only" };
|
||||
}
|
||||
export function deleteRefusal(document, rolesDir, instance, actor) {
|
||||
...unknown-instance unchanged...
|
||||
const refusal = protectedRefusal(document, entry.definition, actor);
|
||||
if (refusal) return refusal;
|
||||
...references, kept, credentials unchanged...
|
||||
}
|
||||
```
|
||||
|
||||
- A creative definition with `actor === document.human` goes on to the remaining checks:
|
||||
references, then kept, then credentials.
|
||||
- The code and message match `creativeHumanOnly` in launch-settings.mjs:13.
|
||||
- The code is `organization-creative-human-only`.
|
||||
- It is raised as a plain refusal code here, as deleteRefusal's other codes are.
|
||||
- I don't call `creativeHumanOnly` itself, because it throws and deleteRefusal returns.
|
||||
- `actor` undefined, null, "tester", or a role actor (`<definition>.<instance>`) never equals
|
||||
`document.human`, so those actors are refused.
|
||||
- validateBusinessDocument runs `requireId(document.human)` (business.mjs:239), so an undefined
|
||||
actor can't match an undefined human.
|
||||
|
||||
### 2. The actor reaches every delete path
|
||||
|
||||
| Path | Today | Change |
|
||||
| --- | --- | --- |
|
||||
| `planDelete(document, instance)` | no actor | `planDelete(document, instance, actor)` |
|
||||
| `set(business, args, actor)` op delete (:207) | has the actor, doesn't pass it | passes it to planDelete |
|
||||
| `checkDelete(business, instance)` | no actor | `checkDelete(business, instance, actor)` |
|
||||
| runtime.mjs:283 `instances.check` | `checkDelete(id, args.instance)` | `checkDelete(id, args.instance, actor)` |
|
||||
|
||||
- The broker's verb check `#deleteRequest` already passes `s.human` to `instances.check` (broker.mjs:2843).
|
||||
- The launcher pass `lifecycleDelete` already passes the request's `r.by`, which is
|
||||
`#owner(s)` at record time, to `instances.set op delete`.
|
||||
- So the broker gets no logic change. Only the deferred comment at :2825 is touched, if it names
|
||||
the old rule.
|
||||
- The CLI (`mosaic instances delete`) and the Console both reach the same bus verb with the human's
|
||||
session.
|
||||
- The tester is refused `tester-refused` at the verb, before any of this (broker.mjs:2595,
|
||||
serve.mjs:394).
|
||||
- That is unchanged.
|
||||
|
||||
### 3. The listing: `endProtected` becomes the per-viewer delete check
|
||||
|
||||
- `read(business)` becomes `read(business, actor)`, and runtime.mjs passes the actor.
|
||||
- The broker already passes `"tester"` for a tester session and `s.human` otherwise (broker.mjs:2627).
|
||||
- Each instance's `endProtected` becomes `protectedRefusal(document, def, actor) !== null`.
|
||||
- This is the same server predicate the delete runs, per the brief.
|
||||
- The human's read shows `endProtected: false` for a creative instance.
|
||||
- The tester's read, and any role read, show `true`.
|
||||
- The chief of staff shows `true` for everyone.
|
||||
- The listing that a write returns is read with the writer's actor.
|
||||
- This covers `commit`'s `{ ...read(business), ... }` at :405 and fire's at :338.
|
||||
- So the human's add of bean returns `endProtected: false`, and the existing :288 assertion
|
||||
changes from `true` to `false` for "jason".
|
||||
- I keep the field name, so the listing gains no key and the deepEqual shapes in
|
||||
instance-settings.test, lifecycle-settings.test and agents-browser.test keep their keys.
|
||||
- Only the value for a creative entry read by the human changes.
|
||||
- The cost is a name that now means "delete refused for its protected definition, for this
|
||||
viewer".
|
||||
- The read() comment says so.
|
||||
- A rename (for example `deletable`) touches every listing fixture; I'd rather not, but I'll take
|
||||
Webby's call.
|
||||
- agents.js:46 is unchanged in logic: `if (!e.endProtected)` offers Delete.
|
||||
- Its comment changes to name row 256.
|
||||
- Delete stays inside the existing `!c.isTester` branch, so the tester page offers none whatever
|
||||
the field says.
|
||||
- The tester has two guards: the server's `true`, and the page's tester branch.
|
||||
|
||||
### 4. Fire is unchanged
|
||||
|
||||
- `fireChecks` / `checkFire` / `instance.fire` / `role.fire` keep refusing END_PROTECTED for
|
||||
every actor.
|
||||
- protectedRefusal is not called from fire.
|
||||
- A test pins that the human's fire of a creative instance still refuses.
|
||||
|
||||
### 5. Persona file
|
||||
|
||||
- `<configDir>/personas/<business>/<instance>.md` is left in place on delete.
|
||||
- That is the brief's smallest choice.
|
||||
- The delete commits the business file and renames the workspace under `.retired`, as today.
|
||||
- The persona file isn't under either path, and nothing reads it once the entry is gone.
|
||||
- bundle.mjs reads it only for a launched creative instance.
|
||||
- Retired names are never reused (row 194 D1), so a later hire can't pick up the old persona.
|
||||
- This goes in BUILD.md, plus a DEFERRED line if Sage wants a cleanup later.
|
||||
- No new file open, so no O_NOFOLLOW test.
|
||||
|
||||
### 6. DEFERRED.md
|
||||
|
||||
The "Creative instances: the human can't delete one" entry (DEFERRED.md:387) gets a closing line:
|
||||
|
||||
> Closed by row 256 (#1761) for delete. Fire still refuses, by REQ-COS-3.
|
||||
|
||||
The entry says "the row covers fire and delete together". The brief keeps fire unchanged, so the
|
||||
closing line says the fire half is closed by ruling, not by code.
|
||||
|
||||
## Tests
|
||||
|
||||
| File | Test |
|
||||
| --- | --- |
|
||||
| business/tests/instance-settings.test.mjs | T1: the human deletes a creative instance (bean); the file loses the entry. |
|
||||
| | T2: the same delete by a role actor (`chief-of-staff.chief`), by "tester" and by no actor refuses `organization-creative-human-only`, and the file is unchanged. |
|
||||
| | T3: the human's delete of the chief of staff refuses `organization-definition-refused`. |
|
||||
| | T4 (R1): with bean the only name in launch.instances, the human's delete of bean gets the last-listed BusinessError from deletedDocument (:64-66), not the creative code, so it passes protectedRefusal and reaches the later checks. A creative instance can't be in launch.keep, launch.by or the arbiters (business.mjs:208, :181-183, :251-255), so those aren't used. |
|
||||
| | T5: read(business, human) shows `endProtected: false` for bean; read(business, "tester") and read(business) with no actor show `true`; the chief of staff shows `true` for every actor. |
|
||||
| | The existing :283-292 test is updated where it pinned the old refusal. |
|
||||
| business/tests/lifecycle-settings.test.mjs | checkDelete with the human passes for a creative instance and with another actor refuses. |
|
||||
| | The fire of a creative instance by the human still refuses (fire unchanged). |
|
||||
| bus/tests/creative.test.mjs (Q2: its runtime() is a real startBroker with the business writer) | T6: the human's `instances.set op delete` on bean records a lifecycle.delete request. |
|
||||
| | Taking it and running the launcher pass commits the delete (the r.by path). |
|
||||
| | The chief is refused `human-required` (broker.mjs:2585) and the tester `tester-refused` (:2595), both at the verb. |
|
||||
| | The human's `instances` read has `endProtected: false` for bean; the tester's has `true`. |
|
||||
| webui/tests/agents-browser.test.mjs | T7: the human page with bean at `endProtected: false` offers Delete for bean. |
|
||||
| | T8: the tester page with the same listing offers no Delete. |
|
||||
| | The existing sage-2164 assertion moves to the tester-shaped listing (bean `endProtected: true`). |
|
||||
|
||||
- The browser tests stub `/api/instances`, so they pin the page's use of the field.
|
||||
- The server side of "never on the tester listener" is T5/T6.
|
||||
- No test touches live paths: fixtures under mktemp in /mnt/storage/scratch, as today.
|
||||
|
||||
## Mutants (all must be killed)
|
||||
|
||||
- M1: protectedRefusal's creative branch returns `null` for every actor, so a non-human deletes a
|
||||
creative instance. Killed by T2 only (N1): through the bus no non-human actor reaches the delete,
|
||||
since the chief and the tester are refused at the verb.
|
||||
- M2: protectedRefusal checks the human before the creative check, so
|
||||
`actor === document.human ? null : ...` for every END_PROTECTED definition. The human then
|
||||
deletes the chief of staff. Killed by T3.
|
||||
- M3: read() keeps the old `END_PROTECTED.includes`, so the listing isn't the per-viewer predicate.
|
||||
Killed by T5.
|
||||
- M4 (N2): the actor isn't threaded: runtime.mjs:283 calls `checkDelete(id, args.instance)`
|
||||
without it. Killed by T6, the human's real-writer delete. A second variant, set op delete not
|
||||
passing the actor to planDelete, is killed by T1 and by T6's launcher pass.
|
||||
|
||||
## Boundaries kept
|
||||
|
||||
- No change to M10 add/change, role.hire, fire, veto or launch.keep.
|
||||
- No schema change and no migration.
|
||||
- No new verb.
|
||||
- No change to the tester gate.
|
||||
|
||||
## Gate (on Sage's CLEAR)
|
||||
|
||||
- Touched test files, each whole.
|
||||
- The business, bus, cli and webui suites.
|
||||
- Every scripts/test-*.sh.
|
||||
- The four mutants above (M4 in both variants), each on a green base, restored and blob-checked (the runb8 shape).
|
||||
|
||||
## Comments rewritten (Q1)
|
||||
|
||||
- `endProtected` keeps its name: agents.js:46 is its only reader, and it means "don't offer Delete".
|
||||
- instance-settings.mjs:28-31 loses "no one deletes one yet". It says the human deletes a creative
|
||||
instance and any other actor is refused, and that the listing's endProtected is protectedRefusal
|
||||
for the reader.
|
||||
- agents.js:45 says the same, naming row 256.
|
||||
|
||||
## Changes from draft 1 (webby-0122)
|
||||
|
||||
- R1: T4 uses the last-listed refusal instead of launch.keep, which the validator refuses for a
|
||||
creative instance.
|
||||
- Q1: keep the name; comments rewritten; T5 pins read(business) with no actor as true.
|
||||
- Q2: the bus test goes in creative.test.mjs.
|
||||
- N1: M1 is killed by T2 only; T6 pins the chief's `human-required`.
|
||||
- N2: M4 added for the threading.
|
||||
@@ -113,12 +113,33 @@ fake or a scratch-container token; values are compared with `assert.ok`, never p
|
||||
|
||||
## Runs
|
||||
|
||||
(filled in after Sage's CLEAR)
|
||||
Sage's CLEAR (sage-2554), 10-22 09:00-09:28Z, one slot script that stops at the first red. Logs are in
|
||||
`scratch/megavolt-r236/logs/` (`slot-2c.txt`, `slot-gate.txt`, `slot-mutants.txt`, `slot-all.txt`).
|
||||
|
||||
- Section 2c files whole, in the seat worktree, TMPDIR under `/mnt/storage/scratch/tmp`:
|
||||
- bus (gitea-grant, the migration tests, schema, channel-proof, chief-of-staff, grant-host,
|
||||
project-runtime): 131 pass, 0 fail, 0 skipped;
|
||||
- cli (grants, stack-release): 224 pass;
|
||||
- route (grants, access): 28 pass;
|
||||
- docker (gitea-grant-docker): 1 pass.
|
||||
- The whole gate, in a detached worktree at bd5f11ded:
|
||||
- `packages/bus/tests/*.test.mjs` 765 pass, `packages/business/tests/` 228 pass,
|
||||
`packages/cli/tests/*.test.mjs` 832 pass, each 0 fail and 0 skipped;
|
||||
- every `scripts/test-*.sh` rc=0: auth, conductor, config, discord, extension-package, foundation,
|
||||
land-work, queue (156 pass), release, task.
|
||||
- Named skips: test-queue line 34 (queue verify and render --check, because the gate worktree isn't the
|
||||
queue's canonical root), and test-task lines 87 and 93 (live provider cases, row 244: no provider call in
|
||||
the gate).
|
||||
- The gate worktree was clean after the run. GATE_PASSED at 09:23:11Z.
|
||||
|
||||
## Mutants
|
||||
|
||||
(section 9's list, each with the test that kills it; filled in after the runs)
|
||||
|
||||
Result, at bd5f11ded after the gate (`r236-mutants.sh`): the base (gitea-grant, gitea-migration,
|
||||
grant-migration, schema; cli grants) was green, then all 40 killed and none survived: M1-M37, N9a, N9b and M38.
|
||||
Each mutant's restore left the worktree clean. SLOT_GREEN at 09:28:50Z.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# FED R3 (#414): the broker's user principal
|
||||
|
||||
Brief: docs/plans/2026-10-22_federation-r3.md (f0d0b31e9). Design: FED r4.11 (b93f098f0), sections 3.5, 3.6 and
|
||||
the R3 row of section 7. Base: row 205a's candidate 67640f892. If 205a changes in review, R3 rebases onto a new
|
||||
branch name. No schema change. Nothing has run yet: every test run waits for Sage's CLEAR.
|
||||
|
||||
## Changes
|
||||
|
||||
`packages/bus/src/broker.mjs`:
|
||||
- `USER_VERBS` is the collaborator list: `project.list`, `thread.create`, `thread.list`, `thread.open`,
|
||||
`thread.send`, `thread.messages`, `names`. The grant level is `collaborator`, through R1's `covers`.
|
||||
- The Broker takes `access`, a function that returns the checked access document (the runtime passes R1's
|
||||
`loadAccess`). `#userGrants` calls it once per user request. A throw, null or no document gives no grants.
|
||||
- `bindUser({business, user})` (3.6 item 1). The keys must be exactly those two. A user id that fails
|
||||
`ID_PATTERN` or is `human`, `tester`, `user` or the business human fails `access-refused`, whatever the file
|
||||
says. No live collaborator grant fails `access-revoked`. The session is
|
||||
`{business, user, role: null, run: null, human: false}`.
|
||||
- `request()`: for a user, a verb off `USER_VERBS` fails `access-refused` before any argument check. Then the
|
||||
grants are read; none fails `access-revoked`. The view the verb sees carries them as `grants`.
|
||||
`requestTask`, `requestWeb` and `requestHost` fail a user `access-refused` first.
|
||||
- Filters: `project.list` keeps granted projects. `thread.list` keeps the user's own threads its grants still
|
||||
cover. `names` keeps granted instances. `thread.create` checks the project and the thread instance against
|
||||
the grants before the project lookup, so an ungranted id and an unknown id both refuse `access-refused`.
|
||||
`thread.open`, `thread.send` and `thread.messages` refuse a thread the grants no longer cover.
|
||||
- `#owner` gives `user:<id>` (item 2). `#threadFor` refuses a user as it refuses the human (item 3).
|
||||
`#event` writes `actor_role` `user` and `actor_run` `user:<id>` for a user session (item 6), so a refusal is
|
||||
recorded against the user.
|
||||
- `thread.send` from a user: `from_role` `user`, `from_run` `user:<id>`, no `human.input`.
|
||||
- `message.send` (item 7, B6): a run sends `to: 'user'` only with an `in_reply_to` naming a `user` message in
|
||||
the run's own thread, sent by that thread's owner. Anything else fails `user-reply-required`. The human and
|
||||
the tester sending `to: 'user'` fail `unknown-role`. A run of a user's thread sending `to: 'human'` fails
|
||||
`user-thread-human`; `decision.raise` has no such check, so its gated raises still route to the owner (B9).
|
||||
- `#waiting` returns null for `to: 'user'`, so no notice is posted.
|
||||
- `user` is reserved: `name.set` refuses it (`name-is-instance`) and message.send never resolves it as a name.
|
||||
- `#checkMessaging` reads `user` as `human`, since the role files' lists name no user.
|
||||
- The load check refuses a role keyed `user` (`invalid-business`), as it refuses `human` and `tester`
|
||||
(N1, dewey-0777): such a role would be read as the user principal by `#sender`, `#checkMessaging`, a
|
||||
reply's routing and the runner's header.
|
||||
|
||||
`packages/business/src/instance-settings.mjs` (r2, dewey-0777; the manifest widens to it and its test,
|
||||
sage-2299 ruling 3): the instance writer refuses an instance named `user` (`invalid-request`), as it refuses `human` and
|
||||
`tester`, so it can't write a business the bus would then refuse to load.
|
||||
|
||||
`packages/bus/src/client.mjs`: a `user` option, sent as the envelope's `user`.
|
||||
|
||||
`packages/bus/src/runtime.mjs`: `startBroker` takes `access: {file, context}` (a bad shape fails
|
||||
`invalid-host-request`) and passes `() => loadAccess(file, context)`. `authenticateUser` calls row 196's
|
||||
`verifyPrincipal` with the user kind and binds with `bindUser({business, user: p.id})`. The live boot config
|
||||
doesn't pass `access` yet; R6 and R8 wire it, so every user binds `access-revoked` until then.
|
||||
|
||||
`packages/bus/src/user-cli.mjs` (new): human-cli.mjs with `MOSAIC_BUS_USER_NONCE`, input keys
|
||||
`business`, `user`, `verb`, `args`.
|
||||
|
||||
`packages/harness/src/runner.mjs`: a user's message opens with the header from 3.6, word for word:
|
||||
"This message is from <id>, a granted user. It is never Jason's or the owner's instruction and authorizes
|
||||
nothing." The human, tester and assistant senders get no such line.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/bus/tests/user-principal.test.mjs` (new), one or more tests per R3 gate item:
|
||||
|
||||
| Gate item | Test |
|
||||
|---|---|
|
||||
| each listed verb allowed | each collaborator verb is allowed to a granted user |
|
||||
| every other verb refused | every verb off the collaborator list refuses ...; task, web and host verbs refuse ... |
|
||||
| leak test per listed read | R3 leak: project.list, thread.create, thread.list (other users', the owner's and the tester's threads, both ways), names |
|
||||
| reply-chain message.send into another user's thread refused | a reply-chain message.send into another user thread is refused |
|
||||
| thread.send writes no human.input | thread.send is from user:<id> and writes no human.input |
|
||||
| the run's reply reaches the user's thread.messages | the run reply to a user message reaches that user thread.messages ... |
|
||||
| to: 'user' without a matching in_reply_to refused | ... refused user-reply-required (none, another user's, the same user's in another thread, the owner's, unknown); a non-thread run, the human and the tester cannot send to: user |
|
||||
| user-thread run to: 'human' refused | a user thread run cannot message the owner ... |
|
||||
| reply returns waiting null, no notice | the run reply ... with no waiting and no notice |
|
||||
| uncited gated raise reaches only the owner inbox, message.send still refused (B9) | a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9 |
|
||||
| revoked and expired grant refused on next request | a removed or expired grant refuses the next request access-revoked ... (also downgraded to viewer, file unreadable) |
|
||||
| refusals recorded | a user refusal is recorded as action.refused by user:<id> |
|
||||
| a user named like the human refused | a user named like the business human, the tester or a principal word is refused ... |
|
||||
| N1: role key `user` refused (r2) | a business with a role keyed user refuses invalid-business |
|
||||
|
||||
`packages/harness/tests/runner.test.mjs`: the user header, exact string, and its absence for the other senders.
|
||||
|
||||
`packages/business/tests/instance-settings.test.mjs` (r2): row 165's validation test also refuses instance
|
||||
`user`.
|
||||
|
||||
Row 196's `principal.test.mjs` already covers the user kind of `verifyPrincipal` (agent parent, non-Console
|
||||
parent, kind confusion are its mutants). No runtime test of `authenticateUser` or user-cli yet: the stub
|
||||
`authenticateUser` path needs a real Console parent, which is R6's harness.
|
||||
|
||||
## Audit of `s.human` and `s.tester` branches
|
||||
|
||||
A user reaches only the seven `USER_VERBS` through `request()`. Task, web and host requests refuse it first.
|
||||
The user cap is transient: the server binds it per request and releases it, and never returns it to a client,
|
||||
so the cap-taking launcher and git ops (`authorize`, `checkPush`, `recordPush`, `checkLand`, `reviewFetch`,
|
||||
`recordEvent`, `recordTask`) can't receive one.
|
||||
|
||||
| Branch | For a user |
|
||||
|---|---|
|
||||
| `#agent`, `#human` | `#human` refuses a user (`human-required`); every listed verb skips it with `!s.user`. `#agent` is reached only from `names` for a non-human, non-tester, non-user session. |
|
||||
| `#threadFor` | refuses a user (item 3). Not reachable: message.send and the review verbs are off the list. An equivalent mutant; the line stays. |
|
||||
| `#testerOwns`, `#testerThread` | `s.tester` false and `s.user` sessions have no thread: not taken. |
|
||||
| `#owner`, `#sender`, `#event` | changed: `user:<id>`, `user`, actor columns. |
|
||||
| `#userThread` | new; false for a user session itself. |
|
||||
| `endLaunch` session sweep | matches `s.run === record.run`; a user's run is null and a record's run is an identifier: never matches. |
|
||||
| `identity` | returns `{business, role: null, run: null, human: null}`. server.mjs reads only `business`, for the web service, and requestWeb then refuses. |
|
||||
| `recordTask` | its cap check would pass a user, but only `requestTask`'s handler and the git ops call it with a cap: not reachable. |
|
||||
| `request()` creative raise, usage, limitStop, organization verbs, hosts, project, library, credentials, reader and tester gates | after the user gate: not reachable. |
|
||||
| `#hostsCaller`, `#requestCredential`, `#limitStop`, `#dispatch` role checks, `#claimEnd`, `#checkBlock` | reached only from verbs off the list: not reachable. |
|
||||
| `message.send`, `message.receive`, `message.read`, `messages`, `replies`, `trail`, `decisions`, `credentials`, `state`, `usage`, `decision.withdraw`, `assistant.stop`, `name.set`, `project.create`, `role.claim` | off the list: not reachable. |
|
||||
| `project.list`, `thread.create`, `thread.list`, `thread.open`, `thread.send`, `thread.messages`, `names` | changed as above. |
|
||||
|
||||
## Mutants (each must fail its named test, in user-principal.test.mjs unless named)
|
||||
|
||||
| Mutant | Edit | Named test |
|
||||
|---|---|---|
|
||||
| M1 | the grant read skipped: no `access-revoked` after the per-request read | a removed or expired grant refuses the next request access-revoked ... |
|
||||
| M2a | project.list filter dropped | R3 leak: project.list shows granted projects only |
|
||||
| M2b | thread.list filter dropped | R3 leak: thread.list shows ... never others |
|
||||
| M2c | names filter dropped | R3 leak: names shows granted instances only |
|
||||
| M2d | `#grantedThread` check dropped | R3 leak: thread.list shows ... never others |
|
||||
| M2e | thread.create grant check dropped | R3 leak: thread.create needs the project and the thread instance granted ... |
|
||||
| M3x | `message.send` added to `USER_VERBS` (list mutant) | every verb off the collaborator list refuses ... |
|
||||
| M4 | the reply-to check dropped | a reply to: user without a matching in_reply_to is refused user-reply-required |
|
||||
| M5 | the business human dropped from bindUser's reserved list | a user named like the business human ... |
|
||||
| M6x | `trail` and `replies` added to `USER_VERBS` (second list mutant) | every verb off the collaborator list refuses ... |
|
||||
| M7 | user-thread-human check dropped | a user thread run cannot message the owner ..., B9 |
|
||||
| M8 | `to === 'user'` dropped from `#waiting` | the run reply to a user message reaches ... with no waiting and no notice |
|
||||
| M9 | thread.send writes `human.input` for a user | thread.send is from user:<id> and writes no human.input |
|
||||
| M10 | the load check accepts a role keyed `user` | a business with a role keyed user refuses invalid-business |
|
||||
| M11 | instance-settings accepts instance `user` | instance-settings.test.mjs: row 165 refuses each validation failure ... |
|
||||
|
||||
Rulings from Dewey's early look (dewey-0777), confirmed by Sage (sage-2299 rulings 1 and 2), on the two
|
||||
mutants the design and brief name:
|
||||
1. "#threadFor's user refusal removed" is an equivalent mutant. No user request reaches `#threadFor`: its
|
||||
only callers are message.send and `#reviewMessage` (review.request and review.verdict), all off the list.
|
||||
The line stays as defence in depth. M3x stays as a list mutant. The kit also runs the reply-chain test
|
||||
under M3x as the two-walls evidence: with the access-refused assertion turned into a log line, it records
|
||||
the code alice's send gets (expected `human-required`, from `#human`, since a user has no run) and still
|
||||
asserts bob's thread holds one message.
|
||||
2. The brief's "drops the 193b1a filter for user sessions" line is void (sage-2299 ruling 2): a user can't
|
||||
call trail or replies, so no filtered read reaches a user. M6x is a second list mutant, not a stand-in.
|
||||
|
||||
## Notes
|
||||
|
||||
- N1: fixed in r2 (dewey-0777). The broker's load check and the instance writer refuse `user`, one test each
|
||||
(M10, M11). The instance-settings change stays in R3 and the manifest widens to it and its test (sage-2299
|
||||
ruling 3). Sage checked the live business config: no role or instance is named `user`, so a re-pin
|
||||
carrying R3 loads (ruling 4; Sage checks again before that re-pin).
|
||||
- N2: server.mjs answers a `user` envelope with no `authenticateUser` as `human-required`, the existing
|
||||
default for a non-tester kind. The runtime always passes `authenticateUser`, so only a bare test server sees it.
|
||||
|
||||
## r4 (sage-2486, sage-2557): on row 194's landing
|
||||
|
||||
Row 194 landed as fd6dd327c. r4 rebases R3 onto it and adds four changes. Sage ruled on each.
|
||||
|
||||
- Halt (sage-2486): processing halt is the kill switch, so it refuses a granted user's writes
|
||||
(thread.create, thread.open, thread.send) with `processing-halted`. The reads on #processingGate's list,
|
||||
thread.messages among them, still answer. The kill-switch plan (:14-16) needs this, and Dewey agreed. Test:
|
||||
"R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still
|
||||
answers". setup() takes a `processing` stand-in for it, as lifecycle-verbs T15 does.
|
||||
- S1 (sage-2557): row 194's hire takes an instance name. Its two checks refused only `human` and `tester`, so
|
||||
a chief could hire `user` and get past N1's load-check refusal. Both now refuse `user` with the code they
|
||||
already give (`invalid-request`): broker #hireFireArgs, and the writer's hire() in instance-settings.mjs. One
|
||||
test each: user-principal "R3 r4: the chief's hire refuses an instance named user ..." (with `helper` as a
|
||||
control that passes the name check), and a hire loop in instance-settings' "row 165 refuses each
|
||||
validation failure ..." that checks the file's bytes stay unchanged.
|
||||
- S2 (sage-2557, sage-2501): the `access` TypeError check moves up beside harnessOn, testerOn and roleFile,
|
||||
before the store is read or initializeControl runs. Test: "R3 r4: a non-function access throws before
|
||||
the store is read" (null store, T8b style).
|
||||
- Merge: the three 194 conflicts in broker.mjs are unions. R3's `user` role refusal moves into 194's
|
||||
checkBusiness(). instance-settings takes 194's add/change/delete op list with R3's `user` refusal.
|
||||
|
||||
Mutants M12-M15 kill each of the changes above: M12 the broker hire, M13 the writer hire, M14 the access
|
||||
check moved below the store read, M15 thread.send added to the halt-exempt list. The kit has 19 mutants plus
|
||||
the M3x two-walls run. All 19 anchors match exactly once on the r4 tree.
|
||||
|
||||
## r5 (sage-2566): covers moves into the vocabulary
|
||||
|
||||
r4 was rebased onto row 236's landing, bd5f11ded, as 1920b926f. Sage ruled (b) in sage-2566, with Dewey's support
|
||||
(dewey-0820), and r5 goes on refactor 47a35bf43. Checking that rebase found a fault in R3 itself.
|
||||
The broker imported `covers` from business/src/access.mjs. That broke the pin in project-runtime.test.mjs
|
||||
("Broker keeps the sandbox client import closure ..."), and it would have broken the runner: the session
|
||||
sandbox mounts only seat/src/sandbox.mjs RUNNER_FILES, and access.mjs, which imports errors, business and
|
||||
util, isn't one of them. R3 had never been run, so nobody had seen this.
|
||||
|
||||
- `covers`, `LEVELS` and the all marker (now `GRANT_ALL`) move into business/src/vocabulary.mjs. That file
|
||||
is already in RUNNER_FILES and still imports nothing, the same reason row 194 put `unhireableAction`
|
||||
there. access.mjs imports them back and re-exports `LEVELS` and `covers`, so index.mjs, the cli and the
|
||||
access tests don't change.
|
||||
- The broker takes `covers` from its vocabulary import. The pin test adds it to that line, with a comment.
|
||||
- RUNNER_FILES is unchanged.
|
||||
- M16 puts the access.mjs import back, and three tests must fail on it: the project-runtime pin; seat
|
||||
sandbox.test.mjs "RUNNER_FILES is exactly what the runner and the harness sources it starts import"
|
||||
(M16b), which walks the real import graph from the runner; and the confined-session test there (M16c),
|
||||
which starts the runner under bwrap with only the RUNNER_FILES mounts, so it proves the closure by loading
|
||||
it. M16c is skipped where bwrap can't run, and the gate log names any skip. The kit has 22 mutant runs plus
|
||||
the M3x two-walls run, and every anchor matches exactly once.
|
||||
- The manifest widens to vocabulary.mjs, access.mjs and project-runtime.test.mjs. Tests that import
|
||||
either module use named exports only; none deepEquals an export list or pins a hash (sage-2566 item 4).
|
||||
The kit runs project-runtime, access, access-levels and seat sandbox whole, plus the seat suite, and adds the cli suite and every `scripts/test-*.sh`
|
||||
(mos-1200).
|
||||
|
||||
## r6 (sage-2576): the list test pins the collaborator list
|
||||
|
||||
The r5 gate on f58cfc361 stopped red at 10:05:09Z: M3x and M6x survived (morgana-0108). The test "every
|
||||
verb off the collaborator list ..." deleted the broker's own USER_VERBS from its off-list set, so a verb a
|
||||
mutant added to the list left the checked set as well. The broker's allowlist at the user gate is right.
|
||||
|
||||
- The test now deepEquals USER_VERBS against the ruled seven verbs, written out, before the delete loop.
|
||||
M3x and M6x fail on that line.
|
||||
- The off-list set adds GRANTS_VERBS, LIFECYCLE_HUMAN_VERBS, WEB_VERBS and HOST_VERBS (sage-2576 item 2).
|
||||
The user gate refuses each of them `access-refused` before any other check. If one doesn't, that is a
|
||||
finding for Sage, not a test fix.
|
||||
- Test only. Only user-principal.test.mjs's blob changes in the manifest. The gate reruns whole on the r6 tip.
|
||||
|
||||
## Gate
|
||||
|
||||
The kit is /mnt/storage/scratch/morgana-r3 (`slot-r3.sh`, `mutate.py`, `busy.sh`), run as
|
||||
`bash busy.sh && CAND=<sha> bash slot-r3.sh` on Sage's CLEAR. It runs these files whole: user-principal,
|
||||
principal, human, tester, tester-threads, tester-writes, threads, names, messaging, messages, decisions,
|
||||
decision-route, broker, transport, peer-transport, process, message-filter, harness runner and business
|
||||
instance-settings, project-runtime, business access and access-levels, and seat sandbox. Then the bus, harness, business, cli and seat suites, then the 22 mutant runs (r5), each on its named
|
||||
test and restored after each, then the M3x two-walls run. It uses
|
||||
umask 022 and TMPDIR from `mktemp -d /mnt/storage/scratch/tmp/gr3XXXX`, refuses unless HEAD is the
|
||||
candidate and the tree is clean, and stops after two unexpected failures (mos-0823).
|
||||
|
||||
The kit runs every `scripts/test-*.sh` after the suites and counts each script's `^skip` lines (r5). Every run is slot work (sage-2280,
|
||||
sage-2299 ruling 5); the brief's "single test files run freely" line is stale.
|
||||
|
||||
Static check: `node --check` on every changed source and test file, run from `/`. All parse. All fifteen
|
||||
mutant anchors match exactly once.
|
||||
@@ -0,0 +1,267 @@
|
||||
# #1782: the owner sees a thread agent's claims (design r1)
|
||||
|
||||
Morgana, 2026-10-22, for Duckworth. Brief: docs/plans/2026-10-22_owner-thread-claims.md (Sage, for
|
||||
mos-1262). The brief wasn't committed and had no queue row when this was written, so the folder is named
|
||||
for the brief. It moves to queue-<row> once the row exists. Base: refactor c27207f6e (205 landed at
|
||||
ce6a96ad6, then 268). No code yet.
|
||||
|
||||
## What the code does now
|
||||
|
||||
- A thread run's `role.claim` writes `thread_claims` only and returns (broker.mjs:2682 at ce6a96ad6).
|
||||
- `agents` (broker.mjs:3409) reads `role_claims` only:
|
||||
- human and reader sessions get `#agentState(c)` per held role;
|
||||
- role and tester sessions get the raw rows.
|
||||
- A held thread claim is the thread's latest `thread_claims` row with op `claim`. `#threadHolders`
|
||||
(:483) and `#threadRows` (:624) already compute it that way, with `c.seq = max(seq)` per thread.
|
||||
- `threads.name` is the name a user wrote. `threads.instance` is the role instance from the business
|
||||
file, which the operator writes, not the user.
|
||||
|
||||
## The problem with the brief's shape
|
||||
|
||||
The brief adds thread rows to the human and reader `agents` result, after the role rows. Every consumer
|
||||
of that result assumes one row per role:
|
||||
|
||||
| Consumer | What it reads | What a thread row would do |
|
||||
|---|---|---|
|
||||
| Console Agents view, webui bus.js:474 | `c.role` in a "Held roles" table, `claims.length` as its count | blank role cell, wrong count |
|
||||
| Console board held table, bus.js:447 and :462 `held-count` | same | same |
|
||||
| Console composer, bus.js:298 | `c.role`, filtered with `typeof r === 'string'` | dropped safely |
|
||||
| CLI TUI agents view, screens.mjs:280 and :284, app.mjs:288 and :433 | `c.role` as title and column | blank title |
|
||||
| CLI `mosaic agents`, format.mjs:54 | `c.role` | `undefined` in the line (brief item 4 covers this one) |
|
||||
|
||||
So the shape as briefed also needs the Console (webui) and the TUI changed in this row, or the live
|
||||
Agents view breaks on the next re-pin. The brief's manifest names only format.mjs.
|
||||
|
||||
## Proposed shape: a separate owner verb
|
||||
|
||||
The brief allows this ("for example a separate owner verb, it goes in BUILD.md to Duckworth before
|
||||
code").
|
||||
|
||||
**D1. New verb `thread.claims`**, human and reader sessions only. Every other session refuses with
|
||||
`human-required`, the code `decisions` uses at :3426. No arguments (`keys(a, [], [])`). The result, in
|
||||
thread creation order:
|
||||
|
||||
```js
|
||||
[{ thread, instance, holder_run, harness, at }]
|
||||
```
|
||||
|
||||
- One row per thread whose latest `thread_claims` op is `claim`.
|
||||
- `at` is that claim row's time.
|
||||
- It doesn't return `name`, message content or `address`. `address` matches the brief's four fields;
|
||||
it can be added if Duckworth wants it next to the role rows.
|
||||
- `instance` is beyond the brief's four. It names which assistant holds the thread, and the operator
|
||||
writes it, not the user.
|
||||
|
||||
SQL, the `#threadHolders` join without the instance and owner filter:
|
||||
|
||||
```sql
|
||||
SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t
|
||||
JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id)
|
||||
WHERE t.business=? AND c.op='claim' ORDER BY t.seq
|
||||
```
|
||||
|
||||
**D2. `agents` doesn't change** for any session. That is stronger than brief item 2 (role sessions
|
||||
only), and it means the Console, TUI and composer need no change in this row.
|
||||
|
||||
**D3. Reader allow-list.** `thread.claims` goes in the reader list at :2370. The processing gate (:402)
|
||||
already exempts human and reader sessions, so a halt doesn't hide claims. No TESTER_VERBS or USER_VERBS
|
||||
entry is added (see D6).
|
||||
|
||||
**D4. CLI `mosaic agents`** (cli.mjs:314) calls `agents` and then `thread.claims`. `formatAgents(list,
|
||||
threads = [])` prints the role lines unchanged, then for each held thread claim:
|
||||
|
||||
```
|
||||
thread <thread> <instance> <holder_run> <harness> since <at>
|
||||
```
|
||||
|
||||
Each line starts with `thread `, so a reader can tell the two kinds apart (brief item 1). Role output
|
||||
with no thread claims is byte-equal to today, including "agents: no role is claimed". When there are no
|
||||
roles but there are thread claims, the empty-roles line is still printed, then the thread lines.
|
||||
|
||||
**D5. The governance pin.** message-filter.test.mjs, the "claims" item: `threadsState` becomes
|
||||
`call(owner, 'thread.claims')`, which must include `{ thread: U, holder_run: 'assistant-run' }`. The
|
||||
comment that names this row goes away (brief item 5).
|
||||
|
||||
**D6. What this row leaves out**, so as not to widen beyond owner and reader sessions (mos-1202):
|
||||
|
||||
- tester sessions;
|
||||
- FED R3 user principals, who own threads under R3, so their own claims are an R6 question;
|
||||
- a Console section;
|
||||
- the TUI.
|
||||
|
||||
## Questions for Duckworth and Sage
|
||||
|
||||
- **Q1.** Do you accept D1 over the briefed mixed list? If you'd rather have the mixed list, the
|
||||
manifest grows to webui bus.js, screens.mjs and app.mjs, plus their tests (bus-browser,
|
||||
webui bus.test and the TUI tests).
|
||||
- **Q2.** Should the Console show thread claims before a user thread goes live? PRD :1083-1084 promises
|
||||
visibility to the owner, and a Console user won't run `mosaic agents`. My recommendation: a follow-up
|
||||
row bound like this one (it lands before any user thread goes live), so this row stays small for
|
||||
10-27. The alternative is a "Thread claims" section in the Console Agents view in this row.
|
||||
|
||||
## Tests (under D1)
|
||||
|
||||
All new tests go in packages/bus/tests/agents.test.mjs unless named otherwise. Each one runs on a store
|
||||
with one held role claim and one thread run.
|
||||
|
||||
- **T1.** Human `thread.claims` lists the held thread claim (thread, instance, holder_run, harness,
|
||||
at). Reader gets the same result.
|
||||
- **T2.** After release, the thread is gone. After a re-claim followed by a revoke, it's gone again.
|
||||
The thread has an earlier `claim` row, so a first-op reading would still list it.
|
||||
- **T3.** A role session's `thread.claims` refuses `human-required`.
|
||||
- **T4.** `agents` is deepEqual to a copy taken before the thread run claimed, for the role, human
|
||||
and reader sessions on the same store.
|
||||
- **T5.** `JSON.stringify` of the `thread.claims` result has neither the thread's name nor the body of
|
||||
a message sent in that thread.
|
||||
- **T6.** packages/cli/tests/format.test.mjs: a mixed list renders the role lines exactly as today,
|
||||
then `thread …` lines. The empty-roles case with threads is covered too.
|
||||
- **T7.** packages/bus/tests/message-filter.test.mjs: the moved pin (D5).
|
||||
|
||||
## Mutants (each killed by a named test)
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | `thread.claims` returns `[]` | T1, T7 |
|
||||
| M2 | the held check uses `min(seq)` (first op) | T2 |
|
||||
| M3 | the human/reader check is dropped, so a role session gets thread rows | T3 |
|
||||
| M4 | `agents` for human appends the thread rows | T4 |
|
||||
| M5 | the SELECT adds `t.name` | T5 |
|
||||
| M6 | formatAgents drops the thread lines | T6 |
|
||||
|
||||
## Manifest (expected)
|
||||
|
||||
- packages/bus/src/broker.mjs
|
||||
- packages/bus/README.md (a paragraph for `thread.claims`, next to `agents`)
|
||||
- packages/bus/tests/agents.test.mjs
|
||||
- packages/bus/tests/message-filter.test.mjs
|
||||
- packages/cli/src/cli.mjs
|
||||
- packages/cli/src/format.mjs
|
||||
- packages/cli/tests/format.test.mjs
|
||||
- this BUILD.md
|
||||
|
||||
Verb-list pins checked at c27207f6e:
|
||||
|
||||
- tester-writes.test.mjs:19 pins `TESTER_VERBS` exactly. D6 adds nothing to it, so the pin holds.
|
||||
- No test pins the reader list at :2370.
|
||||
- `thread.messages` appears in tester-threads, threads and webui chat as calls, not as a verb set.
|
||||
|
||||
Before the in-review move, I'll rerun the same grep for `thread.claims` and for any new verb-set pin.
|
||||
|
||||
Gate (brief): Duckworth approves the manifest. On Sage's gate, the touched test files run whole, the
|
||||
bus and cli suites are green, every scripts/test-*.sh is green, and M1-M6 are killed.
|
||||
|
||||
## r2 (duckworth-0118: D1 approved with C1-C3)
|
||||
|
||||
Q1: Duckworth accepts D1. Q2: Duckworth is fine with a follow-up row under the same bind; Sage rules on
|
||||
it. Duckworth's note is agents/duckworth/work/row1782/DESIGN-r1.md on duckworth/reviews 13fb9abc5.
|
||||
|
||||
**C1. `--tester` makes one call.** callFor binds the tester when `--tester` is given (cli.mjs:192).
|
||||
D6 keeps `thread.claims` off TESTER_VERBS, so a second call there would refuse tester-refused and exit
|
||||
3. tester.test.mjs:128 pins exactly one spawn, `agents`. So cli.mjs calls `thread.claims` only when
|
||||
`--tester` is absent. A tester's `mosaic agents` is unchanged.
|
||||
- T8 (packages/cli/tests/format.test.mjs or the CLI test that drives main() with a stand-in transport;
|
||||
the exact file is picked at build): `agents --tester` makes one call, `agents`; human `agents` makes
|
||||
two, `agents` then `thread.claims`.
|
||||
- Gate adds tester.test.mjs and other-tree.test.mjs, each run whole.
|
||||
- M7: thread.claims is called on the tester path too. T8 and tester.test.mjs:128 kill it.
|
||||
|
||||
**C2. `--json` shape.** `mosaic agents --json` prints the `agents` array unchanged, as `inbox --json`
|
||||
prints decisions only (cli.mjs:308). Only the text output adds the `thread …` lines. The machine read of
|
||||
thread claims is the bus verb, and the bus README paragraph documents `thread.claims` as such. A grep at
|
||||
c27207f6e found no consumer of `mosaic agents --json` in packages/*/tests or scripts. T6 pins both: the
|
||||
text output is mixed, and the `--json` output deepEquals the `agents` result alone.
|
||||
|
||||
**C3. Where the pin moves.** Under D1 the governance pin moves to the owner's `thread.claims` call, not
|
||||
to `agents`. That is brief item 5 read under D1: the brief named `agents` because it put thread rows
|
||||
there.
|
||||
|
||||
Notes taken:
|
||||
- N1: T4's before/after equality covers the tester session too.
|
||||
- N2: T3 adds a tester: `thread.claims` refuses tester-refused.
|
||||
- N3: FED R3 user principals must not get other users' thread claims. This row gives them none
|
||||
(no USER_VERBS entry). The rule carries with 205 N1/N3 to 205b/R3.
|
||||
- N4: the :403 processing-gate list is in the verb-set grep. Human and reader sessions return before
|
||||
that list (:402), so `thread.claims` needs no entry there, and no test pins the list (grep at
|
||||
c27207f6e). The :2370 reader list is the one list that gains the verb.
|
||||
|
||||
Mutants are now M1-M7. Building starts once Sage creates the row.
|
||||
|
||||
## r3: build (sage-2486, on 23c3d6218)
|
||||
|
||||
Changes from the design, each a placement choice:
|
||||
|
||||
- **Bus tests file.** T1-T5 go in a new file, packages/bus/tests/thread-claims.test.mjs, not
|
||||
agents.test.mjs. agents.test.mjs's businesses have no `assistant` instance, so a thread run would
|
||||
need a new fixture there. The new file copies message-filter.test.mjs's setup (chief plus an
|
||||
`assistant` thread instance, human, reader and tester sessions). Its threads are owned by the
|
||||
business human (`jason`), so the human's `assistant.stop` reaches the run for T2's revoke.
|
||||
- **T3** covers the chief's role session and the thread run itself (both `human-required`) and the
|
||||
tester (`tester-refused`, N2).
|
||||
- **T4** covers human, reader, tester and chief sessions (N1).
|
||||
- **T5** writes the thread's message to the store, as message-filter.test.mjs's put() does. The run's
|
||||
own message.send would also pass through the messaging graph, which T5 doesn't test.
|
||||
- **T6/T8 split.** format.test.mjs holds T6's text cases: mixed, empty roles with threads, and no
|
||||
threads argument. The `--json` pin needs main(), so it sits with T8 in tester.test.mjs, whose
|
||||
setup has both stand-in transports. That test also pins the call order: tester `["agents"]`; human
|
||||
`["agents", "thread.claims"]`; human `--json` adds only `agents`. Its JSON deepEquals the broker's
|
||||
own `agents` result.
|
||||
- **T7.** The message-filter.test.mjs pin now reads `holder_run` from the owner's `thread.claims`.
|
||||
The old pin read `holder` from `threadsState`.
|
||||
|
||||
Manifest (actual):
|
||||
|
||||
- packages/bus/src/broker.mjs
|
||||
- packages/bus/README.md (a "Thread claims" paragraph at the end of "Agent state")
|
||||
- packages/bus/tests/thread-claims.test.mjs (new)
|
||||
- packages/bus/tests/message-filter.test.mjs
|
||||
- packages/cli/src/cli.mjs
|
||||
- packages/cli/src/format.mjs
|
||||
- packages/cli/tests/format.test.mjs
|
||||
- packages/cli/tests/tester.test.mjs
|
||||
- agents/morgana/work/queue-277/BUILD.md
|
||||
|
||||
Mutants: M1 (T1, T7), M2 (T2), M3 (T3), M4 (T1, T4), M5 (T5), M6 (T6), M7 (T8). Gate: the touched test
|
||||
files run whole, plus other-tree.test.mjs. Then the bus and cli suites and every scripts/test-*.sh.
|
||||
Nothing runs before Sage's CLEAR.
|
||||
|
||||
Early look (sage-2504, 05:27Z), on c050bb90b: thread-claims.test.mjs failed 5/5. The test's `call`
|
||||
helper took any string as a session name, but a run's capability is a string too, so the run's calls
|
||||
went out unauthenticated. T1 also compared null-prototype store rows strictly. Both are test bugs, and
|
||||
the fix is r2 of the build. On r2, thread-claims 5/5, cli tester 9/9, other-tree 4/4 pass, 0 skipped.
|
||||
|
||||
## r3: Duckworth notes N1/N2 (duckworth-0119, ruled sage-2505), for the post-194 rebase round
|
||||
|
||||
- **N1.** A human `mosaic agents` no longer fails as a whole when `thread.claims` is refused.
|
||||
cli.mjs prints the role lines, then `thread claims: refused <code>`, and exits 0. The choice of
|
||||
exit 0 follows D1: `agents` stays as it was, and it exited 0 before this row. Only an error with a
|
||||
bus code is caught (the same `^[a-z-]{1,64}$` shape `markRead` checks); any other error still
|
||||
throws. Pinned by a new test in cli tester.test.mjs: a stand-in transport refuses thread.claims
|
||||
with `processing-halted`, the role row prints, then the refusal line. This matches row 278 item 4
|
||||
for the Console. M8 (the catch drops the refusal line, or rethrows) is killed by that test.
|
||||
- **N2.** packages/cli/README.md: the usage line gains `[--tester]`, and a bullet describes the
|
||||
`thread` lines, the refusal line, `--json` and `--tester`. host.test.mjs:855 reads this README
|
||||
for a unit file name, which is still there. host.test.mjs joins the gate's whole-file runs.
|
||||
|
||||
Manifest adds packages/cli/README.md (10 paths). Nothing here has run yet. It runs whole after the
|
||||
rebase onto 194, on Sage's CLEAR.
|
||||
|
||||
## r4: rebase onto 0165be266 and Duckworth's delta notes (sage-2587, duckworth-0123)
|
||||
|
||||
- **Rebase.** Commits 1-6 are cherry-picked onto refactor 0165be266, which has 194, 236 and 274
|
||||
landed, on morgana/row277-0165. The range-diff is `=` for all six and the patch-ids are unchanged.
|
||||
Four files auto-merged around upstream lines: bus README, broker.mjs, cli README and cli.mjs.
|
||||
- **N1 (duckworth-0123).** The N1 catch's rethrow branch had no test, so a mutant that takes every
|
||||
error as a refusal (it would print `refused undefined`) survived. A new cli tester.test.mjs test
|
||||
throws from thread.claims with no code, then with `ECONNRESET`. Each rejects main with the same
|
||||
error and prints nothing. M9 (the code check dropped) is killed by that test.
|
||||
- **N2 (duckworth-0123), accepted.** Since 274, the owner's and a reader's thread.claims can include
|
||||
user threads (id, instance, holder run, harness, time; no name, no messages). That is the design
|
||||
join approved in duckworth-0118, with no owner filter, and owner-visible run governance under
|
||||
sage-1368 B1.
|
||||
- **N3 (duckworth-0123), for later.** When 264a and 277 meet, thread.claims's latest row per thread
|
||||
goes per session, and thread-claims.test.mjs needs the default session. It is checked in whichever
|
||||
round lands second.
|
||||
|
||||
Gate (sage-2590): the touched test files plus user-principal, other-tree and host whole; the bus,
|
||||
cli, harness, business and seat suites; every scripts/test-*.sh; M1-M9. The kit prints HEAD and the
|
||||
manifest check first. Nothing runs before Sage's CLEAR.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Vikunja502: processing test and runner hardening
|
||||
|
||||
Briefed by Rocko before implementation, from sage-2460 and mos-1267/1268.
|
||||
Parent row191 is landed at0fa7bf421. This follow-up changes tests and the
|
||||
owned runner copy; it does not change the live stack or processing policy.
|
||||
Queue next currently offers211; Sage's explicit502 assignment governs this
|
||||
work. No211claim or unrelated row work.
|
||||
|
||||
## Outcome and bounds
|
||||
|
||||
Keep row191's product behavior unchanged while making its failure evidence
|
||||
precise and bounded. Four accepted fixes only:
|
||||
|
||||
1. packages/processing-control/tests/stop-run.test.mjs:106-108: the second
|
||||
host must pair its real sleep with a real advancing clock. Override now
|
||||
with Date.now alongside its existing real sleep. Retain all identity,
|
||||
signal, ownership and receipt assertions. Replaying SR08 must produce a
|
||||
named assertion failure and exit rather than hang until the harness kills it.
|
||||
2. packages/webui/tests/processing.test.mjs:52: replace coarse status>=400
|
||||
with a case table of exact status/error outcomes and no writer call per
|
||||
refusal. Keep valid same-origin requests as the baseline; isolate the
|
||||
unnamed-host case from the unrelated outer-origin refusal. The existing
|
||||
direct-handler origin/site test stays intact. Replay KS43 and KS44 against
|
||||
their appropriate existing tests to prove the right guard is detected.
|
||||
3. packages/bus/tests/tasks.test.mjs:252-265: close the opened runtime in
|
||||
finally around the dependency assertions. Keep each assertion and the
|
||||
subsequent close-count check. Replaying KS54 must yield its named
|
||||
dependency assertion and terminate without a90second hang.
|
||||
4. Own-dir run-mutants.py: generic timeout causes must reflect the actual
|
||||
same-invocation control record, including the no-control case. Start from
|
||||
reviewed fe0d360f. Keep the old row191 runner and all historical outputs
|
||||
immutable; deliver the corrected copy in this packet. Update the cause
|
||||
call to receive its actual control. Add focused pure-function regression
|
||||
checks for absent, failed and passing control wording. No policy change:
|
||||
classification, controls,90second bound and cleanup remain as reviewed.
|
||||
|
||||
The original row191 evidence stays frozen. No live files, bus store, units,
|
||||
provider calls, Docker, browser, credentials or activation action. No push,
|
||||
force-push, branch deletion or attribution lines. Sage sweeps the pending
|
||||
canonical191 BUILD.md/SESSIONS/BUILD-LOG edits; Rocko will not commit those.
|
||||
|
||||
## Build and acceptance evidence
|
||||
|
||||
New isolated worktree /mnt/storage/scratch/rocko-v502, new branch
|
||||
rocko/vikunja502-20261022, baseline0fa7bf421; link node_modules immediately.
|
||||
Use scratch TMPDIR and a dead Docker socket for every permitted run.
|
||||
|
||||
Write the runner wording regression checks before changing the helper.
|
||||
Request Sage's slot before any test or mutant run. Proposed focused gate:
|
||||
whole stop-run.test.mjs, processing.test.mjs and bus tasks.test.mjs singly;
|
||||
runner wording regression file; replay SR08, KS43, KS44 and KS54 one at a
|
||||
time with owned process groups and cleanup. The old SR08/KS54 red evidence
|
||||
is retained, and the new replays must fail by named assertion and exit.
|
||||
Run broader package suites only if Sage grants them. No carry from old tests
|
||||
is claimed after these test changes. Grep readers/pins before final manifest.
|
||||
|
||||
Packet will contain base, patch, changed paths, candidate hashes, exact run
|
||||
logs, mutant detections and the corrected runner hash/diff. Source-only
|
||||
candidate paths are the three test files; runner files are separately hashed
|
||||
packet deliverables. Sage resolves reviewer/gate ownership and landing.
|
||||
|
||||
## Current state
|
||||
|
||||
Initial brief state: no tests or mutant runs yet; subsequent results below record the authorized run.
|
||||
|
||||
|
||||
## Sage scope approval and slot: sage-2471 (2026-10-22 04:55:20Z)
|
||||
|
||||
Scope above approved as written. Slot order is MVP205 gate,268r4,194,236;
|
||||
Sage will fit502into a gap and sends explicit CLEAR. Nothing may run yet.
|
||||
|
||||
Prepared5-site table:SR08(clock),KS43(direct handler origin),KS44(bodybound),
|
||||
KS54(runtime cleanup),V502-status(cross-site403→400). The last site proves
|
||||
the exact status assertion catches a response that the old>=400 accepted.
|
||||
Each old site statically matches exactly once in this worktree. No mutation
|
||||
applied and no syntax/test command executed during this preparation.
|
||||
|
||||
A successful replay must be an assertion kill and exit, including SR08 and
|
||||
KS54. Timeout kills do not satisfy this follow-up's fixture-hardening goal.
|
||||
The new runner's root will point to rocko-v502 when its wording helper is
|
||||
fixed after the first red. This is runner workspace configuration, not a
|
||||
policy change. New packet files/evidence remain separate from landed191.
|
||||
|
||||
|
||||
## Focused verification complete: sage-2475
|
||||
|
||||
Run04:59:34Z–05:00:52Z on0fa7bf421 in rocko-v502. Expected regression
|
||||
red is a named assertion: old generic cause falsely claims a passed control.
|
||||
After the minimal helper/context fix, that regression passes. Whole single
|
||||
files:stop-run12/12,processingHTTP6/6,bustasks9/9;awk27pass/0fail/0skip.
|
||||
All4Python regressions pass (missing,failed,passingcontrol and SR08cause).
|
||||
No unexpected test failures. No ^skip or TAPSKIP lines.
|
||||
|
||||
Five mutants are assertion kills,0timeouts:SR08(5.211s),KS43(.161s),
|
||||
KS44(.156s),KS54(.883s),V502-status(.162s). SR08 now pairs the parent clock
|
||||
with its real sleep, and KS54 closes its runtime even when the deps assertion
|
||||
throws; neither requires timeout adjudication. The wrong-status mutant
|
||||
returns400instead403 and is rejected by the exact fence assertions.
|
||||
|
||||
All5candidate code/test hashes restored. Cwd node/chromium/Python sweep[];
|
||||
slot returned Sage05:01:29Z. No suites,Docker,browser,live actions or provider
|
||||
calls. The expected red/green run is distinguished from verification failures.
|
||||
|
||||
The candidate manifest binds3landed test files plus the corrected owned
|
||||
runner and its Python regression source,5paths in total. This expands the
|
||||
brief's initial source-only3path convention solely to bind the two already
|
||||
in-scope runner deliverables to the same verified candidate. Other packet
|
||||
files and historical baseline are inventory-hashed,not candidate paths.
|
||||
RunnerSHA256:c5bdc764d1cfe11752db2df412c96baa4f366236777df1755bb337e9a943ddea.
|
||||
Runner code changes:truthful control-aware cause, passing actual control to
|
||||
the renderer, and workspace root set to this isolated worktree. Classification,
|
||||
90second limit, group cleanup, restore and control policy unchanged.
|
||||
|
||||
Earlier row191runner/evidence remain untouched. No code commit,push,live
|
||||
step or queue move. Sage owns review assignment and integration of502.
|
||||
|
||||
|
||||
## Rebase and commits: sage-2482
|
||||
|
||||
No candidate path changed from0fa7bf421 toce6a96ad6. On new branch
|
||||
rocko/vikunja502-ce6a-20261022, source candidate was committed then rebased
|
||||
without conflict ontoce6a96ad6. All5blobs remain byte-identical, range-diff
|
||||
pairs equal and stable patch-id matches. Focused runs carry by Sage's ruling;
|
||||
no rerun. rebase-proof.json and rebase-range-diff.txt contain exact hashes.
|
||||
commit.txt names the source candidate commit; the subsequent packet commit
|
||||
adds evidence only,not candidate source changes. No pending canonical logs
|
||||
or row191packet files are included. No push; Sage publishes and requests
|
||||
Dewey review after checking the two-commit range.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Vikunja502 focused results
|
||||
|
||||
|Check|Pass|Fail|Skip|
|
||||
|---|---:|---:|---:|
|
||||
|stop-run whole|12|0|0|
|
||||
|processing HTTP whole|6|0|0|
|
||||
|bus tasks whole|9|0|0|
|
||||
|Python regressions|4|0|0|
|
||||
|
||||
Expected wording regression:1assertion red before fix, then green. No unexpected red.
|
||||
|
||||
|Mutant|Result|Seconds|Named failing test|
|
||||
|---|---|---:|---|
|
||||
|SR08|assertion-killed|5.211|a separate host joins durable first ownership without duplicate signals|
|
||||
|KS43|assertion-killed|0.161|processing handler independently refuses wrong origin and cross-site before its writer|
|
||||
|KS44|assertion-killed|0.156|processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer|
|
||||
|KS54|assertion-killed|0.883|the runtime refuses an invalid adapter and closes a valid one|
|
||||
|V502-status|assertion-killed|0.162|processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer|
|
||||
|
||||
5/5assertionkills,0timeoutkills. No skips. Candidate restored,process sweep[],slotreturned05:01:29Z.
|
||||
|
||||
No package suites,Docker,browser or live action under focused grant.
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "SR08",
|
||||
"file": "packages/processing-control/src/runtimes.mjs",
|
||||
"old": "if (winner.id !== claim.id) {",
|
||||
"new": "if (false) {",
|
||||
"test": "packages/processing-control/tests/stop-run.test.mjs",
|
||||
"pattern": "separate host joins durable first ownership",
|
||||
"check": "cross-process exclusive first owner",
|
||||
"status": "unrun"
|
||||
},
|
||||
{
|
||||
"id": "KS43",
|
||||
"file": "packages/webui/src/processing-http.mjs",
|
||||
"old": "if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');",
|
||||
"new": "// skip origin fence",
|
||||
"test": "packages/webui/tests/processing.test.mjs",
|
||||
"pattern": "processing handler independently refuses wrong origin and cross-site before its writer",
|
||||
"check": "same-origin browser write",
|
||||
"status": "unrun"
|
||||
},
|
||||
{
|
||||
"id": "KS44",
|
||||
"file": "packages/webui/src/processing-http.mjs",
|
||||
"old": "if (size > 1024) throw Error();",
|
||||
"new": "void 0;",
|
||||
"test": "packages/webui/tests/processing.test.mjs",
|
||||
"pattern": "processing write fences refuse",
|
||||
"check": "bounded control payload",
|
||||
"status": "unrun"
|
||||
},
|
||||
{
|
||||
"id": "KS54",
|
||||
"file": "packages/bus/src/runtime.mjs",
|
||||
"old": "adapter = await tasks({ broker, credentials, businesses, processing });",
|
||||
"new": "adapter = await tasks({ broker, credentials, businesses });",
|
||||
"test": "packages/bus/tests/tasks.test.mjs",
|
||||
"pattern": "",
|
||||
"check": "production tracker injection",
|
||||
"status": "unrun"
|
||||
},
|
||||
{
|
||||
"id": "V502-status",
|
||||
"file": "packages/webui/src/processing-http.mjs",
|
||||
"old": "return refuse(403, 'cross-site')",
|
||||
"new": "return refuse(400, 'cross-site')",
|
||||
"test": "packages/webui/tests/processing.test.mjs",
|
||||
"pattern": "processing write fences refuse",
|
||||
"check": "exact refusal status rejects a wrong 400 that coarse >=400 accepted",
|
||||
"status": "unrun"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"old_base": "0fa7bf421",
|
||||
"new_base": "ce6a96ad6",
|
||||
"candidate_paths": [
|
||||
"agents/rocko/work/vikunja-502/run-mutants.py",
|
||||
"agents/rocko/work/vikunja-502/tests/test_timeout_cause.py",
|
||||
"packages/bus/tests/tasks.test.mjs",
|
||||
"packages/processing-control/tests/stop-run.test.mjs",
|
||||
"packages/webui/tests/processing.test.mjs"
|
||||
],
|
||||
"changed": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ce6a96ad6153a0a7a689e6d9d0f625b55eb86b51
|
||||
@@ -0,0 +1,312 @@
|
||||
diff --git a/agents/rocko/work/vikunja-502/run-mutants.py b/agents/rocko/work/vikunja-502/run-mutants.py
|
||||
new file mode 100644
|
||||
index 000000000..8dce3064d
|
||||
--- /dev/null
|
||||
+++ b/agents/rocko/work/vikunja-502/run-mutants.py
|
||||
@@ -0,0 +1,182 @@
|
||||
+#!/usr/bin/env python3
|
||||
+"""Run only with Sage's explicit slot grant. Mutations are exact-once and restored."""
|
||||
+from pathlib import Path
|
||||
+import argparse, hashlib, json, os, re, signal, subprocess, sys, time
|
||||
+packet = Path(__file__).resolve().parent
|
||||
+root = Path('/mnt/storage/scratch/rocko-v502')
|
||||
+all_sites = json.loads((packet/'all-mutants.json').read_text())
|
||||
+parser = argparse.ArgumentParser()
|
||||
+parser.add_argument('ids', nargs='*')
|
||||
+parser.add_argument('--resume-from')
|
||||
+parser.add_argument('--control-file')
|
||||
+parser.add_argument('--adjudicate-timeout')
|
||||
+args = parser.parse_args()
|
||||
+sites = all_sites
|
||||
+if args.resume_from:
|
||||
+ ids = [s['id'] for s in all_sites]
|
||||
+ if args.ids or args.resume_from not in ids: raise SystemExit('invalid resume selection')
|
||||
+ sites = all_sites[ids.index(args.resume_from):]
|
||||
+elif args.ids:
|
||||
+ wanted = set(args.ids); sites = [s for s in all_sites if s['id'] in wanted]
|
||||
+ if {s['id'] for s in sites} != wanted: raise SystemExit('unknown mutant id')
|
||||
+if args.adjudicate_timeout and not args.control_file:
|
||||
+ raise SystemExit('timeout adjudication requires an unmutated whole-file control')
|
||||
+originals = {e['file']: (root/e['file']).read_bytes() for s in sites for e in [s, *s.get('extra_edits', [])]}
|
||||
+for s in sites:
|
||||
+ for e in [s, *s.get('extra_edits', [])]:
|
||||
+ if originals[e['file']].decode().count(e['old']) != 1: raise SystemExit('preflight '+s['id'])
|
||||
+logs = packet/('resume-mutant-logs' if args.resume_from else 'mutant-logs'); logs.mkdir(exist_ok=True)
|
||||
+result_file = packet/('resume-mutant-results.json' if args.resume_from else 'mutant-results.json')
|
||||
+results = []
|
||||
+controls = {}
|
||||
+run_id = str(time.time_ns())
|
||||
+
|
||||
+def verify_candidate():
|
||||
+ expected = (packet/'commit.txt').read_text().strip()
|
||||
+ if subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip()!=expected:
|
||||
+ raise RuntimeError('candidate HEAD changed')
|
||||
+ manifest = packet/'candidate-manifest.sha256'
|
||||
+ digest = hashlib.sha256(manifest.read_bytes()).hexdigest()
|
||||
+ if digest!=(packet/'candidate.txt').read_text().strip(): raise RuntimeError('manifest digest changed')
|
||||
+ for line in manifest.read_text().splitlines():
|
||||
+ wanted,name = line.split(' ',1)
|
||||
+ if hashlib.sha256((root/name).read_bytes()).hexdigest()!=wanted: raise RuntimeError('candidate drift '+name)
|
||||
+ return {'head':expected,'manifest':digest}
|
||||
+
|
||||
+def stat(pid):
|
||||
+ try:
|
||||
+ raw = Path('/proc',str(pid),'stat').read_text(); fields = raw[raw.rfind(')')+2:].split()
|
||||
+ return dict(ppid=int(fields[1]), group=int(fields[2]), start=fields[19], state=fields[0])
|
||||
+ except (FileNotFoundError, PermissionError, ProcessLookupError): return None
|
||||
+def capture(child, known):
|
||||
+ current = {}
|
||||
+ for p in Path('/proc').iterdir():
|
||||
+ if p.name.isdecimal():
|
||||
+ value=stat(int(p.name))
|
||||
+ if value: current[int(p.name)]=value
|
||||
+ changed=True
|
||||
+ while changed:
|
||||
+ changed=False
|
||||
+ for pid,value in current.items():
|
||||
+ if pid not in known and (pid==child or (value['ppid'] in known and current.get(value['ppid'],{}).get('start')==known[value['ppid']]['start'])): known[pid]=value;changed=True
|
||||
+
|
||||
+def cleanup(known):
|
||||
+ groups={}
|
||||
+ for pid,old in known.items():
|
||||
+ cur=stat(pid)
|
||||
+ if cur and cur['start']==old['start'] and cur['state']!='Z': groups.setdefault(cur['group'],[]).append(pid)
|
||||
+ for group,anchors in groups.items():
|
||||
+ # Every member here descends from this test runner, identified by start ticks.
|
||||
+ if not any((cur:=stat(pid)) and cur['start']==known[pid]['start'] and cur['group']==group for pid in anchors):continue
|
||||
+ try: os.killpg(group, signal.SIGKILL)
|
||||
+ except ProcessLookupError: pass
|
||||
+def execute(test, pattern, log):
|
||||
+ child = None; known = {}; timedout = False; started = time.monotonic()
|
||||
+ command = ['node','--test','--test-reporter=tap']
|
||||
+ if pattern: command += ['--test-name-pattern='+re.escape(pattern)]
|
||||
+ command += [test]
|
||||
+ try:
|
||||
+ with log.open('w') as out:
|
||||
+ child = subprocess.Popen(command,cwd=root,stdout=out,stderr=subprocess.STDOUT,start_new_session=True,
|
||||
+ env={**os.environ,'TMPDIR':os.environ['TMPDIR'],'DOCKER_HOST':'unix:///nonexistent.sock',
|
||||
+ 'MOSAIC_CONFIG':str(Path(os.environ['TMPDIR'])/'no-live-config.json')})
|
||||
+ first = stat(child.pid)
|
||||
+ if first: known[child.pid] = first
|
||||
+ deadline = started+90
|
||||
+ while child.poll() is None:
|
||||
+ capture(child.pid,known)
|
||||
+ if time.monotonic()>=deadline:
|
||||
+ timedout = True; cleanup(known)
|
||||
+ try: os.killpg(child.pid,signal.SIGKILL)
|
||||
+ except ProcessLookupError: pass
|
||||
+ break
|
||||
+ time.sleep(.05)
|
||||
+ rc = child.wait()
|
||||
+ finally:
|
||||
+ if child and child.poll() is None:
|
||||
+ cleanup(known)
|
||||
+ try: os.killpg(child.pid,signal.SIGKILL)
|
||||
+ except ProcessLookupError: pass
|
||||
+ child.wait()
|
||||
+ cleanup(known)
|
||||
+ text = log.read_text()
|
||||
+ return {'exit':rc,'timeout':timedout,'seconds':time.monotonic()-started,'log':str(log),
|
||||
+ 'failing':re.findall(r'^\s*not ok \d+ - (.*)$',text,re.M),
|
||||
+ 'counts':{k:int(v) for k,v in re.findall(r'^# (tests|pass|fail|skipped|cancelled) (\d+)\s*$',text,re.M)}}
|
||||
+
|
||||
+def timeout_cause(site, control=None):
|
||||
+ if site['id']=='SR08':
|
||||
+ return ('runtimes.mjs:333 loops on now() <= deadline; stop-run.test.mjs:106-108 '
|
||||
+ 'gives the second host a fixed fake clock with a real sleep. Removing the join '
|
||||
+ 'branch enters the delivery loop without advancing that clock. Original log: TAP header only.')
|
||||
+ if not control or control.get('passed') is not True:
|
||||
+ return ('Mutated '+site['file']+' timed out after 90s; no passing same-file control '
|
||||
+ 'exists in this invocation. Detection is not established. '
|
||||
+ 'Detailed cause must be recorded before review.')
|
||||
+ return ('Mutated '+site['file']+' timed out after 90s while the same whole test file '
|
||||
+ 'passed unmutated in this invocation. Detailed cause must be recorded before review.')
|
||||
+
|
||||
+try:
|
||||
+ binding = verify_candidate()
|
||||
+ if args.control_file:
|
||||
+ if args.control_file not in {s['test'] for s in all_sites}: raise SystemExit('unknown control file')
|
||||
+ before = hashlib.sha256((root/args.control_file).read_bytes()).hexdigest()
|
||||
+ control = execute(args.control_file,'',logs/'unmutated-control.tap')
|
||||
+ after = hashlib.sha256((root/args.control_file).read_bytes()).hexdigest()
|
||||
+ counts = control['counts']
|
||||
+ passed = (control['exit']==0 and not control['timeout'] and not control['failing'] and before==after
|
||||
+ and counts.get('tests',0)>0 and counts.get('pass')==counts.get('tests')
|
||||
+ and counts.get('fail')==0 and counts.get('cancelled')==0 and counts.get('skipped')==0)
|
||||
+ control.update(run_id=run_id,test=args.control_file,test_sha256=before,passed=passed,**binding)
|
||||
+ (packet/'unmutated-control.json').write_text(json.dumps(control,indent=2)+'\n')
|
||||
+ print('CONTROL',json.dumps(control),flush=True)
|
||||
+ if not passed: raise SystemExit('control failed; stop for Mos, no mutation applied')
|
||||
+ verify_candidate()
|
||||
+ controls[args.control_file] = control
|
||||
+ if args.adjudicate_timeout:
|
||||
+ # This is the already-observed SR08 timeout, not a rerun or an assertion kill.
|
||||
+ if args.adjudicate_timeout!='SR08': raise SystemExit('only SR08 historical adjudication approved')
|
||||
+ prior = json.loads((packet/'mutant-results.json').read_text())
|
||||
+ old = next((s for s in prior if s['id']=='SR08'),None)
|
||||
+ site = next(s for s in all_sites if s['id']=='SR08')
|
||||
+ if not old or old['status']!='timeout' or old['test']!=args.control_file or old['failing']:
|
||||
+ raise SystemExit('historical timeout does not match approved control')
|
||||
+ if Path(old['log']).read_text().strip()!='TAP version 13': raise SystemExit('historical timeout log changed')
|
||||
+ adjudication = {**old,'status':'killed-by-timeout','cause':timeout_cause(site, controls[args.control_file]),'control':controls[args.control_file],
|
||||
+ 'original_log_sha256':hashlib.sha256(Path(old['log']).read_bytes()).hexdigest()}
|
||||
+ (packet/'SR08-timeout-adjudication.json').write_text(json.dumps(adjudication,indent=2)+'\n')
|
||||
+ print('TIMEOUT_KILL SR08',adjudication['cause'],flush=True)
|
||||
+ for i,s in enumerate(sites,1):
|
||||
+ verify_candidate()
|
||||
+ edits = [s, *s.get('extra_edits', [])]
|
||||
+ for e in edits:
|
||||
+ if (root/e['file']).read_bytes()!=originals[e['file']]: raise RuntimeError('source drift '+s['id'])
|
||||
+ print(f'{i}/{len(sites)} {s["id"]}: {s["check"]}',flush=True)
|
||||
+ try:
|
||||
+ for e in edits:
|
||||
+ (root/e['file']).write_bytes(originals[e['file']].decode().replace(e['old'],e['new'],1).encode())
|
||||
+ observed = execute(s['test'],s['pattern'],logs/(s['id']+'.tap'))
|
||||
+ finally:
|
||||
+ for e in edits: (root/e['file']).write_bytes(originals[e['file']])
|
||||
+ expected = not s['pattern'] or any(s['pattern'] in name for name in observed['failing'])
|
||||
+ control = controls.get(s['test'])
|
||||
+ if observed['timeout']:
|
||||
+ status = 'killed-by-timeout' if control and control['passed'] and control['run_id']==run_id else 'timeout-without-control'
|
||||
+ elif observed['exit']==0: status = 'survived'
|
||||
+ elif observed['failing'] and expected: status = 'assertion-killed'
|
||||
+ else: status = 'invalid-failure'
|
||||
+ row = {**{k:s[k] for k in ['id','file','test','pattern','check']},**observed,'status':status}
|
||||
+ if observed['timeout']: row.update(cause=timeout_cause(s, control),control=control)
|
||||
+ results.append(row)
|
||||
+ result_file.write_text(json.dumps(results,indent=2)+'\n')
|
||||
+ print(status,flush=True)
|
||||
+ if status not in ('assertion-killed','killed-by-timeout'): raise SystemExit(1)
|
||||
+finally:
|
||||
+ for name,data in originals.items(): (root/name).write_bytes(data)
|
||||
+ if any((root/name).read_bytes()!=data for name,data in originals.items()): raise RuntimeError('restore failed')
|
||||
+summary = {'assertion_kills':sum(s['status']=='assertion-killed' for s in results),
|
||||
+ 'timeout_kills':sum(s['status']=='killed-by-timeout' for s in results),
|
||||
+ 'historical_timeout_kills':int(bool(args.adjudicate_timeout)),'selected':len(sites),
|
||||
+ 'run_id':run_id,'candidate':binding}
|
||||
+(packet/'mutant-resume-summary.json').write_text(json.dumps(summary,indent=2)+'\n')
|
||||
+print('MUTANT_SUMMARY',json.dumps(summary),flush=True)
|
||||
diff --git a/agents/rocko/work/vikunja-502/tests/test_timeout_cause.py b/agents/rocko/work/vikunja-502/tests/test_timeout_cause.py
|
||||
new file mode 100644
|
||||
index 000000000..444ec2c5e
|
||||
--- /dev/null
|
||||
+++ b/agents/rocko/work/vikunja-502/tests/test_timeout_cause.py
|
||||
@@ -0,0 +1,44 @@
|
||||
+"""Exercise the actual pure cause renderer without executing the mutant driver."""
|
||||
+import ast
|
||||
+from pathlib import Path
|
||||
+import unittest
|
||||
+
|
||||
+RUNNER = Path(__file__).resolve().parents[1] / 'run-mutants.py'
|
||||
+
|
||||
+
|
||||
+def cause_renderer():
|
||||
+ tree = ast.parse(RUNNER.read_text())
|
||||
+ function = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'timeout_cause')
|
||||
+ namespace = {}
|
||||
+ exec(compile(ast.Module(body=[function], type_ignores=[]), str(RUNNER), 'exec'), namespace)
|
||||
+ return namespace['timeout_cause']
|
||||
+
|
||||
+
|
||||
+class TimeoutCause(unittest.TestCase):
|
||||
+ def setUp(self):
|
||||
+ self.cause = cause_renderer()
|
||||
+ self.site = {'id': 'KS54', 'file': 'packages/bus/src/runtime.mjs'}
|
||||
+
|
||||
+ def test_missing_control_never_claims_an_unmutated_pass(self):
|
||||
+ text = self.cause(self.site)
|
||||
+ self.assertIn('no passing same-file control', text)
|
||||
+ self.assertNotIn('passed unmutated', text)
|
||||
+
|
||||
+ def test_failed_control_never_claims_an_unmutated_pass(self):
|
||||
+ text = self.cause(self.site, {'passed': False})
|
||||
+ self.assertIn('no passing same-file control', text)
|
||||
+ self.assertNotIn('passed unmutated', text)
|
||||
+
|
||||
+ def test_passing_control_can_state_its_observed_pass(self):
|
||||
+ text = self.cause(self.site, {'passed': True})
|
||||
+ self.assertIn('passed unmutated', text)
|
||||
+ self.assertNotIn('no passing same-file control', text)
|
||||
+
|
||||
+ def test_known_sr08_cause_remains_specific_without_claiming_a_control(self):
|
||||
+ text = self.cause({'id': 'SR08', 'file': 'packages/processing-control/src/runtimes.mjs'})
|
||||
+ self.assertIn('fixed fake clock', text)
|
||||
+ self.assertNotIn('passed unmutated', text)
|
||||
+
|
||||
+
|
||||
+if __name__ == '__main__':
|
||||
+ unittest.main()
|
||||
diff --git a/packages/bus/tests/tasks.test.mjs b/packages/bus/tests/tasks.test.mjs
|
||||
index 4c8aae9d6..081925299 100644
|
||||
--- a/packages/bus/tests/tasks.test.mjs
|
||||
+++ b/packages/bus/tests/tasks.test.mjs
|
||||
@@ -259,10 +259,13 @@ test('the runtime refuses an invalid adapter and closes a valid one', async (t)
|
||||
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');
|
||||
- await runtime.close();
|
||||
+ try {
|
||||
+ // Row 191: runtime injects cancellation alongside the existing factory dependencies.
|
||||
+ assert.deepEqual(given, ['broker', 'businesses', 'credentials', 'processing']);
|
||||
+ assert.equal(typeof processing.work, 'function');
|
||||
+ } finally {
|
||||
+ await runtime.close();
|
||||
+ }
|
||||
assert.equal(closed, 5);
|
||||
await assert.rejects(
|
||||
startBroker({
|
||||
diff --git a/packages/processing-control/tests/stop-run.test.mjs b/packages/processing-control/tests/stop-run.test.mjs
|
||||
index ed69bc111..f54022251 100644
|
||||
--- a/packages/processing-control/tests/stop-run.test.mjs
|
||||
+++ b/packages/processing-control/tests/stop-run.test.mjs
|
||||
@@ -105,7 +105,7 @@ test('a separate host joins durable first ownership without duplicate signals',
|
||||
const messages = []; child.on('message', value => messages.push(value));
|
||||
await once(child, 'message');
|
||||
const joined = stopRun({ ...f.args, owner: { decision: 'second-host' } },
|
||||
- { ...f.options, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)) });
|
||||
+ { ...f.options, now: Date.now, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)) });
|
||||
setTimeout(() => child.send('continue'), 30);
|
||||
const result = await joined;
|
||||
const exit = child.exitCode === null ? await once(child, 'exit') : [child.exitCode];
|
||||
diff --git a/packages/webui/tests/processing.test.mjs b/packages/webui/tests/processing.test.mjs
|
||||
index e9874602c..14956867b 100644
|
||||
--- a/packages/webui/tests/processing.test.mjs
|
||||
+++ b/packages/webui/tests/processing.test.mjs
|
||||
@@ -52,15 +52,26 @@ test('Console halt and generation-bound resume use only the management writer an
|
||||
test('processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer', async t => {
|
||||
const f = await fixture(t);
|
||||
const cases = [
|
||||
- { headers: { ...POST.headers, origin: 'https://elsewhere' } },
|
||||
- { headers: { ...POST.headers, 'sec-fetch-site': 'cross-site' } },
|
||||
- { headers: { ...POST.headers, host: `127.0.0.1:${f.server.address().port}`, 'x-user': undefined } },
|
||||
- { headers: { ...POST.headers, 'x-user': ['jason', 'jason'] } },
|
||||
- { headers: { ...POST.headers, 'content-type': 'text/plain' } },
|
||||
- { body: '{"human":"jason"}' }, { body: '[]' }, { body: 'null' }, { body: ' '.repeat(1025) + '{}' },
|
||||
+ ['wrong origin', { headers: { ...POST.headers, origin: 'https://elsewhere' } }, 403, 'cross-origin request refused'],
|
||||
+ ['cross-site', { headers: { ...POST.headers, 'sec-fetch-site': 'cross-site' } }, 403, 'cross-site'],
|
||||
+ ['unnamed host', { headers: { ...POST.headers, host: `127.0.0.1:${f.server.address().port}`,
|
||||
+ origin: `http://127.0.0.1:${f.server.address().port}`, 'x-user': undefined } }, 403, 'read-only'],
|
||||
+ ['duplicate identity', { headers: { ...POST.headers, 'x-user': ['jason', 'jason'] } }, 401, 'sign-in required'],
|
||||
+ ['content type', { headers: { ...POST.headers, 'content-type': 'text/plain' } }, 415, 'invalid-request'],
|
||||
+ ['extra key', { body: '{"human":"jason"}' }, 400, 'invalid-request'],
|
||||
+ ['array', { body: '[]' }, 400, 'invalid-request'],
|
||||
+ ['null', { body: 'null' }, 400, 'invalid-request'],
|
||||
+ ['body bound', { body: ' '.repeat(1025) + '{}' }, 400, 'invalid-request'],
|
||||
];
|
||||
- for (const c of cases) assert.ok((await http(f.server, '/api/processing/halt', { ...POST, ...c })).status >= 400);
|
||||
- assert.equal((await http(f.server, '/api/processing/resume', POST)).status, 400);
|
||||
+ for (const [name, changed, status, error] of cases) {
|
||||
+ const response = await http(f.server, '/api/processing/halt', { ...POST, ...changed });
|
||||
+ assert.equal(response.status, status, name);
|
||||
+ assert.equal(response.value.error, error, name);
|
||||
+ assert.deepEqual(f.calls, [], name + ' must refuse before the writer');
|
||||
+ }
|
||||
+ const missingGeneration = await http(f.server, '/api/processing/resume', POST);
|
||||
+ assert.equal(missingGeneration.status, 400);
|
||||
+ assert.equal(missingGeneration.value.error, 'invalid-request');
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
test('tester cannot invoke either control writer and failures never echo backend text', async t => {
|
||||
@@ -0,0 +1,5 @@
|
||||
c5bdc764d1cfe11752db2df412c96baa4f366236777df1755bb337e9a943ddea agents/rocko/work/vikunja-502/run-mutants.py
|
||||
475ab651a60bc4721ba8bb193b3f05ff67d25afc068e80382174b98828ad085d agents/rocko/work/vikunja-502/tests/test_timeout_cause.py
|
||||
a17ef09d68a13181fb0d27fde9006bd14ecf340270e07d7c79f6b37025f773c8 packages/bus/tests/tasks.test.mjs
|
||||
bbf88324b1adc9acbb91a49f08d3ae5edb1d65a233933bdb86bc96701b0a35d3 packages/processing-control/tests/stop-run.test.mjs
|
||||
28caf153da5d16949c5a3e1cedf8c91613e7230840d9df65d5dc4bdc95e72bf5 packages/webui/tests/processing.test.mjs
|
||||
@@ -0,0 +1 @@
|
||||
968d6ee345766da838df68f491b5a77c9a25157034dc49e9d4a9b603e4ce7812
|
||||
@@ -0,0 +1,5 @@
|
||||
agents/rocko/work/vikunja-502/run-mutants.py
|
||||
agents/rocko/work/vikunja-502/tests/test_timeout_cause.py
|
||||
packages/bus/tests/tasks.test.mjs
|
||||
packages/processing-control/tests/stop-run.test.mjs
|
||||
packages/webui/tests/processing.test.mjs
|
||||
@@ -0,0 +1 @@
|
||||
8e3c6a79bfa39e739bf728f949f51e9842ca0df9
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,87 @@
|
||||
[
|
||||
{
|
||||
"step": "runner-red",
|
||||
"command": [
|
||||
"python3",
|
||||
"agents/rocko/work/vikunja-502/tests/test_timeout_cause.py",
|
||||
"TimeoutCause.test_missing_control_never_claims_an_unmutated_pass"
|
||||
],
|
||||
"start": "2026-10-22T04:59:34Z",
|
||||
"end": "2026-10-22T04:59:34Z",
|
||||
"exit": 1,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/logs/runner-red.log"
|
||||
},
|
||||
{
|
||||
"step": "runner-green",
|
||||
"command": [
|
||||
"python3",
|
||||
"agents/rocko/work/vikunja-502/tests/test_timeout_cause.py",
|
||||
"TimeoutCause.test_missing_control_never_claims_an_unmutated_pass"
|
||||
],
|
||||
"start": "2026-10-22T05:00:01Z",
|
||||
"end": "2026-10-22T05:00:01Z",
|
||||
"exit": 0,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/logs/runner-green.log"
|
||||
},
|
||||
{
|
||||
"step": "stop-run-whole",
|
||||
"command": [
|
||||
"node",
|
||||
"--test",
|
||||
"--test-reporter=tap",
|
||||
"packages/processing-control/tests/stop-run.test.mjs"
|
||||
],
|
||||
"start": "2026-10-22T05:00:01Z",
|
||||
"end": "2026-10-22T05:00:01Z",
|
||||
"exit": 0,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/logs/stop-run-whole.log"
|
||||
},
|
||||
{
|
||||
"step": "processing-http-whole",
|
||||
"command": [
|
||||
"node",
|
||||
"--test",
|
||||
"--test-reporter=tap",
|
||||
"packages/webui/tests/processing.test.mjs"
|
||||
],
|
||||
"start": "2026-10-22T05:00:02Z",
|
||||
"end": "2026-10-22T05:00:02Z",
|
||||
"exit": 0,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/logs/processing-http-whole.log"
|
||||
},
|
||||
{
|
||||
"step": "bus-tasks-whole",
|
||||
"command": [
|
||||
"node",
|
||||
"--test",
|
||||
"--test-reporter=tap",
|
||||
"packages/bus/tests/tasks.test.mjs"
|
||||
],
|
||||
"start": "2026-10-22T05:00:02Z",
|
||||
"end": "2026-10-22T05:00:03Z",
|
||||
"exit": 0,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/logs/bus-tasks-whole.log"
|
||||
},
|
||||
{
|
||||
"step": "python-regressions",
|
||||
"command": [
|
||||
"python3",
|
||||
"agents/rocko/work/vikunja-502/tests/test_timeout_cause.py"
|
||||
],
|
||||
"start": "2026-10-22T05:00:03Z",
|
||||
"end": "2026-10-22T05:00:03Z",
|
||||
"exit": 0,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/logs/python-regressions.log"
|
||||
},
|
||||
{
|
||||
"step": "replays",
|
||||
"command": [
|
||||
"python3",
|
||||
"agents/rocko/work/vikunja-502/run-mutants.py"
|
||||
],
|
||||
"start": "2026-10-22T05:00:45Z",
|
||||
"end": "2026-10-22T05:00:52Z",
|
||||
"exit": 0,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/logs/replays.log"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"status": "focused scope completed under sage-2475; no further run authorized",
|
||||
"scope": "sage-2471",
|
||||
"worktree": "/mnt/storage/scratch/rocko-v502",
|
||||
"base": "0fa7bf421fc153a226b5f06ed484e089e48e23ba",
|
||||
"red_command": [
|
||||
"python3",
|
||||
"/mnt/storage/src/mosaic-stack/agents/rocko/work/vikunja-502/tests/test_timeout_cause.py",
|
||||
"TimeoutCause.test_missing_control_never_claims_an_unmutated_pass"
|
||||
],
|
||||
"green_command": [
|
||||
"python3",
|
||||
"/mnt/storage/src/mosaic-stack/agents/rocko/work/vikunja-502/tests/test_timeout_cause.py"
|
||||
],
|
||||
"single_files": [
|
||||
"packages/processing-control/tests/stop-run.test.mjs",
|
||||
"packages/webui/tests/processing.test.mjs",
|
||||
"packages/bus/tests/tasks.test.mjs"
|
||||
],
|
||||
"replay_ids": [
|
||||
"SR08",
|
||||
"KS43",
|
||||
"KS44",
|
||||
"KS54",
|
||||
"V502-status"
|
||||
],
|
||||
"replay_required_status": "assertion-killed for every site; no timeout kills accepted as fixture-hardening evidence",
|
||||
"process_rules": "short scratch TMPDIR,deadDocker,one test file at a time,owned process groups,timeouts,restore and cwd sweep"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
TAP version 13
|
||||
# Subtest: recordTask keeps sync reads and a role write apart
|
||||
ok 1 - recordTask keeps sync reads and a role write apart
|
||||
---
|
||||
duration_ms: 22.077964
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: read_at must be one canonical UTC format, so the projection compares strings safely
|
||||
ok 2 - read_at must be one canonical UTC format, so the projection compares strings safely
|
||||
---
|
||||
duration_ms: 9.65343
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a bad entry refuses the whole record
|
||||
ok 3 - a bad entry refuses the whole record
|
||||
---
|
||||
duration_ms: 10.008621
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: taskView reads the projection for one business
|
||||
ok 4 - taskView reads the projection for one business
|
||||
---
|
||||
duration_ms: 11.605317
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: requestTask hands only a holder and a task verb to the handler, and records refusals
|
||||
ok 5 - requestTask hands only a holder and a task verb to the handler, and records refusals
|
||||
---
|
||||
duration_ms: 21.879126
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous
|
||||
ok 6 - the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous
|
||||
---
|
||||
duration_ms: 294.372769
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: without an adapter the server refuses every task verb
|
||||
ok 7 - without an adapter the server refuses every task verb
|
||||
---
|
||||
duration_ms: 39.665659
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the runtime refuses an invalid adapter and closes a valid one
|
||||
ok 8 - the runtime refuses an invalid adapter and closes a valid one
|
||||
---
|
||||
duration_ms: 51.674609
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the process loads the S3 adapter from plain-data trackers
|
||||
ok 9 - the process loads the S3 adapter from plain-data trackers
|
||||
---
|
||||
duration_ms: 248.929804
|
||||
type: 'test'
|
||||
...
|
||||
1..9
|
||||
# tests 9
|
||||
# suites 0
|
||||
# pass 9
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 819.657876
|
||||
@@ -0,0 +1,46 @@
|
||||
TAP version 13
|
||||
# Subtest: read-only Console can read processing state but neither human control writes without --send
|
||||
ok 1 - read-only Console can read processing state but neither human control writes without --send
|
||||
---
|
||||
duration_ms: 17.100325
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Console halt and generation-bound resume use only the management writer and keep partial stops honest
|
||||
ok 2 - Console halt and generation-bound resume use only the management writer and keep partial stops honest
|
||||
---
|
||||
duration_ms: 3.741854
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
ok 3 - processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
---
|
||||
duration_ms: 5.750951
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: tester cannot invoke either control writer and failures never echo backend text
|
||||
ok 4 - tester cannot invoke either control writer and failures never echo backend text
|
||||
---
|
||||
duration_ms: 5.062465
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: processing writes use the existing SSO signer and still require --send and same-origin
|
||||
ok 5 - processing writes use the existing SSO signer and still require --send and same-origin
|
||||
---
|
||||
duration_ms: 4.114043
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: processing handler independently refuses wrong origin and cross-site before its writer
|
||||
ok 6 - processing handler independently refuses wrong origin and cross-site before its writer
|
||||
---
|
||||
duration_ms: 0.618941
|
||||
type: 'test'
|
||||
...
|
||||
1..6
|
||||
# tests 6
|
||||
# suites 0
|
||||
# pass 6
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 134.607131
|
||||
@@ -0,0 +1,5 @@
|
||||
....
|
||||
----------------------------------------------------------------------
|
||||
Ran 4 tests in 0.011s
|
||||
|
||||
OK
|
||||
@@ -0,0 +1,11 @@
|
||||
1/5 SR08: cross-process exclusive first owner
|
||||
assertion-killed
|
||||
2/5 KS43: same-origin browser write
|
||||
assertion-killed
|
||||
3/5 KS44: bounded control payload
|
||||
assertion-killed
|
||||
4/5 KS54: production tracker injection
|
||||
assertion-killed
|
||||
5/5 V502-status: exact refusal status rejects a wrong 400 that coarse >=400 accepted
|
||||
assertion-killed
|
||||
MUTANT_SUMMARY {"assertion_kills": 5, "timeout_kills": 0, "historical_timeout_kills": 0, "selected": 5, "run_id": "1792645245774744257", "candidate": {"head": "0fa7bf421fc153a226b5f06ed484e089e48e23ba", "manifest": "968d6ee345766da838df68f491b5a77c9a25157034dc49e9d4a9b603e4ce7812"}}
|
||||
@@ -0,0 +1,5 @@
|
||||
.
|
||||
----------------------------------------------------------------------
|
||||
Ran 1 test in 0.004s
|
||||
|
||||
OK
|
||||
@@ -0,0 +1,13 @@
|
||||
F
|
||||
======================================================================
|
||||
FAIL: test_missing_control_never_claims_an_unmutated_pass (__main__.TimeoutCause.test_missing_control_never_claims_an_unmutated_pass)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/tests/test_timeout_cause.py", line 24, in test_missing_control_never_claims_an_unmutated_pass
|
||||
self.assertIn('no passing same-file control', text)
|
||||
AssertionError: 'no passing same-file control' not found in 'Mutated packages/bus/src/runtime.mjs timed out after 90s while the same whole test file passed unmutated in this invocation. Detailed cause must be recorded before review.'
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 1 test in 0.003s
|
||||
|
||||
FAILED (failures=1)
|
||||
@@ -0,0 +1,82 @@
|
||||
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: 41.095674
|
||||
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: 32.241062
|
||||
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.366003
|
||||
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: 29.310841
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: missing run and another business never borrow a pid or signal
|
||||
ok 5 - missing run and another business never borrow a pid or signal
|
||||
---
|
||||
duration_ms: 8.032376
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: halt joins an in-progress fire without another TERM or deadline and links its first receipt
|
||||
ok 6 - halt joins an in-progress fire without another TERM or deadline and links its first receipt
|
||||
---
|
||||
duration_ms: 26.551382
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a separate host joins durable first ownership without duplicate signals
|
||||
ok 7 - a separate host joins durable first ownership without duplicate signals
|
||||
---
|
||||
duration_ms: 99.31242
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: corrupt and symlinked final receipts refuse as unknown rather than signalling again
|
||||
ok 8 - corrupt and symlinked final receipts refuse as unknown rather than signalling again
|
||||
---
|
||||
duration_ms: 24.064935
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a later exit gets separate halt evidence while the original alive result stays immutable
|
||||
ok 9 - a later exit gets separate halt evidence while the original alive result stays immutable
|
||||
---
|
||||
duration_ms: 58.520012
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: root aliases join but a different root never shares the run stop
|
||||
ok 10 - root aliases join but a different root never shares the run stop
|
||||
---
|
||||
duration_ms: 44.780832
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a changed runtime record cannot replace the original stop identity after TERM
|
||||
ok 11 - a changed runtime record cannot replace the original stop identity after TERM
|
||||
---
|
||||
duration_ms: 7.48526
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: identity is rechecked at actual signal delivery, not just at the loop observation
|
||||
ok 12 - identity is rechecked at actual signal delivery, not just at the loop observation
|
||||
---
|
||||
duration_ms: 6.177784
|
||||
type: 'test'
|
||||
...
|
||||
1..12
|
||||
# tests 12
|
||||
# suites 0
|
||||
# pass 12
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 446.315604
|
||||
@@ -0,0 +1,33 @@
|
||||
TAP version 13
|
||||
# Subtest: processing handler independently refuses wrong origin and cross-site before its writer
|
||||
not ok 1 - processing handler independently refuses wrong origin and cross-site before its writer
|
||||
---
|
||||
duration_ms: 4.161227
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-v502/packages/webui/tests/processing.test.mjs:106:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly equal:
|
||||
|
||||
503 !== 403
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 403
|
||||
actual: 503
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-v502/packages/webui/tests/processing.test.mjs:118:12)
|
||||
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
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 113.430641
|
||||
@@ -0,0 +1,33 @@
|
||||
TAP version 13
|
||||
# Subtest: processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
not ok 1 - processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
---
|
||||
duration_ms: 22.664684
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-v502/packages/webui/tests/processing.test.mjs:52:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
body bound
|
||||
|
||||
200 !== 400
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 400
|
||||
actual: 200
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-v502/packages/webui/tests/processing.test.mjs:68:12)
|
||||
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
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 126.507861
|
||||
@@ -0,0 +1,94 @@
|
||||
TAP version 13
|
||||
# Subtest: recordTask keeps sync reads and a role write apart
|
||||
ok 1 - recordTask keeps sync reads and a role write apart
|
||||
---
|
||||
duration_ms: 23.452757
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: read_at must be one canonical UTC format, so the projection compares strings safely
|
||||
ok 2 - read_at must be one canonical UTC format, so the projection compares strings safely
|
||||
---
|
||||
duration_ms: 9.929636
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a bad entry refuses the whole record
|
||||
ok 3 - a bad entry refuses the whole record
|
||||
---
|
||||
duration_ms: 9.894173
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: taskView reads the projection for one business
|
||||
ok 4 - taskView reads the projection for one business
|
||||
---
|
||||
duration_ms: 11.839691
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: requestTask hands only a holder and a task verb to the handler, and records refusals
|
||||
ok 5 - requestTask hands only a holder and a task verb to the handler, and records refusals
|
||||
---
|
||||
duration_ms: 23.330397
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous
|
||||
ok 6 - the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous
|
||||
---
|
||||
duration_ms: 297.537138
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: without an adapter the server refuses every task verb
|
||||
ok 7 - without an adapter the server refuses every task verb
|
||||
---
|
||||
duration_ms: 43.546083
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the runtime refuses an invalid adapter and closes a valid one
|
||||
not ok 8 - the runtime refuses an invalid adapter and closes a valid one
|
||||
---
|
||||
duration_ms: 50.654664
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-v502/packages/bus/tests/tasks.test.mjs:239:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
[
|
||||
'broker',
|
||||
'businesses',
|
||||
'credentials',
|
||||
- 'processing'
|
||||
]
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
0: 'broker'
|
||||
1: 'businesses'
|
||||
2: 'credentials'
|
||||
3: 'processing'
|
||||
actual:
|
||||
0: 'broker'
|
||||
1: 'businesses'
|
||||
2: 'credentials'
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-v502/packages/bus/tests/tasks.test.mjs:264:12)
|
||||
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
|
||||
...
|
||||
# Subtest: the process loads the S3 adapter from plain-data trackers
|
||||
ok 9 - the process loads the S3 adapter from plain-data trackers
|
||||
---
|
||||
duration_ms: 249.07671
|
||||
type: 'test'
|
||||
...
|
||||
1..9
|
||||
# tests 9
|
||||
# suites 0
|
||||
# pass 8
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 829.345079
|
||||
@@ -0,0 +1,39 @@
|
||||
TAP version 13
|
||||
# Subtest: a separate host joins durable first ownership without duplicate signals
|
||||
not ok 1 - a separate host joins durable first ownership without duplicate signals
|
||||
---
|
||||
duration_ms: 5083.453611
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-v502/packages/processing-control/tests/stop-run.test.mjs:90:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
the second host sends no signal
|
||||
+ actual - expected
|
||||
|
||||
+ [
|
||||
+ 'SIGTERM',
|
||||
+ 'SIGKILL'
|
||||
+ ]
|
||||
- []
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
actual:
|
||||
0: 'SIGTERM'
|
||||
1: 'SIGKILL'
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-v502/packages/processing-control/tests/stop-run.test.mjs:113: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 5132.608875
|
||||
@@ -0,0 +1,33 @@
|
||||
TAP version 13
|
||||
# Subtest: processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
not ok 1 - processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
---
|
||||
duration_ms: 19.005372
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-v502/packages/webui/tests/processing.test.mjs:52:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
cross-site
|
||||
|
||||
400 !== 403
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 403
|
||||
actual: 400
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-v502/packages/webui/tests/processing.test.mjs:68:12)
|
||||
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
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 130.795212
|
||||
@@ -0,0 +1,112 @@
|
||||
[
|
||||
{
|
||||
"id": "SR08",
|
||||
"file": "packages/processing-control/src/runtimes.mjs",
|
||||
"test": "packages/processing-control/tests/stop-run.test.mjs",
|
||||
"pattern": "separate host joins durable first ownership",
|
||||
"check": "cross-process exclusive first owner",
|
||||
"exit": 1,
|
||||
"timeout": false,
|
||||
"seconds": 5.2106276580598205,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/mutant-logs/SR08.tap",
|
||||
"failing": [
|
||||
"a separate host joins durable first ownership without duplicate signals"
|
||||
],
|
||||
"counts": {
|
||||
"tests": 1,
|
||||
"pass": 0,
|
||||
"fail": 1,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"status": "assertion-killed"
|
||||
},
|
||||
{
|
||||
"id": "KS43",
|
||||
"file": "packages/webui/src/processing-http.mjs",
|
||||
"test": "packages/webui/tests/processing.test.mjs",
|
||||
"pattern": "processing handler independently refuses wrong origin and cross-site before its writer",
|
||||
"check": "same-origin browser write",
|
||||
"exit": 1,
|
||||
"timeout": false,
|
||||
"seconds": 0.16104768705554307,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/mutant-logs/KS43.tap",
|
||||
"failing": [
|
||||
"processing handler independently refuses wrong origin and cross-site before its writer"
|
||||
],
|
||||
"counts": {
|
||||
"tests": 1,
|
||||
"pass": 0,
|
||||
"fail": 1,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"status": "assertion-killed"
|
||||
},
|
||||
{
|
||||
"id": "KS44",
|
||||
"file": "packages/webui/src/processing-http.mjs",
|
||||
"test": "packages/webui/tests/processing.test.mjs",
|
||||
"pattern": "processing write fences refuse",
|
||||
"check": "bounded control payload",
|
||||
"exit": 1,
|
||||
"timeout": false,
|
||||
"seconds": 0.15614235494285822,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/mutant-logs/KS44.tap",
|
||||
"failing": [
|
||||
"processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer"
|
||||
],
|
||||
"counts": {
|
||||
"tests": 1,
|
||||
"pass": 0,
|
||||
"fail": 1,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"status": "assertion-killed"
|
||||
},
|
||||
{
|
||||
"id": "KS54",
|
||||
"file": "packages/bus/src/runtime.mjs",
|
||||
"test": "packages/bus/tests/tasks.test.mjs",
|
||||
"pattern": "",
|
||||
"check": "production tracker injection",
|
||||
"exit": 1,
|
||||
"timeout": false,
|
||||
"seconds": 0.8833191560115665,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/mutant-logs/KS54.tap",
|
||||
"failing": [
|
||||
"the runtime refuses an invalid adapter and closes a valid one"
|
||||
],
|
||||
"counts": {
|
||||
"tests": 9,
|
||||
"pass": 8,
|
||||
"fail": 1,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"status": "assertion-killed"
|
||||
},
|
||||
{
|
||||
"id": "V502-status",
|
||||
"file": "packages/webui/src/processing-http.mjs",
|
||||
"test": "packages/webui/tests/processing.test.mjs",
|
||||
"pattern": "processing write fences refuse",
|
||||
"check": "exact refusal status rejects a wrong 400 that coarse >=400 accepted",
|
||||
"exit": 1,
|
||||
"timeout": false,
|
||||
"seconds": 0.16231847403105348,
|
||||
"log": "/mnt/storage/scratch/rocko-v502/agents/rocko/work/vikunja-502/mutant-logs/V502-status.tap",
|
||||
"failing": [
|
||||
"processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer"
|
||||
],
|
||||
"counts": {
|
||||
"tests": 1,
|
||||
"pass": 0,
|
||||
"fail": 1,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"status": "assertion-killed"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"assertion_kills": 5,
|
||||
"timeout_kills": 0,
|
||||
"historical_timeout_kills": 0,
|
||||
"selected": 5,
|
||||
"run_id": "1792645245774744257",
|
||||
"candidate": {
|
||||
"head": "0fa7bf421fc153a226b5f06ed484e089e48e23ba",
|
||||
"manifest": "968d6ee345766da838df68f491b5a77c9a25157034dc49e9d4a9b603e4ce7812"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
9dbbf20dc65be5306b1a4fb1259bfcf179bc748390f5b206ca42012f9c8ca078 agents/rocko/work/vikunja-502/BUILD.md
|
||||
8a5e6ea781f413f838aa9f0f3b49364202600aac4d095c9a22b52e959d68de66 agents/rocko/work/vikunja-502/RESULTS.md
|
||||
501bbda0c8c33f37857856345322a020af0f77e91d86860f1ac0d0244f708cd5 agents/rocko/work/vikunja-502/all-mutants.json
|
||||
011d0877029723282896967d6fcd0c6f6c1eff532e5a841b43eda16189395e8f agents/rocko/work/vikunja-502/base-drift-ce6a.json
|
||||
d0c009ec9f9cad210038db986fc135c7aa358ab96a1c61c78dce4543333bc737 agents/rocko/work/vikunja-502/base.txt
|
||||
d66e23404156a941f0d4f8d97a83c3de8ba2a243bedccf511462053254542ec9 agents/rocko/work/vikunja-502/build.patch
|
||||
968d6ee345766da838df68f491b5a77c9a25157034dc49e9d4a9b603e4ce7812 agents/rocko/work/vikunja-502/candidate-manifest.sha256
|
||||
62ebb87cb798e5c96fed4354399a5252861bf57621fd8bc21d552adfe3a28ab4 agents/rocko/work/vikunja-502/candidate.txt
|
||||
ebaa1dd2a828fd591a625f1e9bfa7b853c3a1e886bd538fe2de4c26d4beab044 agents/rocko/work/vikunja-502/changed-paths.txt
|
||||
edd003aa45d2b541012de68a8e7869c47c388e8270bc06adca804bf645c3ff86 agents/rocko/work/vikunja-502/commit.txt
|
||||
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 agents/rocko/work/vikunja-502/cwd-sweep.json
|
||||
0e5426a7de4dc633a11c15ca9a22812c5ccb7300e1bf02a806c0556b866c7f79 agents/rocko/work/vikunja-502/focused-results.json
|
||||
7a0d496a5b7babf07e04e31991b804759510b40d58f9573b0b78c5e91e4b3b18 agents/rocko/work/vikunja-502/gate-plan.json
|
||||
0b91b9016d0da6dc94b496447c4624bac9eefa486d3eba9ea4e733987d13f753 agents/rocko/work/vikunja-502/logs/bus-tasks-whole.log
|
||||
4245c5f91bfa421c5f697841a93a679afc1d901762c72409830460efa4d6116b agents/rocko/work/vikunja-502/logs/processing-http-whole.log
|
||||
d52cbb1058abf83e3acb402d1c2b8ee089749613d6075f5d973dd66c8ef09200 agents/rocko/work/vikunja-502/logs/python-regressions.log
|
||||
bbc1528eeeb64ce52b2532db1e0e798eded967fd66c805a030d35ef34d605ca9 agents/rocko/work/vikunja-502/logs/replays.log
|
||||
a626407facf1102b512cc38f00f6e7d889aa6280a5c03d1722ac6d1c4eb08dc5 agents/rocko/work/vikunja-502/logs/runner-green.log
|
||||
23ffbda3d00898ea85570996abad994c521b9500698b36bd8481622f5943e161 agents/rocko/work/vikunja-502/logs/runner-red.log
|
||||
7c52ba2561dd20f8bdc272647ac6a6cb6358a3005d0cf66ef38914e7d98a173d agents/rocko/work/vikunja-502/logs/stop-run-whole.log
|
||||
5e0bde2f8340fa3ae0660f08ac1eab29aae0f84856d72df6784b76e0c61110c4 agents/rocko/work/vikunja-502/mutant-logs/KS43.tap
|
||||
4d57c485599655d5f5c4b3e6d42f24d5dfd32553af3d4993a0b287d939b4be90 agents/rocko/work/vikunja-502/mutant-logs/KS44.tap
|
||||
5d91c5a8380f7d4f8a9bb8677561bc6edf0c9444eb780e502d5b1dc1fa9f1bcc agents/rocko/work/vikunja-502/mutant-logs/KS54.tap
|
||||
f195652fa91e8b07c46128ac809b8ec458632f00bced4f64bf8846761ea91ba1 agents/rocko/work/vikunja-502/mutant-logs/SR08.tap
|
||||
ac40be01b48349b3b3545a92a16576951e1481513bea4aeb5526e66ca6ecbe6e agents/rocko/work/vikunja-502/mutant-logs/V502-status.tap
|
||||
46306ee1d3a681e6fb1e61d2887b3b62a4832f3617f51209ee8ec8e4c07bc0b7 agents/rocko/work/vikunja-502/mutant-results.json
|
||||
286e53b9cd0849db12da5fb4113aab2c9c89a49341d1661fe70b23c757ae3b51 agents/rocko/work/vikunja-502/mutant-resume-summary.json
|
||||
730813393354bb940f766f4894fde1a4dffe6fe4afbcc9d2df31fa4a829da2cd agents/rocko/work/vikunja-502/pre-rebase-commit.txt
|
||||
d44d16850224227a86842652fd0f9071592e330f580852db5cfce5c60455c727 agents/rocko/work/vikunja-502/rebase-proof.json
|
||||
a4dd7e62f35597edef0fc970e8fe9e5be3f4ff15feac61863b02735bc3d8c6a4 agents/rocko/work/vikunja-502/rebase-range-diff.txt
|
||||
bdae7b9f187f9945828e6b164f70e4f7a73ecbcafbfe780727e91ea3dbdc9c83 agents/rocko/work/vikunja-502/restored-candidate.json
|
||||
fe0d360f86d86ab2df967da5338479c593c27c88d06eb38a1389d99a08489369 agents/rocko/work/vikunja-502/run-mutants.baseline.py
|
||||
c5bdc764d1cfe11752db2df412c96baa4f366236777df1755bb337e9a943ddea agents/rocko/work/vikunja-502/run-mutants.py
|
||||
708397d67087d30bbfacdd27d0dfd81055aa47d841cbcbc8281bc4bb98632f21 agents/rocko/work/vikunja-502/runner-change.diff
|
||||
c68bc5fb03bfec8ef2cb1559e8d7bfc67e2460c41828e55c9abd6e21a355f3f1 agents/rocko/work/vikunja-502/runner.sha256
|
||||
041a6eed7c60eb2f6900974718ed8db85efb0178afe71ca5ce828f0e89588b96 agents/rocko/work/vikunja-502/scan-summary.json
|
||||
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 agents/rocko/work/vikunja-502/skips.json
|
||||
475ab651a60bc4721ba8bb193b3f05ff67d25afc068e80382174b98828ad085d agents/rocko/work/vikunja-502/tests/test_timeout_cause.py
|
||||
9532b4498e2aa1ad91b37aa032a8305c338b6b9953728e2d88b5ae0071647097 agents/rocko/work/vikunja-502/tmpdir.txt
|
||||
@@ -0,0 +1 @@
|
||||
3b04790e28a2390a9b3c9cfad71ab63acc35e0ce
|
||||
@@ -0,0 +1,24 @@
|
||||
# V502 rerun on 980cec88c
|
||||
|
||||
Sage-2609 authorized the clean picks; Dewey-0834 carries approval subject
|
||||
to this new-base rerun. Source 407c8d7be, packet 04a86cf33; both patch-ids
|
||||
match 8e3c6a79b and 9bf4683c6. No conflicts; stop-run auto-merged with row
|
||||
279's landed overshoot regression. Original packet and inventory remain
|
||||
historical, unchanged; this subpacket records current-base hashes/results.
|
||||
|
||||
Sage-2616 CLEAR supersedes sage-2614's wait. Run the three whole touched
|
||||
Node files and Python check only, sequentially. No suites, mutants, Docker,
|
||||
browser or live action. scripts/mosaic-task.mjs does not change, so the
|
||||
DEFERRED N7 runs-suite trigger does not apply. A red stops without retry.
|
||||
Short scratch TMPDIR, process-group timeout, pipefail and tee; final source
|
||||
hash check and cwd sweep precede the slot return.
|
||||
|
||||
## Rerun result
|
||||
|
||||
2026-10-22T11:27:32Z–11:27:34Z on 04a86cf33: whole stop-run 13/13,
|
||||
whole processing HTTP 6/6, whole bus tasks 9/9; Node total 28/28. Python
|
||||
regressions 4/4. No skips, cancelled tests or timeouts. Commands used
|
||||
pipefail and tee with a 120-second owned process-group deadline. All five
|
||||
candidate hashes match before/after and cwd node/chromium sweep is empty.
|
||||
The original five assertion-killed mutant results carry because both
|
||||
approved patch-ids match; no mutant rerun or suite was taken in this CLEAR.
|
||||
@@ -0,0 +1 @@
|
||||
980cec88c276f3af6636c140c531d0e6008a0a20
|
||||
@@ -0,0 +1,5 @@
|
||||
c5bdc764d1cfe11752db2df412c96baa4f366236777df1755bb337e9a943ddea agents/rocko/work/vikunja-502/run-mutants.py
|
||||
475ab651a60bc4721ba8bb193b3f05ff67d25afc068e80382174b98828ad085d agents/rocko/work/vikunja-502/tests/test_timeout_cause.py
|
||||
a17ef09d68a13181fb0d27fde9006bd14ecf340270e07d7c79f6b37025f773c8 packages/bus/tests/tasks.test.mjs
|
||||
a26e5c15daff43b946aed8235158eb4037a21eaa67e8241f74a75b42b456fc62 packages/processing-control/tests/stop-run.test.mjs
|
||||
28caf153da5d16949c5a3e1cedf8c91613e7230840d9df65d5dc4bdc95e72bf5 packages/webui/tests/processing.test.mjs
|
||||
@@ -0,0 +1 @@
|
||||
04a86cf338930ffb4ef528fc4b81a5b7a2ebd237
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,65 @@
|
||||
TAP version 13
|
||||
# Subtest: recordTask keeps sync reads and a role write apart
|
||||
ok 1 - recordTask keeps sync reads and a role write apart
|
||||
---
|
||||
duration_ms: 22.535421
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: read_at must be one canonical UTC format, so the projection compares strings safely
|
||||
ok 2 - read_at must be one canonical UTC format, so the projection compares strings safely
|
||||
---
|
||||
duration_ms: 10.077554
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a bad entry refuses the whole record
|
||||
ok 3 - a bad entry refuses the whole record
|
||||
---
|
||||
duration_ms: 11.903011
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: taskView reads the projection for one business
|
||||
ok 4 - taskView reads the projection for one business
|
||||
---
|
||||
duration_ms: 12.856767
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: requestTask hands only a holder and a task verb to the handler, and records refusals
|
||||
ok 5 - requestTask hands only a holder and a task verb to the handler, and records refusals
|
||||
---
|
||||
duration_ms: 21.237027
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous
|
||||
ok 6 - the server sends task verbs to the adapter with its own timeout; other verbs stay synchronous
|
||||
---
|
||||
duration_ms: 295.239472
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: without an adapter the server refuses every task verb
|
||||
ok 7 - without an adapter the server refuses every task verb
|
||||
---
|
||||
duration_ms: 39.741274
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the runtime refuses an invalid adapter and closes a valid one
|
||||
ok 8 - the runtime refuses an invalid adapter and closes a valid one
|
||||
---
|
||||
duration_ms: 48.57706
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the process loads the S3 adapter from plain-data trackers
|
||||
ok 9 - the process loads the S3 adapter from plain-data trackers
|
||||
---
|
||||
duration_ms: 229.152599
|
||||
type: 'test'
|
||||
...
|
||||
1..9
|
||||
# tests 9
|
||||
# suites 0
|
||||
# pass 9
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 793.825413
|
||||
V502_STEP_META {"command": ["node", "--test", "--test-reporter=tap", "packages/bus/tests/tasks.test.mjs"], "start": "2026-10-22T11:27:33Z", "end": "2026-10-22T11:27:34Z", "exit": 0, "timeout": false}
|
||||
@@ -0,0 +1,47 @@
|
||||
TAP version 13
|
||||
# Subtest: read-only Console can read processing state but neither human control writes without --send
|
||||
ok 1 - read-only Console can read processing state but neither human control writes without --send
|
||||
---
|
||||
duration_ms: 18.400047
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Console halt and generation-bound resume use only the management writer and keep partial stops honest
|
||||
ok 2 - Console halt and generation-bound resume use only the management writer and keep partial stops honest
|
||||
---
|
||||
duration_ms: 3.897097
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
ok 3 - processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer
|
||||
---
|
||||
duration_ms: 5.981101
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: tester cannot invoke either control writer and failures never echo backend text
|
||||
ok 4 - tester cannot invoke either control writer and failures never echo backend text
|
||||
---
|
||||
duration_ms: 3.994511
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: processing writes use the existing SSO signer and still require --send and same-origin
|
||||
ok 5 - processing writes use the existing SSO signer and still require --send and same-origin
|
||||
---
|
||||
duration_ms: 4.204597
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: processing handler independently refuses wrong origin and cross-site before its writer
|
||||
ok 6 - processing handler independently refuses wrong origin and cross-site before its writer
|
||||
---
|
||||
duration_ms: 0.562412
|
||||
type: 'test'
|
||||
...
|
||||
1..6
|
||||
# tests 6
|
||||
# suites 0
|
||||
# pass 6
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 140.239653
|
||||
V502_STEP_META {"command": ["node", "--test", "--test-reporter=tap", "packages/webui/tests/processing.test.mjs"], "start": "2026-10-22T11:27:33Z", "end": "2026-10-22T11:27:33Z", "exit": 0, "timeout": false}
|
||||
@@ -0,0 +1,6 @@
|
||||
....
|
||||
----------------------------------------------------------------------
|
||||
Ran 4 tests in 0.011s
|
||||
|
||||
OK
|
||||
V502_STEP_META {"command": ["python3", "agents/rocko/work/vikunja-502/tests/test_timeout_cause.py"], "start": "2026-10-22T11:27:34Z", "end": "2026-10-22T11:27:34Z", "exit": 0, "timeout": false}
|
||||
@@ -0,0 +1,89 @@
|
||||
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: 33.307311
|
||||
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: 23.800235
|
||||
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.579582
|
||||
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: 28.626285
|
||||
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: 25.884332
|
||||
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: 8.820752
|
||||
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.62055
|
||||
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: 102.133976
|
||||
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.157638
|
||||
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: 57.055196
|
||||
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.230422
|
||||
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.372218
|
||||
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.951622
|
||||
type: 'test'
|
||||
...
|
||||
1..13
|
||||
# tests 13
|
||||
# suites 0
|
||||
# pass 13
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 445.400398
|
||||
V502_STEP_META {"command": ["node", "--test", "--test-reporter=tap", "packages/processing-control/tests/stop-run.test.mjs"], "start": "2026-10-22T11:27:32Z", "end": "2026-10-22T11:27:33Z", "exit": 0, "timeout": false}
|
||||
@@ -0,0 +1,14 @@
|
||||
0a8e6bc20b2ba835adef447093a653f2dff364c6ad040229d866660a63ba2a71 BUILD.md
|
||||
ecec3a1a2e175702e6ed6370550e0d5c8efdbce87c0d9b4d7851301d33877f39 base.txt
|
||||
5758d699f04ecc81f7fed2a1f14af035130cceb6f99847cd954d89b111802257 candidate-manifest.sha256
|
||||
cd0fe73242c88babb31499654bc104d5b75c47fed37b8d0cf84979c7232af8d5 candidate.txt
|
||||
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 cwd-sweep.json
|
||||
dea8596ba0cde895238a9d8dce11d43e02604592a7e810e96e0e609f0eb97050 logs/bus-tasks-whole.log
|
||||
bdfe555de3365f1067fd53839ae488177d23b0f1f1950688e7b0ded89df7e6ea logs/processing-http-whole.log
|
||||
33ffbf98d5c5e910d1605868b4beeb6955d764ee092879a3cf3ea846641cbc63 logs/python-regressions.log
|
||||
1b6f920baa4aa071f51b8820efb465dc6d514bf3d6b95f192b48020f235e0e74 logs/stop-run-whole.log
|
||||
043f3cd860db3c9c6e5249ea33eb6fa2d8538365382d68ce2e4b2be780922ce8 range-diff.txt
|
||||
c90b6a02dfaa0cb64e6fab5be9f655f030222770ed694e2042854566bca28d65 restored-hashes.json
|
||||
07d020c6a1410acf45961589c37f6bfff1f7d5df5c7b273e11d856bfa37e84dc results.json
|
||||
76c5c03767ca34bc38a7f69c14fbf1c06e8ebcc93120bc577a44e2271f6fdb16 scan-result.json
|
||||
061e5c8904f40545d3552c1caa08ee961545b23d559b01ba4e95f4e7fac202e2 tmpdir.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
1: 8e3c6a79b = 1: 407c8d7be test: harden processing failure diagnostics and cleanup
|
||||
2: 9bf4683c6 = 2: 04a86cf33 docs: record Vikunja 502 focused verification and rebase evidence
|
||||
@@ -0,0 +1 @@
|
||||
{"paths": 5, "drift": []}
|
||||
@@ -0,0 +1,79 @@
|
||||
[
|
||||
{
|
||||
"log": "bus-tasks-whole.log",
|
||||
"command": [
|
||||
"node",
|
||||
"--test",
|
||||
"--test-reporter=tap",
|
||||
"packages/bus/tests/tasks.test.mjs"
|
||||
],
|
||||
"start": "2026-10-22T11:27:33Z",
|
||||
"end": "2026-10-22T11:27:34Z",
|
||||
"exit": 0,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": 9,
|
||||
"pass": 9,
|
||||
"fail": 0,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"log": "processing-http-whole.log",
|
||||
"command": [
|
||||
"node",
|
||||
"--test",
|
||||
"--test-reporter=tap",
|
||||
"packages/webui/tests/processing.test.mjs"
|
||||
],
|
||||
"start": "2026-10-22T11:27:33Z",
|
||||
"end": "2026-10-22T11:27:33Z",
|
||||
"exit": 0,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": 6,
|
||||
"pass": 6,
|
||||
"fail": 0,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"log": "python-regressions.log",
|
||||
"command": [
|
||||
"python3",
|
||||
"agents/rocko/work/vikunja-502/tests/test_timeout_cause.py"
|
||||
],
|
||||
"start": "2026-10-22T11:27:34Z",
|
||||
"end": "2026-10-22T11:27:34Z",
|
||||
"exit": 0,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": 4,
|
||||
"pass": 4,
|
||||
"fail": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"log": "stop-run-whole.log",
|
||||
"command": [
|
||||
"node",
|
||||
"--test",
|
||||
"--test-reporter=tap",
|
||||
"packages/processing-control/tests/stop-run.test.mjs"
|
||||
],
|
||||
"start": "2026-10-22T11:27:32Z",
|
||||
"end": "2026-10-22T11:27:33Z",
|
||||
"exit": 0,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": 13,
|
||||
"pass": 13,
|
||||
"fail": 0,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"binary_files": [],
|
||||
"secret_matches": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/mnt/storage/scratch/tmp/g8zT
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"old_base": "0fa7bf421",
|
||||
"old_commit": "3b04790e28a2390a9b3c9cfad71ab63acc35e0ce",
|
||||
"new_base": "ce6a96ad6153a0a7a689e6d9d0f625b55eb86b51",
|
||||
"new_commit": "8e3c6a79bfa39e739bf728f949f51e9842ca0df9",
|
||||
"paths": 5,
|
||||
"blob_drift": [],
|
||||
"old_patch_id": "37135ebe64f1e9b40f952f4b7047fbd31db58362",
|
||||
"new_patch_id": "37135ebe64f1e9b40f952f4b7047fbd31db58362",
|
||||
"range_diff": "equal",
|
||||
"verification_carries": "sage-2482"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
1: 3b04790e2 = 1: 8e3c6a79b test: harden processing failure diagnostics and cleanup
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"paths": 5,
|
||||
"drift": []
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only with Sage's explicit slot grant. Mutations are exact-once and restored."""
|
||||
from pathlib import Path
|
||||
import argparse, hashlib, json, os, re, signal, subprocess, sys, time
|
||||
packet = Path(__file__).resolve().parent
|
||||
root = Path('/mnt/storage/scratch/rocko-r191-r3')
|
||||
all_sites = json.loads((packet/'all-mutants.json').read_text())
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('ids', nargs='*')
|
||||
parser.add_argument('--resume-from')
|
||||
parser.add_argument('--control-file')
|
||||
parser.add_argument('--adjudicate-timeout')
|
||||
args = parser.parse_args()
|
||||
sites = all_sites
|
||||
if args.resume_from:
|
||||
ids = [s['id'] for s in all_sites]
|
||||
if args.ids or args.resume_from not in ids: raise SystemExit('invalid resume selection')
|
||||
sites = all_sites[ids.index(args.resume_from):]
|
||||
elif args.ids:
|
||||
wanted = set(args.ids); sites = [s for s in all_sites if s['id'] in wanted]
|
||||
if {s['id'] for s in sites} != wanted: raise SystemExit('unknown mutant id')
|
||||
if args.adjudicate_timeout and not args.control_file:
|
||||
raise SystemExit('timeout adjudication requires an unmutated whole-file control')
|
||||
originals = {e['file']: (root/e['file']).read_bytes() for s in sites for e in [s, *s.get('extra_edits', [])]}
|
||||
for s in sites:
|
||||
for e in [s, *s.get('extra_edits', [])]:
|
||||
if originals[e['file']].decode().count(e['old']) != 1: raise SystemExit('preflight '+s['id'])
|
||||
logs = packet/('resume-mutant-logs' if args.resume_from else 'mutant-logs'); logs.mkdir(exist_ok=True)
|
||||
result_file = packet/('resume-mutant-results.json' if args.resume_from else 'mutant-results.json')
|
||||
results = []
|
||||
controls = {}
|
||||
run_id = str(time.time_ns())
|
||||
|
||||
def verify_candidate():
|
||||
expected = (packet/'commit.txt').read_text().strip()
|
||||
if subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip()!=expected:
|
||||
raise RuntimeError('candidate HEAD changed')
|
||||
manifest = packet/'candidate-manifest.sha256'
|
||||
digest = hashlib.sha256(manifest.read_bytes()).hexdigest()
|
||||
if digest!=(packet/'candidate.txt').read_text().strip(): raise RuntimeError('manifest digest changed')
|
||||
for line in manifest.read_text().splitlines():
|
||||
wanted,name = line.split(' ',1)
|
||||
if hashlib.sha256((root/name).read_bytes()).hexdigest()!=wanted: raise RuntimeError('candidate drift '+name)
|
||||
return {'head':expected,'manifest':digest}
|
||||
|
||||
def stat(pid):
|
||||
try:
|
||||
raw = Path('/proc',str(pid),'stat').read_text(); fields = raw[raw.rfind(')')+2:].split()
|
||||
return dict(ppid=int(fields[1]), group=int(fields[2]), start=fields[19], state=fields[0])
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError): return None
|
||||
def capture(child, known):
|
||||
current = {}
|
||||
for p in Path('/proc').iterdir():
|
||||
if p.name.isdecimal():
|
||||
value=stat(int(p.name))
|
||||
if value: current[int(p.name)]=value
|
||||
changed=True
|
||||
while changed:
|
||||
changed=False
|
||||
for pid,value in current.items():
|
||||
if pid not in known and (pid==child or (value['ppid'] in known and current.get(value['ppid'],{}).get('start')==known[value['ppid']]['start'])): known[pid]=value;changed=True
|
||||
|
||||
def cleanup(known):
|
||||
groups={}
|
||||
for pid,old in known.items():
|
||||
cur=stat(pid)
|
||||
if cur and cur['start']==old['start'] and cur['state']!='Z': groups.setdefault(cur['group'],[]).append(pid)
|
||||
for group,anchors in groups.items():
|
||||
# Every member here descends from this test runner, identified by start ticks.
|
||||
if not any((cur:=stat(pid)) and cur['start']==known[pid]['start'] and cur['group']==group for pid in anchors):continue
|
||||
try: os.killpg(group, signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
def execute(test, pattern, log):
|
||||
child = None; known = {}; timedout = False; started = time.monotonic()
|
||||
command = ['node','--test','--test-reporter=tap']
|
||||
if pattern: command += ['--test-name-pattern='+re.escape(pattern)]
|
||||
command += [test]
|
||||
try:
|
||||
with log.open('w') as out:
|
||||
child = subprocess.Popen(command,cwd=root,stdout=out,stderr=subprocess.STDOUT,start_new_session=True,
|
||||
env={**os.environ,'TMPDIR':os.environ['TMPDIR'],'DOCKER_HOST':'unix:///nonexistent.sock',
|
||||
'MOSAIC_CONFIG':str(Path(os.environ['TMPDIR'])/'no-live-config.json')})
|
||||
first = stat(child.pid)
|
||||
if first: known[child.pid] = first
|
||||
deadline = started+90
|
||||
while child.poll() is None:
|
||||
capture(child.pid,known)
|
||||
if time.monotonic()>=deadline:
|
||||
timedout = True; cleanup(known)
|
||||
try: os.killpg(child.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
break
|
||||
time.sleep(.05)
|
||||
rc = child.wait()
|
||||
finally:
|
||||
if child and child.poll() is None:
|
||||
cleanup(known)
|
||||
try: os.killpg(child.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
child.wait()
|
||||
cleanup(known)
|
||||
text = log.read_text()
|
||||
return {'exit':rc,'timeout':timedout,'seconds':time.monotonic()-started,'log':str(log),
|
||||
'failing':re.findall(r'^\s*not ok \d+ - (.*)$',text,re.M),
|
||||
'counts':{k:int(v) for k,v in re.findall(r'^# (tests|pass|fail|skipped|cancelled) (\d+)\s*$',text,re.M)}}
|
||||
|
||||
def timeout_cause(site):
|
||||
if site['id']=='SR08':
|
||||
return ('runtimes.mjs:333 loops on now() <= deadline; stop-run.test.mjs:106-108 '
|
||||
'gives the second host a fixed fake clock with a real sleep. Removing the join '
|
||||
'branch enters the delivery loop without advancing that clock. Original log: TAP header only.')
|
||||
return ('Mutated '+site['file']+' timed out after 90s while the same whole test file '
|
||||
'passed unmutated in this invocation. Detailed cause must be recorded before review.')
|
||||
|
||||
try:
|
||||
binding = verify_candidate()
|
||||
if args.control_file:
|
||||
if args.control_file not in {s['test'] for s in all_sites}: raise SystemExit('unknown control file')
|
||||
before = hashlib.sha256((root/args.control_file).read_bytes()).hexdigest()
|
||||
control = execute(args.control_file,'',logs/'unmutated-control.tap')
|
||||
after = hashlib.sha256((root/args.control_file).read_bytes()).hexdigest()
|
||||
counts = control['counts']
|
||||
passed = (control['exit']==0 and not control['timeout'] and not control['failing'] and before==after
|
||||
and counts.get('tests',0)>0 and counts.get('pass')==counts.get('tests')
|
||||
and counts.get('fail')==0 and counts.get('cancelled')==0 and counts.get('skipped')==0)
|
||||
control.update(run_id=run_id,test=args.control_file,test_sha256=before,passed=passed,**binding)
|
||||
(packet/'unmutated-control.json').write_text(json.dumps(control,indent=2)+'\n')
|
||||
print('CONTROL',json.dumps(control),flush=True)
|
||||
if not passed: raise SystemExit('control failed; stop for Mos, no mutation applied')
|
||||
verify_candidate()
|
||||
controls[args.control_file] = control
|
||||
if args.adjudicate_timeout:
|
||||
# This is the already-observed SR08 timeout, not a rerun or an assertion kill.
|
||||
if args.adjudicate_timeout!='SR08': raise SystemExit('only SR08 historical adjudication approved')
|
||||
prior = json.loads((packet/'mutant-results.json').read_text())
|
||||
old = next((s for s in prior if s['id']=='SR08'),None)
|
||||
site = next(s for s in all_sites if s['id']=='SR08')
|
||||
if not old or old['status']!='timeout' or old['test']!=args.control_file or old['failing']:
|
||||
raise SystemExit('historical timeout does not match approved control')
|
||||
if Path(old['log']).read_text().strip()!='TAP version 13': raise SystemExit('historical timeout log changed')
|
||||
adjudication = {**old,'status':'killed-by-timeout','cause':timeout_cause(site),'control':controls[args.control_file],
|
||||
'original_log_sha256':hashlib.sha256(Path(old['log']).read_bytes()).hexdigest()}
|
||||
(packet/'SR08-timeout-adjudication.json').write_text(json.dumps(adjudication,indent=2)+'\n')
|
||||
print('TIMEOUT_KILL SR08',adjudication['cause'],flush=True)
|
||||
for i,s in enumerate(sites,1):
|
||||
verify_candidate()
|
||||
edits = [s, *s.get('extra_edits', [])]
|
||||
for e in edits:
|
||||
if (root/e['file']).read_bytes()!=originals[e['file']]: raise RuntimeError('source drift '+s['id'])
|
||||
print(f'{i}/{len(sites)} {s["id"]}: {s["check"]}',flush=True)
|
||||
try:
|
||||
for e in edits:
|
||||
(root/e['file']).write_bytes(originals[e['file']].decode().replace(e['old'],e['new'],1).encode())
|
||||
observed = execute(s['test'],s['pattern'],logs/(s['id']+'.tap'))
|
||||
finally:
|
||||
for e in edits: (root/e['file']).write_bytes(originals[e['file']])
|
||||
expected = not s['pattern'] or any(s['pattern'] in name for name in observed['failing'])
|
||||
control = controls.get(s['test'])
|
||||
if observed['timeout']:
|
||||
status = 'killed-by-timeout' if control and control['passed'] and control['run_id']==run_id else 'timeout-without-control'
|
||||
elif observed['exit']==0: status = 'survived'
|
||||
elif observed['failing'] and expected: status = 'assertion-killed'
|
||||
else: status = 'invalid-failure'
|
||||
row = {**{k:s[k] for k in ['id','file','test','pattern','check']},**observed,'status':status}
|
||||
if observed['timeout']: row.update(cause=timeout_cause(s),control=control)
|
||||
results.append(row)
|
||||
result_file.write_text(json.dumps(results,indent=2)+'\n')
|
||||
print(status,flush=True)
|
||||
if status not in ('assertion-killed','killed-by-timeout'): raise SystemExit(1)
|
||||
finally:
|
||||
for name,data in originals.items(): (root/name).write_bytes(data)
|
||||
if any((root/name).read_bytes()!=data for name,data in originals.items()): raise RuntimeError('restore failed')
|
||||
summary = {'assertion_kills':sum(s['status']=='assertion-killed' for s in results),
|
||||
'timeout_kills':sum(s['status']=='killed-by-timeout' for s in results),
|
||||
'historical_timeout_kills':int(bool(args.adjudicate_timeout)),'selected':len(sites),
|
||||
'run_id':run_id,'candidate':binding}
|
||||
(packet/'mutant-resume-summary.json').write_text(json.dumps(summary,indent=2)+'\n')
|
||||
print('MUTANT_SUMMARY',json.dumps(summary),flush=True)
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only with Sage's explicit slot grant. Mutations are exact-once and restored."""
|
||||
from pathlib import Path
|
||||
import argparse, hashlib, json, os, re, signal, subprocess, sys, time
|
||||
packet = Path(__file__).resolve().parent
|
||||
root = Path('/mnt/storage/scratch/rocko-v502')
|
||||
all_sites = json.loads((packet/'all-mutants.json').read_text())
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('ids', nargs='*')
|
||||
parser.add_argument('--resume-from')
|
||||
parser.add_argument('--control-file')
|
||||
parser.add_argument('--adjudicate-timeout')
|
||||
args = parser.parse_args()
|
||||
sites = all_sites
|
||||
if args.resume_from:
|
||||
ids = [s['id'] for s in all_sites]
|
||||
if args.ids or args.resume_from not in ids: raise SystemExit('invalid resume selection')
|
||||
sites = all_sites[ids.index(args.resume_from):]
|
||||
elif args.ids:
|
||||
wanted = set(args.ids); sites = [s for s in all_sites if s['id'] in wanted]
|
||||
if {s['id'] for s in sites} != wanted: raise SystemExit('unknown mutant id')
|
||||
if args.adjudicate_timeout and not args.control_file:
|
||||
raise SystemExit('timeout adjudication requires an unmutated whole-file control')
|
||||
originals = {e['file']: (root/e['file']).read_bytes() for s in sites for e in [s, *s.get('extra_edits', [])]}
|
||||
for s in sites:
|
||||
for e in [s, *s.get('extra_edits', [])]:
|
||||
if originals[e['file']].decode().count(e['old']) != 1: raise SystemExit('preflight '+s['id'])
|
||||
logs = packet/('resume-mutant-logs' if args.resume_from else 'mutant-logs'); logs.mkdir(exist_ok=True)
|
||||
result_file = packet/('resume-mutant-results.json' if args.resume_from else 'mutant-results.json')
|
||||
results = []
|
||||
controls = {}
|
||||
run_id = str(time.time_ns())
|
||||
|
||||
def verify_candidate():
|
||||
expected = (packet/'commit.txt').read_text().strip()
|
||||
if subprocess.check_output(['git','rev-parse','HEAD'],cwd=root,text=True).strip()!=expected:
|
||||
raise RuntimeError('candidate HEAD changed')
|
||||
manifest = packet/'candidate-manifest.sha256'
|
||||
digest = hashlib.sha256(manifest.read_bytes()).hexdigest()
|
||||
if digest!=(packet/'candidate.txt').read_text().strip(): raise RuntimeError('manifest digest changed')
|
||||
for line in manifest.read_text().splitlines():
|
||||
wanted,name = line.split(' ',1)
|
||||
if hashlib.sha256((root/name).read_bytes()).hexdigest()!=wanted: raise RuntimeError('candidate drift '+name)
|
||||
return {'head':expected,'manifest':digest}
|
||||
|
||||
def stat(pid):
|
||||
try:
|
||||
raw = Path('/proc',str(pid),'stat').read_text(); fields = raw[raw.rfind(')')+2:].split()
|
||||
return dict(ppid=int(fields[1]), group=int(fields[2]), start=fields[19], state=fields[0])
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError): return None
|
||||
def capture(child, known):
|
||||
current = {}
|
||||
for p in Path('/proc').iterdir():
|
||||
if p.name.isdecimal():
|
||||
value=stat(int(p.name))
|
||||
if value: current[int(p.name)]=value
|
||||
changed=True
|
||||
while changed:
|
||||
changed=False
|
||||
for pid,value in current.items():
|
||||
if pid not in known and (pid==child or (value['ppid'] in known and current.get(value['ppid'],{}).get('start')==known[value['ppid']]['start'])): known[pid]=value;changed=True
|
||||
|
||||
def cleanup(known):
|
||||
groups={}
|
||||
for pid,old in known.items():
|
||||
cur=stat(pid)
|
||||
if cur and cur['start']==old['start'] and cur['state']!='Z': groups.setdefault(cur['group'],[]).append(pid)
|
||||
for group,anchors in groups.items():
|
||||
# Every member here descends from this test runner, identified by start ticks.
|
||||
if not any((cur:=stat(pid)) and cur['start']==known[pid]['start'] and cur['group']==group for pid in anchors):continue
|
||||
try: os.killpg(group, signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
def execute(test, pattern, log):
|
||||
child = None; known = {}; timedout = False; started = time.monotonic()
|
||||
command = ['node','--test','--test-reporter=tap']
|
||||
if pattern: command += ['--test-name-pattern='+re.escape(pattern)]
|
||||
command += [test]
|
||||
try:
|
||||
with log.open('w') as out:
|
||||
child = subprocess.Popen(command,cwd=root,stdout=out,stderr=subprocess.STDOUT,start_new_session=True,
|
||||
env={**os.environ,'TMPDIR':os.environ['TMPDIR'],'DOCKER_HOST':'unix:///nonexistent.sock',
|
||||
'MOSAIC_CONFIG':str(Path(os.environ['TMPDIR'])/'no-live-config.json')})
|
||||
first = stat(child.pid)
|
||||
if first: known[child.pid] = first
|
||||
deadline = started+90
|
||||
while child.poll() is None:
|
||||
capture(child.pid,known)
|
||||
if time.monotonic()>=deadline:
|
||||
timedout = True; cleanup(known)
|
||||
try: os.killpg(child.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
break
|
||||
time.sleep(.05)
|
||||
rc = child.wait()
|
||||
finally:
|
||||
if child and child.poll() is None:
|
||||
cleanup(known)
|
||||
try: os.killpg(child.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
child.wait()
|
||||
cleanup(known)
|
||||
text = log.read_text()
|
||||
return {'exit':rc,'timeout':timedout,'seconds':time.monotonic()-started,'log':str(log),
|
||||
'failing':re.findall(r'^\s*not ok \d+ - (.*)$',text,re.M),
|
||||
'counts':{k:int(v) for k,v in re.findall(r'^# (tests|pass|fail|skipped|cancelled) (\d+)\s*$',text,re.M)}}
|
||||
|
||||
def timeout_cause(site, control=None):
|
||||
if site['id']=='SR08':
|
||||
return ('runtimes.mjs:333 loops on now() <= deadline; stop-run.test.mjs:106-108 '
|
||||
'gives the second host a fixed fake clock with a real sleep. Removing the join '
|
||||
'branch enters the delivery loop without advancing that clock. Original log: TAP header only.')
|
||||
if not control or control.get('passed') is not True:
|
||||
return ('Mutated '+site['file']+' timed out after 90s; no passing same-file control '
|
||||
'exists in this invocation. Detection is not established. '
|
||||
'Detailed cause must be recorded before review.')
|
||||
return ('Mutated '+site['file']+' timed out after 90s while the same whole test file '
|
||||
'passed unmutated in this invocation. Detailed cause must be recorded before review.')
|
||||
|
||||
try:
|
||||
binding = verify_candidate()
|
||||
if args.control_file:
|
||||
if args.control_file not in {s['test'] for s in all_sites}: raise SystemExit('unknown control file')
|
||||
before = hashlib.sha256((root/args.control_file).read_bytes()).hexdigest()
|
||||
control = execute(args.control_file,'',logs/'unmutated-control.tap')
|
||||
after = hashlib.sha256((root/args.control_file).read_bytes()).hexdigest()
|
||||
counts = control['counts']
|
||||
passed = (control['exit']==0 and not control['timeout'] and not control['failing'] and before==after
|
||||
and counts.get('tests',0)>0 and counts.get('pass')==counts.get('tests')
|
||||
and counts.get('fail')==0 and counts.get('cancelled')==0 and counts.get('skipped')==0)
|
||||
control.update(run_id=run_id,test=args.control_file,test_sha256=before,passed=passed,**binding)
|
||||
(packet/'unmutated-control.json').write_text(json.dumps(control,indent=2)+'\n')
|
||||
print('CONTROL',json.dumps(control),flush=True)
|
||||
if not passed: raise SystemExit('control failed; stop for Mos, no mutation applied')
|
||||
verify_candidate()
|
||||
controls[args.control_file] = control
|
||||
if args.adjudicate_timeout:
|
||||
# This is the already-observed SR08 timeout, not a rerun or an assertion kill.
|
||||
if args.adjudicate_timeout!='SR08': raise SystemExit('only SR08 historical adjudication approved')
|
||||
prior = json.loads((packet/'mutant-results.json').read_text())
|
||||
old = next((s for s in prior if s['id']=='SR08'),None)
|
||||
site = next(s for s in all_sites if s['id']=='SR08')
|
||||
if not old or old['status']!='timeout' or old['test']!=args.control_file or old['failing']:
|
||||
raise SystemExit('historical timeout does not match approved control')
|
||||
if Path(old['log']).read_text().strip()!='TAP version 13': raise SystemExit('historical timeout log changed')
|
||||
adjudication = {**old,'status':'killed-by-timeout','cause':timeout_cause(site, controls[args.control_file]),'control':controls[args.control_file],
|
||||
'original_log_sha256':hashlib.sha256(Path(old['log']).read_bytes()).hexdigest()}
|
||||
(packet/'SR08-timeout-adjudication.json').write_text(json.dumps(adjudication,indent=2)+'\n')
|
||||
print('TIMEOUT_KILL SR08',adjudication['cause'],flush=True)
|
||||
for i,s in enumerate(sites,1):
|
||||
verify_candidate()
|
||||
edits = [s, *s.get('extra_edits', [])]
|
||||
for e in edits:
|
||||
if (root/e['file']).read_bytes()!=originals[e['file']]: raise RuntimeError('source drift '+s['id'])
|
||||
print(f'{i}/{len(sites)} {s["id"]}: {s["check"]}',flush=True)
|
||||
try:
|
||||
for e in edits:
|
||||
(root/e['file']).write_bytes(originals[e['file']].decode().replace(e['old'],e['new'],1).encode())
|
||||
observed = execute(s['test'],s['pattern'],logs/(s['id']+'.tap'))
|
||||
finally:
|
||||
for e in edits: (root/e['file']).write_bytes(originals[e['file']])
|
||||
expected = not s['pattern'] or any(s['pattern'] in name for name in observed['failing'])
|
||||
control = controls.get(s['test'])
|
||||
if observed['timeout']:
|
||||
status = 'killed-by-timeout' if control and control['passed'] and control['run_id']==run_id else 'timeout-without-control'
|
||||
elif observed['exit']==0: status = 'survived'
|
||||
elif observed['failing'] and expected: status = 'assertion-killed'
|
||||
else: status = 'invalid-failure'
|
||||
row = {**{k:s[k] for k in ['id','file','test','pattern','check']},**observed,'status':status}
|
||||
if observed['timeout']: row.update(cause=timeout_cause(s, control),control=control)
|
||||
results.append(row)
|
||||
result_file.write_text(json.dumps(results,indent=2)+'\n')
|
||||
print(status,flush=True)
|
||||
if status not in ('assertion-killed','killed-by-timeout'): raise SystemExit(1)
|
||||
finally:
|
||||
for name,data in originals.items(): (root/name).write_bytes(data)
|
||||
if any((root/name).read_bytes()!=data for name,data in originals.items()): raise RuntimeError('restore failed')
|
||||
summary = {'assertion_kills':sum(s['status']=='assertion-killed' for s in results),
|
||||
'timeout_kills':sum(s['status']=='killed-by-timeout' for s in results),
|
||||
'historical_timeout_kills':int(bool(args.adjudicate_timeout)),'selected':len(sites),
|
||||
'run_id':run_id,'candidate':binding}
|
||||
(packet/'mutant-resume-summary.json').write_text(json.dumps(summary,indent=2)+'\n')
|
||||
print('MUTANT_SUMMARY',json.dumps(summary),flush=True)
|
||||
@@ -0,0 +1,46 @@
|
||||
--- agents/rocko/work/vikunja-502/run-mutants.baseline.py 2026-10-21 23:53:18.260554870 -0500
|
||||
+++ agents/rocko/work/vikunja-502/run-mutants.py 2026-10-22 00:00:01.232405637 -0500
|
||||
@@ -3,7 +3,7 @@
|
||||
from pathlib import Path
|
||||
import argparse, hashlib, json, os, re, signal, subprocess, sys, time
|
||||
packet = Path(__file__).resolve().parent
|
||||
-root = Path('/mnt/storage/scratch/rocko-r191-r3')
|
||||
+root = Path('/mnt/storage/scratch/rocko-v502')
|
||||
all_sites = json.loads((packet/'all-mutants.json').read_text())
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('ids', nargs='*')
|
||||
@@ -104,11 +104,15 @@
|
||||
'failing':re.findall(r'^\s*not ok \d+ - (.*)$',text,re.M),
|
||||
'counts':{k:int(v) for k,v in re.findall(r'^# (tests|pass|fail|skipped|cancelled) (\d+)\s*$',text,re.M)}}
|
||||
|
||||
-def timeout_cause(site):
|
||||
+def timeout_cause(site, control=None):
|
||||
if site['id']=='SR08':
|
||||
return ('runtimes.mjs:333 loops on now() <= deadline; stop-run.test.mjs:106-108 '
|
||||
'gives the second host a fixed fake clock with a real sleep. Removing the join '
|
||||
'branch enters the delivery loop without advancing that clock. Original log: TAP header only.')
|
||||
+ if not control or control.get('passed') is not True:
|
||||
+ return ('Mutated '+site['file']+' timed out after 90s; no passing same-file control '
|
||||
+ 'exists in this invocation. Detection is not established. '
|
||||
+ 'Detailed cause must be recorded before review.')
|
||||
return ('Mutated '+site['file']+' timed out after 90s while the same whole test file '
|
||||
'passed unmutated in this invocation. Detailed cause must be recorded before review.')
|
||||
|
||||
@@ -138,7 +142,7 @@
|
||||
if not old or old['status']!='timeout' or old['test']!=args.control_file or old['failing']:
|
||||
raise SystemExit('historical timeout does not match approved control')
|
||||
if Path(old['log']).read_text().strip()!='TAP version 13': raise SystemExit('historical timeout log changed')
|
||||
- adjudication = {**old,'status':'killed-by-timeout','cause':timeout_cause(site),'control':controls[args.control_file],
|
||||
+ adjudication = {**old,'status':'killed-by-timeout','cause':timeout_cause(site, controls[args.control_file]),'control':controls[args.control_file],
|
||||
'original_log_sha256':hashlib.sha256(Path(old['log']).read_bytes()).hexdigest()}
|
||||
(packet/'SR08-timeout-adjudication.json').write_text(json.dumps(adjudication,indent=2)+'\n')
|
||||
print('TIMEOUT_KILL SR08',adjudication['cause'],flush=True)
|
||||
@@ -162,7 +166,7 @@
|
||||
elif observed['failing'] and expected: status = 'assertion-killed'
|
||||
else: status = 'invalid-failure'
|
||||
row = {**{k:s[k] for k in ['id','file','test','pattern','check']},**observed,'status':status}
|
||||
- if observed['timeout']: row.update(cause=timeout_cause(s),control=control)
|
||||
+ if observed['timeout']: row.update(cause=timeout_cause(s, control),control=control)
|
||||
results.append(row)
|
||||
result_file.write_text(json.dumps(results,indent=2)+'\n')
|
||||
print(status,flush=True)
|
||||
@@ -0,0 +1 @@
|
||||
c5bdc764d1cfe11752db2df412c96baa4f366236777df1755bb337e9a943ddea agents/rocko/work/vikunja-502/run-mutants.py
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"scope": "owned502packet",
|
||||
"binary_files": [],
|
||||
"secret_pattern_matches": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Exercise the actual pure cause renderer without executing the mutant driver."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
RUNNER = Path(__file__).resolve().parents[1] / 'run-mutants.py'
|
||||
|
||||
|
||||
def cause_renderer():
|
||||
tree = ast.parse(RUNNER.read_text())
|
||||
function = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'timeout_cause')
|
||||
namespace = {}
|
||||
exec(compile(ast.Module(body=[function], type_ignores=[]), str(RUNNER), 'exec'), namespace)
|
||||
return namespace['timeout_cause']
|
||||
|
||||
|
||||
class TimeoutCause(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cause = cause_renderer()
|
||||
self.site = {'id': 'KS54', 'file': 'packages/bus/src/runtime.mjs'}
|
||||
|
||||
def test_missing_control_never_claims_an_unmutated_pass(self):
|
||||
text = self.cause(self.site)
|
||||
self.assertIn('no passing same-file control', text)
|
||||
self.assertNotIn('passed unmutated', text)
|
||||
|
||||
def test_failed_control_never_claims_an_unmutated_pass(self):
|
||||
text = self.cause(self.site, {'passed': False})
|
||||
self.assertIn('no passing same-file control', text)
|
||||
self.assertNotIn('passed unmutated', text)
|
||||
|
||||
def test_passing_control_can_state_its_observed_pass(self):
|
||||
text = self.cause(self.site, {'passed': True})
|
||||
self.assertIn('passed unmutated', text)
|
||||
self.assertNotIn('no passing same-file control', text)
|
||||
|
||||
def test_known_sr08_cause_remains_specific_without_claiming_a_control(self):
|
||||
text = self.cause({'id': 'SR08', 'file': 'packages/processing-control/src/runtimes.mjs'})
|
||||
self.assertIn('fixed fake clock', text)
|
||||
self.assertNotIn('passed unmutated', text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1 @@
|
||||
/mnt/storage/scratch/tmp/gU6o
|
||||
@@ -1505,3 +1505,17 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-22 07:56:59 UTC | Rocko (Codex gpt-6.1-sol, d99326cc) | row 280 slim packet under sage-2541 | b0fd28b6f on rocko/row280-packet-b765, parent b765f5cb8; 22 text files, 21-entry slim inventory914f888d, full evidence d6c924764 retained, portability/follow-ups recorded; no tests or source changes.
|
||||
2026-10-22 08:00:02 UTC | Sage (1ef1e4f8) | row 280 review, gate and landing | design approve sage-2533; CLEARs for phase 1, diagnostic and repeat; published d6c924764 as jarvis; review request rev 3151/3152; gate bus 656/0/0; landed b0fd28b6f (ff), note 3154, done 3155; #1785 closed; P19 511 comment 623; Honker told for 194 b17.
|
||||
2026-10-22 08:38:11 UTC | Sage (1ef1e4f8) | row 194 b17/b18 window and landing | b17 CLEAR (sage-2544), then my refactor push forced b18 (sage-2545, Webby sage-2546); review request rev 3158/3159; landed fd6dd327c (ff), note 3161, done 3162; #1692 closed; P19 414 comment 625
|
||||
2026-10-22 08:42:04 UTC | Honker (Claude Opus 5.5, ecd17786) | row 194 #1692 build to landing | b8-b18 on honker/row194-b*; G13 diagnosis 60 runs (slot returned 07:11Z); b17 then b18 rebases (no force-push); runb8 + gate whole green on fd6dd327c, slot returned 08:31Z swept clean; in-review move revs 3156/3157 (request failed, no token); landed by Sage fd6dd327c; no live action.
|
||||
2026-10-22T08:45:16Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) rebase onto be2b008cc, ce74477e1 (sage-2553/2555, megavolt-0128) | static look clean: c1 unions match 194 code, 2-7 =, manifest d7dd27b8 ok; fire-keeps-grant note to Sage; verdict waits on the green gate
|
||||
2026-10-22T09:37:08Z | Dewey (Opus 5.5, 84fb346c) | row 236 (#1742) manifest round 1 on 0b00269b (bd5f11ded) | APPROVE, comment 30450, queue rev 3168; no blockers, no notes
|
||||
2026-10-22T09:43:34Z | Sage (1ef1e4f8) | row 236 landing, row 281 filing | landed bd5f11ded (ff), note 3169, done 3170, #1742 closed c30451, P19 436 comment 639; row 281 #1786 brief and DEFERRED entry for mos-1271
|
||||
2026-10-22T09:46:13Z | Dewey (Opus 5.5, 84fb346c) | row 274 (#1779) r4 rebase look on 1920b926f (bd5f11ded) | clean static look, not a verdict: range-diff/patch-ids reproduced, manifest 04a0750c ok, S1/S2/halt tests present; r5 covers move supported (dewey-0819/0820)
|
||||
2026-10-22T09:50:02Z | Dewey (Opus 5.5, 84fb346c) | row 274 (#1779) r5 static look on f58cfc361 (47a35bf43) | clean, not a verdict: range-diff 1-4 = plus r5, manifest 13 paths 6f5f453a ok, covers moved to vocabulary.mjs as a re-export, RUNNER_FILES untouched (dewey-0821/0822)
|
||||
2026-10-22T10:00:55Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) design d3 review on 36c2d98b8 (47a35bf43) | CHANGES dewey-0823/0824: B1 pending decisions and raise-after-fire, B2 awaited step G vs host.op 10 s and runner 30 s, B3 per-agent concurrency; N1 agent-scoped sweep, N2 name hosts revoke, N3 instance.fire tests in manifest
|
||||
2026-10-22T10:08:33Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) design d4/d5 review on 59142e9fb (47a35bf43) | CHANGES dewey-0825/0826: B1-B3 closed; C1 step C in the job needs the chief's claim (#agent not-holder) and B/C failures mislabeled internal; N4 restart mid-job, N5 section 7 refusals, N6 leftover-line wording
|
||||
2026-10-22T10:08:33Z | Dewey (Opus 5.5, 84fb346c) | row 274 (#1779) r6 static look on 4c485c63b (47a35bf43) | clean, not a verdict (dewey-0827/0828): range-diff 1-5 = plus r6, patch-ids reproduce, manifest 13 paths 00047b52 ok, deepEqual pins :132 before the delete loop; packet blob change doc-only
|
||||
2026-10-22T10:11:28Z | Dewey (Opus 5.5, 84fb346c) | row 281 (#1786) design d6 review on 97535587b (47a35bf43) | APPROVE dewey-0829/0830: C1 and N4-N6 closed; build-time N7 (T12 step B refusal must survive step G), N8 (T11 decision-consumed can't hold for a within-role fire)
|
||||
2026-10-22T10:28Z | Dewey (Opus 5.5, 84fb346c) | row 274 FED R3 r6 manifest round, 4c485c63b manifest 00047b52 | APPROVE, #1779 comment 30487, recorded rev 3178 (dewey-274-verdict-r6); N1 tests present, 22/22 mutants, 23 file lines (not 24)
|
||||
2026-10-22 10:30:51 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 274 landing, row 281 d6 and 264a r5 plans | 274 landed 4c485c63b (ff, #1779 closed c30491, P19 414 c646); review request posted for Morgana (no token) c30488; 281 d6 approved by Dewey, code started; 264a r5 c4c75b6b4/5cb234285 run plan accepted; N7 DEFERRED (runs suite gates mosaic-task.mjs) committed
|
||||
2026-10-22 10:59:30 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 256 #1761 creative delete landing | 256 landed f12441d4f (ff on 0165be266, 4 Honker commits); Webby APPROVE c30519 posted as jarvis for Webby (no token), rev 3185; request c30518; note 3186, done 3187; #1761 closed c30520; P19 416 c649; 277 rebases onto this landing next (sage-2594)
|
||||
2026-10-22 11:21:00 UTC | Sage (Claude Opus 5.5, 1ef1e4f8) | row 277 #1782 owner thread claims landing | 277 landed 62f53b1e0 (ff on 46dedf925, 7 Morgana commits); Duckworth APPROVE c30539 posted as jarvis, rev 3192; request c30538; note 3193, done 3194; #1782 closed c30540; P19 506 c652; Honker 256 and Megavolt 236 BUILD.md notes picked; DEFERRED M8 resolve pin
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# A fire or a delete revokes the instance's grants (194/236 follow-up, MVP)
|
||||
|
||||
Sage, 2026-10-22, for mos-1271. Dewey found it on the row 236 rebase look (dewey-0815). Row 281, Gitea #1786, Vikunja 524.
|
||||
|
||||
## Why
|
||||
|
||||
Row 194's `instance.fire` revokes only the roles entry's credential services. It does this through
|
||||
`revoke()` in packages/bus/src/runtime.mjs (:151), which instance-settings.mjs `fire()` calls as its
|
||||
beforeWrite (:337, :343). Row 256's human delete goes through the same `revoke()`. Neither touches a
|
||||
grant. A fired agent's host grants (row 235) stay on every host they name, and its Gitea user and token
|
||||
(row 236) stay live. They last until someone runs `mosaic hosts revoke AGENT HOST` or `mosaic grants
|
||||
revoke AGENT` by hand. Those CLI verbs are the human's. The tester's `hosts.revoke` and `grants.revoke`
|
||||
refuse `tester-dev-only` outside a dev business (broker.mjs :1573 and #grantsCaller). The Console has
|
||||
no revoke route. Nothing tells the human that a fired instance still holds a grant.
|
||||
|
||||
Live is 86f95bb27, which carries 235's host grants but not 236. dragon-lin runs as tester with no real
|
||||
host grants, so nothing live holds a grant today. The Case 1 install on .53 will hold them.
|
||||
|
||||
## Brief: no grant outlives its instance
|
||||
|
||||
`--after` 236. It lands before the Case 1 install on .53 hires any instance with a grant (mos-1271
|
||||
item 4). It doesn't hold the dragon-lin re-pin. It comes after 274 in the landing order unless Mos
|
||||
moves it.
|
||||
|
||||
Design before code. The design goes in the packet BUILD.md. The reviewer approves it before any code.
|
||||
|
||||
What to build:
|
||||
|
||||
1. A fire, and a delete, end every grant the instance holds before the write that removes the
|
||||
instance. That covers each host grant on each host, through the same steps `hosts.revoke` takes
|
||||
(the grant.revoked record, the key removal and the host line, row 235 r6 section 6). It also covers
|
||||
the Gitea grant, through the same steps `grants.revoke` takes (row 236 #436b).
|
||||
2. Fail closed, as `revoke()` does for credentials. If any grant revoke can't complete, the verb stops,
|
||||
the organization file stays unchanged, and the refusal names the instance, the grant and the reason.
|
||||
A repeated fire then picks up where the first stopped, as 194's repeated fire already does for step
|
||||
C (webby-0011 B2). A grant that's already revoked counts as done. The design says how a host that
|
||||
can't be reached is handled. It may not drop a grant record while its key could still be on the
|
||||
host.
|
||||
3. The fire's result, and the event the human sees, list the grants revoked as well as the credential
|
||||
services.
|
||||
4. Nothing else changes. The hosts and grants verbs keep their callers and refusals. The tester's
|
||||
`tester-dev-only` stays. No new Console route.
|
||||
|
||||
Tests, each in the owning package, whole files:
|
||||
|
||||
- A fire of an instance with a host grant on two hosts and a Gitea grant revokes all three. It records
|
||||
each revoke the way the manual verbs do, and a later host.run or Gitea use by that agent refuses.
|
||||
- The same for the human's delete (row 256's path).
|
||||
- A grant revoke that fails: the fire refuses, the file is unchanged, and the instance and its other
|
||||
grants are as they were or recorded as revoked, per the design. A repeat fire then completes.
|
||||
- An instance with no grant fires exactly as it does today, with no new events.
|
||||
|
||||
Mutants, each killed by a named test:
|
||||
|
||||
- M1. The fire skips the host grant revokes.
|
||||
- M2. The fire skips the Gitea grant revoke.
|
||||
- M3. A failed grant revoke is ignored, so the fire goes on to the write.
|
||||
- M4. The delete path skips grant revokes.
|
||||
|
||||
Gate: the reviewer approves the design and then the manifest. The bus, business and cli suites run
|
||||
whole, with every touched test file whole on its own. The Docker gitea-grant test runs. Every
|
||||
scripts/test-*.sh is green on Sage's gate, and M1-M4 are killed. Any red goes to Sage, and a red Sage
|
||||
can't place goes to Mos.
|
||||
|
||||
Owner Megavolt. Reviewer Dewey. Gate owner Sage.
|
||||
@@ -396,6 +396,12 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
(instances.set launch: false) but neither fire nor delete removes a
|
||||
creative entry (sage-2166). The row covers fire and delete together;
|
||||
Sage files it. (2026-10-21)
|
||||
Closed for delete by row 256 (#1761, REQ-COS-3): `deleteRefusal` takes
|
||||
the actor, so the human deletes a creative instance and any other actor
|
||||
is refused `organization-creative-human-only`, and the listing's
|
||||
endProtected is the same check for its reader. Fire still refuses every
|
||||
END_PROTECTED instance, by the row 256 brief's ruling, not by a code
|
||||
change. (2026-10-22)
|
||||
- **A fire stuck between step A and step B has no human escape.** (Webby
|
||||
N4 on row 194 b5, webby-0094; Sage's ruling sage-2241, #1692.) The
|
||||
broker's chief fire records `fire.stopped` at step A, and `#firing`
|
||||
@@ -425,6 +431,34 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
comes near 64 runs. The follow-up row either raises the cap or splits
|
||||
the done. Its test: a fire's done over the cap refuses, and the runs it
|
||||
left alive still get the alive flag. (2026-10-22)
|
||||
- **A fired or deleted instance keeps its grants.** (Dewey on the row 236
|
||||
rebase look, dewey-0815; Mos mos-1271; Sage sage-2558.) Row 194's
|
||||
`instance.fire` and row 256's delete revoke only the roles entry's
|
||||
credential services through `revoke()` in `runtime.mjs`. The agent's
|
||||
host grants (row 235) and its Gitea user and token (row 236) stay live
|
||||
until a human runs `mosaic hosts revoke` or `mosaic grants revoke`. The
|
||||
Console has no revoke route and doesn't say a grant is left. Row 281
|
||||
(#1786) closes it: a fire or a delete revokes every host and Gitea grant
|
||||
the instance holds, fail closed, with a test for each kind. It lands
|
||||
before the .53 Case 1 install hires an instance with a grant.
|
||||
(2026-10-22)
|
||||
|
||||
- **The runs suite doesn't gate a change to `scripts/mosaic-task.mjs`.**
|
||||
(Dewey N7 on row 191, repeated on the Vikunja 502 review.)
|
||||
`packages/runs/tests/task-cli.test.mjs` spawns `scripts/mosaic-task.mjs`,
|
||||
but row 191's gate didn't run the runs suite. test-task.sh covers the same
|
||||
list and show paths, so nothing is known broken. Binds the next row that
|
||||
changes `scripts/mosaic-task.mjs`: its gate runs the runs suite whole.
|
||||
(2026-10-22)
|
||||
|
||||
- **Row 277's M8 is killed by a rethrown error, not an assertion.**
|
||||
(Duckworth N1 on the row 277 manifest round, duckworth-0127.) M8 makes
|
||||
the thread.claims catch in `packages/cli/src/cli.mjs` rethrow. The stubbed
|
||||
`processing-halted` error then escapes `main` and fails
|
||||
`packages/cli/tests/tester.test.mjs:149`. That counts as a kill, but no
|
||||
test pins that `main` resolves on that path. Binds the next row that
|
||||
changes `packages/cli/tests/tester.test.mjs`: add the resolve pin so M8
|
||||
dies on an assertion. (2026-10-22)
|
||||
|
||||
## Queue
|
||||
|
||||
|
||||
+6
-5
@@ -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 3162 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3194 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -272,7 +272,7 @@ Generated from `docs/plans/queue.json` revision 3162 by `queue render`. Do not e
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | done | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
|
||||
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | in-progress | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | done | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | Landed bd5f11ded on refactor, a fast-forward on be2b008cc. It is 08840f3c1 rebased past 194 (commit 1 unions only, 2-7 =) plus 2 stale-pin fixes and a gitea-not-configured raise test (sage-2559). Dewey approve on manifest 0b00269b. Megavolt gate under Sage CLEAR: 2c green, bus 765, business 228, cli 832, ten scripts rc=0, mutants 40/40; skips named, env-only. Overlap 13/257 clean. Schema 3j-3k. #1742 closed. |
|
||||
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
|
||||
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | done | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
|
||||
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
|
||||
@@ -292,7 +292,7 @@ Generated from `docs/plans/queue.json` revision 3162 by `queue render`. Do not e
|
||||
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | in-progress | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | — |
|
||||
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
|
||||
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
|
||||
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | briefed | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | — |
|
||||
| 256 | the human deletes a creative instance; any other actor refused, fire unchanged (REQ-COS-3, before the v1 tag) | honker; reviewers webby | #1761 | done | 194 done | Webby approves the manifest; touched test files whole plus business, bus, cli and webui suites and every scripts/test-*.sh green on Sage's slot; both mutants killed (sage) | `docs/plans/2026-10-21_creative-delete-human.md` § Brief: the human deletes a creative instance (REQ-COS-3, after row 194) | LANDED f12441d4f ff from 0165be266 (4 Honker commits, patch-id b28e6374). Webby APPROVE manifest-r3 01eb75aa (10 paths), request c30518. Gate 10:33-10:52Z: 4 touched files 31/0, 18 packages 3757/0 (bus 788, business 231, cli 832, webui 335), 10 scripts rc=0, mutants M1-M4b killed; skips named, env only (webui esbuild pin, test-queue root, test-task live x2). Overlap 13/257 clean. Lands before the 10-27 v1 tag. |
|
||||
| 257 | Q198 S1: pressure parser and pure rules evaluator (post-v1) | morgana; reviewers filbert | #1762 | in-review, round 2, request posted | — | Reviewer approves the manifest; touched test files whole plus the package suite green on Sage's slot; both brief mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: Q198 S1 pressure parser and pure rules evaluator | — |
|
||||
| 258 | Email E01: mail PRD addendum draft and schemas (post-v1) | unassigned | #1763 | queued | — | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E01 mail PRD addendum draft and schemas | — |
|
||||
| 259 | Email E03: mail ledger, state and quotas (post-v1) | unassigned | #1764 | queued | 258 done | Reviewer approves the manifest; the row's named tests whole plus the mail suite and touched suites green on Sage's slot; reviewer-named mutants killed (post-v1, MVP keeps slot priority) (sage) | `docs/plans/2026-10-21_post-v1-row-briefs.md` § Brief: E03 mail ledger, state and quotas | — |
|
||||
@@ -310,13 +310,14 @@ Generated from `docs/plans/queue.json` revision 3162 by `queue render`. Do not e
|
||||
| 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. |
|
||||
| 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 | — |
|
||||
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | done | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | LANDED 4c485c63b ff from 47a35bf43 (6 Morgana commits). Dewey APPROVE c30487 on 4c485c63b (manifest 00047b52, 13 paths). Gate 10:09-10:22Z: 23 touched files 0 fail, bus 787, harness 151, business 228, cli 832, seat 167, 10 scripts rc=0 (skips test-queue 1, test-task 2), 22/22 mutants. M3x wall code not-holder (kit comment said human-required). r5 f58cfc361 red, void. Overlap 13/257 clean. |
|
||||
| 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 | — |
|
||||
| 277 | The owner sees a thread agent's claims: an owner or reader read lists held thread claims (10-27, mos-1262; no user thread live before it) | morgana; reviewers duckworth | #1782 | done | 205 done | Duckworth approves the manifest. The touched test files run whole, the bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the owner sees thread claims | LANDED 62f53b1e0 ff from 46dedf925 (7 Morgana commits, range patch-id 7f0533520bd2). Duckworth APPROVE manifest e281aa2c (10 paths), comment 30539. Gate 11:01-11:14Z: 7 touched files 0 fail, bus 793, cli 836, harness 151, business 231, seat 167, 10 scripts rc=0, M1-M9 killed; skips named, env only (test-queue root, test-task live x2). Overlap 13/257 clean. N3 per-session claims carry to 264a r7. |
|
||||
| 278 | The Console Agents view shows thread claims (follow-up to row 277; no user thread live before it) | gander; reviewers webby | #1783 | briefed | 277 done | Webby approves the manifest. The touched test files run whole, the webui and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_owner-thread-claims.md` § Brief: the Console shows thread claims | — |
|
||||
| 279 | stopRun reads a live run's status at the deadline: the deadline branch is the only time exit (191 follow-up, MVP, mos-1269; lands before 194 b16) | rocko; reviewers dewey | #1784 | done | — | Dewey approves the manifest. The processing-control package's tests and every other touched test file run whole, the business and cli suites are green on Sage's gate, and M1 is killed. (sage) | `docs/plans/2026-10-22_stoprun-deadline.md` § Brief: stopRun's deadline branch is the only time exit | LANDED ceadad993 (carry of cad927137 onto 343b6dd76: range-diff 1:= 2:=, patch-ids 6d8ae7f4/e484c297 equal, Dewey APPROVE dewey-0809 c30409 + carry). Fix: stopRun loop is for(;;), deadline branch is the only time exit. Sage gate green: business 209/0/0, cli 794/0/0, discord processing+stop 9/0/0; Rocko stop-run 13/13, processing-control 62/62, M1 killed. Overlap sweep clean (13, 257). Next: 194 b16 rebases onto it (mos-1269). |
|
||||
| 280 | Bus tests: the scratch sshd proves it owns its port (194 gate follow-up, MVP, mos-1270) | rocko; reviewers duckworth | #1785 | done | — | Duckworth approves the manifest. Both files run whole, the bus suite is green on Sage's gate, the 30-run repeat has no timeout, and M1 is killed. Row 194 b17's full gate then runs on top. Any red goes to Mos (mos-1270). (sage) | `docs/plans/2026-10-22_sshd-port-race.md` § Brief: the scratch sshd's port is ours before a test uses it | Landed b0fd28b6f 07:57:30Z, ff from da5d3651d: b765f5cb8 (Duckworth approve rev 3153, manifest 27806c05) + slim packet. Sage gate bus 656/0/0, ge 41, hr 26 (c30431). Repeat 2010/2010 under cli 1588/1588, no timeout; M1/M2 killed 4/4. Full evidence d6c924764 on rocko/row280-da5d-20261022. N2-N4 carry in BUILD.md. 194 b17 rebases on b0fd28b6f. |
|
||||
| 281 | A fire or a delete revokes the instance's host and Gitea grants (194/236 follow-up, MVP, mos-1271) | megavolt; reviewers dewey | #1786 | in-progress | 236 done | Dewey approves the design, then the manifest. Bus, business, cli and every touched test file run whole, plus the Docker gitea-grant test and every scripts/test-*.sh, green on Sage's gate. M1-M4 killed. Lands before .53 Case 1 hires a granted instance. Red goes to Sage, then Mos. (sage) | `docs/plans/2026-10-22_fire-revokes-grants.md` § Brief: no grant outlives its instance | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+1056
-29
File diff suppressed because it is too large
Load Diff
@@ -345,6 +345,17 @@ A new holder doesn't inherit the previous holder's unread deliveries;
|
||||
decisions stay with the role. Unheld and released roles aren't listed, so a
|
||||
message stranded for a role nobody holds shows once a run claims the role.
|
||||
|
||||
Thread claims (row 277, #1782): a thread run's `role.claim` claims its thread
|
||||
in `thread_claims`, which `agents` doesn't read, so `agents` lists roles only.
|
||||
`thread.claims` takes no arguments and lists the held thread claims in the
|
||||
business, oldest thread first, as `{thread, instance, holder_run, harness,
|
||||
at}`. A claim is held while its thread's latest `thread_claims` row is a
|
||||
claim, so a released or revoked thread drops out. Human and reader sessions
|
||||
only: a role session refuses `human-required` and the tester
|
||||
`tester-refused`. The result carries the thread id but never its name, which
|
||||
a user wrote, nor any message. `mosaic agents` prints these as `thread` lines
|
||||
after the roles; `mosaic agents --json` and `--tester` print `agents` alone.
|
||||
|
||||
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
|
||||
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
|
||||
recipient. Message classes are REQUEST, ASSIGNMENT, REVIEW-REQUEST, REVIEW-RESULT,
|
||||
|
||||
+144
-34
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -126,6 +126,12 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
|
||||
'instance.launch', 'instance.end', 'instance.request']);
|
||||
// FED R3 (#414, federation design 3.5): the collaborator level is this exact list. Every other verb
|
||||
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
|
||||
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
|
||||
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
|
||||
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
|
||||
const USER_LEVEL = 'collaborator';
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -320,7 +326,9 @@ function checkBusiness(name, b) {
|
||||
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
for (const role of Object.keys(b.roles)) {
|
||||
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
|
||||
// FED R3 (dewey-0777): a role keyed `user` would be read as the user principal by #sender,
|
||||
// #checkMessaging, a reply's routing and the runner's header.
|
||||
if (!id(role) || role === 'human' || role === 'tester' || role === 'user') fail('invalid-business');
|
||||
const a = b.roles[role].authority;
|
||||
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
|
||||
fail('invalid-authority');
|
||||
@@ -371,6 +379,9 @@ export class Broker {
|
||||
#hostGrant;
|
||||
// Row 235 (#1741, r6 X2 to X5): grantKeys from host-grant.mjs, the executor's calls on Credentials, or null.
|
||||
#hostKeys;
|
||||
// FED R3: () => the checked access document (R1's loadAccess), read once per user request. Null,
|
||||
// a throw or no document leaves every user with no grant (design 3.5, fail closed).
|
||||
#access;
|
||||
// r6 X3: interrupted host grants whose remote undo waits for reconcileHosts, each with its claim.
|
||||
#hostUndo = [];
|
||||
// r6 section 6 (N4): revokes a restart cut short, whose step 3 waits for reconcileHosts.
|
||||
@@ -389,12 +400,13 @@ export class Broker {
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
|
||||
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
|
||||
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
// A Broker is a trusted host startup, including in-process hosts. Never repair an existing state as off.
|
||||
@@ -413,6 +425,7 @@ export class Broker {
|
||||
this.#hostKeys = hostKeys;
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#access = access;
|
||||
this.#project = project;
|
||||
this.#library = library;
|
||||
this.#usage = usage;
|
||||
@@ -684,7 +697,7 @@ export class Broker {
|
||||
if (s.thread) return s.thread;
|
||||
if (!this.#threaded(s.business, to)) return null;
|
||||
// The user names the thread with thread.send, which checks the owner (REQ-USER-2).
|
||||
if (s.human) fail('thread-required-use-thread-send');
|
||||
if (s.human || s.user) fail('thread-required-use-thread-send');
|
||||
const seen = new Set();
|
||||
for (let at = inReplyTo ?? null; at !== null && !seen.has(at); ) {
|
||||
seen.add(at);
|
||||
@@ -723,7 +736,35 @@ export class Broker {
|
||||
}
|
||||
// Row 177 (#1675): who owns the threads a session makes. A tester's are `tester`'s, never the human's.
|
||||
#owner(s) {
|
||||
return s.tester ? 'tester' : s.human;
|
||||
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
|
||||
}
|
||||
// FED R3 (design 3.5): the user's live grants in the session's business, from one read of the
|
||||
// access file. Any failure to read leaves none.
|
||||
#userGrants(s) {
|
||||
let doc = null;
|
||||
try {
|
||||
doc = this.#access?.() ?? null;
|
||||
} catch {
|
||||
doc = null;
|
||||
}
|
||||
const now = new Date();
|
||||
return (doc?.grants ?? []).filter((g) => g.user === s.user && g.business === s.business &&
|
||||
covers(g, { business: s.business, project: g.projects[0], instance: g.instances[0] }, now, { level: USER_LEVEL }));
|
||||
}
|
||||
// Does one of the request's grants cover a thread of `instance` in `project`? A null `instance`
|
||||
// asks about the project alone, a null `project` about the instance alone.
|
||||
#granted(s, project, instance) {
|
||||
const now = new Date();
|
||||
return s.grants.some((g) => covers(g, {
|
||||
business: s.business,
|
||||
project: project ?? g.projects[0],
|
||||
instance: instance ?? g.instances[0],
|
||||
}, now, { level: USER_LEVEL }));
|
||||
}
|
||||
// A thread the request's grants still cover, else access-refused (design 3.5, thread.open/send/messages).
|
||||
#grantedThread(s, t) {
|
||||
if (s.user && !this.#granted(s, t.project, t.instance)) fail('access-refused');
|
||||
return t;
|
||||
}
|
||||
// Row 205 (193b1a, #1703, row 193 design 5.7): a message in a thread a user other than the caller
|
||||
// owns is hidden from trail and replies unless #readable holds. The business human's and the
|
||||
@@ -928,6 +969,19 @@ export class Broker {
|
||||
if (this.#business(record.business).vars?.['bus.tester'] !== true) fail('tester-off');
|
||||
return this.#cap({ business: record.business, tester: true, role: 'tester', run: null, human: false });
|
||||
}
|
||||
// FED R3 (#414, design 3.6): the trusted user transport invokes this only after row 196's
|
||||
// verifyPrincipal checked the user CLI and its Console parent. It binds only a user the access
|
||||
// file gives a live collaborator grant in the business (check 3), else access-revoked. A user id
|
||||
// is never the bus's own principals' or the business human's, whatever the file says.
|
||||
bindUser(record) {
|
||||
keys(record, ['business', 'user'], ['business', 'user']);
|
||||
const b = this.#business(record.business);
|
||||
if (typeof record.user !== 'string' || !ID_PATTERN.test(record.user) ||
|
||||
['human', 'tester', 'user', b.human].includes(record.user)) fail('access-refused');
|
||||
const s = { business: record.business, user: record.user, role: null, run: null, human: false };
|
||||
if (!this.#userGrants(s).length) fail('access-revoked');
|
||||
return this.#cap(s);
|
||||
}
|
||||
bindReader({ business }) {
|
||||
this.#business(business);
|
||||
return this.#cap({ business, reader: true, role: null, run: null, human: false });
|
||||
@@ -953,8 +1007,8 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
kind,
|
||||
s.role,
|
||||
s.run,
|
||||
s.user ? 'user' : s.role,
|
||||
s.user ? 'user:' + s.user : s.run,
|
||||
subject,
|
||||
JSON.stringify(body),
|
||||
);
|
||||
@@ -2705,8 +2759,9 @@ export class Broker {
|
||||
// check its route as the sender of the close message; `refused` replaces both codes when given.
|
||||
#checkMessaging(business, from, to, refused) {
|
||||
const b = this.#business(business);
|
||||
const person = (who) => who === 'human' || who === 'tester';
|
||||
const name = (who) => (person(who) ? who : b.roles[who]?.definition);
|
||||
const person = (who) => who === 'human' || who === 'tester' || who === 'user';
|
||||
// FED R3: the role files' lists name no user, so a user is read as the human.
|
||||
const name = (who) => (who === 'user' ? 'human' : person(who) ? who : b.roles[who]?.definition);
|
||||
const lists = (who) => (person(who) ? undefined : b.definitions?.[name(who)]?.messaging);
|
||||
const out = lists(from);
|
||||
if (out && !out.sendTo.includes(name(to))) fail(refused ?? 'message-recipient-refused');
|
||||
@@ -2714,7 +2769,12 @@ export class Broker {
|
||||
if (inbound && !inbound.receiveFrom.includes(name(from))) fail(refused ?? 'message-sender-refused');
|
||||
}
|
||||
#sender(s) {
|
||||
return s.human ? 'human' : s.tester ? 'tester' : s.role;
|
||||
return s.human ? 'human' : s.tester ? 'tester' : s.user ? 'user' : s.role;
|
||||
}
|
||||
// FED R3 (design 3.6 item 7): a run of a thread a user owns. It answers that user, never the human.
|
||||
#userThread(s) {
|
||||
return !s.human && !s.tester && !s.user && !!s.thread
|
||||
&& Boolean(this.#store.get('SELECT owner FROM threads WHERE id=?', s.thread)?.owner?.startsWith('user:'));
|
||||
}
|
||||
// Row 177 (#1675): a run of a thread the tester owns. It answers the tester, never the human.
|
||||
#testerThread(s) {
|
||||
@@ -3523,6 +3583,14 @@ export class Broker {
|
||||
try {
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
string(request.verb, 64);
|
||||
// FED R3 (design 3.5, 3.7 trigger 1): a user reaches the collaborator list only, and each
|
||||
// request reads the access file again, so a removed or expired grant refuses the next one.
|
||||
let grants = null;
|
||||
if (s.user) {
|
||||
if (!USER_VERBS.includes(request.verb)) fail('access-refused');
|
||||
grants = this.#userGrants(s);
|
||||
if (!grants.length) fail('access-revoked');
|
||||
}
|
||||
const args = request.args ?? {};
|
||||
if (!object(args)) fail('invalid-request');
|
||||
this.#secretCheck(args);
|
||||
@@ -3637,14 +3705,14 @@ export class Broker {
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
|
||||
if (s.reader && !['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage'].includes(request.verb))
|
||||
fail('read-only');
|
||||
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
|
||||
// A tester reads the human's views, as a reader does, and its message verbs are its own.
|
||||
// Since row 175 the view also carries the writes name.set and project.create, and since row 177
|
||||
// thread.create, thread.open, thread.send, thread.messages and assistant.stop. None of their
|
||||
// branches checks `reader`: each passes a tester by `s.tester`, and each write records `tester`.
|
||||
const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s;
|
||||
const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s.user ? { ...s, grants } : s;
|
||||
const result = this.#dispatch(view, request.verb, args);
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
@@ -3773,7 +3841,8 @@ export class Broker {
|
||||
// The shape of hire and fire arguments.
|
||||
#hireFireArgs(action, args) {
|
||||
identifier(args.name);
|
||||
if (args.name === 'human' || args.name === 'tester') fail('invalid-request');
|
||||
// FED R3 r4 (sage-2557): `user` is the granted user's principal, as checkBusiness refuses it.
|
||||
if (args.name === 'human' || args.name === 'tester' || args.name === 'user') fail('invalid-request');
|
||||
string(args.reason, 500);
|
||||
if (action === 'role.fire') return;
|
||||
identifier(args.definition);
|
||||
@@ -4028,6 +4097,7 @@ export class Broker {
|
||||
async requestTask(cap, request, handler) {
|
||||
const s = this.#session(cap);
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -4065,6 +4135,7 @@ export class Broker {
|
||||
};
|
||||
let args;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
this.#processingWork();
|
||||
if (!verb) fail('unknown-verb');
|
||||
@@ -4123,6 +4194,7 @@ export class Broker {
|
||||
};
|
||||
let a, entry, timeout, bound;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -4526,7 +4598,7 @@ export class Broker {
|
||||
// key, and the message records both. name.set refuses a name equal to a key or a role, but an
|
||||
// instance or definition added later can take a word some instance holds as its name. The key
|
||||
// or the role wins: a send to a definition no instance is keyed by refuses unknown-role.
|
||||
const named = typeof a.to === 'string' && !Object.hasOwn(b.roles, a.to) && a.to !== 'human' && a.to !== 'tester'
|
||||
const named = typeof a.to === 'string' && !Object.hasOwn(b.roles, a.to) && !['human', 'tester', 'user'].includes(a.to)
|
||||
&& !this.#definitions(b).has(a.to)
|
||||
? this.#named(s.business, a.to) : null;
|
||||
const to = named ?? a.to;
|
||||
@@ -4547,15 +4619,29 @@ export class Broker {
|
||||
!(id(a.in_reply_to) && this.#store.get("SELECT 1 FROM messages WHERE id=? AND business=? AND from_role='tester'", a.in_reply_to, s.business))
|
||||
)
|
||||
fail('tester-reply-required');
|
||||
// FED R3 (design 3.6 item 7, B6): a run answers a user only in reply to that user's message
|
||||
// in its own thread, the thread the user owns. Anything else, another user's message or one
|
||||
// outside the thread included, refuses.
|
||||
if (to === 'user') {
|
||||
const m = id(a.in_reply_to) && s.thread
|
||||
? this.#store.get(
|
||||
"SELECT m.from_run FROM messages m JOIN message_threads mt ON mt.message=m.id JOIN threads t ON t.id=mt.thread WHERE m.id=? AND m.business=? AND m.from_role='user' AND mt.thread=? AND t.owner=m.from_run",
|
||||
a.in_reply_to, s.business, s.thread)
|
||||
: null;
|
||||
if (!m) fail('user-reply-required');
|
||||
}
|
||||
} else {
|
||||
this.#human(s);
|
||||
if (to === 'tester') fail('unknown-role');
|
||||
if (to === 'tester' || to === 'user') fail('unknown-role');
|
||||
}
|
||||
if (to !== 'human' && to !== 'tester' && !Object.hasOwn(b.roles, to)) fail('unknown-role');
|
||||
if (to !== 'human' && to !== 'tester' && to !== 'user' && !Object.hasOwn(b.roles, to)) fail('unknown-role');
|
||||
// Row 177 (#1675): a run of a tester's thread answers the tester, never the human, and the
|
||||
// lists read the tester there as the human, so the run reaches it as far as it reaches the human.
|
||||
const testerThread = this.#testerThread(s);
|
||||
if (testerThread && to === 'human') fail('tester-thread-human');
|
||||
// FED R3 (design 3.6 item 7): a run of a user's thread never messages the owner. Its gated
|
||||
// decisions still route to the owner (B9, sage-1368), so decision.raise has no such check.
|
||||
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
|
||||
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
|
||||
string(a.body, 32768);
|
||||
if (
|
||||
@@ -4858,6 +4944,18 @@ export class Broker {
|
||||
if (verb === 'tasks') return this.#tasks(s.business, a);
|
||||
return this.#trail(s, identifier(a.subject));
|
||||
}
|
||||
// Row 277 (#1782, design D1): the thread claims held in the business, for human and reader
|
||||
// sessions only. A thread run's role.claim writes thread_claims, which `agents` doesn't read,
|
||||
// so this is where the owner sees it (PRD :1083-1084). A claim is held when its thread's latest
|
||||
// thread_claims row is a claim, as #threadHolders reads it. No thread name: a user wrote that.
|
||||
case 'thread.claims': {
|
||||
if (!s.human && !s.reader) fail('human-required');
|
||||
keys(a, [], []);
|
||||
return this.#store.all(
|
||||
"SELECT t.id AS thread, t.instance, c.holder_run, c.harness, c.at FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND c.op='claim' ORDER BY t.seq",
|
||||
s.business,
|
||||
);
|
||||
}
|
||||
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
|
||||
// reader sessions only. Each row is inbox's view of the decision: the `decisions` row trail
|
||||
// gives the same session for that id, with blocking as a boolean, plus `authorization` from
|
||||
@@ -4949,7 +5047,7 @@ export class Broker {
|
||||
if (s.human) this.#human(s);
|
||||
if (!Object.hasOwn(b.roles, identifier(a.instance))) fail('unknown-role');
|
||||
if (typeof a.name !== 'string' || !ID_PATTERN.test(a.name)) fail('invalid-request');
|
||||
if (Object.hasOwn(b.roles, a.name) || a.name === 'human' || a.name === 'tester') fail('name-is-instance');
|
||||
if (Object.hasOwn(b.roles, a.name) || ['human', 'tester', 'user'].includes(a.name)) fail('name-is-instance');
|
||||
if (this.#definitions(b).has(a.name)) fail('name-is-role');
|
||||
const holder = this.#named(s.business, a.name);
|
||||
if (holder && holder !== a.instance) fail('name-taken');
|
||||
@@ -5035,11 +5133,12 @@ export class Broker {
|
||||
}
|
||||
case 'names': {
|
||||
keys(a, []);
|
||||
if (!s.human && !s.tester) this.#agent(s);
|
||||
// FED R3 (design 3.5): a user sees the names of granted instances only.
|
||||
if (!s.human && !s.tester && !s.user) this.#agent(s);
|
||||
return this.#store.all(
|
||||
'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance',
|
||||
s.business,
|
||||
);
|
||||
).filter((r) => !s.user || this.#granted(s, null, r.instance));
|
||||
}
|
||||
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
|
||||
// project from the business file; the rename to repos is a P19 follow-up.
|
||||
@@ -5064,16 +5163,25 @@ export class Broker {
|
||||
return { id: pid };
|
||||
}
|
||||
case 'project.list': {
|
||||
if (!s.tester) this.#human(s);
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, []);
|
||||
return this.#store.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business);
|
||||
// FED R3 (design 3.5): a user lists granted projects only.
|
||||
return this.#store
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, p.id, null));
|
||||
}
|
||||
// A thread belongs to the user who made it and to the business's one thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester) this.#human(s);
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project', 'name'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
if (threaded.length !== 1 || !this.#granted(s, identifier(a.project), threaded[0])) fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
// The bus never picks between instances; the business file declares exactly one.
|
||||
@@ -5097,9 +5205,10 @@ export class Broker {
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
case 'thread.list': {
|
||||
if (!s.tester) this.#human(s);
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project']);
|
||||
if (a.project !== undefined) identifier(a.project);
|
||||
// FED R3 (design 3.5): a user's own threads that its grants still cover.
|
||||
return this.#store.all(
|
||||
'SELECT id,at,project,instance,name FROM threads WHERE business=? AND owner=?' +
|
||||
(a.project === undefined ? '' : ' AND project=?') +
|
||||
@@ -5107,22 +5216,22 @@ export class Broker {
|
||||
s.business,
|
||||
this.#owner(s),
|
||||
...(a.project === undefined ? [] : [a.project]),
|
||||
);
|
||||
).filter((t) => !s.user || this.#granted(s, t.project, t.instance));
|
||||
}
|
||||
// Opening a thread asks the launcher to start its session (REQ-LAUNCH-1).
|
||||
case 'thread.open': {
|
||||
if (!s.tester) this.#human(s);
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread'], ['thread']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
this.#store.run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', this.#now(), t.id, this.#owner(s));
|
||||
return { opened: t.id };
|
||||
}
|
||||
// Row 177 (#1675): a tester's message is the tester's, from tester-cli, and writes no
|
||||
// human.input, as on message.send. In its own thread the lists read it as the human.
|
||||
case 'thread.send': {
|
||||
if (!s.tester) this.#human(s);
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread', 'body', 'class'], ['thread', 'body']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
// Row 120's lists hold here as on message.send: the instance must hear from the human.
|
||||
this.#checkMessaging(s.business, 'human', t.instance);
|
||||
string(a.body, 32768);
|
||||
@@ -5133,8 +5242,8 @@ export class Broker {
|
||||
mid,
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.tester ? 'tester' : 'human',
|
||||
s.tester ? 'tester-cli' : 'human-cli',
|
||||
s.tester ? 'tester' : s.user ? 'user' : 'human',
|
||||
s.tester ? 'tester-cli' : s.user ? this.#owner(s) : 'human-cli',
|
||||
t.instance,
|
||||
a.class ?? 'INFO',
|
||||
null,
|
||||
@@ -5143,13 +5252,14 @@ export class Broker {
|
||||
a.body,
|
||||
);
|
||||
this.#route(s.business, mid, t.instance, t.id);
|
||||
return { id: mid, request: s.tester ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
|
||||
// A user's message, like a tester's, is never the owner's authority evidence (design 3.5).
|
||||
return { id: mid, request: s.tester || s.user ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
|
||||
}
|
||||
// The thread's history, oldest first. Reading writes nothing.
|
||||
case 'thread.messages': {
|
||||
if (!s.tester) this.#human(s);
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['thread', 'after', 'limit'], ['thread']);
|
||||
const t = this.#thread(s, a.thread);
|
||||
const t = this.#grantedThread(s, this.#thread(s, a.thread));
|
||||
const limit = a.limit ?? THREAD_PAGE;
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
|
||||
let after = 0;
|
||||
@@ -5306,7 +5416,7 @@ export class Broker {
|
||||
// launch.instances, so the sender learns at once that no launch can follow. launch.by is
|
||||
// still told: the Console shows the notice as the reply to the human's message.
|
||||
#waiting(s, mid, to, thread) {
|
||||
if (to === 'human' || to === 'tester' || (thread && this.#threaded(s.business, to))) return null;
|
||||
if (to === 'human' || to === 'tester' || to === 'user' || (thread && this.#threaded(s.business, to))) return null;
|
||||
if (this.#recipient(s.business, to, thread)) return null;
|
||||
const launch = this.#business(s.business).launch;
|
||||
const by = launch?.by ?? null;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { connect } from 'node:net';
|
||||
import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
export class Client {
|
||||
constructor({ path, cap, human, tester, timeout = 5000 }) {
|
||||
constructor({ path, cap, human, tester, user, timeout = 5000 }) {
|
||||
this.path = path;
|
||||
this.cap = cap;
|
||||
this.human = human;
|
||||
this.tester = tester;
|
||||
this.user = user;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
call(verb, args = {}) {
|
||||
const request =
|
||||
JSON.stringify({
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : { cap: this.cap }),
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }),
|
||||
verb,
|
||||
args,
|
||||
}) + '\n';
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 { loadAccess } from '../../business/src/access.mjs';
|
||||
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
@@ -98,7 +99,12 @@ export async function startBroker({
|
||||
// slot, { command, intervalMs, configDir }. A boot config is JSON and can't carry a function, so
|
||||
// a librarySync without one is refused.
|
||||
librarySync = null,
|
||||
// FED R3 (#414): the access file and its loader context, { file, context } (R1's loadAccess).
|
||||
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet.
|
||||
access = null,
|
||||
}) {
|
||||
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'))
|
||||
throw new BusError('invalid-host-request');
|
||||
if (librarySync !== null && (typeof librarySync?.command !== 'function'
|
||||
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
||||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||||
@@ -253,6 +259,8 @@ export async function startBroker({
|
||||
}
|
||||
}
|
||||
broker = new Broker({
|
||||
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
|
||||
access: access ? () => loadAccess(access.file, access.context) : null,
|
||||
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,
|
||||
@@ -283,11 +291,12 @@ export async function startBroker({
|
||||
organization:settings?(id,verb,args,actor,context)=>{
|
||||
if(verb==='limits')return withTokens(id,settings.read(id));
|
||||
if(verb==='plugins')return plugins.read(id);
|
||||
if(verb==='instances')return instances.read(id);
|
||||
// Row 256 (#1761): the actor is the reader, so endProtected is the delete check for that reader.
|
||||
if(verb==='instances')return instances.read(id,actor);
|
||||
// Row 65 (mos-0840): the Console's routing table.
|
||||
if(verb==='routes')return settings.routes(id);
|
||||
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
|
||||
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
|
||||
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
|
||||
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
|
||||
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
|
||||
let result;
|
||||
@@ -405,6 +414,11 @@ export async function startBroker({
|
||||
const p = verifyPrincipal({ tester: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
|
||||
return broker.bindTester({ business: p.business });
|
||||
},
|
||||
// FED R3 (design 3.6): checks 1 and 2 are row 196's user kind; check 3, the live grant, is bindUser's.
|
||||
authenticateUser: (proof, peer) => {
|
||||
const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
|
||||
return broker.bindUser({ business: p.business, user: p.id });
|
||||
},
|
||||
});
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// FED R3 (#414, design 3.6): the granted user's transport shim, human-cli.mjs with its own nonce
|
||||
// variable, a `user` in its input and a user proof. Only the Console's tree runs it (row 196's user
|
||||
// kind checks the parent). Not a second product CLI. Input and proof never carry service tokens.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Client } from './client.mjs';
|
||||
import { readProcess } from './human.mjs';
|
||||
const file = fileURLToPath(import.meta.url);
|
||||
if (!process.env.MOSAIC_BUS_USER_NONCE) {
|
||||
const child = spawnSync(process.execPath, [file, ...process.argv.slice(2)], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') },
|
||||
});
|
||||
process.exit(child.status ?? 2);
|
||||
}
|
||||
try {
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
typeof request.user !== 'string' ||
|
||||
Object.keys(request).some((k) => !['business', 'user', 'verb', 'args'].includes(k))
|
||||
)
|
||||
throw Error();
|
||||
const me = readProcess(process.pid);
|
||||
const client = new Client({
|
||||
path: process.argv[2],
|
||||
user: {
|
||||
business: request.business,
|
||||
user: request.user,
|
||||
pid: me.pid,
|
||||
startTime: me.startTime,
|
||||
nonce: process.env.MOSAIC_BUS_USER_NONCE,
|
||||
},
|
||||
});
|
||||
const result = await client.call(request.verb, request.args ?? {});
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} catch (e) {
|
||||
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -123,7 +123,7 @@ async function runtime(t) {
|
||||
const history = join(root, 'businesses/.history/demo/changes.jsonl');
|
||||
const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []);
|
||||
const bytes = () => readFileSync(file, 'utf8');
|
||||
return { human, tester, chief, call, log, bytes };
|
||||
return { bus, human, tester, chief, call, log, bytes };
|
||||
}
|
||||
|
||||
test('M10 through the broker: the chief and the tester are refused on bean, the human\'s limits.set of its models key works', async (t) => {
|
||||
@@ -151,3 +151,30 @@ test('M10 through the broker: the chief and the tester are refused on bean, the
|
||||
assert.equal(JSON.parse(bytes()).launch.models['claude-sonnet-5-5'].model, 'claude-sonnet-5-5-next');
|
||||
assert.deepEqual(log().filter((e) => e.state === 'committed').map((e) => e.actor), ['jason']);
|
||||
});
|
||||
|
||||
// Row 256 (#1761, REQ-COS-3, T6): the human deletes bean through the verb and the launcher's pass, on
|
||||
// the real writer, so the actor reaches the delete check at both. The chief is refused human-required
|
||||
// and the tester tester-refused at the verb, so neither reaches the writer. Each reader's instances
|
||||
// view carries its own endProtected.
|
||||
test('row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb', async (t) => {
|
||||
const { bus, human, tester, chief, call, log, bytes } = await runtime(t);
|
||||
assert.equal(call(human, 'instances').instances.bean.endProtected, false);
|
||||
assert.equal(call(tester, 'instances').instances.bean.endProtected, true);
|
||||
const { revision } = call(human, 'instances');
|
||||
const before = bytes();
|
||||
assert.throws(() => call(chief, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'human-required' });
|
||||
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'delete' }), { code: 'tester-refused' });
|
||||
assert.equal(bytes(), before);
|
||||
// The verb's check runs instances.check with the human as actor, and records the request.
|
||||
const { request } = call(human, 'instances.set', { revision, instance: 'bean', op: 'delete' });
|
||||
assert.equal(bytes(), before);
|
||||
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
// The launcher's pass commits the delete with the request's by, the human.
|
||||
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
|
||||
const file = JSON.parse(bytes());
|
||||
assert.equal(Object.hasOwn(file.roles, 'bean'), false);
|
||||
assert.deepEqual(file.retired, ['bean']);
|
||||
const committed = log().filter((e) => e.state === 'committed');
|
||||
assert.deepEqual(committed.map((e) => e.actor), ['jason']);
|
||||
assert.equal(committed[0].transaction, transaction);
|
||||
});
|
||||
|
||||
@@ -172,11 +172,9 @@ test('governance pin: the owner sees decisions, inbox entries, claims, tasks and
|
||||
|
||||
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');
|
||||
// Row 277 (#1782): the owner's thread.claims lists the run's held thread claim.
|
||||
const claim = (await f.call(f.human, 'thread.claims')).find((x) => x.thread === U);
|
||||
assert.equal(claim?.holder_run, 'assistant-run', 'claims: the run claim shows to the owner');
|
||||
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' });
|
||||
|
||||
@@ -31,7 +31,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// Row 213 (#1714) reads CREATIVE_DEFINITIONS for the creative raise refusal (M6). Row 194
|
||||
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl } from '../../business/src/vocabulary.mjs';",
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -259,10 +259,13 @@ test('the runtime refuses an invalid adapter and closes a valid one', async (t)
|
||||
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');
|
||||
await runtime.close();
|
||||
try {
|
||||
// Row 191: runtime injects cancellation alongside the existing factory dependencies.
|
||||
assert.deepEqual(given, ['broker', 'businesses', 'credentials', 'processing']);
|
||||
assert.equal(typeof processing.work, 'function');
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
assert.equal(closed, 5);
|
||||
await assert.rejects(
|
||||
startBroker({
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Row 277 (#1782, design D1, duckworth-0118): `thread.claims` gives human and reader sessions the
|
||||
// thread claims held in the business. A thread run's role.claim writes thread_claims, which `agents`
|
||||
// doesn't read, so this is where the owner sees it. `agents` is unchanged for every session.
|
||||
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 } from '../src/broker.mjs';
|
||||
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const within = (grants) => ({ authority: { withinRole: grants, crossRole: [] } });
|
||||
const ROW = ['thread', 'instance', 'holder_run', 'harness', 'at'];
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-thread-claims-'));
|
||||
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 chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
reader: b.bindReader({ business: 'demo' }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
chief,
|
||||
};
|
||||
// `who` is a session name from caps, or a run's capability.
|
||||
const call = (who, verb, args = {}) => b.request(Object.hasOwn(caps, who) ? caps[who] : who, { verb, args });
|
||||
const project = call('human', 'project.create', { name: 'shared work' }).id;
|
||||
// The human's own thread, so the human's assistant.stop reaches its run.
|
||||
const thread = (name) => {
|
||||
const id = randomUUID();
|
||||
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', project, 'assistant', 'jason', name);
|
||||
return id;
|
||||
};
|
||||
const claim = (H, run) => {
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: H });
|
||||
call(cap, 'role.claim');
|
||||
return cap;
|
||||
};
|
||||
return { b, store, caps, call, thread, claim };
|
||||
}
|
||||
|
||||
test('T1: the human and the reader see a held thread claim; agents still lists the role only', (t) => {
|
||||
const { store, call, thread, claim } = setup(t);
|
||||
const H = thread('plan');
|
||||
claim(H, 'assistant-run');
|
||||
const at = store.get("SELECT at FROM thread_claims WHERE thread=? AND op='claim'", H).at;
|
||||
const rows = call('human', 'thread.claims');
|
||||
// The store's rows have no prototype, so each is copied before the strict compare.
|
||||
assert.deepEqual(rows.map((r) => ({ ...r })), [{ thread: H, instance: 'assistant', holder_run: 'assistant-run', harness: 'pi', at }]);
|
||||
for (const r of rows) assert.deepEqual(Object.keys(r), ROW);
|
||||
assert.deepEqual(call('reader', 'thread.claims'), rows);
|
||||
assert.deepEqual(call('human', 'agents').map((r) => r.role), ['chief']);
|
||||
});
|
||||
|
||||
test('T2: a released or revoked thread claim is not listed, though its thread has an earlier claim row', (t) => {
|
||||
const { store, call, thread, claim } = setup(t);
|
||||
const H = thread('plan');
|
||||
const first = claim(H, 'run-1');
|
||||
call(first, 'role.release');
|
||||
assert.deepEqual(call('human', 'thread.claims'), [], 'released');
|
||||
claim(H, 'run-2');
|
||||
assert.deepEqual(call('human', 'thread.claims').map((r) => r.holder_run), ['run-2'], 'claimed again');
|
||||
call('human', 'assistant.stop', { instance: 'assistant' });
|
||||
assert.deepEqual(call('human', 'thread.claims'), [], 'revoked');
|
||||
// The thread's first row is still a claim, so only the latest row decides.
|
||||
assert.deepEqual(store.all('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq', H).map((r) => r.op), ['claim', 'release', 'claim', 'revoke']);
|
||||
});
|
||||
|
||||
test('T3: a role session refuses human-required and the tester refuses tester-refused', (t) => {
|
||||
const { call, thread, claim } = setup(t);
|
||||
const run = claim(thread('plan'), 'assistant-run');
|
||||
for (const who of ['chief', run]) assert.throws(() => call(who, 'thread.claims'), { code: 'human-required' });
|
||||
assert.throws(() => call('tester', 'thread.claims'), { code: 'tester-refused' });
|
||||
});
|
||||
|
||||
test('T4: agents answers each session the same before and after a thread run claims', (t) => {
|
||||
const { call, thread, claim } = setup(t);
|
||||
const sessions = ['human', 'reader', 'tester', 'chief'];
|
||||
const before = Object.fromEntries(sessions.map((who) => [who, call(who, 'agents')]));
|
||||
claim(thread('plan'), 'assistant-run');
|
||||
assert.equal(call('human', 'thread.claims').length, 1);
|
||||
for (const who of sessions) assert.deepEqual(call(who, 'agents'), before[who], who);
|
||||
});
|
||||
|
||||
test('T5: the result carries neither the thread name a user wrote nor a message body', (t) => {
|
||||
const { store, call, thread, claim } = setup(t);
|
||||
const H = thread('alice secret thread name');
|
||||
claim(H, 'assistant-run');
|
||||
// The thread's message, written to the store as message-filter.test.mjs's put() writes one.
|
||||
const m = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
m, AT, 'demo', 'assistant', 'assistant-run', 'human', 'INFO', null, null, null, 'alice secret message body',
|
||||
);
|
||||
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', m, H);
|
||||
const text = JSON.stringify(call('human', 'thread.claims'));
|
||||
assert.ok(text.includes(H), 'the thread row is there');
|
||||
assert.doesNotMatch(text, /secret/);
|
||||
});
|
||||
@@ -0,0 +1,384 @@
|
||||
// FED R3 (#414, federation design 3.5, 3.6 and the R3 row of section 7): the broker's user principal.
|
||||
// A user binds only with a live collaborator grant, reaches the exact collaborator verb list, sees
|
||||
// only what its grants cover, and talks to the granted instance in its own threads. The access
|
||||
// document comes in as a function, as the runtime passes R1's loadAccess; each test changes it.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import {
|
||||
Broker, ACTIONS, USER_VERBS, TESTER_VERBS, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, CREDENTIAL_VERBS,
|
||||
PROJECT_FILE_VERBS, LIBRARY_VERBS, TESTER_ORGANIZATION_VERBS, GRANTS_VERBS, LIFECYCLE_HUMAN_VERBS,
|
||||
} from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const grant = (over = {}) => ({
|
||||
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
|
||||
});
|
||||
|
||||
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
||||
function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
// The document a test edits between requests; `reads` counts the loader calls.
|
||||
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants }, reads: 0, fail: false };
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
},
|
||||
...(processing ? { processing } : {}),
|
||||
access: () => {
|
||||
access.reads++;
|
||||
if (access.fail) throw new Error('access file refused');
|
||||
return access.doc;
|
||||
},
|
||||
});
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
};
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const code = (who, verb, args = {}) => {
|
||||
try {
|
||||
call(who, verb, args);
|
||||
} catch (e) {
|
||||
return e.code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const user = (name, as = name) => (caps[as] = b.bindUser({ business: 'demo', user: name }));
|
||||
const launch = (name, thread) => {
|
||||
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
|
||||
return call(name, 'role.claim');
|
||||
};
|
||||
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
|
||||
return { b, store, access, caps, call, code, user, launch, inputs };
|
||||
}
|
||||
|
||||
// Two projects, alice granted only `p1`, and alice's thread A in it.
|
||||
function granted(t, options) {
|
||||
const ctx = setup(t, options);
|
||||
const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
const p2 = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
ctx.access.doc.grants = [grant({ projects: [p1] })];
|
||||
ctx.user('alice');
|
||||
const A = ctx.call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
|
||||
return { ...ctx, p1, p2, A };
|
||||
}
|
||||
|
||||
test('R3: bindUser binds a granted user and refuses one with no live collaborator grant', (t) => {
|
||||
const { b, access, user } = setup(t);
|
||||
assert.ok(user('alice'));
|
||||
for (const [what, grants] of [
|
||||
['no grant', []],
|
||||
['another user', [grant({ user: 'bob' })]],
|
||||
['another business', [grant({ business: 'other' })]],
|
||||
['expired', [grant({ expires: '2000-01-01T00:00:00Z' })]],
|
||||
['viewer level', [grant({ level: 'viewer' })]],
|
||||
]) {
|
||||
access.doc.grants = grants;
|
||||
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, what);
|
||||
}
|
||||
access.doc.grants = [grant()];
|
||||
access.fail = true;
|
||||
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, 'unreadable file');
|
||||
access.fail = false;
|
||||
access.doc = null;
|
||||
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice' }), { code: 'access-revoked' }, 'no file');
|
||||
assert.throws(() => b.bindUser({ business: 'demo', user: 'alice', extra: 1 }), { code: 'invalid-request' });
|
||||
});
|
||||
|
||||
test('R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say', (t) => {
|
||||
const { b, access } = setup(t);
|
||||
for (const name of ['jason', 'human', 'tester', 'user']) {
|
||||
access.doc.grants = [grant({ user: name })];
|
||||
assert.throws(() => b.bindUser({ business: 'demo', user: name }), { code: 'access-refused' }, name);
|
||||
}
|
||||
access.doc.grants = [grant({ user: 'Alice!' })];
|
||||
assert.throws(() => b.bindUser({ business: 'demo', user: 'Alice!' }), { code: 'access-refused' });
|
||||
});
|
||||
|
||||
test('R3: each collaborator verb is allowed to a granted user', (t) => {
|
||||
const { call, store, p1, A } = granted(t);
|
||||
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1]);
|
||||
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
|
||||
assert.deepEqual(call('alice', 'thread.open', { thread: A }), { opened: A });
|
||||
const sent = call('alice', 'thread.send', { thread: A, body: 'hello' });
|
||||
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent.id]);
|
||||
assert.ok(Array.isArray(call('alice', 'names')));
|
||||
assert.equal(store.get('SELECT owner FROM threads WHERE id=?', A).owner, 'user:alice');
|
||||
assert.equal(store.get('SELECT by FROM thread_opens WHERE thread=?', A).by, 'user:alice');
|
||||
});
|
||||
|
||||
test('R3: every verb off the collaborator list refuses a user access-refused before its own checks', (t) => {
|
||||
const { b, caps } = granted(t);
|
||||
const others = new Set([
|
||||
...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...LIBRARY_VERBS, ...CREDENTIAL_VERBS, ...HOSTS_VERBS,
|
||||
// R3 r6 (sage-2576): the grants, lifecycle, web and host lists, each refused at the user gate too.
|
||||
...GRANTS_VERBS, ...LIFECYCLE_HUMAN_VERBS, ...WEB_VERBS, ...HOST_VERBS,
|
||||
'decision.raise', 'decision.resolve', 'decision.seen', 'role.claim', 'role.release', 'role.end', 'record.write', 'spec.read',
|
||||
'usage.begin', 'usage.end', 'limits.set', 'organization.set', 'organization.revision', 'review.request', 'review.verdict',
|
||||
'staff.request', 'state', 'input', 'launch', 'no.such.verb',
|
||||
]);
|
||||
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
|
||||
// rather than leaving the set below with the verb it added (M3x, M6x).
|
||||
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
|
||||
for (const v of USER_VERBS) others.delete(v);
|
||||
assert.ok(others.size > 40);
|
||||
for (const verb of others) {
|
||||
// Bad arguments on purpose: the refusal comes before any argument check.
|
||||
assert.throws(() => b.request(caps.alice, { verb, args: { junk: true } }), { code: 'access-refused' }, verb);
|
||||
}
|
||||
});
|
||||
|
||||
test('R3: task, web and host verbs refuse a user access-refused', async (t) => {
|
||||
const { b, caps } = granted(t);
|
||||
for (const verb of TASK_VERBS)
|
||||
await assert.rejects(b.requestTask(caps.alice, { verb, args: {} }, async () => ({})), { code: 'access-refused' }, verb);
|
||||
for (const verb of WEB_VERBS)
|
||||
await assert.rejects(b.requestWeb(caps.alice, { verb, args: {} }, null), { code: 'access-refused' }, verb);
|
||||
for (const verb of HOST_VERBS)
|
||||
await assert.rejects(b.requestHost(caps.alice, { verb, args: {} }, null), { code: 'access-refused' }, verb);
|
||||
});
|
||||
|
||||
test('R3 leak: project.list shows granted projects only', (t) => {
|
||||
const { call, access, p1, p2 } = granted(t);
|
||||
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1]);
|
||||
assert.deepEqual(call('human', 'project.list').map((p) => p.id), [p1, p2]);
|
||||
access.doc.grants = [grant()];
|
||||
assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [p1, p2]);
|
||||
});
|
||||
|
||||
test('R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist', (t) => {
|
||||
const { call, code, access, p1, p2 } = granted(t);
|
||||
assert.equal(code('alice', 'thread.create', { project: p2, name: 'x' }), 'access-refused');
|
||||
assert.equal(code('alice', 'thread.create', { project: '00000000-0000-4000-8000-000000000000', name: 'x' }), 'access-refused');
|
||||
access.doc.grants = [grant({ projects: [p1] }), grant({ id: 'g-2', projects: [p2], instances: ['chief'] })];
|
||||
assert.equal(code('alice', 'thread.create', { project: p2, name: 'x' }), 'access-refused', 'another instance');
|
||||
assert.ok(call('alice', 'thread.create', { project: p1, name: 'second' }).id);
|
||||
});
|
||||
|
||||
test('R3 leak: thread.list shows the user its own threads that are still granted, never others', (t) => {
|
||||
const { call, code, access, user, p1, p2, A } = granted(t);
|
||||
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
|
||||
const T = call('tester', 'thread.create', { project: p1, name: 'probe' }).id;
|
||||
access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })];
|
||||
user('bob');
|
||||
const B = call('bob', 'thread.create', { project: p2, name: 'bob work' }).id;
|
||||
assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]);
|
||||
assert.deepEqual(call('bob', 'thread.list').map((x) => x.id), [B]);
|
||||
// Neither the owner's nor the tester's lists carry a user's thread.
|
||||
assert.deepEqual(call('human', 'thread.list').map((x) => x.id), [H]);
|
||||
assert.deepEqual(call('tester', 'thread.list').map((x) => x.id), [T]);
|
||||
for (const [verb, args] of [['thread.open', {}], ['thread.send', { body: 'hi' }], ['thread.messages', {}]]) {
|
||||
for (const other of [B, H, T]) assert.equal(code('alice', verb, { thread: other, ...args }), 'thread-not-owned', `${verb} ${other}`);
|
||||
assert.equal(code('human', verb, { thread: A, ...args }), 'thread-not-owned', `human ${verb}`);
|
||||
assert.equal(code('tester', verb, { thread: A, ...args }), 'thread-not-owned', `tester ${verb}`);
|
||||
}
|
||||
// A thread whose project is no longer granted drops off the list and refuses the thread verbs.
|
||||
access.doc.grants = [grant({ projects: [p2] })];
|
||||
assert.deepEqual(call('alice', 'thread.list'), []);
|
||||
for (const [verb, args] of [['thread.open', {}], ['thread.send', { body: 'hi' }], ['thread.messages', {}]])
|
||||
assert.equal(code('alice', verb, { thread: A, ...args }), 'access-refused', verb);
|
||||
});
|
||||
|
||||
test('R3 leak: names shows granted instances only', (t) => {
|
||||
const { call, access } = granted(t);
|
||||
call('human', 'name.set', { instance: 'assistant', name: 'ada' });
|
||||
call('human', 'name.set', { instance: 'chief', name: 'cleo' });
|
||||
assert.deepEqual(call('alice', 'names').map((n) => n.instance), ['assistant']);
|
||||
assert.deepEqual(call('human', 'names').map((n) => n.instance), ['assistant', 'chief']);
|
||||
access.doc.grants = [grant({ instances: ['*'] })];
|
||||
assert.deepEqual(call('alice', 'names').map((n) => n.instance), ['assistant', 'chief']);
|
||||
});
|
||||
|
||||
test('R3: thread.send is from user:<id> and writes no human.input', (t) => {
|
||||
const { store, call, inputs, A } = granted(t);
|
||||
const before = inputs();
|
||||
const sent = call('alice', 'thread.send', { thread: A, body: 'please draft it' });
|
||||
assert.equal(sent.request, null);
|
||||
assert.equal(inputs(), before);
|
||||
const m = store.get('SELECT * FROM messages WHERE id=?', sent.id);
|
||||
assert.equal(m.from_role, 'user');
|
||||
assert.equal(m.from_run, 'user:alice');
|
||||
assert.equal(m.to_role, 'assistant');
|
||||
assert.equal(store.get('SELECT thread FROM message_threads WHERE message=?', sent.id).thread, A);
|
||||
});
|
||||
|
||||
test('R3: a reply-chain message.send into another user thread is refused', (t) => {
|
||||
const { call, code, access, user, p1 } = granted(t);
|
||||
access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })];
|
||||
user('bob');
|
||||
const B = call('bob', 'thread.create', { project: p1, name: 'bob work' }).id;
|
||||
const asked = call('bob', 'thread.send', { thread: B, body: 'bob asks' }).id;
|
||||
assert.equal(code('alice', 'message.send', { to: 'assistant', body: 'me too', in_reply_to: asked }), 'access-refused');
|
||||
assert.equal(call('bob', 'thread.messages', { thread: B }).length, 1);
|
||||
});
|
||||
|
||||
test('R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice', (t) => {
|
||||
const { store, call, launch, A } = granted(t);
|
||||
const asked = call('alice', 'thread.send', { thread: A, body: 'question' }).id;
|
||||
launch('a', A);
|
||||
const before = store.get('SELECT count(*) n FROM messages').n;
|
||||
const reply = call('a', 'message.send', { to: 'user', body: 'answer', class: 'RESULT', in_reply_to: asked });
|
||||
assert.equal('waiting' in reply, false);
|
||||
assert.equal(reply.request, null);
|
||||
assert.equal(store.get('SELECT count(*) n FROM messages').n, before + 1, 'no notice message');
|
||||
const r = store.get('SELECT * FROM messages WHERE id=?', reply.id);
|
||||
assert.equal(r.to_role, 'user');
|
||||
assert.equal(r.from_run, 'a-run');
|
||||
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [asked, reply.id]);
|
||||
// The owner's and the tester's receive get nothing of it.
|
||||
assert.deepEqual(call('human', 'message.receive'), []);
|
||||
assert.deepEqual(call('tester', 'message.receive'), []);
|
||||
});
|
||||
|
||||
test('R3: a reply to: user without a matching in_reply_to is refused user-reply-required', (t) => {
|
||||
const { store, call, code, access, user, launch, p1, A } = granted(t);
|
||||
access.doc.grants = [grant(), grant({ id: 'g-b', user: 'bob' })];
|
||||
user('bob');
|
||||
const B = call('bob', 'thread.create', { project: p1, name: 'bob work' }).id;
|
||||
const fromBob = call('bob', 'thread.send', { thread: B, body: 'bob asks' }).id;
|
||||
const fromAlice = call('alice', 'thread.send', { thread: A, body: 'alice asks' }).id;
|
||||
const A2 = call('alice', 'thread.create', { project: p1, name: 'alice other' }).id;
|
||||
const elsewhere = call('alice', 'thread.send', { thread: A2, body: 'alice asks elsewhere' }).id;
|
||||
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
|
||||
const fromOwner = call('human', 'thread.send', { thread: H, body: 'owner asks' }).id;
|
||||
launch('a', A);
|
||||
const send = (in_reply_to) => code('a', 'message.send', { to: 'user', body: 'x', ...(in_reply_to ? { in_reply_to } : {}) });
|
||||
assert.equal(send(null), 'user-reply-required', 'no in_reply_to');
|
||||
assert.equal(send(fromBob), 'user-reply-required', 'another user message in another thread');
|
||||
assert.equal(send(elsewhere), 'user-reply-required', 'the same user message outside the thread');
|
||||
assert.equal(send(fromOwner), 'user-reply-required', 'the owner message');
|
||||
assert.equal(send('00000000-0000-4000-8000-000000000000'), 'user-reply-required', 'an unknown id');
|
||||
assert.equal(send(fromAlice), null, 'its own user message in its own thread');
|
||||
assert.equal(store.get("SELECT count(*) n FROM messages WHERE to_role='user'").n, 1);
|
||||
});
|
||||
|
||||
test('R3: a non-thread run, the human and the tester cannot send to: user', (t) => {
|
||||
const { b, call, code, A } = granted(t);
|
||||
const asked = call('alice', 'thread.send', { thread: A, body: 'question' }).id;
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
assert.throws(() => b.request(chief, { verb: 'message.send', args: { to: 'user', body: 'x', in_reply_to: asked } }), { code: 'user-reply-required' });
|
||||
assert.equal(code('human', 'message.send', { to: 'user', body: 'x' }), 'unknown-role');
|
||||
assert.equal(code('tester', 'message.send', { to: 'user', body: 'x' }), 'unknown-role');
|
||||
});
|
||||
|
||||
test('R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9', (t) => {
|
||||
const { store, call, code, launch, A } = granted(t);
|
||||
call('alice', 'thread.send', { thread: A, body: 'ship it' });
|
||||
launch('a', A);
|
||||
assert.equal(code('a', 'message.send', { to: 'human', body: 'psst' }), 'user-thread-human');
|
||||
const before = store.get('SELECT count(*) n FROM messages').n;
|
||||
const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id;
|
||||
assert.equal(store.get('SELECT route_to FROM decisions WHERE id=?', d).route_to, 'human');
|
||||
assert.ok(call('human', 'inbox').some((x) => x.id === d), 'in the owner inbox');
|
||||
assert.equal(store.get('SELECT count(*) n FROM messages').n, before, 'no message to any role');
|
||||
assert.equal(code('alice', 'inbox'), 'access-refused', 'the user has no inbox');
|
||||
assert.equal(code('a', 'message.send', { to: 'human', body: 'about that raise' }), 'user-thread-human');
|
||||
});
|
||||
|
||||
test('R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request', (t) => {
|
||||
const { call, code, access } = granted(t);
|
||||
const reads = access.reads;
|
||||
call('alice', 'thread.list');
|
||||
call('alice', 'project.list');
|
||||
assert.equal(access.reads, reads + 2);
|
||||
const kept = access.doc.grants;
|
||||
access.doc.grants = [];
|
||||
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'removed');
|
||||
access.doc.grants = kept.map((g) => ({ ...g, expires: '2000-01-01T00:00:00Z' }));
|
||||
assert.equal(code('alice', 'project.list'), 'access-revoked', 'expired');
|
||||
access.doc.grants = kept.map((g) => ({ ...g, level: 'viewer' }));
|
||||
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'downgraded');
|
||||
access.fail = true;
|
||||
access.doc.grants = kept;
|
||||
assert.equal(code('alice', 'thread.list'), 'access-revoked', 'file refused');
|
||||
access.fail = false;
|
||||
assert.equal(code('alice', 'thread.list'), null, 'restored');
|
||||
});
|
||||
|
||||
test('R3: a user refusal is recorded as action.refused by user:<id>', (t) => {
|
||||
const { store, code, access, A } = granted(t);
|
||||
assert.equal(code('alice', 'inbox'), 'access-refused');
|
||||
access.doc.grants = [];
|
||||
assert.equal(code('alice', 'thread.messages', { thread: A }), 'access-revoked');
|
||||
const rows = store.all("SELECT actor_role, actor_run, body FROM events WHERE kind='action.refused' AND actor_run='user:alice' ORDER BY seq");
|
||||
assert.deepEqual(rows.map((r) => [r.actor_role, JSON.parse(r.body).code]), [['user', 'access-refused'], ['user', 'access-revoked']]);
|
||||
});
|
||||
|
||||
test('R3: a business with a role keyed user refuses invalid-business', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-boot-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
user: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.throws(() => new Broker({ store, businesses }), { code: 'invalid-business' });
|
||||
delete businesses.demo.roles.user;
|
||||
assert.ok(new Broker({ store, businesses }), 'the same business without it loads');
|
||||
});
|
||||
|
||||
test('R3: name.set refuses the name user', (t) => {
|
||||
const { code } = setup(t);
|
||||
assert.equal(code('human', 'name.set', { instance: 'assistant', name: 'user' }), 'name-is-instance');
|
||||
});
|
||||
|
||||
// FED R3 r4 (sage-2486): halt is the kill switch, so it refuses a granted user's write as it refuses
|
||||
// the human's. The read still answers, and nothing is stored.
|
||||
test('R3 r4: while halted, a granted user\'s thread.send refuses processing-halted and thread.messages still answers', (t) => {
|
||||
const generation = '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69';
|
||||
const sw = { state: { version: 1, halted: false, generation } };
|
||||
const { call, code, store, A } = granted(t, { processing: { read: () => sw.state } });
|
||||
const sent = call('alice', 'thread.send', { thread: A, body: 'before' });
|
||||
sw.state = { version: 1, halted: true, generation };
|
||||
const stored = store.get('SELECT count(*) n FROM messages').n;
|
||||
assert.equal(code('alice', 'thread.send', { thread: A, body: 'during' }), 'processing-halted');
|
||||
assert.equal(store.get('SELECT count(*) n FROM messages').n, stored);
|
||||
assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent.id]);
|
||||
});
|
||||
|
||||
// FED R3 r4 (sage-2557): row 194's hire takes an instance name, and `user` is refused as human and
|
||||
// tester are, so a hire can't write a role the load check refuses.
|
||||
test('R3 r4: the chief\'s hire refuses an instance named user with the code it gives human and tester', (t) => {
|
||||
const { b, code, caps } = setup(t);
|
||||
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
||||
b.request(caps.chief, { verb: 'role.claim' });
|
||||
const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' });
|
||||
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'invalid-request', name);
|
||||
// A name that isn't reserved passes the name check and refuses later, for want of role files.
|
||||
assert.notEqual(hire('helper'), 'invalid-request');
|
||||
});
|
||||
|
||||
// FED R3 r4 (sage-2557, sage-2501): `access` is checked with the other function arguments, before
|
||||
// the broker touches the store, so a null store never gets read.
|
||||
test('R3 r4: a non-function access throws before the store is read', () => {
|
||||
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
||||
});
|
||||
@@ -9,14 +9,15 @@ import { join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { configDir } from "./business.mjs";
|
||||
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
// FED R3 r5 (row 274): LEVELS, the all marker and covers() live in the vocabulary, which the session
|
||||
// sandbox mounts, because the broker checks a user's grant with covers(). This file re-exports them.
|
||||
import { LEVELS, GRANT_ALL as ALL, covers } from "./vocabulary.mjs";
|
||||
|
||||
export { LEVELS, covers };
|
||||
|
||||
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
|
||||
// no instance. covers() relies on this order.
|
||||
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
|
||||
// Ids a user may never take: the bus's own principals.
|
||||
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
||||
const LOCAL_HOME = "local";
|
||||
const ALL = "*";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
||||
const SUB_MAX = 255;
|
||||
@@ -171,20 +172,3 @@ export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
|
||||
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
|
||||
// read and any grant covers it, while `collaborator` asks to drive the instance
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
|
||||
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
|
||||
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -25,15 +25,25 @@ const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(v
|
||||
// Every limits.* key merges by intersection, so it can only narrow.
|
||||
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
|
||||
|
||||
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
|
||||
// The last-listed check runs when the proposed file is validated (deletedDocument). A creative
|
||||
// definition is END_PROTECTED too (row 213), so no one deletes one yet (sage-2164, DEFERRED). The
|
||||
// listing's endProtected is this same check, so the Agents page offers no Delete it would refuse.
|
||||
const endProtected = definition => END_PROTECTED.includes(definition);
|
||||
export function deleteRefusal(document, rolesDir, instance) {
|
||||
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
|
||||
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
|
||||
// change. The assistant is refused for every actor, the human included. The listing's
|
||||
// endProtected is this same check for its reader, so the Agents page offers no Delete it would refuse.
|
||||
function protectedRefusal(document, instance, actor) {
|
||||
const { definition } = document.roles[instance];
|
||||
if (!END_PROTECTED.includes(definition)) return null;
|
||||
if (!CREATIVE_DEFINITIONS.includes(definition)) return { code: "organization-definition-refused" };
|
||||
if (actor === document.human) return null;
|
||||
return { code: "organization-creative-human-only", message: `${instance} is a ${definition} instance, which only the human deletes` };
|
||||
}
|
||||
|
||||
// Row 194 (#1692, V5): why an instance can't be deleted by actor, checked in the design's order, or
|
||||
// null. The last-listed check runs when the proposed file is validated (deletedDocument).
|
||||
export function deleteRefusal(document, rolesDir, instance, actor) {
|
||||
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
|
||||
const entry = document.roles[instance];
|
||||
if (endProtected(entry.definition)) return { code: "organization-definition-refused" };
|
||||
const refused = protectedRefusal(document, instance, actor);
|
||||
if (refused) return refused;
|
||||
const references = [];
|
||||
if (document.launch?.by === instance) references.push("launch.by");
|
||||
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
|
||||
@@ -157,7 +167,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
return out;
|
||||
}
|
||||
|
||||
function read(business) {
|
||||
// Row 256: actor is the reader, so endProtected is the delete check for that reader. A read with
|
||||
// no actor (the bus's reloads) answers as for any non-human.
|
||||
function read(business, actor) {
|
||||
const { document, revision } = writer.read(business);
|
||||
validateBusinessDocument(document, businessFilePath(business, dir), { rolesDir });
|
||||
const launch = document.launch ?? null;
|
||||
@@ -168,7 +180,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
launcher: launch ? launch.by === name : false,
|
||||
protected: closed(entry.definition),
|
||||
kept: (launch?.keep ?? []).includes(name),
|
||||
endProtected: endProtected(entry.definition),
|
||||
endProtected: protectedRefusal(document, name, actor) !== null,
|
||||
}]));
|
||||
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
|
||||
return {
|
||||
@@ -190,9 +202,10 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
}
|
||||
const { instance, op } = args;
|
||||
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
|
||||
// The broker refuses a business whose roles name human or tester (broker.mjs, the load check),
|
||||
// so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1).
|
||||
if (instance === "human" || instance === "tester") {
|
||||
// The broker refuses a business whose roles name human, tester or user (broker.mjs, the load
|
||||
// check), so an instance under any of them would stop the bus at its next start (row 165 r2,
|
||||
// Dewey F1; FED R3, dewey-0777).
|
||||
if (instance === "human" || instance === "tester" || instance === "user") {
|
||||
throw new OrganizationError("invalid-request", `${instance} names the bus's own sessions, never a role instance`);
|
||||
}
|
||||
if (Object.hasOwn(args, "definition") && !id(args.definition)) throw new OrganizationError("invalid-request");
|
||||
@@ -204,7 +217,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
|
||||
const services = Object.keys(old.roles[instance]?.credentials ?? {});
|
||||
const beforeWrite = revoke ? () => revoke(business, instance, "delete", services) : null;
|
||||
const result = commit(business, old, planDelete(old, instance), { revision: args.revision, actor, beforeWrite });
|
||||
const result = commit(business, old, planDelete(old, instance, actor), { revision: args.revision, actor, beforeWrite });
|
||||
// sage-1610 (b): the bot user and any token it holds live in Vikunja, which this write
|
||||
// can't reach (row 199 A5).
|
||||
const bot = old.roles[instance]?.tracker?.bot;
|
||||
@@ -255,15 +268,15 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
|
||||
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
|
||||
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
|
||||
function planDelete(document, instance) {
|
||||
const refusal = deleteRefusal(document, rolesDir, instance);
|
||||
function planDelete(document, instance, actor) {
|
||||
const refusal = deleteRefusal(document, rolesDir, instance, actor);
|
||||
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
|
||||
return deletedDocument(document, instance);
|
||||
}
|
||||
|
||||
function checkDelete(business, instance) {
|
||||
function checkDelete(business, instance, actor) {
|
||||
const { document, revision } = writer.read(business);
|
||||
const next = planDelete(document, instance);
|
||||
const next = planDelete(document, instance, actor);
|
||||
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
||||
return { revision };
|
||||
}
|
||||
@@ -297,7 +310,8 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
const { document: old, revision } = writer.read(business);
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const { name, definition } = args;
|
||||
if (!id(name) || !id(definition) || name === "human" || name === "tester") throw new OrganizationError("invalid-request");
|
||||
// FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses.
|
||||
if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request");
|
||||
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
|
||||
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
|
||||
}
|
||||
@@ -335,7 +349,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
// its tokens revoked, with no write. A revoke is safe to repeat.
|
||||
if (!old.launch.instances.includes(name)) {
|
||||
const revoked = revoke ? revoke(business, name, "fire", services) : null;
|
||||
return { ...read(business), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
|
||||
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
|
||||
}
|
||||
const next = structuredClone(old);
|
||||
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
|
||||
@@ -402,7 +416,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
|
||||
if (beforeWrite) beforeWrite();
|
||||
const receipt = writer.write(business, next, { revision, actor, context });
|
||||
return { ...read(business), transaction: receipt.transaction, restart: false };
|
||||
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
||||
}
|
||||
|
||||
return { writer, read, set, checkDelete, checkFire, veto, hire, fire };
|
||||
|
||||
@@ -271,3 +271,31 @@ export function forkProblem(upstream, identity) {
|
||||
if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork";
|
||||
return null;
|
||||
}
|
||||
|
||||
// FED R3 r5 (row 274, #414): the access grant levels, the all marker and covers(), from access.mjs,
|
||||
// which re-exports them. They live here because the session sandbox mounts this file and not
|
||||
// access.mjs (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs, which checks a
|
||||
// user's grant with covers().
|
||||
|
||||
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
|
||||
// no instance. covers() relies on this order.
|
||||
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
|
||||
// A grant's projects or instances list that is just this covers every one.
|
||||
export const GRANT_ALL = "*";
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
|
||||
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
|
||||
// read and any grant covers it, while `collaborator` asks to drive the instance
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === GRANT_ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === GRANT_ALL || grant.instances.includes(instance))) return false;
|
||||
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
|
||||
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,15 @@ test("row 165 refuses each validation failure and leaves the file's bytes alone"
|
||||
// Dewey F1: the broker refuses a business whose roles name human or tester, so the writer does too.
|
||||
refuses(f, { instance: "human", op: "add", definition: "merge-gate" }, "invalid-request");
|
||||
refuses(f, { instance: "tester", op: "add", definition: "merge-gate" }, "invalid-request");
|
||||
// FED R3 (dewey-0777): and user, the granted user's principal.
|
||||
refuses(f, { instance: "user", op: "add", definition: "merge-gate" }, "invalid-request");
|
||||
// FED R3 r4 (sage-2557): row 194's hire refuses the same three names, and leaves the file alone.
|
||||
for (const name of ["human", "tester", "user"]) {
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.throws(() => f.settings.hire("acme", { name, definition: "security-reviewer" }, { actor: "chief", context: {} }),
|
||||
e => e.code === "invalid-request", name);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes, name);
|
||||
}
|
||||
const one = fixture(doc => { doc.launch.instances = ["coder"]; });
|
||||
refuses(one, { instance: "coder", op: "change", launch: false }, "BusinessError");
|
||||
const none = fixture(doc => { delete doc.launch; });
|
||||
@@ -285,7 +294,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
e => e.code === "organization-creative-human-only", `${actor} redefine`);
|
||||
}
|
||||
let done = f.settings.set("acme", { revision: view.revision, ...add }, "jason");
|
||||
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: true });
|
||||
// Row 256: the listing a write returns is read for the writer, and the human may delete bean.
|
||||
assert.deepEqual(done.instances.bean, { definition: "companion", vars: persona, launched: false, launcher: false, protected: false, kept: false, endProtected: false });
|
||||
for (const actor of ["chief-of-staff.chief", "tester"]) {
|
||||
for (const change of [{ vars: {} }, { launch: true }, { definition: "security-reviewer" }]) {
|
||||
assert.throws(() => f.settings.set("acme", { revision: done.revision, instance: "bean", op: "change", ...change }, actor),
|
||||
@@ -315,3 +325,65 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
assert.equal(st.mode & 0o777, st.isDirectory() ? 0o700 : 0o600, name);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 256 (#1761, REQ-COS-3): the human deletes a creative instance and any other actor is refused
|
||||
// with the creative code. The assistant, the END_PROTECTED definition that isn't creative, refuses
|
||||
// every actor, the human included. The listing's endProtected is that same check for its reader.
|
||||
test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor does, and the assistant stays", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.roles.muse = { definition: "ideas" };
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
});
|
||||
const del = (instance, actor) => () => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance, op: "delete" }, actor);
|
||||
const refused = (act, code, label) => {
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.throws(act, e => e.code === code, label);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes, label);
|
||||
};
|
||||
// T5: the reader's view. A read with no actor answers as for any non-human.
|
||||
for (const [actor, bean] of [["jason", false], ["tester", true], ["chief-of-staff.chief", true], [undefined, true]]) {
|
||||
const view = f.settings.read("acme", actor);
|
||||
assert.equal(view.instances.bean.endProtected, bean, `bean ${actor}`);
|
||||
assert.equal(view.instances.assistant.endProtected, true, `assistant ${actor}`);
|
||||
assert.equal(view.instances.coder.endProtected, false, `coder ${actor}`);
|
||||
}
|
||||
assert.equal(f.settings.read("acme").instances.bean.endProtected, true);
|
||||
// T2: every actor but the human gets the creative code, and the message says why.
|
||||
for (const actor of ["chief-of-staff.chief", "tester", "mos", undefined]) {
|
||||
refused(del("bean", actor), "organization-creative-human-only", String(actor));
|
||||
assert.throws(() => f.settings.checkDelete("acme", "bean", actor), e => e.code === "organization-creative-human-only", `check ${actor}`);
|
||||
}
|
||||
assert.throws(del("bean", "tester"), /bean is a companion instance, which only the human deletes/);
|
||||
// T3: the assistant refuses every actor, the human included.
|
||||
for (const actor of ["jason", "chief-of-staff.chief", "tester"]) {
|
||||
refused(del("assistant", actor), "organization-definition-refused", `assistant ${actor}`);
|
||||
}
|
||||
// T1: the human's delete.
|
||||
assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.settings.read("acme").revision });
|
||||
const done = del("bean", "jason")();
|
||||
assert.equal(Object.hasOwn(done.instances, "bean"), false);
|
||||
assert.deepEqual(done.retired, ["bean"]);
|
||||
assert.equal(done.instances.muse.endProtected, false);
|
||||
const file = JSON.parse(readFileSync(f.file, "utf8"));
|
||||
assert.equal(Object.hasOwn(file.roles, "bean"), false);
|
||||
assert.deepEqual(file.retired, ["bean"]);
|
||||
assert.equal(f.log().at(-1).actor, "jason");
|
||||
const run = f.validate();
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
});
|
||||
|
||||
// Row 256 (R1, webby-0122): a creative delete by the human goes on to the later checks. A creative
|
||||
// instance can't be kept, launch.by or an arbiter, so the check it reaches is the last-listed one.
|
||||
test("row 256 T4 the human's delete of the only listed instance, a creative one, is the last-listed refusal", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.launch.instances = ["bean"];
|
||||
});
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
const revision = f.settings.read("acme").revision;
|
||||
assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "jason"), e => e.name === "BusinessError");
|
||||
assert.throws(() => f.settings.checkDelete("acme", "bean", "jason"), e => e.name === "BusinessError");
|
||||
assert.throws(() => f.settings.set("acme", { revision, instance: "bean", op: "delete" }, "tester"), e => e.code === "organization-creative-human-only");
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
@@ -229,3 +229,18 @@ test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and
|
||||
const one = fixture(doc => { doc.launch.instances = ["sec-1"]; });
|
||||
refuses(one, () => fire(one, "sec-1"), "BusinessError");
|
||||
});
|
||||
|
||||
// Row 256 (#1761): checkDelete takes the actor, and fire is unchanged: a creative instance refuses
|
||||
// every actor's fire, the human's included.
|
||||
test("row 256 checkDelete passes the human's creative delete and refuses another actor's; fire still refuses", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.launch.instances.push("bean");
|
||||
});
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.deepEqual(f.settings.checkDelete("acme", "bean", "jason"), { revision: f.revision() });
|
||||
for (const actor of ["chief", "tester", undefined]) refuses(f, () => f.settings.checkDelete("acme", "bean", actor), "organization-creative-human-only");
|
||||
for (const actor of ["chief", "jason"]) refuses(f, () => f.settings.fire("acme", { name: "bean" }, { actor, context: CONTEXT }), "organization-definition-refused");
|
||||
assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused");
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ mosaic inbox [--business <id>] [--json]
|
||||
mosaic inbox --read <id>... | --read-all [--business <id>]
|
||||
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
mosaic tasks [--business <id>] [--json]
|
||||
mosaic agents [--business <id>] [--json]
|
||||
mosaic agents [--business <id>] [--json] [--tester]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
mosaic follow [--business <id>]
|
||||
@@ -78,6 +78,12 @@ mosaic launches list [--json]
|
||||
message that call receives drops out of a later `follow`, like the ones
|
||||
plain `talk` skips, and stays unread in `inbox` until marked. Neither flag
|
||||
takes `--json` or `--tester` (exit 4).
|
||||
- `agents` (row 277, #1782) prints the held roles, then a `thread` line for
|
||||
each held thread claim from the bus verb `thread.claims`: thread id,
|
||||
instance, run, harness and since when. It never prints a thread's name. If
|
||||
the bus refuses `thread.claims`, the role lines still print, then `thread
|
||||
claims: refused <code>`, and the command exits 0. `--json` prints the
|
||||
`agents` array alone, and `--tester` makes only the `agents` call.
|
||||
- `decide` takes the full id, or a unique prefix of at least 8 characters,
|
||||
from the inbox. It prints the decision and whether the choice authorizes
|
||||
or declines, then asks `[y/N]` on a terminal. Without a terminal it needs
|
||||
|
||||
@@ -313,7 +313,20 @@ async function human(verb, rest, io, deps) {
|
||||
`\nmessages: ${value.messages.length} unread\n` + value.messages.map(formatMessage).join(""));
|
||||
}
|
||||
if (verb === "tasks") return print(await call("tasks"), formatTasks);
|
||||
return print(await call("agents"), formatAgents);
|
||||
const agents = await call("agents");
|
||||
// Row 277 (#1782, C1/C2): the human's text output adds the held thread claims. A tester can't read
|
||||
// thread.claims, so --tester makes the one call, and --json stays the agents array.
|
||||
if (json || parsed.flags.has("--tester")) return print(agents, formatAgents);
|
||||
// Row 277 N1 (sage-2505): `agents` stays as it was, so a refused thread.claims still prints the
|
||||
// role rows, then the refusal with its code. An error without a bus code still throws.
|
||||
let threads;
|
||||
try {
|
||||
threads = await call("thread.claims");
|
||||
} catch (e) {
|
||||
if (typeof e?.code !== "string" || !/^[a-z-]{1,64}$/.test(e.code)) throw e;
|
||||
return print(agents, (list) => `${formatAgents(list)}thread claims: refused ${e.code}\n`);
|
||||
}
|
||||
return print(agents, (list) => formatAgents(list, threads));
|
||||
}
|
||||
|
||||
// Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an
|
||||
|
||||
@@ -49,9 +49,10 @@ export function formatDecision(d) {
|
||||
return `${out.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function formatAgents(list) {
|
||||
if (list.length === 0) return "agents: no role is claimed\n";
|
||||
return `${list.map((c) => `${c.role} ${c.holder_run} ${c.harness ?? "-"} since ${c.at}`).join("\n")}\n`;
|
||||
// Row 277 (#1782): the held thread claims follow the role lines, each marked `thread`.
|
||||
export function formatAgents(list, threads = []) {
|
||||
const roles = list.length === 0 ? "agents: no role is claimed\n" : `${list.map((c) => `${c.role} ${c.holder_run} ${c.harness ?? "-"} since ${c.at}`).join("\n")}\n`;
|
||||
return roles + threads.map((c) => `thread ${c.thread} ${c.instance} ${c.holder_run} ${c.harness} since ${c.at}\n`).join("");
|
||||
}
|
||||
|
||||
export function formatTasks(list) {
|
||||
|
||||
@@ -8,6 +8,18 @@ test("empty views say so", () => {
|
||||
assert.equal(formatTasks([]), "tasks: none\n");
|
||||
});
|
||||
|
||||
// Row 277 (#1782, C2): the held thread claims follow the role lines, each marked `thread`.
|
||||
test("agents adds a thread line for each held thread claim, after the roles", () => {
|
||||
const roles = [{ role: "coder", holder_run: "coder-run", harness: "pi", at: "2026-10-22T05:00:00Z" }];
|
||||
const threads = [{ thread: "t-1", instance: "assistant", holder_run: "assistant-run", harness: "pi", at: "2026-10-22T05:01:00Z" }];
|
||||
assert.equal(
|
||||
formatAgents(roles, threads),
|
||||
"coder coder-run pi since 2026-10-22T05:00:00Z\nthread t-1 assistant assistant-run pi since 2026-10-22T05:01:00Z\n",
|
||||
);
|
||||
assert.equal(formatAgents([], threads), "agents: no role is claimed\nthread t-1 assistant assistant-run pi since 2026-10-22T05:01:00Z\n");
|
||||
assert.equal(formatAgents(roles), formatAgents(roles, []));
|
||||
});
|
||||
|
||||
test("the trail keeps the broker's order and names a decision's task without its rows", () => {
|
||||
const rows = [
|
||||
{ at: "2026-10-08T10:00:00Z", table: "decisions", seq: 4, id: "d-1", class: "gated", action: "deploy", route_to: "human", blocking: 1, question: "Ship?", task_ref: "vikunja:1/7" },
|
||||
|
||||
@@ -128,6 +128,55 @@ test("with no stand-in transport, --tester runs tester-cli.mjs on the data root'
|
||||
assert.deepEqual(seen, [{ argv: [TESTER_CLI, join(dataRoot, "bus", "broker.sock")], input: { business: "demo", verb: "agents", args: {} } }]);
|
||||
});
|
||||
|
||||
// Row 277 (#1782, C1/C2): the human's text adds thread.claims, which a tester can't read, so
|
||||
// `agents --tester` stays one call. --json prints the agents array alone, as `inbox --json` does.
|
||||
test("agents --tester makes one call; the human's agents also reads thread.claims, and --json is the agents array", async (t) => {
|
||||
const s = setup(t);
|
||||
await main(["agents", "--tester", "--business", "demo"], inAgent(), s.deps);
|
||||
assert.deepEqual(s.calls.tester.map((c) => c.verb), ["agents"]);
|
||||
const x = io();
|
||||
await main(["agents", "--business", "demo"], x, s.deps);
|
||||
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents", "thread.claims"]);
|
||||
assert.match(x.out.text, /^coder coder-run pi since /);
|
||||
const j = io();
|
||||
await main(["agents", "--json", "--business", "demo"], j, s.deps);
|
||||
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents", "thread.claims", "agents"]);
|
||||
assert.deepEqual(JSON.parse(j.out.text), s.b.request(s.human, { verb: "agents", args: {} }));
|
||||
});
|
||||
|
||||
// Row 277 N1 (sage-2505): a refused thread.claims doesn't fail the human's agents. The role rows
|
||||
// print, then the refusal with its code.
|
||||
test("a refused thread.claims still prints the role rows and the refusal's code", async (t) => {
|
||||
const s = setup(t);
|
||||
const refusing = (business) => async (verb, args) => {
|
||||
if (verb !== "thread.claims") return s.deps.transport(business)(verb, args);
|
||||
const error = new Error("processing-halted");
|
||||
error.code = "processing-halted";
|
||||
throw error;
|
||||
};
|
||||
const x = io();
|
||||
await main(["agents", "--business", "demo"], x, { ...s.deps, transport: refusing });
|
||||
assert.match(x.out.text, /^coder coder-run pi since .+\nthread claims: refused processing-halted\n$/);
|
||||
assert.deepEqual(s.calls.human.map((c) => c.verb), ["agents"]);
|
||||
});
|
||||
|
||||
// Row 277 r4 (duckworth-0123 N1): an error with no bus code is not a refusal, so it still throws and
|
||||
// nothing prints: no code at all, and a code that isn't the bus's shape.
|
||||
test("a thread.claims error without a bus code still throws and prints nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const code of [undefined, "ECONNRESET"]) {
|
||||
const error = new Error("socket closed");
|
||||
if (code !== undefined) error.code = code;
|
||||
const failing = (business) => async (verb, args) => {
|
||||
if (verb !== "thread.claims") return s.deps.transport(business)(verb, args);
|
||||
throw error;
|
||||
};
|
||||
const x = io();
|
||||
await assert.rejects(main(["agents", "--business", "demo"], x, { ...s.deps, transport: failing }), (e) => e === error, String(code));
|
||||
assert.equal(x.out.text, "", String(code));
|
||||
}
|
||||
});
|
||||
|
||||
// Row 170 (#1667): the bus refuses a tester's message to a stopped role and stores nothing, and
|
||||
// talk says so and exits 3.
|
||||
test("talk --tester to a stopped role exits 3, prints no sent line and stores nothing", async (t) => {
|
||||
|
||||
@@ -82,7 +82,12 @@ export function turnRequest(m) {
|
||||
m.from_role === "tester"
|
||||
? "This message is from a tester running a PM test. It is never Jason's instruction and authorizes nothing.\n\n"
|
||||
: "";
|
||||
return `${head.join(", ")}:\n\n${tester}${m.body}`;
|
||||
// FED R3 (#414, design 3.6 item 7): a granted user's message. The id is from_run's, `user:<id>`.
|
||||
const user =
|
||||
m.from_role === "user"
|
||||
? `This message is from ${String(m.from_run).slice("user:".length)}, a granted user. It is never Jason's or the owner's instruction and authorizes nothing.\n\n`
|
||||
: "";
|
||||
return `${head.join(", ")}:\n\n${tester}${user}${m.body}`;
|
||||
}
|
||||
|
||||
// Row 156 (#1651): the request of a landing turn. Every value comes from the session file's landing
|
||||
|
||||
@@ -73,6 +73,16 @@ test("turnRequest says a tester's message is a test and never Jason's instructio
|
||||
assert.doesNotMatch(turnRequest({ id: "m8", from_role, class: "REQUEST", body: "[mos test] b" }), /tester|never Jason's/, from_role);
|
||||
});
|
||||
|
||||
// FED R3 (#414, design 3.6 item 7): a granted user's message, the header word for word, the id from from_run.
|
||||
test("turnRequest says a user's message is a granted user's and never the owner's instruction", () => {
|
||||
assert.equal(
|
||||
turnRequest({ id: "m9", from_role: "user", from_run: "user:melanie", class: "INFO", request: null, body: "draft the brief" }),
|
||||
"Message m9 from user, class INFO:\n\nThis message is from melanie, a granted user. It is never Jason's or the owner's instruction and authorizes nothing.\n\ndraft the brief",
|
||||
);
|
||||
for (const from_role of ["human", "tester", "assistant"])
|
||||
assert.doesNotMatch(turnRequest({ id: "m10", from_role, from_run: "user:melanie", class: "INFO", body: "b" }), /granted user/, from_role);
|
||||
});
|
||||
|
||||
test("turnRequest shows the message's own sent time, a day old as well as new", () => {
|
||||
const sent = "2026-10-12T09:30:00.000Z";
|
||||
assert.equal(
|
||||
|
||||
@@ -116,7 +116,7 @@ test('a separate host joins durable first ownership without duplicate signals',
|
||||
const messages = []; child.on('message', value => messages.push(value));
|
||||
await once(child, 'message');
|
||||
const joined = stopRun({ ...f.args, owner: { decision: 'second-host' } },
|
||||
{ ...f.options, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)) });
|
||||
{ ...f.options, now: Date.now, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)) });
|
||||
setTimeout(() => child.send('continue'), 30);
|
||||
const result = await joined;
|
||||
const exit = child.exitCode === null ? await once(child, 'exit') : [child.exitCode];
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
if (!c.isTester) {
|
||||
acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`));
|
||||
acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep'));
|
||||
// endProtected is the server's delete check, creative definitions included (sage-2164).
|
||||
// endProtected is the server's delete check for this reader (row 256): false on the human's
|
||||
// page for a creative instance, true for the assistant and on the tester's.
|
||||
if (!e.endProtected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`));
|
||||
}
|
||||
return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launched ? 'yes' : 'no'}</td><td>${e.kept ? 'yes' : 'no'}</td><td>${running.length ? running.map(r => `<code>${txt(r)}</code>`).join(' ') : '<span class="muted">none</span>'}${alive.map(r => ` <strong class="agent-alive">still running after fire: <code>${txt(r)}</code></strong>`).join('')}</td><td class="agent-actions">${acts.join(' ')}</td></tr>`;
|
||||
|
||||
@@ -40,8 +40,8 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action]").length>0'),true);
|
||||
// The protected assistant has no Delete; the kept instance offers Unkeep; reviewer is no-hire.
|
||||
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="assistant"]\')'),false);
|
||||
// sage-2164: a creative instance isn't protected (the human changes it), yet the server refuses its
|
||||
// delete, so endProtected hides Delete for it too.
|
||||
// A creative instance isn't protected (the human changes it). This listing marks bean endProtected,
|
||||
// as a non-human reader's does, so the page hides its Delete; row 256's test below has the human's.
|
||||
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="bean"]\')'),false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="launch"][data-agent-name="bean"]\')'),true);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile);
|
||||
@@ -123,3 +123,29 @@ test('row 194 T16 Settings Agents: unavailable read-only, launch follows its req
|
||||
assert.equal(await b.evaluate('posts.length'),sent+1);
|
||||
}finally{await b.close();await close(web);}
|
||||
});
|
||||
|
||||
// Row 256 (#1761, T7, T8): the human's listing carries endProtected false for a creative instance
|
||||
// (the server's delete check for that reader), so the human's page offers Delete for it. The tester's
|
||||
// page offers no Delete even on that same listing; the tester's own listing marks bean endProtected
|
||||
// as well (bus creative.test T6).
|
||||
test('row 256 T7 T8 the human page offers Delete on a creative instance, the tester page never does',{timeout:90000},async()=>{
|
||||
const human={...view,instances:{...view.instances,bean:{...view.instances.bean,endProtected:false}}};
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>human};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`const q=new URL(location.href).searchParams;const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify(q.has('tester')?{tester:true}:{send:true}));return original(url,options);};`});
|
||||
const port=web.address().port;
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
const del=name=>`document.querySelector('[data-agent-action="delete"][data-agent-name="${name}"]')`;
|
||||
// T7: the human's page.
|
||||
await b.navigate(`http://127.0.0.1:${port}/?human=1#/settings`);
|
||||
await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')?.disabled===false`);
|
||||
assert.equal(await b.evaluate(`${del('bean')}?.disabled`),false);
|
||||
assert.equal(await b.evaluate(`!!${del('assistant')}`),false);
|
||||
assert.equal(await b.evaluate(`!!${del('coder')}`),true);
|
||||
// T8: the tester's page, on the same listing.
|
||||
await b.navigate(`http://127.0.0.1:${port}/?tester=1#/settings`);
|
||||
await wait(`!!document.querySelector('#tester-banner')&&!!document.querySelector('[data-agent-action="launch"][data-agent-name="bean"]')`);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('[data-agent-action="delete"]').length`),0);
|
||||
}finally{await b.close();await close(web);}
|
||||
});
|
||||
|
||||
@@ -52,15 +52,26 @@ test('Console halt and generation-bound resume use only the management writer an
|
||||
test('processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer', async t => {
|
||||
const f = await fixture(t);
|
||||
const cases = [
|
||||
{ headers: { ...POST.headers, origin: 'https://elsewhere' } },
|
||||
{ headers: { ...POST.headers, 'sec-fetch-site': 'cross-site' } },
|
||||
{ headers: { ...POST.headers, host: `127.0.0.1:${f.server.address().port}`, 'x-user': undefined } },
|
||||
{ headers: { ...POST.headers, 'x-user': ['jason', 'jason'] } },
|
||||
{ headers: { ...POST.headers, 'content-type': 'text/plain' } },
|
||||
{ body: '{"human":"jason"}' }, { body: '[]' }, { body: 'null' }, { body: ' '.repeat(1025) + '{}' },
|
||||
['wrong origin', { headers: { ...POST.headers, origin: 'https://elsewhere' } }, 403, 'cross-origin request refused'],
|
||||
['cross-site', { headers: { ...POST.headers, 'sec-fetch-site': 'cross-site' } }, 403, 'cross-site'],
|
||||
['unnamed host', { headers: { ...POST.headers, host: `127.0.0.1:${f.server.address().port}`,
|
||||
origin: `http://127.0.0.1:${f.server.address().port}`, 'x-user': undefined } }, 403, 'read-only'],
|
||||
['duplicate identity', { headers: { ...POST.headers, 'x-user': ['jason', 'jason'] } }, 401, 'sign-in required'],
|
||||
['content type', { headers: { ...POST.headers, 'content-type': 'text/plain' } }, 415, 'invalid-request'],
|
||||
['extra key', { body: '{"human":"jason"}' }, 400, 'invalid-request'],
|
||||
['array', { body: '[]' }, 400, 'invalid-request'],
|
||||
['null', { body: 'null' }, 400, 'invalid-request'],
|
||||
['body bound', { body: ' '.repeat(1025) + '{}' }, 400, 'invalid-request'],
|
||||
];
|
||||
for (const c of cases) assert.ok((await http(f.server, '/api/processing/halt', { ...POST, ...c })).status >= 400);
|
||||
assert.equal((await http(f.server, '/api/processing/resume', POST)).status, 400);
|
||||
for (const [name, changed, status, error] of cases) {
|
||||
const response = await http(f.server, '/api/processing/halt', { ...POST, ...changed });
|
||||
assert.equal(response.status, status, name);
|
||||
assert.equal(response.value.error, error, name);
|
||||
assert.deepEqual(f.calls, [], name + ' must refuse before the writer');
|
||||
}
|
||||
const missingGeneration = await http(f.server, '/api/processing/resume', POST);
|
||||
assert.equal(missingGeneration.status, 400);
|
||||
assert.equal(missingGeneration.value.error, 'invalid-request');
|
||||
assert.deepEqual(f.calls, []);
|
||||
});
|
||||
test('tester cannot invoke either control writer and failures never echo backend text', async t => {
|
||||
|
||||
Reference in New Issue
Block a user