Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6838332056 | ||
|
|
d38c5213a7 | ||
|
|
73fd4ac243 | ||
|
|
33d3844a09 | ||
|
|
fe4ee8c7b5 | ||
|
|
0621c439e0 | ||
|
|
d243f6b25f | ||
|
|
869670d1b1 | ||
|
|
dbbda25373 | ||
|
|
a9da046f83 | ||
|
|
4a71b56abc | ||
|
|
5208894196 | ||
|
|
8dc132bec8 | ||
|
|
08b8831bce | ||
|
|
d339892301 | ||
|
|
0ea3db297b | ||
|
|
f859d06d20 | ||
|
|
70651f45d8 | ||
|
|
490f45f024 | ||
|
|
1e629c892d | ||
|
|
84de3fbca3 | ||
|
|
1e4c1e6e94 | ||
|
|
5b846b048a | ||
|
|
59f0e44f47 | ||
|
|
bb031ec840 | ||
|
|
07315e6514 | ||
|
|
fb04cbc431 | ||
|
|
9d929911c8 | ||
|
|
bcf3c1dacf | ||
|
|
d52d9f1f2f | ||
|
|
830f3d8bff | ||
|
|
e48d602165 | ||
|
|
cabcb81d49 | ||
|
|
353d65f577 | ||
|
|
2c289f6f0d | ||
|
|
a863015a51 |
No files matched your search
@@ -5389,3 +5389,17 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
|
||||
|
||||
2026-10-23T11:03:46Z | Gyro Vikunja571 mos-1444 PRD d8 follow-on | Branch gyro/editor-571-d8 from origin/refactor c16e943b9. Both designs now keep library.convert outside ACTIONS/ROUTED and broker processing, retain REQ-WAIT-1/hard-wait test unchanged, constrain unmarked story eligibility to mapped book/playground scope, and add whole-host pre-import restore recovery from remote import manifests plus the Gitea archive commit. Lookups refuse until one current version is proven; VM1012 restore needs Jason present under Q185. W7 no longer owns hard-waits.test.mjs. Canonical queue next gyro returned nothing; direct Mos assignment governs. Static diff, exact replacement and append-only checks pass. No code, tests, build, browser, private-file or live action. Fenton re-check requested after authorized branch push.
|
||||
|
||||
2026-10-22 | Huey | Row 272 / 264e (#1777) preparation on huey/row272, base 2d0d407ba. Before: bus session metadata and launcher isolation exist, CLI/Console expose only the saved thread default. Draft: session CLI, Console chooser with isolated drafts/cursors/receipts, metadata counts and safe-clear operation receipts against Gizmoduck's 0260 contracts; narrow deferred B6 reply fence. Tests authored, not run: Gander row 253 holds Sage's slot; no Node, claim, HTTPS git, push or live changes. This entry records preparation after the first draft, not a green acceptance result.
|
||||
|
||||
2026-10-22 | Huey | Row 272 draft, 264d additive contract gizmoduck-0272 (78e14352f). Added confirmed cancellation through the human channel, cancelled receipt retirement distinction, named refusals and routed-message cancellation output. Before: cancelled was not recognized as terminal; after: draft CLI stops polling and Console shows the before/after retirement outcome. Acceptance remains unverified: no Node/tests/claim/commit/push under Sage's gate. No runtime/lifecycle authority expanded.
|
||||
|
||||
2026-10-23 | Huey | Row 272 / 264e sage-3141 local rebase. Before: uncommitted draft on 2d0d407ba. After: same draft on new huey/row272-b23783f39 at b23783f39, with 270/271 landed (7328da8ca) and schema 3n. Product changes auto-merged; both append-only logs retain the full new base and original Huey additions. Checked landed session.list and clear/cancel/operation envelopes against the wired draft: no incompatible contract change. Historical unavailable-dependency cases stay as stand-ins; actual base supplies counters and lifecycle state. No tests/Node/claim/commit/push while Gander holds row 574 run 2. Existing-test fixture/assertion delta is named in the packet for Mos's ruling via Sage before execution.
|
||||
|
||||
2026-10-23 | Huey | Row 272 sage-3145 preservation correction. refs/stash is shared, so it was unsafe as sole draft storage. Pinned exact stash commit 07dc9d2f7944b4781b5e80dfa18425f4749f7a45 to local huey/row272-stash-keep and left the entry untouched. Sage explicitly directed committing the rebased draft before testing to preserve it: an untested local checkpoint, no acceptance claim. No Node/test/queue/network/live action. Separate claims and test CLEARs remain pending; old-test delta is with Mos via sage-3144.
|
||||
|
||||
2026-10-23 | Huey | Row 272 early static review follow-up, scrooge-272-early-read-huey-1. Before: a bad-session 404 erased sibling draft/recovery caches, malformed clear/cancel write receipts omitted the audit line, and another session's op key produced an ambiguous error. Draft after: purge only on named thread-level refusals, write one redacted malformed-receipt audit line, and identify key reuse without a write. New browser/HTTP/CLI tests authored, unrun under test hold; approved old-test hunks unchanged. Published 850b22680/round-1 manifest unchanged; follow-up is uncommitted, no acceptance claim.
|
||||
|
||||
2026-10-23 | Huey | Row 272 round 2 on row 219 landing 5fdfd7275, after 295. Replayed round 1 and B1/N1/N2; seven conflicts resolved retaining both log histories, both test sets, 219 instance chooser and 295 owner-settings-only transport. Static comparison confirms old tests differ only by mos-1388 approved hunks. Sage3357/Mos1442 authorize bounded granted-user legacy fallback: only session.list access-refused and omitted-session route; no new session grants or owner routing. RED first under3358 on new file only: 1 expected legacy assertion failure, 3 guards pass, 3.504s, TMPDIR mktemp/no underscore, no compose network; slot returned as huey0035. Then implemented fallback and added timeout/bus/malformed guards plus isolated-copy mutant runner, all unrun afterward. Candidate commit/push precedes Sage's full gate by3354; no green claim.
|
||||
|
||||
2026-10-23 | Huey | Row 272 round 3 per mos-1455 / sage-3435. Gate run 1 on b44332a93 RED: bus 891/0, business 231/0, CLI 940/1, plugins 110/1, webui 372/20; no mutants. Fixed only new CLI fixture role authority and three null-old render guards. Catching browser tests unchanged. manifest.r3 freezes the same 17 paths. Authorized pre-gate follow-up commit/push; no local tests, Node or queue writes; separate compactions dependency and Sage gate remain pending.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Row 305 (#1812, Vikunja 591): shell suites remove the compose network they create
|
||||
|
||||
Owner Gizmoduck. Reviewer Filbert. Gate owner Sage. Brief:
|
||||
docs/plans/2026-10-23_gate-env-rows.md, "Brief: shell suites remove the compose network they create
|
||||
(Vikunja 591)", at c16e943b9. Base c16e943b9. Mos approved the change in mos-1442 point 2. Mos approved
|
||||
the two new checks in mos-1445 under mos-1272, adding the presence half.
|
||||
|
||||
## The leak, as the code has it at c16e943b9
|
||||
|
||||
- `packages/processing-control/src/containers.mjs:16-19`. `containerArgs` builds
|
||||
`docker compose run --rm ...`. `--rm` removes the container. The project's `<project>_default`
|
||||
network stays.
|
||||
- `compose.yaml` has no top-level `name:`, so compose names the project after the checkout
|
||||
directory. Every gate worktree left one network. By 10-23 they had used up Docker's address pools.
|
||||
|
||||
## Does COMPOSE_PROJECT_NAME reach compose? Yes, on every path the two suites take
|
||||
|
||||
- Task path. `scripts/run-task.sh:29` execs `node scripts/mosaic-task.mjs`. Nothing scrubs the env
|
||||
before that: `scripts/common.sh` has no `env -i`, no `unset` and no COMPOSE. `mosaic-task.mjs:326`
|
||||
sets `spawnEnv = { ...process.env }`, `:405` passes `env: spawnEnv` to `runContainer`, and
|
||||
`containers.mjs:25` passes those options to `spawnSync('docker', ...)`.
|
||||
- test-release. `release.sh activate` calls `health_check`, and `health_check` calls `run-task.sh`
|
||||
at `:54`, which is the task path above. `env MOSAIC_MOCK_RESPONSE=...` adds to the env and keeps
|
||||
the rest. The suite calls only `status`, `activate` and `rollback`, so `release.sh:122`
|
||||
`compose build` is off its path.
|
||||
- test-task seat cases (`:240`, `:315-351`). `agent.sh:209` execs
|
||||
`packages/processing-control/src/container-cli.mjs`, and `container-cli.mjs:16` calls
|
||||
`runContainer` with `env: process.env`.
|
||||
- `build.sh:13`, `release.sh:122` and `verify.sh:40` also run compose. Neither suite calls them.
|
||||
|
||||
## Change (scripts/test-release.sh, scripts/test-task.sh only)
|
||||
|
||||
- After `SANDBOX="$(mktemp -d)"`, export
|
||||
`COMPOSE_PROJECT_NAME=mosaic-test-<release|task>-<id>`. `<id>` is the sandbox name after its last
|
||||
dot, lowercased, alphanumeric only.
|
||||
- `remove_compose_network` runs `docker network rm "${COMPOSE_PROJECT_NAME}_default"`:
|
||||
- If there's no docker binary, it returns 0.
|
||||
- If the error says `not found` or `No such network`, it returns 0.
|
||||
- On any other failure it prints one stderr line naming the network, then returns 0. That
|
||||
includes a daemon that is down. Sage kept that line in sage-3367 (a).
|
||||
- Each existing EXIT trap keeps everything it did and gains `; remove_compose_network` at the end.
|
||||
The trap's own exit status doesn't change the script's.
|
||||
- One new check per suite, just before the summary. That is after the last Docker step whether or
|
||||
not the live cases ran. Both halves sit inside a `docker info` branch:
|
||||
- the network is present under the run's project name before the explicit
|
||||
`remove_compose_network` (this proves the export reached compose);
|
||||
- after the remove, it is absent.
|
||||
|
||||
A failing `docker network ls` fails the check. Without the daemon, the check prints a skip line.
|
||||
- No `docker compose down`, and no other network is touched. No product file changes.
|
||||
|
||||
Commits: 541dee2b7 (draft), 3bce852d5 (presence half, mos-1445).
|
||||
|
||||
## Gate (Sage's slot, in a gap)
|
||||
|
||||
- Before the first run, run `docker network rm` on a name that was never created and record the
|
||||
daemon's exact wording (sage-3367 b). If the wording matches neither pattern, stop and report to
|
||||
Sage.
|
||||
- Each suite runs whole from a gate worktree, under `timeout -k 10 900`. That is far more than
|
||||
3x the row 302 gate's walls: test-release 3 s, test-task 16 s (mos-1275).
|
||||
- TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`.
|
||||
- Before and after each run, record `docker network ls` filtered to `mosaic-test-`. The run's
|
||||
network must be absent after.
|
||||
- Grep `^skip` and name every skip.
|
||||
- A run killed by the timeout's SIGKILL skips the EXIT trap and leaks its one network. The name is
|
||||
per-run under `mosaic-test-`, so the gate's after-listing finds it and removes it by its exact name
|
||||
(Filbert N1; no code change).
|
||||
- If `docker info` works but there is no network to find, that's a first-run red. It goes to Sage
|
||||
for Mos, unfixed (mos-1445).
|
||||
|
||||
## Mutants (mos-1445)
|
||||
|
||||
- (a) Drop the export. The new check must go red, because compose uses the directory's project and
|
||||
nothing exists under the run's name.
|
||||
- (b) Make the function's `docker network rm` a no-op. The new check must go red, because the
|
||||
network is still present after the remove.
|
||||
- (c) Drop `remove_compose_network` from the trap. This one is a known survivor: the explicit call
|
||||
covers the normal path, and the trap covers early exits.
|
||||
|
||||
Each mutant runs on an isolated copy of the file. Restores are checked by sha256. After each mutant, the network it
|
||||
leaves is removed by its exact name (mos-1438). Mutant (a) leaves the worktree directory's
|
||||
`<dir>_default`, and mutant (b) leaves the run's `${COMPOSE_PROJECT_NAME}_default`.
|
||||
@@ -0,0 +1,88 @@
|
||||
start 2026-10-23T11:14:01Z HEAD 8ddba89b7 refactor b99ca86b9 docker 29.7.2
|
||||
== wording
|
||||
rc=1 stderr: Error response from daemon: network mosaic-test-absent-46829331_default not found
|
||||
exit status 1
|
||||
== green
|
||||
TMPDIR /mnt/storage/scratch/tmp/gIj4
|
||||
nets[before test-task]:
|
||||
test-task rc=0 wall 16 s :: selftest: 92 passed, 0 failed
|
||||
OK compose network made under the run's project and removed after the run
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
nets[after test-task]:
|
||||
TMPDIR /mnt/storage/scratch/tmp/gDYF
|
||||
nets[before test-release]:
|
||||
test-release rc=0 wall 4 s :: selftest: 15 passed, 0 failed
|
||||
OK compose network made under the run's project and removed after the run
|
||||
nets[after test-release]:
|
||||
== mutants
|
||||
Ma scripts/test-task.sh | 2 +-
|
||||
TMPDIR /mnt/storage/scratch/tmp/g6SN
|
||||
nets[before Ma-task]:
|
||||
Ma-task rc=1 wall 16 s :: selftest: 91 passed, 1 failed
|
||||
FAIL compose network made under the run's project and removed after the run
|
||||
red: FAIL compose network made under the run's project and removed after the run
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
nets[after Ma-task]: gizmo-305_default
|
||||
restore ok scripts/test-task.sh
|
||||
Ma-task KILLED
|
||||
cleaned gizmo-305_default
|
||||
nets[after cleanup Ma-task]:
|
||||
Ma scripts/test-release.sh | 2 +-
|
||||
TMPDIR /mnt/storage/scratch/tmp/g48Q
|
||||
nets[before Ma-release]:
|
||||
Ma-release rc=1 wall 3 s :: selftest: 14 passed, 1 failed
|
||||
FAIL compose network made under the run's project and removed after the run
|
||||
red: FAIL compose network made under the run's project and removed after the run
|
||||
nets[after Ma-release]: gizmo-305_default
|
||||
restore ok scripts/test-release.sh
|
||||
Ma-release KILLED
|
||||
cleaned gizmo-305_default
|
||||
nets[after cleanup Ma-release]:
|
||||
Mb scripts/test-task.sh | 2 +-
|
||||
TMPDIR /mnt/storage/scratch/tmp/gcm1
|
||||
nets[before Mb-task]:
|
||||
Mb-task rc=1 wall 15 s :: selftest: 91 passed, 1 failed
|
||||
FAIL compose network made under the run's project and removed after the run
|
||||
red: FAIL compose network made under the run's project and removed after the run
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
nets[after Mb-task]: mosaic-test-task-pf33rz8tic_default
|
||||
restore ok scripts/test-task.sh
|
||||
Mb-task KILLED
|
||||
cleaned mosaic-test-task-pf33rz8tic_default
|
||||
nets[after cleanup Mb-task]:
|
||||
Mb scripts/test-release.sh | 2 +-
|
||||
TMPDIR /mnt/storage/scratch/tmp/gSxg
|
||||
nets[before Mb-release]:
|
||||
Mb-release rc=1 wall 3 s :: selftest: 14 passed, 1 failed
|
||||
FAIL compose network made under the run's project and removed after the run
|
||||
red: FAIL compose network made under the run's project and removed after the run
|
||||
nets[after Mb-release]: mosaic-test-release-db4p70csnf_default
|
||||
restore ok scripts/test-release.sh
|
||||
Mb-release KILLED
|
||||
cleaned mosaic-test-release-db4p70csnf_default
|
||||
nets[after cleanup Mb-release]:
|
||||
Mc scripts/test-task.sh | 2 +-
|
||||
TMPDIR /mnt/storage/scratch/tmp/g6WJ
|
||||
nets[before Mc-task]:
|
||||
Mc-task rc=0 wall 16 s :: selftest: 92 passed, 0 failed
|
||||
OK compose network made under the run's project and removed after the run
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
skip: skip live provider cases (row 244: no provider call in the gate)
|
||||
nets[after Mc-task]:
|
||||
restore ok scripts/test-task.sh
|
||||
Mc-task survived (known)
|
||||
nets[after cleanup Mc-task]:
|
||||
Mc scripts/test-release.sh | 2 +-
|
||||
TMPDIR /mnt/storage/scratch/tmp/g2jz
|
||||
nets[before Mc-release]:
|
||||
Mc-release rc=0 wall 3 s :: selftest: 15 passed, 0 failed
|
||||
OK compose network made under the run's project and removed after the run
|
||||
nets[after Mc-release]:
|
||||
restore ok scripts/test-release.sh
|
||||
Mc-release survived (known)
|
||||
nets[after cleanup Mc-release]:
|
||||
tree clean after mutants
|
||||
end 2026-10-23T11:15:17Z
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 305 gate (sage-3374): wording, both suites whole, mutants a/b/c with exact-name cleanup.
|
||||
set -uo pipefail
|
||||
WT=/mnt/storage/scratch/gizmo-305
|
||||
OUT=/mnt/storage/scratch/gizmo-305-out/gate
|
||||
mkdir -p "$OUT"
|
||||
cd "$WT" || exit 9
|
||||
[ "$(git rev-parse HEAD)" = 8ddba89b7442f09fc71cf24d7f250e40a7f60fdf ] || { echo "HEAD drift"; exit 9; }
|
||||
[ -z "$(git status --porcelain)" ] || { echo "dirty tree"; exit 9; }
|
||||
[ -L node_modules ] || ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules
|
||||
[ -L node_modules ] || { echo "no node_modules link"; exit 8; }
|
||||
DIRNET=gizmo-305_default
|
||||
nets() { echo " nets[$1]: $(docker network ls --format '{{.Name}}' | grep -E '^(mosaic-test-|gizmo-305_)' | tr '\n' ' ')"; }
|
||||
newtmp() { export TMPDIR="$(mktemp -d /mnt/storage/scratch/tmp/gXXX)"; echo " TMPDIR $TMPDIR"; }
|
||||
run() { # name script
|
||||
local name=$1 s=$2; newtmp; nets "before $name"
|
||||
local t0=$SECONDS
|
||||
timeout -k 10 900 "$s" > "$OUT/$name.log" 2>&1; local rc=$?
|
||||
echo "$name rc=$rc wall $((SECONDS-t0)) s :: $(grep -E '^selftest:' "$OUT/$name.log")"
|
||||
[ $rc -eq 124 ] || [ $rc -eq 137 ] && echo "SHELL-PHASE TIMEOUT (mos-1275): to Mos unplaced"
|
||||
grep -E 'compose network' "$OUT/$name.log" | sed 's/^/ /'
|
||||
grep -E '^FAIL|could not remove' "$OUT/$name.log" | sed 's/^/ red: /'
|
||||
grep -E '^skip' "$OUT/$name.log" | sed 's/^/ skip: /'
|
||||
nets "after $name"
|
||||
LASTRC=$rc
|
||||
}
|
||||
echo "start $(date -u +%FT%TZ) HEAD $(git rev-parse --short HEAD) refactor $(git rev-parse --short origin/refactor) docker $(docker version --format '{{.Server.Version}}')"
|
||||
echo "== wording"
|
||||
ABS="mosaic-test-absent-$RANDOM$RANDOM"_default
|
||||
docker network rm "$ABS" >/dev/null 2>"$OUT/absent.txt"; echo " rc=$? stderr: $(cat "$OUT/absent.txt")"
|
||||
grep -qE 'not found|No such network' "$OUT/absent.txt" || { echo "STOP: wording matches neither pattern"; exit 3; }
|
||||
echo "== green"
|
||||
for s in task release; do run "test-$s" "scripts/test-$s.sh"; [ $LASTRC -eq 0 ] || { echo "STOP: red on green run"; exit 4; }; done
|
||||
nets "after green" | grep -qE 'mosaic-test-|gizmo-305_' && { echo "STOP: network left after green"; exit 5; }
|
||||
echo "== mutants"
|
||||
mut() { # tag python-edit expected(red|green)
|
||||
local tag=$1 edit=$2 want=$3
|
||||
for s in task release; do
|
||||
local f="scripts/test-$s.sh" sum; sum=$(sha256sum "$f" | cut -d' ' -f1)
|
||||
cp -p "$f" "$OUT/$s.orig"
|
||||
python3 -B -c "$edit" "$f" || { echo "mutant $tag edit failed"; exit 6; }
|
||||
git diff --stat -- "$f" | head -1 | sed "s/^/ M$tag /"
|
||||
run "M$tag-$s" "$f"
|
||||
cp -p "$OUT/$s.orig" "$f"
|
||||
[ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ] && echo " restore ok $f" || { echo "RESTORE MISMATCH $f"; exit 7; }
|
||||
if [ "$want" = red ]; then grep -q '^FAIL.*compose network' "$OUT/M$tag-$s.log" && echo " M$tag-$s KILLED" || echo " M$tag-$s SURVIVED (unexpected)"
|
||||
else grep -q '^OK.*compose network' "$OUT/M$tag-$s.log" && echo " M$tag-$s survived (known)" || echo " M$tag-$s unexpected red"; fi
|
||||
# exact-name cleanup (mos-1438)
|
||||
for n in $(docker network ls --format '{{.Name}}' | grep -E '^(mosaic-test-(task|release)-[a-z0-9]+_default|gizmo-305_default)$'); do
|
||||
docker network rm "$n" >/dev/null && echo " cleaned $n"
|
||||
done
|
||||
nets "after cleanup M$tag-$s"
|
||||
done
|
||||
}
|
||||
E='import sys;p=sys.argv[1];s=open(p).read();o=%s;n=%s;assert s.count(o)==1;open(p,"w").write(s.replace(o,n))'
|
||||
mut a "$(printf "$E" "'export COMPOSE_PROJECT_NAME='" "'COMPOSE_PROJECT_NAME='")" red
|
||||
mut b "$(printf "$E" "'err=\"\$(docker network rm \"\$net\" 2>&1 >/dev/null)\" && return 0'" "'err=\"\" && return 0'")" red
|
||||
mut c "$(printf "$E" "'; remove_compose_network'\"'\"' EXIT'" "''\"'\"' EXIT'")" green
|
||||
[ -z "$(git status --porcelain | grep -v '^?? node_modules$')" ] && echo "tree clean after mutants" || { echo "TREE DIRTY"; git status --porcelain; }
|
||||
echo "end $(date -u +%FT%TZ)"
|
||||
@@ -0,0 +1,180 @@
|
||||
# Row 307 (#1814, Vikunja 598): move row 270's pi compaction reading behind the harness kind
|
||||
|
||||
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
|
||||
docs/plans/2026-10-23_harness-kind-compactions.md. Base 1e629c892. Design: DESIGN.md beside this file,
|
||||
approved (static) by Duckworth on 67daf6403 (duckworth-0280), with C1, C2, N1 and N2 folded in at
|
||||
cb048b93c. Mos ruled go on the four old-test edits in mos-1459, with the bounds below.
|
||||
|
||||
## Commits
|
||||
|
||||
1. 1f6db1589, red first. The two new test files and capture.mjs. Its product files are base's:
|
||||
`git diff --stat 1e629c892 1f6db1589` lists only this packet, capture.mjs and the two tests.
|
||||
2. 44104adb1, the build. The product files and the four old-test edits.
|
||||
|
||||
## What the build does
|
||||
|
||||
- `packages/plugins/src/kinds.mjs:52-57`: the harness kind's exports end in `"compactions"`, and the
|
||||
comment gains its contract line. `interfaceVersion` stays 1 (DESIGN.md "interfaceVersion stays 1").
|
||||
- New `packages/plugins/harness/pi/compactions.mjs`: base's `scanPi`, `readRange`, `digest`, `STREAM`,
|
||||
`TAIL`, the chunk size, `PI_VERSION` and `claudeHistory`, unchanged in behaviour, plus the export.
|
||||
`scanPi` is no longer exported and takes no defaults: its one caller passes all four arguments.
|
||||
- `pi/index.mjs` re-exports it. `claude-code/index.mjs` answers harness-unsupported with version null.
|
||||
- `packages/seat/src/compactions.mjs`: keeps the ledger. `readLedger` sets `unsupported` on
|
||||
`r.reason === "harness-unsupported"` only (:113). `checkAnswer` (:147-164) refuses an answer out of
|
||||
contract. `collectCompactions(store, { name, compactions }, opts)` (:174-205) calls the export, checks
|
||||
it, and writes the same lines in the same order as base.
|
||||
- `packages/cli/src/launcher.mjs`: the `compactions` closure takes the loaded module and calls the
|
||||
export directly, with the N1 comment on why it skips `call()`. Both call sites pass `harness`.
|
||||
- `packages/plugins/README.md`: the Harness section gains the `compactions` bullet.
|
||||
|
||||
## Old-test edits (mos-1459 bounds)
|
||||
|
||||
| File | Edit | Bound |
|
||||
| --- | --- | --- |
|
||||
| plugins/tests/kinds.test.mjs | `"compactions"` appended to the pinned list | :17-21, append only |
|
||||
| seat/tests/compactions.test.mjs | two module imports; `collect` passes `{ name, compactions }` | :11/:21, helper only |
|
||||
| seat/tests/compactions-budget.test.mjs | pi import; `collect` passes `{ name, compactions }` | :13/:24-25, helper only |
|
||||
| cli/tests/compactions-launch.test.mjs | pi import; base :344's call (now :345) passes `{ name: "pi", compactions: pi }` | call only, `.count === 1` kept |
|
||||
|
||||
No call or expected value in those files changed. harness.test.mjs, cli helpers.mjs:133-151,
|
||||
launch-routes.test.mjs:17-23 and launcher.test.mjs:694-697 are unedited.
|
||||
|
||||
## New tests
|
||||
|
||||
- `packages/seat/tests/compactions-ledger.test.mjs`:
|
||||
- FIXTURES: seven ledgers row 270's code writes (known, store-incomplete, store-unreadable,
|
||||
not-collected, harness-unsupported from claude-code, harness-unsupported from Claude history, and
|
||||
torn), each with its streams, projects and steps. Per fixture, test A reads `compactionCount` and
|
||||
test B runs one collection through `{ name: "pi", compactions: pi.compactions }`.
|
||||
- Test B runs over the fixture's own streams, not an empty session as DESIGN.md:189 says. Over an
|
||||
empty session every fixture with a cursor would go store-incomplete (the stream went away), so
|
||||
"the others go known" could not hold. The expected answers: known stays known (1), store-unreadable
|
||||
goes known with 0 (the cursor is at the end), not-collected goes known with 1, and
|
||||
store-incomplete, torn and both harness-unsupported fixtures stay unknown.
|
||||
- The malformed-answer test (17 answers, plus a module that throws, over a fresh store and over the
|
||||
known fixture) and the argument test (exact keys; core passes a copy of the cursors).
|
||||
- `packages/plugins/tests/harness-compactions.test.mjs`: CASES plain, budget (100 bytes), cap (1
|
||||
stream of 2), shrank and header against pi; pi's `first` with and without Claude history;
|
||||
claude-code; and a module re-exporting every pi export but `compactions`, refused `missing-export`.
|
||||
- capture.mjs reads both capture blocks from the test files. It replays each fixture with base's
|
||||
`collectCompactions(store, "<name>", ...)` and compares the ledger byte for byte, and it runs base's
|
||||
`scanPi` per case and compares the answer under base's reason order (:301 at base) and
|
||||
`PI_VERSION`. It prints base's `compactionCount` per fixture: that is the before half.
|
||||
|
||||
## Expected red on 1f6db1589
|
||||
|
||||
- seat compactions-ledger: the seven test A pass (the ledger rules are base's). Test B fails for known,
|
||||
store-unreadable and not-collected: base takes the object as a harness that isn't pi and writes
|
||||
harness-unsupported. Test B passes on base for the other four, whose expected answer is unknown with
|
||||
the same reason. The malformed-answer and argument tests fail: base never calls the module.
|
||||
Correction after the run: test B fails on base for claude-history too, so the reds are known,
|
||||
store-unreadable, not-collected and claude-history. On base the object harness takes :304, which
|
||||
writes `collected` without a scan, so the count stays null. Pi on the tip scans on a later
|
||||
collection, finds c1 and answers 1, still unknown with harness-unsupported. The test is right and
|
||||
this list was wrong (Duckworth agreed, duckworth-0287).
|
||||
- plugins harness-compactions: the five CASES, the `first` test and the claude-code test fail with a
|
||||
TypeError (`compactions` isn't exported on base). The missing-export test fails: base's loader loads
|
||||
the module.
|
||||
- harness.test.mjs:192 is already red on base. That's the brief's starting point.
|
||||
|
||||
## Run plan (mos-1275, `timeout -k 10`)
|
||||
|
||||
All on a CLEAR from Sage. TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`. Gate worktrees
|
||||
under /mnt/storage/scratch, node_modules linked right after `git worktree add`, pipefail and tee.
|
||||
|
||||
| Run | Longest recorded | LIMIT |
|
||||
| --- | --- | --- |
|
||||
| capture.mjs on a 1f6db1589 worktree (base and candidate both it) | none | 120 s |
|
||||
| red first: the two new files on 1f6db1589, each whole | none (seat suite 7 s) | 300 s |
|
||||
| the touched test files whole on 44104adb1, one at a time | compactions-launch alone: none; other files 15 s | 600 s |
|
||||
| bus suite | 25 s (270 r5) | 900 s |
|
||||
| cli suite | 682 s (270 r5) | 2100 s |
|
||||
| plugins suite | none recorded | 900 s |
|
||||
| webui suite | 58.2 s (294) | 600 s |
|
||||
| business suite | none recorded | 900 s |
|
||||
| seat suite whole, `node --test tests/` (Duckworth C1) | 7 s (270 r5) | 600 s |
|
||||
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
|
||||
| each mutant, its kill files whole | 1 s (264a probes) | 300 s |
|
||||
|
||||
- In the cli run, compactions-launch's T3 tests (base :206, and :305 with the :344 edit; :207, :306
|
||||
and :345 after the import line) must show as run, not skipped on `sandboxProblem()` (base :36, now
|
||||
:37). The report names every skip (Duckworth C2).
|
||||
- Plugins must have zero failures.
|
||||
- A timeout with no failed test is a hang and goes to Sage. A timeout in the shell phase goes to Mos
|
||||
unplaced. A first-run red in a new 307 test is placed and reported after (mos-1272). A red in an old
|
||||
test goes to Sage, unfixed.
|
||||
|
||||
## Mutants
|
||||
|
||||
Each on an isolated copy of the file, restore checked by sha256, only on a green base.
|
||||
|
||||
| # | Change | Kill files |
|
||||
| --- | --- | --- |
|
||||
| M1 | `readLedger`: `if (r.harness !== "pi" \|\| r.reason === "harness-unsupported")` restored | plugins harness.test.mjs (:192) |
|
||||
| M2 | pi `compactions` drops the `first && claudeHistory(store)` branch | seat compactions-budget.test.mjs (base :86, now :87), plugins harness-compactions.test.mjs |
|
||||
| M3 | `readLedger` never sets `unsupported` | seat compactions-ledger.test.mjs, seat compactions.test.mjs (base :197, now :199) |
|
||||
| M4 | `collectCompactions` skips `checkAnswer` | seat compactions-ledger.test.mjs |
|
||||
|
||||
## Runs
|
||||
|
||||
Sage's CLEARs: sage-3455 for steps 1-3, sage-3465 for steps 4-6. Gate worktrees at
|
||||
/mnt/storage/scratch/g307-base (1f6db1589), g307-tip and g307-mut (1f00d4f91), with node_modules
|
||||
linked. TMPDIR was /mnt/storage/scratch/tmp/gTIK. Outputs went to /mnt/storage/scratch/gizmo-307-out/s1 and s2.
|
||||
|
||||
### Steps 1-3 (13:31:21Z-13:31:36Z, then the capture rerun at 13:32Z)
|
||||
|
||||
- capture.mjs crashed with ENOENT on its first run. Base `sessionStore` (session.mjs:78
|
||||
`privateDirs`, called from :117) makes its directories one level at a time, and :44 passed a
|
||||
per-fixture root that didn't exist yet. The fix is one line before :44,
|
||||
`` mkdirSync(join(root, `f-${name}`)) ``. A copy with that line, run on g307-base as both base and
|
||||
candidate, exited rc 0 and printed `all match`: 7 ledgers byte for byte, 7 base counts and 5 scans.
|
||||
Base counts: known 1 known; store-incomplete, store-unreadable and not-collected null, unknown
|
||||
with their own reason; claude-code and claude-history null, harness-unsupported; torn 1,
|
||||
store-incomplete.
|
||||
- Red first on 1f6db1589, both rc 1. Seat compactions-ledger: 9 pass, 7 fail (the corrected list
|
||||
above, plus the malformed-answer and argument tests). Plugins harness-compactions: 0 pass, 8 fail.
|
||||
Seven fail with `TypeError: pi.compactions is not a function` or `claude.compactions is not a
|
||||
function`. The missing-export test fails because base loads the module.
|
||||
- Touched files whole on 1f00d4f91, all rc 0: compactions-ledger 16/16, harness-compactions 8/8,
|
||||
kinds 5/5, harness 9/9 (:192 green), compactions 20/20, compactions-budget 10/10,
|
||||
compactions-launch 5/5 with 0 skipped.
|
||||
|
||||
### Steps 4-6 (14:00:10Z-14:14:00Z, on 1f00d4f91)
|
||||
|
||||
| Run | rc | Time | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| bus | 0 | 24 s | 890 pass, 0 fail, 0 skipped |
|
||||
| cli | 0 | 631 s | 927 pass, 0 fail, 0 skipped; T3 :207, :306, :345 ran |
|
||||
| plugins | 0 | 0 s | 119 pass, 0 fail, 0 skipped |
|
||||
| webui | 0 | 57 s | 374 pass, 0 fail, 1 skipped (below) |
|
||||
| business | 0 | 2 s | 231 pass, 0 fail, 0 skipped |
|
||||
| seat | 0 | 7 s | 218 pass, 0 fail, 0 skipped |
|
||||
| test-auth | 0 | 0 s | 15 passed, 0 failed |
|
||||
| test-conductor | 0 | 7 s | |
|
||||
| test-config | 0 | 3 s | |
|
||||
| test-discord | 0 | 7 s | |
|
||||
| test-extension-package | 0 | 2 s | |
|
||||
| test-foundation | 0 | 39 s | |
|
||||
| test-land-work | 0 | 12 s | |
|
||||
| test-queue | 0 | 20 s | one skip (below) |
|
||||
| test-release | 0 | 3 s | 15 passed, 0 failed |
|
||||
| test-task | 0 | 14 s | 92 passed, 0 failed, two skips (below) |
|
||||
|
||||
Skips:
|
||||
- webui: "rebuilding from the source gives the same bytes" needs the pinned esbuild. 307 doesn't
|
||||
touch the editor.
|
||||
- test-queue: "queue verify and render --check", because the gate worktree isn't the queue's
|
||||
canonical root.
|
||||
- test-task: "live provider cases" twice (row 244, no provider call in the gate).
|
||||
|
||||
Mutants ran in g307-mut. Each restore matched its sha256, and the tree was clean after M4.
|
||||
|
||||
| # | Killed by |
|
||||
| --- | --- |
|
||||
| M1 | plugins harness.test.mjs:192 |
|
||||
| M2 | seat compactions-budget.test.mjs:87 and :104; plugins harness-compactions.test.mjs:47 |
|
||||
| M3 | seat compactions-ledger.test.mjs:123 (test B, claude-code and claude-history); seat compactions.test.mjs:199 |
|
||||
| M4 | seat compactions-ledger.test.mjs:151 (the malformed-answer test) |
|
||||
|
||||
There were no reds, so nothing was placed under mos-1272. The sweep found no node or chromium left in
|
||||
my cwds.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Row 307 (#1814, Vikunja 598): row 270's pi compaction reading moves behind the harness kind
|
||||
|
||||
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
|
||||
`docs/plans/2026-10-23_harness-kind-compactions.md` at 1e629c892, which is the base. Mos ruled the
|
||||
move in mos-1455 item 3 and declined an EXCEPT entry.
|
||||
|
||||
## What is red, as the code has it at 1e629c892
|
||||
|
||||
`packages/plugins/tests/harness.test.mjs:192` greps core sources for a quoted harness name. It finds
|
||||
three lines in `packages/seat/src/compactions.mjs`:
|
||||
|
||||
- `:232` in `readLedger`: `if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;`
|
||||
- `:283` and `:287` in `collectCompactions`: the `harness === "pi"` branches.
|
||||
|
||||
Everything else row 270 put in that file is either harness-free (the ledger, the budget and stream
|
||||
settings, `storeDir`, `compactionCount`) or pi-only (`scanPi`, `PI_VERSION`, `claudeHistory`, and
|
||||
the helpers only `scanPi` uses: `readRange`, `digest`, `STREAM`, `TAIL`).
|
||||
|
||||
## The new export: `compactions`
|
||||
|
||||
`packages/plugins/src/kinds.mjs:54` appends `"compactions"` to the harness kind's exports, so the
|
||||
loader refuses a module without it (`loader.mjs:61-66`, code `missing-export`).
|
||||
|
||||
```
|
||||
compactions({ store, cursors, budget, streams, first })
|
||||
-> { found, cursors, known, reason, version }
|
||||
```
|
||||
|
||||
Arguments, all from core:
|
||||
|
||||
- `store` is `{ session, projects }`, the two native store directories `sessionStore` makes. The
|
||||
module never gets `store.dir`, the directory that holds the ledger.
|
||||
- `cursors` is a copy of the ledger's cursor map, `{ [stream]: { bytes, tail } }`.
|
||||
- `budget` and `streams` are `readBudget(...)` and `readStreams(...)`, as the launcher passes them
|
||||
now (`launcher.mjs:299`, `:305`). Core keeps both readers and their defaults.
|
||||
- `first` is true when the store has no ledger yet (today's `!prior`).
|
||||
|
||||
The answer:
|
||||
|
||||
- `found`: `[{ entry, completedAt, stream }]`, the compactions read past the cursors. `entry` is a
|
||||
non-empty string, `completedAt` a string or null, `stream` a non-empty string.
|
||||
- `cursors`: `{ [stream]: { bytes, tail } }` for each stream the module read. `bytes` is a safe
|
||||
integer of 0 or more and `tail` 64 lowercase hex.
|
||||
- `reason`: null, `store-incomplete`, `store-unreadable`, `not-collected` or `harness-unsupported`.
|
||||
- `known`: a boolean, true exactly when `reason` is null.
|
||||
- `version`: the store format the module read against as a string, or null when it read nothing.
|
||||
|
||||
Core checks the answer before it writes. Anything else throws `harness <name> compactions: bad
|
||||
answer: <what>` and nothing is appended. A throw from the module itself is the same case. Both reach
|
||||
the launcher's existing catch (`launcher.mjs:313-316`), which logs the message and records the run's
|
||||
`compactions` event as unknown with `store-unreadable`, as a scan that throws today does.
|
||||
|
||||
### interfaceVersion stays 1
|
||||
|
||||
- Every harness module lives in this tree, and the loader loads nothing else (decision 143,
|
||||
`loader.mjs:1-5`). Both shipped modules gain the export in the same commit.
|
||||
- A module without the export already refuses at load, as `missing-export` naming `compactions`.
|
||||
That is the brief's third test. A bump would add a second refusal for the same fact.
|
||||
- No existing export changes its arguments or answer. Row 125 moved limit-check to 2 because
|
||||
`mayLaunch`, `mustStop` and `report` changed shape. Nothing like that happens here.
|
||||
- `harness.test.mjs:36` pins `interfaceVersion` 1, and the brief keeps that file unchanged.
|
||||
|
||||
The tradeoff: a module someone wrote against version 1 without `compactions` reports
|
||||
`missing-export`, not `interface-version`. The message names the export, which is the more useful of
|
||||
the two.
|
||||
|
||||
## What moves where
|
||||
|
||||
New file `packages/plugins/harness/pi/compactions.mjs`, imported by `pi/index.mjs`. The loader
|
||||
checks only `index.mjs` itself (`loader.mjs:42-54`), and claude-code's module already imports files
|
||||
outside its directory (`claude-code/index.mjs:11-12`). It takes, unchanged in behaviour:
|
||||
|
||||
- `scanPi`, with `readRange`, `digest`, `STREAM`, `TAIL` and the chunk size it reads by;
|
||||
- `PI_VERSION` ("0.85.1");
|
||||
- `claudeHistory(store)`;
|
||||
- the reason order `["store-incomplete", "store-unreadable", "not-collected"]` from `:301`;
|
||||
- the header comment's Pi paragraphs (`compactions.mjs:12-19`, `:21-24`).
|
||||
|
||||
pi's `compactions`:
|
||||
|
||||
- `first` and `claudeHistory(store)`: answers `{ found: [], cursors: {}, known: false, reason:
|
||||
"harness-unsupported", version: "0.85.1" }`. That is today's `:283-286`.
|
||||
- otherwise: `scanPi(store.session, cursors, budget, streams)`, and answers its `found` and
|
||||
`cursors` with the first reason in the order above, `known` when that's null, and version
|
||||
"0.85.1". That is today's `:287-302` before the dedupe.
|
||||
|
||||
claude-code's `compactions` reads nothing and answers `{ found: [], cursors: {}, known: false,
|
||||
reason: "harness-unsupported", version: null }`. That is today's `:303-304`. Vikunja 466 still has
|
||||
no pinned Claude Code format.
|
||||
|
||||
## Core keeps the ledger
|
||||
|
||||
`packages/seat/src/compactions.mjs` keeps `LEDGER`, `READ_BYTES`, `STREAMS`, `readBudget`,
|
||||
`readStreams`, `storeDir`, `readLedger`, `compactionCount`, the key and the single O_APPEND write.
|
||||
|
||||
`collectCompactions(store, harness, { now, budget, streams })` takes `harness = { name,
|
||||
compactions }`, the name the route used and the loaded module's export. It:
|
||||
|
||||
1. reads the ledger (`prior`), as now;
|
||||
2. calls `harness.compactions({ store: { session: store.session, projects: store.projects },
|
||||
cursors: { ...ledger.cursors }, budget, streams, first: prior === null })` and checks the answer;
|
||||
3. appends, in today's order: one `compaction` line per key the ledger doesn't hold yet, one
|
||||
`cursor` line per stream whose cursor moved, then `{ v: 1, kind: "collected", at, known, reason,
|
||||
harness: name, version }`. These are the same lines and field order as today's `:288-302` and
|
||||
`:304`. `name` is a variable, never a literal;
|
||||
4. returns `compactionCount(store.dir)`.
|
||||
|
||||
`readLedger`'s unsupported test becomes `r.reason === "harness-unsupported"`, a field every answer
|
||||
carries. No core line names a harness. The core comments drop the quoted names too, because the
|
||||
grep reads comments.
|
||||
|
||||
`packages/cli/src/launcher.mjs:309-318`: the `compactions` closure takes the loaded module too and
|
||||
calls `collectCompactions(store, { name: model.harness, compactions: harness.compactions }, {
|
||||
budget, streams })`. Both call sites, `:509` and `:650`, have `harness` in scope (it's
|
||||
`startLocked`'s `let harness` from `:503`; the `exited.then` callback at `:632` closes over it).
|
||||
The closure calls the export directly, not through `call()` (`launcher.mjs:196`), because `call()`
|
||||
turns a throw into a run refusal. The closure gets a comment saying so (Duckworth N1).
|
||||
`bus/src/runtime.mjs:282` and `broker.mjs:885-889` keep calling `compactionCount` and don't change.
|
||||
|
||||
`packages/plugins/README.md`, Harness section, and the comment at `kinds.mjs:35-51` each gain the
|
||||
`compactions` line with the contract above.
|
||||
|
||||
## A ledger row 270 wrote reads the same count
|
||||
|
||||
Row 270's code writes a `collected` line in exactly three places:
|
||||
|
||||
| Line | harness | reason | version | old rule (`:232`) | new rule |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `:286`, Claude history on the first pi collection | `"pi"` | `harness-unsupported` | "0.85.1" | unsupported | unsupported |
|
||||
| `:302`, a pi scan | `"pi"` | null, `store-incomplete`, `store-unreadable` or `not-collected` | "0.85.1" | supported | supported |
|
||||
| `:304`, any other harness name (`claude-code`, or `claude` in the tests) | that name | `harness-unsupported` | null | unsupported | unsupported |
|
||||
|
||||
The two rules agree on every line row 270 can write. The rest of `readLedger` (the `bad`, `torn`,
|
||||
`incomplete` and `events` handling) and all of `compactionCount` don't change. So every ledger row
|
||||
270 wrote reads the same `{ count, known, reason, at }` after the move. Nothing rewrites a ledger;
|
||||
the next collection only appends.
|
||||
|
||||
The rules would differ only on a `collected` line naming a harness other than `"pi"` with a reason
|
||||
other than `harness-unsupported`. Row 270 never writes one, and the launcher is the ledger's only
|
||||
writer (`compactions.mjs:7-10`).
|
||||
|
||||
New collections after the move append the same bytes as before for the same store state. pi's
|
||||
answer is today's scan result, core keeps the dedupe and the line format, and `harness` in the line
|
||||
is still the route's name.
|
||||
|
||||
## Existing tests that change (to Mos through Sage, mos-1272)
|
||||
|
||||
Grepped for `interfaceVersion`, the harness exports, `write("harness"`, `fakeHarnesses` and
|
||||
`collectCompactions(` in `packages/*/tests/*` and `scripts/`.
|
||||
|
||||
Four existing files change:
|
||||
|
||||
1. `packages/plugins/tests/kinds.test.mjs:17-21`: the pinned harness export list gains
|
||||
`"compactions"` at the end.
|
||||
2. `packages/seat/tests/compactions.test.mjs:11` and `:21`: import the two shipped modules. `setup`'s
|
||||
`collect(harness = "pi")` passes `{ name: harness, compactions: MODULES[harness].compactions }`,
|
||||
with `pi` mapped to the pi module and `claude` to claude-code's. The 27 `f.collect()` calls and
|
||||
the two named ones at `:199` and `:201` stay as written.
|
||||
3. `packages/seat/tests/compactions-budget.test.mjs:13` and `:24-25`: the same change in its
|
||||
`collect`. Its calls at `:67-69` and `:129-134` stay as written.
|
||||
4. `packages/cli/tests/compactions-launch.test.mjs:344`: the one direct
|
||||
`collectCompactions(legacy, "pi")` becomes `collectCompactions(legacy, { name: "pi",
|
||||
compactions: pi.compactions })`, with an import of the pi module.
|
||||
|
||||
Fixtures that build a harness module and need no edit, because they re-export a real module and
|
||||
inherit `compactions`:
|
||||
|
||||
- `packages/cli/tests/helpers.mjs:133-151` `fakeHarnesses`, used by `compactions-launch`,
|
||||
`idle-stop-race`, `launcher`, `lifecycle`, `session-clear`, `session-launch`, `thread-launch` and
|
||||
`thread-revoke`;
|
||||
- `packages/cli/tests/launch-routes.test.mjs:17-23`: `down`, `sparse`, `metered` and `throws`
|
||||
re-export pi. `broken` lacks every export but `adapter` on purpose. The loader still refuses it at
|
||||
`levels` first, so `:114` and `:138` (`missing-export`) hold;
|
||||
- `packages/cli/tests/launcher.test.mjs:694-697`: `down` and `fixture` re-export pi.
|
||||
|
||||
`packages/plugins/tests/harness.test.mjs` doesn't change. `:33-37` loops over the kind's exports,
|
||||
which both modules then have, and `:192` goes green.
|
||||
|
||||
No `scripts/test-*.sh` builds a harness module. `test-config.sh:266-280` checks only the two
|
||||
config keys.
|
||||
|
||||
## New tests, red first (mos-1272)
|
||||
|
||||
1. `packages/seat/tests/compactions-ledger.test.mjs`: one fixture ledger per reason row 270 writes:
|
||||
known, `store-incomplete`, `store-unreadable`, `not-collected`, `harness-unsupported` from
|
||||
claude-code, `harness-unsupported` from Claude history, and a torn ledger.
|
||||
- Each is literal JSONL in the test. Before the build, in the slot, a capture script runs base
|
||||
1e629c892's `collectCompactions` with a fixed `now` over the same stores. The literals must
|
||||
equal its output byte for byte. That proves row 270's code wrote them.
|
||||
- `compactionCount` reads each one. I run this half on the base and on the candidate, and record
|
||||
both outputs in BUILD.md as the before and after.
|
||||
- Then one more collection through `{ name, compactions }` over an empty session gives each
|
||||
fixture's expected next answer: unsupported and `store-incomplete` stay, the others go known.
|
||||
This half is red on the base, where core treats the object as a harness that isn't pi.
|
||||
2. `packages/plugins/tests/harness-compactions.test.mjs`:
|
||||
- pi's `compactions` over fixture streams answers what base `scanPi` answers, captured by the same
|
||||
script: a plain count, a budget that stops short (cursor at the last whole line,
|
||||
`not-collected`), more streams than the cap (no cursor for unopened streams, `not-collected`),
|
||||
a stream that shrank under its cursor (`store-incomplete`), a non-session header
|
||||
(`store-unreadable`). Each answer has version "0.85.1".
|
||||
- pi with `first` and a non-empty `projects` answers `harness-unsupported` without scanning.
|
||||
Without `first`, the same store scans.
|
||||
- claude-code answers `harness-unsupported` with version null, over directories that don't exist.
|
||||
- A harness module that re-exports every other pi export by name, without `compactions`, refuses
|
||||
at load: `missing-export`, "doesn't export compactions".
|
||||
3. In the same seat file: core refuses each malformed answer and appends nothing. The cases are
|
||||
`found` not an array, an empty `entry`, a cursor whose `bytes` is negative or `tail` isn't 64
|
||||
hex, `known` true with a reason, an unknown reason, a numeric `version`, and a module that
|
||||
throws. The module gets exactly `{ store: { session, projects }, cursors, budget, streams,
|
||||
first }`.
|
||||
|
||||
## Mutants
|
||||
|
||||
- M1, the brief's first: core branches on a harness name again (`readLedger`'s `r.harness !== "pi"
|
||||
||` restored). Killed by `harness.test.mjs:192`.
|
||||
- M2, the brief's second: pi's module drops the Claude-history check. Killed by
|
||||
`compactions-budget.test.mjs:86` and new test 2's `first` case.
|
||||
- M3, the brief's third: `readLedger` never sets `unsupported`. Killed by new test 1's two
|
||||
`harness-unsupported` fixtures and `compactions.test.mjs:197`.
|
||||
- M4, mine: core skips the answer check. Killed by new test 3.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves this design, then the manifest. On Sage's CLEAR:
|
||||
|
||||
- the capture script on base 1e629c892 first, then the new tests red on the base;
|
||||
- the bus, cli, plugins (zero failures), webui and business suites, and every `scripts/test-*.sh`;
|
||||
- the seat suite whole (`packages/seat`, `node --test tests/`), where two old-test edits, new
|
||||
tests 1 and 3, and the M2 and M3 kills live (Duckworth C1);
|
||||
- in the cli run, `compactions-launch`'s T3 tests (`:206`, and `:305` with the `:344` edit) must
|
||||
run, not skip on `sandboxProblem()` (`:36`). BUILD.md names every skip (Duckworth C2);
|
||||
- the mutants, each on an isolated copy with its restore checked by sha256.
|
||||
|
||||
Each suite runs under `timeout -k 10` at 3x or more of its longest recorded wall (mos-1275). I'll
|
||||
put the numbers in BUILD.md before the first run. TMPDIR comes from
|
||||
`mktemp -d /mnt/storage/scratch/tmp/gXXX`.
|
||||
|
||||
## Round 1
|
||||
|
||||
Duckworth approved round 1 on 67daf6403 (duckworth-0280). This revision takes C1 and C2 into
|
||||
the gate, adds the N1 comment to the launcher plan, and corrects the N2 line refs (`:302`, `:301`).
|
||||
@@ -0,0 +1,79 @@
|
||||
// Row 307 (#1814): checks the new tests' literals against row 270's code at base 1e629c892.
|
||||
//
|
||||
// node capture.mjs <base checkout at 1e629c892> <candidate checkout>
|
||||
//
|
||||
// Reads the FIXTURES and CASES blocks (between `// capture:begin` and `// capture:end`) from the
|
||||
// candidate's two new test files. Replays each fixture's steps with the base's collectCompactions,
|
||||
// harness named by string as row 270 takes it, with the clock at 12:00:00 and one second a step,
|
||||
// and compares the ledger byte for byte. Prints the base's compactionCount for each. Runs the
|
||||
// base's scanPi over each case and compares the answer the base's reason order gives (:301) with
|
||||
// version PI_VERSION. Exit 0 only when every one matches. Writes only under TMPDIR.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const [base, cand] = process.argv.slice(2).map((p) => p && resolve(p));
|
||||
if (!base || !cand) {
|
||||
console.error("usage: node capture.mjs <base checkout> <candidate checkout>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function block(file) {
|
||||
const text = readFileSync(join(cand, file), "utf8");
|
||||
const m = /\n\/\/ capture:begin\nconst [A-Z]+ = (\{[\s\S]*?\});\n\/\/ capture:end\n/.exec(text);
|
||||
if (!m) throw new Error(`${file}: no capture block`);
|
||||
return JSON.parse(m[1]);
|
||||
}
|
||||
|
||||
const FIXTURES = block("packages/seat/tests/compactions-ledger.test.mjs");
|
||||
const CASES = block("packages/plugins/tests/harness-compactions.test.mjs");
|
||||
const seat = await import(pathToFileURL(join(base, "packages/seat/src/compactions.mjs")).href);
|
||||
const { sessionStore } = await import(pathToFileURL(join(base, "packages/seat/src/session.mjs")).href);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "g307-capture-"));
|
||||
let failed = 0;
|
||||
const report = (what, same, detail) => {
|
||||
console.log(`${same ? "MATCH" : "DIFF "} ${what}${detail ? ` ${detail}` : ""}`);
|
||||
if (!same) failed++;
|
||||
};
|
||||
try {
|
||||
for (const [name, fx] of Object.entries(FIXTURES)) {
|
||||
mkdirSync(join(root, `f-${name}`));
|
||||
const store = sessionStore(join(root, `f-${name}`), "acme", "th1", "s1");
|
||||
for (const [file, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, file), text);
|
||||
for (const [file, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, file), text);
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
for (const step of fx.steps) {
|
||||
if (step.tear !== undefined) appendFileSync(join(store.dir, seat.LEDGER), step.tear);
|
||||
else seat.collectCompactions(store, step.collect, { now, ...(step.budget === undefined ? {} : { budget: step.budget }) });
|
||||
}
|
||||
const got = readFileSync(join(store.dir, seat.LEDGER), "utf8");
|
||||
const want = fx.ledger.join("\n") + "\n";
|
||||
report(`ledger ${name}`, got === want, got === want ? `(${Buffer.byteLength(got)} bytes)` : `\n--- base wrote\n${got}--- test has\n${want}`);
|
||||
const count = seat.compactionCount(store.dir);
|
||||
report(`base count ${name}`, JSON.stringify(count) === JSON.stringify(fx.count), JSON.stringify(count));
|
||||
}
|
||||
for (const [name, c] of Object.entries(CASES)) {
|
||||
const dir = join(root, `c-${name}`);
|
||||
mkdirSync(dir);
|
||||
for (const [file, text] of Object.entries(c.streams)) writeFileSync(join(dir, file), text);
|
||||
const scan = seat.scanPi(dir, c.cursors, c.budget, c.cap);
|
||||
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
|
||||
const answer = { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: seat.PI_VERSION };
|
||||
let same = true;
|
||||
try {
|
||||
assert.deepStrictEqual(answer, c.answer);
|
||||
} catch {
|
||||
same = false;
|
||||
}
|
||||
report(`scan ${name}`, same, JSON.stringify(scan));
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
console.log(failed ? `${failed} differ` : "all match");
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,342 @@
|
||||
# Row 272 / 264e (#1777): session CLI and Console chooser
|
||||
|
||||
Owner Huey; reviewer Scrooge per sage-2816 / mos-1310 item 2 (supersedes sage-2796). Current base 5fdfd7275, branch huey/row272-5fdfd7275 (sage-3354). Original draft base 2d0d407ba; round 1 was 850b22680 on b23783f39.
|
||||
Brief: docs/plans/2026-10-21_t1-sessions.md (ff0be0c79), section 8; row 264 rulings
|
||||
at 5f5b51dd6. Dependencies: 264a-d landed; 270/271 landing is 7328da8ca.
|
||||
Count/clear implementation and contracts are now in the current base. No runtime, launcher, library adapter,
|
||||
role, schema or queue edits belong to this slice.
|
||||
|
||||
## Execution and evidence
|
||||
|
||||
1. Read session/channel/HTTP/browser contracts and write isolation/validation tests.
|
||||
2. Add session CLI through the existing verified human channel, preserving defaults.
|
||||
3. Add authenticated Console chooser, per-session drafts/history/cursors, count and
|
||||
clear status. Unavailable dependency verbs refuse explicitly; no simulated success.
|
||||
4. On Sage's CLEAR: claim, run focused tests and required suites/mutants, repair and
|
||||
record exact evidence. No Node, tests, HTTPS git, claim or push under Gander's gate.
|
||||
5. After 253 and 269 land, rebase on a new branch name. Review packet to Scrooge,
|
||||
Mos, Gizmoduck and Sage; push only when permitted.
|
||||
|
||||
2026-10-22 preparation: clean worktree switched locally to the requested base. Read
|
||||
approved brief and all Q1-Q7 rulings. Della notified: no startBroker/library changes.
|
||||
Gizmoduck asked to pin count/clear contracts. Tests will be written first; Sage's
|
||||
explicit gate restriction defers RED/GREEN execution. No test result claimed.
|
||||
|
||||
Verification plan: CLI real-broker isolation and channel refusal; HTTP strict keys,
|
||||
Origin/Fetch Metadata, forged selector/refusal and no retries; fixture-browser two
|
||||
sessions, isolated draft/cursor/receipt, deep-link/refresh, count unknown/known,
|
||||
read-only writes, clear confirmation/pending. Add selector omission and cursor/draft
|
||||
sharing mutants. Broader cli/webui/plugins suites use Sage's gate only.
|
||||
|
||||
264c/d contracts received gizmoduck-0260: session.list gains compactions and revision/state;
|
||||
session.clear and session.operation use the pinned envelopes. CLI polls at most 30 s
|
||||
then reports pending; Console status checks use GET only. Counts absent on 264b show
|
||||
unavailable; unknown never becomes zero. Clear absent on 264b is disabled with a reason.
|
||||
USER_VERBS and tester listener write policy remain unchanged (Q7). Existing collaborators
|
||||
therefore cannot enter the new session surface until the reviewed RBAC integration.
|
||||
|
||||
Consumer audit found the explicitly deferred B6 user-reply scope (DEFERRED.md:455-459,
|
||||
queue-264/BUILD.md:317-322) bound to 272. Added a narrow broker predicate change from
|
||||
thread to session and a sibling-session regression fixture, without widening user verbs.
|
||||
This is the only planned broker change and does not overlap Della's runtime adapter.
|
||||
|
||||
2026-10-22 static review: added explicit --session to thread read/open and talk/follow;
|
||||
omission remains the saved default. CLI clear first reads a recorded operation, so a
|
||||
repeat after a lifecycle revision change cannot become a conflicting second write.
|
||||
Console deep links name both thread and session; accepted-send receipts, drafts and
|
||||
history pagination use that tuple. Clear-status GETs refresh the envelope without
|
||||
reissuing clear. Read failures discard private metadata; late generations cannot paint
|
||||
another selection. Scrooge is reviewer per sage-2816 / mos-1310, superseding the first
|
||||
assignment. CLEAR remains after 269, 275, 203 and 207. No execution evidence yet.
|
||||
|
||||
## Acceptance / mutant map (prepared, unexecuted)
|
||||
|
||||
| Check | Evidence file / intended kill |
|
||||
| --- | --- |
|
||||
| T5 legacy human, forged agent, foreign session, strict HTTP identity/Origin/site | session-cli.test.mjs, chat.test.mjs; unchanged USER_VERBS matrix in user-principal.test.mjs |
|
||||
| T7 saved default, explicit CLI session selector, metadata-only create | session-cli.test.mjs real broker and thread CLI compatibility tests |
|
||||
| T7 Console chooser, deep link, draft/cursor/receipt isolation, unknown count, confirmation/pending | chat-browser.test.mjs; existing history/sign-out/late-read tests run whole |
|
||||
| Count/clear contract absence | CLI unavailable refusal; Console disabled clear reason; exact zero vs unknown lower bound tests |
|
||||
| B6 assigned follow-up | user-principal.test.mjs sibling-session citation refusal and valid same-session reply |
|
||||
| M1 omit session in chat.mjs messages forwarding | chat.test.mjs explicit selector deepEqual fails |
|
||||
| M2 collapse browser state key from thread/session to thread | chooser draft isolation assertion fails |
|
||||
| M3 treat known:false count:0 as exact zero | CLI label assertion and browser unknown-count assertion fail |
|
||||
| M4 restore B6 mt.thread/s.thread predicate | user-principal sibling refusal assertion fails |
|
||||
| M5 remove session from CLI thread.send payload | explicit --session talk assertion fails |
|
||||
| M6 repeat a clear write in place of session.operation poll | CLI exactly-one-clear assertion fails |
|
||||
|
||||
Intended focused commands, only after CLEAR, one process at a time:
|
||||
|
||||
- `timeout -k 10 300 node --test packages/cli/tests/session-cli.test.mjs`
|
||||
- `timeout -k 10 300 node --test packages/cli/tests/thread-cli.test.mjs`
|
||||
- `timeout -k 10 300 node --test packages/bus/tests/user-principal.test.mjs`
|
||||
- `timeout -k 10 300 node --test packages/plugins/tests/channel.test.mjs`
|
||||
- `timeout -k 10 300 node --test packages/webui/tests/chat.test.mjs`
|
||||
- `timeout -k 10 600 node --test packages/webui/tests/chat-browser.test.mjs`
|
||||
|
||||
Whole touched suites on Sage's slot: bus (900 s), cli (1800 s, >=3x recorded 591 s),
|
||||
webui (600 s), plugins (600 s), plus required integration scripts per final gate brief.
|
||||
Mutants run in disposable copies of the pinned candidate, never against a live stack;
|
||||
only failed assertions count as kills, never a timeout or import/setup failure.
|
||||
No test process, browser, provider, credential read or live service action ran in this
|
||||
preparation. `git diff --check` is the only executed draft verification so far.
|
||||
|
||||
Next: Sage CLEAR for claim/focused runs; repair against actual results, then rebase on
|
||||
a newly named branch once 253 and 269 land. Generate final manifest only after those
|
||||
changes. Review handoff is Scrooge and Mos, copying Gizmoduck and Sage; no handoff to
|
||||
Duckworth under the superseded assignment. No candidate commit or green claim yet.
|
||||
|
||||
2026-10-22, gizmoduck-0272 / sage-2828 additive contract at 78e14352f: added
|
||||
session.cancel to CLI/channel/Console with confirmed human writes and strict keys.
|
||||
Cancelled envelopes retain `retired:null` (old active) or `true` (startup cancelled,
|
||||
old retired); cancelled is terminal for polling. CLI reports routed message cancellation
|
||||
with stored ID and exit 3, never waits for delivery or replays it. HTTP maps
|
||||
session-name-too-long and session-clear-complete without reflecting transport text.
|
||||
Added unexecuted before/after cancellation, identity/Origin, confirmation and message
|
||||
receipt tests. Existing clear/create/read wiring is preserved. Asked Gizmoduck whether
|
||||
the deterministic successor keeps 264a's UUID shape, as selectors currently require.
|
||||
No Node, test run, claim, commit, push, provider call or live action. Static diff only.
|
||||
|
||||
Gizmoduck-0274 resolves the selector compatibility check: successors are lowercase
|
||||
RFC 9562 v8 UUIDs (variant 10), while 264a sessions are v4. Existing generic UUID
|
||||
validators accept both and stay unchanged. CLI explicit-selector and Console chooser
|
||||
fixtures now include mixed v4/v8 IDs to prevent a later v4-only regression. Unexecuted.
|
||||
|
||||
|
||||
## sage-3141 rebase and landed-contract check (2026-10-23)
|
||||
|
||||
Reapplied the uncommitted draft onto b23783f39 in the same isolated worktree, on new
|
||||
branch huey/row272-b23783f39. No candidate commits existed to replay. Preserved the
|
||||
original draft in the named local stash `huey row272 draft before sage-3141 rebase`.
|
||||
Product files merged without conflict. Only append-only BUILD-LOG.md and SESSIONS.md
|
||||
conflicted: kept every new-base byte and appended the original Huey entries unchanged.
|
||||
No force push, fetch, Node, test, queue write or live change. No claim or candidate
|
||||
commit yet. Static `git diff --check` clean after resolution.
|
||||
|
||||
Checked the actual landed broker, not just the older contract packet:
|
||||
|
||||
- broker.mjs:687-707 clearEnvelope supplies the same op/thread/session/successor/state/
|
||||
start/refusal/at fields and cancelled-only retired:null|true. The generic UUID
|
||||
selectors still accept both original v4 and successor v8 IDs.
|
||||
- broker.mjs:6227-6245 session.list supplies compactions, state, revision and successor;
|
||||
count reader/unknown behavior is the landed 270 contract. Real-base tests must now
|
||||
exercise these available fields; stand-ins still exercise an unavailable dependency.
|
||||
- broker.mjs:6270-6330 clear/cancel/operation inputs and receipts match the wired contract.
|
||||
clear repeats preserve the op; cancel also refuses a failed clear as
|
||||
session-clear-complete. Our existing refusal mapping already covers it.
|
||||
- queue-271/BUILD.md:225-238 clarifies cancellation after retirement cancels this clear's
|
||||
startup, while later deliberate human open/send can start the fresh successor. It
|
||||
does not permanently disable that new session. No frontend authority change needed.
|
||||
- store.mjs:17 is 3n; migration is 3m-3n.sql. This slice adds/pins no schema step,
|
||||
migration blob or schema test count. No DDL copied from an older branch.
|
||||
|
||||
Plan after the Gander 574 run-2 hold: on a claims-only CLEAR, claim only; run no Node
|
||||
or tests beyond the explicit scope of Sage's grant. On test CLEAR run new CLI tests
|
||||
first, then new broker/HTTP/browser cases and whole touched files, sequentially under
|
||||
the recorded timeouts. Place/report a first-run red only in a new test; do not alter
|
||||
an old test, loosen an assertion or fix a second red without Mos's ruling via Sage.
|
||||
Whole-suite/mutant execution remains on Sage's slot. Candidate commit and push follow
|
||||
applicable green evidence and an allowed gap, then Scrooge/Mos handoff with final SHA,
|
||||
manifest, copying Gizmoduck/Sage.
|
||||
|
||||
Existing-test edits in the inherited draft need Mos's ruling through Sage before
|
||||
execution: channel.test.mjs extends its existing supported-verbs table for five
|
||||
session verbs; chat-browser.test.mjs extends the existing fetch fixture with session
|
||||
catalog/operation responses and changes its exact send-payload assertion to include
|
||||
the selected session. Assertions are not loosened. All other added test cases are
|
||||
new tests. These changes were prepared before sage-3141 and have never run. Ask Sage
|
||||
to forward this precise delta rather than treating a later old-test red as permission.
|
||||
|
||||
sage-3145 correction: refs/stash is shared across worktrees; using it as sole draft
|
||||
storage was wrong. Pinned the exact draft commit 07dc9d2f7944b4781b5e80dfa18425f4749f7a45
|
||||
on local branch huey/row272-stash-keep before any stash cleanup. The entry remains
|
||||
untouched; the branch retains its tracked and untracked snapshots. Future seat draft
|
||||
preservation uses owned branches/local commits, not shared stash positions.
|
||||
|
||||
Sage explicitly directed a local draft commit before tests to preserve the rebased
|
||||
work. This overrides the normal green-before-commit gate for this storage checkpoint
|
||||
only. It is an untested draft, not an approved candidate, test result, push or landing.
|
||||
Claims await the post-574-run-2 CLEAR; test execution awaits Mos's ruling and Sage's
|
||||
separate test CLEAR. Old-test deltas were forwarded as sage-3144; no ruling assumed.
|
||||
|
||||
## Scrooge early static read, 850b22680 (2026-10-23)
|
||||
|
||||
Review key scrooge-272-early-read-huey-1. B1 confirmed: the render catch used any
|
||||
403/404 to delete every sibling's draft/receipt and clear/create operation key.
|
||||
Uncommitted follow-up restricts that purge to thread-not-owned, thread-not-found and
|
||||
access-refused. Session-not-found still shows an unavailable read, preserving sibling
|
||||
state and pending recovery coordinates. New browser test retains A's draft and a lost
|
||||
clear key across a bad-session deep link, then proves Clear queries the retained op
|
||||
without another write.
|
||||
|
||||
N1: malformed clear/cancel write receipts now add the same redacted outcome-unknown
|
||||
audit line as other writes. New HTTP test checks one write log and no read log.
|
||||
N2: a valid recorded clear belonging to another session now refuses with a precise
|
||||
operation-key-reuse message (exit 2), rather than ambiguous outcome unknown; new CLI
|
||||
test proves no clear write. Malformed envelopes retain the outcome-unknown refusal.
|
||||
No approved old assertion or shared stub edited. Static diff check only; all new tests
|
||||
remain unrun pending Sage's test CLEAR. Published round 1 still pins 850b22680 and its
|
||||
original manifest; these follow-up edits do not replace that evidence. No new commit,
|
||||
push, queue write, provider call or live change for this early-read follow-up.
|
||||
|
||||
Execution watch: inspect message-filter.test first if the bus suite reports a B6
|
||||
fixture red (manual message_threads rows must name the run's bound session); do not
|
||||
change an old fixture/assertion without Mos's ruling via Sage. thread-shares uses the
|
||||
persisted session_defaults mapping per Scrooge's check. No runtime verdict claimed.
|
||||
|
||||
## Granted-user legacy compatibility (sage-3357; pre-candidate design)
|
||||
|
||||
219/295 are retained on base 5fdfd7275. USER_VERBS still excludes session verbs;
|
||||
193/T5 user session grants are a separate DEFERRED item, outside 272. No owner
|
||||
transport carries a user session operation. Bounded negotiation applies only to the
|
||||
session.list read: only `access-refused` and an omitted-session route permit legacy
|
||||
thread-default read/send/open. All other errors keep the unavailable/error state, and
|
||||
explicit session links always refuse. On fallback session controls are hidden and one
|
||||
status line says sessions are not available for this account. No fake session ID,
|
||||
count or lifecycle status is invented; the broker continues choosing its persisted
|
||||
default under current grants on every existing thread request. Owner routes keep the
|
||||
normal chooser and explicit selected-session arguments.
|
||||
|
||||
New file chat-sessions-access.test.mjs uses the real broker with an already granted
|
||||
principal over loopback and the existing repository browser fixture. It checks legacy
|
||||
read/send/open with no session controls, explicit-link refusal, another-error refusal,
|
||||
and unchanged owner chooser. No shared stub or existing assertion changes. The three
|
||||
required mutants remove the access-refused check, remove the omitted-session check,
|
||||
and display the controls during fallback; guard tests must kill each by assertion.
|
||||
|
||||
RED-first slot sage-3358: only this new file, before fallback implementation. Expected
|
||||
run 100 s including four Chromium fixtures; bound 300 s (3x), command
|
||||
`timeout -k 10 300 node --test packages/webui/tests/chat-sessions-access.test.mjs`.
|
||||
TMPDIR must be assigned from mktemp -d, with no underscore. No compose service is used;
|
||||
check/remove the generated directory's compose default network if any exists. Expected
|
||||
legacy-positive red is placed/reported afterward under mos-1272. Any other red is
|
||||
reported to Sage before a fix. No other test file/suite is authorized in this slot.
|
||||
|
||||
sage-3358 RED run completed before the fallback implementation. Only the new
|
||||
chat-sessions-access.test.mjs ran, under the 300 s bound, TMPDIR allocated by
|
||||
`mktemp -d /tmp/huey272-red-XXXXXXXX` as /tmp/huey272-red-0q66Jcf4. Exit 1;
|
||||
4 tests, expected legacy-positive assertion red and 3 passes, no other reds/skips/
|
||||
cancellations/timeouts, 3.504 s. Exact output: r2-red-access.tap. No matching compose
|
||||
network existed. Slot returned to Sage/Mos as huey-0035; no further tests run.
|
||||
|
||||
mos-1442 / sage-3361 adds explicit timeout, bus-error and malformed-answer refusal
|
||||
coverage. The new private fixture adds those guard cases (unrun), with a widening-key
|
||||
mutant alongside the access-refused/omitted-selector/control mutants. No shared stub
|
||||
or existing assertion changed. Native preview status/open both reported unavailable
|
||||
in this headless environment before the authorized repository-browser regression.
|
||||
|
||||
Implementation now local: the catalog request catches only access-refused on omitted
|
||||
session routes. Its internal null result selects the existing broker-chosen thread
|
||||
default with no fabricated session ID. Read/send/open omit the session property in
|
||||
that mode. One status line replaces all session controls. Explicit links and any
|
||||
other failure never select that mode. USER_VERBS and all owner transport routing
|
||||
remain byte-unchanged. Guard cases are preserved on the pre-fallback baseline; their
|
||||
RED evidence comes from the required mutants, as Sage accepted in sage-3358.
|
||||
|
||||
Fallback mutants are executable via fallback-mutants-r2.py, only on Sage's slot.
|
||||
It copies packages/roles into a private mktemp directory and mutates only the copied
|
||||
chat.js, never candidate bytes. Each run has a 300 s bound and requires a named
|
||||
assertion failure; timeout/import/setup failures do not count. F1 removes the exact
|
||||
access-refused key (human-required guard); F2 removes the omitted-selector check;
|
||||
F3 shows session controls; F4 widens the key to timeout/unreachable, no-bus-host and
|
||||
invalid-response (timeout guard). The test file also independently checks bus-error
|
||||
and malformed-answer refusal. These mutants and the post-implementation tests have
|
||||
not run locally. The earlier six non-fallback mutants in this packet remain required
|
||||
in the full gate; the new runner covers the four fallback mutants only.
|
||||
|
||||
|
||||
## Round 2 rebase receipt (sage-3354)
|
||||
|
||||
Queue rev 3510 returned row 272 to in-progress. On new branch huey/row272-5fdfd7275,
|
||||
replayed round 1 and the preserved B1/N1/N2 fixes onto 5fdfd7275 (219, after 295).
|
||||
Resolved exactly the seven named conflicts. Test append conflicts retain both complete
|
||||
sets of tests; existing test content equals the new base plus only the mos-1388 hunks.
|
||||
No old assertion or shared stub was changed beyond those approved hunks. Static byte
|
||||
comparison confirms the entire upstream test file prefixes and approved modifications.
|
||||
Both append-only logs retain the complete 5fdfd7275 prefix plus Huey's entries.
|
||||
|
||||
219's per-instance thread picker, explicit createThread instance forwarding, choice
|
||||
refresh/revocation behavior and tests remain present. 295's createChat owner-settings
|
||||
read stays unchanged: only plugins uses the owner transport; user chat verbs stay on
|
||||
the user transport. Removed a duplicate access-refused status entry introduced by
|
||||
combining those patches. USER_VERBS is unchanged. No schema or migration changed.
|
||||
|
||||
This is a pre-gate candidate per Sage's explicit instruction to commit/push/request
|
||||
round 2 before Sage runs the full gate. Only the expected-red new fallback test ran
|
||||
under sage-3358. No green test claim, no suite/mutant result and no old-file run is
|
||||
implied. Every touched test file still needs its whole-file run on Sage's slot, plus
|
||||
all touched suites and mutants. Any first-run red in launcher/creative/chat-browser
|
||||
returns to Mos via Sage before fixing. TMPDIR for gate runs comes only from mktemp -d,
|
||||
with no underscore; remove a matching compose default network only if created.
|
||||
|
||||
|
||||
## Round 3 preparation — gate run 1 (sage-3433)
|
||||
|
||||
Sage ran candidate b44332a93 over refactor 1e4c1e6e9 with the pinned esbuild
|
||||
and MOSAIC_REQUIRE_EDITOR_BUILD=1. Gate RED: bus 891/0, business 231/0,
|
||||
CLI 940/1, plugins 110/1, webui 372/20. No mutants ran. Sage reproduced
|
||||
every failing file on b44332a93 alone, excluding overlay drift as the cause.
|
||||
Evidence: /mnt/storage/scratch/sage-r272/run-*.txt and split-*.txt.
|
||||
|
||||
A: the new session CLI real-broker fixture omitted role authority. Verified
|
||||
checkBusiness requires withinRole and crossRole arrays on every role; added
|
||||
empty arrays to chief and assistant in that fixture only, as sage-3433 permits.
|
||||
No production authority or existing assertion changed. No local tests ran.
|
||||
|
||||
B remains held for Mos under mos-1316/1317: initial bare-chat rendering compares
|
||||
old?.id with undefined id, then dereferences null old at the sessions/session/key
|
||||
initializers. Sage proposes old && old.id === id at all three sites. No B edit
|
||||
made pending the ruling. C (plugins harness compactions) is separately owned.
|
||||
|
||||
Round 3 is not committed or pushed yet. The manifest path set remains the same
|
||||
17 paths; regenerate hashes when the authorized fixes are complete. Future
|
||||
launcher, creative and chat-browser runs use a TMPDIR allocated with
|
||||
mktemp -d /mnt/storage/scratch/tmp/<alnum>XXXXXX, because bwrap hides /tmp.
|
||||
Sage owns the next full gate. No tests, repo Node or queue writes here.
|
||||
|
||||
|
||||
### Round 3 candidate — mos-1455 / sage-3435
|
||||
|
||||
Mos authorized B: all three initial render comparisons now use old && old.id === id,
|
||||
so an initial list route cannot dereference null old. A remains fixture-only.
|
||||
chat-browser.test.mjs and granted-user.test.mjs are byte-identical to b44332a93.
|
||||
No pre-existing assertion, authority validator or shared stub changed.
|
||||
|
||||
Frozen manifest: manifest.r3.txt, 17 paths unchanged; SHA256 f98c5b7a884ed1e5951a584227ece16d403baf62199d405aaa1981d667e9e075.
|
||||
Parent candidate: b44332a9360504291dda3f81131c934b286f3226; base remains 5fdfd7275.
|
||||
Only session-cli.test.mjs and public/chat.js product hashes differ from round 2.
|
||||
This follow-up commit/push is authorized before the round 3 gate by sage-3435.
|
||||
No local tests, repo Node, mutants or queue writes ran. Gate remains RED until
|
||||
Sage reruns; plugins harness compactions is a separately owned dependency, and
|
||||
Mos declined an EXCEPT edit. Scrooge reviews this A/B delta; Sage posts round 3.
|
||||
|
||||
|
||||
## Round 4 — scoped browser heading waits (mos-1460/1461)
|
||||
|
||||
Round 3 gate on 72690cae7: bus 891/0, CLI 941/0, business 231/0; plugins
|
||||
had only the allowed exact base red, checked by Mos; webui 390/2. Both reds
|
||||
were newly added chat-browser missing-session waits at lines 361 and 437.
|
||||
Whole-file rerun reproduced 17/2. No round 3 green acceptance claim.
|
||||
|
||||
Diagnosis authorized by sage-3450/3453 and mos-1460: unchanged whole-file run
|
||||
17/2, 28.223 s; scratch-only CDP probes showed the route reaching chat.js:176,
|
||||
catalog rows A/B without M, and catch receiving session-not-found with status
|
||||
404. DOM had first h1 "Control board" (shell), then .chat-area h1 "Thread
|
||||
unavailable". Product refusal and painting worked. Evidence retained under
|
||||
/mnt/storage/scratch/tmp/hueyP1enyB/ (baseline.tap, probe.tap, probe-catch.tap,
|
||||
proposed.diff); scratch probe source removed, tracked tree remained clean.
|
||||
|
||||
mos-1461 / sage-3458 authorizes exactly two selector changes: document-wide
|
||||
h1 becomes .chat-area h1 in those waits. Expected text, timeout and every
|
||||
other line of chat-browser.test.mjs remain byte-identical to 72690cae7.
|
||||
This scopes the same assertion to its component; it does not weaken the
|
||||
composer/write/draft/recovery checks. No production code changes in round 4.
|
||||
|
||||
manifest.r4.txt retains the same 17 paths; SHA256 a47ed1a23fb98c1ffe6cbcc4e7130e5c515387a2c5dc41e5ae1cbf04640d8c1b.
|
||||
Only chat-browser.test.mjs hash differs from r3. New local follow-up commit
|
||||
is authorized before execution; push and single-file run await Sage CLEAR
|
||||
while Gizmoduck owns the slot. Required pass: whole chat-browser.test.mjs,
|
||||
19/19 and skipped 0, timeout -k 10 300, TMPDIR from
|
||||
mktemp -d /mnt/storage/scratch/tmp/hueyXXXXXX. No round 4 run yet.
|
||||
After green, claimant moves row to in-review; Sage posts request and
|
||||
Scrooge reviews the exact two-line delta. Full five-suite gate remains Sage's.
|
||||
@@ -0,0 +1 @@
|
||||
5fdfd7275ce3048e488d93dcdb1f9f2aa5961a69
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only on Sage's slot. Mutate disposable sources, never the candidate tree."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
GUARD = "if (err.code === 'access-refused' && !match[3]) return null;"
|
||||
CONTROLS = " if (current.legacyDefault) return '<p role=\"status\">Sessions are not available for this account.</p>';"
|
||||
MUTANTS = {
|
||||
'F1-any-code': (GUARD, "if (!match[3]) return null;", 'human-required catalog failure'),
|
||||
'F2-explicit-link': (GUARD, "if (err.code === 'access-refused') return null;", 'granted explicit-session route'),
|
||||
'F3-controls': (CONTROLS, ' // mutant: show session controls on a legacy fallback', 'granted legacy route'),
|
||||
'F4-widened-key': (GUARD, "if (['access-refused', 'unreachable', 'no-bus-host', 'invalid-response'].includes(err.code) && !match[3]) return null;", 'timeout catalog failure'),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--out', type=Path, required=True)
|
||||
ap.add_argument('--mutant', choices=list(MUTANTS), action='append')
|
||||
args = ap.parse_args()
|
||||
source = Path(__file__).resolve().parents[4]
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
for name in args.mutant or MUTANTS:
|
||||
# Explicit mktemp, no underscores: same TMPDIR rule as the real gate.
|
||||
scratch = Path(subprocess.check_output(['mktemp', '-d', '/tmp/huey272-fallback-mutant-XXXXXXXX'], text=True).strip())
|
||||
try:
|
||||
for directory in ['packages', 'roles']:
|
||||
shutil.copytree(source / directory, scratch / directory)
|
||||
if (source / 'node_modules').exists():
|
||||
(scratch / 'node_modules').symlink_to((source / 'node_modules').resolve(), target_is_directory=True)
|
||||
for filename in ['package.json', 'RELEASE']:
|
||||
if (source / filename).exists():
|
||||
shutil.copy2(source / filename, scratch / filename)
|
||||
path = scratch / 'packages/webui/src/public/chat.js'
|
||||
old, new, target = MUTANTS[name]
|
||||
text = path.read_text()
|
||||
if text.count(old) != 1:
|
||||
raise RuntimeError(f'{name}: mutation anchor is not unique')
|
||||
path.write_text(text.replace(old, new, 1))
|
||||
env = dict(os.environ, TMPDIR=str(scratch))
|
||||
result = subprocess.run(['timeout', '-k', '10', '300', 'node', '--test', '--test-reporter=tap',
|
||||
'packages/webui/tests/chat-sessions-access.test.mjs'],
|
||||
cwd=scratch, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
(args.out / f'{name}.tap').write_text(result.stdout)
|
||||
killed = result.returncode == 1 and 'ERR_ASSERTION' in result.stdout and bool(
|
||||
re.search(r'not ok [^\n]*' + re.escape(target), result.stdout))
|
||||
print(json.dumps({'mutant': name, 'returncode': result.returncode, 'assertion_kill': killed}), flush=True)
|
||||
if not killed:
|
||||
raise RuntimeError(f'{name}: not an assertion kill; stop, diagnose and report before any fix')
|
||||
finally:
|
||||
# These tests never use compose. Remove the disposable copy after browser teardown;
|
||||
# retained TAP lives only in --out. No live network/volume/service cleanup is performed.
|
||||
shutil.rmtree(scratch)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
fb518c370aeb531220de123464f0a809156fc546b51d7195f4f9ca660409cf22 packages/bus/src/broker.mjs
|
||||
35d044b0904ce913b3356b02cdc5bf22babecf738b511dca80c75f704e4ef78e packages/bus/tests/user-principal.test.mjs
|
||||
e795bc4ee5aa038835edfeabe3498e53fdd700df478198cac6767498e2fac0b7 packages/cli/README.md
|
||||
96c4f93628964b6ea10fb87d5cf936f9ecf2e7286aa20c0914ae8b0fb32c47e7 packages/cli/src/cli.mjs
|
||||
0c31ac7260e77556f30f33a9932dc59a30f394982d8556650af7c521d2bcc947 packages/cli/src/session.mjs
|
||||
58888b7731f8009d4c3f8be2caf4445f13dd8f534e1633f6e2720b259fcc3f92 packages/cli/src/thread.mjs
|
||||
27fb7dd91007ec5f729fbe854219f423177cf4db2d44d1f8b04c7e8c5e6db90a packages/cli/tests/session-cli.test.mjs
|
||||
5e7e1ec169392ee3065f8f47d19732ebbe8bdd90f9193581425b956e66da6b73 packages/plugins/channel/cli/index.mjs
|
||||
aa086c365c4a6b0eaf84359a20ef2b30e723013ada6573fb0133163d5bf98a74 packages/plugins/channel/webui/index.mjs
|
||||
143bf601badf5cc2b25cb24368ace2ec34840abec858d272af239d6a4ddcdecb packages/plugins/tests/channel.test.mjs
|
||||
0720f66a57d965c9275f598b7c7ac438f238a0fac52808fc8f10312b85ba2727 packages/webui/src/chat-http.mjs
|
||||
65fd3fd2337aaa88245a9fb10b55fd872f9150085ff9a53fe522e758a93c64fa packages/webui/src/chat.mjs
|
||||
88dd353f5df0603ed1fd43dd543ef7b45af93fee72b6bb6a03a26c068202fbd3 packages/webui/src/public/chat.css
|
||||
074d570aee4e2b6cdcbd6b8be399a2a21e863f2d620f95dad8d93ffc7d9c46da packages/webui/src/public/chat.js
|
||||
2161c4830a3f73f0343e76521b948b933e401f7b9ba4ef8a5c75bf99c565d6b6 packages/webui/tests/chat-browser.test.mjs
|
||||
b79a8809951580187b581450cdd4012245f2d88a371bf7d2f123580af8815449 packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,17 @@
|
||||
c21f1bd9af6841126baf19f5c9926ca23ba8e56b9af342373d8e4fefcee63632 packages/bus/src/broker.mjs
|
||||
3957243d59e50eaa2326b88b9205ed8e8b5d33bde2ce1ef62e68f20e56283568 packages/bus/tests/user-principal.test.mjs
|
||||
0a920131a981c2a1c9b73428a998966f446babae4dfe7d10eebdd6455571c0c9 packages/cli/README.md
|
||||
96c4f93628964b6ea10fb87d5cf936f9ecf2e7286aa20c0914ae8b0fb32c47e7 packages/cli/src/cli.mjs
|
||||
1a30048032f5bb64419ae9716a1692978bc1a137220a094d1900298aca6ba1d7 packages/cli/src/session.mjs
|
||||
58888b7731f8009d4c3f8be2caf4445f13dd8f534e1633f6e2720b259fcc3f92 packages/cli/src/thread.mjs
|
||||
359c9fbfe30d860046ee3d29d23681900d9c73b3a4c3a91cd2f4e1d2c6be143e packages/cli/tests/session-cli.test.mjs
|
||||
5e7e1ec169392ee3065f8f47d19732ebbe8bdd90f9193581425b956e66da6b73 packages/plugins/channel/cli/index.mjs
|
||||
aa086c365c4a6b0eaf84359a20ef2b30e723013ada6573fb0133163d5bf98a74 packages/plugins/channel/webui/index.mjs
|
||||
143bf601badf5cc2b25cb24368ace2ec34840abec858d272af239d6a4ddcdecb packages/plugins/tests/channel.test.mjs
|
||||
6e743878e507be20d87ae10afdb01c105f6f4fab76d02410e065f3f20d18e597 packages/webui/src/chat-http.mjs
|
||||
7a5a18a9879fe7132f20a651c6927b007475571bd8fc45314eda9d6809288cea packages/webui/src/chat.mjs
|
||||
88dd353f5df0603ed1fd43dd543ef7b45af93fee72b6bb6a03a26c068202fbd3 packages/webui/src/public/chat.css
|
||||
e4c25743a6d8cbe15eaac2b4d8c3bd309ffa5793f35584d862690a9d89725b72 packages/webui/src/public/chat.js
|
||||
1aa313f1602cf87a0ddac145bf58bbd7f1073c02009c65797e85418fdfdbd838 packages/webui/tests/chat-browser.test.mjs
|
||||
23de8678566f24a6c8bf0ca75b4550969015b06253586070e229f4eaf0bba2b4 packages/webui/tests/chat-sessions-access.test.mjs
|
||||
773538c0c6a26ab525aa30e588249ba4b4ee981129533db0348f2107da5dd1df packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,17 @@
|
||||
c21f1bd9af6841126baf19f5c9926ca23ba8e56b9af342373d8e4fefcee63632 packages/bus/src/broker.mjs
|
||||
3957243d59e50eaa2326b88b9205ed8e8b5d33bde2ce1ef62e68f20e56283568 packages/bus/tests/user-principal.test.mjs
|
||||
0a920131a981c2a1c9b73428a998966f446babae4dfe7d10eebdd6455571c0c9 packages/cli/README.md
|
||||
96c4f93628964b6ea10fb87d5cf936f9ecf2e7286aa20c0914ae8b0fb32c47e7 packages/cli/src/cli.mjs
|
||||
1a30048032f5bb64419ae9716a1692978bc1a137220a094d1900298aca6ba1d7 packages/cli/src/session.mjs
|
||||
58888b7731f8009d4c3f8be2caf4445f13dd8f534e1633f6e2720b259fcc3f92 packages/cli/src/thread.mjs
|
||||
a32bfeecd8589d41b298317954fae172dc936ee0559ec37990e84ed03bd38b07 packages/cli/tests/session-cli.test.mjs
|
||||
5e7e1ec169392ee3065f8f47d19732ebbe8bdd90f9193581425b956e66da6b73 packages/plugins/channel/cli/index.mjs
|
||||
aa086c365c4a6b0eaf84359a20ef2b30e723013ada6573fb0133163d5bf98a74 packages/plugins/channel/webui/index.mjs
|
||||
143bf601badf5cc2b25cb24368ace2ec34840abec858d272af239d6a4ddcdecb packages/plugins/tests/channel.test.mjs
|
||||
6e743878e507be20d87ae10afdb01c105f6f4fab76d02410e065f3f20d18e597 packages/webui/src/chat-http.mjs
|
||||
7a5a18a9879fe7132f20a651c6927b007475571bd8fc45314eda9d6809288cea packages/webui/src/chat.mjs
|
||||
88dd353f5df0603ed1fd43dd543ef7b45af93fee72b6bb6a03a26c068202fbd3 packages/webui/src/public/chat.css
|
||||
68696f41a7335db23c0786537de76ff1202142518647d3d7b89b6b6503baf4f1 packages/webui/src/public/chat.js
|
||||
1aa313f1602cf87a0ddac145bf58bbd7f1073c02009c65797e85418fdfdbd838 packages/webui/tests/chat-browser.test.mjs
|
||||
23de8678566f24a6c8bf0ca75b4550969015b06253586070e229f4eaf0bba2b4 packages/webui/tests/chat-sessions-access.test.mjs
|
||||
773538c0c6a26ab525aa30e588249ba4b4ee981129533db0348f2107da5dd1df packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,17 @@
|
||||
c21f1bd9af6841126baf19f5c9926ca23ba8e56b9af342373d8e4fefcee63632 packages/bus/src/broker.mjs
|
||||
3957243d59e50eaa2326b88b9205ed8e8b5d33bde2ce1ef62e68f20e56283568 packages/bus/tests/user-principal.test.mjs
|
||||
0a920131a981c2a1c9b73428a998966f446babae4dfe7d10eebdd6455571c0c9 packages/cli/README.md
|
||||
96c4f93628964b6ea10fb87d5cf936f9ecf2e7286aa20c0914ae8b0fb32c47e7 packages/cli/src/cli.mjs
|
||||
1a30048032f5bb64419ae9716a1692978bc1a137220a094d1900298aca6ba1d7 packages/cli/src/session.mjs
|
||||
58888b7731f8009d4c3f8be2caf4445f13dd8f534e1633f6e2720b259fcc3f92 packages/cli/src/thread.mjs
|
||||
a32bfeecd8589d41b298317954fae172dc936ee0559ec37990e84ed03bd38b07 packages/cli/tests/session-cli.test.mjs
|
||||
5e7e1ec169392ee3065f8f47d19732ebbe8bdd90f9193581425b956e66da6b73 packages/plugins/channel/cli/index.mjs
|
||||
aa086c365c4a6b0eaf84359a20ef2b30e723013ada6573fb0133163d5bf98a74 packages/plugins/channel/webui/index.mjs
|
||||
143bf601badf5cc2b25cb24368ace2ec34840abec858d272af239d6a4ddcdecb packages/plugins/tests/channel.test.mjs
|
||||
6e743878e507be20d87ae10afdb01c105f6f4fab76d02410e065f3f20d18e597 packages/webui/src/chat-http.mjs
|
||||
7a5a18a9879fe7132f20a651c6927b007475571bd8fc45314eda9d6809288cea packages/webui/src/chat.mjs
|
||||
88dd353f5df0603ed1fd43dd543ef7b45af93fee72b6bb6a03a26c068202fbd3 packages/webui/src/public/chat.css
|
||||
68696f41a7335db23c0786537de76ff1202142518647d3d7b89b6b6503baf4f1 packages/webui/src/public/chat.js
|
||||
12250a4f052489602182a49779fa67b49c7eed9d2f4c3ed7077306b2c360a344 packages/webui/tests/chat-browser.test.mjs
|
||||
23de8678566f24a6c8bf0ca75b4550969015b06253586070e229f4eaf0bba2b4 packages/webui/tests/chat-sessions-access.test.mjs
|
||||
773538c0c6a26ab525aa30e588249ba4b4ee981129533db0348f2107da5dd1df packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,32 @@
|
||||
✖ 272 fallback: granted legacy route keeps default history, send and open without session controls (967.088999ms)
|
||||
✔ 272 fallback guard: granted explicit-session route refuses without default history or writes (780.341865ms)
|
||||
✔ 272 fallback guard: another catalog error never enables default-thread access (765.314522ms)
|
||||
✔ 272 fallback guard: owner route retains chooser and explicit default-session addressing (901.74851ms)
|
||||
ℹ tests 4
|
||||
ℹ suites 0
|
||||
ℹ pass 3
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 3503.583341
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/webui/tests/chat-sessions-access.test.mjs:74:1
|
||||
✖ 272 fallback: granted legacy route keeps default history, send and open without session controls (967.088999ms)
|
||||
AssertionError [ERR_ASSERTION]: legacy granted-user thread must remain usable after session.list access-refused
|
||||
|
||||
false !== true
|
||||
|
||||
at TestContext.<anonymous> (file:///home/jwoltje/.t3/worktrees/mosaic-stack/huey-system-manager-design/packages/webui/tests/chat-sessions-access.test.mjs:77:10)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) {
|
||||
generatedMessage: false,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: false,
|
||||
expected: true,
|
||||
operator: 'strictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Row 300 build notes (Launchpad): R6-N1, an HTTP-level test of the user-session option swap (#1807)
|
||||
|
||||
Brief: docs/plans/2026-10-23_r6-followup-rows.md § Brief: R6-N1. Reviewer Duckworth, gate owner Sage.
|
||||
Base c13c337e1 (refactor, after row 294 landed at d3059c2c3). Branch launchpad/row300.
|
||||
|
||||
## Change
|
||||
|
||||
Tests only. One new file, packages/webui/tests/user-option-swap.test.mjs. No product change and no
|
||||
existing assertion or fixture touched.
|
||||
|
||||
- **Gate stubbed open.** The file registers an in-thread load hook (`registerHooks`, as
|
||||
thread-claims.test and library-cli.test do through `--import`) keyed on the one URL
|
||||
`serve.mjs?gate=open`. On that URL alone it prepends `return true;` to `userRoute`'s body, then
|
||||
imports `startServer` from it. Every other import of serve.mjs, and every relative import inside
|
||||
the copy, is the real module. If the gate's signature line is not in serve.mjs exactly once, the
|
||||
hook throws. A renamed gate then fails loudly, instead of quietly testing the closed gate. The
|
||||
test also asserts that the copy's `userRoute` answers true for `POST /api/bus/message`.
|
||||
- **Setup.** This is granted-user.test.mjs's setup, cut to what the test needs: the fake OP, the
|
||||
in-process broker for `demo`, createSso with the access hook, and `userOptionsFor` over alice's
|
||||
transport. Every owner option is a stub that records its own name. The owner transport records
|
||||
`['transport', verb, args]`. One list, `owner`, holds both kinds of owner call.
|
||||
- **Positive control.** The owner signs in and posts `/api/bus/message`. Exactly
|
||||
`['option', 'send']` must be recorded, so the route reaches `send` and the count works.
|
||||
- **The check.** Alice signs in and walks G4's owner routes, copied, plus two chat reads. No answer
|
||||
may say `owner-only`, so the gate really is open. After each route, every owner record must be
|
||||
`['transport', 'plugins', {}]`, the read mos-1394 allowed. The message route must reach the user
|
||||
transport (`message.send`), and every transport opened is alice's.
|
||||
|
||||
Why a new file: granted-user.test.mjs's setup imports startServer statically. Passing the gate-open
|
||||
copy into it would change that fixture, and a fixture change goes to Mos. The route list and setup
|
||||
are copied, with a comment naming their source.
|
||||
|
||||
## Mutant
|
||||
|
||||
M12: serve.mjs:689 drops `send` from the user-session destructure, so a user session keeps the
|
||||
owner's `send`. Killed when "R6-N1" reports not ok. Expected failure: on `POST /api/bus/message`,
|
||||
the owner record holds `['option', 'send']`. run1/mutants.py applies it (anchor exactly once), runs
|
||||
the new file alone, restores with git checkout and checks the tree is clean.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves the manifest. On Sage's CLEAR: run1/gate.sh does npm ci (root and webui), the new
|
||||
file whole, the webui suite whole, then M12. Logs go to /mnt/storage/scratch/launchpad-r300/run1,
|
||||
outside the tree. Not run yet.
|
||||
|
||||
MANIFEST.sha256 lists the one new path.
|
||||
|
||||
## Run 1 (sage-3408 CLEAR, 12:05Z)
|
||||
|
||||
npm ci root and webui were green. The new file was red on its first run (mos-1272, placed by me):
|
||||
`TypeError: Cannot read properties of undefined (reading 'set-cookie')` in signIn. When I cut down
|
||||
granted-user.test.mjs's `call`, I dropped `headers: res.headers` from what it resolves, so the
|
||||
sign-in found no cookies. The fix puts `headers` back, the same as granted-user.test.mjs:40. No
|
||||
assertion changed. The new file then passed alone (1/0). The fix is a new commit, and the manifest
|
||||
is re-hashed. The gate resumes at new-file on the new head.
|
||||
|
||||
Run 1 resumed (TAG -r2) on db5122920, manifest db8be52ce895603e687985acfae29e53e3e8782ae945c3cded6cca36141bfa3b:
|
||||
|
||||
- new file whole: 1 pass, 0 fail.
|
||||
- webui suite whole: 375 pass, 0 fail, 0 skipped, 0 cancelled, 58.2 s (limit 600).
|
||||
- M12 killed. "R6-N1" reports not ok at :151: `POST /api/bus/message: ["option","send"]`, where
|
||||
`["transport","plugins",{}]` is expected. The restore left the tree clean.
|
||||
|
||||
Duckworth N1 (duckworth-0263, non-blocking): the setup passes no `upstream`, so the swap's
|
||||
`upstream = null` has no recorder here. The test proves the swap for the options it stubs. It
|
||||
proves nothing about `upstream`.
|
||||
@@ -0,0 +1 @@
|
||||
03b5d60f68ea2bddd779e1df19519855a5f01cc28ec9ddb26ee7c45fcfe75cf4 packages/webui/tests/user-option-swap.test.mjs
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 300 gate run 1 (brief R6-N1): npm ci (root and packages/webui), the new test file whole, the
|
||||
# webui suite whole, then mutant M12. Stops at the first red. FROM=<step> resumes at a step.
|
||||
set -u -o pipefail
|
||||
WT=/mnt/storage/scratch/launchpad-r300/wt
|
||||
OUT=$WT/agents/launchpad/work/queue-300/run1
|
||||
LOG=/mnt/storage/scratch/launchpad-r300/run1
|
||||
FROM=${FROM:-npm-root}; TAG=${TAG:-}
|
||||
mkdir -p $LOG
|
||||
export TMPDIR=$(mktemp -d /mnt/storage/scratch/tmp/gXXXXXX)
|
||||
echo "start $(date -u +%H:%M:%SZ) FROM=$FROM TAG=$TAG TMPDIR $TMPDIR head $(git -C $WT rev-parse HEAD)" >> $LOG/summary.txt
|
||||
[ -z "$(git -C $WT status --porcelain)" ] || { echo "STOP worktree not clean" | tee -a $LOG/summary.txt; exit 9; }
|
||||
on=0
|
||||
step() { # name limit dir cmd...
|
||||
local name=$1 limit=$2 dir=$3; shift 3
|
||||
[ $on = 1 ] || [ $name = $FROM ] || return 0; on=1
|
||||
local t0=$(date +%s%N)
|
||||
( cd $dir && timeout -k 10 $limit "$@" ) > $LOG/$name$TAG.log 2>&1
|
||||
local rc=$?
|
||||
local ms=$(( ($(date +%s%N) - t0) / 1000000 )); local dt=$((ms/1000)).$(printf %03d $((ms%1000)))
|
||||
echo "$name$TAG rc=$rc seconds=$dt limit=$limit $(date -u +%H:%M:%SZ)" | tee -a $LOG/summary.txt
|
||||
[ $rc -eq 0 ] || { echo "STOP at $name$TAG" | tee -a $LOG/summary.txt; exit $rc; }
|
||||
}
|
||||
step npm-root 900 $WT npm ci --ignore-scripts
|
||||
step npm-webui 900 $WT/packages/webui npm ci --ignore-scripts
|
||||
step new-file 300 $WT/packages/webui node --test tests/user-option-swap.test.mjs
|
||||
step webui 600 $WT/packages/webui node --test tests/
|
||||
[ -z "$(git -C $WT status --porcelain)" ] || { echo "STOP worktree dirty after the suite" | tee -a $LOG/summary.txt; git -C $WT status --porcelain >> $LOG/summary.txt; exit 9; }
|
||||
echo "SUITES GREEN $(date -u +%H:%M:%SZ)" | tee -a $LOG/summary.txt
|
||||
python3 -B $OUT/mutants.py
|
||||
rc=$?; echo "MUTANTS rc=$rc $(date -u +%H:%M:%SZ)" | tee -a $LOG/summary.txt; exit $rc
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 300 mutant M12 (BUILD.md "Mutant"): serve.mjs keeps the owner's send for a user session. The
|
||||
# edit must match exactly once; the new file runs alone under timeout -k 10 300 with the TAP
|
||||
# reporter; git checkout restores and the tree must be clean. Killed = "R6-N1" reports "not ok".
|
||||
import json, os, re, subprocess, sys, time
|
||||
|
||||
WT = '/mnt/storage/scratch/launchpad-r300/wt'
|
||||
LOG = '/mnt/storage/scratch/launchpad-r300/run1'
|
||||
SWAP = "({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));"
|
||||
MUTANTS = [
|
||||
('M12', 'packages/webui/src/serve.mjs', SWAP,
|
||||
"({ bus, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));",
|
||||
('packages/webui', 'tests/user-option-swap.test.mjs'), 'R6-N1'),
|
||||
]
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(['git', '-C', WT, *a], capture_output=True, text=True, check=True).stdout
|
||||
|
||||
def main():
|
||||
if git('status', '--porcelain'): raise SystemExit('worktree not clean at start')
|
||||
summary, bad = [], 0
|
||||
for mid, path, old, new, (pkg, test), killer in MUTANTS:
|
||||
p = os.path.join(WT, path)
|
||||
try:
|
||||
s = open(p).read()
|
||||
if s.count(old) != 1: raise SystemExit(f'{mid}: anchor count {s.count(old)} in {path}')
|
||||
open(p, 'w').write(s.replace(old, new))
|
||||
diff = git('diff')
|
||||
t0 = time.time()
|
||||
r = subprocess.run(['timeout', '-k', '10', '300', 'node', '--test', '--test-reporter=tap', test],
|
||||
cwd=os.path.join(WT, pkg), capture_output=True, text=True)
|
||||
dt = round(time.time() - t0, 1)
|
||||
finally:
|
||||
git('checkout', '--', path)
|
||||
if git('status', '--porcelain'): raise SystemExit('worktree not clean after restore')
|
||||
open(f'{LOG}/{mid}.diff', 'w').write(diff)
|
||||
open(f'{LOG}/{mid}.tap', 'w').write(r.stdout + '\n# stderr\n' + r.stderr)
|
||||
res = {m.group(2): m.group(1) == 'ok' for m in re.finditer(r'^(ok|not ok) \d+ - (.*)$', r.stdout, re.M)}
|
||||
hit = [n for n in res if n.startswith(killer)]
|
||||
killed = r.returncode not in (0, 124, 137) and bool(hit) and all(not res[n] for n in hit)
|
||||
bad += not killed
|
||||
print(f'{mid} rc={r.returncode} seconds={dt} killed={killed} red={[n for n, ok in res.items() if not ok]}', flush=True)
|
||||
summary.append({'mutant': mid, 'rc': r.returncode, 'seconds': dt, 'killed': killed, 'killer': killer})
|
||||
json.dump(summary, open(f'{LOG}/mutants.json', 'w'), indent=1)
|
||||
sys.exit(1 if bad else 0)
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
# Row 301 build notes (Launchpad): R6-N4, fold user-bus.mjs back into bus.mjs (#1808)
|
||||
|
||||
Brief: docs/plans/2026-10-23_r6-followup-rows.md § Brief: R6-N4. Reviewer Duckworth, gate owner Sage.
|
||||
Base e551cd846 (refactor after row 303). Branch launchpad/row301.
|
||||
|
||||
## Change
|
||||
|
||||
- packages/webui/src/bus.mjs: the user transport moves in beside humanCall, after `busReader`:
|
||||
`USER_CLI`, `userCall`, `busBusiness` and `userCaller`, byte for byte from user-bus.mjs (checked
|
||||
with diff), under a three-line header. One import is added, `fileURLToPath` from node:url.
|
||||
`readHostState`, `spawn`, `randomBytes` and `BusReadError` were already in bus.mjs. humanCall and
|
||||
busCaller are untouched.
|
||||
- packages/webui/src/user-bus.mjs: deleted.
|
||||
- packages/webui/src/cli.mjs: the two imports become one, from './bus.mjs'.
|
||||
- packages/webui/src/console-bus.mjs: a comment names bus.mjs instead of user-bus.mjs.
|
||||
- packages/webui/tests/granted-user.test.mjs:22-23: the two imports become one, from '../src/bus.mjs'.
|
||||
Only the import path changes. Mos ruled (mos-1449, via sage-3399) that this is covered by "update
|
||||
its imports" and is not an old-test change.
|
||||
|
||||
No other reference to user-bus.mjs remains under packages/. tools/agent-watch's "user-bus" means
|
||||
something unrelated. The 294 packet's mutants name the old file as history only.
|
||||
|
||||
## Re-pin
|
||||
|
||||
channel-proof.test.mjs:28 pinned packages/webui/src/bus.mjs at 15e8123f…3313. After the fold the
|
||||
file hashes to 39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c. A pin change is an
|
||||
old-test change. Mos approved it under mos-1272 in mos-1449 (via sage-3399), as its own commit, with
|
||||
row 301 making its own re-pin window. Mos checked fd4b8c36e read-only: bus.mjs hashes to 39fcb27c,
|
||||
its diff removes no lines, user-bus.mjs from `export const USER_CLI` to the end is in it byte for
|
||||
byte, and nothing else imports user-bus.mjs. The second commit changes that one value and adds one
|
||||
comment line in the row 165/171/194 style.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves the manifest. channel-proof.test, the webui suite and the 294 user-transport
|
||||
tests (granted-user.test.mjs) run whole and green, on Sage's CLEAR.
|
||||
|
||||
MANIFEST.sha256 lists the changed paths. DELETED.txt names the one removed path.
|
||||
|
||||
## Run 1 (sage-3408 CLEAR, 12:08Z, head 04a179740)
|
||||
|
||||
npm ci root and webui green. channel-proof.test whole 2 pass 0 fail. The webui suite whole went
|
||||
374 tests, 372 pass, 2 fail, 0 skipped, so the gate stopped. granted-user.test whole, run after the
|
||||
stop to see whether the stop hid another red: 19 pass 0 fail. No file changed.
|
||||
|
||||
Both reds were in old tests: library-cli.test.mjs:10 (C2-04) and thread-claims.test.mjs:54 (row278
|
||||
Console adapter). Each failed with `SyntaxError: The requested module './bus.mjs' does not provide an
|
||||
export named 'busBusiness'` at cli.mjs:5. Each test spawns cli.mjs with a registerHooks load hook
|
||||
that replaces /webui/src/bus.mjs with a fixture exporting only BusReadError and busCaller. Before
|
||||
the fold, cli.mjs took busBusiness and userCaller from the real user-bus.mjs. Now they come from the
|
||||
stubbed bus.mjs, so cli.mjs fails to link. cli.mjs calls them only under --sso (:98, :103), and
|
||||
neither test passes --sso. A URL-keyed stub is not an import, so the "nothing else imports
|
||||
user-bus.mjs" check and the static reviews missed it. `git grep "webui/src/bus.mjs" --
|
||||
'packages/*/tests/*'` finds exactly channel-proof, library-cli and thread-claims.
|
||||
|
||||
## Fixture change (mos-1451 under mos-1272, via sage-3418)
|
||||
|
||||
Mos approved option A with one change and refused option B, a namespace import in cli.mjs. Each
|
||||
of the two busSource template strings (library-cli.test.mjs:17, thread-claims.test.mjs:58) gets
|
||||
this appended, and nothing else changes in either file:
|
||||
|
||||
export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};
|
||||
|
||||
The stubs throw rather than return null, so a test that reaches them outside --sso fails loudly.
|
||||
The text has no backtick, `${` or backslash, so the template literal carries it unchanged. The
|
||||
manifest goes from five paths to seven. Sage's gate: webui 374/0, channel-proof 2/0,
|
||||
granted-user 19/0. Any other red stops the gate and goes to Mos.
|
||||
@@ -0,0 +1 @@
|
||||
deleted packages/webui/src/user-bus.mjs
|
||||
@@ -0,0 +1,7 @@
|
||||
db1f05e312b9bee7ece7df7df78047a2965024109a297cb8a24112135a6e5f04 packages/bus/tests/channel-proof.test.mjs
|
||||
39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c packages/webui/src/bus.mjs
|
||||
0862b2b5bf6511d2c5deca1f318eee643edaee489c0ce8c83e57364f2f54a3d8 packages/webui/src/cli.mjs
|
||||
653d2087c0796a35c3884b7243d1adf8a78c52950a4924049317063f2d3c0974 packages/webui/src/console-bus.mjs
|
||||
34b6d052855369c8f7cf1faf8bab3c59ddf96ccd4c42731bd86a4e61e5e70640 packages/webui/tests/granted-user.test.mjs
|
||||
7e782c2b6b46b6b8b6e58cd3d148bf041d49e8fbf2cbbed51080cdb02fc11d89 packages/webui/tests/library-cli.test.mjs
|
||||
dd1e62d14a59bb8fe7cd4654dd1de8e518270c36d104ee356e165faee7f95a02 packages/webui/tests/thread-claims.test.mjs
|
||||
@@ -0,0 +1,205 @@
|
||||
# Row 306: a revoked run's late bus call stops it instead of exiting 22 (#1813, 295 N1)
|
||||
|
||||
Owner Morgana. Reviewer Dewey. Gate owner Sage. Brief: docs/plans/2026-10-23_gate-env-rows.md, third
|
||||
section (c16e943b9). Go: sage-3478 (Mos-1453 puts 306 next). Base: refactor fe4ee8c7b, after row 272
|
||||
(d33989230) and row 307 (d243f6b25). Every line below is as it stands at fe4ee8c7b.
|
||||
|
||||
## Design round 1 (no code yet), round 2 edits marked r2
|
||||
|
||||
### The window today
|
||||
|
||||
- `#revokeLost` (packages/bus/src/broker.mjs:1012-1029) ends each lost user thread's held claim in
|
||||
one transaction: `#claimEnd(..., 'revoke', null, 'access-revoked', by)` at :1022. The four
|
||||
callers are the timer `accessSweep` (:1039), a user's request (:4542), `access.reload` (:4561)
|
||||
and `threadsState` (:1107 and the access-expiry sweep at :1113).
|
||||
- The launcher learns of the loss from `threadsState`'s `revoked: true` row, records
|
||||
`thread-revoked` and sends SIGTERM (packages/cli/src/launcher.mjs:710-718).
|
||||
- A runner call between that claim end and the signal goes through `#agent` (:1064-1072). The
|
||||
run's latest claim row is now op `revoke`, so the check at :1069 fails. :1070 answers
|
||||
`launcher-released` only for a run in `#launcherReleased` (:431, row 302), and `not-holder`
|
||||
otherwise.
|
||||
- The runner's `ended()` (packages/harness/src/runner.mjs:488-500) counts `not-holder` as a gone
|
||||
session (`SESSION_GONE`, :55). It sets `code = EXIT.session` and the run exits 22 with
|
||||
`session-invalid`.
|
||||
|
||||
### D1: the broker remembers the revoked run in memory, the same way as #launcherReleased
|
||||
|
||||
- A new field next to `#launcherReleased` (:431): `#accessRevoked = new Set()`, keyed
|
||||
`business:run`, with a comment naming row 306.
|
||||
- `#revokeLost` adds each ended claim's `h.business + ':' + h.holder_run`. It adds them after the
|
||||
transaction at :1018-1026 commits, by iterating `ended`, so a transaction that throws leaves the
|
||||
Set unchanged. This is the only place the Set grows. No other `#claimEnd` caller adds to it:
|
||||
`assistant.stop` (:6078), `role.revoke`/`role.end` (:5333, :5338), `instance.fire` (:4780), the
|
||||
limit stop (:4949), `threadIdle` (:1133) and `landRelease` do not.
|
||||
- `#agent`'s refusal at :1070 becomes three-way, and only on the existing failure branch:
|
||||
`launcher-released` if the run is in `#launcherReleased`, else `thread-revoked` if it is in
|
||||
`#accessRevoked`, else `not-holder`. A run that still holds its claim never reaches the branch.
|
||||
The two sets don't overlap. A released run holds no claim for the sweep to end, and a revoked run
|
||||
holds none for `threadIdle` or `landRelease` to release (both refuse `not-holder` first, :1131 and
|
||||
:4237).
|
||||
- **Where the memory ends.** `endLaunch` deletes the key next to `#launcherReleased.delete(key)` at
|
||||
:1394. That is also where every capability of the run is dropped (:1395-1396), so after it no call
|
||||
of the run reaches `#agent`.
|
||||
- **The alternative I didn't take.** Derive the answer from the store: the run's latest claim row is
|
||||
op `revoke` with reason `access-revoked`. It would survive a broker restart. But `reason` is
|
||||
caller text at :4780, :5333 and :5338, so a human's `role.revoke` with reason `access-revoked`
|
||||
would turn that run's `not-holder` into `thread-revoked`, a stopped exit. `by` doesn't separate the
|
||||
cases either: `access.reload` passes the human's name (:4561). The Set can't be spoofed that way,
|
||||
and it matches row 302's shape. The cost is N1 below.
|
||||
- **Unchanged.** `role.claim` keeps its order: `launcher-released` (:5252), then `run-revoked` for a
|
||||
run with any revoke row in its session (:5257-5258), then `thread-revoked` from the file (:5259).
|
||||
`bindLaunch` keeps refusing `thread-revoked` for a new run of a lost thread (:1371). The limit
|
||||
stop's `not-holder` (:4952) is unchanged.
|
||||
|
||||
### D2: the runner maps thread-revoked from a call to stopped
|
||||
|
||||
- runner.mjs gets `THREAD_REVOKED = "thread-revoked"` next to `LAUNCHER_RELEASED` (:58), with a
|
||||
comment naming row 306.
|
||||
- In `ended()` (:491-500), `thread-revoked` takes the launcher-released branch:
|
||||
- it logs its own line, `runner: thread revoked by the access file`;
|
||||
- it sets `released = true` and returns true;
|
||||
- `code` stays `EXIT.stopped`.
|
||||
|
||||
`released` also skips the exit-time `role.release` (:596-602), which is right because the sweep
|
||||
already ended the claim.
|
||||
- The refusal of the run's first `role.claim` (:459-467) is unchanged. `thread-revoked` there stays
|
||||
`EXIT.claim` (21), because the brief maps it only "from a call" after the claim. Mostly
|
||||
`bindLaunch` refuses such a run before it starts.
|
||||
- `ended()` covers every call site in the loop: landing `state` (:521), `message.receive` (:543),
|
||||
the turn's `state` (:561) and the reply's `message.send`/`message.read` (:589). The harness's tool
|
||||
calls go through the tool server and return their error to the harness, as they do for
|
||||
`launcher-released` today. The run stops at its next own call.
|
||||
|
||||
### D3: docs
|
||||
|
||||
- packages/bus/README.md:328: "the run's next bus call refuses `not-holder`" becomes
|
||||
`thread-revoked`, until the run ends (row 306).
|
||||
- packages/harness/README.md:296: exit 0 adds `thread-revoked` next to `launcher-released`.
|
||||
- The `#revokeLost` comment at broker.mjs:1008 changes the same way as the bus README.
|
||||
|
||||
## Tests (red first under mos-1272)
|
||||
|
||||
### New file packages/bus/tests/revoke-window.test.mjs
|
||||
|
||||
It uses access-revoke.test.mjs's fixture shape, copied, because that file is an old test.
|
||||
|
||||
- **T1.** A run the timer sweep ended answers `thread-revoked` on `message.receive`, `state`,
|
||||
`message.read` and `message.send`, and each answer is logged as `action.refused` with code
|
||||
`thread-revoked`. Its `role.claim` still refuses `run-revoked`. The same holds when the trigger is
|
||||
a user's request or `access.reload`, one assert each, because all three go through `#revokeLost`.
|
||||
- **T2, the control.** In the same broker, after the sweep ended the first run:
|
||||
- a second user thread's run whose claim the human's `assistant.stop` ended answers `not-holder`;
|
||||
- a third run with a pending limit stop answers `not-holder` on its first `message.receive`, and
|
||||
then `not-holder` again on its next call (r2, Dewey C2). The first answer comes from
|
||||
`#limitStop` (:4952) and never reaches `#agent`, so only the second call checks the Set.
|
||||
|
||||
### New file packages/cli/tests/revoke-window.test.mjs
|
||||
|
||||
thread-revoke.test.mjs's `start()`, copied the way idle-stop-race.test.mjs copied thread-launch's,
|
||||
with the `threadsState` op replaced by the caller.
|
||||
|
||||
- **T3, the forcing test.**
|
||||
- Setup: a user thread A owned by alice and a human-owned thread B. Both get a message and a
|
||||
RESULT, so both runs hold.
|
||||
- Trigger: a hand edit drops alice's grant.
|
||||
- The hold: the replaced `threadsState` op calls `runtime.threadsState`. That call's sweep ends A's
|
||||
claim. When the rows include A as `revoked`, and before returning them to the launcher, the op
|
||||
has the human `assistant.stop` the instance's human-owned threads (B), then waits. It waits until
|
||||
A's runner has written its verdict line and the launch log has A's `end`, then the same for B.
|
||||
The launcher doesn't tick while a pass is running, so nothing signals either run while the op
|
||||
is held.
|
||||
- Asserts for A:
|
||||
- the verdict is `thread revoked by the access file`;
|
||||
- the end is `["stopped", 0, null]`;
|
||||
- the launch log has no `thread-revoked` event for A's run (r2, Dewey C1). The launcher
|
||||
removes A from the registry before it records `end` (packages/cli/src/launcher.mjs:643-646),
|
||||
so when the held op returns, `registry.running` at :705 finds nothing and :711-717 record
|
||||
and signal nothing. The runner stopped by itself before any SIGTERM;
|
||||
- the runner log has no `session stopped working` and no `role.release:`, and ends with
|
||||
`exiting 0`;
|
||||
- the store's `action.refused` codes for A include `thread-revoked` and not `not-holder`.
|
||||
- Asserts for B, the control:
|
||||
- the verdict is `session stopped working: not-holder`;
|
||||
- the end is `["session-invalid", 22, null]`;
|
||||
- B's codes include `not-holder`.
|
||||
- Every wait returns what it last saw, so a mutant fails on an assertion, not a timeout
|
||||
(sage-2707).
|
||||
|
||||
## Mutants
|
||||
|
||||
| # | Edit | Must turn red |
|
||||
|---|------|---------------|
|
||||
| M1 | `#agent` drops the `#accessRevoked` check: the failure answers `launcher-released` or `not-holder` | T1, T3 (A gets 22) |
|
||||
| M2 | runner `ended()` maps `thread-revoked` to `EXIT.session` (it moves into `SESSION_GONE`) | T3 (A exits 22) |
|
||||
| M3 | every ended thread claim marks its run revoked: `#claimEnd` adds `s.business:h.holder_run` to `#accessRevoked` when `h.thread` (anchor `if (h.thread)`, one hit at :6495) | T2 (both halves, the limit half on its second call), T3 (B gets thread-revoked) |
|
||||
|
||||
Each anchor must hit exactly once. The kit prints the count, as row 295's M8 receipt did.
|
||||
|
||||
## Expected first-run reds
|
||||
|
||||
- **New tests (mos-1272).** On a red-first run against fe4ee8c7b, T1 and T3 are red: A gets
|
||||
`not-holder` and exits 22. T2 is green on both sides. Any other first-run red in T1-T3 I place
|
||||
myself and report after.
|
||||
- **Old test that changes: Mos GO (mos-1463, via sage-3483).** In
|
||||
packages/bus/tests/access-revoke.test.mjs (row 295), `not-holder` becomes `thread-revoked` in the
|
||||
six asserts at :103, :120, :140, :171, :201 and :262, and the :95 title's "refuses not-holder"
|
||||
becomes "refuses thread-revoked". Nothing else in the file changes; `git diff --numstat` for it
|
||||
reads `7 7`. Dewey checks that in the manifest round. At fe4ee8c7b those are the only seven
|
||||
`not-holder` lines in the file.
|
||||
- **Checked and not expected to change.**
|
||||
- thread-revoke.test.mjs:180 asserts `["stopped", 0, null]` for a run that only the SIGTERM ends.
|
||||
If its runner's poll happened to land in the window, it would get exit 0 now, where today it
|
||||
would get 22, so the assert holds either way.
|
||||
- idle-stop-race.test.mjs's control is a human `assistant.stop`, not a revoke, so it stays
|
||||
`not-holder`/22. M3 would turn it red too.
|
||||
- A `git grep` of `packages/*/tests/*` finds `not-holder` after a revoke only in
|
||||
access-revoke.test.mjs.
|
||||
|
||||
## Gate (per the brief)
|
||||
|
||||
Dewey approves this design, then the manifest. On Sage's gate (mos-1455 D4, sage-3483/3484):
|
||||
- the new files and access-revoke.test.mjs whole;
|
||||
- the bus, cli, plugins, webui, business and harness suites;
|
||||
- every `scripts/test-*.sh` under `timeout -k 10` at three times its longest run (mos-1275);
|
||||
- M1-M3.
|
||||
|
||||
Every suite shows 0 failures. Any red follows mos-1272.
|
||||
|
||||
## N items
|
||||
|
||||
Both stay out of this row (sage-3483); Sage adds them to DEFERRED at landing. Dewey agrees on N1
|
||||
(dewey-0918).
|
||||
|
||||
- N1. The Set is lost on a broker restart. recover() rebinds an earlier host's run (:1366-1373),
|
||||
and if that run makes a call before the launcher's SIGTERM, it gets `not-holder` and exits 22, as
|
||||
every revoked run does today. Seeding the Set from the store at a rebind would need a mark only
|
||||
`#revokeLost` writes (D1's alternative shows `reason` and `by` aren't one). I'd leave it unless
|
||||
Dewey wants that mark.
|
||||
- N2. A revoked run that also has a pending limit stop gets `#limitStop`'s `not-holder` (:4572-4573,
|
||||
:4952) on `message.receive` and `state`, because that check runs before `#agent`. It's rare; I'd
|
||||
leave it and record it.
|
||||
|
||||
## Round 2 (dewey-0918 CHANGES C1-C3, sage-3483)
|
||||
|
||||
- C1: T3 asserts A has no `thread-revoked` launch-log event, in place of "end before the event".
|
||||
- C2: T2's limit-stopped run makes a second call, which carries M3.
|
||||
- C3: the gate lists the six suites and every scripts/test-*.sh with 0 failures; mos-1463's
|
||||
bounds for access-revoke.test.mjs are in "Expected first-run reds".
|
||||
- Cosmetic: threadIdle's `#claimEnd` is :1133.
|
||||
|
||||
## Build (dewey-0920 APPROVE r2, sage-3487)
|
||||
|
||||
- Red-first commit d38c5213a: packages/bus/tests/revoke-window.test.mjs (T1 two tests, T2) and
|
||||
packages/cli/tests/revoke-window.test.mjs (T3), no product code.
|
||||
- Build commit (this one): D1 in broker.mjs, D2 in runner.mjs, D3 in the two READMEs and the
|
||||
#revokeLost comment, and the mos-1463 edit to access-revoke.test.mjs (numstat 7 7).
|
||||
- Nothing has run yet; every run is slot work.
|
||||
|
||||
Expected first-run reds, on d38c5213a (new tests, fe4ee8c7b product):
|
||||
- bus revoke-window: both T1 tests red, A answers `not-holder` where `thread-revoked` is expected.
|
||||
T2 green.
|
||||
- cli revoke-window T3 red: A's verdict is `session stopped working: not-holder`, its end
|
||||
`session-invalid`/22.
|
||||
- access-revoke.test.mjs is unchanged at d38c5213a, so it stays green there.
|
||||
|
||||
On the build commit every file above, and every gate suite, is expected green.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Row 304 / Vikunja 590 / Gitea #1811
|
||||
|
||||
Owner Rocko; reviewer Dewey; gate owner Sage. Base c16e943b90ef71ac96cfa5314bbea6040634d14d.
|
||||
Brief docs/plans/2026-10-23_gate-env-rows.md first section, approved exact
|
||||
one-line old-test change under mos-1442 point 1 / sage-3362.
|
||||
|
||||
265 is queued and has no active build, so 304 goes first. Slot requested;
|
||||
no Node, test, queue claim or push before Sage CLEAR.
|
||||
|
||||
Before the edit, run the unchanged whole claude-session.test.mjs with
|
||||
fixture-only HOME, CLAUDE_CONFIG_DIR and scripted API under an underscore
|
||||
TMPDIR and a hyphen TMPDIR. Expected: underscore 9/1 only at the unrestricted
|
||||
memory control (:298), hyphen 10/0. Record both raw results here. Stop for
|
||||
Sage if the installed CLI's mapping differs from [^A-Za-z0-9] -> '-'.
|
||||
|
||||
Only then replace /[/.]/g with /[^A-Za-z0-9]/g on the slug line. Every
|
||||
assertion and every other test line stays byte-identical. No product change.
|
||||
Acceptance is whole file green under mktemp, underscore, and space TMPDIR
|
||||
(if the CLI accepts space); old-regex mutant must fail the :298 assertion
|
||||
under underscore. Run scope and claim wait for Sage's slot grant.
|
||||
|
||||
Use short fixture TMPDIRs under /mnt/storage/scratch/tmp, process groups,
|
||||
explicit TAP and bounded runs. No Docker, browser, suites or live state.
|
||||
Keep token/config paths and real provider access out of every fixture.
|
||||
|
||||
## Evidence, sage-3366 (mos-1442 point 1)
|
||||
|
||||
queue next selected304; claim rev3521, rc0. Base test unchanged until both
|
||||
mapping probes finished. Correct restricted-check citation: :293, not :291.
|
||||
|
||||
| Run | TMPDIR | Exit | Pass/fail/skip |
|
||||
|---|---|---|---|
|
||||
| baseline-underscore | /mnt/storage/scratch/tmp/rk304_OV81Op | 1 | 9/1/0 |
|
||||
| baseline-hyphen | /mnt/storage/scratch/tmp/rk304-UnWJbm | 0 | 10/0/0 |
|
||||
| fixed-mktemp | /mnt/storage/scratch/tmp/rk304yw6LWT | 0 | 10/0/0 |
|
||||
| fixed-underscore | /mnt/storage/scratch/tmp/rk304_MyhpSs | 0 | 10/0/0 |
|
||||
| fixed-space | /mnt/storage/scratch/tmp/rk304 i5d7HA | 0 | 10/0/0 |
|
||||
| M1-old-regex | /mnt/storage/scratch/tmp/rk304_595gfj | 1 | 9/1/0 |
|
||||
|
||||
Baseline underscore and M1 both fail only the named unrestricted positive
|
||||
control at claude-session.test.mjs:298:10, ERR_ASSERTION, missing MARKER-MEMORY.
|
||||
Hyphen baseline is green. This confirms the expected installed-CLI mapping.
|
||||
Space is accepted and passes after the fix. All3 fixed whole-file runs30/0/0;
|
||||
baseline hyphen10/0/0; M1 one assertion kill (whole file9/1/0).0cancellations,
|
||||
0skips throughout. Tests11:05:01Z-11:05:36Z, no unexpected red or retry.
|
||||
Every other source byte, including all assertions, is unchanged, recorded in
|
||||
assertion-preservation.json. Fixed test sha2567690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9.
|
||||
Cwd node/chromium/claude sweep empty; no Docker/browser/suite/live activity.
|
||||
Sage's separate gate and Dewey's review still required; no gate claimed here.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"allOtherBytesUnchanged": true,
|
||||
"assertionLinesUnchanged": true,
|
||||
"baseSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0",
|
||||
"candidateSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
c16e943b90ef71ac96cfa5314bbea6040634d14d
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/packages/harness/tests/claude-session.test.mjs b/packages/harness/tests/claude-session.test.mjs
|
||||
index bf5cb2d29..d9c45e446 100644
|
||||
--- a/packages/harness/tests/claude-session.test.mjs
|
||||
+++ b/packages/harness/tests/claude-session.test.mjs
|
||||
@@ -269,7 +269,7 @@ test("claude adapter: a directory pin exits 2 before it runs; a pin with a space
|
||||
|
||||
test("claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do", { skip }, async (t) => {
|
||||
const { dir, workspace, env } = await session(t, []);
|
||||
- const slug = realpathSync(workspace).replace(/[/.]/g, "-");
|
||||
+ const slug = realpathSync(workspace).replace(/[^A-Za-z0-9]/g, "-");
|
||||
const plant = {
|
||||
"MARKER-USER": [join(env.HOME, ".claude", "CLAUDE.md"), join(env.CLAUDE_CONFIG_DIR, "CLAUDE.md")],
|
||||
"MARKER-PARENT": [join(dir, "CLAUDE.md")],
|
||||
@@ -0,0 +1 @@
|
||||
7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9 packages/harness/tests/claude-session.test.mjs
|
||||
@@ -0,0 +1 @@
|
||||
packages/harness/tests/claude-session.test.mjs
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,14 @@
|
||||
START baseline-underscore 2026-10-23T11:05:01.371651+00:00 /mnt/storage/scratch/tmp/rk304_OV81Op
|
||||
END {"label": "baseline-underscore", "start": "2026-10-23T11:05:01.371651+00:00", "end": "2026-10-23T11:05:06.998843+00:00", "seconds": 5.627, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_OV81Op", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-underscore.tap"}
|
||||
START baseline-hyphen 2026-10-23T11:05:07.003823+00:00 /mnt/storage/scratch/tmp/rk304-UnWJbm
|
||||
END {"label": "baseline-hyphen", "start": "2026-10-23T11:05:07.003823+00:00", "end": "2026-10-23T11:05:12.829873+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304-UnWJbm", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-hyphen.tap"}
|
||||
START fixed-mktemp 2026-10-23T11:05:12.834143+00:00 /mnt/storage/scratch/tmp/rk304yw6LWT
|
||||
END {"label": "fixed-mktemp", "start": "2026-10-23T11:05:12.834143+00:00", "end": "2026-10-23T11:05:18.560124+00:00", "seconds": 5.726, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304yw6LWT", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-mktemp.tap"}
|
||||
START fixed-underscore 2026-10-23T11:05:18.564553+00:00 /mnt/storage/scratch/tmp/rk304_MyhpSs
|
||||
END {"label": "fixed-underscore", "start": "2026-10-23T11:05:18.564553+00:00", "end": "2026-10-23T11:05:24.340312+00:00", "seconds": 5.776, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_MyhpSs", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-underscore.tap"}
|
||||
START fixed-space 2026-10-23T11:05:24.344312+00:00 /mnt/storage/scratch/tmp/rk304 i5d7HA
|
||||
END {"label": "fixed-space", "start": "2026-10-23T11:05:24.344312+00:00", "end": "2026-10-23T11:05:30.170101+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304 i5d7HA", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-space.tap"}
|
||||
START M1-old-regex 2026-10-23T11:05:30.173937+00:00 /mnt/storage/scratch/tmp/rk304_595gfj
|
||||
END {"label": "M1-old-regex", "start": "2026-10-23T11:05:30.173937+00:00", "end": "2026-10-23T11:05:36.100361+00:00", "seconds": 5.926, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_595gfj", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/M1-old-regex.tap"}
|
||||
BUILD_GREEN 2026-10-23T11:05:36.103508+00:00
|
||||
CWD_SWEEP []
|
||||
@@ -0,0 +1,99 @@
|
||||
TAP version 13
|
||||
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
---
|
||||
duration_ms: 810.176546
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: the hook alone blocks a path outside the workspace
|
||||
ok 2 - claude: the hook alone blocks a path outside the workspace
|
||||
---
|
||||
duration_ms: 503.543336
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a second turn resumes the first turn's session
|
||||
ok 3 - claude: a second turn resumes the first turn's session
|
||||
---
|
||||
duration_ms: 915.865915
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: --restricted is always passed
|
||||
ok 4 - claude adapter: --restricted is always passed
|
||||
---
|
||||
duration_ms: 4.807726
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
---
|
||||
duration_ms: 15.271577
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
---
|
||||
duration_ms: 5.309932
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
not ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
---
|
||||
duration_ms: 888.601244
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:270:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
[
|
||||
'MARKER-USER',
|
||||
'MARKER-PARENT',
|
||||
'MARKER-WS',
|
||||
- 'MARKER-MEMORY'
|
||||
]
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
0: 'MARKER-USER'
|
||||
1: 'MARKER-PARENT'
|
||||
2: 'MARKER-WS'
|
||||
3: 'MARKER-MEMORY'
|
||||
actual:
|
||||
0: 'MARKER-USER'
|
||||
1: 'MARKER-PARENT'
|
||||
2: 'MARKER-WS'
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:298:10)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
|
||||
...
|
||||
# Subtest: claude: a missing hook or MCP file refuses before claude starts
|
||||
ok 8 - claude: a missing hook or MCP file refuses before claude starts
|
||||
---
|
||||
duration_ms: 9.250346
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
---
|
||||
duration_ms: 1764.786892
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
---
|
||||
duration_ms: 871.328606
|
||||
type: 'test'
|
||||
...
|
||||
1..10
|
||||
# tests 10
|
||||
# suites 0
|
||||
# pass 9
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 5870.59342
|
||||
@@ -0,0 +1,70 @@
|
||||
TAP version 13
|
||||
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
---
|
||||
duration_ms: 780.047981
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: the hook alone blocks a path outside the workspace
|
||||
ok 2 - claude: the hook alone blocks a path outside the workspace
|
||||
---
|
||||
duration_ms: 503.370776
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a second turn resumes the first turn's session
|
||||
ok 3 - claude: a second turn resumes the first turn's session
|
||||
---
|
||||
duration_ms: 848.822885
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: --restricted is always passed
|
||||
ok 4 - claude adapter: --restricted is always passed
|
||||
---
|
||||
duration_ms: 4.629499
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
---
|
||||
duration_ms: 15.371187
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
---
|
||||
duration_ms: 5.545444
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
---
|
||||
duration_ms: 947.229815
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a missing hook or MCP file refuses before claude starts
|
||||
ok 8 - claude: a missing hook or MCP file refuses before claude starts
|
||||
---
|
||||
duration_ms: 8.467575
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
---
|
||||
duration_ms: 1738.629452
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
---
|
||||
duration_ms: 857.865121
|
||||
type: 'test'
|
||||
...
|
||||
1..10
|
||||
# tests 10
|
||||
# suites 0
|
||||
# pass 10
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 5790.544695
|
||||
@@ -0,0 +1,100 @@
|
||||
TAP version 13
|
||||
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
---
|
||||
duration_ms: 748.693342
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: the hook alone blocks a path outside the workspace
|
||||
ok 2 - claude: the hook alone blocks a path outside the workspace
|
||||
---
|
||||
duration_ms: 499.864544
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a second turn resumes the first turn's session
|
||||
ok 3 - claude: a second turn resumes the first turn's session
|
||||
---
|
||||
duration_ms: 829.415272
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: --restricted is always passed
|
||||
ok 4 - claude adapter: --restricted is always passed
|
||||
---
|
||||
duration_ms: 4.710643
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
---
|
||||
duration_ms: 14.085981
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
---
|
||||
duration_ms: 4.998474
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
not ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
---
|
||||
duration_ms: 863.150735
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:270:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
[
|
||||
'MARKER-USER',
|
||||
'MARKER-PARENT',
|
||||
'MARKER-WS',
|
||||
- 'MARKER-MEMORY'
|
||||
]
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
0: 'MARKER-USER'
|
||||
1: 'MARKER-PARENT'
|
||||
2: 'MARKER-WS'
|
||||
3: 'MARKER-MEMORY'
|
||||
actual:
|
||||
0: 'MARKER-USER'
|
||||
1: 'MARKER-PARENT'
|
||||
2: 'MARKER-WS'
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r304/packages/harness/tests/claude-session.test.mjs:298:10)
|
||||
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: claude: a missing hook or MCP file refuses before claude starts
|
||||
ok 8 - claude: a missing hook or MCP file refuses before claude starts
|
||||
---
|
||||
duration_ms: 8.695573
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
---
|
||||
duration_ms: 1671.315421
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
---
|
||||
duration_ms: 823.801717
|
||||
type: 'test'
|
||||
...
|
||||
1..10
|
||||
# tests 10
|
||||
# suites 0
|
||||
# pass 9
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 5552.702962
|
||||
@@ -0,0 +1,70 @@
|
||||
TAP version 13
|
||||
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
---
|
||||
duration_ms: 822.315273
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: the hook alone blocks a path outside the workspace
|
||||
ok 2 - claude: the hook alone blocks a path outside the workspace
|
||||
---
|
||||
duration_ms: 473.785584
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a second turn resumes the first turn's session
|
||||
ok 3 - claude: a second turn resumes the first turn's session
|
||||
---
|
||||
duration_ms: 834.093184
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: --restricted is always passed
|
||||
ok 4 - claude adapter: --restricted is always passed
|
||||
---
|
||||
duration_ms: 4.587717
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
---
|
||||
duration_ms: 15.598032
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
---
|
||||
duration_ms: 5.369426
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
---
|
||||
duration_ms: 831.362199
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a missing hook or MCP file refuses before claude starts
|
||||
ok 8 - claude: a missing hook or MCP file refuses before claude starts
|
||||
---
|
||||
duration_ms: 8.237071
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
---
|
||||
duration_ms: 1687.124498
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
---
|
||||
duration_ms: 884.665674
|
||||
type: 'test'
|
||||
...
|
||||
1..10
|
||||
# tests 10
|
||||
# suites 0
|
||||
# pass 10
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 5663.888591
|
||||
@@ -0,0 +1,70 @@
|
||||
TAP version 13
|
||||
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
---
|
||||
duration_ms: 740.530837
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: the hook alone blocks a path outside the workspace
|
||||
ok 2 - claude: the hook alone blocks a path outside the workspace
|
||||
---
|
||||
duration_ms: 484.250736
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a second turn resumes the first turn's session
|
||||
ok 3 - claude: a second turn resumes the first turn's session
|
||||
---
|
||||
duration_ms: 857.991709
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: --restricted is always passed
|
||||
ok 4 - claude adapter: --restricted is always passed
|
||||
---
|
||||
duration_ms: 5.19059
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
---
|
||||
duration_ms: 16.380045
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
---
|
||||
duration_ms: 5.47195
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
---
|
||||
duration_ms: 869.204192
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a missing hook or MCP file refuses before claude starts
|
||||
ok 8 - claude: a missing hook or MCP file refuses before claude starts
|
||||
---
|
||||
duration_ms: 8.011154
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
---
|
||||
duration_ms: 1760.816087
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
---
|
||||
duration_ms: 951.447392
|
||||
type: 'test'
|
||||
...
|
||||
1..10
|
||||
# tests 10
|
||||
# suites 0
|
||||
# pass 10
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 5778.476849
|
||||
@@ -0,0 +1,70 @@
|
||||
TAP version 13
|
||||
# Subtest: claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
ok 1 - claude: typed tools through MCP, the hook blocks, builtins outside --tools don't exist
|
||||
---
|
||||
duration_ms: 766.913918
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: the hook alone blocks a path outside the workspace
|
||||
ok 2 - claude: the hook alone blocks a path outside the workspace
|
||||
---
|
||||
duration_ms: 496.038436
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a second turn resumes the first turn's session
|
||||
ok 3 - claude: a second turn resumes the first turn's session
|
||||
---
|
||||
duration_ms: 839.816577
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: --restricted is always passed
|
||||
ok 4 - claude adapter: --restricted is always passed
|
||||
---
|
||||
duration_ms: 4.571987
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
ok 5 - claude adapter: MOSAIC_CLAUDE_BIN wins over PATH and must be an absolute executable
|
||||
---
|
||||
duration_ms: 17.17544
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
ok 6 - claude adapter: a directory pin exits 2 before it runs; a pin with a space runs
|
||||
---
|
||||
duration_ms: 5.448032
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
ok 7 - claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do
|
||||
---
|
||||
duration_ms: 873.897762
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: a missing hook or MCP file refuses before claude starts
|
||||
ok 8 - claude: a missing hook or MCP file refuses before claude starts
|
||||
---
|
||||
duration_ms: 8.282957
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
ok 9 - claude: row 66 a reply with a tell gets the correction notice on the next turn, a clean reply gets none, and the counts read after
|
||||
---
|
||||
duration_ms: 1738.792709
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
ok 10 - claude: row 66 a broken lists.json gives no clean verdict, the operator is told once, and the session still answers
|
||||
---
|
||||
duration_ms: 863.908172
|
||||
type: 'test'
|
||||
...
|
||||
1..10
|
||||
# tests 10
|
||||
# suites 0
|
||||
# pass 10
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 5704.886282
|
||||
@@ -0,0 +1,16 @@
|
||||
0b487adf165fb6b83e9cba38fc49167c6ec54b66261d955052f7ee5a877fde07 BUILD.md
|
||||
a75846e9e3217b524ad09bb423033975da6c44074d8d74dc14193550ec1666b9 assertion-preservation.json
|
||||
5ddf1af4e2d23bc579e6a4005c2b355794dcc33fbd29c0421a6d1e9ca60f70ec base.txt
|
||||
85dc6f969a11d933b04cc7b2fe49199d745029779c66bf1815f549c51ec91568 build.patch
|
||||
a09b498872aaf4f3b3af9b297d839d4ddb6fdc57d7a01f05cc1a48200c30cfd4 candidate-manifest.sha256
|
||||
8588e890cdfadd6911fd3b473aad3ff826f6dd91a52e8d10aeb4a28f7929d995 changed-paths.txt
|
||||
37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 cwd-sweep.json
|
||||
767c5c04bc6442bb6efc8de3bf86e5ef8d2bbdd90d20d72fd604d18e69290f91 driver.log
|
||||
f6f8a66ccc0c025782cc6932de9745720ca7574a0376d6122074275332abaf43 logs/M1-old-regex.tap
|
||||
dc290774a454c85033fdc8c71226f7cd56a07e52106b2868ca91ca5ea652ddca logs/baseline-hyphen.tap
|
||||
960114150fbf0a3069762d356b6cffe0068208a69e805bb0422de0da8825af35 logs/baseline-underscore.tap
|
||||
1d0836115cdf344e7cdcbe1ef9de7a3f2e57ca06a4cca42a07adb14ba82bd29a logs/fixed-mktemp.tap
|
||||
a69191da6fd3eb20ec99b92521df9418e9815b7879a159cc7b2abbd5b790261c logs/fixed-space.tap
|
||||
9776e07a392bc567609573a66a2a17619ded0863b8ee35cbc53482e42c58220b logs/fixed-underscore.tap
|
||||
3adeed328506abdeff0bb108ac16d306736e2caba1c8188d48173e5ea75b5c81 results.jsonl
|
||||
084a848241bf874a5873a881d2c47efd29deff7b6682947fc7738053556845d0 run-build.py
|
||||
@@ -0,0 +1,6 @@
|
||||
{"label": "baseline-underscore", "start": "2026-10-23T11:05:01.371651+00:00", "end": "2026-10-23T11:05:06.998843+00:00", "seconds": 5.627, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_OV81Op", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-underscore.tap"}
|
||||
{"label": "baseline-hyphen", "start": "2026-10-23T11:05:07.003823+00:00", "end": "2026-10-23T11:05:12.829873+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304-UnWJbm", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/baseline-hyphen.tap"}
|
||||
{"label": "fixed-mktemp", "start": "2026-10-23T11:05:12.834143+00:00", "end": "2026-10-23T11:05:18.560124+00:00", "seconds": 5.726, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304yw6LWT", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-mktemp.tap"}
|
||||
{"label": "fixed-underscore", "start": "2026-10-23T11:05:18.564553+00:00", "end": "2026-10-23T11:05:24.340312+00:00", "seconds": 5.776, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_MyhpSs", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-underscore.tap"}
|
||||
{"label": "fixed-space", "start": "2026-10-23T11:05:24.344312+00:00", "end": "2026-10-23T11:05:30.170101+00:00", "seconds": 5.826, "exit": 0, "counts": {"tests": 10, "pass": 10, "fail": 0, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304 i5d7HA", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "7690a4a945e45f5606633d36a3db9a160892be9d125d40351cd848f5bf3ea9c9", "skips": [], "log": "agents/rocko/work/queue-304/logs/fixed-space.tap"}
|
||||
{"label": "M1-old-regex", "start": "2026-10-23T11:05:30.173937+00:00", "end": "2026-10-23T11:05:36.100361+00:00", "seconds": 5.926, "exit": 1, "counts": {"tests": 10, "pass": 9, "fail": 1, "cancelled": 0, "skipped": 0}, "tmpdir": "/mnt/storage/scratch/tmp/rk304_595gfj", "head": "c16e943b90ef71ac96cfa5314bbea6040634d14d", "testSha256": "3a43f134d334e985e6dd50f641a7c1788de29beb86e2f9e2ac7f0ceaf924a8e0", "skips": [], "log": "agents/rocko/work/queue-304/logs/M1-old-regex.tap"}
|
||||
@@ -0,0 +1,66 @@
|
||||
from pathlib import Path
|
||||
import subprocess,os,signal,re,json,hashlib,time
|
||||
from datetime import datetime,timezone
|
||||
ROOT=Path(__file__).resolve().parents[4];P=Path(__file__).resolve().parent;O=P/'logs';O.mkdir(exist_ok=True)
|
||||
FILE='packages/harness/tests/claude-session.test.mjs';source=ROOT/FILE
|
||||
BASE='c16e943b90ef71ac96cfa5314bbea6040634d14d'
|
||||
old='realpathSync(workspace).replace(/[/.]/g, "-")';new='realpathSync(workspace).replace(/[^A-Za-z0-9]/g, "-")'
|
||||
original=source.read_bytes();assert original.decode().count(old)==1
|
||||
fixed=original.decode().replace(old,new,1).encode()
|
||||
rows=[];temps=[]
|
||||
def stamp():return datetime.now(timezone.utc).isoformat()
|
||||
def verify(data):
|
||||
assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()==BASE
|
||||
assert source.read_bytes()==data
|
||||
|
||||
def run(label,template,data):
|
||||
verify(data);tmp=subprocess.check_output(['mktemp','-d',template],text=True).strip();temps.append(tmp)
|
||||
env=os.environ.copy();env.update(TMPDIR=tmp,DOCKER_HOST='unix:///mnt/storage/scratch/rocko-r304-dead.sock',NO_COLOR='1');env.pop('MOSAIC_GITEA_CREDENTIAL_FILE',None);env.pop('MOSAIC_LIVE_PROVIDER',None)
|
||||
args=['timeout','-k','10','180','node','--test','--test-reporter=tap',FILE];log=O/(label+'.tap');start=stamp();begin=time.monotonic()
|
||||
print('START',label,start,tmp,flush=True)
|
||||
with log.open('wb') as f:
|
||||
child=subprocess.Popen(args,cwd=ROOT,env=env,stdout=f,stderr=subprocess.STDOUT,start_new_session=True)
|
||||
try:rc=child.wait(timeout=195)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid,signal.SIGKILL);child.wait();rc=124
|
||||
finally:
|
||||
try:os.killpg(child.pid,signal.SIGTERM)
|
||||
except ProcessLookupError:pass
|
||||
text=log.read_text(errors='replace');counts={k:int(m[-1]) if (m:=re.findall(r'^# '+k+r' (\d+)\s*$',text,re.M)) else None for k in ['tests','pass','fail','cancelled','skipped']}
|
||||
row=dict(label=label,start=start,end=stamp(),seconds=round(time.monotonic()-begin,3),exit=rc,counts=counts,tmpdir=tmp,head=BASE,testSha256=hashlib.sha256(data).hexdigest(),skips=[x for x in text.splitlines() if re.search(r'^skip\b|# SKIP\b',x,re.I)],log=str(log.relative_to(ROOT)))
|
||||
rows.append(row)
|
||||
with (P/'results.jsonl').open('a') as f:f.write(json.dumps(row)+'\n')
|
||||
print('END',json.dumps(row),flush=True)
|
||||
assert rc not in [124,137,-9] and 'SyntaxError:' not in text,'invalid run '+label
|
||||
verify(data);return row,text
|
||||
|
||||
def red(label,template,data):
|
||||
r,t=run(label,template,data)
|
||||
assert r['exit']!=0 and r['counts']['tests']==10 and r['counts']['pass']==9 and r['counts']['fail']==1 and r['counts']['cancelled']==0 and r['counts']['skipped']==0,'unexpected red counts '+label
|
||||
names=re.findall(r'^not ok \d+ - (.*)$',t,re.M)
|
||||
assert len(names)==1 and "CLAUDE.md files and auto-memory" in names[0] and ':298:10' in t and 'ERR_ASSERTION' in t and 'MARKER-MEMORY' in t,'unexpected red '+label
|
||||
|
||||
def green(label,template,data):
|
||||
r,t=run(label,template,data)
|
||||
assert r['exit']==0 and r['counts']['tests']==10 and r['counts']['pass']==10 and r['counts']['fail']==0 and r['counts']['cancelled']==0 and r['counts']['skipped']==0,'unexpected mapping/refusal '+label
|
||||
|
||||
try:
|
||||
red('baseline-underscore','/mnt/storage/scratch/tmp/rk304_XXXXXX',original)
|
||||
green('baseline-hyphen','/mnt/storage/scratch/tmp/rk304-XXXXXX',original)
|
||||
# Both installed-CLI probes now confirm the approved mapping before editing.
|
||||
source.write_bytes(fixed)
|
||||
green('fixed-mktemp','/mnt/storage/scratch/tmp/rk304XXXXXX',fixed)
|
||||
green('fixed-underscore','/mnt/storage/scratch/tmp/rk304_XXXXXX',fixed)
|
||||
green('fixed-space','/mnt/storage/scratch/tmp/rk304 XXXXXX',fixed)
|
||||
source.write_bytes(original)
|
||||
try:red('M1-old-regex','/mnt/storage/scratch/tmp/rk304_XXXXXX',original)
|
||||
finally:source.write_bytes(fixed)
|
||||
verify(fixed);print('BUILD_GREEN',stamp(),flush=True)
|
||||
finally:
|
||||
found=[]
|
||||
for proc in Path('/proc').glob('[0-9]*'):
|
||||
try:
|
||||
name=(proc/'comm').read_text().strip();cwd=str((proc/'cwd').resolve())
|
||||
if any(x in name for x in ['node','chrom','claude']) and any(cwd==r or cwd.startswith(r+'/') for r in [str(ROOT),*temps]):found.append(dict(pid=int(proc.name),name=name,cwd=cwd))
|
||||
except (FileNotFoundError,PermissionError,ProcessLookupError):pass
|
||||
(P/'cwd-sweep.json').write_text(json.dumps(found,indent=2)+'\n');print('CWD_SWEEP',found,flush=True)
|
||||
@@ -12,6 +12,11 @@ export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
|
||||
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
|
||||
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
|
||||
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
|
||||
# Gate rule (mos-1455 item 4, 2026-10-23): every landing gate runs the bus, cli, plugins, webui and business
|
||||
# suites whatever the row touches. The 270+271 gate skipped plugins, and its red sat on refactor unseen.
|
||||
for p in bus cli plugins webui business; do [ -d "$G/packages/$p/tests" ] || { echo "STOP no packages/$p/tests"; exit 8; }; done
|
||||
# mos-1453: a gate on the canonical node_modules must not let the webui editor rebuild test skip (Vikunja 597).
|
||||
export MOSAIC_REQUIRE_EDITOR_BUILD=1
|
||||
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
|
||||
run() { local name="$1" dir="$2" cmd="$3"
|
||||
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
|
||||
|
||||
@@ -1576,3 +1576,12 @@ are never rewritten or removed; corrections are new entries.
|
||||
- 2026-10-23T08:10:29Z | sage (Opus 5.5, 1ef1e4f8) | 299 gate and landing, 303 review, 219/295 rebases | 299 LANDED ffb2fedfb 08:03Z (done 3487, #1806 c31087); queue-commit 8a35ffadb (revs 3474-3487); 303 Duckworth approve rev 3480; requests posted as sage for 303 (c31082) and 299 (c31083); 219 full restart on bfa421b6c from 08:08Z (sage-3283)
|
||||
- 2026-10-23T10:48:40Z | sage (Opus 5.5, 1ef1e4f8) | 302/295/219 landings, Docker pool diag, 590/591 briefs, 272 rebase | 302 LANDED 3ed9fd754, 295 LANDED 2efd127ce, 219 LANDED 5fdfd7275 (#1720 c31110, P19 416 c913); test-release 11/3 from exhausted Docker pools, 18 networks removed (mos-1437/1438); queue-commit 90633c864 (revs 3495-3510, 3510 Huey's); 272 rebase and fallback ruled (sage-3357)
|
||||
2026-10-23T11:03:46Z | Gyro (Codex gpt-6.1-sol, mos-1444) | Vikunja571 PRD d8 docs alignment | R6 vocabulary boundary, mapped-folder eligibility and R14 whole-host recovery updated in parent/W4; static checks only, no tests or live changes. Fenton reviews; Sage lands.
|
||||
- 2026-10-23T11:47:32Z | filbert (Claude Opus 5.5, T3 048f3c63) | row 305 round 1 (#1812, Vikunja 591, Gizmoduck, candidate 7e8218a2e) | approve in 31127, rev 3534; static on git objects (scripts = 3bce852d5; traps kept, no compose down, ls failure fails, skip only on docker info); gate receipts read, not rerun: Gizmoduck run1 + Sage gate task 92/0, release 15/0, mutants a/b red, c survives
|
||||
2026-10-22 | Huey (Codex gpt-6.1-sol, cabd45ca) | Row 272 / 264e (#1777), sage-2796, huey/row272 on 2d0d407ba | CLI/Console session work in preparation; test execution/claim/push deferred under Gander's gate, no runtime or live change.
|
||||
2026-10-22 | Huey (cabd45ca) | Row 272 additive 264d contract gizmoduck-0272 at 78e14352f | Draft cancellation UI/CLI and terminal receipts added; tests authored but not run under sage-2816 gate, no commit/push/live change.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3141 local rebase | Draft reapplied from 2d0d407ba onto b23783f39 on huey/row272-b23783f39; product merge clean, append-only log conflicts retained both sides. Landed 270/271 contracts checked; no Node/tests/claim/commit/push pending CLEAR.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 sage-3145 draft preservation | Shared stash commit 07dc9d2f7 pinned to huey/row272-stash-keep; rebased draft committed locally by explicit Sage instruction before tests. Untested checkpoint only; claims/test/push CLEARs still pending.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 Scrooge early static read on 850b22680 | B1 and N1/N2 follow-up drafted with new tests only; old assertions/shared stub unchanged. Static diff clean; no tests/commit/push pending CLEAR, round 1 remains frozen.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 round 2, sage-3354/3357/3358/3361 | Rebased onto 5fdfd7275 on huey/row272-5fdfd7275, preserving 219/295 and approved old-test hunks; rev3510 in-progress. New fallback test ran RED first in Sage's slot: expected legacy failure, 3 guards pass; slot returned. Bounded fallback and extra guards/mutants prepared for Sage's full gate; no post-implementation run or green claim.
|
||||
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 round 3, mos-1455 / sage-3435 | Added empty role authority arrays to new CLI fixture and guarded null old at all three chat render sites; browser tests byte-identical. Same 17-path manifest.r3, no local execution or queue writes. Follow-up candidate awaits Scrooge delta review and Sage gate; gate run 1 remains RED.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Row 270's pi compaction reading moves behind the harness kind
|
||||
|
||||
Found during row 272's gate (#1777) on 10-23 (sage-3432). Mos ruled the move in mos-1455 item 3 and
|
||||
declined an EXCEPT entry. Vikunja 598.
|
||||
|
||||
## Brief: row 270's pi compaction reading moves behind the harness kind (Vikunja 598)
|
||||
|
||||
Gitea #1814. Owner Gizmoduck, who wrote row 270. Reviewer Duckworth, who reviewed it. Gate owner Sage.
|
||||
Base: refactor at this brief's commit. Design first, in `agents/gizmoduck/work/queue-307/DESIGN.md`.
|
||||
|
||||
`packages/plugins/tests/harness.test.mjs:192` (row 65) checks that no core source outside
|
||||
`packages/plugins` names a harness. It is red on refactor 1e4c1e6e9. Its `named` list is three lines
|
||||
of `packages/seat/src/compactions.mjs`:
|
||||
|
||||
- :232, where `readLedger` marks a ledger unsupported when a collection's harness isn't `"pi"`;
|
||||
- :283 and :287, where `collectCompactions` branches on `harness === "pi"`.
|
||||
|
||||
Row 270 (#1775, 8c28ee731) added them. The 270+271 gate didn't run the plugins suite.
|
||||
|
||||
REQ-PLUG-2 (`docs/prd/mosaic-stack.md:1001`) sends new work that calls out to a part of a plug-in kind
|
||||
through that kind from the start. Row 270 was new work, so its pi reading belongs in the pi harness
|
||||
module, not in core.
|
||||
|
||||
Change:
|
||||
|
||||
- The harness kind (`packages/plugins/src/kinds.mjs:52`) gains an export that collects a session
|
||||
store's compactions. A core change through `kinds.mjs` is in scope, as are its callers and its
|
||||
contract in `packages/plugins/README.md`. The design names the export, its arguments, its answer,
|
||||
and whether `interfaceVersion` moves.
|
||||
- pi's module owns the stream reading (`scanPi`, `PI_VERSION`, the Claude-history check) and answers
|
||||
what `collectCompactions` writes for pi today. claude-code's module answers harness-unsupported, as
|
||||
it does now.
|
||||
- Core keeps the ledger: `readLedger`, `compactionCount`, the O_APPEND write and the `collected` line.
|
||||
No core line names a harness. `readLedger`'s unsupported test reads a field the module's answer
|
||||
carries, not a harness name.
|
||||
- `packages/cli/src/launcher.mjs:312` calls through the kind. `packages/bus/src/runtime.mjs:282` and
|
||||
`broker.mjs` keep reading counts through `compactionCount`.
|
||||
- A ledger that row 270's code wrote gives the same count after the move, including the pi,
|
||||
claude-code and Claude-history cases. Nothing rewrites a ledger.
|
||||
|
||||
Tests, red first under mos-1272:
|
||||
|
||||
- a fixture ledger written by the current code reads the same count before and after the move, one
|
||||
per reason row 270 writes;
|
||||
- pi's module counts a fixture stream the way `scanPi` does now, with the read budget and stream cap;
|
||||
- a harness module without the export refuses at load (`packages/plugins/src/loader.mjs:61-64`).
|
||||
|
||||
`harness.test.mjs` stays unchanged and goes green. Any change to an existing test goes to Mos through
|
||||
Sage first. A new required export breaks every existing test fixture that builds a harness module, so
|
||||
DESIGN.md lists those fixtures (grep the tests for `interfaceVersion` and the harness exports). Those
|
||||
edits go to Mos with the design.
|
||||
|
||||
Mutants:
|
||||
|
||||
- core branches on a harness name again;
|
||||
- pi's module drops the Claude-history check;
|
||||
- `readLedger` treats every collection as supported.
|
||||
|
||||
Gate: Duckworth approves the design and then the manifest. On Sage's gate, the bus, cli, plugins,
|
||||
webui and business suites run green, plugins with no failures at all, along with every
|
||||
`scripts/test-*.sh`, and the mutants are killed.
|
||||
@@ -644,3 +644,23 @@ Open follow-up (dewey-0913 N1). Row 219 (#1720, C2-09a) lands on 5fdfd7275 with
|
||||
## Row295 N1: a revoked run's late bus call exits 22, not stopped
|
||||
|
||||
Open follow-up (morgana-0167, sage-3318, sage-3360), to become a row. Row 295 (#1801) lands on 2efd127ce. Revocation ends a user's thread claim in one transaction, and the launcher then sends SIGTERM. A run that calls the bus between the claim end and the SIGTERM gets not-holder and exits 22, so its record reads error rather than stopped. Row 302's launcher-released answer covers only threadIdle and landRelease. The row gives a revoked claim the same shape: calls that need the claim answer thread-revoked until the run ends, and the runner maps that answer to stopped. It needs a forcing test that holds the window open and a mutant the test kills. Brief: docs/plans/2026-10-23_gate-env-rows.md; owner Morgana, reviewer Dewey, after row 272.
|
||||
|
||||
|
||||
|
||||
|
||||
## Row272 N1: users can't reach session verbs
|
||||
|
||||
Open follow-up (Huey's BUILD.md, sage-3357), to become a row. Row 272 (#1777) lands the session CLI and the Console chooser for owners. USER_VERBS still leaves out the session verbs, and no transport carries a user's session operation. A granted user's Console therefore falls back to the thread default when session.list answers access-refused on a route with no session named. A session link with an explicit session still refuses, and the session controls stay hidden. The row that adds user session grants (193/T5) adds the verbs to USER_VERBS under a grant check, and adds a test that a granted user lists, opens and clears only sessions on threads the grant covers. It removes the fallback once the grant exists.
|
||||
|
||||
## Row270 N6 rebound: the ledger and the session.list read are still unbounded after 272
|
||||
|
||||
Row 272 (#1777) lands without bounding the compaction ledger or the whole-file read on session.list, and it records no reason the 64 MiB collection cap is enough. N6 above stays open. It moves to the row after 307 (#1814, Vikunja 598), because 307 reshapes the compaction reading in packages/seat/src/compactions.mjs and both rows would edit readLedger. That row bounds the ledger or the read, with a test and a mutant, or records why the cap is enough.
|
||||
|
||||
## Row307 N1: row 270's store-unreadable doesn't stick
|
||||
|
||||
Duckworth, row 307 build round 1 (duckworth-0285, note d43690d0b). When a collection finds an unreadable
|
||||
session store header, it writes store-unreadable. The cursor then moves past the bad header, so the next
|
||||
collection never checks it again and the count reads as known with 0. The row 307 ledger test pins this
|
||||
behavior as it stands, so the move didn't change it. Bound to a follow-up row on row 270's
|
||||
store-unreadable path. The fix keeps the unreadable state until the header reads clean, or re-checks the
|
||||
header on every collection. Either way it's an old-test change and goes to Mos.
|
||||
+8
-7
@@ -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 3530 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3588 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -308,7 +308,7 @@ Generated from `docs/plans/queue.json` revision 3530 by `queue render`. Do not e
|
||||
| 269 | 264b T1 item 7: per-session native store, workspace and single-writer fence | gizmoduck; reviewers duckworth | #1774 | done | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T1 run half, T2 workspace and path mutants; whichever of 191 and 264b lands second wires 191's admission checks (Q5). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | Landed efcc9ea8d fast-forward on 94c33a43c (13 commits, 12 paths, manifest 455c12ee). r3 is a carry of r2 aafcc857c, range-diff = (Duckworth 0194, c30756, rev 3307). Gate r3 18:37-18:50Z all green, 0 skips: bus 834, seat 172, cli 869, case2-unit 43, singles 38. M1-M12 kills carry. Re-pin needs the Console restart by Jason or Mos right after the host. A 264a rollback refuses a v2 sessions.json, see BUILD.md Rollback. |
|
||||
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | 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). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | LANDED 7328da8ca (with 271, mos-1351) 02:13Z 10-23 on 767bb62c7: 33 commits cherry-picked from gizmoduck/row270-271-r4 49cee846e (base 25c09f19f), range-diff all '=', 40 paths identical to 49cee846e. Manifest f4174458 (15 paths) matches the tip. Duckworth APPROVE c30930 rev 3389. C2 run 5 GREEN on 53406f3e4: bus 873, seat 202, cli 905, ten scripts, 38/38 mutants killed. #1775 closes. |
|
||||
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | done | 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 | LANDED 7328da8ca (with 270, mos-1351) 02:13Z 10-23: tip 2b964a082 is in the 33-commit pick, range-diff all '='. Manifest 3ab1f5c8 (33 paths): 29 match the tip; BUILD.md(270), bus README, launcher.mjs and compactions.mjs moved under 270 and match 270's f4174458. Duckworth APPROVE rev 3380. Combined C2 run 5 GREEN, 271 M1-M14 killed. #1776 closes. |
|
||||
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | huey; reviewers scrooge | #1777 | in-review, round 2, request posted | 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 | — |
|
||||
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | huey; reviewers scrooge | #1777 | done | 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 | LANDED d33989230: 5 row commits replayed onto 1e629c892, every non-log patch-id equal, candidate paths = bea0c8e1c. Scrooge APPROVE rev 3579 c31184. Sage round 4 gate: bus, cli, plugins, webui, business; plugins red only the known base red harness.test.mjs:192 on compactions.mjs:232/283/287 (row 270), allowed by mos-1456, fixed under row 307 Vikunja 598; M1-M6 and F1-F4 killed. DEFERRED: 272 N1 user session verbs, 270 N6 rebound. |
|
||||
| 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 | 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 | done | 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 | Landed 3410df7c3 on refactor (fast-forward from 48e450ce8, 3 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 39847b15 (4 paths) blobs match the commit. Gate: whole files 7/22/6, bus 834, cli 861, harness 151, business 231, seat 167, 0 fail; 10 scripts/test-*.sh rc 0 with 3 named skips; M6 and M10 killed. Duckworth APPROVE round 1, Gitea #1780 comment 30717. |
|
||||
@@ -336,13 +336,14 @@ Generated from `docs/plans/queue.json` revision 3530 by `queue render`. Do not e
|
||||
| 297 | GitHub grant method (Bushroot's half of #436b) | bushroot; reviewers dewey | #1803 | briefed | 198 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the GitHub grant method (Bushroot's half of #436b) | — |
|
||||
| 298 | Q228: chief-of-staff grant scope | megavolt; reviewers dewey | #1804 | briefed | 252 done | Dewey approves the manifest. The touched suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: the chief-of-staff grant scope (Q228) | — |
|
||||
| 299 | 573: the P10 hop band (Vikunja 573) | gander; reviewers filbert | #1806 | done | 293 done | Filbert approves. The whole file under load at least 3 times, then the cli suite, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 573, the P10 hop band (Vikunja 573) | landed ffb2fedfb 08:03Z on 15e688cc0 via Filbert SESSIONS c1f910b4c; 5 commits = gander/row299-gate-277ace0b 63db96d8b (range-diff 5/5 =, tree differs only BUILD-LOG/SESSIONS); manifest 277ace0b sha256 -c OK. Gate green on ffa93b054: case1 whole 3x 53/0/0, cli whole 917/0/0 (59 files), P10 inner 2.27 outer 4.01, M1-M6 killed (gander-0189). Filbert approve rev 3485 c31084; N1 M1 1.23 s margin. |
|
||||
| 300 | R6-N1: HTTP-level test of the user-session option swap (294 N1) | launchpad; reviewers duckworth | #1807 | briefed | 294 done | Duckworth approves the manifest; new test file whole, webui suite, mutant M12 killed (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N1, an HTTP-level test of the user-session option swap | — |
|
||||
| 301 | R6-N4: fold user-bus.mjs back into bus.mjs (294 N4) | launchpad; reviewers duckworth | #1808 | briefed | 294 done | Duckworth approves the manifest; channel-proof.test, webui suite and 294 user-transport tests whole and green; re-pin only with Mos go (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N4, fold user-bus.mjs back into bus.mjs | — |
|
||||
| 300 | R6-N1: HTTP-level test of the user-session option swap (294 N1) | launchpad; reviewers duckworth | #1807 | done | 294 done | Duckworth approves the manifest; new test file whole, webui suite, mutant M12 killed (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N1, an HTTP-level test of the user-session option swap | Landed on refactor as 9d929911c..07315e651 (from launchpad/row300 a00b25b37, range-diff equal), pushed 5b846b048 12:28Z. Duckworth approved r2 (c31158, rev 3559). Sage gate on bcf3c1dac: user-option-swap 1/0, webui 375/0 with pinned esbuild and MOSAIC_REQUIRE_EDITOR_BUILD=1, M12 killed at :151. Run 1 red placed under mos-1272 (headers). N1 deferred: board proxy upstream has no recorder. |
|
||||
| 301 | R6-N4: fold user-bus.mjs back into bus.mjs (294 N4) | launchpad; reviewers duckworth | #1808 | done | 294 done | Duckworth approves the manifest; channel-proof.test, webui suite and 294 user-transport tests whole and green; re-pin only with Mos go (sage) | `docs/plans/2026-10-23_r6-followup-rows.md` § Brief: R6-N4, fold user-bus.mjs back into bus.mjs | Landed on refactor as bb031ec84..5b846b048 (from launchpad/row301 acc97fb9c, range-diff equal), pushed 12:28Z. Re-pin of webui bus.mjs to 39fcb27c is its own commit 59f0e44f4 (mos-1449); fixture stubs per mos-1451. Duckworth approved r2 (c31159, rev 3560). Sage gate: channel-proof 2/0, webui 374/0 pinned esbuild, granted-user 19/0. Live unchanged; 301 makes its own re-pin window. |
|
||||
| 302 | Idle-stop race: an idle-released run's last call ends it session-invalid/22 (Vikunja 588) | gizmoduck; reviewers dewey | #1809 | done | 292 done | Dewey approves the design, then the manifest; forcing test, compactions-launch.test whole, bus/cli/harness suites, all scripts/test-*.sh; fix-drop mutant dies (sage) | `docs/plans/2026-10-23_idle-stop-race-row.md` § Brief: the idle-stop race (Vikunja 588) | Landed 3ed9fd754 08:49Z 10-23: Sage cherry-picked 0e30a85fa..2a8281a6f (7 commits) onto 1ac655005; range-diff all equal, patch-ids match, tree equals gated merge 783602e06 plus packet-only 2a8281a6f files. Gate run 2 green (bus 876, harness 151, cli 918, ten test-*.sh, M1-M5 M7-M9 killed). Dewey approve rev 3492 c31092. mos-1430 fixed launcher-released.test:89. mos-1411 rerun allowance ends. |
|
||||
| 303 | R9 shows its output when rehearse makes no directory (Vikunja 589) | launchpad; reviewers duckworth | #1810 | in-review, round 1, request posted | 294 done | Duckworth approves the diff; stack-release.test.mjs whole and green on Sage's gate, in a gap (sage) | `docs/plans/2026-10-23_r9-guard-row.md` § Brief: R9 shows its output when rehearse makes no directory (Vikunja 589) | — |
|
||||
| 304 | claude-session test builds the memory slug the way the CLI does (Vikunja 590) | rocko; reviewers dewey | #1811 | in-review, round 1, request posted | — | Dewey approves the diff; claude-session.test.mjs whole and green 3x (mktemp, underscore, space TMPDIRs) on Sage's gate, in a gap; old-regex mutant red at :298 (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: claude-session test builds the memory slug the way the CLI does (Vikunja 590) | — |
|
||||
| 305 | Shell suites remove the compose network they create (Vikunja 591) | gizmoduck; reviewers filbert | #1812 | in-review, round 1, request posted | — | Filbert approves the diff; test-release.sh and test-task.sh whole and green on Sage's gate, in a gap, project network absent after each run; dropped-rm mutant red (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: shell suites remove the compose network they create (Vikunja 591) | — |
|
||||
| 303 | R9 shows its output when rehearse makes no directory (Vikunja 589) | launchpad; reviewers duckworth | #1810 | done | 294 done | Duckworth approves the diff; stack-release.test.mjs whole and green on Sage's gate, in a gap (sage) | `docs/plans/2026-10-23_r9-guard-row.md` § Brief: R9 shows its output when rehearse makes no directory (Vikunja 589) | Landed e551cd846 from launchpad 88ffecaf7, patch-id 559c802b equal, test blob e936808b matches the manifest. Gate: Duckworth approve c31079; Sage gate on c13c337e1 plus the test blob, stack-release.test.mjs whole 224/0 rc 0 in 595 s, alnum mktemp TMPDIR, default networks 6 before and 6 after. No overlap with in-review 272, 304, 305. |
|
||||
| 304 | claude-session test builds the memory slug the way the CLI does (Vikunja 590) | rocko; reviewers dewey | #1811 | done | — | Dewey approves the diff; claude-session.test.mjs whole and green 3x (mktemp, underscore, space TMPDIRs) on Sage's gate, in a gap; old-regex mutant red at :298 (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: claude-session test builds the memory slug the way the CLI does (Vikunja 590) | Landed a863015a5 from rocko 5e8354ebb, range-diff equal, patch-id 808fa765. Gate: Dewey approve c31129 rev 3533; Sage gate on e551cd846 with file 7690a4a9, claude 2.1.309: claude-session.test.mjs whole 10/0 under mktemp, underscore and space TMPDIRs; old-regex mutant under underscore 9/1 at :298. Gates may drop the alnum TMPDIR rule (mos-1432 ends here). |
|
||||
| 305 | Shell suites remove the compose network they create (Vikunja 591) | gizmoduck; reviewers filbert | #1812 | done | — | Filbert approves the diff; test-release.sh and test-task.sh whole and green on Sage's gate, in a gap, project network absent after each run; dropped-rm mutant red (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: shell suites remove the compose network they create (Vikunja 591) | Landed 353d65f57..d52d9f1f2 from gizmoduck 541dee2b7..7e8218a2e, range-diff all equal, on top of filbert 2c289f6f0. Gate: Filbert approve c31127 rev 3534; Sage gate on e551cd846 with task 7d6fbf67, release b29850ce: test-task 92/0, test-release 15/0, network check OK; mutants a and b red in both, c a known survivor; own mutant networks removed, none left. Gates stop the manual network rm (mos-1438 ends here). |
|
||||
| 306 | A revoked run's late bus call stops it instead of exiting 22 (295 N1) | morgana; reviewers dewey | #1813 | briefed | 272 done | Dewey approves the design, then the manifest; bus, harness and cli suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: a revoked run's late bus call stops it instead of exiting 22 (295 N1) | — |
|
||||
| 307 | Row 270's pi compaction reading moves behind the harness kind (REQ-PLUG-2, Vikunja 598) | gizmoduck; reviewers duckworth | #1814 | done | — | Duckworth approves the design, then the manifest; bus, cli, plugins (0 failures), webui and business suites, every scripts/test-*.sh green and the mutants killed on Sage's gate (sage) | `docs/plans/2026-10-23_harness-kind-compactions.md` § Brief: row 270's pi compaction reading moves behind the harness kind (Vikunja 598) | LANDED d243f6b25: 6 row commits replayed onto 8dc132bec, every non-log patch-id equal, candidate paths = 6fdfe3790 (manifest 17c9f8d9). Duckworth APPROVE rev 3586 c31189 round 1, C2 met. Sage gate: bus, cli 0 skipped, plugins fully green incl harness.test.mjs:192, webui, business, seat, every scripts/test-*.sh; M1-M4 killed. P19 598 commented. DEFERRED: 307 N1 store-unreadable not sticky. N3 doc-only BUILD.md red list one short, next BUILD.md change. |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+1826
-40
File diff suppressed because it is too large.
Load diff
@@ -325,8 +325,9 @@ starts such a thread and sends a running one SIGTERM.
|
||||
Revocation (row 295, #1801, FED R4, federation design 3.7). A user thread is lost when no grant
|
||||
in the access file covers it at collaborator level, so a downgrade to viewer is a loss. One
|
||||
transaction ends the held claim of every session of every lost thread, op `revoke`, reason
|
||||
`access-revoked`; the run's next bus call refuses `not-holder`, and a new run of the thread
|
||||
refuses `thread-revoked` at `bindLaunch` and at `role.claim`. A rebind of an earlier host's run is
|
||||
`access-revoked`; the run's bus calls refuse `thread-revoked` until it ends (row 306, so the runner
|
||||
stops rather than exit as a lost session), and a new run of the thread refuses `thread-revoked` at
|
||||
`bindLaunch` and at `role.claim`. A rebind of an earlier host's run is
|
||||
allowed, so the launcher's recovery can end it. Four triggers end the claims, each with its `by`:
|
||||
the runtime's timer, every 30 s (`access.intervalMs` sets it in process, as `librarySync.intervalMs`
|
||||
does), which reads the file whatever its stamp (`access-timer`); a user's request or a
|
||||
|
||||
@@ -429,6 +429,10 @@ export class Broker {
|
||||
// landRelease release and then signal, so the run's own calls in between answer launcher-released,
|
||||
// not not-holder, and the runner exits stopped instead of as a lost session.
|
||||
#launcherReleased = new Set();
|
||||
// Row 306 (#1813): runs whose claim #revokeLost ended, by business:run, until endLaunch. The launcher
|
||||
// signals only after its threadsState op returns, so the run's own calls in between answer
|
||||
// thread-revoked, not not-holder, and the runner exits stopped instead of as a lost session.
|
||||
#accessRevoked = new Set();
|
||||
#clock = 0;
|
||||
#processing;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
@@ -1005,10 +1009,10 @@ export class Broker {
|
||||
covers(g, { business: t.business, project: t.project, instance: t.instance }, now, { level: 'collaborator' }));
|
||||
}
|
||||
// Design 3.7: on a loss, one transaction ends the held claim of every session of every user thread
|
||||
// the access file no longer covers, in every business. The run's next bus call refuses not-holder
|
||||
// (F10), and a later claim refuses thread-revoked. Runs only when the read is new: a forced read,
|
||||
// a stamp it hasn't swept, or no stamp at all. `doc` is a read the caller just made, so a request
|
||||
// reads once. Returns the ended claims.
|
||||
// the access file no longer covers, in every business. The run's bus calls refuse thread-revoked
|
||||
// until it ends (row 306), and a later claim refuses thread-revoked. Runs only when the read is
|
||||
// new: a forced read, a stamp it hasn't swept, or no stamp at all. `doc` is a read the caller just
|
||||
// made, so a request reads once. Returns the ended claims.
|
||||
#revokeLost(by, force = false, doc = this.#accessDoc(force)) {
|
||||
const stamp = this.#accessSeen.stamp;
|
||||
if (!force && stamp !== null && stamp === this.#accessSwept) return [];
|
||||
@@ -1023,6 +1027,7 @@ export class Broker {
|
||||
ended.push(h);
|
||||
}
|
||||
});
|
||||
for (const h of ended) this.#accessRevoked.add(h.business + ':' + h.holder_run);
|
||||
this.#accessSwept = stamp;
|
||||
return ended;
|
||||
}
|
||||
@@ -1067,7 +1072,8 @@ export class Broker {
|
||||
if (s.human) fail('agent-required');
|
||||
const h = this.#own(s);
|
||||
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
|
||||
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released' : 'not-holder');
|
||||
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
|
||||
: this.#accessRevoked.has(s.business + ':' + s.run) ? 'thread-revoked' : 'not-holder');
|
||||
return h;
|
||||
}
|
||||
// Row 264a (#1769, T2): the generation a thread run holds, its latest claim's in its session, or
|
||||
@@ -1392,6 +1398,7 @@ export class Broker {
|
||||
if (exitCode !== null && !Number.isSafeInteger(exitCode)) fail('invalid-request');
|
||||
this.#ended.add(key);
|
||||
this.#launcherReleased.delete(key);
|
||||
this.#accessRevoked.delete(key);
|
||||
for (const [cap, s] of this.#sessions)
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
|
||||
return this.#store.transaction(() => {
|
||||
@@ -5607,13 +5614,13 @@ export class Broker {
|
||||
)
|
||||
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.
|
||||
// in its own session of the thread the user owns (264e, deferred B6). A sibling
|
||||
// session, another user or another thread cannot supply that citation.
|
||||
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)
|
||||
"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.session=? AND t.owner=m.from_run",
|
||||
a.in_reply_to, s.business, s.session)
|
||||
: null;
|
||||
if (!m) fail('user-reply-required');
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ function setup(t, { runs = true, grants = [grant(), grant({ user: 'bob' })] } =
|
||||
|
||||
const withoutAlice = (gs) => gs.filter((g) => g.user !== 'alice');
|
||||
|
||||
test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its next call refuses not-holder', (t) => {
|
||||
test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its next call refuses thread-revoked', (t) => {
|
||||
const { b, code, edit, claim, A } = setup(t);
|
||||
edit(withoutAlice);
|
||||
const ended = b.accessSweep();
|
||||
@@ -100,7 +100,7 @@ test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its
|
||||
assert.deepEqual({ ...ended[0], session: undefined }, { business: 'demo', thread: A, session: undefined, run: 'a-run' });
|
||||
assert.equal(typeof ended[0].session, 'string');
|
||||
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
|
||||
assert.equal(code('a', 'message.receive'), 'not-holder', 'the ended run');
|
||||
assert.equal(code('a', 'message.receive'), 'thread-revoked', 'the ended run');
|
||||
assert.equal(code('b', 'message.receive'), null, 'another user\'s run stays');
|
||||
assert.equal(code('h', 'message.receive'), null, 'the human\'s run stays');
|
||||
assert.deepEqual([claim('b-run').op, claim('h-run').op], ['claim', 'claim']);
|
||||
@@ -117,7 +117,7 @@ test('R4: an expiry with no write and no request ends the run at the next timer
|
||||
assert.equal(code('bob', 'thread.list'), null);
|
||||
assert.equal(claim('a-run').op, 'claim');
|
||||
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
|
||||
assert.equal(code('a', 'message.receive'), 'not-holder');
|
||||
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
||||
assert.equal(code('b', 'message.receive'), null);
|
||||
});
|
||||
|
||||
@@ -137,7 +137,7 @@ test('R4: another user\'s request after a hand edit ends the lost user\'s run, a
|
||||
assert.equal(code('a', 'message.receive'), null, 'an agent request is not a trigger');
|
||||
assert.equal(code('bob', 'thread.list'), null);
|
||||
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-change' });
|
||||
assert.equal(code('a', 'message.receive'), 'not-holder');
|
||||
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
||||
assert.equal(code('b', 'message.receive'), null);
|
||||
assert.equal(code('h', 'message.receive'), null);
|
||||
});
|
||||
@@ -168,7 +168,7 @@ test('R4: access.reload ends the lost runs at once for the human; the tester, a
|
||||
assert.deepEqual(ended.map((h) => [h.thread, h.run]), [[A, 'a-run']]);
|
||||
assert.equal(typeof ended[0].session, 'string');
|
||||
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'jason' });
|
||||
assert.equal(code('a', 'message.receive'), 'not-holder');
|
||||
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
||||
assert.equal(code('b', 'message.receive'), null);
|
||||
assert.deepEqual(b.accessSweep(), []);
|
||||
});
|
||||
@@ -198,7 +198,7 @@ test('R4: threadsState after an expiry with no write and no sweep ends the claim
|
||||
const a = row(A);
|
||||
assert.deepEqual([a.revoked, a.holder], [true, null]);
|
||||
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-expiry' });
|
||||
assert.equal(code('a', 'message.receive'), 'not-holder');
|
||||
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
||||
assert.deepEqual([row(B).holder, claim('b-run').op], ['b-run', 'claim']);
|
||||
// Ended once: the next call writes no second revoke.
|
||||
row(A);
|
||||
@@ -259,7 +259,7 @@ test('R4 runtime: a hand edit ends the run at the first timer period, intervalMs
|
||||
t.mock.timers.tick(ACCESS_SWEEP_MS - 1);
|
||||
assert.equal(receive(), null, 'before the first period');
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(receive(), 'not-holder', 'at the first period');
|
||||
assert.equal(receive(), 'thread-revoked', 'at the first period');
|
||||
// After close the timer is gone: the sweep is never called again.
|
||||
let sweeps = 0;
|
||||
b.accessSweep = () => void sweeps++;
|
||||
|
||||
@@ -25,7 +25,8 @@ const baselines = {
|
||||
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
|
||||
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
|
||||
// organization-tracker-bot-taken 409; no proof or transport change.
|
||||
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
|
||||
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
|
||||
"packages/webui/src/bus.mjs": "39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Row 306 (#1813, row 295's N1): a run whose claim #revokeLost ended answers thread-revoked on every
|
||||
// claim-needing call until the run ends, so the runner stops it instead of exiting session-invalid.
|
||||
// A claim another cause ended still answers not-holder. access-revoke.test.mjs's fixture, copied, with
|
||||
// a limit stub and a usage stub for the limit stop. BUILD.md (agents/morgana/work/queue-306) names
|
||||
// the mutant each test kills.
|
||||
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 } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const grant = (over = {}) => ({
|
||||
id: `g-${over.user ?? 'alice'}`, user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
|
||||
});
|
||||
const BUSINESSES = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, plugins: { harness: ['pi'] },
|
||||
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 } },
|
||||
},
|
||||
};
|
||||
const HIT = { path: ['tokens', 'instances', 'assistant'], limit: 100, count: 120, unknownTurns: 1 };
|
||||
|
||||
// Alice and Bob each hold a collaborator grant and own a thread; the human owns thread H. Each thread
|
||||
// has a claimed run: a-run on A, b-run on B, h-run on H. `stops` are the limit checks' answers, in order.
|
||||
function setup(t, { stops = [] } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-revoke-window-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant(), grant({ user: 'bob' })] }, rev: 1 };
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: BUSINESSES,
|
||||
access: () => access.doc,
|
||||
accessStamp: () => String(access.rev),
|
||||
limits: { mustStop: async () => stops.shift() ?? { stop: false } },
|
||||
usage: { collect: async () => ({ recorded: true }), view: () => ({}) },
|
||||
});
|
||||
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
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 edit = (fn) => {
|
||||
access.doc = { ...access.doc, revision: access.doc.revision + 1, grants: fn(access.doc.grants) };
|
||||
access.rev++;
|
||||
};
|
||||
const launch = (name, thread) => {
|
||||
caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
|
||||
return call(name, 'role.claim');
|
||||
};
|
||||
const claim = (run) => ({ ...store.get('SELECT op, reason, by FROM thread_claims WHERE holder_run=? ORDER BY seq DESC LIMIT 1', run) });
|
||||
// The codes of the run's refusals, as request() logs them.
|
||||
const refused = (run) =>
|
||||
store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", run).map((e) => JSON.parse(e.body).code);
|
||||
const P = call('human', 'project.create', { name: 'shared' }).id;
|
||||
caps.alice = b.bindUser({ business: 'demo', user: 'alice' });
|
||||
caps.bob = b.bindUser({ business: 'demo', user: 'bob' });
|
||||
const A = call('alice', 'thread.create', { project: P, name: 'alice work' }).id;
|
||||
const B = call('bob', 'thread.create', { project: P, name: 'bob work' }).id;
|
||||
const H = call('human', 'thread.create', { project: P, name: 'owner work' }).id;
|
||||
launch('a', A);
|
||||
launch('b', B);
|
||||
launch('h', H);
|
||||
return { b, store, caps, call, code, edit, claim, refused, A, B, H };
|
||||
}
|
||||
|
||||
const withoutAlice = (gs) => gs.filter((g) => g.user !== 'alice');
|
||||
|
||||
// The run's claim-needing calls after the sweep: each answers thread-revoked, and each is logged.
|
||||
function lateCalls(code) {
|
||||
return [
|
||||
code('a', 'message.receive'),
|
||||
code('a', 'state'),
|
||||
code('a', 'message.read', { id: 'm-none' }),
|
||||
code('a', 'message.send', { to: 'human', body: 'late' }),
|
||||
];
|
||||
}
|
||||
|
||||
test('row 306 T1: a run the timer sweep ended answers thread-revoked on every claim-needing call, logged, and its claim still refuses run-revoked', (t) => {
|
||||
const { b, code, edit, claim, refused } = setup(t);
|
||||
edit(withoutAlice);
|
||||
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
|
||||
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
|
||||
assert.deepEqual(lateCalls(code), Array(4).fill('thread-revoked'));
|
||||
assert.deepEqual(refused('a-run'), Array(4).fill('thread-revoked'));
|
||||
assert.equal(code('a', 'role.claim'), 'run-revoked', 'role.claim keeps its order');
|
||||
assert.equal(code('b', 'message.receive'), null, 'another user\'s run stays');
|
||||
assert.equal(code('h', 'message.receive'), null, 'the human\'s run stays');
|
||||
});
|
||||
|
||||
test('row 306 T1: a user\'s request and access.reload end the run the same way, and it answers thread-revoked', (t) => {
|
||||
for (const trigger of ['request', 'reload']) {
|
||||
const { code, call, edit, claim } = setup(t);
|
||||
edit(withoutAlice);
|
||||
if (trigger === 'request') assert.equal(code('bob', 'thread.list'), null);
|
||||
else assert.deepEqual(call('human', 'access.reload').ended.map((h) => h.run), ['a-run']);
|
||||
assert.equal(claim('a-run').reason, 'access-revoked', trigger);
|
||||
assert.equal(code('a', 'message.receive'), 'thread-revoked', trigger);
|
||||
}
|
||||
});
|
||||
|
||||
// The control (Dewey's C2 in dewey-0918): the first call after a pending limit stop answers from
|
||||
// #limitStop and never reaches #agent, so only the run's next call checks how its claim ended.
|
||||
test('row 306 T2: after the sweep, a run the human stopped and a run the limit stop ended still answer not-holder', async (t) => {
|
||||
const { b, call, code, edit, claim, refused, B } = setup(t, { stops: [{ stop: true, check: 'token-limit', hit: HIT }] });
|
||||
edit(withoutAlice);
|
||||
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
|
||||
// The human's stop takes the human's own thread only: H.
|
||||
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }).stopped.map((s) => s.run), ['h-run']);
|
||||
assert.equal(code('h', 'message.receive'), 'not-holder', 'stopped by the human');
|
||||
assert.equal(code('h', 'state'), 'not-holder', 'stopped by the human, again');
|
||||
// Bob's turn ends at the limit, and the run's next receive ends its claim.
|
||||
const { id } = call('bob', 'thread.send', { thread: B, body: 'look' });
|
||||
call('b', 'message.receive');
|
||||
await call('b', 'usage.begin', { turn: id });
|
||||
await call('b', 'usage.end', { turn: id });
|
||||
assert.equal(code('b', 'message.receive'), 'not-holder', 'the limit stop');
|
||||
assert.equal(claim('b-run').by, 'limit-check');
|
||||
assert.equal(code('b', 'message.receive'), 'not-holder', 'the limit-stopped run\'s next call');
|
||||
assert.equal(code('b', 'state'), 'not-holder');
|
||||
assert.deepEqual(refused('h-run'), ['not-holder', 'not-holder']);
|
||||
assert.deepEqual(refused('b-run'), ['not-holder', 'not-holder', 'not-holder']);
|
||||
});
|
||||
@@ -413,3 +413,20 @@ test('C2-09a: naming an instance cannot bypass its collaborator grant or disclos
|
||||
assert.equal(bean.instance, 'bean');
|
||||
assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
||||
});
|
||||
|
||||
test('264e deferred B6: a reply cannot cite the same user in a sibling session', t => {
|
||||
const { b, store, caps, call, A } = granted(t);
|
||||
// Session writes remain off USER_VERBS (Q7). Seed the future multi-session case directly,
|
||||
// without granting a collaborator new authority just to exercise the reply fence.
|
||||
const sibling = 'f0000000-0000-4000-8000-000000000001';
|
||||
store.run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,?,instance,owner,'sibling','264e-fixture' FROM threads WHERE id=?", sibling, A);
|
||||
const original = call('alice', 'thread.send', { thread: A, body: 'original asks' }).id;
|
||||
const second = call('alice', 'thread.send', { thread: A, session: sibling, body: 'sibling asks' }).id;
|
||||
caps.worker = b.bindLaunch({ business:'demo', role:'assistant', run:'session-worker', harness:'pi', thread:A, session:sibling });
|
||||
call('worker', 'role.claim');
|
||||
const before = store.get('SELECT count(*) n FROM messages').n;
|
||||
assert.throws(() => call('worker', 'message.send', { to:'user', in_reply_to:original, body:'wrong context' }), { code:'user-reply-required' });
|
||||
assert.equal(store.get('SELECT count(*) n FROM messages').n, before);
|
||||
const reply = call('worker', 'message.send', { to:'user', in_reply_to:second, body:'right context' });
|
||||
assert.equal(store.get('SELECT session FROM message_threads WHERE message=?', reply.id).session, sibling);
|
||||
});
|
||||
@@ -1147,3 +1147,45 @@ result. Resume refuses unconfirmed stops and a stale generation. A restarted
|
||||
`bus start --pm` keeps its control bus up while halted and skips the chief.
|
||||
The switch does not clear other launch/Discord brakes or retry tasks. Native
|
||||
diagnostic launch stays available. See packages/processing-control/README.md.
|
||||
|
||||
### Independent thread sessions (264e)
|
||||
|
||||
`mosaic session list <thread>` lists logical sessions and the saved default. `show`
|
||||
reads metadata/count; `open <thread> <session>` requests that session's normal guarded
|
||||
launch. Reads and `create <thread> <name> --op <key>` spend nothing and start no run.
|
||||
Use `--json` for receipts and `--business <id>` as on the thread commands. A session
|
||||
reference is its full ID, unambiguous ID prefix (8+ characters), or exact name in that
|
||||
thread. Creating or opening a session never changes the saved default. Legacy thread
|
||||
commands continue using that default.
|
||||
|
||||
`mosaic session clear <thread> <session> --yes --op <key>` requests safe context
|
||||
replacement. The confirmation preserves old history and all old workspace files,
|
||||
including dirty/untracked/unpushed work. The successor gets a fresh checkout at the
|
||||
approved revision; old work is not silently copied. No forced stop is authorized.
|
||||
The command waits up to 30 seconds by reading `session.operation`, then prints pending
|
||||
if stop proof/capacity is outstanding. Keep the operation key: `mosaic session operation
|
||||
<thread> <key> --json` reads the recorded outcome. A transport failure never triggers an
|
||||
automatic second clear. Count/clear dependencies that are not installed report
|
||||
unavailable; unsupported or incomplete count evidence reports unknown, never false zero.
|
||||
|
||||
The Console Chat thread pane offers the same chooser, New session and Clear context.
|
||||
Links preserve the selected session; drafts, paging cursors and receipts belong only
|
||||
to that session. Clear status can be checked with its operation key without another
|
||||
write. Read-only listeners disable writes. Until the reviewed multi-user integration,
|
||||
these operations retain the legacy human's own-thread authority; they grant neither
|
||||
collaborator session writes nor organization-owner access to others' private context.
|
||||
|
||||
To address a nondefault session explicitly, use `mosaic talk --thread <thread>
|
||||
--session <full-session-id> <text>`, `mosaic follow --thread <thread> --session <id>`,
|
||||
or `mosaic thread read|open <thread> --session <id>`. Omitting `--session` preserves the
|
||||
legacy saved-default behavior; opening a chooser session never redirects later sends.
|
||||
|
||||
`mosaic session cancel <thread> <clear-op-key> --yes` cancels a clear using its recorded
|
||||
operation key. Before retirement this restores the old session to active without
|
||||
replaying cancelled inputs. After retirement it cancels successor startup only; the
|
||||
old session stays retired. A successor that already started refuses cancellation.
|
||||
The Console's Check clear operation field and Cancel clear action use the same key
|
||||
and require confirmation. Cancellation adds no force-stop or kill-switch authority.
|
||||
Both before/after outcomes appear as `cancelled`, with `retired: null` or `true` in JSON.
|
||||
If a routed message is stored but cancelled because its session is clearing/retired,
|
||||
the CLI prints its ID, reports the cancellation and exits 3 without waiting for a reply.
|
||||
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { BINDING_NAME } from "../../discord/src/binding.mjs";
|
||||
import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } from "./project.mjs";
|
||||
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
|
||||
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
|
||||
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
@@ -50,6 +51,7 @@ export const USAGE = `usage:
|
||||
${PROJECT_BUS_USAGE}
|
||||
${THREADS_USAGE}
|
||||
${THREAD_USAGE}
|
||||
${SESSION_USAGE}
|
||||
mosaic inbox [--business <id>] [--json] [--tester]
|
||||
mosaic inbox --read <id>... | --read-all [--business <id>]
|
||||
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
@@ -377,8 +379,10 @@ function waitingLines(w, business) {
|
||||
// tester's replies arrive only through message.receive, as for the human; inbox --tester lists
|
||||
// decisions only.
|
||||
async function talk(rest, io, deps, receiveOnly = false) {
|
||||
const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", "--thread", ...(receiveOnly ? [] : ["--wait"])] });
|
||||
const parsed = parse(rest, { flags: ["--follow", "--tester"], values: ["--business", "--thread", "--session", ...(receiveOnly ? [] : ["--wait"])] });
|
||||
const threadRef = parsed.values["--thread"];
|
||||
const session = parsed.values["--session"];
|
||||
if (session !== undefined && (threadRef === undefined || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(session))) throw usage('--session needs --thread and a full session id');
|
||||
if (parsed.positional.length !== (receiveOnly ? 0 : threadRef === undefined ? 2 : 1)) {
|
||||
throw usage(receiveOnly ? "follow takes no arguments" : threadRef === undefined ? "talk takes a role and one quoted text" : "talk --thread takes one quoted text");
|
||||
}
|
||||
@@ -398,7 +402,7 @@ async function talk(rest, io, deps, receiveOnly = false) {
|
||||
}
|
||||
if (!receiveOnly) {
|
||||
try {
|
||||
sent = await call(thread ? "thread.send" : "message.send", thread ? { thread: thread.id, body, class: "REQUEST" } : { to, body, class: "REQUEST" });
|
||||
sent = await call(thread ? "thread.send" : "message.send", thread ? { thread: thread.id, ...(session ? { session } : {}), body, class: "REQUEST" } : { to, body, class: "REQUEST" });
|
||||
} catch (e) {
|
||||
// The bus never picks a thread for the human, and neither does the CLI (row 128).
|
||||
if (e.code === "thread-required-use-thread-send") {
|
||||
@@ -411,6 +415,11 @@ async function talk(rest, io, deps, receiveOnly = false) {
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (sent.cancelled !== undefined) {
|
||||
if (!['session-clearing', 'session-retired'].includes(sent.cancelled)) throw new CliError('outcome unknown: invalid cancellation receipt; nothing was retried', 1);
|
||||
io.stdout.write(`stored message ${sent.id}\n`);
|
||||
throw new CliError(`message cancelled (${sent.cancelled}); it was not delivered and will not be replayed`, 3);
|
||||
}
|
||||
io.stdout.write(`sent ${shortId(sent.id)} to ${to}\n`);
|
||||
if (sent.waiting) io.stdout.write(waitingLines(sent.waiting, () => businessFor(parsed, (deps.system ?? loadSystem({ env: io.env })).dataRoot)));
|
||||
if (!follow && wait === 0) return;
|
||||
@@ -418,7 +427,7 @@ async function talk(rest, io, deps, receiveOnly = false) {
|
||||
// message.receive says nothing of a message's thread, so --follow asks the thread for its ids.
|
||||
// Reading the thread first makes `follow --thread` refuse a thread that isn't the user's before
|
||||
// anything is received: message.receive writes a delivery row for every message it returns.
|
||||
const members = thread && follow ? threadMembers(call, thread.id) : null;
|
||||
const members = thread && follow ? threadMembers(call, thread.id, session) : null;
|
||||
await members?.refresh();
|
||||
const pollMs = deps.talkPollMs ?? TALK_POLL_MS;
|
||||
const deadline = Date.now() + wait * 1000;
|
||||
@@ -630,6 +639,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
||||
if (verb === "access") {
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
|
||||
@@ -306,10 +306,13 @@ export function createLauncher({
|
||||
if (system?.compactionStreams !== undefined && streams !== system.compactionStreams) {
|
||||
log(`system config: compactionStreams ${JSON.stringify(system.compactionStreams)} isn't a whole number from 32 to 65536; compaction collections open up to the default ${STREAMS} streams`);
|
||||
}
|
||||
const compactions = (store, harness, { instance, thread, session, run }) => {
|
||||
// Row 307 (#1814): the route's harness module reads its own store through the kind's
|
||||
// `compactions`. The call skips call(): call() turns a throw into a launch refusal, and a
|
||||
// collection that fails must never stop or fail the run, so this catch records it instead.
|
||||
const compactions = (store, name, harness, { instance, thread, session, run }) => {
|
||||
let c;
|
||||
try {
|
||||
c = collectCompactions(store, harness, { budget, streams });
|
||||
c = collectCompactions(store, { name, compactions: harness.compactions }, { budget, streams });
|
||||
} catch (e) {
|
||||
log(`run ${run}: compactions: ${e.message}`);
|
||||
c = { count: null, known: false, reason: "store-unreadable" };
|
||||
@@ -506,7 +509,7 @@ export function createLauncher({
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
if (store) compactions(store, model.harness, { instance, thread, session, run });
|
||||
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
|
||||
const toolSocket = join(runDir, "tools.sock");
|
||||
const turnMarker = join(runDir, "turn.done");
|
||||
const version = harnessVersion !== undefined ? harnessVersion : call(model.harness, "version", () => harness.version({ env }));
|
||||
@@ -647,7 +650,7 @@ export function createLauncher({
|
||||
} finally {
|
||||
try { unregisterRuntime(dataRoot, managed.id); }
|
||||
catch { log(`run ${run}: processing receipt cleanup refused`); }
|
||||
if (store) compactions(store, model.harness, { instance, thread, session, run });
|
||||
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
|
||||
try { lock?.release(); }
|
||||
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
|
||||
children.delete(run);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Row 272 / 264e: logical thread sessions, through the verified human channel only.
|
||||
import { CliError } from './errors.mjs';
|
||||
import { parseArgs, resolveThread } from './thread.mjs';
|
||||
|
||||
export const SESSION_USAGE = [
|
||||
'mosaic session list <thread> [--json] [--business <id>]',
|
||||
'mosaic session show|open <thread> <session> [--json] [--business <id>]',
|
||||
'mosaic session create <thread> <name> --op <key> [--json] [--business <id>]',
|
||||
'mosaic session clear <thread> <session> --yes --op <key> [--json] [--business <id>]',
|
||||
'mosaic session cancel <thread> <key> --yes [--json] [--business <id>]',
|
||||
'mosaic session operation <thread> <key> [--json] [--business <id>]',
|
||||
].join('\n ');
|
||||
const KEY = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/;
|
||||
const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
export const compactionLabel = value => !value ? 'unavailable (count collector not installed)' :
|
||||
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
||||
`unknown (${value.reason ?? 'not-collected'})`;
|
||||
const missing = e => e.code === 'unknown-verb' || e.code === 'channel-operation';
|
||||
async function operation(call, verb, args) {
|
||||
try { return await call(verb, args); }
|
||||
catch (e) {
|
||||
if (missing(e)) throw new CliError('session operation unavailable: its dependency is not installed; nothing was retried', 3);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Never resolve a foreign full ID by falling back to the thread default.
|
||||
export function selectSession(rows, ref) {
|
||||
const exact = rows.find(r => r.id === ref) ?? rows.find(r => r.name === ref);
|
||||
if (exact) return exact;
|
||||
const hits = /^[a-f0-9-]{8,}$/i.test(ref) ? rows.filter(r => r.id.startsWith(ref)) : [];
|
||||
if (hits.length === 1) return hits[0];
|
||||
throw new CliError(hits.length > 1 ? 'ambiguous session; use the full id' : 'session not found in this thread; use mosaic session list', 2);
|
||||
}
|
||||
const validReceipt = (v, thread, op, session) => v && v.thread === thread && v.op === op &&
|
||||
(session === undefined || v.session === session) && typeof v.successor === 'string' &&
|
||||
['clearing', 'uncertain', 'retired', 'failed', 'cancelled'].includes(v.state) && [null, 'held', 'started', 'refused'].includes(v.start) &&
|
||||
(v.state !== 'cancelled' || v.retired === null || v.retired === true);
|
||||
const receipt = (value, thread, op, session) => {
|
||||
if (session !== undefined && validReceipt(value, thread, op) && value.session !== session) {
|
||||
throw new CliError('operation key already used for another session; choose a new key or query the recorded operation', 2);
|
||||
}
|
||||
if (!validReceipt(value, thread, op, session)) throw new CliError(`outcome unknown; check mosaic session operation ${thread} ${op}; nothing was retried`, 1);
|
||||
return value;
|
||||
};
|
||||
const terminal = value => ['failed', 'retired', 'cancelled'].includes(value.state);
|
||||
export async function sessionCommand(args, io, callFor, { now = Date.now, sleep = ms => new Promise(r => setTimeout(r, ms)), waitMs = 30000 } = {}) {
|
||||
const [sub, ...rest] = args;
|
||||
const usage = why => new CliError(`${why}\n${SESSION_USAGE}`, 4);
|
||||
if (!['list', 'show', 'create', 'open', 'clear', 'cancel', 'operation'].includes(sub)) throw usage('choose a session verb');
|
||||
const p = parseArgs(rest, SESSION_USAGE, { flags: ['--json', '--yes'], values: ['--business', '--op'] });
|
||||
if (p.positional.length !== (sub === 'list' ? 1 : 2)) throw usage('wrong number of session arguments');
|
||||
if (p.flags.has('--yes') && !['clear', 'cancel'].includes(sub) || p.values['--op'] !== undefined && !['create', 'clear'].includes(sub)) throw usage('option does not apply to this verb');
|
||||
if (['create', 'clear'].includes(sub) && !KEY.test(p.values['--op'] ?? '')) throw usage('--op needs a reusable operation key');
|
||||
if (sub === 'clear' && !p.flags.has('--yes')) throw usage('clear needs --yes: it ends this context, preserves historical work and uses a fresh approved checkout');
|
||||
if (sub === 'cancel' && !p.flags.has('--yes')) throw usage('cancel needs --yes: it stops replacement before retirement, or stops successor startup after retirement; nothing is un-retired or replayed');
|
||||
if (['operation', 'cancel'].includes(sub) && !KEY.test(p.positional[1])) throw usage('invalid operation key');
|
||||
const call = callFor(p), thread = await resolveThread(call, p.positional[0]);
|
||||
let result;
|
||||
if (sub === 'create') result = await operation(call, 'session.create', { thread: thread.id, name: p.positional[1], op: p.values['--op'] });
|
||||
else if (sub === 'cancel') result = receipt(await operation(call, 'session.cancel', { thread: thread.id, op: p.positional[1], confirm: true }), thread.id, p.positional[1]);
|
||||
else if (sub === 'operation') result = receipt(await operation(call, 'session.operation', { thread: thread.id, op: p.positional[1] }), thread.id, p.positional[1]);
|
||||
else {
|
||||
const rows = await call('session.list', { thread: thread.id });
|
||||
if (sub === 'list') result = rows;
|
||||
else {
|
||||
const selected = selectSession(rows, p.positional[1]);
|
||||
if (sub === 'show') result = selected;
|
||||
else if (sub === 'open') result = await call('thread.open', { thread: thread.id, session: selected.id });
|
||||
else {
|
||||
if (!Number.isSafeInteger(selected.revision)) throw new CliError('clear unavailable: the safe-clear dependency is not installed', 3);
|
||||
// Print recovery coordinates before the write, including when its answer is lost.
|
||||
io.stderr.write(`clear operation ${p.values['--op']}; check mosaic session operation ${thread.id} ${p.values['--op']}\n`);
|
||||
const op = p.values['--op'];
|
||||
// Repeated CLI invocation resumes the recorded operation even after revision changes.
|
||||
// Only a definitive not-found permits the first write. Transport uncertainty refuses.
|
||||
try { result = receipt(await operation(call, 'session.operation', { thread: thread.id, op }), thread.id, op, selected.id); }
|
||||
catch (e) { if (e.code !== 'operation-not-found') throw e; }
|
||||
if (!result) result = receipt(await operation(call, 'session.clear', { thread: thread.id, session: selected.id, revision: selected.revision, confirm: true, op }), thread.id, op, selected.id);
|
||||
const until = now() + waitMs;
|
||||
while (!terminal(result) && now() < until) {
|
||||
await sleep(Math.min(1000, Math.max(0, until - now())));
|
||||
result = receipt(await operation(call, 'session.operation', { thread: thread.id, op }), thread.id, op, selected.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sub === 'cancel' && result.state !== 'cancelled') throw new CliError('outcome unknown: query the operation; cancel was not retried', 1);
|
||||
if (p.flags.has('--json')) return json(io, result);
|
||||
if (sub === 'list' || sub === 'show') {
|
||||
const rows = sub === 'list' ? result : [result];
|
||||
return io.stdout.write(rows.length ? rows.map(r => `${r.id} ${r.name}${r.default ? ' (default)' : ''} ${r.state ?? 'status unavailable'} Compactions: ${compactionLabel(r.compactions)}\n`).join('') : 'no sessions\n');
|
||||
}
|
||||
if (sub === 'create') return io.stdout.write(`created session ${result.id}; no run started\n`);
|
||||
if (sub === 'open') return io.stdout.write(`resume requested for ${p.positional[1]} in ${thread.label}\n`);
|
||||
const cancellation = result.state !== 'cancelled' ? '' : result.retired === true ? '; startup cancelled; old session remains retired' : '; replacement cancelled; old session active; cancelled inputs not replayed';
|
||||
io.stdout.write(`operation ${result.op}: ${result.state}${terminal(result) ? '' : ' (pending; no stop forced)'}${cancellation}; successor ${result.successor ?? 'pending'}; start ${result.start ?? 'pending'}${result.refusal ? `; ${result.refusal}` : ''}\n`);
|
||||
}
|
||||
+12
-11
@@ -14,12 +14,12 @@ import { rolesDir } from "./config.mjs";
|
||||
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json] [--tester]";
|
||||
export const THREAD_USAGE = [
|
||||
"mosaic thread create <project> <name> [--business <id>] [--tester]",
|
||||
"mosaic thread open <thread> [--business <id>] [--tester]",
|
||||
"mosaic thread read <thread> [--business <id>] [--json] [--tester]",
|
||||
"mosaic thread open <thread> [--session <id>] [--business <id>] [--tester]",
|
||||
"mosaic thread read <thread> [--session <id>] [--business <id>] [--json] [--tester]",
|
||||
].join("\n ");
|
||||
export const THREAD_TALK_USAGE = [
|
||||
"mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]",
|
||||
"mosaic follow --thread <thread> [--business <id>] [--tester]",
|
||||
"mosaic talk --thread <thread> <text> [--session <id>] [--wait <seconds> | --follow] [--business <id>] [--tester]",
|
||||
"mosaic follow --thread <thread> [--session <id>] [--business <id>] [--tester]",
|
||||
].join("\n ");
|
||||
|
||||
// The parse shape callFor reads (flags, values, positional). Errors carry the verb's own usage.
|
||||
@@ -79,10 +79,10 @@ const PAGE = 200;
|
||||
|
||||
// The thread's messages after `after`, oldest first, from every page of thread.messages.
|
||||
// Reading writes no delivery or read row.
|
||||
export async function threadMessages(call, thread, after) {
|
||||
export async function threadMessages(call, thread, after, session) {
|
||||
const out = [];
|
||||
for (;;) {
|
||||
const page = await call("thread.messages", { thread, limit: PAGE, ...(after ? { after } : {}) });
|
||||
const page = await call("thread.messages", { thread, ...(session ? { session } : {}), limit: PAGE, ...(after ? { after } : {}) });
|
||||
out.push(...page);
|
||||
if (page.length < PAGE) return out;
|
||||
after = page.at(-1).id;
|
||||
@@ -93,13 +93,13 @@ export async function threadMessages(call, thread, after) {
|
||||
// does not say which thread a message is in, so `--follow` receives first and asks the thread
|
||||
// second; the broker records a message's thread when it stores the message, so a message it
|
||||
// received is already listed.
|
||||
export function threadMembers(call, thread) {
|
||||
export function threadMembers(call, thread, session) {
|
||||
const seqs = new Map();
|
||||
let last;
|
||||
return {
|
||||
seq: (id) => seqs.get(id),
|
||||
async refresh() {
|
||||
for (const m of await threadMessages(call, thread, last)) {
|
||||
for (const m of await threadMessages(call, thread, last, session)) {
|
||||
seqs.set(m.id, m.seq);
|
||||
last = m.id;
|
||||
}
|
||||
@@ -127,7 +127,8 @@ export async function threadsCommand(args, io, callFor) {
|
||||
export async function threadCommand(args, io, callFor, format) {
|
||||
const [sub, ...rest] = args;
|
||||
if (!["create", "open", "read"].includes(sub)) throw new CliError(`usage:\n ${THREAD_USAGE}`, 4);
|
||||
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business"] });
|
||||
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business", ...(sub === "create" ? [] : ["--session"])] });
|
||||
if (parsed.values["--session"] !== undefined && !UUID.test(parsed.values["--session"])) throw new CliError("--session needs a full session id", 4);
|
||||
if (parsed.positional.length !== (sub === "create" ? 2 : 1)) {
|
||||
throw new CliError(`thread ${sub} takes ${sub === "create" ? "a project and a name" : "one thread"}\n${THREAD_USAGE}`, 4);
|
||||
}
|
||||
@@ -140,10 +141,10 @@ export async function threadCommand(args, io, callFor, format) {
|
||||
}
|
||||
const thread = await resolveThread(call, parsed.positional[0]);
|
||||
if (sub === "open") {
|
||||
await call("thread.open", { thread: thread.id });
|
||||
await call("thread.open", { thread: thread.id, ...(parsed.values["--session"] ? { session: parsed.values["--session"] } : {}) });
|
||||
return io.stdout.write(`opened ${thread.label}\n`);
|
||||
}
|
||||
const messages = await threadMessages(call, thread.id);
|
||||
const messages = await threadMessages(call, thread.id, undefined, parsed.values["--session"]);
|
||||
if (parsed.flags.has("--json")) return json(io, messages);
|
||||
io.stdout.write(messages.length === 0 ? `${thread.label}: no messages\n` : messages.map(format).join(""));
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { collectCompactions, LEDGER } from "../../seat/src/compactions.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, SESSION_CREDENTIALS, sessionStore } from "../../seat/src/session.mjs";
|
||||
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
@@ -341,7 +342,7 @@ test("REQ-THREAD-2 T3 T4 a clear leaves the retired session's count as it was, i
|
||||
seeded.close();
|
||||
const legacy = sessionStore(f.dataRoot, "acme", L, L, { legacy: true });
|
||||
writeFileSync(join(legacy.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c7", parentId: null, timestamp: "2026-10-22T11:07:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
||||
assert.equal(collectCompactions(legacy, "pi").count, 1);
|
||||
assert.equal(collectCompactions(legacy, { name: "pi", compactions: pi }).count, 1);
|
||||
host = await start(t, prepared);
|
||||
const [row] = host.call("session.list", { thread: L });
|
||||
assert.equal(row.id, L);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Row 306 (#1813, row 295's N1): the access sweep ends a lost user thread's claim in the threadsState
|
||||
// op, and the launcher sends SIGTERM only after the op returns. A runner call the broker answers in
|
||||
// between used to get not-holder, and the run ended session-invalid/22 instead of stopped/0. This test
|
||||
// holds the op open until a call has landed in that window and the runner has exited, so it doesn't
|
||||
// depend on timing. The launcher skips a tick while one is running, so nothing else signals a run
|
||||
// while the op is held.
|
||||
//
|
||||
// thread-revoke.test.mjs's host, with the access file, and idle-stop-race.test.mjs's short tick and
|
||||
// poll, with threadsState replaced by the caller. BUILD.md (agents/morgana/work/queue-306) names the
|
||||
// mutant each test kills.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { Store } from "../../bus/src/store.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, SESSION_CREDENTIALS } from "../../seat/src/session.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const ADAPTER = fileURLToPath(new URL("./fixtures/thread-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
|
||||
// A mutant's kill is an assertion failure, not a timeout (sage-2707): the wait returns what it last
|
||||
// saw and the assertions after it decide.
|
||||
async function settle(fn, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
let v;
|
||||
while (Date.now() < end) {
|
||||
v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
// The assistant is the thread instance. Alice holds a collaborator grant on it in the access file;
|
||||
// `grant(false)` is the hand edit that removes it. The idle stop is an hour, so it never runs here.
|
||||
function prepare(t) {
|
||||
const root = tmp(t, "mosaic-revoke-window-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder", "assistant"], { opus: 1, sonnet: 2 });
|
||||
doc.launch.threadIdleSeconds = 3600;
|
||||
return doc;
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
gitIn(project, "add", "file.txt");
|
||||
gitIn(project, "commit", "-q", "-m", "one");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`, { mode: 0o644 });
|
||||
const file = join(root, "access.json");
|
||||
let revision = 0;
|
||||
const grant = (on) =>
|
||||
writeFileSync(file, JSON.stringify({
|
||||
accessVersion: 1,
|
||||
revision: ++revision,
|
||||
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
|
||||
grants: on ? [{ id: "g-alice", user: "alice", business: "acme", projects: ["*"], instances: ["assistant"], level: "collaborator" }] : [],
|
||||
}), { mode: 0o600 });
|
||||
grant(true);
|
||||
const context = { providers: ["authentik"], peers: [], businesses: { acme: { human: f.doc.human, instances: Object.keys(f.doc.roles) } }, ssoSubjects: [] };
|
||||
return { root, f, adapter, access: { file, context }, grant };
|
||||
}
|
||||
|
||||
// A host and its launcher, ticking every 100 ms, with runners that poll every 100 ms, so a run's
|
||||
// call lands in the held window.
|
||||
async function start(t, { f, adapter, access }, threadsState) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker({ ...boot, access });
|
||||
const ops = {
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => threadsState(runtime, m),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
sessionClears: (m) => runtime.sessionClears(m.business),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return await fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
const host = {
|
||||
business: "acme",
|
||||
path: runtime.path,
|
||||
dataRoot: f.dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [ADAPTER],
|
||||
threadTickMs: 100,
|
||||
log: () => {},
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
let alice = null;
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const as = (verb, args = {}) => runtime.broker.request((alice ??= runtime.broker.bindUser({ business: "acme", user: "alice" })), { verb, args });
|
||||
return { runtime, launcher, call, as, close };
|
||||
}
|
||||
|
||||
const launchLog = (f) =>
|
||||
existsSync(launchLogFile(f.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
|
||||
: [];
|
||||
const runnerLog = (f, run) => {
|
||||
const p = join(f.dataRoot, "launches", "acme", run, "runner.log");
|
||||
return existsSync(p) ? readFileSync(p, "utf8") : "";
|
||||
};
|
||||
const ended = (f, run) => launchLog(f).find((e) => e.event === "end" && e.run === run);
|
||||
const launched = (f, thread) => launchLog(f).find((e) => e.event === "launch" && e.thread === thread)?.run ?? null;
|
||||
// The runner's own verdict on a refusal: the revoke line, the launcher-release line, or a lost session's.
|
||||
const verdict = (f, run) =>
|
||||
/runner: (thread revoked by the access file|session released by the launcher|session stopped working: [a-z-]+)\n/.exec(runnerLog(f, run))?.[1] ?? null;
|
||||
|
||||
// Send on the thread and wait for the run's RESULT, so the run holds the thread.
|
||||
async function ask(send, read, thread, body) {
|
||||
const sent = send("thread.send", { thread, body });
|
||||
assert.ok(await settle(() => read("thread.messages", { thread }).find((m) => m.in_reply_to === sent.id && m.class === "RESULT")), `no RESULT on ${thread}`);
|
||||
}
|
||||
|
||||
test("row 306 REQ-LAUNCH-1 R4 a revoked run whose call lands before the SIGTERM ends stopped/0; a run the human stopped in the same window still ends session-invalid/22", { skip, timeout: 120000 }, async (t) => {
|
||||
const prepared = prepare(t);
|
||||
const { f } = prepared;
|
||||
let A = null;
|
||||
let window = null;
|
||||
let host = null;
|
||||
// The first pass that sees A revoked: the sweep in runtime.threadsState has ended A's claim. Inside
|
||||
// the held window the human stops their own thread's run, which no pass can signal while the op is held.
|
||||
const threadsState = async (runtime, m) => {
|
||||
const rows = runtime.threadsState(m.business);
|
||||
if (window || !rows.some((x) => x.thread === A && x.revoked)) return rows;
|
||||
window = { run: launched(f, A) };
|
||||
window.stopped = host.call("assistant.stop", { instance: "assistant" }).stopped;
|
||||
// Both runners decide on their own before the op returns and the launcher can signal.
|
||||
window.revoked = await settle(() => ended(f, window.run) && verdict(f, window.run));
|
||||
window.control = window.stopped[0]?.run ?? null;
|
||||
window.human = window.control ? await settle(() => ended(f, window.control) && verdict(f, window.control)) : null;
|
||||
window.done = true;
|
||||
return rows;
|
||||
};
|
||||
host = await start(t, prepared, threadsState);
|
||||
const project = host.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
A = host.as("thread.create", { project, name: "alice work" }).id;
|
||||
const B = host.call("thread.create", { project, name: "owner work" }).id;
|
||||
await Promise.all([ask(host.as, host.as, A, "for A"), ask(host.call, host.call, B, "for B")]);
|
||||
// The hand edit. No user request follows, so the launcher's threadsState is what sweeps.
|
||||
prepared.grant(false);
|
||||
await settle(() => window?.done, 60000);
|
||||
assert.ok(window?.done, JSON.stringify(launchLog(f)));
|
||||
const { run, control } = window;
|
||||
assert.equal(run, launched(f, A));
|
||||
assert.deepEqual(window.stopped.map((s) => [s.thread, s.run]), [[B, launched(f, B)]], "the human's stop took only the human's thread");
|
||||
await settle(() => ended(f, run) && ended(f, control), 60000);
|
||||
|
||||
// The revoked run refused in the window and exited by itself before the op returned.
|
||||
assert.equal(window.revoked, "thread revoked by the access file", runnerLog(f, run));
|
||||
const log = launchLog(f);
|
||||
const end = (r) => log.find((e) => e.event === "end" && e.run === r);
|
||||
assert.deepEqual([end(run).reason, end(run).exitCode, end(run).signal], ["stopped", 0, null]);
|
||||
// The launcher removed the run before it recorded the end, so the pass after the op had nothing to
|
||||
// signal (Dewey's C1 in dewey-0918).
|
||||
assert.deepEqual(log.filter((e) => e.event === "thread-revoked" && e.run === run), []);
|
||||
assert.doesNotMatch(runnerLog(f, run), /session stopped working/);
|
||||
// The sweep already ended the claim, so the runner doesn't release it at exit.
|
||||
assert.doesNotMatch(runnerLog(f, run), /runner: role\.release:/);
|
||||
assert.match(runnerLog(f, run), /runner: exiting 0\n/);
|
||||
// The control: a real not-holder in the same window still ends 22, with no signal.
|
||||
assert.equal(window.human, "session stopped working: not-holder", runnerLog(f, control));
|
||||
assert.deepEqual([end(control).reason, end(control).exitCode, end(control).signal], ["session-invalid", 22, null]);
|
||||
assert.match(runnerLog(f, control), /runner: exiting 22\n/);
|
||||
|
||||
await host.close();
|
||||
const store = new Store(f.dataRoot);
|
||||
t.after(() => store.close());
|
||||
const codes = (r) => store.all("SELECT body FROM events WHERE kind='action.refused' AND actor_run=? ORDER BY seq", r).map((e) => JSON.parse(e.body).code);
|
||||
assert.ok(codes(run).includes("thread-revoked"), JSON.stringify(codes(run)));
|
||||
assert.ok(!codes(run).includes("not-holder"), JSON.stringify(codes(run)));
|
||||
assert.ok(codes(control).includes("not-holder"), JSON.stringify(codes(control)));
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// Row 272 / 264e: user-facing sessions use the same human channel as threads.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { run } from '../src/cli.mjs';
|
||||
import { io } from './helpers.mjs';
|
||||
const T = '10000000-0000-4000-8000-000000000001';
|
||||
const A = '20000000-0000-4000-8000-000000000002';
|
||||
const B = '30000000-0000-4000-8000-000000000003';
|
||||
function setup() {
|
||||
const calls = [], rows = [{ id: A, name: 'default', default: true }, { id: B, name: 'second', default: false }];
|
||||
const transport = () => async (verb, args) => {
|
||||
if (verb === 'plugins') return { plugins: { channel: ['cli'] } };
|
||||
calls.push([verb, args]);
|
||||
if (verb === 'thread.list') return [{ id: T, project: A, name: 'thread' }];
|
||||
if (verb === 'project.list') return [{ id: A, name: 'project' }];
|
||||
if (verb === 'session.list') return rows;
|
||||
if (verb === 'session.create') return { id: B, thread: T, name: args.name };
|
||||
if (verb === 'thread.open') return { opened: T };
|
||||
throw Object.assign(new Error('unknown-verb'), { code: 'unknown-verb' });
|
||||
};
|
||||
const cli = async args => {
|
||||
const x = io(); const code = await run(['session', ...args, '--business', 'demo'], x, { system: { dataRoot: '/unused' }, transport });
|
||||
return { code, out: x.out.text, err: x.err.text };
|
||||
};
|
||||
return { calls, rows, cli };
|
||||
}
|
||||
test('session list/show/open select one session without changing the saved default', async () => {
|
||||
const s = setup();
|
||||
const listed = await s.cli(['list', T, '--json']);
|
||||
assert.equal(listed.code, 0, listed.err);
|
||||
assert.deepEqual(JSON.parse(listed.out), s.rows);
|
||||
const shown = await s.cli(['show', T, B, '--json']);
|
||||
assert.equal(shown.code, 0, shown.err);
|
||||
assert.equal(JSON.parse(shown.out).id, B);
|
||||
const opened = await s.cli(['open', T, B, '--json']);
|
||||
assert.equal(opened.code, 0, opened.err);
|
||||
assert.deepEqual(s.calls.at(-1), ['thread.open', { thread: T, session: B }]);
|
||||
assert.equal(s.rows[0].default, true);
|
||||
});
|
||||
test('create requires a reusable operation key and returns the broker identity', async () => {
|
||||
const s = setup();
|
||||
assert.equal((await s.cli(['create', T, 'second'])).code, 4);
|
||||
assert.ok(!s.calls.some(([v]) => v === 'session.create'));
|
||||
const made = await s.cli(['create', T, 'second', '--op', 'create-second', '--json']);
|
||||
assert.equal(made.code, 0, made.err);
|
||||
assert.equal(JSON.parse(made.out).id, B);
|
||||
assert.deepEqual(s.calls.at(-1), ['session.create', { thread: T, name: 'second', op: 'create-second' }]);
|
||||
});
|
||||
test('invalid/foreign session selection never falls back to the default or opens a session', async () => {
|
||||
const s = setup();
|
||||
for (const ref of ['missing', T, '200']) assert.notEqual((await s.cli(['open', T, ref])).code, 0);
|
||||
assert.ok(!s.calls.some(([v]) => v === 'thread.open'));
|
||||
assert.equal((await s.cli(['list', T, '--session', A])).code, 4);
|
||||
});
|
||||
test('unsupported clear refuses with a reason and never opens or creates a replacement', async () => {
|
||||
const s = setup();
|
||||
const result = await s.cli(['clear', T, B, '--yes', '--op', 'clear-second']);
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.match(result.err, /unavailable|not available|not implemented/);
|
||||
assert.ok(!s.calls.some(([v]) => ['session.create', 'thread.open'].includes(v)));
|
||||
});
|
||||
|
||||
test('clear waits by operation reads only, keeps revision, then reports pending at 30s', async () => {
|
||||
const { sessionCommand } = await import('../src/session.mjs');
|
||||
const x = io(), seen = [];
|
||||
let clock = 0, accepted = false;
|
||||
const pending = { op:'clear-one', thread:T, session:B, successor:A, state:'uncertain', start:null, refusal:null, at:null };
|
||||
const call = async (verb, args) => {
|
||||
seen.push([verb, args]);
|
||||
if (verb === 'thread.list') return [{id:T,project:A,name:'thread'}];
|
||||
if (verb === 'project.list') return [{id:A,name:'project'}];
|
||||
if (verb === 'session.list') return [{id:B,name:'second',revision:3}];
|
||||
if (verb === 'session.clear') { accepted=true; return pending; }
|
||||
if (verb === 'session.operation') { if (!accepted) throw Object.assign(new Error('operation-not-found'),{code:'operation-not-found'}); return pending; }
|
||||
assert.fail(verb);
|
||||
};
|
||||
await sessionCommand(['clear',T,B,'--yes','--op','clear-one'], x, () => call, { now:()=>clock, sleep:async ms=>{clock+=ms;} });
|
||||
assert.equal(clock, 30000);
|
||||
assert.equal(seen.filter(([v])=>v==='session.clear').length, 1);
|
||||
assert.deepEqual(seen.find(([v])=>v==='session.clear')[1], {thread:T,session:B,revision:3,confirm:true,op:'clear-one'});
|
||||
assert.match(x.out.text, /uncertain.*pending/);
|
||||
assert.match(x.err.text, /session operation.*clear-one/);
|
||||
assert.ok(seen.filter(([v])=>v==='session.operation').every(([,a])=>a.thread===T && a.op==='clear-one'));
|
||||
});
|
||||
test('count output distinguishes exact zero, lower bounds and unavailable collection', async () => {
|
||||
const { compactionLabel } = await import('../src/session.mjs');
|
||||
assert.equal(compactionLabel({known:true,count:0,reason:null}), '0');
|
||||
assert.match(compactionLabel({known:false,count:0,reason:'harness-unsupported'}), /^unknown.*harness-unsupported/);
|
||||
assert.match(compactionLabel(undefined), /^unavailable/);
|
||||
assert.match(compactionLabel({known:true,count:-1}), /^unknown/);
|
||||
});
|
||||
|
||||
test('session CLI real broker: create/list/open keeps default and selectors isolated', async t => {
|
||||
const { Broker } = await import('../../bus/src/broker.mjs');
|
||||
const { Store } = await import('../../bus/src/store.mjs');
|
||||
const { mkdtempSync, rmSync } = await import('node:fs');
|
||||
const { tmpdir } = await import('node:os');
|
||||
const { join } = await import('node:path');
|
||||
const root = mkdtempSync(join(tmpdir(),'mosaic-session-cli-'));
|
||||
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'},roles:{chief:{definition:'chief-of-staff',authority:{withinRole:[],crossRole:[]}},assistant:{definition:'assistant',authority:{withinRole:[],crossRole:[]}}},definitions:{assistant:{},'chief-of-staff':{}}}}});
|
||||
const cap = b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const call = async (verb,args={}) => verb === 'plugins' ? {plugins:{channel:['cli']}} : b.request(cap,{verb,args});
|
||||
const project = (await call('project.create',{name:'work'})).id;
|
||||
const thread = (await call('thread.create',{project,name:'one'})).id;
|
||||
const command = async args => { const x=io();const code=await run(['session',...args,'--business','demo'],x,{system:{dataRoot:root},transport:()=>call});assert.equal(code,0,x.err.text);return x.out.text; };
|
||||
const [defaultSession] = JSON.parse(await command(['list',thread,'--json']));
|
||||
const second = JSON.parse(await command(['create',thread,'second','--op','new-second','--json']));
|
||||
assert.notEqual(second.id, defaultSession.id);
|
||||
await command(['open',thread,second.id]);
|
||||
assert.equal(store.get('SELECT session FROM thread_opens ORDER BY seq DESC LIMIT 1').session, second.id);
|
||||
assert.equal(JSON.parse(await command(['list',thread,'--json'])).find(r=>r.default).id, defaultSession.id);
|
||||
});
|
||||
|
||||
test('explicit --session on thread reads, opens and talk never drops the selected id', async () => {
|
||||
const B = '30000000-0000-8000-8000-000000000003'; // clear successor v8; thread T remains v4
|
||||
const seen = [];
|
||||
const transport = () => async (verb,args={}) => {
|
||||
if (verb === 'plugins') return {plugins:{channel:['cli']}};
|
||||
seen.push([verb,args]);
|
||||
if (verb === 'thread.list') return [{id:T,project:A,name:'thread'}];
|
||||
if (verb === 'project.list') return [{id:A,name:'project'}];
|
||||
if (verb === 'thread.messages') return [];
|
||||
if (verb === 'thread.open') return {opened:T};
|
||||
if (verb === 'thread.send') return {id:A};
|
||||
assert.fail(verb);
|
||||
};
|
||||
for (const args of [['thread','read',T,'--session',B], ['thread','open',T,'--session',B], ['talk','--thread',T,'hello','--session',B,'--wait','0']]) {
|
||||
const x=io();assert.equal(await run([...args,'--business','demo'],x,{system:{dataRoot:'/unused'},transport}),0,x.err.text);
|
||||
}
|
||||
assert.ok(seen.filter(([v])=>['thread.messages','thread.open','thread.send'].includes(v)).every(([,a])=>a.session===B));
|
||||
assert.equal(seen.filter(([v])=>v==='thread.send').length,1);
|
||||
const x=io();assert.equal(await run(['talk','assistant','hello','--session',B],x,{system:{dataRoot:'/unused'},transport}),4);
|
||||
});
|
||||
|
||||
test('repeating clear with a recorded operation queries it and never sends a new revision', async () => {
|
||||
const { sessionCommand } = await import('../src/session.mjs');
|
||||
const x=io(), seen=[];
|
||||
const call=async (verb,args)=>{
|
||||
seen.push(verb);
|
||||
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
||||
if(verb==='project.list')return [{id:A,name:'project'}];
|
||||
if(verb==='session.list')return [{id:B,name:'second',revision:9,state:'retired'}];
|
||||
if(verb==='session.operation')return {op:'old-op',thread:T,session:B,successor:A,state:'retired',start:'held'};
|
||||
assert.fail(`unexpected write ${verb}`);
|
||||
};
|
||||
await sessionCommand(['clear',T,B,'--yes','--op','old-op','--json'],x,()=>call);
|
||||
assert.equal(JSON.parse(x.out.text).state,'retired');
|
||||
assert.equal(seen.filter(v=>v==='session.clear').length,0);
|
||||
});
|
||||
|
||||
test('new CLI surface refuses forged agent identity before reaching transport', async () => {
|
||||
const x=io();x.env.MOSAIC_RUN_ID='worker';let calls=0;
|
||||
const code=await run(['session','list',T,'--business','demo'],x,{system:{dataRoot:'/unused'},transport:()=>{calls++;return async()=>[];}});
|
||||
assert.equal(code,3);assert.equal(calls,0);assert.match(x.err.text,/outside any agent/);
|
||||
});
|
||||
|
||||
test('cancel requires confirmation, uses the clear op and accepts cancelled before or after retirement', async () => {
|
||||
const { sessionCommand } = await import('../src/session.mjs');
|
||||
for (const retired of [null, true]) {
|
||||
const seen=[], x=io();
|
||||
const envelope={op:'clear-one',thread:T,session:B,successor:A,state:'cancelled',start:retired ? 'held' : null,retired};
|
||||
const call=async (verb,args)=>{
|
||||
seen.push([verb,args]);
|
||||
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
||||
if(verb==='project.list')return [{id:A,name:'project'}];
|
||||
if(verb==='session.cancel')return envelope;
|
||||
assert.fail(verb);
|
||||
};
|
||||
await assert.rejects(sessionCommand(['cancel',T,'clear-one'],x,()=>call),e=>e.exitCode===4);
|
||||
assert.deepEqual(seen,[]);
|
||||
await sessionCommand(['cancel',T,'clear-one','--yes','--json'],x,()=>call);
|
||||
assert.deepEqual(JSON.parse(x.out.text),envelope);
|
||||
assert.deepEqual(seen.at(-1),['session.cancel',{thread:T,op:'clear-one',confirm:true}]);
|
||||
assert.ok(!seen.some(([v])=>['session.create','session.clear','thread.open'].includes(v)));
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelled operation is terminal: clear polling stops without another write', async () => {
|
||||
const { sessionCommand } = await import('../src/session.mjs');
|
||||
const x=io();let slept=false;
|
||||
const call=async verb=>{
|
||||
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
||||
if(verb==='project.list')return [{id:A,name:'project'}];
|
||||
if(verb==='session.list')return [{id:B,name:'second',revision:3}];
|
||||
if(verb==='session.operation')return {op:'cancelled-op',thread:T,session:B,successor:A,state:'cancelled',retired:null,start:null};
|
||||
assert.fail(`unexpected write ${verb}`);
|
||||
};
|
||||
await sessionCommand(['clear',T,B,'--op','cancelled-op','--yes','--json'],x,()=>call,{sleep:async()=>{slept=true;}});
|
||||
assert.equal(JSON.parse(x.out.text).state,'cancelled');assert.equal(slept,false);
|
||||
});
|
||||
|
||||
test('routed message cancellation prints its stored receipt and never waits for a reply', async () => {
|
||||
for(const cancelled of ['session-clearing','session-retired']) {
|
||||
const x=io(), seen=[];
|
||||
const transport=()=>async(verb,args)=>{seen.push(verb);assert.equal(verb,'message.send');return {id:A,cancelled};};
|
||||
const code=await run(['talk','assistant','reply','--business','demo'],x,{system:{dataRoot:'/unused'},transport});
|
||||
assert.equal(code,3);assert.match(x.out.text,new RegExp(A));assert.match(x.err.text,new RegExp(cancelled));
|
||||
assert.deepEqual(seen,['message.send']);
|
||||
}
|
||||
});
|
||||
|
||||
test('272 N2: reusing another session clear key gives a precise refusal without a write', async () => {
|
||||
const {sessionCommand}=await import('../src/session.mjs');
|
||||
const x=io(), seen=[];
|
||||
const call=async verb=>{
|
||||
seen.push(verb);
|
||||
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
||||
if(verb==='project.list')return [{id:A,name:'project'}];
|
||||
if(verb==='session.list')return [{id:B,name:'selected',revision:1}];
|
||||
if(verb==='session.operation')return {thread:T,op:'already-used',session:A,successor:B,state:'clearing',start:null};
|
||||
assert.fail(`unexpected write ${verb}`);
|
||||
};
|
||||
await assert.rejects(sessionCommand(['clear',T,B,'--yes','--op','already-used'],x,()=>call),e=>e.exitCode===2 && /operation key already used for another session/.test(e.message));
|
||||
assert.ok(!seen.includes('session.clear'));
|
||||
});
|
||||
@@ -293,7 +293,7 @@ it never touches the disk and isn't in the environment or argv.
|
||||
|
||||
| Exit | Meaning | Launch end reason |
|
||||
|---|---|---|
|
||||
| 0 | stopped: by a signal, or the launcher's idle stop or land-relaunch released the claim (`launcher-released`) | `stopped` |
|
||||
| 0 | stopped: by a signal, the launcher's idle stop or land-relaunch released the claim (`launcher-released`), or the access sweep ended a lost user thread's claim (`thread-revoked`, row 306) | `stopped` |
|
||||
| 2 | bad run directory or stdin | `failed` |
|
||||
| 20 | founder credentials | `founder-credentials` |
|
||||
| 21 | `role.claim` refused | `claim-refused` |
|
||||
|
||||
@@ -56,6 +56,9 @@ const SESSION_GONE = new Set(["unauthenticated", "not-holder", "run-ended"]);
|
||||
// Row 302 (#1809): the launcher's idle stop or land-relaunch released the claim and will stop the run.
|
||||
// That is a stop, not a lost session, so the run exits stopped even when its call lands before the signal.
|
||||
const LAUNCHER_RELEASED = "launcher-released";
|
||||
// Row 306 (#1813): the access sweep ended the claim of a thread whose user lost the grant, and the
|
||||
// launcher will stop the run. That is a stop too, when the run's call lands before the signal.
|
||||
const THREAD_REVOKED = "thread-revoked";
|
||||
const BODY_MAX = 32000;
|
||||
const DEFAULTS = Object.freeze({ turnTimeout: 900, pollInterval: 2000, brokerRetries: 30 });
|
||||
|
||||
@@ -485,12 +488,13 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
|
||||
let code = EXIT.stopped;
|
||||
let failures = 0;
|
||||
// Whether a broker refusal ends the run, and how: a launcher release stops it, a gone session is 22.
|
||||
// Whether a broker refusal ends the run, and how: a launcher release or an access revoke stops it,
|
||||
// a gone session is 22. Either stop already ended the claim, so `released` skips the exit release.
|
||||
// The reply site has logged its failure already, so it passes say=false.
|
||||
let released = false;
|
||||
const ended = (err, say = true) => {
|
||||
if (err.code === LAUNCHER_RELEASED) {
|
||||
log("runner: session released by the launcher");
|
||||
if (err.code === LAUNCHER_RELEASED || err.code === THREAD_REVOKED) {
|
||||
log(err.code === LAUNCHER_RELEASED ? "runner: session released by the launcher" : "runner: thread revoked by the access file");
|
||||
released = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ test("claude adapter: a directory pin exits 2 before it runs; a pin with a space
|
||||
|
||||
test("claude: CLAUDE.md files and auto-memory don't reach the model; without --restricted they do", { skip }, async (t) => {
|
||||
const { dir, workspace, env } = await session(t, []);
|
||||
const slug = realpathSync(workspace).replace(/[/.]/g, "-");
|
||||
const slug = realpathSync(workspace).replace(/[^A-Za-z0-9]/g, "-");
|
||||
const plant = {
|
||||
"MARKER-USER": [join(env.HOME, ".claude", "CLAUDE.md"), join(env.CLAUDE_CONFIG_DIR, "CLAUDE.md")],
|
||||
"MARKER-PARENT": [join(dir, "CLAUDE.md")],
|
||||
|
||||
@@ -153,6 +153,21 @@ export returns:
|
||||
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
|
||||
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
|
||||
how a turn ends and where its transcripts land.
|
||||
- `compactions({store, cursors, budget, streams, first})` reads a thread
|
||||
session's native store for row 270's compaction count (row 307). Core keeps
|
||||
the ledger (`packages/seat/src/compactions.mjs`) and passes `store` as
|
||||
`{session, projects}`, the ledger's `cursors` by stream, the read `budget`
|
||||
and stream cap `streams` from the system config, and `first` when the
|
||||
store has no ledger yet. The answer is `{found, cursors, known, reason,
|
||||
version}`: `found` lists `{entry, completedAt, stream}` past the cursors,
|
||||
`cursors` maps each stream read to `{bytes, tail}` (`tail` is the sha256 of
|
||||
up to 4096 bytes ending there), `reason` is null, `store-incomplete`,
|
||||
`store-unreadable`, `not-collected` or `harness-unsupported`, `known` is
|
||||
true exactly when `reason` is null, and `version` names the store format
|
||||
read, or null. Core refuses any other answer and writes nothing. pi reads
|
||||
its streams within the budget and the cap, and answers
|
||||
`harness-unsupported` on a first collection that finds Claude history.
|
||||
Claude Code answers `harness-unsupported` with version null (Vikunja 466).
|
||||
- Row 66: both modules wire the unslop check from `tools/unslop-hook`.
|
||||
`mounts` adds its three files read-only and `bundle` names them in the
|
||||
manifest's sources. Pi's `env` loads `extension.ts` as the second entry of
|
||||
|
||||
@@ -9,6 +9,11 @@ const VERBS = new Set([
|
||||
"thread.messages",
|
||||
"thread.send",
|
||||
"thread.open",
|
||||
"session.list",
|
||||
"session.create",
|
||||
"session.clear",
|
||||
"session.cancel",
|
||||
"session.operation",
|
||||
"message.receive",
|
||||
"message.read",
|
||||
]);
|
||||
|
||||
@@ -9,6 +9,11 @@ const VERBS = new Set([
|
||||
"thread.messages",
|
||||
"thread.send",
|
||||
"thread.open",
|
||||
"session.list",
|
||||
"session.create",
|
||||
"session.clear",
|
||||
"session.cancel",
|
||||
"session.operation",
|
||||
]);
|
||||
|
||||
export async function dispatch({ verb, args }, { call }) {
|
||||
|
||||
@@ -128,3 +128,6 @@ export function env({ files, policy, session }) {
|
||||
// A turn ends when claude exits; there is no marker.
|
||||
export const turnEnd = () => "exit";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects"));
|
||||
// Row 307 (#1814): Claude Code has no pinned compaction format yet (Vikunja 466), so a collection
|
||||
// reads nothing and the count is unknown with harness-unsupported, never 0.
|
||||
export const compactions = () => ({ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
|
||||
@@ -0,0 +1,166 @@
|
||||
// Row 307 (#1814, Vikunja 598): pi's half of row 270's compaction count (264c, #1775, T1 item 7
|
||||
// D6), moved here from packages/seat/src/compactions.mjs. Core keeps the ledger and calls
|
||||
// `compactions` through the harness kind (packages/plugins/README.md has the contract). This file
|
||||
// reads only the session's native pi store and the Claude history beside it.
|
||||
//
|
||||
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
|
||||
// written by SessionManager.appendCompaction after the summary exists. A start
|
||||
// or an aborted compaction writes none. A `compactionSummary` message, a
|
||||
// message inside a compaction's retainedTail and a branch_summary are not
|
||||
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
|
||||
// file of the same directory with their id and timestamp unchanged, so a
|
||||
// compaction's key is (entry id, entry timestamp) over the whole store, and the
|
||||
// stream it was first seen in is recorded beside it.
|
||||
//
|
||||
// A session whose first collection finds Claude history in claude-projects/
|
||||
// from before the ledger existed is unknown with harness-unsupported (row 270
|
||||
// r2, B3): those runs' compactions can't be counted (Vikunja 466).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const PI_VERSION = "0.85.1";
|
||||
const TAIL = 4096;
|
||||
const CHUNK = 1 << 20;
|
||||
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
|
||||
// store-incomplete outranks store-unreadable: lost entries don't come back.
|
||||
// not-collected is last: the next collection reads on.
|
||||
const REASONS = ["store-incomplete", "store-unreadable", "not-collected"];
|
||||
|
||||
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
|
||||
|
||||
// Only the short tail reads (at most TAIL bytes) use this.
|
||||
function readRange(fd, from, to) {
|
||||
const out = Buffer.alloc(to - from);
|
||||
let off = 0;
|
||||
while (off < out.length) {
|
||||
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
|
||||
if (n === 0) break;
|
||||
off += n;
|
||||
}
|
||||
return out.subarray(0, off);
|
||||
}
|
||||
|
||||
// One collection over a Pi session directory. `cursors` are the ledger's,
|
||||
// by stream. Opens at most `streams` streams, in name order, and reads at
|
||||
// most `budget` bytes past their cursors, over all of them.
|
||||
// Returns the compactions found past each cursor, the new cursors, and the
|
||||
// problems: store-unreadable (a file that can't be read or isn't a Pi
|
||||
// session), store-incomplete (a stream that shrank, changed or went away, or
|
||||
// a line that isn't JSON) and not-collected (the budget or the stream cap
|
||||
// ran out first). A
|
||||
// missing directory is empty.
|
||||
function scanPi(dir, cursors, budget, streams) {
|
||||
const found = [], next = {}, problems = new Set();
|
||||
let left = budget;
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") problems.add("store-unreadable");
|
||||
names = [];
|
||||
}
|
||||
const present = new Set(names);
|
||||
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
|
||||
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
|
||||
if (visit.length > streams) problems.add("not-collected");
|
||||
for (const name of visit.slice(0, streams)) {
|
||||
const path = join(dir, name);
|
||||
let fd;
|
||||
try {
|
||||
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
|
||||
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const size = fstatSync(fd).size;
|
||||
const c = cursors[name];
|
||||
let from = 0;
|
||||
if (c) {
|
||||
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
|
||||
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
|
||||
else problems.add("store-incomplete");
|
||||
}
|
||||
let header = from > 0;
|
||||
const take = (raw) => {
|
||||
const line = raw.toString("utf8");
|
||||
if (!line.trim()) return;
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
e = null;
|
||||
}
|
||||
if (!e || typeof e !== "object" || Array.isArray(e)) {
|
||||
problems.add("store-incomplete");
|
||||
return;
|
||||
}
|
||||
if (!header) {
|
||||
header = true;
|
||||
if (e.type !== "session") problems.add("store-unreadable");
|
||||
return;
|
||||
}
|
||||
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
|
||||
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
|
||||
}
|
||||
};
|
||||
// A CHUNK at a time, up to the budget left. Complete lines only: a line
|
||||
// split across chunks waits in `carry`, and a partial last line waits
|
||||
// for its newline. `end` is past the last whole line.
|
||||
const stop = Math.min(size, from + left);
|
||||
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
|
||||
let pos = from, end = from, carry = [];
|
||||
while (pos < stop) {
|
||||
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
|
||||
if (n === 0) break;
|
||||
const view = chunk.subarray(0, n);
|
||||
let i = 0;
|
||||
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
|
||||
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
|
||||
carry = [];
|
||||
i = nl + 1;
|
||||
end = pos + i;
|
||||
}
|
||||
if (i < n) carry.push(Buffer.from(view.subarray(i)));
|
||||
pos += n;
|
||||
}
|
||||
left -= pos - from;
|
||||
if (stop < size) problems.add("not-collected");
|
||||
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
return { found, cursors: next, problems: [...problems].sort() };
|
||||
}
|
||||
|
||||
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
|
||||
// directory that can't be read throws, so the collection is not recorded and
|
||||
// the next one checks again.
|
||||
function claudeHistory(store) {
|
||||
if (!store.projects) return false;
|
||||
try {
|
||||
return readdirSync(store.projects).length > 0;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// The harness kind's `compactions` for pi: the compactions past the ledger's
|
||||
// cursors, the moved cursors, and the reason the count isn't whole, if any.
|
||||
export function compactions({ store, cursors, budget, streams, first }) {
|
||||
if (first && claudeHistory(store)) {
|
||||
// Claude runs before this ledger: their compactions can't be counted
|
||||
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
|
||||
return { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: PI_VERSION };
|
||||
}
|
||||
const scan = scanPi(store.session, cursors, budget, streams);
|
||||
const reason = REASONS.find((p) => scan.problems.includes(p)) ?? null;
|
||||
return { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: PI_VERSION };
|
||||
}
|
||||
@@ -73,3 +73,5 @@ export function env({ policy, tools, session }) {
|
||||
// The extension writes the turn marker at agent_end; an exit without it is a failed turn.
|
||||
export const turnEnd = () => "marker";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session"));
|
||||
// Row 307 (#1814): the session store's compactions, read from its pi streams (compactions.mjs).
|
||||
export { compactions } from "./compactions.mjs";
|
||||
@@ -49,10 +49,12 @@ export const KINDS = Object.freeze({
|
||||
// env({ files, policy, tools, session }) -> its adapter variables, written into session.json
|
||||
// turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit"
|
||||
// transcript({ runDir, store }) -> the directory its transcripts land in, for usage
|
||||
// compactions({ store, cursors, budget, streams, first }) -> { found, cursors, known, reason, version },
|
||||
// the session store's compactions past the ledger's cursors (row 307)
|
||||
harness: Object.freeze({
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"]),
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"]),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ test("channel shipped modules load and preserve every supported operation and bu
|
||||
assert.deepEqual(loader.loaded().channel, ["cli", "webui"]);
|
||||
const modules = loader.modules("channel");
|
||||
for (const [index, verbs] of [
|
||||
[0, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "message.receive", "message.read"]],
|
||||
[1, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open"]],
|
||||
[0, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "session.list", "session.create", "session.clear", "session.cancel", "session.operation", "message.receive", "message.read"]],
|
||||
[1, ["project.list", "project.create", "thread.list", "thread.create", "thread.messages", "thread.send", "thread.open", "session.list", "session.create", "session.clear", "session.cancel", "session.operation"]],
|
||||
]) {
|
||||
for (const verb of verbs) {
|
||||
const seen = [];
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Row 307 (#1814, Vikunja 598): the harness kind's `compactions`. pi's answers what row 270's
|
||||
// scanPi read at 1e629c892, with its reason order and version; claude-code reads nothing and
|
||||
// answers harness-unsupported; a module without the export doesn't load.
|
||||
//
|
||||
// CASES: each case's streams and cursors, scanned at 1e629c892 with its budget and cap, give its
|
||||
// answer (agents/gizmoduck/work/queue-307/capture.mjs checks that).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { KINDS } from "../src/kinds.mjs";
|
||||
import * as pi from "../harness/pi/index.mjs";
|
||||
import * as claude from "../harness/claude-code/index.mjs";
|
||||
import { tree } from "./helpers.mjs";
|
||||
|
||||
// capture:begin
|
||||
const CASES = {
|
||||
"plain": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": true, "reason": null, "version": "0.85.1"}},
|
||||
"budget": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 100, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
|
||||
"cap": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n", "b.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
|
||||
"shrank": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "store-incomplete", "version": "0.85.1"}},
|
||||
"header": {"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 29, "tail": "91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df"}}, "known": false, "reason": "store-unreadable", "version": "0.85.1"}}
|
||||
};
|
||||
// capture:end
|
||||
|
||||
function stores(t, streams = {}, projects = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "mosaic-harness-compactions-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = { session: join(root, "session"), projects: join(root, "claude-projects") };
|
||||
for (const [dir, files] of [[store.session, streams], [store.projects, projects]]) {
|
||||
mkdirSync(dir);
|
||||
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
for (const [name, c] of Object.entries(CASES)) {
|
||||
test(`row 307 pi answers what row 270 read: ${name}`, (t) => {
|
||||
const store = stores(t, c.streams);
|
||||
assert.deepEqual(pi.compactions({ store, cursors: c.cursors, budget: c.budget, streams: c.cap, first: false }), c.answer);
|
||||
});
|
||||
}
|
||||
|
||||
test("row 307 pi's first collection over Claude history answers harness-unsupported without a scan, and a later one scans", (t) => {
|
||||
const { streams, budget, cap, answer } = CASES.plain;
|
||||
const store = stores(t, streams, { "p.jsonl": "{}\n" });
|
||||
const args = { store, cursors: {}, budget, streams: cap };
|
||||
assert.deepEqual(pi.compactions({ ...args, first: true }), { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: "0.85.1" });
|
||||
assert.deepEqual(pi.compactions({ ...args, first: false }), answer);
|
||||
// With no Claude history, the first collection scans too.
|
||||
assert.deepEqual(pi.compactions({ ...args, store: stores(t, streams), first: true }), answer);
|
||||
});
|
||||
|
||||
test("row 307 claude-code reads nothing and answers harness-unsupported with no version", () => {
|
||||
const store = { session: "/nonexistent/session", projects: "/nonexistent/claude-projects" };
|
||||
for (const first of [true, false]) {
|
||||
assert.deepEqual(claude.compactions({ store, cursors: {}, budget: 1 << 20, streams: 32, first }),
|
||||
{ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
|
||||
}
|
||||
});
|
||||
|
||||
test("row 307 a harness module without compactions doesn't load: missing-export", async (t) => {
|
||||
const s = tree(t);
|
||||
const url = pathToFileURL(resolve(fileURLToPath(new URL("../harness/pi/index.mjs", import.meta.url)))).href;
|
||||
const names = ["interfaceVersion", ...KINDS.harness.exports.filter((k) => k !== "compactions")];
|
||||
s.write("harness", "no-compactions", `export { ${names.join(", ")} } from ${JSON.stringify(url)};\n`);
|
||||
const l = await s.loader();
|
||||
const result = await l.load({ harness: ["no-compactions"] });
|
||||
assert.deepEqual(result.refused.map((e) => [e.kind, e.name, e.code]), [["harness", "no-compactions", "missing-export"]]);
|
||||
assert.match(result.refused[0].message, /^harness no-compactions doesn't export compactions$/);
|
||||
assert.deepEqual(l.loaded().harness, []);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1,
|
||||
assert.deepEqual({ ...KINDS.harness, exports: [...KINDS.harness.exports] }, {
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: ["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"],
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"],
|
||||
});
|
||||
assert.ok(Object.isFrozen(KINDS.harness) && Object.isFrozen(KINDS.harness.exports));
|
||||
assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports));
|
||||
|
||||
@@ -9,19 +9,14 @@
|
||||
// workspace lock (session.mjs lockSessionWorkspace); each collection is one
|
||||
// O_APPEND write. The count is rebuilt from the ledger, never from a page.
|
||||
//
|
||||
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
|
||||
// written by SessionManager.appendCompaction after the summary exists. A start
|
||||
// or an aborted compaction writes none. A `compactionSummary` message, a
|
||||
// message inside a compaction's retainedTail and a branch_summary are not
|
||||
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
|
||||
// file of the same directory with their id and timestamp unchanged, so a
|
||||
// compaction's key is (entry id, entry timestamp) over the whole store, and the
|
||||
// stream it was first seen in is recorded beside it.
|
||||
//
|
||||
// Claude Code has no pinned compaction format yet (Vikunja 466): a session
|
||||
// any Claude run collected for is unknown with harness-unsupported, never 0.
|
||||
// So is one whose first collection finds Claude history in claude-projects/
|
||||
// from before this ledger existed (row 270 r2, B3).
|
||||
// What a compaction is depends on the harness, so core never reads a native
|
||||
// stream itself (row 307, #1814, Vikunja 598). Each collection calls the route's
|
||||
// harness module's `compactions` (packages/plugins/README.md has the contract),
|
||||
// checks its answer, and writes the ledger lines from it. A compaction's key is
|
||||
// (entry, completedAt) over the whole store, and the stream it was first seen in
|
||||
// is recorded beside it. A harness with no pinned compaction format answers
|
||||
// harness-unsupported, and so does one that can't count the store's history: a
|
||||
// session any such collection ran for is unknown for good, never 0.
|
||||
//
|
||||
// One collection reads at most a budget of stream bytes (row 270 r2, B1).
|
||||
// The budget is the system config's compactionReadBytes, which only a human
|
||||
@@ -46,14 +41,11 @@
|
||||
// that shrank, changed under its cursor or went away is store-incomplete from
|
||||
// then on: entries may be lost, so the count stays a lower bound for good.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, writeSync } from "node:fs";
|
||||
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
export const PI_VERSION = "0.85.1";
|
||||
export const LEDGER = "compactions.jsonl";
|
||||
const TAIL = 4096;
|
||||
const CHUNK = 1 << 20;
|
||||
// BUILD.md (queue-270, round 2) has the measured stream sizes behind the default.
|
||||
export const READ_BYTES = 64 * CHUNK;
|
||||
@@ -61,9 +53,7 @@ const READ_MIN = CHUNK, READ_MAX = 1024 * CHUNK;
|
||||
// BUILD.md (queue-270, round 3) has the measured stream counts behind the default.
|
||||
export const STREAMS = 1024;
|
||||
const STREAMS_MIN = 32, STREAMS_MAX = 65536;
|
||||
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
|
||||
|
||||
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
|
||||
const keyOf = (entry, completedAt) => JSON.stringify([entry, completedAt]);
|
||||
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
|
||||
|
||||
@@ -87,115 +77,6 @@ export function readStreams(value) {
|
||||
return Number.isSafeInteger(value) && value >= STREAMS_MIN && value <= STREAMS_MAX ? value : STREAMS;
|
||||
}
|
||||
|
||||
// Only the short tail reads (at most TAIL bytes) use this.
|
||||
function readRange(fd, from, to) {
|
||||
const out = Buffer.alloc(to - from);
|
||||
let off = 0;
|
||||
while (off < out.length) {
|
||||
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
|
||||
if (n === 0) break;
|
||||
off += n;
|
||||
}
|
||||
return out.subarray(0, off);
|
||||
}
|
||||
|
||||
// One collection over a Pi session directory. `cursors` are the ledger's,
|
||||
// by stream. Opens at most `streams` streams, in name order, and reads at
|
||||
// most `budget` bytes past their cursors, over all of them.
|
||||
// Returns the compactions found past each cursor, the new cursors, and the
|
||||
// problems: store-unreadable (a file that can't be read or isn't a Pi
|
||||
// session), store-incomplete (a stream that shrank, changed or went away, or
|
||||
// a line that isn't JSON) and not-collected (the budget or the stream cap
|
||||
// ran out first). A
|
||||
// missing directory is empty.
|
||||
export function scanPi(dir, cursors = {}, budget = READ_BYTES, streams = STREAMS) {
|
||||
const found = [], next = {}, problems = new Set();
|
||||
let left = budget;
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") problems.add("store-unreadable");
|
||||
names = [];
|
||||
}
|
||||
const present = new Set(names);
|
||||
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
|
||||
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
|
||||
if (visit.length > streams) problems.add("not-collected");
|
||||
for (const name of visit.slice(0, streams)) {
|
||||
const path = join(dir, name);
|
||||
let fd;
|
||||
try {
|
||||
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
|
||||
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const size = fstatSync(fd).size;
|
||||
const c = cursors[name];
|
||||
let from = 0;
|
||||
if (c) {
|
||||
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
|
||||
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
|
||||
else problems.add("store-incomplete");
|
||||
}
|
||||
let header = from > 0;
|
||||
const take = (raw) => {
|
||||
const line = raw.toString("utf8");
|
||||
if (!line.trim()) return;
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
e = null;
|
||||
}
|
||||
if (!e || typeof e !== "object" || Array.isArray(e)) {
|
||||
problems.add("store-incomplete");
|
||||
return;
|
||||
}
|
||||
if (!header) {
|
||||
header = true;
|
||||
if (e.type !== "session") problems.add("store-unreadable");
|
||||
return;
|
||||
}
|
||||
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
|
||||
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
|
||||
}
|
||||
};
|
||||
// A CHUNK at a time, up to the budget left. Complete lines only: a line
|
||||
// split across chunks waits in `carry`, and a partial last line waits
|
||||
// for its newline. `end` is past the last whole line.
|
||||
const stop = Math.min(size, from + left);
|
||||
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
|
||||
let pos = from, end = from, carry = [];
|
||||
while (pos < stop) {
|
||||
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
|
||||
if (n === 0) break;
|
||||
const view = chunk.subarray(0, n);
|
||||
let i = 0;
|
||||
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
|
||||
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
|
||||
carry = [];
|
||||
i = nl + 1;
|
||||
end = pos + i;
|
||||
}
|
||||
if (i < n) carry.push(Buffer.from(view.subarray(i)));
|
||||
pos += n;
|
||||
}
|
||||
left -= pos - from;
|
||||
if (stop < size) problems.add("not-collected");
|
||||
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
return { found, cursors: next, problems: [...problems].sort() };
|
||||
}
|
||||
|
||||
// The ledger as recorded. Throws on a ledger that can't be read. `bad` is set
|
||||
// by any line that isn't a v 1 record, a torn last line included.
|
||||
export function readLedger(dir) {
|
||||
@@ -229,28 +110,16 @@ export function readLedger(dir) {
|
||||
else if (r.kind === "collected") {
|
||||
last = r;
|
||||
if (r.reason === "store-incomplete") incomplete = true;
|
||||
if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;
|
||||
if (r.reason === "harness-unsupported") unsupported = true;
|
||||
} else bad = true;
|
||||
}
|
||||
return { events, cursors, last, incomplete, unsupported, bad, torn };
|
||||
}
|
||||
|
||||
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
|
||||
// directory that can't be read throws, so the collection is not recorded and
|
||||
// the next one checks again.
|
||||
function claudeHistory(store) {
|
||||
if (!store.projects) return false;
|
||||
try {
|
||||
return readdirSync(store.projects).length > 0;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// What session.list shows: { count, known, reason, at }. Known only when the
|
||||
// ledger is whole, every collection ran on Pi and none lost entries. Any other
|
||||
// answer is unknown with a reason and a lower bound, or null for none: never 0.
|
||||
// ledger is whole, no collection was harness-unsupported and none lost
|
||||
// entries. Any other answer is unknown with a reason and a lower bound, or null
|
||||
// for none: never 0.
|
||||
export function compactionCount(dir) {
|
||||
const unknown = (reason, count = null, at = null) => ({ count: count || null, known: false, reason, at });
|
||||
let ledger;
|
||||
@@ -270,39 +139,57 @@ export function compactionCount(dir) {
|
||||
return { count, known: true, reason: null, at };
|
||||
}
|
||||
|
||||
// One collection for a session's store ({ dir, session } from sessionStore),
|
||||
// at run start (resume) and run end, under the session's workspace lock.
|
||||
// Appends the new compactions, the moved cursors and one collected line in a
|
||||
// single O_APPEND write, then answers the count. A key already in the ledger
|
||||
// is never written again, and nothing rewrites the file.
|
||||
const REASONS = ["store-incomplete", "store-unreadable", "not-collected", "harness-unsupported"];
|
||||
const HEX = /^[0-9a-f]{64}$/;
|
||||
|
||||
// The harness module's answer, checked before anything is written: what the
|
||||
// export's contract allows and nothing else. Returns what's wrong, or null.
|
||||
function checkAnswer(a) {
|
||||
const plain = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
||||
if (!plain(a)) return "not an object";
|
||||
if (!Array.isArray(a.found)) return "found isn't a list";
|
||||
for (const f of a.found) {
|
||||
if (!plain(f) || typeof f.entry !== "string" || !f.entry) return "a found entry has no entry id";
|
||||
if (f.completedAt !== null && typeof f.completedAt !== "string") return `found ${f.entry} has a completedAt that isn't a string or null`;
|
||||
if (typeof f.stream !== "string" || !f.stream) return `found ${f.entry} has no stream`;
|
||||
}
|
||||
if (!plain(a.cursors)) return "cursors isn't an object";
|
||||
for (const [stream, c] of Object.entries(a.cursors)) {
|
||||
if (!plain(c) || !Number.isSafeInteger(c.bytes) || c.bytes < 0 || typeof c.tail !== "string" || !HEX.test(c.tail)) return `cursor ${JSON.stringify(stream)} isn't { bytes, tail }`;
|
||||
}
|
||||
if (a.reason !== null && !REASONS.includes(a.reason)) return `reason ${JSON.stringify(a.reason)} isn't one of ${REASONS.join(", ")}`;
|
||||
if (a.known !== (a.reason === null)) return "known must be true exactly when reason is null";
|
||||
if (a.version !== null && typeof a.version !== "string") return "version isn't a string or null";
|
||||
return null;
|
||||
}
|
||||
|
||||
// One collection for a session's store ({ dir, session, projects } from
|
||||
// sessionStore), at run start (resume) and run end, under the session's
|
||||
// workspace lock. `harness` is { name, compactions }: the route's harness name
|
||||
// and its loaded module's export. Appends the new compactions, the moved
|
||||
// cursors and one collected line in a single O_APPEND write, then answers the
|
||||
// count. A key already in the ledger is never written again, and nothing
|
||||
// rewrites the file. A module that throws or answers out of contract throws
|
||||
// here, before anything is written.
|
||||
export function collectCompactions(store, harness, { now = () => new Date().toISOString(), budget = READ_BYTES, streams = STREAMS } = {}) {
|
||||
const prior = readLedger(store.dir);
|
||||
const ledger = prior ?? { events: new Map(), cursors: {}, torn: false };
|
||||
const at = now();
|
||||
const answer = harness.compactions({ store: { session: store.session, projects: store.projects }, cursors: { ...ledger.cursors }, budget, streams, first: prior === null });
|
||||
const wrong = checkAnswer(answer);
|
||||
if (wrong) throw new Error(`harness ${harness.name} compactions: bad answer: ${wrong}`);
|
||||
const out = [];
|
||||
if (harness === "pi" && !prior && claudeHistory(store)) {
|
||||
// Claude runs before this ledger: their compactions can't be counted
|
||||
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
|
||||
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: PI_VERSION });
|
||||
} else if (harness === "pi") {
|
||||
const scan = scanPi(store.session, ledger.cursors, budget, streams);
|
||||
for (const f of scan.found) {
|
||||
const key = keyOf(f.entry, f.completedAt);
|
||||
if (ledger.events.has(key)) continue;
|
||||
ledger.events.set(key, f);
|
||||
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
|
||||
}
|
||||
for (const [stream, c] of Object.entries(scan.cursors)) {
|
||||
const old = ledger.cursors[stream];
|
||||
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
|
||||
}
|
||||
// store-incomplete outranks store-unreadable: lost entries don't come back.
|
||||
// not-collected is last: the next collection reads on.
|
||||
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
|
||||
out.push({ v: 1, kind: "collected", at, known: reason === null, reason, harness, version: PI_VERSION });
|
||||
} else {
|
||||
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: null });
|
||||
for (const f of answer.found) {
|
||||
const key = keyOf(f.entry, f.completedAt);
|
||||
if (ledger.events.has(key)) continue;
|
||||
ledger.events.set(key, f);
|
||||
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
|
||||
}
|
||||
for (const [stream, c] of Object.entries(answer.cursors)) {
|
||||
const old = ledger.cursors[stream];
|
||||
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
|
||||
}
|
||||
out.push({ v: 1, kind: "collected", at, known: answer.known, reason: answer.reason, harness: harness.name, version: answer.version });
|
||||
const fd = openSync(join(store.dir, LEDGER), fsc.O_WRONLY | fsc.O_APPEND | fsc.O_CREAT | fsc.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
if (!fstatSync(fd).isFile()) throw Object.assign(new Error(`${join(store.dir, LEDGER)} isn't a regular file`), { code: "ledger-unsafe" });
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { collectCompactions, LEDGER, READ_BYTES, readBudget, readStreams, STREAMS } from "../src/compactions.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
||||
|
||||
const MiB = 1 << 20;
|
||||
|
||||
@@ -22,7 +23,7 @@ function setup(t) {
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
const collect = (harness = "pi", budget = undefined, streams = undefined) =>
|
||||
collectCompactions(store, harness, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
|
||||
collectCompactions(store, { name: harness, compactions: { pi }[harness] }, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
|
||||
const records = () => readFileSync(join(store.dir, LEDGER), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
return { root, store, collect, records };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Row 307 (#1814, Vikunja 598): core keeps the ledger and asks the harness module for the
|
||||
// compactions. A ledger row 270 wrote reads the same count after the move, the next collection
|
||||
// goes through the module's `compactions`, and core refuses an answer outside the contract before
|
||||
// it writes anything.
|
||||
//
|
||||
// FIXTURES are what row 270's collectCompactions wrote at 1e629c892, one ledger per reason it
|
||||
// writes plus a torn one: each fixture's streams, Claude projects and steps, replayed there with
|
||||
// the clock at 12:00:00 and one second a step, give its ledger byte for byte
|
||||
// (agents/gizmoduck/work/queue-307/capture.mjs checks that). `count` is compactionCount over it,
|
||||
// and `next` is the count after one more pi collection at the next second.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { collectCompactions, compactionCount, LEDGER } from "../src/compactions.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
import * as pi from "../../plugins/harness/pi/index.mjs";
|
||||
|
||||
// capture:begin
|
||||
const FIXTURES = {
|
||||
"known": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"store-incomplete": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\nnot json\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":99,\"tail\":\"14607eb2c2f70f460e1a420da1b4ead7f00f2a81cc4105e9e55e6c5c828d6626\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-incomplete\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"store-unreadable": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":29,\"tail\":\"91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-unreadable\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "store-unreadable", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 0, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"not-collected": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"steps": [{"collect": "pi", "budget": 100}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":90,\"tail\":\"2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"not-collected\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "not-collected", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"claude-code": {
|
||||
"streams": {},
|
||||
"steps": [{"collect": "claude-code"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"claude-code\",\"version\":null}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"claude-history": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"projects": {"p.jsonl": "{}\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 1, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"torn": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"steps": [{"collect": "pi"}, {"tear": "{\"v\":1,\"kind\":\"coll"}, {"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}",
|
||||
"{\"v\":1,\"kind\":\"coll",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:01.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"},
|
||||
"next": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:02.000Z"}
|
||||
}
|
||||
};
|
||||
// capture:end
|
||||
|
||||
function setup(t, fx = { streams: {}, ledger: null }) {
|
||||
const root = mkdtempSync(join(tmpdir(), "seat-compactions-ledger-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = sessionStore(root, "acme", "th1", "s1");
|
||||
for (const [name, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, name), text);
|
||||
for (const [name, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, name), text);
|
||||
if (fx.ledger) writeFileSync(join(store.dir, LEDGER), fx.ledger.join("\n") + "\n", { mode: 0o600 });
|
||||
const ledger = () => {
|
||||
try {
|
||||
return readFileSync(join(store.dir, LEDGER), "utf8");
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return { root, store, ledger };
|
||||
}
|
||||
|
||||
for (const [name, fx] of Object.entries(FIXTURES)) {
|
||||
test(`row 307 the ${name} ledger row 270 wrote reads the same count`, (t) => {
|
||||
const f = setup(t, fx);
|
||||
assert.deepEqual(compactionCount(f.store.dir), fx.count);
|
||||
});
|
||||
|
||||
test(`row 307 after the ${name} ledger, one collection through pi's module answers the next count`, (t) => {
|
||||
const f = setup(t, fx);
|
||||
assert.deepEqual(collectCompactions(f.store, { name: "pi", compactions: pi.compactions }, { now: () => fx.next.at }), fx.next);
|
||||
});
|
||||
}
|
||||
|
||||
const ok = { found: [], cursors: {}, known: true, reason: null, version: null };
|
||||
const HEX = "0".repeat(64);
|
||||
const BAD = [
|
||||
[null, "not an object"],
|
||||
[[], "not an object"],
|
||||
[{ ...ok, found: {} }, "found isn't a list"],
|
||||
[{ ...ok, found: [{ entry: "", completedAt: null, stream: "a.jsonl" }] }, "a found entry has no entry id"],
|
||||
[{ ...ok, found: ["c1"] }, "a found entry has no entry id"],
|
||||
[{ ...ok, found: [{ entry: "c1", completedAt: 5, stream: "a.jsonl" }] }, "found c1 has a completedAt that isn't a string or null"],
|
||||
[{ ...ok, found: [{ entry: "c1", completedAt: null, stream: "" }] }, "found c1 has no stream"],
|
||||
[{ ...ok, cursors: [] }, "cursors isn't an object"],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: -1, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: 1.5, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: HEX.toUpperCase().replace(/0/g, "A") } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: "0".repeat(63) } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, reason: "lost", known: false }, 'reason "lost" isn\'t one of store-incomplete, store-unreadable, not-collected, harness-unsupported'],
|
||||
[{ ...ok, reason: "not-collected" }, "known must be true exactly when reason is null"],
|
||||
[{ ...ok, known: false }, "known must be true exactly when reason is null"],
|
||||
[{ ...ok, known: 1 }, "known must be true exactly when reason is null"],
|
||||
[{ ...ok, version: 1 }, "version isn't a string or null"],
|
||||
];
|
||||
|
||||
test("row 307 core refuses an answer outside the contract and appends nothing", (t) => {
|
||||
for (const fx of [undefined, FIXTURES.known]) {
|
||||
const f = setup(t, fx);
|
||||
const before = f.ledger();
|
||||
for (const [answer, what] of BAD) {
|
||||
const message = `harness odd compactions: bad answer: ${what}`;
|
||||
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => answer }), { message }, JSON.stringify(answer));
|
||||
assert.equal(f.ledger(), before, JSON.stringify(answer));
|
||||
}
|
||||
// The module's own throw reaches the caller as it is, and nothing is written either.
|
||||
const boom = new Error("boom");
|
||||
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => { throw boom; } }), (e) => e === boom);
|
||||
assert.equal(f.ledger(), before);
|
||||
// A good answer is written: the check refuses only what's out of contract.
|
||||
assert.equal(collectCompactions(f.store, { name: "odd", compactions: () => ok }, { now: () => "2026-10-22T12:00:09.000Z" }).at, "2026-10-22T12:00:09.000Z");
|
||||
}
|
||||
});
|
||||
|
||||
test("row 307 the module gets the store's two native directories, a copy of the cursors, the budget, the cap and first", (t) => {
|
||||
const seen = [];
|
||||
// The module writes into the cursors it got and answers the same cursor: core's ledger copy
|
||||
// doesn't have it, so core writes it.
|
||||
const harness = { name: "odd", compactions: (args) => (seen.push(structuredClone(args)), args.cursors.x = { bytes: 0, tail: HEX }, { ...ok, cursors: { x: { bytes: 0, tail: HEX } } }) };
|
||||
const fresh = setup(t);
|
||||
collectCompactions(fresh.store, harness, { budget: 123, streams: 45 });
|
||||
const known = setup(t, FIXTURES.known);
|
||||
collectCompactions(known.store, harness);
|
||||
const store = (f) => ({ session: f.store.session, projects: f.store.projects });
|
||||
assert.deepEqual(seen, [
|
||||
{ store: store(fresh), cursors: {}, budget: 123, streams: 45, first: true },
|
||||
{ store: store(known), cursors: { "a.jsonl": { bytes: 233, tail: "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a" } }, budget: 64 << 20, streams: 1024, first: false },
|
||||
]);
|
||||
assert.equal(readFileSync(join(known.store.dir, LEDGER), "utf8").split("\n").filter((l) => l.includes('"stream":"x"')).length, 1);
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { join, relative, isAbsolute } from "node:path";
|
||||
import { collectCompactions, compactionCount, LEDGER, readLedger, storeDir } from "../src/compactions.mjs";
|
||||
import { binds } from "../src/sandbox.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
||||
import { compactions as claude } from "../../plugins/harness/claude-code/index.mjs";
|
||||
|
||||
function setup(t, { legacy = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "seat-compactions-"));
|
||||
@@ -18,7 +20,7 @@ function setup(t, { legacy = false } = {}) {
|
||||
const store = sessionStore(root, "acme", "th1", legacy ? "th1" : "s1", { legacy });
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
const collect = (harness = "pi") => collectCompactions(store, harness, { now });
|
||||
const collect = (harness = "pi") => collectCompactions(store, { name: harness, compactions: { pi, claude }[harness] }, { now });
|
||||
const ledger = () => readFileSync(join(store.dir, LEDGER), "utf8");
|
||||
const records = () => ledger().trim().split("\n").map((l) => JSON.parse(l));
|
||||
return { root, store, collect, ledger, records };
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
@@ -72,6 +73,63 @@ export function busCaller({ dataRoot, socket, business = null, env = process.env
|
||||
}
|
||||
export const busReader = options => views({ call: busCaller(options) });
|
||||
|
||||
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Same
|
||||
// protocol as humanCall: the request on stdin, JSON on stdout, or a bus error code as the last
|
||||
// `^[a-z-]{1,64}$` line of stderr. Row 301 (#1808) moved it here from user-bus.mjs.
|
||||
export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url));
|
||||
|
||||
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
|
||||
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
|
||||
// can never pass as human-cli, and it only ever runs USER_CLI.
|
||||
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
|
||||
out.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
|
||||
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
|
||||
child.on('close', status => {
|
||||
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
|
||||
if (status !== 0) {
|
||||
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
|
||||
return done(reject, new BusReadError(code));
|
||||
}
|
||||
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
|
||||
export function busBusiness({ dataRoot, business = null }) {
|
||||
return () => {
|
||||
if (business) return business;
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
return state.business;
|
||||
};
|
||||
}
|
||||
|
||||
// One granted user's caller, for that user's session only.
|
||||
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
|
||||
const which = busBusiness({ dataRoot, business });
|
||||
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
|
||||
}
|
||||
|
||||
// A send's error, sorted. The broker writes in one transaction, so any code it
|
||||
// returns means nothing was written. A transport that lost the answer, or an
|
||||
// answer that can't be read, leaves the outcome open: the message may exist.
|
||||
|
||||
@@ -16,11 +16,16 @@ const STATUS = {
|
||||
'project-exists': 409, 'thread-exists': 409, 'human-required': 403, unauthenticated: 403,
|
||||
'unknown-business': 403, 'read-only': 403, 'not-configured': 503, 'no-bus-host': 503,
|
||||
'thread-instance-missing': 503, 'thread-instance-ambiguous': 503, 'outcome-unknown': 503,
|
||||
'session-not-found': 404, 'operation-not-found': 404, 'session-exists': 409,
|
||||
'session-op-conflict': 409, 'session-clear-complete': 409, 'session-name-too-long': 409, 'session-clearing': 409, 'session-revision-stale': 409,
|
||||
'session-retired': 409, 'processing-halted': 409, 'confirm-required': 400,
|
||||
'unknown-verb': 503, 'channel-operation': 503,
|
||||
'tester-off': 403, 'tester-required': 403, 'tester-refused': 403,
|
||||
// FED R6 (#1800): the broker's refusals of a granted user.
|
||||
'access-refused': 403, 'access-revoked': 401,
|
||||
};
|
||||
function safeMessage(code) {
|
||||
if (code === 'unknown-verb' || code === 'channel-operation') return 'This session operation is unavailable until its dependency is installed.';
|
||||
if (code === 'channel-disabled') return 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].';
|
||||
if (code === 'thread-not-owned') return 'This thread is not available to this user.';
|
||||
if (code === 'outcome-unknown') return 'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried.';
|
||||
@@ -67,11 +72,12 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
try {
|
||||
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open)$/.exec(path);
|
||||
const match = /^\/api\/chat\/threads\/([^/]+)\/(messages|open|sessions|clear|cancel|operation)$/.exec(path);
|
||||
if (match) {
|
||||
thread = match[1];
|
||||
if (!id(thread)) throw Error();
|
||||
}
|
||||
if (write && preview?.tester && ['sessions', 'clear', 'cancel'].includes(match?.[2])) return refuse(403, 'read-only');
|
||||
if (write) {
|
||||
query(search, []);
|
||||
const v = await payload(req);
|
||||
@@ -83,12 +89,22 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) ||
|
||||
(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error();
|
||||
action = 'createThread'; args = v;
|
||||
} else if (match?.[2] === 'sessions') {
|
||||
if (!exact(v, ['name', 'op']) || !name(v.name) || !KEY.test(v.op ?? '')) throw Error();
|
||||
action = 'createSession'; args = { thread, name: v.name, op: v.op };
|
||||
} else if (match?.[2] === 'cancel') {
|
||||
if (!exact(v, ['op', 'confirm']) || !KEY.test(v.op ?? '') || v.confirm !== true) throw Error();
|
||||
action = 'cancelSession'; args = { thread, op: v.op, confirm: true };
|
||||
} else if (match?.[2] === 'clear') {
|
||||
if (!exact(v, ['session', 'revision', 'confirm', 'op']) || !id(v.session) ||
|
||||
!Number.isSafeInteger(v.revision) || v.revision < 0 || v.confirm !== true || !KEY.test(v.op ?? '')) throw Error();
|
||||
action = 'clearSession'; args = { thread, ...v };
|
||||
} else if (match?.[2] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
action = 'send'; args = { thread, body: v.body };
|
||||
if (!exact(v, ['body', 'session'], ['body']) || (Object.hasOwn(v, 'session') && !id(v.session)) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
action = 'send'; args = { thread, body: v.body, ...(v.session ? { session: v.session } : {}) };
|
||||
} else if (match?.[2] === 'open') {
|
||||
if (!exact(v, [])) throw Error();
|
||||
action = 'open'; args = { thread };
|
||||
if (!exact(v, ['session'], []) || (Object.hasOwn(v, 'session') && !id(v.session))) throw Error();
|
||||
action = 'open'; args = { thread, ...(v.session ? { session: v.session } : {}) };
|
||||
} else return refuse(404, 'not-found');
|
||||
} else if (path === '/api/chat/projects') {
|
||||
query(search, []); action = 'projects'; args = {};
|
||||
@@ -96,11 +112,19 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
const q = query(search, ['project']);
|
||||
if (q.has('project') && !id(q.get('project'))) throw Error();
|
||||
action = 'threads'; args = q.has('project') ? { project: q.get('project') } : {};
|
||||
} else if (match?.[2] === 'sessions') {
|
||||
query(search, []); action = 'sessions'; args = { thread };
|
||||
} else if (match?.[2] === 'operation') {
|
||||
const q = query(search, ['op']);
|
||||
if (!q.has('op') || !KEY.test(q.get('op'))) throw Error();
|
||||
action = 'sessionOperation'; args = { thread, op: q.get('op') };
|
||||
} else if (match?.[2] === 'messages') {
|
||||
const q = query(search, ['after', 'limit']);
|
||||
const q = query(search, ['after', 'limit', 'session']);
|
||||
if (q.has('session') && !id(q.get('session'))) throw Error();
|
||||
if (q.has('after') && !id(q.get('after'))) throw Error();
|
||||
if (q.has('limit') && (!/^[1-9]\d*$/.test(q.get('limit')) || Number(q.get('limit')) > 200)) throw Error();
|
||||
action = 'messages'; args = { thread, limit: q.has('limit') ? Number(q.get('limit')) : 100 };
|
||||
if (q.has('session')) args.session = q.get('session');
|
||||
if (q.has('after')) args.after = q.get('after');
|
||||
} else return refuse(404, 'not-found');
|
||||
} catch { return refuse(400, 'invalid-request'); }
|
||||
@@ -108,6 +132,22 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
const line = outcome => log(`${new Date().toISOString()} chat action=${action} user=${JSON.stringify(preview?.user ?? null)} ${outcome}`);
|
||||
try {
|
||||
const value = await chat[action](args);
|
||||
if (action === 'sessionOperation' || action === 'clearSession' || action === 'cancelSession') {
|
||||
// Only this operation's receipt crosses the HTTP seam. Never expose native paths/proofs.
|
||||
if (!value || value.thread !== args.thread || value.op !== args.op || !id(value.session) ||
|
||||
!id(value.successor) || (action === 'clearSession' && value.session !== args.session) ||
|
||||
!['clearing', 'uncertain', 'retired', 'failed', 'cancelled'].includes(value.state) ||
|
||||
![null, 'held', 'started', 'refused'].includes(value.start) ||
|
||||
(value.state === 'cancelled' && value.retired !== null && value.retired !== true) ||
|
||||
(action === 'cancelSession' && value.state !== 'cancelled') ||
|
||||
!(value.refusal === null || typeof value.refusal === 'string' && /^[a-z-]{1,64}$/.test(value.refusal))) {
|
||||
if (write) line('error=outcome-unknown');
|
||||
return refuse(write ? 503 : 502, write ? 'outcome-unknown' : 'invalid-response', write ? 'unknown' : false);
|
||||
}
|
||||
if (write) line(`op=${args.op}`);
|
||||
const { op, thread, session, successor, state, start, refusal, at } = value;
|
||||
return json(res, 200, { op, thread, session, successor, state, start, refusal, at, ...(state === 'cancelled' ? { retired: value.retired } : {}) });
|
||||
}
|
||||
if (!write) {
|
||||
if (!Array.isArray(value)) return refuse(502, 'invalid-response');
|
||||
const page = action === 'messages' ? {
|
||||
|
||||
@@ -26,8 +26,13 @@ export function createChat({ call, owner = null }) {
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),
|
||||
messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }),
|
||||
send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }),
|
||||
open: ({ thread }) => channel('thread.open', { thread }),
|
||||
sessions: ({ thread }) => channel('session.list', { thread }),
|
||||
createSession: ({ thread, name, op }) => channel('session.create', { thread, name, op }),
|
||||
clearSession: ({ thread, session, revision, confirm, op }) => channel('session.clear', { thread, session, revision, confirm, op }),
|
||||
cancelSession: ({ thread, op, confirm }) => channel('session.cancel', { thread, op, confirm }),
|
||||
sessionOperation: ({ thread, op }) => channel('session.operation', { thread, op }),
|
||||
messages: ({ thread, session, after, limit }) => channel('thread.messages', { thread, ...(session ? { session } : {}), ...(after ? { after } : {}), limit }),
|
||||
send: ({ thread, session, body }) => channel('thread.send', { thread, ...(session ? { session } : {}), body, class: 'REQUEST' }),
|
||||
open: ({ thread, session }) => channel('thread.open', { thread, ...(session ? { session } : {}) }),
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busCaller } from './bus.mjs';
|
||||
import { busBusiness, userCaller } from './user-bus.mjs';
|
||||
import { busCaller, busBusiness, userCaller } from './bus.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FED R6 (#1800): the Console's bus-backed options for startServer, built over one transport
|
||||
// `call`. cli.mjs builds them once over the human transport. A granted user's session gets the same
|
||||
// set over its own user transport (user-bus.mjs userCaller), so no route a user reaches holds a
|
||||
// set over its own user transport (bus.mjs userCaller), so no route a user reaches holds a
|
||||
// human-transport object, and the broker decides every verb the user sends (design 3.1 D4).
|
||||
import { createChat } from './chat.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
|
||||
@@ -17,3 +17,9 @@
|
||||
.chat-paging { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
#chat-receipt { min-height: 1.5em; overflow-wrap: anywhere; }
|
||||
@media (max-width: 40rem) { .chat-log { max-height: 48vh; padding: .5rem; } }
|
||||
|
||||
/* Logical sessions stay inside the selected thread, separate from machine seats. */
|
||||
.chat-session-controls { border: 1px solid var(--border, #445); border-radius: .5rem; padding: .8rem; margin: .8rem 0; }
|
||||
.chat-session-controls label { display: block; }
|
||||
.chat-session-controls select, .chat-session-controls input { max-width: 100%; }
|
||||
.chat-session-controls form { margin: .6rem 0; }
|
||||
@@ -46,13 +46,15 @@
|
||||
try { data = await res.json(); }
|
||||
catch { throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown'); }
|
||||
if (!res.ok) throw new ChatError(typeof data?.error === 'string' ? data.error : 'invalid-response', res.status, data?.sent);
|
||||
if (!data || typeof data !== 'object' || (value === undefined && !Array.isArray(data.rows))) {
|
||||
if (!data || typeof data !== 'object' || (value === undefined && !path.includes('/operation?') && !Array.isArray(data.rows))) {
|
||||
throw new ChatError(value === undefined ? 'invalid-response' : 'outcome-unknown');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const link = (type, id, label) => UUID.test(id) ? `<a href="#/chat/${type}/${id}">${txt(label)}</a>` : txt(label);
|
||||
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
|
||||
const disabledSession = pending => disabled(pending || selectedInactive());
|
||||
const disabled = pending => !current.canSend || out() || pending ? ' disabled' : '';
|
||||
const stamp = at => `<time>${txt(at)}</time>`;
|
||||
function creation(kind, key) {
|
||||
@@ -67,11 +69,22 @@
|
||||
function threads(rows) {
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}</span></li>`).join('')}</ul>` : '<p>No threads yet.</p>';
|
||||
}
|
||||
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
|
||||
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
|
||||
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
||||
`unknown (${value.reason ?? 'not-collected'})`;
|
||||
function sessionControls(t) {
|
||||
if (current.legacyDefault) return '<p role="status">Sessions are not available for this account.</p>';
|
||||
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
|
||||
const clear = form('clear:' + c.key);
|
||||
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
||||
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
|
||||
}
|
||||
function conversation(t) {
|
||||
const s = state(t.id);
|
||||
const s = state(current.key);
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
|
||||
const receipt = s.receipt;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabled(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabled(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabled(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
}
|
||||
function capture() {
|
||||
if (!current) return null;
|
||||
@@ -86,7 +99,7 @@
|
||||
if (c.error) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
|
||||
if (c.kind === 'thread') {
|
||||
const t = c.threads.find(t => t.id === c.id);
|
||||
if (t && !c.refused) html += conversation(t);
|
||||
if (t && (c.session || c.legacyDefault) && !c.refused) html += conversation(t);
|
||||
else html += heading('Thread unavailable');
|
||||
} else if (c.kind === 'project') {
|
||||
const p = c.projects.find(p => p.id === c.id);
|
||||
@@ -101,7 +114,7 @@
|
||||
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||
const log = c.container.querySelector('.chat-log');
|
||||
if (log) {
|
||||
const s = state(c.id);
|
||||
const s = state(c.key ?? c.id);
|
||||
if (navigated || s.tailRequested || s.follow && (keep?.tail ?? true)) log.scrollTop = log.scrollHeight;
|
||||
else if (keep?.top !== undefined) log.scrollTop = keep.top;
|
||||
s.tailRequested = false;
|
||||
@@ -116,32 +129,70 @@
|
||||
}
|
||||
function readError(err) {
|
||||
return err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
|
||||
err.code === 'thread-not-found' ? 'Thread not found.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
|
||||
err.code === 'thread-not-found' ? 'Thread not found.' :
|
||||
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
|
||||
}
|
||||
async function render({ path, container, canSend, navigated = false, next = false }) {
|
||||
if (out()) return;
|
||||
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+))?$/.exec(path);
|
||||
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
|
||||
controller?.abort(); controller = new AbortController();
|
||||
const signal = controller.signal, g = ++generation;
|
||||
const kind = match?.[1] === 'projects' ? 'project' : match?.[1] === 'threads' ? 'thread' : 'list';
|
||||
const id = match?.[2];
|
||||
if (!match || (id && !UUID.test(id))) {
|
||||
if (!match || (id && !UUID.test(id)) || match?.[3] && (kind !== 'thread' || !UUID.test(match[3]))) {
|
||||
current = { container, kind: 'list', canSend, projects: [], threads: [], repos: [], error: 'Invalid chat route.', refused: true };
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false };
|
||||
current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false, legacyDefault: false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
const after = s?.loaded && (!s.more || next) ? s.after : null;
|
||||
const [projects, listed, page] = await Promise.all([
|
||||
// Resolve the explicit session before touching history/cursors. Missing selectors never
|
||||
// silently fall back to a sibling. The saved default is used only for thread-only routes.
|
||||
const [projects, listed, catalog] = await Promise.all([
|
||||
request('projects', undefined, signal),
|
||||
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
|
||||
s ? request(`threads/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal) : null,
|
||||
kind === 'thread' ? request(`threads/${id}/sessions`, undefined, signal).catch(err => {
|
||||
// A denied optional catalog does not revoke an existing default-thread grant.
|
||||
// Explicit selectors and every other failure keep their refusal/error state.
|
||||
if (err.code === 'access-refused' && !match[3]) return null;
|
||||
throw err;
|
||||
}) : null,
|
||||
]);
|
||||
if (g !== generation || out()) return;
|
||||
current.projects = projects.rows; current.threads = listed.rows;
|
||||
let s = null, page = null, after = null;
|
||||
if (kind === 'thread' && catalog === null) {
|
||||
current.legacyDefault = true; current.sessions = []; current.session = null;
|
||||
current.key = id + '/legacy-default';
|
||||
s = state(current.key);
|
||||
after = s.loaded && (!s.more || next) ? s.after : null;
|
||||
page = await request(`threads/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal);
|
||||
if (g !== generation || out()) return;
|
||||
}
|
||||
if (catalog) {
|
||||
if (catalog.rows.some(r => !UUID.test(r.id) || typeof r.name !== 'string')) throw new ChatError('invalid-response');
|
||||
const selected = match[3] ? catalog.rows.find(r => r.id === match[3]) : catalog.rows.find(r => r.default === true);
|
||||
if (!selected) throw new ChatError('session-not-found', 404);
|
||||
current.sessions = catalog.rows; current.session = selected.id; current.key = id + '/' + selected.id;
|
||||
s = state(current.key);
|
||||
after = s.loaded && (!s.more || next) ? s.after : null;
|
||||
page = await request(`threads/${id}/messages?limit=100&session=${selected.id}${after ? '&after=' + after : ''}`, undefined, signal);
|
||||
if (g !== generation || out()) return;
|
||||
const clear = form('clear:' + current.key);
|
||||
if (clear.op && !clear.pending) {
|
||||
try {
|
||||
const op = clear.op;
|
||||
const receipt = await request(`threads/${id}/operation?op=${encodeURIComponent(op)}`, undefined, signal);
|
||||
if (g !== generation || out()) return;
|
||||
if (clear.op !== op) return;
|
||||
clear.record = receipt; clear.receipt = clearText(receipt);
|
||||
} catch (err) {
|
||||
if (g !== generation || out() || err.name === 'AbortError') return;
|
||||
clear.receipt = `Status unavailable (${inert(err.code)}). Operation ${clear.op}; no write was retried.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (s) {
|
||||
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after))) throw new ChatError('invalid-response');
|
||||
const rows = after ? [...s.rows, ...page.rows] : page.rows;
|
||||
@@ -186,8 +237,14 @@
|
||||
} catch (err) {
|
||||
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
|
||||
current.error = readError(err);
|
||||
// A bad session selector is local to that read. Only a thread-level refusal
|
||||
// invalidates all sibling drafts, receipts and recovery operation keys.
|
||||
if (['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) {
|
||||
for (const key of states.keys()) if (key === id || key.startsWith(id + '/')) states.delete(key);
|
||||
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
|
||||
}
|
||||
if (err.status === 403 || err.status === 404) {
|
||||
states.delete(id); current.projects = []; current.threads = []; current.refused = true;
|
||||
current.projects = []; current.threads = []; current.refused = true;
|
||||
}
|
||||
paint(navigated);
|
||||
}
|
||||
@@ -200,7 +257,8 @@
|
||||
}
|
||||
document.addEventListener('input', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
if (event.target.id === 'chat-operation') { const f = form('clear:' + current.key); if (f.pending) return; f.op = event.target.value; f.record = null; const button = document.getElementById('chat-cancel'); if (button) button.disabled = !current.canSend || f.pending || !f.op; }
|
||||
if (event.target.id === 'chat-body') state(current.key ?? current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
if (f && event.target.dataset.chatField) {
|
||||
const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
@@ -211,14 +269,15 @@
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
const el = event.target;
|
||||
state(current.id).follow = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
state(current.key ?? current.id).follow = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}
|
||||
}, true);
|
||||
async function write(action, key, value, path) {
|
||||
if (!current || !current.canSend || out()) return;
|
||||
const isThread = action === 'send' || action === 'open';
|
||||
const targetThread = current.id;
|
||||
const s = isThread ? state(key) : form(key);
|
||||
if (s.pending) return;
|
||||
if (s.pending || isThread && selectedInactive()) return;
|
||||
if (action === 'send' && (!value.body.trim() || new TextEncoder().encode(JSON.stringify(value)).length > 16384)) {
|
||||
s.receipt = value.body.trim() ? 'Message is too long (16 KiB JSON maximum).' : 'Write a message first.';
|
||||
paint(); return;
|
||||
@@ -228,35 +287,46 @@
|
||||
try {
|
||||
const result = await request(path, value);
|
||||
if (out()) return;
|
||||
if (action === 'open' ? result.opened !== key : !UUID.test(result.id)) throw new ChatError('outcome-unknown');
|
||||
if (action === 'open' ? result.opened !== targetThread : !UUID.test(result.id)) throw new ChatError('outcome-unknown');
|
||||
if (action === 'send') {
|
||||
s.receipt = `Accepted message ${result.id}. Waiting for the conversation to show it.`;
|
||||
if (s.draft === value.body) s.draft = '';
|
||||
s.follow = true; s.tailRequested = true; s.receiptId = result.id;
|
||||
} else if (action === 'open') s.receipt = 'Assistant resume requested.';
|
||||
else {
|
||||
s.name = ''; s.repo = '';
|
||||
if (location.hash === startedRoute) location.hash = '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id;
|
||||
s.name = ''; s.repo = ''; s.op = null; s.payload = null;
|
||||
if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id;
|
||||
}
|
||||
} catch (err) {
|
||||
if (out() || err.code === 'signed-out') return;
|
||||
s.receipt = err.sent === false ? `Not sent (${inert(err.code)}). Your draft is kept.` :
|
||||
'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried; your draft is kept.';
|
||||
if (err.status === 403 && err.code === 'thread-not-owned') {
|
||||
if (action === 'createSession' && err.sent === false) { s.op = null; s.payload = null; }
|
||||
if (err.status === 403 && ['thread-not-owned', 'access-refused'].includes(err.code)) {
|
||||
states.delete(key);
|
||||
if (current?.id === key) { current.threads = []; current.refused = true; current.error = readError(err); }
|
||||
if ((current?.key ?? current?.id) === key) { current.threads = []; current.refused = true; current.error = readError(err); }
|
||||
}
|
||||
} finally {
|
||||
s.pending = false;
|
||||
if (!out() && current && (isThread ? current.id === key : (key === 'projects' ? current.kind === 'list' : current.id === key))) paint();
|
||||
if (!out() && current && (isThread ? (current.key ?? current.id) === key : (action === 'createSession' ? current.id === targetThread : key === 'projects' ? current.kind === 'list' : current.id === key))) paint();
|
||||
}
|
||||
}
|
||||
document.addEventListener('submit', event => {
|
||||
if (!current || !event.target.id.startsWith('chat-')) return;
|
||||
event.preventDefault();
|
||||
if (event.target.id === 'chat-send-form') {
|
||||
const id = current.id;
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
const id = current.id, session = current.session, key = current.key;
|
||||
void write('send', key, { body: state(key).draft, ...(session ? { session } : {}) }, `threads/${id}/messages`);
|
||||
} else if (event.target.id === 'chat-session-form') {
|
||||
const key = 'session:' + current.id, f = form(key);
|
||||
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
|
||||
f.op ??= crypto.randomUUID();
|
||||
f.payload ??= { name: f.name, op: f.op };
|
||||
void write('createSession', key, f.payload, `threads/${current.id}/sessions`);
|
||||
} else if (event.target.id === 'chat-operation-form') {
|
||||
const clear = form('clear:' + current.key);
|
||||
clear.op = document.getElementById('chat-operation').value;
|
||||
void checkClear(current.id, current.key, clear);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
|
||||
@@ -266,10 +336,67 @@
|
||||
});
|
||||
document.addEventListener('click', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-resume') void write('open', current.id, {}, `threads/${current.id}/open`);
|
||||
if (event.target.id === 'chat-resume') void write('open', current.key, current.session ? { session: current.session } : {}, `threads/${current.id}/open`);
|
||||
if (event.target.id === 'chat-cancel') void cancelClear();
|
||||
if (event.target.id === 'chat-clear') void clearContext();
|
||||
if (event.target.id === 'chat-next') void refresh(true);
|
||||
if (event.target.id === 'chat-new') { state(current.id).tailRequested = true; state(current.id).follow = true; paint(); }
|
||||
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) {
|
||||
location.hash = sessionPath(current.id, event.target.value);
|
||||
}
|
||||
});
|
||||
const clearText = r => r.state === 'cancelled' ? `Operation ${r.op}: ${r.retired === true ? 'startup cancelled; old session remains retired' : 'replacement cancelled; old session active'}. Cancelled inputs are not replayed.` : `Operation ${r.op}: ${r.state}; successor ${r.successor}; start ${r.start ?? 'pending'}${r.refusal ? '; ' + r.refusal : ''}. No stop is forced.`;
|
||||
async function checkClear(thread, key, f) {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) { f.receipt = 'Enter a valid operation key.'; paint(); return; }
|
||||
const op = f.op;
|
||||
try {
|
||||
const r = await request(`threads/${thread}/operation?op=${encodeURIComponent(op)}`);
|
||||
if (out() || f.op !== op) return;
|
||||
f.record = r; f.receipt = clearText(r);
|
||||
if (current?.key === key) await refresh();
|
||||
} catch (e) { if (!out()) f.receipt = `Status unavailable (${inert(e.code)}). Operation ${f.op}; no write was retried.`; }
|
||||
if (current?.key === key) paint();
|
||||
}
|
||||
async function cancelClear() {
|
||||
const c = current, key = c.key, f = form('clear:' + key);
|
||||
if (!c.canSend || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return;
|
||||
if (!window.confirm(`Cancel clear operation ${f.op}? Before retirement this restores the old session to active. After retirement it only cancels successor startup. Cancelled messages are never replayed, and nothing is un-retired.`)) return;
|
||||
const op = f.op;
|
||||
f.pending = true; paint();
|
||||
try {
|
||||
const r = await request(`threads/${c.id}/cancel`, { op, confirm: true });
|
||||
if (out()) return;
|
||||
f.record = r; f.receipt = clearText(r);
|
||||
} catch (e) {
|
||||
if (out()) return;
|
||||
f.receipt = e.sent === false ? `Cancel refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${op}; cancel was not retried.`;
|
||||
} finally {
|
||||
f.pending = false;
|
||||
if (!out() && current?.key === key) { await refresh(); paint(); }
|
||||
}
|
||||
}
|
||||
async function clearContext() {
|
||||
const c = current, key = c.key, selected = c.sessions.find(r => r.id === c.session), f = form('clear:' + key);
|
||||
if (!c.canSend || out() || f.pending || selectedInactive() || !Number.isSafeInteger(selected?.revision)) return;
|
||||
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
|
||||
// A lost answer retains this key. Only status reads are allowed afterward.
|
||||
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
|
||||
f.op = crypto.randomUUID(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
|
||||
try {
|
||||
const r = await request(`threads/${c.id}/clear`, { session: selected.id, revision: selected.revision, confirm: true, op: f.op });
|
||||
if (out()) return;
|
||||
f.record = r; f.receipt = clearText(r);
|
||||
} catch (e) {
|
||||
if (out()) return;
|
||||
f.receipt = e.sent === false ? `Clear refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${f.op}; nothing was retried.`;
|
||||
if (e.sent === false) f.op = null;
|
||||
} finally {
|
||||
f.pending = false;
|
||||
if (!out() && current?.key === key) { await refresh(); paint(); }
|
||||
}
|
||||
}
|
||||
document.addEventListener('mosaic-signed-out', () => { signedOut = true; forget(); });
|
||||
window.MosaicChat = Object.freeze({ render, leave, forget });
|
||||
})();
|
||||
@@ -1,63 +0,0 @@
|
||||
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Kept apart
|
||||
// from bus.mjs so the human transport's reviewed bytes stay as row 164 froze them
|
||||
// (bus/tests/channel-proof.test.mjs). Same protocol as humanCall: the request on stdin, JSON on
|
||||
// stdout, or a bus error code as the last `^[a-z-]{1,64}$` line of stderr.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { BusReadError } from './bus.mjs';
|
||||
|
||||
export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url));
|
||||
|
||||
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
|
||||
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
|
||||
// can never pass as human-cli, and it only ever runs USER_CLI.
|
||||
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
|
||||
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
|
||||
out.push(chunk);
|
||||
});
|
||||
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
|
||||
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
|
||||
child.on('close', status => {
|
||||
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
|
||||
if (status !== 0) {
|
||||
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
|
||||
return done(reject, new BusReadError(code));
|
||||
}
|
||||
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
|
||||
export function busBusiness({ dataRoot, business = null }) {
|
||||
return () => {
|
||||
if (business) return business;
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
return state.business;
|
||||
};
|
||||
}
|
||||
|
||||
// One granted user's caller, for that user's session only.
|
||||
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
|
||||
const which = busBusiness({ dataRoot, business });
|
||||
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const stub = `(() => {
|
||||
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
|
||||
window.__instances=[{instance:'assistant',name:'assistant'}];
|
||||
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
|
||||
window.__sessions={};
|
||||
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
const path=String(url), method=init.method||'GET';
|
||||
@@ -29,6 +30,7 @@ const stub = `(() => {
|
||||
if(a.hold)await new Promise(r=>window.__releaseWrite=r);
|
||||
if(a.reject)throw new TypeError('network');
|
||||
if(a.redirect)return {type:'opaqueredirect',status:0,ok:false};
|
||||
if(a.body?.state==='cancelled')window.__operation=a.body;
|
||||
return new Response(JSON.stringify(a.body),{status:a.status||200});
|
||||
}
|
||||
window.__reads.push(path);
|
||||
@@ -36,6 +38,8 @@ const stub = `(() => {
|
||||
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
|
||||
if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
|
||||
if(path==='/api/chat/projects')return data({rows:window.__projects});
|
||||
if(path.endsWith('/sessions')){const t=path.split('/')[4];return data({rows:window.__sessions[t]||[{id:t,name:'default',default:true}]});}
|
||||
if(path.includes('/operation?'))return data(window.__operation);
|
||||
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
return data({rows:[],after:null,hasMore:false});
|
||||
@@ -108,7 +112,7 @@ test('chat browser: thread drafts, receipts and cursor history stay isolated; re
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
||||
await route(B); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'beta draft');
|
||||
assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Accepted/);
|
||||
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.body)'), [{body:'alpha draft'}]);
|
||||
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.body)'), [{body:'alpha draft',session:A}]);
|
||||
assert.equal(await b.evaluate('window.__writes[0].redirect'), 'manual');
|
||||
assert.equal(await b.evaluate('window.__writes[0].path'), `/api/chat/threads/${A}/messages`);
|
||||
await b.evaluate('window.__answers.push({body:{opened:location.hash.split("/").at(-1)}}),true');
|
||||
@@ -328,3 +332,115 @@ test('C2-09a picker: a single instance is chosen explicitly but read-only mode c
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
});
|
||||
|
||||
|
||||
test('264e chooser: two sessions keep drafts, cursors, deep links and counts independent', async t => {
|
||||
const B = '30000000-0000-8000-8000-000000000003'; // mixed v4/v8 logical sessions
|
||||
const { b, wait, set, click, route, refresh } = await setup(t);
|
||||
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,compactions:{known:true,count:3,reason:null}}, {id:'${B}',name:'second',default:false,compactions:{known:false,count:0,reason:'harness-unsupported'}}],true`);
|
||||
await route(A);
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-compactions").textContent'), /Compactions: 3/);
|
||||
await set('#chat-body', 'first session draft');
|
||||
await b.evaluate(`document.querySelector('#chat-session').value='${B}';document.querySelector('#chat-session').dispatchEvent(new Event('change',{bubbles:true}));true`);
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-compactions").textContent'), /unknown.*harness-unsupported/);
|
||||
await set('#chat-body', 'second session draft');
|
||||
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${A}',true`);
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${A}'`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'first session draft');
|
||||
await refresh();
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'first session draft');
|
||||
assert.ok((await b.evaluate('window.__reads')).some(p => p.includes(`session=${B}`)));
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
||||
await click('#chat-send-form button[type=submit]');
|
||||
await wait('window.__writes.length===1');
|
||||
assert.deepEqual(await b.evaluate('window.__writes[0].body'), {body:'first session draft',session:A});
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-clear").disabled'), true);
|
||||
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${M}',true`);
|
||||
await wait('document.querySelector(".chat-area h1")?.textContent === "Thread unavailable"');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), false);
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
||||
});
|
||||
|
||||
test('264e clear confirmation and status never resend, force-stop or change selection', async t => {
|
||||
const { b, wait, click, route, refresh } = await setup(t);
|
||||
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2},{id:'${B}',name:'second',state:'active',revision:0}];window.confirm=()=>false;true`);
|
||||
await route(A); await click('#chat-clear');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
await b.evaluate(`window.confirm=()=>true;window.__answers.push({body:{op:'placeholder',thread:'${A}',session:'${A}',successor:'${M}',state:'uncertain',start:null,refusal:null,at:null}});true`);
|
||||
// The fixture echoes the actual idempotency key, as the broker contract does.
|
||||
await b.evaluate(`(()=>{const real=window.fetch;window.fetch=async(u,i)=>{const r=await real(u,i);if(String(u).endsWith('/clear')){const v=await r.json();v.op=JSON.parse(i.body).op;window.__operation=v;return new Response(JSON.stringify(v));}return r;};return true;})()`);
|
||||
await click('#chat-clear');
|
||||
await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("uncertain")');
|
||||
const posted = await b.evaluate('window.__writes[0]');
|
||||
assert.equal(posted.path, `/api/chat/threads/${A}/clear`);
|
||||
assert.deepEqual(Object.keys(posted.body).sort(), ['confirm','op','revision','session']);
|
||||
assert.equal(posted.body.session, A); assert.equal(posted.body.revision, 2); assert.equal(posted.body.confirm, true);
|
||||
await click('#chat-operation-form button');
|
||||
await wait('window.__reads.some(p=>p.includes("/operation?"))');
|
||||
await refresh();
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
||||
assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.session'), A);
|
||||
});
|
||||
|
||||
test('264e new session keeps its operation key after an unknown response and uses no implicit open', async t => {
|
||||
const {b,wait,set,click,route}=await setup(t);
|
||||
await route(A); await set('#chat-session-name','new context');
|
||||
await b.evaluate('window.__answers.push({reject:true}),true');
|
||||
await click('#chat-session-form button');
|
||||
await wait('document.querySelector("#chat-session-form [role=status]")?.textContent.includes("Outcome unknown")');
|
||||
const first=await b.evaluate('window.__writes[0]');
|
||||
assert.equal(first.path, `/api/chat/threads/${A}/sessions`);
|
||||
assert.equal(first.body.name,'new context');
|
||||
assert.match(first.body.op,/^[a-f0-9-]{36}$/);
|
||||
// A deliberate retry uses the pinned payload; selecting a session never replays this write.
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${B}'}});window.__sessions['${A}']=[{id:'${A}',name:'default',default:true},{id:'${B}',name:'new context'}];true`);
|
||||
await click('#chat-session-form button');
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
|
||||
assert.deepEqual(await b.evaluate('window.__writes[1].body'), first.body);
|
||||
assert.equal(await b.evaluate('window.__writes.length'),2);
|
||||
assert.equal(await b.evaluate('location.hash'),`#/chat/threads/${A}/sessions/${B}`);
|
||||
});
|
||||
|
||||
test('264e cancel shows before/after retirement and never starts or replays work', async t => {
|
||||
const {b,wait,set,click,route}=await setup(t);
|
||||
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}];window.confirm=()=>true;true`);
|
||||
await route(A);
|
||||
for (const retired of [null,true]) {
|
||||
const envelope={op:retired?'after-retirement':'before-retirement',thread:A,session:A,successor:M,state:'cancelled',start:retired?'held':null,retired,refusal:null,at:null};
|
||||
await set('#chat-operation',envelope.op);
|
||||
await b.evaluate(`window.__operation=${JSON.stringify({...envelope,state:'uncertain'})};window.__answers.push({body:${JSON.stringify(envelope)}});true`);
|
||||
await click('#chat-cancel');
|
||||
await b.evaluate(`window.__operation=${JSON.stringify(envelope)};true`);
|
||||
await wait(`document.querySelector('#chat-clear-receipt')?.textContent.includes('${retired?'startup cancelled':'old session active'}')`);
|
||||
const last=await b.evaluate('window.__writes.at(-1)');
|
||||
assert.equal(last.path,`/api/chat/threads/${A}/cancel`);
|
||||
assert.deepEqual(last.body,{op:envelope.op,confirm:true});
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-cancel").disabled'),true);
|
||||
}
|
||||
assert.equal(await b.evaluate('window.__writes.length'),2);
|
||||
assert.equal(await b.evaluate('window.__writes.some(w=>/\\/(open|messages|sessions|clear)$/.test(w.path))'),false);
|
||||
});
|
||||
|
||||
test('272 B1: a bad-session deep link preserves the draft and lost clear operation on A', async t => {
|
||||
const {b,wait,set,click,route}=await setup(t);
|
||||
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2},{id:'${B}',name:'sibling',state:'active',revision:0}];window.confirm=()=>true;true`);
|
||||
await route(A);
|
||||
await set('#chat-body','keep A draft through invalid session');
|
||||
await b.evaluate('window.__answers.push({reject:true}),true');
|
||||
await click('#chat-clear');
|
||||
await wait('window.__writes.length===1 && !document.querySelector("#chat-clear").disabled');
|
||||
const op=await b.evaluate('window.__writes[0].body.op');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-operation").value'),op);
|
||||
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${M}',true`);
|
||||
await wait('document.querySelector(".chat-area h1")?.textContent === "Thread unavailable"');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
||||
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${A}',true`);
|
||||
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${A}'`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'keep A draft through invalid session');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-operation").value'),op);
|
||||
await click('#chat-clear');
|
||||
await wait('window.__reads.some(p=>p.includes("/operation?"))');
|
||||
assert.equal(await b.evaluate('window.__writes.length'),1,'a retained operation is queried, never replaced with a new clear');
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// Row 272, sage-3357: UI session negotiation over the real broker's account boundary.
|
||||
// The loopback fixture binds one already granted principal; login/CSRF are covered by
|
||||
// granted-user.test.mjs. No real account, provider, config or owner transport is used.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
|
||||
async function setup(t, { owner = false, catalogError = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'huey-session-access-'));
|
||||
const store = new Store(root), calls = [];
|
||||
const access = { accessVersion:1, revision:1, users:[], grants:[{id:'g-1',user:'alice',business:'demo',projects:['*'],instances:['assistant'],level:'collaborator'}] };
|
||||
const broker = new Broker({store, access:()=>access, businesses:{demo:{
|
||||
id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},
|
||||
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:JSON.parse(readFileSync(new URL('../../../roles/assistant.json',import.meta.url),'utf8')).messaging}},
|
||||
}}});
|
||||
const human = broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const cap = owner ? human : broker.bindUser({business:'demo',user:'alice'});
|
||||
const invoke = (verb,args={}) => broker.request(cap,{verb,args});
|
||||
const project = broker.request(human,{verb:'project.create',args:{name:'shared'}}).id;
|
||||
const thread = invoke('thread.create',{project,name:'account thread',instance:'assistant'}).id;
|
||||
invoke('thread.send',{thread,body:'existing account history'});
|
||||
const session = store.get('SELECT session FROM session_defaults WHERE thread=? ORDER BY seq DESC LIMIT 1',thread).session;
|
||||
let b;
|
||||
const server = createServer(async (req,res) => {
|
||||
const url = new URL(req.url,'http://fixture');
|
||||
const json = (status,value) => {res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));};
|
||||
if(url.pathname === '/') {res.writeHead(200,{'content-type':'text/html'});res.end('<!doctype html><title>Session access fixture</title><div id="view"></div><script src="/chat.js"></script>');return;}
|
||||
if(url.pathname === '/chat.js') {res.writeHead(200,{'content-type':'text/javascript'});res.end(readFileSync(new URL('../src/public/chat.js',import.meta.url)));return;}
|
||||
try {
|
||||
let verb,args={};
|
||||
const match=/^\/api\/chat\/threads\/([^/]+)\/(sessions|messages|open)$/.exec(url.pathname);
|
||||
if(url.pathname === '/api/chat/projects') verb='project.list';
|
||||
else if(url.pathname === '/api/chat/threads') verb='thread.list';
|
||||
else if(match) {
|
||||
args.thread=match[1];
|
||||
if(match[2] === 'sessions') verb='session.list';
|
||||
else if(match[2] === 'open') verb='thread.open';
|
||||
else verb=req.method === 'POST' ? 'thread.send' : 'thread.messages';
|
||||
if(req.method === 'POST') {
|
||||
let body='';for await(const part of req)body+=part;
|
||||
Object.assign(args,JSON.parse(body));
|
||||
if(verb === 'thread.send')args.class='REQUEST';
|
||||
} else if(verb === 'thread.messages') {
|
||||
args.limit=Number(url.searchParams.get('limit')??100);
|
||||
for(const key of ['session','after'])if(url.searchParams.has(key))args[key]=url.searchParams.get(key);
|
||||
}
|
||||
} else return json(404,{error:'not-found'});
|
||||
calls.push({verb,args:structuredClone(args),owner});
|
||||
if(verb === 'session.list' && catalogError === 'malformed')return json(200,{rows:{invalid:true}});
|
||||
if(verb === 'session.list' && catalogError)throw Object.assign(new Error(catalogError),{code:catalogError});
|
||||
const value=invoke(verb,args);
|
||||
if(req.method === 'POST')return json(200,value);
|
||||
return json(200,{rows:value,after:verb==='thread.messages'?value.at(-1)?.id??null:null,hasMore:false});
|
||||
} catch(e) {json(['access-refused','human-required'].includes(e.code)?403:503,{error:e.code??'invalid-response',sent:false});}
|
||||
});
|
||||
t.after(async()=>{await b?.close();await new Promise(r=>server.close(r));store.close();rmSync(root,{recursive:true,force:true});});
|
||||
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
||||
b=await browser();
|
||||
if(catalogError === 'timeout')await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`{const actual=fetch.bind(window);window.fetch=(url,opts)=>String(url).endsWith('/sessions')?Promise.reject(new DOMException('fixture transport timeout','TimeoutError')):actual(url,opts);}`});
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/`);
|
||||
const render = async explicit => {
|
||||
const path=`/chat/threads/${thread}${explicit?`/sessions/${session}`:''}`;
|
||||
await b.evaluate(`(async()=>{location.hash=${JSON.stringify('#'+path)};await window.MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('view'),canSend:true,navigated:true});return true;})()`);
|
||||
};
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}return false;})()`);
|
||||
return {b,calls,render,wait,thread,session,store};
|
||||
}
|
||||
|
||||
test('272 fallback: granted legacy route keeps default history, send and open without session controls', async t => {
|
||||
const {b,calls,render,wait,thread,store}=await setup(t);
|
||||
await render(false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),true,'legacy granted-user thread must remain usable after session.list access-refused');
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false);
|
||||
assert.match(await b.evaluate('document.getElementById("view").textContent'),/sessions.*not.*available.*account/i);
|
||||
await b.evaluate('(()=>{const input=document.getElementById("chat-body");input.value="new granted message";input.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-send-form button").click();return true;})()');
|
||||
assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("Accepted")'),true);
|
||||
assert.equal(store.get("SELECT count(*) n FROM messages WHERE body='new granted message'").n,1);
|
||||
const send=calls.find(c=>c.verb==='thread.send');
|
||||
assert.equal(send.args.thread,thread);assert.equal(Object.hasOwn(send.args,'session'),false);
|
||||
await b.evaluate('document.getElementById("chat-resume").click(),true');
|
||||
assert.equal(await wait('document.getElementById("chat-receipt").textContent.includes("resume requested")'),true);
|
||||
assert.deepEqual(calls.find(c=>c.verb==='thread.open').args,{thread});
|
||||
assert.ok(calls.some(c=>c.verb==='session.list'));
|
||||
assert.ok(calls.filter(c=>c.verb==='thread.messages').every(c=>!Object.hasOwn(c.args,'session')));
|
||||
assert.ok(calls.every(c=>c.owner===false),'no request may switch to owner authority');
|
||||
});
|
||||
|
||||
test('272 fallback guard: granted explicit-session route refuses without default history or writes', async t => {
|
||||
const {b,calls,render}=await setup(t);
|
||||
await render(true);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),false);
|
||||
assert.equal(calls.some(c=>['thread.messages','thread.send','thread.open'].includes(c.verb)),false);
|
||||
});
|
||||
|
||||
for(const failure of ['human-required','timeout','no-bus-host','malformed']) {
|
||||
test(`272 fallback guard: ${failure} catalog failure never enables default-thread access`, async t => {
|
||||
const {b,calls,render}=await setup(t,{catalogError:failure});
|
||||
await render(false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
||||
assert.equal(calls.some(c=>c.verb==='thread.messages'),false);
|
||||
});
|
||||
}
|
||||
|
||||
test('272 fallback guard: owner route retains chooser and explicit default-session addressing', async t => {
|
||||
const {b,calls,render,session}=await setup(t,{owner:true});
|
||||
await render(false);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-session-controls")'),true);
|
||||
assert.equal(await b.evaluate('document.getElementById("chat-session").value'),session);
|
||||
assert.equal(calls.find(c=>c.verb==='thread.messages').args.session,session);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/existing account history/);
|
||||
});
|
||||
@@ -353,3 +353,101 @@ test('C2-09a malformed instance and extra thread fields refuse before the channe
|
||||
assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
|
||||
assert.deepEqual(c.calls, []);
|
||||
});
|
||||
|
||||
test('264e session catalog/create and explicit selectors keep the human transport and strict keys', async t => {
|
||||
const c = await setup(t);
|
||||
c.state.answer = [{ id: M, name: 'second', default: false }];
|
||||
const list = await c.get(`/api/chat/threads/${T}/sessions`);
|
||||
assert.equal(list.status, 200);
|
||||
assert.deepEqual(c.calls.at(-1), ['session.list', { thread: T }]);
|
||||
c.state.answer = { id: M, thread: T, name: 'second' };
|
||||
const made = await c.get(`/api/chat/threads/${T}/sessions`, { method: 'POST', value: { name: 'second', op: 'new-second' } });
|
||||
assert.equal(made.status, 200);
|
||||
assert.deepEqual(c.calls.at(-1), ['session.create', { thread: T, name: 'second', op: 'new-second' }]);
|
||||
c.state.answer = [];
|
||||
assert.equal((await c.get(`/api/chat/threads/${T}/messages?session=${M}`)).status, 200);
|
||||
assert.deepEqual(c.calls.at(-1), ['thread.messages', { thread: T, session: M, limit: 100 }]);
|
||||
c.state.answer = { opened: T };
|
||||
assert.equal((await c.get(`/api/chat/threads/${T}/open`, { method: 'POST', value: { session: M } })).status, 200);
|
||||
assert.deepEqual(c.calls.at(-1), ['thread.open', { thread: T, session: M }]);
|
||||
const before = c.calls.length;
|
||||
for (const path of [`messages?session=no`, `messages?session=${M}&session=${M}`, `sessions?owner=other`]) {
|
||||
assert.equal((await c.get(`/api/chat/threads/${T}/${path}`)).status, 400);
|
||||
}
|
||||
for (const value of [{name:'x'}, {name:'x',op:'key',owner:'other'}, {name:'x',op:'../escape'}]) {
|
||||
assert.equal((await c.get(`/api/chat/threads/${T}/sessions`, { method: 'POST', value })).status, 400);
|
||||
}
|
||||
assert.equal(c.calls.length, before);
|
||||
});
|
||||
test('264e session writes retain Origin/identity fences and explicit selector errors', async t => {
|
||||
const c = await setup(t);
|
||||
for (const extra of [{origin:'https://evil.example'}, {'remote-user':'other'}, {'sec-fetch-site':'cross-site'}]) {
|
||||
assert.ok((await c.get(`/api/chat/threads/${T}/sessions`, { method:'POST', value:{name:'x',op:'new-x'}, extra })).status >= 400);
|
||||
}
|
||||
assert.deepEqual(c.calls, []);
|
||||
c.state.answer = new BusReadError('session-not-found');
|
||||
const failed = await c.get(`/api/chat/threads/${T}/messages?session=${M}`);
|
||||
assert.equal(failed.status, 404);
|
||||
assert.equal(failed.data.error, 'session-not-found');
|
||||
assert.equal(c.calls.length, 1);
|
||||
});
|
||||
|
||||
test('264e clear envelope is projected, pending is preserved and GET never repeats the clear', async t => {
|
||||
const c = await setup(t);
|
||||
const envelope = {op:'clear-one',thread:T,session:M,successor:P,state:'uncertain',start:null,refusal:null,at:null};
|
||||
c.state.answer = {...envelope, native:'/private/store', proof:'secret'};
|
||||
const post = await c.get(`/api/chat/threads/${T}/clear`, {method:'POST',value:{session:M,revision:2,confirm:true,op:'clear-one'}});
|
||||
assert.equal(post.status, 200);
|
||||
assert.deepEqual(post.data, envelope);
|
||||
assert.deepEqual(c.calls.at(-1), ['session.clear',{thread:T,session:M,revision:2,confirm:true,op:'clear-one'}]);
|
||||
const get = await c.get(`/api/chat/threads/${T}/operation?op=clear-one`);
|
||||
assert.equal(get.status, 200); assert.deepEqual(get.data, envelope);
|
||||
assert.deepEqual(c.calls.at(-1), ['session.operation',{thread:T,op:'clear-one'}]);
|
||||
assert.equal(c.calls.filter(([v])=>v==='session.clear').length, 1);
|
||||
for (const value of [{session:M,revision:2,confirm:false,op:'clear-one'},{session:M,revision:-1,confirm:true,op:'clear-one'},{session:M,revision:2,confirm:true,op:'clear-one',pid:1}]) {
|
||||
assert.equal((await c.get(`/api/chat/threads/${T}/clear`,{method:'POST',value})).status,400);
|
||||
}
|
||||
c.state.answer = new BusReadError('unknown-verb');
|
||||
const missing = await c.get(`/api/chat/threads/${T}/clear`,{method:'POST',value:{session:M,revision:2,confirm:true,op:'clear-one'}});
|
||||
assert.equal(missing.status,503);assert.equal(missing.data.sent,false);assert.match(missing.data.message,/unavailable/);
|
||||
});
|
||||
|
||||
test('264e cancel is an authenticated, confirmed human write and retains retirement distinction', async t => {
|
||||
const c=await setup(t), path=`/api/chat/threads/${T}/cancel`;
|
||||
for(const retired of [null,true]) {
|
||||
const envelope={op:'clear-one',thread:T,session:M,successor:P,state:'cancelled',start:retired?'held':null,retired,refusal:null,at:null};
|
||||
c.state.answer={...envelope,proof:'private'};
|
||||
const cancelled=await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true}});
|
||||
assert.equal(cancelled.status,200);assert.deepEqual(cancelled.data,envelope);
|
||||
assert.deepEqual(c.calls.at(-1),['session.cancel',{thread:T,op:'clear-one',confirm:true}]);
|
||||
assert.deepEqual((await c.get(`/api/chat/threads/${T}/operation?op=clear-one`)).data,envelope);
|
||||
}
|
||||
const before=c.calls.length;
|
||||
for(const value of [{op:'clear-one'}, {op:'clear-one',confirm:false}, {op:'clear-one',confirm:true,user:'other'}]) assert.equal((await c.get(path,{method:'POST',value})).status,400);
|
||||
for(const extra of [{origin:'https://evil.example'}, {'remote-user':'other'}, {'sec-fetch-site':'cross-site'}]) assert.ok((await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true},extra})).status>=400);
|
||||
assert.equal(c.calls.length,before);
|
||||
for(const code of ['session-clear-complete','session-name-too-long','session-op-conflict']) {
|
||||
c.state.answer=new BusReadError(code);
|
||||
const result=await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true}});
|
||||
assert.equal(result.status,409);assert.equal(result.data.sent,false);assert.equal(result.data.error,code);
|
||||
}
|
||||
});
|
||||
|
||||
test('272 N1: malformed clear/cancel write receipts get one redacted outcome-unknown audit line', async t => {
|
||||
const c=await setup(t);
|
||||
for(const [suffix,value,action] of [
|
||||
['clear',{session:M,revision:0,confirm:true,op:'audit-clear'},'clearSession'],
|
||||
['cancel',{confirm:true,op:'audit-cancel'},'cancelSession'],
|
||||
]) {
|
||||
c.state.answer={private:'SENTINEL',op:value.op,thread:T};
|
||||
const before=c.logs.length;
|
||||
const result=await c.get(`/api/chat/threads/${T}/${suffix}`,{method:'POST',value});
|
||||
assert.equal(result.status,503);assert.equal(result.data.sent,'unknown');
|
||||
assert.equal(c.logs.length,before+1);
|
||||
assert.match(c.logs.at(-1),new RegExp(`action=${action} .*error=outcome-unknown`));
|
||||
assert.doesNotMatch(c.logs.at(-1),/SENTINEL|private/);
|
||||
}
|
||||
const before=c.logs.length;
|
||||
assert.equal((await c.get(`/api/chat/threads/${T}/operation?op=audit-clear`)).status,502);
|
||||
assert.equal(c.logs.length,before,'pure reads do not add write audit lines');
|
||||
});
|
||||
@@ -19,8 +19,7 @@ import { startServer, userRoute } from '../src/serve.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { consoleBus, userOptionsFor } from '../src/console-bus.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { USER_CLI, userCall } from '../src/user-bus.mjs';
|
||||
import { BusReadError, USER_CLI, userCall } from '../src/bus.mjs';
|
||||
import { close, fixture } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
|
||||
@@ -14,7 +14,7 @@ test('C2-04 CLI: both listener callback pairs dispatch library verbs with their
|
||||
// The loader supplies a fixture bus and captures callbacks instead of
|
||||
// opening an HTTP listener. Production imports and routes are unchanged.
|
||||
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){if(typeof o.libraryFile!=='function'||typeof o.libraryStatus!=='function')throw Error('missing library callbacks');await o.libraryFile({project:'books',path:'Book One/A.md'});await o.libraryStatus({project:'books'});return {address:()=>({port:0}),close:()=>{}};}";
|
||||
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};`;
|
||||
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};`;
|
||||
writeFileSync(hook, `import {registerHooks} from 'node:module';
|
||||
registerHooks({load(url,context,next){
|
||||
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
|
||||
|
||||
@@ -55,7 +55,7 @@ test('row278 Console adapter calls argument-free thread.claims through the norma
|
||||
const root = tmp(t, 'claims-cli-'), f = fixture(root), log = join(root, 'calls.jsonl'), hook = join(root, 'hooks.mjs');
|
||||
writeFileSync(log, '');
|
||||
const serveSource = `export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){await o.bus['thread.claims']();return {address:()=>({port:0}),close:()=>{}};}`;
|
||||
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,...args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,tester:!!o.cli})+'\\n');return [];};`;
|
||||
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,...args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,tester:!!o.cli})+'\\n');return [];};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};`;
|
||||
writeFileSync(hook, `import {registerHooks} from 'node:module';registerHooks({load(url,context,next){
|
||||
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
|
||||
if(url.endsWith('/webui/src/bus.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(busSource)}};
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// R6-N1 (#1807, row 294 N1): the user-session option swap in serve.mjs, driven through HTTP.
|
||||
// granted-user.test.mjs G4 stops every owner route at the userRoute gate, so it never reaches the
|
||||
// swap behind it. Here serve.mjs loads with that gate stubbed open (a load hook keyed on one query
|
||||
// string; the product file is unchanged), and every owner route a user session might try must reach
|
||||
// no owner option. The owner transport may carry only the plugins read mos-1394 allowed.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { userOptionsFor } from '../src/console-bus.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const SERVE = new URL('../src/serve.mjs', import.meta.url).href, OPEN = `${SERVE}?gate=open`;
|
||||
const GATE = 'export function userRoute(path, method) {';
|
||||
registerHooks({
|
||||
load(url, context, next) {
|
||||
const r = next(url, context);
|
||||
if (url !== OPEN) return r;
|
||||
const source = String(r.source);
|
||||
// A renamed gate fails here rather than leaving the test on the closed gate.
|
||||
if (source.split(GATE).length !== 2) throw new Error('serve.mjs: the userRoute gate is not there exactly once');
|
||||
return { ...r, source: source.replace(GATE, `${GATE} return true;`) };
|
||||
},
|
||||
});
|
||||
const { startServer, userRoute } = await import(OPEN);
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host: HOST, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
|
||||
const writeHeaders = cookie => ({ cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
|
||||
// As granted-user.test.mjs setup: the fake OP, a broker for `demo`, createSso with the access hook,
|
||||
// counting owner stubs, and consoleBus over alice's transport for her session. Only startServer
|
||||
// differs: it is the gate-open copy.
|
||||
async function setup(t) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = op.issuer('a');
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-option-swap-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const doc = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
|
||||
const load = () => doc;
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: {},
|
||||
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: JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8')).messaging } },
|
||||
},
|
||||
},
|
||||
access: load,
|
||||
});
|
||||
const owner = [], user = [], opened = [];
|
||||
const userTransport = name => async (verb, args = {}) => {
|
||||
user.push(verb);
|
||||
try { return broker.request(broker.bindUser({ business: 'demo', user: name }), { verb, args }); }
|
||||
catch (e) { throw new BusReadError(e.code ?? 'invalid-response'); }
|
||||
};
|
||||
const ownerCall = async (verb, args) => { owner.push(['transport', verb, args]); return { plugins: { channel: ['webui'] } }; };
|
||||
const userOptions = userOptionsFor({ owner: ownerCall, send: true, transport: name => { opened.push(name); return userTransport(name); } });
|
||||
const lines = [];
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }] }] };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), users: consoleAccess({ load, business: () => 'demo' }) });
|
||||
t.after(() => sso.close());
|
||||
// Every owner option is a stub that records its own name.
|
||||
const stub = name => async () => { owner.push(['option', name]); return name === 'send' ? { id: 'msg-0001' } : [{ verb: name }]; };
|
||||
const options = {
|
||||
bus: Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'names', 'instances', 'routes', 'thread.claims'].map(v => [v, stub(`bus.${v}`)])),
|
||||
send: stub('send'), settingsWrite: stub('settingsWrite'), projectFiles: stub('projectFiles'), projectFile: stub('projectFile'), projectSave: stub('projectSave'),
|
||||
nameWrite: stub('nameWrite'), assistantStop: stub('assistantStop'), instancesWrite: stub('instancesWrite'), routesWrite: stub('routesWrite'), agentWrite: stub('agentWrite'),
|
||||
processing: { read: stub('processing.read'), halt: stub('processing.halt'), resume: stub('processing.resume') },
|
||||
reads: Object.fromEntries(['settings', 'business', 'busStatus', 'queue', 'row'].map(v => [v, stub(`reads.${v}`)])),
|
||||
chat: Object.fromEntries(['projects', 'threads', 'messages', 'send', 'open', 'createProject', 'createThread'].map(v => [v, stub(`chat.${v}`)])),
|
||||
};
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, log: l => lines.push(l), ...options, userOptions });
|
||||
t.after(() => close(web));
|
||||
return { op, iss, owner, user, opened, lines, port: web.address().port };
|
||||
}
|
||||
|
||||
async function signIn(env, sub) {
|
||||
const start = await call(env.port, { path: '/auth/login' });
|
||||
assert.equal(start.status, 303);
|
||||
const cookie = setCookies(start)[0].split(';')[0];
|
||||
const location = start.headers.location;
|
||||
const g = env.op.grant(env.iss, location, { sub });
|
||||
const q = new URLSearchParams({ code: g.code, state: new URL(location).searchParams.get('state') });
|
||||
const r = await call(env.port, { path: `/auth/callback?${q}`, headers: { cookie } });
|
||||
const session = setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0];
|
||||
assert.ok(session, `${sub}: no session cookie (${r.status})`);
|
||||
return session;
|
||||
}
|
||||
|
||||
// granted-user.test.mjs G4's owner routes, plus the chat routes a user session reaches anyway.
|
||||
const ROUTES = [
|
||||
['GET', '/api/bus/inbox'], ['GET', '/api/bus/tasks'], ['GET', '/api/bus/names'], ['GET', '/api/bus/thread.claims'],
|
||||
['POST', '/api/bus/message', { to: 'coder', body: 'hi' }],
|
||||
['GET', '/api/settings'], ['POST', '/api/settings', { revision: 'x' }],
|
||||
['POST', '/api/name', { role: 'assistant', name: 'x' }], ['POST', '/api/assistant/stop', {}],
|
||||
['POST', '/api/instances', { instance: 'x' }], ['POST', '/api/routes', { routes: [] }],
|
||||
['POST', '/api/agent/launch', { instance: 'x' }], ['GET', '/api/agent/request?id=x'],
|
||||
['GET', '/api/processing'], ['POST', '/api/processing/halt', {}],
|
||||
['GET', '/api/project-files?project=p'], ['GET', '/api/project-file?project=p&path=a'], ['POST', '/api/project-file', { project: 'p' }],
|
||||
['GET', '/api/board'], ['GET', '/api/conversations'], ['POST', '/api/seen', {}], ['POST', '/api/reply', {}],
|
||||
['GET', '/api/queue'], ['GET', '/api/queue/1'], ['GET', '/api/business'], ['GET', '/api/bus-status'], ['GET', '/api/task-fields'],
|
||||
['GET', '/api/chat/projects'], ['GET', '/api/chat/instances'],
|
||||
['GET', '/app.js'], ['GET', '/bus.js'], ['GET', '/agents.js'], ['GET', '/editor.js'], ['GET', '/shared/app.css'], ['GET', '/index.html'],
|
||||
];
|
||||
const go = (env, session, [method, path, value]) => call(env.port, { method, path, headers: value === undefined ? { cookie: session } : writeHeaders(session), body: value === undefined ? undefined : JSON.stringify(value) });
|
||||
|
||||
test('R6-N1 with the userRoute gate open, a user session reaches no owner option over HTTP', async t => {
|
||||
// The stub took: the gate-open copy lets an owner route through.
|
||||
assert.equal(userRoute('/api/bus/message', 'POST'), true);
|
||||
const env = await setup(t);
|
||||
// Positive control: the owner's session reaches the owner's send on this route.
|
||||
const ownerSession = await signIn(env, 'owner-sub');
|
||||
const sent = await go(env, ownerSession, ['POST', '/api/bus/message', { to: 'coder', body: 'hi' }]);
|
||||
assert.deepEqual(env.owner, [['option', 'send']], `the owner's send did not run: ${sent.status} ${sent.body}`);
|
||||
env.owner.length = 0;
|
||||
const session = await signIn(env, 'alice-sub');
|
||||
for (const route of ROUTES) {
|
||||
const r = await go(env, session, route);
|
||||
assert.doesNotMatch(r.body, /owner-only/, `${route[0]} ${route[1]}: the gate is still closed`);
|
||||
// Only the plugins read with {}, as granted-user.test.mjs G9 pins for the chat.
|
||||
for (const entry of env.owner) assert.deepEqual(entry, ['transport', 'plugins', {}], `${route[0]} ${route[1]}: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
assert.ok(env.user.includes('message.send'), 'the message route never reached the user transport');
|
||||
assert.ok(env.opened.length > 0 && env.opened.every(name => name === 'alice'));
|
||||
});
|
||||
+30
-1
@@ -12,7 +12,22 @@ RELEASE_BACKUP="$(mktemp)"
|
||||
cp RELEASE "$RELEASE_BACKUP"
|
||||
# One exit trap: the repo RELEASE is ALWAYS restored from the backup,
|
||||
# regardless of how the test run ends.
|
||||
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"' EXIT
|
||||
# Compose names the project after the checkout directory unless told otherwise,
|
||||
# and `compose run --rm` leaves the project's <project>_default network behind.
|
||||
# Every run gets its own project, and the exit trap removes that one network
|
||||
# (row 305). It never runs `compose down` and never touches another network.
|
||||
COMPOSE_ID="$(basename "$SANDBOX")"
|
||||
COMPOSE_ID="$(printf '%s' "${COMPOSE_ID##*.}" | tr -cd '[:alnum:]' | tr '[:upper:]' '[:lower:]')"
|
||||
export COMPOSE_PROJECT_NAME="mosaic-test-release-$COMPOSE_ID"
|
||||
remove_compose_network() { # absent is fine; any other failure is one stderr line, never a status
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local net="${COMPOSE_PROJECT_NAME}_default" err
|
||||
err="$(docker network rm "$net" 2>&1 >/dev/null)" && return 0
|
||||
case "$err" in *"not found"*|*"No such network"*) return 0 ;; esac
|
||||
echo "test-release: could not remove network $net: ${err%%$'\n'*}" >&2
|
||||
return 0
|
||||
}
|
||||
trap 'cp "$RELEASE_BACKUP" RELEASE 2>/dev/null; rm -rf "$SANDBOX" "$RELEASE_BACKUP"; remove_compose_network' EXIT
|
||||
# Compose mounts PI_AUTH_FILE. Point it at a fixture so no activation reaches
|
||||
# the host's pi credential (row 244). chmod after the write: umask-proof (row 243).
|
||||
{ mkdir -p "$SANDBOX/pi" && printf '{}' > "$SANDBOX/pi/auth.json" && chmod 600 "$SANDBOX/pi/auth.json"; } \
|
||||
@@ -108,6 +123,20 @@ else
|
||||
echo "skip state-machine cases (docker daemon unavailable)"
|
||||
fi
|
||||
|
||||
# The compose network goes with the run (row 305). Present before the remove
|
||||
# proves compose used this run's project name; absent after proves the trap's
|
||||
# cleanup removes it (mos-1445).
|
||||
if docker info >/dev/null 2>&1; then
|
||||
NET="${COMPOSE_PROJECT_NAME}_default"
|
||||
BEFORE="$(docker network ls -q --filter "name=^${NET}\$")" && [ -n "$BEFORE" ] \
|
||||
&& remove_compose_network \
|
||||
&& AFTER="$(docker network ls -q --filter "name=^${NET}\$")" && [ -z "$AFTER" ] \
|
||||
&& check "compose network made under the run's project and removed after the run" 0 \
|
||||
|| check "compose network made under the run's project and removed after the run" 1
|
||||
else
|
||||
echo "skip compose network check (docker daemon unavailable)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+30
-1
@@ -8,7 +8,22 @@ set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SANDBOX="$(mktemp -d)"
|
||||
trap 'rm -rf "$SANDBOX"' EXIT
|
||||
# Compose names the project after the checkout directory unless told otherwise,
|
||||
# and `compose run --rm` leaves the project's <project>_default network behind.
|
||||
# Every run gets its own project, and the exit trap removes that one network
|
||||
# (row 305). It never runs `compose down` and never touches another network.
|
||||
COMPOSE_ID="$(basename "$SANDBOX")"
|
||||
COMPOSE_ID="$(printf '%s' "${COMPOSE_ID##*.}" | tr -cd '[:alnum:]' | tr '[:upper:]' '[:lower:]')"
|
||||
export COMPOSE_PROJECT_NAME="mosaic-test-task-$COMPOSE_ID"
|
||||
remove_compose_network() { # absent is fine; any other failure is one stderr line, never a status
|
||||
command -v docker >/dev/null 2>&1 || return 0
|
||||
local net="${COMPOSE_PROJECT_NAME}_default" err
|
||||
err="$(docker network rm "$net" 2>&1 >/dev/null)" && return 0
|
||||
case "$err" in *"not found"*|*"No such network"*) return 0 ;; esac
|
||||
echo "test-task: could not remove network $net: ${err%%$'\n'*}" >&2
|
||||
return 0
|
||||
}
|
||||
trap 'rm -rf "$SANDBOX"; remove_compose_network' EXIT
|
||||
# Real-model cases need the operator's credential and a billed provider call,
|
||||
# so they run only on request (row 244, sage-2061). The gate lane never passes
|
||||
# MOSAIC_LIVE_PROVIDER through.
|
||||
@@ -558,6 +573,20 @@ fi
|
||||
git -C "$SANDBOX/repo" rev-parse >/dev/null 2>&1 || true
|
||||
unset MOSAIC_CONFIG
|
||||
|
||||
# The compose network goes with the run (row 305). Present before the remove
|
||||
# proves compose used this run's project name; absent after proves the trap's
|
||||
# cleanup removes it (mos-1445).
|
||||
if docker info >/dev/null 2>&1; then
|
||||
NET="${COMPOSE_PROJECT_NAME}_default"
|
||||
BEFORE="$(docker network ls -q --filter "name=^${NET}\$")" && [ -n "$BEFORE" ] \
|
||||
&& remove_compose_network \
|
||||
&& AFTER="$(docker network ls -q --filter "name=^${NET}\$")" && [ -z "$AFTER" ] \
|
||||
&& check "compose network made under the run's project and removed after the run" 0 \
|
||||
|| check "compose network made under the run's project and removed after the run" 1
|
||||
else
|
||||
echo "skip compose network check (docker daemon unavailable)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "selftest: $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in new issue
Block a user