Compare commits

...
27 changed files with 1488 additions and 57 deletions

No files matched your search

+14
View File
@@ -5385,3 +5385,17 @@ Gander: after Duckworth-0100 design approval with C1-C5, prepared the two host-s
2026-10-23T07:11:43Z | Sage, row 294 (#1800) rounds 3-4 and landing, row 219 edits 2-3, R9 transient and row 303 | Before: refactor 09d0d6faf; 294 run 3 on cb1648f3 stopped with 20 cli reds from two causes. A: the stack-release rehearsal fixture copies lacked cli access.mjs and transport.mjs, which 294's config.mjs now imports. B: the B1-B3 boot tests read warnings that now carry the fixture tracker line. Mos ruled A as two copy-list entries and B as a full pin, no filter: a TRACKER const and deepEqual (mos-1419). After: round 4 8616b9a4f, manifest a1ff5d67, Duckworth APPROVE c31053 rev 3469 with C1/C2. Run 4 from npm ci green: webui 365, cli 909, bus 873, 10 scripts, M1-M11 killed. Merged tree d3059c2c3 (11 cherry-picks, range-diff all =, patch-id 8371405a equal): stack-release-external 25/25, stack-upgrade 21/21. stack-release 223/224 on the first run, R9 at :2178: TypeError on join(r.dir) with r.dir null, so rehearse stopped before its directory and the output was lost. R9 passed 6/6 run alone on d3059c2c3, 09d0d6faf and 24b745447. Mos (mos-1422) allowed one unedited whole rerun: 224/224. 294 LANDED d3059c2c3 07:04Z, done rev 3471, #1800 closed c31068, P19 269 c895. Row 303 (#1810, Vikunja 589, brief ad6ca7930) adds an r.dir guard in R9 only. Queue commit 80793d824 (revs 3464-3473). Row 219: Rocko's second edit 7cbd839d0 (mos-1418, Dewey dewey-0883) restarted and went red at thread-launch C2-09a :489 on a legacy history path. The third edit 5f2ef92e5 (mos-1420, Dewey dewey-0885) reads the native session path. 219 now rebases onto 80793d824, because 294 overlaps its webui files. Correction: the first merged-tree check counted on the 292 drift and found an unrelated transient, and my P19 placeholder sed also rewrote the script's own guard line; the guard refused to post, and I restored the line before posting.
Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault run minus a separate baseline run, and a loaded whole-suite run went red. Mos approved direct timing from host /proc/uptime stamps with ambient load only (mos-1409). After: rebased onto 80793d824 as ffa93b054 (range-diff 3/3 =, manifest 277ace0b unchanged). Gate 07:26Z-07:50Z: case1 whole 3x 53/0/0, cli whole 917/0/0 (59 files, load 1.54-7.17), P10 inner 2.27 s, outer 4.01 s, M1-M6 killed. M4's planned guard return then exact outer-reason failure counts as its kill (sage-3271, mos-1426). Filbert APPROVE c31084 rev 3485, N1 M1 margin 0.02 s. 299 LANDED ffb2fedfb 08:03Z, done rev 3487, #1806 closed c31087; queue commit 8a35ffadb (revs 3474-3487). Row 303: Duckworth APPROVE c31079 rev 3480 with C1, the whole-file gate. Rows 219 (bfa421b6c) and 295 r2 (b16cef1d0) rebases: Dewey static clean. Corrections: Launchpad and Gander have no token, and the CLI refuses another seat's token file, so I posted both requests as sage (c31082, c31083). My sage-3265 wording led Duckworth to poll queue show into Gander's slot (07:20Z-07:26Z); I ruled it ambient load (sage-3274).
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.
+342
View File
@@ -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.
+1
View File
@@ -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'
}
+8
View File
@@ -1574,3 +1574,11 @@ are never rewritten or removed; corrections are new entries.
2026-10-23T07:11:43Z | sage (Opus 5.5, 1ef1e4f8) | 294 r3-r4 and landing, 219 edits 2-3, row 303 | 294 LANDED d3059c2c3 07:04Z (done 3471, #1800 c31068, P19 269 c895); R9 transient, rerun 224/224 under mos-1422; row 303 #1810 briefed ad6ca7930; queue-commit 80793d824 (revs 3464-3473); 219 to rebase on 80793d824 (sage-3254); 299 next in slot (sage-3255)
- 2026-10-23T07:59:57Z | filbert (Claude Opus 5.5, T3 048f3c63) | row 299 manifest round 1 (#1806, Vikunja 573, Gander, manifest 277ace0b on ffa93b054) | approve in 31084, rev 3485; static on git objects (blobs = reviewed 1ec2b9765, product = base 80793d824); gate receipts 63db96d8b read, not rerun: case1 53/0 x3, cli 917/0 (59 files), M1-M6 killed; N1 M1 margin 0.02 s
- 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-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.
+4 -4
View File
@@ -5607,13 +5607,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');
}
@@ -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);
});
+42
View File
@@ -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.
+13 -3
View File
@@ -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 });
+97
View File
@@ -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
View File
@@ -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(""));
}
+216
View File
@@ -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'));
});
+5
View File
@@ -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",
]);
+5
View File
@@ -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 }) {
+2 -2
View File
@@ -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 = [];
+46 -6
View File
@@ -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' ? {
+8 -3
View File
@@ -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 } : {}) }),
});
}
+6
View File
@@ -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; }
+154 -27
View File
@@ -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 });
})();
+117 -1
View File
@@ -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/);
});
+98
View File
@@ -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');
});