merge: combine row 219 with landed row 295
This commit is contained in:
commit
054da642f9
112 files changed
+13616
-46
No files matched your search
@@ -0,0 +1,342 @@
|
||||
# Row 219 / C2-09a: one thread per creative instance
|
||||
|
||||
Assignment sage-2793, base aca16404c; branch rocko/row219, own worktree
|
||||
/mnt/storage/scratch/rocko-r219. Claim waits for Sage's gap. No Node, tests,
|
||||
HTTPS git or push while Gander's row253 gate holds the slot.
|
||||
|
||||
Read both C2-09a and C2-09b briefs at aca16404c and 51cc0445c: the two
|
||||
sections are byte-identical. The newer brief changes C2-06's reply limits
|
||||
under Q222, not C2-09a. This row introduces no M5 limit, writer or setting.
|
||||
Row265 rooms chains on this row and stays separate.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
Broker: replace assistant-only #threaded with THREAD_DEFINITIONS membership.
|
||||
thread.create gains optional instance, validates it as a declared thread
|
||||
instance, and binds both the thread and default session to it. Omitted
|
||||
instance retains single-instance fallback and ambiguous/missing refusals.
|
||||
User grants must still cover the selected instance and project before
|
||||
project lookup; an explicit selector cannot bypass FED R3. Existing tester
|
||||
ownership and per-thread/per-session holder checks remain.
|
||||
|
||||
Launcher: import shared THREAD_DEFINITIONS and replace its two assistant
|
||||
checks (check and ending), preserving launch admission, quotas, session
|
||||
isolation and stop policy. No runtime.mjs, bundle or library adapter change;
|
||||
Della row224 was notified of these narrow launcher hunks.
|
||||
|
||||
Console: actual shipped new-thread UI is in public/chat.js, with transport
|
||||
mapping in chat.mjs and HTTP validation in chat-http.mjs, not public/bus.js.
|
||||
These three files need Sage's scope addition before their edits. Proposed
|
||||
metadata read GET /api/chat/instances in serve.mjs uses the existing
|
||||
bus.instances read, filters using shared THREAD_DEFINITIONS, and returns
|
||||
only {instance,name}, with vars['persona.name'] or instance as the label.
|
||||
It adds no channel operation or bus verb; send still traverses createChat
|
||||
and the existing channel dispatch. The new select sends optional instance
|
||||
through chat-http validation and chat.mjs. It is disabled when metadata
|
||||
cannot be read, so the UI never silently chooses between agents.
|
||||
|
||||
## Acceptance evidence to build (runs await CLEAR)
|
||||
|
||||
Bus: explicit creative selection binds the requested thread and default
|
||||
session; omitted selector preserves assistant compatibility, missing and
|
||||
ambiguous cases. Unknown/non-thread/malformed selectors refuse without
|
||||
new threads; user grants cover only the selected instance, with prior
|
||||
non-disclosure order intact. Real creative role definition messaging still
|
||||
holds. A creative thread claims/receives independently of its peer.
|
||||
|
||||
CLI: real fixture launcher starts a creative thread on pending input and
|
||||
keeps peer thread/session stores separate. Direct non-thread launch of a
|
||||
creative instance refuses thread-required. Existing assistant tests remain.
|
||||
|
||||
WebUI: metadata lists thread definitions only with persona/name labels;
|
||||
selection survives refresh and reaches thread.create exactly. Malformed
|
||||
instance fails before a write, old omitted instance still works. Browser
|
||||
asserts two creative options, inert hostile labels and read-only disablement.
|
||||
Keep all identity/origin/content/SSO fences. Named mutants cover each new
|
||||
acceptance guard; no runs until Sage's announced gap/CLEAR.
|
||||
|
||||
Before review: grep source readers/hash pins, update only pins required by
|
||||
owned changes, rebase onto row253 landing under a new branch, then rerun
|
||||
touched files on the new tree. No schema, role-policy, live or canonical
|
||||
product-path change. Handoff directly to Dewey, Mos and Sage when ready.
|
||||
|
||||
## Scope ruling and picker design before UI edits
|
||||
|
||||
Sage-2807 adds public/chat.js, chat.mjs and chat-http.mjs because the shipped
|
||||
form is chat.js:55/235, its forwarding is chat.mjs:23, and POST validation is
|
||||
chat-http.mjs:80-82. bus.js stays byte-identical. The new route sits after
|
||||
existing auth/session checks and uses startServer's own bus, so the tester
|
||||
socket's separately supplied testerBus is used too (Dewey U1). No new bus or
|
||||
channel operation. The route projects only instance/name and keeps the
|
||||
THREAD_DEFINITIONS filter server-side, rather than making the browser read
|
||||
all /api/bus/instances vars (Dewey route note).
|
||||
|
||||
Dewey-0849 U2-U5: multiple choices start with a required placeholder; exactly
|
||||
one starts chosen and is sent explicitly; none disables Create and says no
|
||||
thread instances are configured. Failed metadata reads disable Create with
|
||||
"Couldn't read the thread instances; reload to try again" and recover on a
|
||||
good read. Labels show persona name plus instance key when different. Picks
|
||||
live in form(key); a removed pick is cleared with a status and never moved
|
||||
to another option, even if only one remains. Repaints retain selection.
|
||||
U6 is deferred: thread-list/header still show their existing instance keys.
|
||||
The shared sign-out/redirect handling and strict HTTP write key validation
|
||||
remain in force. Add unauthenticated metadata and selected-instance user
|
||||
grant refusal tests as Sage requires.
|
||||
|
||||
## Existing fixture adaptations (unrun)
|
||||
|
||||
C2-08 creative.test's setup previously bound creative roles without threads
|
||||
and sent human input with message.send. This row makes those roles threaded:
|
||||
setup now selects assistant explicitly, creates each creative instance's
|
||||
thread and binds its claim there; the human uses thread.send. Existing
|
||||
M6/messaging refusal and count assertions stay in place. The user-principal
|
||||
fixture gains optional extra threadRoles for the selected-grant regression.
|
||||
|
||||
The browser fixture now answers /api/chat/instances with its one assistant.
|
||||
Its existing create receipt assertion gains instance:'assistant', since the
|
||||
new picker explicitly sends its single preselected choice. No existing
|
||||
assertion is dropped. New tests cover multiple/empty/failed metadata,
|
||||
duplicate hostile labels, selected-instance POST, refresh and disappearing
|
||||
choices. No Node or test has been run under Gander's hold.
|
||||
|
||||
The existing launcher.test C2-08 persona-mismatch regression cannot keep
|
||||
requesting a standalone creative launch: C2-09a now correctly refuses it
|
||||
thread-required before bundle construction. Its trigger is therefore the
|
||||
thread pass, with an injected host threadsState snapshot (the persona
|
||||
mismatch occurs before any bind). The exact mismatch reason, no persona
|
||||
content leak, one refusal and no running session assertions remain. The
|
||||
old per-run `['session']` directory assertion changes to `[]` and gains an
|
||||
assertion for the thread-owned session directory, matching row121's actual
|
||||
thread-store layout. A new e.thread assertion pins the path. This is a
|
||||
fixture/expected-layout adaptation, not a product exception for that test.
|
||||
|
||||
## Old-test approval: mos-1316
|
||||
|
||||
Mos approved the three adaptations in OLD-TEST-CHANGES.md (sha256
|
||||
9a1e51e6b43040593a3dd517c4a0c71f5ac42b6b57d66d474cd0517f06e97ed6)
|
||||
and old-persona-test.diff (sha256
|
||||
6aaca9820b692ec659058e9b044f7f60872cdecb16fe3ac1321c2c0ed530701d).
|
||||
The source commit must name mos-1316 and carry OLD-TEST-CHANGES.md.
|
||||
Dewey checks each changed line against that record.
|
||||
|
||||
Keep the new thread-launch test's fromSession(chief.cap, 'bean') refusal
|
||||
(thread-required), and the threads test's direct human message.send refusal
|
||||
for each creative definition (thread-required-use-thread-send). The former
|
||||
is sandbox-dependent: its gate record must show it ran without a skip;
|
||||
otherwise report to Mos rather than claiming the pin. T06 restores the old
|
||||
assistant-only launcher check; T22 restores the ending check separately.
|
||||
The persona mismatch fixture wraps only threadsState; all other host ops
|
||||
remain real. Under mos-1272, a first-run red in launcher.test.mjs,
|
||||
creative.test.mjs or chat-browser.test.mjs goes to Mos before any fix.
|
||||
No run has occurred as of this approval record.
|
||||
|
||||
### mos-1317: always-running standalone admission pin
|
||||
|
||||
Added a separate new test next to the assistant standalone refusal:
|
||||
`C2-09a: fromSession refuses a creative instance outside a thread without a sandbox`.
|
||||
It has no sandbox skip. The real fixture host binds/claims the chief, and
|
||||
fromSession must refuse Bean with thread-required before bundle/sandbox
|
||||
construction. The existing sandbox-dependent pin remains unchanged.
|
||||
T23 restores both launcher assistant-only comparisons and targets this new
|
||||
exact refusal assertion. T06 and T22 remain as separate launch/ending checks:
|
||||
23 mutants total. No tests run; slot scope amendment sent to Sage.
|
||||
Keep the Mos-approved threadsState wrap as the only persona-test host override.
|
||||
|
||||
## Inspection candidate under sage-3113 (2026-10-23)
|
||||
|
||||
Explicitly UNVERIFIED. Sage authorized a local inspection commit before
|
||||
runs; no claim of review readiness or green tests. Base remains aca16404c,
|
||||
branch rocko/row219. No rebase has been performed. No Node, browser, test,
|
||||
mutant or suite has run; the earlier gate holds and subsequent row219 hold
|
||||
were honored. Static Python exact-once preparation named 23 mutant sites;
|
||||
git diff --check passes. The mutant execution runner is not yet written.
|
||||
|
||||
14 source/test paths: six product files and eight whole test files. The
|
||||
candidate manifest excludes this packet. The new no-sandbox fromSession
|
||||
Bean test and T23 record mos-1317; old test adaptations stay governed by
|
||||
mos-1316 and OLD-TEST-CHANGES.md. First-run reds in launcher, creative and
|
||||
chat-browser must go to Mos before any fix. Run evidence must prove the
|
||||
sandbox-dependent pin ran, or report its skip to Mos.
|
||||
|
||||
Remaining: Sage-directed rebase to current landing base/new branch; slot
|
||||
CLEAR and claim; eight whole test files and 23 mutants with process-group
|
||||
cleanup; path/hash-reader sweep, acceptance evidence and final review packet.
|
||||
No product behavior is verified. Push waits for Sage's named gap; reviewers
|
||||
and dependent rows may inspect this commit but must not treat it as approved.
|
||||
|
||||
## Rebase under sage-3127 / exact resolution sage-3128
|
||||
|
||||
Rebased the unverified c4b4fab98 inspection candidate from aca16404c onto
|
||||
062646487451993302c768c6812b827c834e952b. The only conflict was broker.mjs's
|
||||
thread.create user guard: row206 added verb-sensitive #granted(s, verb,
|
||||
project, instance). Sage-3128 supplied the exact resolution: retain the
|
||||
selected-instance validation and call #granted(s, 'thread.create',
|
||||
identifier(a.project), instance). No guessed conflict resolution. The merged
|
||||
broker has no remaining three-argument #granted(s, ...) call (static balanced
|
||||
argument scan). T05 now matches that four-argument call and substitutes null
|
||||
only for instance; all 23 mutant old anchors, including T23's second edit,
|
||||
remain exact-once. Rebuild manifest/base/patch on this base. Other auto-merges
|
||||
were user-principal.test, launcher and thread-launch.test. No tests, Node,
|
||||
mutants or reader audit run: CLEAR still required after 574, covering every
|
||||
touched test file whole. This branch remains UNVERIFIED.
|
||||
|
||||
## Full gate preparation: sage-3174
|
||||
|
||||
Clean rebase onto 4a9c536e861f1d9f9a018019b3ac4519d65eccf0, after rows270+271.
|
||||
No conflicts or manual product changes. All 23 anchors remain exact-once,
|
||||
including T05 and T23's second edit. All 14 candidate hashes rebuilt.
|
||||
|
||||
Before first run: eight files whole, then bus/cli/webui whole, then all ten
|
||||
scripts/test-*.sh, then 23 mutants, then reader audit with positive control.
|
||||
Singles timeout 300s; suites 1800s; mutants 180s, each in its own process
|
||||
group, TERM then KILL after 10s and restore in finally. Every stage gets a
|
||||
fresh short TMPDIR under /mnt/storage/scratch/tmp/r*. DOCKER_HOST is dead for
|
||||
Node runs; shell fixtures may use Docker, with project rocko-r219-gate and
|
||||
owned network cleanup in finally. Live provider cases are not enabled.
|
||||
|
||||
Each of the ten shell files uses `timeout -k 10 3600`, comfortably above 3x
|
||||
the longest entire earlier observed builder gate (roughly 25min -> 75min
|
||||
would exceed 3600; use 7200 seconds instead below). To avoid relying on
|
||||
old skip-fast samples, the actual shell command is timeout -k 10 7200,
|
||||
more than 3x that 25-minute conservative baseline for any individual shell.
|
||||
No shell run silently retries. Log each skip verbatim. Initial timings are
|
||||
in historical queue114/r1/out/full/gate-results.json (old task run skipped
|
||||
live cases, so not used as the Docker worst-case estimate).
|
||||
|
||||
Mos1316/1317 first-run launcher/creative/chat-browser reds, any old-test red
|
||||
or a second red stop before fixes and go to Sage/Mos. No run evidence yet.
|
||||
|
||||
## First red and approved test-only correction: mos-1408 / sage-3178-3179
|
||||
|
||||
Run c7bf3622d stopped at launcher.test's row213 persona-mismatch assertion:
|
||||
04:07:22.895-04:07:51.870Z, creative 4/0/0, threads 19/0/0,
|
||||
user-principal 23/0/0, launcher 33/1/0: total 79 pass, 1 fail, 0 skip.
|
||||
The approved threadsState snapshot had no session; row269's session-required
|
||||
admission fired before bundle/persona reading. Original logs remain in
|
||||
run-4a9c/ and derived-counts-first-stop.json reads their default spec output.
|
||||
No retry or fix occurred before Mos's ruling. Slot returned after empty
|
||||
Node/Chromium cwd sweep and all 14 source hashes checked.
|
||||
|
||||
Mos-1408 permits only an explicit SESSION in that snapshot and literal-path
|
||||
assertions: the native threads/acme/<thread>/sessions/<SESSION>/session exists,
|
||||
and the legacy threads/acme/<thread>/session does not. No sessionStore() call
|
||||
in the assertion: it creates directories and could manufacture the evidence.
|
||||
Every other assertion stays exactly as before; no second host op is wrapped.
|
||||
No product change. Runner explicitly selects --test-reporter=tap (packet-only)
|
||||
and uses new run-4a9c-mos1408/ output, preserving the failed first attempt.
|
||||
The next CLEAR reruns from the beginning: whole files, suites, shells, all
|
||||
23 mutants, reader audit. Any red or second edit must return to Sage/Mos.
|
||||
|
||||
Sage-3179: commit/push during the current gap; hold re-claim until our own
|
||||
slot, after 292 then 294. No tests or queue writes under another seat's slot.
|
||||
|
||||
## Restart scope: sage-3224
|
||||
|
||||
Run HEAD b97a3ff97, source/test manifest a973912f, unchanged. Start every
|
||||
stage from the beginning; no prior passing test carry. Sage-3224 explicitly
|
||||
requires timeout -k 10 per stage, so the packet driver now wraps each Node
|
||||
stage in that command too (shell stages already had it); the existing process
|
||||
group timeout/cleanup remains a second bound. No source or test edit.
|
||||
Singles 300s, suites 1800s, shells 7200s, mutants 180s, TERM-to-KILL 10s.
|
||||
Fresh short stage TMPDIR. Explicit TAP. Separate run-4a9c-mos1408 output.
|
||||
|
||||
## Second old-test edit: mos-1418 / sage-3232 (no restart yet)
|
||||
|
||||
The b97a restart at 05:48:57.418-05:49:26.296Z stopped at row213 again:
|
||||
creative4/0/0, threads19/0/0, user-principal23/0/0, launcher33/1/0.
|
||||
Original TAP is run-4a9c-mos1408/single-cli-launcher.test.tap. Row270 now
|
||||
records compactions before the persona refusal: captured tuple was
|
||||
['compactions',undefined,2], not the full compactions object. Static inference
|
||||
of count0/known true/reason null was explicitly identified as inference.
|
||||
No edit or rerun occurred before Mos's approval; slot returned after empty
|
||||
cwd sweep and 14-hash check. Remaining stages unrun.
|
||||
|
||||
Mos-1418 permits this exact second edit: move run-directory lookup above the
|
||||
pin, require exactly two rows, deepEqual row0 minus at to event compactions,
|
||||
instance bean, thread, SESSION, run runs[0], count0, known true, reason null;
|
||||
then take e from row1 and require refused/launch-failed. Later e.thread,
|
||||
e.why, content nonleak, no sessions, one run, empty run directory and native/
|
||||
legacy store assertions stay unchanged. Only the specified comment changes.
|
||||
No product bytes change. If inference is wrong, report red, no third edit.
|
||||
Dewey must inspect this test diff before the next full restart. Local commit
|
||||
only while294 holds; push waits for Sage's named gap. No runs after this edit.
|
||||
|
||||
## Third edit and new manifest round r2: mos-1420 / sage-3243
|
||||
|
||||
Full restart on7cbd839d0 began06:09:43.706Z and stopped06:10:30.695Z:
|
||||
creative4/0/0, threads19/0/0, user-principal23/0/0, launcher34/0/0,
|
||||
thread-launch13/1/0, total93 pass/1 fail/0 skip. Persona's exact two-row pin
|
||||
passed. The new creative-history test failed ENOENT on its obsolete legacy
|
||||
history path, after its real send/reply checks. Always-running Bean refusal
|
||||
passed; sandbox-dependent Bean assertion followed the failed read and was
|
||||
not reached. No coverage claim for that assertion. Slot returned after empty
|
||||
cwd sweep and14 hashes checked. Original earlier red output was archived
|
||||
byte-identically before this restart; all later stages unrun.
|
||||
|
||||
Mos-1420 authorizes exactly the two history-path corrections in that new
|
||||
test: obtain each default session from the real runtime threadsState and
|
||||
read the literal threads/acme/T/sessions/<session>/session/history path.
|
||||
Contents remain exactly Bean input\nBean again\n and Chet input\n.
|
||||
Add one literal legacy-path nonexistence assertion per thread, no mkdir
|
||||
helper. Every other file line stays unchanged. No product change.
|
||||
The new r2/ manifest/patch snapshot is this third-edit round; root manifest
|
||||
is refreshed to the same digest for the next claimant/review handoff.
|
||||
Local commit only; Dewey checks mos1420 items1-3 before push/restart, and
|
||||
Sage names a gap/CLEAR after294 round4. No runs after this edit. Any further
|
||||
red, including later lines in this test, returns to Mos through Sage.
|
||||
|
||||
## Rebase round r3 after row294: sage-3250 / sage-3254
|
||||
|
||||
Rebased five commits from4a9c536e8..5f2ef92e5 onto
|
||||
80793d824e05baaef4820f4be7790aba55971b6f, new branch
|
||||
rocko/row219-on-80793d824. No conflicts and no manual product edits.
|
||||
Row294's shared chat-http status refusals, chat owner-only channel-settings
|
||||
transport, and serve's user rebinding/route fencing/static federation assets
|
||||
remain. Row219 still forwards the selected instance over the bound caller;
|
||||
its metadata route follows the listener/session bus after row294 rebinding.
|
||||
|
||||
r3/range-diff.txt records all five pairs: four are '='; the first is '!'
|
||||
only because createChat's surrounding signature now includes owner=null.
|
||||
No hunk body changed. Per-path patch IDs and row294 carry delta IDs are in
|
||||
r3/path-patch-ids.json and r3/294-carry-patch-ids.json; conflict list is empty.
|
||||
r3/overlap-readers.json lists exact source readers and whole tests via their
|
||||
test-helper import closure, refreshed on this base. Root and r3 manifest,
|
||||
base and build.patch are refreshed. Old r2 packet is historical and unchanged.
|
||||
|
||||
Dewey's checked mos1418 and mos1420 test edits stand; this handoff requests
|
||||
rebase-only static review. No tests, Node, mutants, reader gate audit or push
|
||||
performed while preparing this local rebase. Full no-carry restart waits for
|
||||
Sage's CLEAR. Any new red returns to Mos through Sage before fixes.
|
||||
|
||||
## Round r4: mos-1429 / sage-3291 old-test corrections, no runs yet
|
||||
|
||||
bfa421 restart began08:08:37.137Z; eight whole files137/0/0, then bus
|
||||
873/2/1 stopped08:10:07.389Z. Old failures: creative ops bound without a
|
||||
thread in grant-executor's sage1908 test; project-runtime exact import pin
|
||||
still singular THREAD_DEFINITION. Skip X-G3: no Docker/local Gitea image,
|
||||
never pulls (Node stages used a dead Docker socket). No later stages ran.
|
||||
Both Bean pins ran in the green thread-launch14/14; persona pin green34/34.
|
||||
Returned slot with empty cwd sweep and14 hashes intact. No edit/retry before
|
||||
Mos1429 approved the old-test changes. Original logs remain recorded.
|
||||
|
||||
Only test changes: create a real project/thread on the restarted broker,
|
||||
bind creative ops with it, preserve the host.run/refusal/request-count/sudo/
|
||||
record and restart lines byte-for-byte. Exact import pin singular->plural;
|
||||
add one row219 comment, preserve all doesNotMatch checks. No product edit.
|
||||
r4/assertion-preservation.json stores old/new text and preserved exact lines.
|
||||
|
||||
No pre-existing bus test bound a creative role without a thread: condition6
|
||||
requires the new test in threads.test, covering all six creative definitions.
|
||||
T01 retargets the old assistant-only #threaded mutant to this direct broker
|
||||
pin; T24 drops only #hostAccess's creative guard and must fail the corrected
|
||||
sage1908 assertion with ERR_ASSERTION. 24 mutants total;16 source/test paths,
|
||||
10 whole changed test files. Source paths are the previous14 plus the two
|
||||
old test files. New manifest round r4 and root snapshots match.
|
||||
|
||||
Short slot plan: focused red sage1908 + exact import pin on detached bfa421,
|
||||
then three whole green files (grant-executor, project-runtime, threads),
|
||||
then T24 on fixed bytes (ERR_ASSERTION required), restore/hash/sweep and
|
||||
push only after authorized. Estimate3-5min. No tests/mutants/Node/push until
|
||||
Sage CLEAR. Dewey delta review follows; later full no-carry restart includes
|
||||
all10 changed files, bus/cli/webui, all10 shells, all24 mutants and reader
|
||||
control/audit. Further old-test red stops and goes to Mos through Sage.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Old-test changes awaiting Mos under mos-1272 (sage-2822)
|
||||
|
||||
No test has run. Row219's product patch is uncommitted on rocko/row219,
|
||||
aca16404c, /mnt/storage/scratch/rocko-r219.
|
||||
|
||||
## Persona-mismatch regression: launcher.test.mjs
|
||||
|
||||
Name: `row 213 a creative instance's persona is read from <configDir>/personas:
|
||||
off its digest the launch fails with the mismatch as its reason`.
|
||||
|
||||
Old trigger: bind/claim a chief, then
|
||||
`assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });`.
|
||||
|
||||
New trigger: fixture-wrap only `host.op({op:'threadsState'})` to report one
|
||||
pending Bean thread, then `await launcher.tick()`. No fake grant, bind,
|
||||
writer or persona reader. The same actual persona bundle check fails before
|
||||
any thread run binds. All other host operations use the real fixture host.
|
||||
|
||||
Why: C2-08 declares companion and the shared THREAD_DEFINITIONS. Row219 now
|
||||
uses that list in check(). Bean's companion definition is therefore
|
||||
threaded, and fromSession's standalone request refuses thread-required
|
||||
before start()/buildBundle can read the persona. Keeping that trigger would
|
||||
stop testing the persona check. No product exemption is added.
|
||||
|
||||
Preserved assertions: exactly one refusal; event/code = refused/launch-failed;
|
||||
exact persona-mismatch reason including the fixture persona path; no
|
||||
PERSONA-MARKER-d2 in logs; no running sessions; exactly one failed run dir.
|
||||
The direct fromSession return assertion is replaced by a thread-id assertion
|
||||
on the same refusal record. Both prove the refusal at the appropriate entry.
|
||||
|
||||
Old layout assertion:
|
||||
`assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);`
|
||||
|
||||
New layout assertions:
|
||||
`assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);`
|
||||
`assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);`
|
||||
|
||||
This matches row121: a thread uses its thread-owned store, never a per-run
|
||||
session directory. The failed persona build still writes no bundle files.
|
||||
Exact diff: old-persona-test.diff.
|
||||
|
||||
## Other existing-test adaptations disclosed with it
|
||||
|
||||
- bus creative.test setup now explicitly chooses assistant, creates Bean and
|
||||
Quill threads, and binds those runs with their own thread ids. The existing
|
||||
human `message.send {to:who}` input becomes `thread.send {thread:threads[who]}`.
|
||||
All original M6 decision, messaging refusal and message-count assertions
|
||||
remain unchanged. This lets those same restrictions run on the now-threaded
|
||||
creative roles; direct human messages are correctly thread-required.
|
||||
- webui chat-browser.test fixture adds /api/chat/instances metadata. Its old
|
||||
expected create body `{project:P,name:'named thread'}` becomes
|
||||
`{project:P,name:'named thread',instance:'assistant'}` because the one-choice
|
||||
picker explicitly sends its preselected instance (Dewey U2). Every other
|
||||
existing assertion remains. The additional expected key strengthens the
|
||||
forwarding check and matches the requested UI behavior.
|
||||
|
||||
Please approve these adaptations before their results count. The builder
|
||||
continues static packet preparation only until Sage's test CLEAR.
|
||||
@@ -0,0 +1,9 @@
|
||||
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/cwd-sweep-final.json
|
||||
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/cwd-sweep.json
|
||||
4c7009747c39843d6666ca54bb56d0f2bf991ab81bb514fa6d45053b8f6e4b92 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/head.json
|
||||
e86cfe9d1e28b8b42e9ed47c1a5836e4f9f09e700dc892935d55092785ece429 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/network-cleanup.log
|
||||
4fd1875a14b5da6e55effa090416b39529e5e00eb54660c2574727624d97ec4d agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/results.jsonl
|
||||
4898d4ece63c8dcad3346c0a661087531b8b9c5471b6c49a023c4eede65726cb agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-bus-creative.test.tap
|
||||
0d7e7b8a83c2d6441fe76403615839b26c0863b09e536ee77bcdafa62dd43948 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-bus-threads.test.tap
|
||||
1d4bc4439cfd51773a66efd88d22fe1d6bb826c7511a2b1b92e5040ebc293448 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-bus-user-principal.test.tap
|
||||
ededb8288ffdb04001aa39bc77d29f7f7ea9ff6cf57f814ea2bd743b4f9fbc8d agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1418/single-cli-launcher.test.tap
|
||||
@@ -0,0 +1,10 @@
|
||||
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/cwd-sweep-final.json
|
||||
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/cwd-sweep.json
|
||||
e74678a27192b58709b8afa57536678fe295e99b8b8572d0e95a131ae9b50467 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/head.json
|
||||
e86cfe9d1e28b8b42e9ed47c1a5836e4f9f09e700dc892935d55092785ece429 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/network-cleanup.log
|
||||
43cdf39a98eb06d613c9582af1e97e7af472f20e01a1220aecbac456d795e1de agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/results.jsonl
|
||||
135b478b49a54826c98988b53d8f80363a94926db70862f2af1abfff51f7874b agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-bus-creative.test.tap
|
||||
25d69420f68d14affbf4c82b8018af46570b6110c4918fa4847010386ffdd6b2 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-bus-threads.test.tap
|
||||
742258172b7784d3d4fe3016d8917aa85d9b67c46b538bd35e75a2e7f8c4f419 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-bus-user-principal.test.tap
|
||||
0cbcf144198e971d8d41e3b0a3d5a80354333043316f2f1381ccd0f79bb92ac3 agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-cli-launcher.test.tap
|
||||
cb8b0f9dd17acc515ad23f39c1a32e4a2fb021a8e87ccebc442e9e8d424d967e agents/rocko/work/queue-219/run-4a9c-mos1408-red-before-mos1420/single-cli-thread-launch.test.tap
|
||||
@@ -0,0 +1 @@
|
||||
80793d824e05baaef4820f4be7790aba55971b6f
|
||||
@@ -0,0 +1,832 @@
|
||||
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
||||
index d76085739..11061c778 100644
|
||||
--- a/packages/bus/src/broker.mjs
|
||||
+++ b/packages/bus/src/broker.mjs
|
||||
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
@@ -598,7 +598,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#threaded(business, role) {
|
||||
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
|
||||
}
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
@@ -6068,24 +6068,27 @@ export class Broker {
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
}
|
||||
- // A thread belongs to the user who made it and to the business's one thread instance.
|
||||
+ // A thread belongs to the user who made it and to one declared thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
- keys(a, ['project', 'name'], ['project', 'name']);
|
||||
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
+ const instance = a.instance === undefined
|
||||
+ ? (instances.length === 1 ? instances[0] : null)
|
||||
+ : identifier(a.instance);
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
|
||||
+ fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
- // The bus never picks between instances; the business file declares exactly one.
|
||||
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (!instances.length) fail('thread-instance-missing');
|
||||
- if (instances.length > 1) fail('thread-instance-ambiguous');
|
||||
+ // The human selects an instance; omission is compatible only with one candidate.
|
||||
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -6094,13 +6097,13 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.project,
|
||||
- instances[0],
|
||||
+ instance,
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
- return { id: tid, instance: instances[0] };
|
||||
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
|
||||
+ return { id: tid, instance };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
|
||||
index 6cfbf1cd6..6d421ba19 100644
|
||||
--- a/packages/bus/tests/creative.test.mjs
|
||||
+++ b/packages/bus/tests/creative.test.mjs
|
||||
@@ -44,15 +44,18 @@ function setup(t) {
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
||||
+ const threads = { assistant: thread };
|
||||
for (const role of Object.keys(roles)) {
|
||||
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
+ if (['bean', 'quill'].includes(role))
|
||||
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
||||
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
- return { call, count, store };
|
||||
+ return { call, count, store, threads };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
- const { call, count } = setup(t);
|
||||
+ const { call, count, threads } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
- call('human', 'message.send', { to: who, body: 'hello' });
|
||||
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
||||
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
|
||||
index e449b7d77..46235d16b 100644
|
||||
--- a/packages/bus/tests/grant-executor.test.mjs
|
||||
+++ b/packages/bus/tests/grant-executor.test.mjs
|
||||
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
|
||||
const f = await setup(t);
|
||||
assert.equal((await f.approve()).end.action, 'grant.applied');
|
||||
const r = await restart(t, f, creativeOps(f.business));
|
||||
- const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
|
||||
+ const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
+ const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
|
||||
+ const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
|
||||
+ const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
|
||||
r.broker.request(cap, { verb: 'role.claim' });
|
||||
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
|
||||
const before = f.requests().length;
|
||||
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
|
||||
index a6086e56c..7275ad902 100644
|
||||
--- a/packages/bus/tests/project-runtime.test.mjs
|
||||
+++ b/packages/bus/tests/project-runtime.test.mjs
|
||||
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
+ // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
|
||||
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
|
||||
index 8b31da9f9..280e03184 100644
|
||||
--- a/packages/bus/tests/threads.test.mjs
|
||||
+++ b/packages/bus/tests/threads.test.mjs
|
||||
@@ -601,3 +601,56 @@ test('project.create keeps unrelated names with and without a repository', t =>
|
||||
{ id: linked.id, name: 'work', repo: 'stack' },
|
||||
]);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
|
||||
+ } }));
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
|
||||
+ for (const [instance, role] of Object.entries(extra)) {
|
||||
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
|
||||
+ assert.equal(receipt.instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
|
||||
+ ctx.launch(instance, instance, { thread: receipt.id });
|
||||
+ assert.equal(ctx.claim(instance).thread, receipt.id);
|
||||
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
|
||||
+ const read = ctx.call(instance, 'message.receive');
|
||||
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
|
||||
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
|
||||
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
|
||||
+ { code: 'thread-required-use-thread-send' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
|
||||
+ const ctx = setup(t);
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ for (const instance of ['not-declared', 'pm', 'coder'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
|
||||
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
|
||||
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
|
||||
+ assert.equal(legacy.instance, 'assistant');
|
||||
+});
|
||||
+
|
||||
+
|
||||
+test('C2-09a: creative bindLaunch without a thread refuses thread-required', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`creative${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, {}])),
|
||||
+ } }));
|
||||
+ for (const instance of Object.keys(extra)) {
|
||||
+ assert.throws(() => ctx.launch(`unthreaded-${instance}`, instance), { code: 'thread-required' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM role_claims'), 0);
|
||||
+});
|
||||
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
|
||||
index 4dcd1ebeb..8471ddf85 100644
|
||||
--- a/packages/bus/tests/user-principal.test.mjs
|
||||
+++ b/packages/bus/tests/user-principal.test.mjs
|
||||
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
|
||||
});
|
||||
|
||||
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
||||
-function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ ...threadRoles,
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
|
||||
test('R3 r4: a non-function access throws before the store is read', () => {
|
||||
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
|
||||
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project] })];
|
||||
+ ctx.user('alice');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
|
||||
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
|
||||
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
|
||||
+ assert.equal(allowed.instance, 'assistant');
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
|
||||
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
|
||||
+ assert.equal(bean.instance, 'bean');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
||||
+});
|
||||
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
||||
index bfe55b61e..d8248284c 100644
|
||||
--- a/packages/cli/src/launcher.mjs
|
||||
+++ b/packages/cli/src/launcher.mjs
|
||||
@@ -53,7 +53,7 @@
|
||||
// session.json names it as gitSocket.
|
||||
//
|
||||
// Thread sessions (row 121, #1615). A thread instance (an instance whose
|
||||
-// role definition is the assistant) runs once per thread, never on its own:
|
||||
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
|
||||
// a launch from a session or --pm refuses thread-required. Every tick
|
||||
// (threadTickMs) the host asks the broker for the business's threads
|
||||
// (launch op threadsState) and:
|
||||
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
const THREAD_TICK_MS = 2000;
|
||||
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
-const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
@@ -331,7 +330,7 @@ export function createLauncher({
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
|
||||
@@ -997,7 +996,7 @@ export function createLauncher({
|
||||
function ending(r) {
|
||||
const runs = r.runs ?? [];
|
||||
const at = Date.parse(r.at);
|
||||
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
|
||||
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
|
||||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
|
||||
}
|
||||
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
||||
index 2202c7753..baabd843e 100644
|
||||
--- a/packages/cli/tests/launcher.test.mjs
|
||||
+++ b/packages/cli/tests/launcher.test.mjs
|
||||
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
- const { f, host, launcher } = await setup(t, undefined, given);
|
||||
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
- const [e] = log(f);
|
||||
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
+ const thread = 'persona-thread';
|
||||
+ const SESSION = 'persona-session';
|
||||
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
+ ...host,
|
||||
+ op: message => message.op === 'threadsState'
|
||||
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
|
||||
+ : host.op(message),
|
||||
+ }) });
|
||||
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
|
||||
+ await launcher.tick();
|
||||
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
+ const entries = log(f);
|
||||
+ assert.equal(entries.length, 2);
|
||||
+ const { at, ...compactions } = entries[0];
|
||||
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
|
||||
+ const e = entries[1];
|
||||
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
|
||||
+ assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
||||
index 0d207a0a0..fa3adb866 100644
|
||||
--- a/packages/cli/tests/thread-launch.test.mjs
|
||||
+++ b/packages/cli/tests/thread-launch.test.mjs
|
||||
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
|
||||
});
|
||||
|
||||
+// Admission refuses before bundle construction or a sandbox is needed.
|
||||
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
|
||||
+ const prepared = prepare(t, { edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ doc.launch.instances.push('bean');
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const { f } = prepared;
|
||||
+ const host = await start(t, prepared);
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+ assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
|
||||
+});
|
||||
+
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
|
||||
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
|
||||
// bus's plug-in loader.
|
||||
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
|
||||
assert.deepEqual(threadRuns(f, A), []);
|
||||
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ const vars = { ...doc.roles.assistant.vars };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
|
||||
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
|
||||
+ doc.launch.instances = ['coder', 'bean', 'chet'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const host = await start(t, prepared), { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
|
||||
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
|
||||
+ const send = async (thread, instance, body) => {
|
||||
+ const message = host.call('thread.send', { thread, body });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ const reply = outcome;
|
||||
+ assert.equal(reply.from_role, instance);
|
||||
+ assert.equal(reply.to_role, 'human');
|
||||
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
|
||||
+ };
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
|
||||
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
|
||||
+ const launches = launchLog(f).filter(row => row.event === 'launch');
|
||||
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
|
||||
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.launch.instances = ['coder', 'bean'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ let requests = [];
|
||||
+ const host = await start(t, prepared, { ops: () => ({
|
||||
+ lifecycleRequests: () => requests,
|
||||
+ lifecycleTake: () => ({ ...requests[0], go: true }),
|
||||
+ lifecycleDone: () => { requests = []; return {}; },
|
||||
+ }) });
|
||||
+ const { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
|
||||
+ for (const thread of threads) {
|
||||
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ }
|
||||
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
|
||||
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
|
||||
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
|
||||
+ await host.launcher.tick();
|
||||
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
|
||||
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
|
||||
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
+});
|
||||
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
|
||||
index 7980c1442..63d3ca974 100644
|
||||
--- a/packages/webui/src/chat-http.mjs
|
||||
+++ b/packages/webui/src/chat-http.mjs
|
||||
@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
|
||||
action = 'createProject'; args = v;
|
||||
} else if (path === '/api/chat/threads') {
|
||||
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
|
||||
+ 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] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
|
||||
index 1b0a41dce..5a1ed8bf1 100644
|
||||
--- a/packages/webui/src/chat.mjs
|
||||
+++ b/packages/webui/src/chat.mjs
|
||||
@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
|
||||
+ 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 }),
|
||||
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
|
||||
index a6b805c1b..15066bdc6 100644
|
||||
--- a/packages/webui/src/public/chat.js
|
||||
+++ b/packages/webui/src/public/chat.js
|
||||
@@ -14,7 +14,7 @@
|
||||
return states.get(id);
|
||||
};
|
||||
const form = key => {
|
||||
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
|
||||
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
class ChatError extends Error {
|
||||
@@ -58,7 +58,11 @@
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
|
||||
+ const choices = current.instances ?? [];
|
||||
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
|
||||
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
|
||||
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
|
||||
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
|
||||
}
|
||||
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>';
|
||||
@@ -106,7 +110,7 @@
|
||||
const f = document.getElementById(keep.field.id);
|
||||
if (f && !f.disabled) {
|
||||
f.focus({ preventScroll: true });
|
||||
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -126,7 +130,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], 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 };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
@@ -147,6 +151,25 @@
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
}
|
||||
}
|
||||
+ if (kind === 'project') {
|
||||
+ try {
|
||||
+ const listed = await request('instances', undefined, signal);
|
||||
+ if (g !== generation || out()) return;
|
||||
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
|
||||
+ throw new ChatError('invalid-response');
|
||||
+ current.instances = listed.rows; current.instanceError = '';
|
||||
+ const f = form(id);
|
||||
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
|
||||
+ f.instance = ''; f.instanceChosen = true;
|
||||
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
|
||||
+ }
|
||||
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
|
||||
+ } catch (err) {
|
||||
+ if (err.name === 'AbortError' || out() || g !== generation) return;
|
||||
+ current.instances = [];
|
||||
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
|
||||
+ }
|
||||
+ }
|
||||
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
|
||||
if (kind === 'list') {
|
||||
try {
|
||||
@@ -179,7 +202,11 @@
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
|
||||
+ if (f && event.target.dataset.chatField) {
|
||||
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
+ draft[field] = event.target.value;
|
||||
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
|
||||
+ }
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
@@ -232,7 +259,8 @@
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
|
||||
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
|
||||
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
||||
index cd51a0b6e..56fcdd4e1 100644
|
||||
--- a/packages/webui/src/serve.mjs
|
||||
+++ b/packages/webui/src/serve.mjs
|
||||
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
|
||||
@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
|
||||
+ if (path === '/api/chat/instances') {
|
||||
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
|
||||
+ if (search) return json(res, 400, { error: 'invalid-request' });
|
||||
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
|
||||
+ try {
|
||||
+ const value = await bus.instances();
|
||||
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
|
||||
+ throw new BusReadError('invalid-response');
|
||||
+ const rows = Object.entries(value.instances)
|
||||
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
|
||||
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
|
||||
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
|
||||
+ return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
+ } catch (err) {
|
||||
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
+ return json(res, busStatus(code), { error: code });
|
||||
+ }
|
||||
+ }
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
|
||||
index f3eeeca96..cc3ec093d 100644
|
||||
--- a/packages/webui/tests/chat-browser.test.mjs
|
||||
+++ b/packages/webui/tests/chat-browser.test.mjs
|
||||
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
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.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
@@ -33,6 +34,7 @@ const stub = `(() => {
|
||||
window.__reads.push(path);
|
||||
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
||||
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.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
||||
await click('#chat-thread-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-send-form")');
|
||||
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
|
||||
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
||||
});
|
||||
+
|
||||
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
|
||||
+ const { b, wait, set, click, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
|
||||
+ assert.match(labels[1], /\(bean\)/);
|
||||
+ assert.match(labels[2], /\(chet\)/);
|
||||
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
|
||||
+ assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
+ await set('#chat-thread-name', 'Bean discussion');
|
||||
+ await click('#chat-thread-form button');
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
|
||||
+ await click('#chat-thread-form button');
|
||||
+ await wait('window.__writes.length===1');
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
|
||||
+ const { b, wait, set, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
|
||||
+ await refresh();
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
|
||||
+ await set('#chat-instance', 'chet');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
|
||||
+ const { b, wait } = await setup(t, '?readonly');
|
||||
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
|
||||
new file mode 100644
|
||||
index 000000000..893c38f80
|
||||
--- /dev/null
|
||||
+++ b/packages/webui/tests/chat-instances.test.mjs
|
||||
@@ -0,0 +1,87 @@
|
||||
+import test from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { request } from 'node:http';
|
||||
+import { mkdtempSync, rmSync } from 'node:fs';
|
||||
+import { join } from 'node:path';
|
||||
+import { tmpdir } from 'node:os';
|
||||
+import { startServer } from '../src/serve.mjs';
|
||||
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
+import { close } from './fixture.mjs';
|
||||
+
|
||||
+const path = '/api/chat/instances';
|
||||
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
|
||||
+const snapshot = {
|
||||
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
|
||||
+ instances: {
|
||||
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
|
||||
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
|
||||
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
|
||||
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
|
||||
+ },
|
||||
+};
|
||||
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
|
||||
+ const address = server.address(), unix = typeof address === 'string';
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
|
||||
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
|
||||
+ let text = '';
|
||||
+ res.on('data', chunk => text += chunk);
|
||||
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
|
||||
+ });
|
||||
+ req.on('error', reject); req.end();
|
||||
+ });
|
||||
+}
|
||||
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
|
||||
+ let calls = 0;
|
||||
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const refused = await get(server);
|
||||
+ assert.equal(refused.status, 401);
|
||||
+ assert.equal(calls, 0);
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
|
||||
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
|
||||
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
|
||||
+ assert.equal(calls, 1);
|
||||
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
|
||||
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok([400, 405].includes(result.status));
|
||||
+ }
|
||||
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
|
||||
+});
|
||||
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
|
||||
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
+ let reads = 0;
|
||||
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
|
||||
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const answer = await get(server);
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
|
||||
+ assert.equal(reads, 1);
|
||||
+});
|
||||
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
|
||||
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
|
||||
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok(answer.status >= 400);
|
||||
+ assert.doesNotMatch(answer.text, /private-path/);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
|
||||
+ let session = null, reads = 0;
|
||||
+ const touches = [];
|
||||
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
|
||||
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
|
||||
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const headers = { host: sso.publicHost };
|
||||
+ assert.equal((await get(server, { headers })).status, 401);
|
||||
+ assert.equal(reads, 0);
|
||||
+ session = { name: 'jason' };
|
||||
+ assert.equal((await get(server, { headers })).status, 200);
|
||||
+ assert.equal(reads, 1);
|
||||
+ assert.deepEqual(touches, [false, false]);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
|
||||
index 8c11611cf..b47ecf1a7 100644
|
||||
--- a/packages/webui/tests/chat.test.mjs
|
||||
+++ b/packages/webui/tests/chat.test.mjs
|
||||
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
|
||||
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
|
||||
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
|
||||
});
|
||||
+
|
||||
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ c.state.answer = { id: T, instance: 'bean' };
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
|
||||
+ assert.equal(answer.data.instance, 'bean');
|
||||
+});
|
||||
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
|
||||
+ assert.equal(answer.status, 400, JSON.stringify(value));
|
||||
+ }
|
||||
+ 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, []);
|
||||
+});
|
||||
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
import os,json
|
||||
busy=[]
|
||||
for p in Path('/proc').glob('[0-9]*'):
|
||||
try:
|
||||
if int(p.name)==os.getpid():continue
|
||||
comm=(p/'comm').read_text().strip()
|
||||
if not any(x in comm for x in ['node','python','bash']):continue
|
||||
args=(p/'cmdline').read_bytes().split(b'\0')
|
||||
if (comm.startswith('node') and b'--test' in args) or any(x.rsplit(b'/',1)[-1] in (b'run-mutants.py',b'gate-run.sh') for x in args):
|
||||
busy.append(dict(pid=int(p.name),cwd=str((p/'cwd').resolve()),name=comm))
|
||||
except (PermissionError,FileNotFoundError,ProcessLookupError):pass
|
||||
print('BUSY_CHECK',json.dumps(busy))
|
||||
raise SystemExit(bool(busy))
|
||||
@@ -0,0 +1,16 @@
|
||||
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
|
||||
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
|
||||
3efd0238da60e0bfcd3b25e5256b9c213085119a86a7b852b13e7a60340d3a24 packages/bus/tests/grant-executor.test.mjs
|
||||
b7ea857637947c7a9ca45c56cea47e37e06f0fd8446ea91316990d9fa7299a7a packages/bus/tests/project-runtime.test.mjs
|
||||
264d82944c53b638bf6a14cc0706cf9ba184d668211c2b9c1031cdbe88e84bb9 packages/bus/tests/threads.test.mjs
|
||||
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
|
||||
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
|
||||
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
|
||||
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
|
||||
39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
|
||||
77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
|
||||
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
|
||||
443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
|
||||
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
|
||||
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
|
||||
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,16 @@
|
||||
packages/bus/src/broker.mjs
|
||||
packages/bus/tests/creative.test.mjs
|
||||
packages/bus/tests/grant-executor.test.mjs
|
||||
packages/bus/tests/project-runtime.test.mjs
|
||||
packages/bus/tests/threads.test.mjs
|
||||
packages/bus/tests/user-principal.test.mjs
|
||||
packages/cli/src/launcher.mjs
|
||||
packages/cli/tests/launcher.test.mjs
|
||||
packages/cli/tests/thread-launch.test.mjs
|
||||
packages/webui/src/chat-http.mjs
|
||||
packages/webui/src/chat.mjs
|
||||
packages/webui/src/public/chat.js
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/chat-browser.test.mjs
|
||||
packages/webui/tests/chat-instances.test.mjs
|
||||
packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,201 @@
|
||||
[
|
||||
{
|
||||
"id": "T01",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);",
|
||||
"new": "return this.#business(business).roles[role]?.definition === 'assistant';",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: creative bindLaunch without a thread"
|
||||
},
|
||||
{
|
||||
"id": "T02",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": ": identifier(a.instance);",
|
||||
"new": ": instances[0];",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: explicit creative"
|
||||
},
|
||||
{
|
||||
"id": "T03",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');",
|
||||
"new": "if (!instances.length) fail('thread-instance-missing');",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: explicit missing"
|
||||
},
|
||||
{
|
||||
"id": "T04",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');",
|
||||
"new": "if (false) fail('thread-instance-ambiguous');",
|
||||
"test": "packages/bus/tests/threads.test.mjs",
|
||||
"pattern": "C2-09a: explicit creative"
|
||||
},
|
||||
{
|
||||
"id": "T05",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "!this.#granted(s, 'thread.create', identifier(a.project), instance)",
|
||||
"new": "!this.#granted(s, 'thread.create', identifier(a.project), null)",
|
||||
"test": "packages/bus/tests/user-principal.test.mjs",
|
||||
"pattern": "C2-09a: naming an instance"
|
||||
},
|
||||
{
|
||||
"id": "T06",
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);",
|
||||
"new": "const threaded = b.roles[instance]?.definition === \"assistant\";",
|
||||
"test": "packages/cli/tests/thread-launch.test.mjs",
|
||||
"pattern": "C2-09a: creative thread messages"
|
||||
},
|
||||
{
|
||||
"id": "T07",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": ".filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))",
|
||||
"new": ".filter(() => true)",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T08",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "name: entry.vars?.['persona.name'] || instance",
|
||||
"new": "name: instance",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T09",
|
||||
"file": "packages/webui/src/chat-http.mjs",
|
||||
"old": "(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))",
|
||||
"new": "false",
|
||||
"test": "packages/webui/tests/chat.test.mjs",
|
||||
"pattern": "C2-09a malformed instance"
|
||||
},
|
||||
{
|
||||
"id": "T10",
|
||||
"file": "packages/webui/src/chat.mjs",
|
||||
"old": "createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),",
|
||||
"new": "createThread: ({ project, name, instance }) => channel('thread.create', { project, name }),",
|
||||
"test": "packages/webui/tests/chat.test.mjs",
|
||||
"pattern": "C2-09a createThread forwards"
|
||||
},
|
||||
{
|
||||
"id": "T11",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": ": { project: key, name: f.name, instance: f.instance };",
|
||||
"new": ": { project: key, name: f.name };",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T12",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "!f.instance && listed.rows.length === 1",
|
||||
"new": "!f.instance && listed.rows.length >= 1",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T13",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "current.instanceError = \"Couldn't read the thread instances; reload to try again\";",
|
||||
"new": "current.instanceError = '';",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T14",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)",
|
||||
"new": "txt(row.name)",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T15",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {",
|
||||
"new": "if (false) {",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T16",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "f.instance = ''; f.instanceChosen = true;",
|
||||
"new": "f.instance = ''; f.instanceChosen = false;",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T17",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));",
|
||||
"new": "const unavailable = false;",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: no silent choice"
|
||||
},
|
||||
{
|
||||
"id": "T18",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });",
|
||||
"new": "if (false) return json(res, 405, { error: 'method-not-allowed' });",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T19",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "if (search) return json(res, 400, { error: 'invalid-request' });",
|
||||
"new": "if (false) return json(res, 400, { error: 'invalid-request' });",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T20",
|
||||
"file": "packages/webui/src/public/chat.js",
|
||||
"old": "current.instances = listed.rows; current.instanceError = '';",
|
||||
"new": "current.instances = listed.rows;",
|
||||
"test": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"pattern": "C2-09a picker: metadata failure"
|
||||
},
|
||||
{
|
||||
"id": "T21",
|
||||
"file": "packages/webui/src/serve.mjs",
|
||||
"old": "({ instance, name: entry.vars?.['persona.name'] || instance })",
|
||||
"new": "({ instance, name: entry.vars?.['persona.name'] || instance, vars: entry.vars })",
|
||||
"test": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"pattern": "C2-09a metadata uses the human"
|
||||
},
|
||||
{
|
||||
"id": "T22",
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);",
|
||||
"new": "const threaded = b.roles[r.instance]?.definition === \"assistant\";",
|
||||
"test": "packages/cli/tests/thread-launch.test.mjs",
|
||||
"pattern": "C2-09a: terminating one creative thread"
|
||||
},
|
||||
{
|
||||
"id": "T23",
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);",
|
||||
"new": "const threaded = b.roles[instance]?.definition === \"assistant\";",
|
||||
"test": "packages/cli/tests/thread-launch.test.mjs",
|
||||
"pattern": "C2-09a: fromSession refuses a creative instance outside a thread without a sandbox",
|
||||
"additional_edits": [
|
||||
{
|
||||
"file": "packages/cli/src/launcher.mjs",
|
||||
"old": "const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);",
|
||||
"new": "const threaded = b.roles[r.instance]?.definition === \"assistant\";"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "T24",
|
||||
"file": "packages/bus/src/broker.mjs",
|
||||
"old": "if (CREATIVE_DEFINITIONS.includes(this.#business(s.business).roles[s.role]?.definition)) fail('host-creative-role');",
|
||||
"new": "void 0;",
|
||||
"test": "packages/bus/tests/grant-executor.test.mjs",
|
||||
"pattern": "sage-1908: once the agent of an applied grant is a creative role"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
||||
index 2202c7753..0a34fe27a 100644
|
||||
--- a/packages/cli/tests/launcher.test.mjs
|
||||
+++ b/packages/cli/tests/launcher.test.mjs
|
||||
@@ -773,21 +773,28 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
- const { f, host, launcher } = await setup(t, undefined, given);
|
||||
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
+ const thread = 'persona-thread';
|
||||
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
+ ...host,
|
||||
+ op: message => message.op === 'threadsState'
|
||||
+ ? Promise.resolve([{ thread, instance: 'bean', holder: null, pending: 1 }])
|
||||
+ : host.op(message),
|
||||
+ }) });
|
||||
+ // The bundle's refusal remains launch-failed with the persona mismatch as its reason.
|
||||
+ await launcher.tick();
|
||||
const [e] = log(f);
|
||||
assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
+ assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), true);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Static preparation only. Executing tests requires Sage CLEAR."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
root=Path(__file__).resolve().parents[4]
|
||||
p=Path(__file__).resolve().parent
|
||||
specs=[]
|
||||
def site(id,file,old,new,test,pattern):
|
||||
text=(root/file).read_text();assert text.count(old)==1,(id,text.count(old))
|
||||
specs.append(dict(id=id,file=file,old=old,new=new,test=test,pattern=pattern))
|
||||
bus='packages/bus/src/broker.mjs';bt='packages/bus/tests/threads.test.mjs';cli='packages/cli/src/launcher.mjs';ct='packages/cli/tests/thread-launch.test.mjs';web='packages/webui/src/serve.mjs';wt='packages/webui/tests/chat-instances.test.mjs';browser='packages/webui/src/public/chat.js';brtest='packages/webui/tests/chat-browser.test.mjs'
|
||||
site('T01',bus,'return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);',"return this.#business(business).roles[role]?.definition === 'assistant';",bt,'C2-09a: creative bindLaunch without a thread')
|
||||
site('T02',bus,': identifier(a.instance);',': instances[0];',bt,'C2-09a: explicit creative')
|
||||
site('T03',bus,"if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');","if (!instances.length) fail('thread-instance-missing');",bt,'C2-09a: explicit missing')
|
||||
site('T04',bus,"if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');","if (false) fail('thread-instance-ambiguous');",bt,'C2-09a: explicit creative')
|
||||
site('T05',bus,"!this.#granted(s, 'thread.create', identifier(a.project), instance)","!this.#granted(s, 'thread.create', identifier(a.project), null)",'packages/bus/tests/user-principal.test.mjs','C2-09a: naming an instance')
|
||||
site('T06',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);','const threaded = b.roles[instance]?.definition === "assistant";',ct,'C2-09a: creative thread messages')
|
||||
site('T07',web,'.filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))','.filter(() => true)',wt,'C2-09a metadata uses the human')
|
||||
site('T08',web,"name: entry.vars?.['persona.name'] || instance",'name: instance',wt,'C2-09a metadata uses the human')
|
||||
site('T09','packages/webui/src/chat-http.mjs',"(Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))",'false','packages/webui/tests/chat.test.mjs','C2-09a malformed instance')
|
||||
site('T10','packages/webui/src/chat.mjs',"createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }),","createThread: ({ project, name, instance }) => channel('thread.create', { project, name }),",'packages/webui/tests/chat.test.mjs','C2-09a createThread forwards')
|
||||
site('T11',browser,": { project: key, name: f.name, instance: f.instance };",": { project: key, name: f.name };",brtest,'C2-09a picker: no silent choice')
|
||||
site('T12',browser,'!f.instance && listed.rows.length === 1','!f.instance && listed.rows.length >= 1',brtest,'C2-09a picker: no silent choice')
|
||||
site('T13',browser,'current.instanceError = "Couldn\'t read the thread instances; reload to try again";',"current.instanceError = '';",brtest,'C2-09a picker: metadata failure')
|
||||
site('T14',browser,'txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)','txt(row.name)',brtest,'C2-09a picker: no silent choice')
|
||||
site('T15',browser,'if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {','if (false) {',brtest,'C2-09a picker: metadata failure')
|
||||
site('T16',browser,"f.instance = ''; f.instanceChosen = true;","f.instance = ''; f.instanceChosen = false;",brtest,'C2-09a picker: metadata failure')
|
||||
site('T17',browser,"const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));",'const unavailable = false;',brtest,'C2-09a picker: no silent choice')
|
||||
# Route-local guards include their full unique response text to avoid touching other routes.
|
||||
site('T18',web,"if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });","if (false) return json(res, 405, { error: 'method-not-allowed' });",wt,'C2-09a metadata uses the human')
|
||||
site('T19',web,"if (search) return json(res, 400, { error: 'invalid-request' });","if (false) return json(res, 400, { error: 'invalid-request' });",wt,'C2-09a metadata uses the human')
|
||||
site('T20',browser,"current.instances = listed.rows; current.instanceError = '';","current.instances = listed.rows;",brtest,'C2-09a picker: metadata failure')
|
||||
site('T21',web,"({ instance, name: entry.vars?.['persona.name'] || instance })","({ instance, name: entry.vars?.['persona.name'] || instance, vars: entry.vars })",wt,'C2-09a metadata uses the human')
|
||||
site('T22',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);','const threaded = b.roles[r.instance]?.definition === "assistant";',ct,'C2-09a: terminating one creative thread')
|
||||
site('T23',cli,'const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);','const threaded = b.roles[instance]?.definition === "assistant";',ct,'C2-09a: fromSession refuses a creative instance outside a thread without a sandbox')
|
||||
ending_old='const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);'
|
||||
assert (root/cli).read_text().count(ending_old)==1
|
||||
specs[-1]['additional_edits']=[dict(file=cli,old=ending_old,new='const threaded = b.roles[r.instance]?.definition === "assistant";')]
|
||||
site('T24',bus,"if (CREATIVE_DEFINITIONS.includes(this.#business(s.business).roles[s.role]?.definition)) fail('host-creative-role');",'void 0;','packages/bus/tests/grant-executor.test.mjs','sage-1908: once the agent of an applied grant is a creative role')
|
||||
(p/'mutants.json').write_text(json.dumps(specs,indent=2)+'\n')
|
||||
print('STATIC_PREFLIGHT',len(specs),'exact-once sites; no tests run')
|
||||
@@ -0,0 +1,6 @@
|
||||
# Row219 r2 / mos1420 third edit
|
||||
|
||||
See ../BUILD.md for the full unchanged scope and preserved red chronology.
|
||||
This round changes only the new thread-launch native-history paths and adds
|
||||
the two legacy-path absence assertions approved by mos1420. No run yet.
|
||||
Dewey pre-restart diff check and Sage push/run CLEAR are required.
|
||||
@@ -0,0 +1 @@
|
||||
4a9c536e861f1d9f9a018019b3ac4519d65eccf0
|
||||
@@ -0,0 +1,788 @@
|
||||
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
||||
index d76085739..11061c778 100644
|
||||
--- a/packages/bus/src/broker.mjs
|
||||
+++ b/packages/bus/src/broker.mjs
|
||||
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
@@ -598,7 +598,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#threaded(business, role) {
|
||||
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
|
||||
}
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
@@ -6068,24 +6068,27 @@ export class Broker {
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
}
|
||||
- // A thread belongs to the user who made it and to the business's one thread instance.
|
||||
+ // A thread belongs to the user who made it and to one declared thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
- keys(a, ['project', 'name'], ['project', 'name']);
|
||||
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
+ const instance = a.instance === undefined
|
||||
+ ? (instances.length === 1 ? instances[0] : null)
|
||||
+ : identifier(a.instance);
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
|
||||
+ fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
- // The bus never picks between instances; the business file declares exactly one.
|
||||
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (!instances.length) fail('thread-instance-missing');
|
||||
- if (instances.length > 1) fail('thread-instance-ambiguous');
|
||||
+ // The human selects an instance; omission is compatible only with one candidate.
|
||||
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -6094,13 +6097,13 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.project,
|
||||
- instances[0],
|
||||
+ instance,
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
- return { id: tid, instance: instances[0] };
|
||||
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
|
||||
+ return { id: tid, instance };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
|
||||
index 6cfbf1cd6..6d421ba19 100644
|
||||
--- a/packages/bus/tests/creative.test.mjs
|
||||
+++ b/packages/bus/tests/creative.test.mjs
|
||||
@@ -44,15 +44,18 @@ function setup(t) {
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
||||
+ const threads = { assistant: thread };
|
||||
for (const role of Object.keys(roles)) {
|
||||
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
+ if (['bean', 'quill'].includes(role))
|
||||
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
||||
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
- return { call, count, store };
|
||||
+ return { call, count, store, threads };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
- const { call, count } = setup(t);
|
||||
+ const { call, count, threads } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
- call('human', 'message.send', { to: who, body: 'hello' });
|
||||
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
||||
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
|
||||
index 8b31da9f9..1410ec3fa 100644
|
||||
--- a/packages/bus/tests/threads.test.mjs
|
||||
+++ b/packages/bus/tests/threads.test.mjs
|
||||
@@ -601,3 +601,42 @@ test('project.create keeps unrelated names with and without a repository', t =>
|
||||
{ id: linked.id, name: 'work', repo: 'stack' },
|
||||
]);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
|
||||
+ } }));
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
|
||||
+ for (const [instance, role] of Object.entries(extra)) {
|
||||
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
|
||||
+ assert.equal(receipt.instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
|
||||
+ ctx.launch(instance, instance, { thread: receipt.id });
|
||||
+ assert.equal(ctx.claim(instance).thread, receipt.id);
|
||||
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
|
||||
+ const read = ctx.call(instance, 'message.receive');
|
||||
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
|
||||
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
|
||||
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
|
||||
+ { code: 'thread-required-use-thread-send' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
|
||||
+ const ctx = setup(t);
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ for (const instance of ['not-declared', 'pm', 'coder'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
|
||||
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
|
||||
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
|
||||
+ assert.equal(legacy.instance, 'assistant');
|
||||
+});
|
||||
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
|
||||
index 4dcd1ebeb..8471ddf85 100644
|
||||
--- a/packages/bus/tests/user-principal.test.mjs
|
||||
+++ b/packages/bus/tests/user-principal.test.mjs
|
||||
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
|
||||
});
|
||||
|
||||
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
||||
-function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ ...threadRoles,
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
|
||||
test('R3 r4: a non-function access throws before the store is read', () => {
|
||||
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
|
||||
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project] })];
|
||||
+ ctx.user('alice');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
|
||||
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
|
||||
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
|
||||
+ assert.equal(allowed.instance, 'assistant');
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
|
||||
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
|
||||
+ assert.equal(bean.instance, 'bean');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
||||
+});
|
||||
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
||||
index bfe55b61e..d8248284c 100644
|
||||
--- a/packages/cli/src/launcher.mjs
|
||||
+++ b/packages/cli/src/launcher.mjs
|
||||
@@ -53,7 +53,7 @@
|
||||
// session.json names it as gitSocket.
|
||||
//
|
||||
// Thread sessions (row 121, #1615). A thread instance (an instance whose
|
||||
-// role definition is the assistant) runs once per thread, never on its own:
|
||||
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
|
||||
// a launch from a session or --pm refuses thread-required. Every tick
|
||||
// (threadTickMs) the host asks the broker for the business's threads
|
||||
// (launch op threadsState) and:
|
||||
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
const THREAD_TICK_MS = 2000;
|
||||
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
-const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
@@ -331,7 +330,7 @@ export function createLauncher({
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
|
||||
@@ -997,7 +996,7 @@ export function createLauncher({
|
||||
function ending(r) {
|
||||
const runs = r.runs ?? [];
|
||||
const at = Date.parse(r.at);
|
||||
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
|
||||
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
|
||||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
|
||||
}
|
||||
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
||||
index 2202c7753..baabd843e 100644
|
||||
--- a/packages/cli/tests/launcher.test.mjs
|
||||
+++ b/packages/cli/tests/launcher.test.mjs
|
||||
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
- const { f, host, launcher } = await setup(t, undefined, given);
|
||||
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
- const [e] = log(f);
|
||||
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
+ const thread = 'persona-thread';
|
||||
+ const SESSION = 'persona-session';
|
||||
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
+ ...host,
|
||||
+ op: message => message.op === 'threadsState'
|
||||
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
|
||||
+ : host.op(message),
|
||||
+ }) });
|
||||
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
|
||||
+ await launcher.tick();
|
||||
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
+ const entries = log(f);
|
||||
+ assert.equal(entries.length, 2);
|
||||
+ const { at, ...compactions } = entries[0];
|
||||
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
|
||||
+ const e = entries[1];
|
||||
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
|
||||
+ assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
||||
index 0d207a0a0..fa3adb866 100644
|
||||
--- a/packages/cli/tests/thread-launch.test.mjs
|
||||
+++ b/packages/cli/tests/thread-launch.test.mjs
|
||||
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
|
||||
});
|
||||
|
||||
+// Admission refuses before bundle construction or a sandbox is needed.
|
||||
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
|
||||
+ const prepared = prepare(t, { edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ doc.launch.instances.push('bean');
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const { f } = prepared;
|
||||
+ const host = await start(t, prepared);
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+ assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
|
||||
+});
|
||||
+
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
|
||||
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
|
||||
// bus's plug-in loader.
|
||||
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
|
||||
assert.deepEqual(threadRuns(f, A), []);
|
||||
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ const vars = { ...doc.roles.assistant.vars };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
|
||||
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
|
||||
+ doc.launch.instances = ['coder', 'bean', 'chet'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const host = await start(t, prepared), { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
|
||||
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
|
||||
+ const send = async (thread, instance, body) => {
|
||||
+ const message = host.call('thread.send', { thread, body });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ const reply = outcome;
|
||||
+ assert.equal(reply.from_role, instance);
|
||||
+ assert.equal(reply.to_role, 'human');
|
||||
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
|
||||
+ };
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
|
||||
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
|
||||
+ const launches = launchLog(f).filter(row => row.event === 'launch');
|
||||
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
|
||||
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.launch.instances = ['coder', 'bean'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ let requests = [];
|
||||
+ const host = await start(t, prepared, { ops: () => ({
|
||||
+ lifecycleRequests: () => requests,
|
||||
+ lifecycleTake: () => ({ ...requests[0], go: true }),
|
||||
+ lifecycleDone: () => { requests = []; return {}; },
|
||||
+ }) });
|
||||
+ const { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
|
||||
+ for (const thread of threads) {
|
||||
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ }
|
||||
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
|
||||
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
|
||||
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
|
||||
+ await host.launcher.tick();
|
||||
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
|
||||
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
|
||||
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
+});
|
||||
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
|
||||
index c7770726d..396b42f3c 100644
|
||||
--- a/packages/webui/src/chat-http.mjs
|
||||
+++ b/packages/webui/src/chat-http.mjs
|
||||
@@ -78,7 +78,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
|
||||
action = 'createProject'; args = v;
|
||||
} else if (path === '/api/chat/threads') {
|
||||
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
|
||||
+ 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] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
|
||||
index 018574ad4..65b89dab3 100644
|
||||
--- a/packages/webui/src/chat.mjs
|
||||
+++ b/packages/webui/src/chat.mjs
|
||||
@@ -20,7 +20,7 @@ export function createChat({ call }) {
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
|
||||
+ 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 }),
|
||||
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
|
||||
index a6b805c1b..15066bdc6 100644
|
||||
--- a/packages/webui/src/public/chat.js
|
||||
+++ b/packages/webui/src/public/chat.js
|
||||
@@ -14,7 +14,7 @@
|
||||
return states.get(id);
|
||||
};
|
||||
const form = key => {
|
||||
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
|
||||
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
class ChatError extends Error {
|
||||
@@ -58,7 +58,11 @@
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
|
||||
+ const choices = current.instances ?? [];
|
||||
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
|
||||
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
|
||||
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
|
||||
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
|
||||
}
|
||||
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>';
|
||||
@@ -106,7 +110,7 @@
|
||||
const f = document.getElementById(keep.field.id);
|
||||
if (f && !f.disabled) {
|
||||
f.focus({ preventScroll: true });
|
||||
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -126,7 +130,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], 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 };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
@@ -147,6 +151,25 @@
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
}
|
||||
}
|
||||
+ if (kind === 'project') {
|
||||
+ try {
|
||||
+ const listed = await request('instances', undefined, signal);
|
||||
+ if (g !== generation || out()) return;
|
||||
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
|
||||
+ throw new ChatError('invalid-response');
|
||||
+ current.instances = listed.rows; current.instanceError = '';
|
||||
+ const f = form(id);
|
||||
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
|
||||
+ f.instance = ''; f.instanceChosen = true;
|
||||
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
|
||||
+ }
|
||||
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
|
||||
+ } catch (err) {
|
||||
+ if (err.name === 'AbortError' || out() || g !== generation) return;
|
||||
+ current.instances = [];
|
||||
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
|
||||
+ }
|
||||
+ }
|
||||
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
|
||||
if (kind === 'list') {
|
||||
try {
|
||||
@@ -179,7 +202,11 @@
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
|
||||
+ if (f && event.target.dataset.chatField) {
|
||||
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
+ draft[field] = event.target.value;
|
||||
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
|
||||
+ }
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
@@ -232,7 +259,8 @@
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
|
||||
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
|
||||
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
||||
index 288871d0c..d9d27a062 100644
|
||||
--- a/packages/webui/src/serve.mjs
|
||||
+++ b/packages/webui/src/serve.mjs
|
||||
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
|
||||
@@ -705,6 +706,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
|
||||
+ if (path === '/api/chat/instances') {
|
||||
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
|
||||
+ if (search) return json(res, 400, { error: 'invalid-request' });
|
||||
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
|
||||
+ try {
|
||||
+ const value = await bus.instances();
|
||||
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
|
||||
+ throw new BusReadError('invalid-response');
|
||||
+ const rows = Object.entries(value.instances)
|
||||
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
|
||||
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
|
||||
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
|
||||
+ return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
+ } catch (err) {
|
||||
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
+ return json(res, busStatus(code), { error: code });
|
||||
+ }
|
||||
+ }
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
|
||||
index f3eeeca96..cc3ec093d 100644
|
||||
--- a/packages/webui/tests/chat-browser.test.mjs
|
||||
+++ b/packages/webui/tests/chat-browser.test.mjs
|
||||
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
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.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
@@ -33,6 +34,7 @@ const stub = `(() => {
|
||||
window.__reads.push(path);
|
||||
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
||||
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.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
||||
await click('#chat-thread-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-send-form")');
|
||||
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
|
||||
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
||||
});
|
||||
+
|
||||
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
|
||||
+ const { b, wait, set, click, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
|
||||
+ assert.match(labels[1], /\(bean\)/);
|
||||
+ assert.match(labels[2], /\(chet\)/);
|
||||
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
|
||||
+ assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
+ await set('#chat-thread-name', 'Bean discussion');
|
||||
+ await click('#chat-thread-form button');
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
|
||||
+ await click('#chat-thread-form button');
|
||||
+ await wait('window.__writes.length===1');
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
|
||||
+ const { b, wait, set, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
|
||||
+ await refresh();
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
|
||||
+ await set('#chat-instance', 'chet');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
|
||||
+ const { b, wait } = await setup(t, '?readonly');
|
||||
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
|
||||
new file mode 100644
|
||||
index 000000000..893c38f80
|
||||
--- /dev/null
|
||||
+++ b/packages/webui/tests/chat-instances.test.mjs
|
||||
@@ -0,0 +1,87 @@
|
||||
+import test from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { request } from 'node:http';
|
||||
+import { mkdtempSync, rmSync } from 'node:fs';
|
||||
+import { join } from 'node:path';
|
||||
+import { tmpdir } from 'node:os';
|
||||
+import { startServer } from '../src/serve.mjs';
|
||||
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
+import { close } from './fixture.mjs';
|
||||
+
|
||||
+const path = '/api/chat/instances';
|
||||
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
|
||||
+const snapshot = {
|
||||
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
|
||||
+ instances: {
|
||||
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
|
||||
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
|
||||
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
|
||||
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
|
||||
+ },
|
||||
+};
|
||||
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
|
||||
+ const address = server.address(), unix = typeof address === 'string';
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
|
||||
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
|
||||
+ let text = '';
|
||||
+ res.on('data', chunk => text += chunk);
|
||||
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
|
||||
+ });
|
||||
+ req.on('error', reject); req.end();
|
||||
+ });
|
||||
+}
|
||||
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
|
||||
+ let calls = 0;
|
||||
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const refused = await get(server);
|
||||
+ assert.equal(refused.status, 401);
|
||||
+ assert.equal(calls, 0);
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
|
||||
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
|
||||
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
|
||||
+ assert.equal(calls, 1);
|
||||
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
|
||||
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok([400, 405].includes(result.status));
|
||||
+ }
|
||||
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
|
||||
+});
|
||||
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
|
||||
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
+ let reads = 0;
|
||||
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
|
||||
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const answer = await get(server);
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
|
||||
+ assert.equal(reads, 1);
|
||||
+});
|
||||
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
|
||||
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
|
||||
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok(answer.status >= 400);
|
||||
+ assert.doesNotMatch(answer.text, /private-path/);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
|
||||
+ let session = null, reads = 0;
|
||||
+ const touches = [];
|
||||
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
|
||||
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
|
||||
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const headers = { host: sso.publicHost };
|
||||
+ assert.equal((await get(server, { headers })).status, 401);
|
||||
+ assert.equal(reads, 0);
|
||||
+ session = { name: 'jason' };
|
||||
+ assert.equal((await get(server, { headers })).status, 200);
|
||||
+ assert.equal(reads, 1);
|
||||
+ assert.deepEqual(touches, [false, false]);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
|
||||
index 8c11611cf..b47ecf1a7 100644
|
||||
--- a/packages/webui/tests/chat.test.mjs
|
||||
+++ b/packages/webui/tests/chat.test.mjs
|
||||
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
|
||||
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
|
||||
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
|
||||
});
|
||||
+
|
||||
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ c.state.answer = { id: T, instance: 'bean' };
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
|
||||
+ assert.equal(answer.data.instance, 'bean');
|
||||
+});
|
||||
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
|
||||
+ assert.equal(answer.status, 400, JSON.stringify(value));
|
||||
+ }
|
||||
+ 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, []);
|
||||
+});
|
||||
@@ -0,0 +1,14 @@
|
||||
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
|
||||
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
|
||||
de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/tests/threads.test.mjs
|
||||
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
|
||||
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
|
||||
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
|
||||
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
|
||||
cbfd54e6b375561d1e4679ee8d78a3285f970d9a7198cec9d22a06510a36afba packages/webui/src/chat-http.mjs
|
||||
f49a49c6ec69783190d3f5eaf34316cb4d2c44d10d2ee9336fe1383207afefff packages/webui/src/chat.mjs
|
||||
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
|
||||
a597cc4902a873fe15650dacfce14dbc897db26310a7b6a24c293287add946c8 packages/webui/src/serve.mjs
|
||||
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
|
||||
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
|
||||
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,14 @@
|
||||
packages/bus/src/broker.mjs
|
||||
packages/bus/tests/creative.test.mjs
|
||||
packages/bus/tests/threads.test.mjs
|
||||
packages/bus/tests/user-principal.test.mjs
|
||||
packages/cli/src/launcher.mjs
|
||||
packages/cli/tests/launcher.test.mjs
|
||||
packages/cli/tests/thread-launch.test.mjs
|
||||
packages/webui/src/chat-http.mjs
|
||||
packages/webui/src/chat.mjs
|
||||
packages/webui/src/public/chat.js
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/chat-browser.test.mjs
|
||||
packages/webui/tests/chat-instances.test.mjs
|
||||
packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,17 @@
|
||||
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
||||
index 84f8ed088..fa3adb866 100644
|
||||
--- a/packages/cli/tests/thread-launch.test.mjs
|
||||
+++ b/packages/cli/tests/thread-launch.test.mjs
|
||||
@@ -486,8 +486,10 @@ test('C2-09a: creative thread messages start distinct runs and keep each instanc
|
||||
assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
|
||||
assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
|
||||
assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
|
||||
- assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
- assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
|
||||
const launches = launchLog(f).filter(row => row.event === 'launch');
|
||||
assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
|
||||
assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"path": "packages/webui/src/chat-http.mjs",
|
||||
"baseDelta": "463a78d259c86d16e4c081bd2336f666477463b8",
|
||||
"tipDelta": "463a78d259c86d16e4c081bd2336f666477463b8"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/src/chat.mjs",
|
||||
"baseDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948",
|
||||
"tipDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/src/serve.mjs",
|
||||
"baseDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928",
|
||||
"tipDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
# Row219 rebase round r3
|
||||
|
||||
See ../BUILD.md rebase round section. Base80793d824; no conflicts.
|
||||
Range-diff, per-path identities, row294 carry identities and exact test
|
||||
reader inventory are adjacent. No tests run. Dewey checks rebase only.
|
||||
@@ -0,0 +1 @@
|
||||
80793d824e05baaef4820f4be7790aba55971b6f
|
||||
@@ -0,0 +1,788 @@
|
||||
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
||||
index d76085739..11061c778 100644
|
||||
--- a/packages/bus/src/broker.mjs
|
||||
+++ b/packages/bus/src/broker.mjs
|
||||
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
@@ -598,7 +598,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#threaded(business, role) {
|
||||
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
|
||||
}
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
@@ -6068,24 +6068,27 @@ export class Broker {
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
}
|
||||
- // A thread belongs to the user who made it and to the business's one thread instance.
|
||||
+ // A thread belongs to the user who made it and to one declared thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
- keys(a, ['project', 'name'], ['project', 'name']);
|
||||
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
+ const instance = a.instance === undefined
|
||||
+ ? (instances.length === 1 ? instances[0] : null)
|
||||
+ : identifier(a.instance);
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
|
||||
+ fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
- // The bus never picks between instances; the business file declares exactly one.
|
||||
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (!instances.length) fail('thread-instance-missing');
|
||||
- if (instances.length > 1) fail('thread-instance-ambiguous');
|
||||
+ // The human selects an instance; omission is compatible only with one candidate.
|
||||
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -6094,13 +6097,13 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.project,
|
||||
- instances[0],
|
||||
+ instance,
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
- return { id: tid, instance: instances[0] };
|
||||
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
|
||||
+ return { id: tid, instance };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
|
||||
index 6cfbf1cd6..6d421ba19 100644
|
||||
--- a/packages/bus/tests/creative.test.mjs
|
||||
+++ b/packages/bus/tests/creative.test.mjs
|
||||
@@ -44,15 +44,18 @@ function setup(t) {
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
||||
+ const threads = { assistant: thread };
|
||||
for (const role of Object.keys(roles)) {
|
||||
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
+ if (['bean', 'quill'].includes(role))
|
||||
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
||||
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
- return { call, count, store };
|
||||
+ return { call, count, store, threads };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
- const { call, count } = setup(t);
|
||||
+ const { call, count, threads } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
- call('human', 'message.send', { to: who, body: 'hello' });
|
||||
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
||||
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
|
||||
index 8b31da9f9..1410ec3fa 100644
|
||||
--- a/packages/bus/tests/threads.test.mjs
|
||||
+++ b/packages/bus/tests/threads.test.mjs
|
||||
@@ -601,3 +601,42 @@ test('project.create keeps unrelated names with and without a repository', t =>
|
||||
{ id: linked.id, name: 'work', repo: 'stack' },
|
||||
]);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
|
||||
+ } }));
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
|
||||
+ for (const [instance, role] of Object.entries(extra)) {
|
||||
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
|
||||
+ assert.equal(receipt.instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
|
||||
+ ctx.launch(instance, instance, { thread: receipt.id });
|
||||
+ assert.equal(ctx.claim(instance).thread, receipt.id);
|
||||
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
|
||||
+ const read = ctx.call(instance, 'message.receive');
|
||||
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
|
||||
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
|
||||
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
|
||||
+ { code: 'thread-required-use-thread-send' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
|
||||
+ const ctx = setup(t);
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ for (const instance of ['not-declared', 'pm', 'coder'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
|
||||
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
|
||||
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
|
||||
+ assert.equal(legacy.instance, 'assistant');
|
||||
+});
|
||||
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
|
||||
index 4dcd1ebeb..8471ddf85 100644
|
||||
--- a/packages/bus/tests/user-principal.test.mjs
|
||||
+++ b/packages/bus/tests/user-principal.test.mjs
|
||||
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
|
||||
});
|
||||
|
||||
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
||||
-function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ ...threadRoles,
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
|
||||
test('R3 r4: a non-function access throws before the store is read', () => {
|
||||
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
|
||||
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project] })];
|
||||
+ ctx.user('alice');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
|
||||
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
|
||||
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
|
||||
+ assert.equal(allowed.instance, 'assistant');
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
|
||||
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
|
||||
+ assert.equal(bean.instance, 'bean');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
||||
+});
|
||||
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
||||
index bfe55b61e..d8248284c 100644
|
||||
--- a/packages/cli/src/launcher.mjs
|
||||
+++ b/packages/cli/src/launcher.mjs
|
||||
@@ -53,7 +53,7 @@
|
||||
// session.json names it as gitSocket.
|
||||
//
|
||||
// Thread sessions (row 121, #1615). A thread instance (an instance whose
|
||||
-// role definition is the assistant) runs once per thread, never on its own:
|
||||
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
|
||||
// a launch from a session or --pm refuses thread-required. Every tick
|
||||
// (threadTickMs) the host asks the broker for the business's threads
|
||||
// (launch op threadsState) and:
|
||||
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
const THREAD_TICK_MS = 2000;
|
||||
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
-const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
@@ -331,7 +330,7 @@ export function createLauncher({
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
|
||||
@@ -997,7 +996,7 @@ export function createLauncher({
|
||||
function ending(r) {
|
||||
const runs = r.runs ?? [];
|
||||
const at = Date.parse(r.at);
|
||||
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
|
||||
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
|
||||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
|
||||
}
|
||||
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
||||
index 2202c7753..baabd843e 100644
|
||||
--- a/packages/cli/tests/launcher.test.mjs
|
||||
+++ b/packages/cli/tests/launcher.test.mjs
|
||||
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
- const { f, host, launcher } = await setup(t, undefined, given);
|
||||
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
- const [e] = log(f);
|
||||
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
+ const thread = 'persona-thread';
|
||||
+ const SESSION = 'persona-session';
|
||||
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
+ ...host,
|
||||
+ op: message => message.op === 'threadsState'
|
||||
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
|
||||
+ : host.op(message),
|
||||
+ }) });
|
||||
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
|
||||
+ await launcher.tick();
|
||||
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
+ const entries = log(f);
|
||||
+ assert.equal(entries.length, 2);
|
||||
+ const { at, ...compactions } = entries[0];
|
||||
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
|
||||
+ const e = entries[1];
|
||||
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
|
||||
+ assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
||||
index 0d207a0a0..fa3adb866 100644
|
||||
--- a/packages/cli/tests/thread-launch.test.mjs
|
||||
+++ b/packages/cli/tests/thread-launch.test.mjs
|
||||
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
|
||||
});
|
||||
|
||||
+// Admission refuses before bundle construction or a sandbox is needed.
|
||||
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
|
||||
+ const prepared = prepare(t, { edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ doc.launch.instances.push('bean');
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const { f } = prepared;
|
||||
+ const host = await start(t, prepared);
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+ assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
|
||||
+});
|
||||
+
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
|
||||
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
|
||||
// bus's plug-in loader.
|
||||
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
|
||||
assert.deepEqual(threadRuns(f, A), []);
|
||||
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ const vars = { ...doc.roles.assistant.vars };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
|
||||
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
|
||||
+ doc.launch.instances = ['coder', 'bean', 'chet'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const host = await start(t, prepared), { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
|
||||
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
|
||||
+ const send = async (thread, instance, body) => {
|
||||
+ const message = host.call('thread.send', { thread, body });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ const reply = outcome;
|
||||
+ assert.equal(reply.from_role, instance);
|
||||
+ assert.equal(reply.to_role, 'human');
|
||||
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
|
||||
+ };
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
|
||||
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
|
||||
+ const launches = launchLog(f).filter(row => row.event === 'launch');
|
||||
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
|
||||
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.launch.instances = ['coder', 'bean'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ let requests = [];
|
||||
+ const host = await start(t, prepared, { ops: () => ({
|
||||
+ lifecycleRequests: () => requests,
|
||||
+ lifecycleTake: () => ({ ...requests[0], go: true }),
|
||||
+ lifecycleDone: () => { requests = []; return {}; },
|
||||
+ }) });
|
||||
+ const { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
|
||||
+ for (const thread of threads) {
|
||||
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ }
|
||||
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
|
||||
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
|
||||
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
|
||||
+ await host.launcher.tick();
|
||||
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
|
||||
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
|
||||
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
+});
|
||||
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
|
||||
index 7980c1442..63d3ca974 100644
|
||||
--- a/packages/webui/src/chat-http.mjs
|
||||
+++ b/packages/webui/src/chat-http.mjs
|
||||
@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
|
||||
action = 'createProject'; args = v;
|
||||
} else if (path === '/api/chat/threads') {
|
||||
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
|
||||
+ 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] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
|
||||
index 1b0a41dce..5a1ed8bf1 100644
|
||||
--- a/packages/webui/src/chat.mjs
|
||||
+++ b/packages/webui/src/chat.mjs
|
||||
@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
|
||||
+ 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 }),
|
||||
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
|
||||
index a6b805c1b..15066bdc6 100644
|
||||
--- a/packages/webui/src/public/chat.js
|
||||
+++ b/packages/webui/src/public/chat.js
|
||||
@@ -14,7 +14,7 @@
|
||||
return states.get(id);
|
||||
};
|
||||
const form = key => {
|
||||
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
|
||||
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
class ChatError extends Error {
|
||||
@@ -58,7 +58,11 @@
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
|
||||
+ const choices = current.instances ?? [];
|
||||
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
|
||||
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
|
||||
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
|
||||
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
|
||||
}
|
||||
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>';
|
||||
@@ -106,7 +110,7 @@
|
||||
const f = document.getElementById(keep.field.id);
|
||||
if (f && !f.disabled) {
|
||||
f.focus({ preventScroll: true });
|
||||
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -126,7 +130,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], 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 };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
@@ -147,6 +151,25 @@
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
}
|
||||
}
|
||||
+ if (kind === 'project') {
|
||||
+ try {
|
||||
+ const listed = await request('instances', undefined, signal);
|
||||
+ if (g !== generation || out()) return;
|
||||
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
|
||||
+ throw new ChatError('invalid-response');
|
||||
+ current.instances = listed.rows; current.instanceError = '';
|
||||
+ const f = form(id);
|
||||
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
|
||||
+ f.instance = ''; f.instanceChosen = true;
|
||||
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
|
||||
+ }
|
||||
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
|
||||
+ } catch (err) {
|
||||
+ if (err.name === 'AbortError' || out() || g !== generation) return;
|
||||
+ current.instances = [];
|
||||
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
|
||||
+ }
|
||||
+ }
|
||||
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
|
||||
if (kind === 'list') {
|
||||
try {
|
||||
@@ -179,7 +202,11 @@
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
|
||||
+ if (f && event.target.dataset.chatField) {
|
||||
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
+ draft[field] = event.target.value;
|
||||
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
|
||||
+ }
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
@@ -232,7 +259,8 @@
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
|
||||
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
|
||||
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
||||
index cd51a0b6e..56fcdd4e1 100644
|
||||
--- a/packages/webui/src/serve.mjs
|
||||
+++ b/packages/webui/src/serve.mjs
|
||||
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
|
||||
@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
|
||||
+ if (path === '/api/chat/instances') {
|
||||
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
|
||||
+ if (search) return json(res, 400, { error: 'invalid-request' });
|
||||
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
|
||||
+ try {
|
||||
+ const value = await bus.instances();
|
||||
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
|
||||
+ throw new BusReadError('invalid-response');
|
||||
+ const rows = Object.entries(value.instances)
|
||||
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
|
||||
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
|
||||
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
|
||||
+ return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
+ } catch (err) {
|
||||
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
+ return json(res, busStatus(code), { error: code });
|
||||
+ }
|
||||
+ }
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
|
||||
index f3eeeca96..cc3ec093d 100644
|
||||
--- a/packages/webui/tests/chat-browser.test.mjs
|
||||
+++ b/packages/webui/tests/chat-browser.test.mjs
|
||||
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
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.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
@@ -33,6 +34,7 @@ const stub = `(() => {
|
||||
window.__reads.push(path);
|
||||
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
||||
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.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
||||
await click('#chat-thread-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-send-form")');
|
||||
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
|
||||
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
||||
});
|
||||
+
|
||||
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
|
||||
+ const { b, wait, set, click, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
|
||||
+ assert.match(labels[1], /\(bean\)/);
|
||||
+ assert.match(labels[2], /\(chet\)/);
|
||||
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
|
||||
+ assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
+ await set('#chat-thread-name', 'Bean discussion');
|
||||
+ await click('#chat-thread-form button');
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
|
||||
+ await click('#chat-thread-form button');
|
||||
+ await wait('window.__writes.length===1');
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
|
||||
+ const { b, wait, set, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
|
||||
+ await refresh();
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
|
||||
+ await set('#chat-instance', 'chet');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
|
||||
+ const { b, wait } = await setup(t, '?readonly');
|
||||
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
|
||||
new file mode 100644
|
||||
index 000000000..893c38f80
|
||||
--- /dev/null
|
||||
+++ b/packages/webui/tests/chat-instances.test.mjs
|
||||
@@ -0,0 +1,87 @@
|
||||
+import test from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { request } from 'node:http';
|
||||
+import { mkdtempSync, rmSync } from 'node:fs';
|
||||
+import { join } from 'node:path';
|
||||
+import { tmpdir } from 'node:os';
|
||||
+import { startServer } from '../src/serve.mjs';
|
||||
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
+import { close } from './fixture.mjs';
|
||||
+
|
||||
+const path = '/api/chat/instances';
|
||||
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
|
||||
+const snapshot = {
|
||||
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
|
||||
+ instances: {
|
||||
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
|
||||
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
|
||||
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
|
||||
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
|
||||
+ },
|
||||
+};
|
||||
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
|
||||
+ const address = server.address(), unix = typeof address === 'string';
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
|
||||
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
|
||||
+ let text = '';
|
||||
+ res.on('data', chunk => text += chunk);
|
||||
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
|
||||
+ });
|
||||
+ req.on('error', reject); req.end();
|
||||
+ });
|
||||
+}
|
||||
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
|
||||
+ let calls = 0;
|
||||
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const refused = await get(server);
|
||||
+ assert.equal(refused.status, 401);
|
||||
+ assert.equal(calls, 0);
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
|
||||
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
|
||||
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
|
||||
+ assert.equal(calls, 1);
|
||||
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
|
||||
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok([400, 405].includes(result.status));
|
||||
+ }
|
||||
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
|
||||
+});
|
||||
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
|
||||
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
+ let reads = 0;
|
||||
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
|
||||
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const answer = await get(server);
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
|
||||
+ assert.equal(reads, 1);
|
||||
+});
|
||||
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
|
||||
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
|
||||
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok(answer.status >= 400);
|
||||
+ assert.doesNotMatch(answer.text, /private-path/);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
|
||||
+ let session = null, reads = 0;
|
||||
+ const touches = [];
|
||||
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
|
||||
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
|
||||
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const headers = { host: sso.publicHost };
|
||||
+ assert.equal((await get(server, { headers })).status, 401);
|
||||
+ assert.equal(reads, 0);
|
||||
+ session = { name: 'jason' };
|
||||
+ assert.equal((await get(server, { headers })).status, 200);
|
||||
+ assert.equal(reads, 1);
|
||||
+ assert.deepEqual(touches, [false, false]);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
|
||||
index 8c11611cf..b47ecf1a7 100644
|
||||
--- a/packages/webui/tests/chat.test.mjs
|
||||
+++ b/packages/webui/tests/chat.test.mjs
|
||||
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
|
||||
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
|
||||
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
|
||||
});
|
||||
+
|
||||
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ c.state.answer = { id: T, instance: 'bean' };
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
|
||||
+ assert.equal(answer.data.instance, 'bean');
|
||||
+});
|
||||
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
|
||||
+ assert.equal(answer.status, 400, JSON.stringify(value));
|
||||
+ }
|
||||
+ 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, []);
|
||||
+});
|
||||
@@ -0,0 +1,14 @@
|
||||
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
|
||||
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
|
||||
de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/tests/threads.test.mjs
|
||||
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
|
||||
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
|
||||
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
|
||||
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
|
||||
39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
|
||||
77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
|
||||
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
|
||||
443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
|
||||
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
|
||||
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
|
||||
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,14 @@
|
||||
packages/bus/src/broker.mjs
|
||||
packages/bus/tests/creative.test.mjs
|
||||
packages/bus/tests/threads.test.mjs
|
||||
packages/bus/tests/user-principal.test.mjs
|
||||
packages/cli/src/launcher.mjs
|
||||
packages/cli/tests/launcher.test.mjs
|
||||
packages/cli/tests/thread-launch.test.mjs
|
||||
packages/webui/src/chat-http.mjs
|
||||
packages/webui/src/chat.mjs
|
||||
packages/webui/src/public/chat.js
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/chat-browser.test.mjs
|
||||
packages/webui/tests/chat-instances.test.mjs
|
||||
packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1 @@
|
||||
No conflicts. No manual resolutions or product edits. All five commits auto-rebased.
|
||||
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"method": "resolve literal relative import/URL/file paths in packages/*/tests/**/*.mjs; reverse test-helper dependency closure; no tests run",
|
||||
"readers": {
|
||||
"packages/webui/src/chat-http.mjs": {
|
||||
"directModules": [
|
||||
"packages/webui/tests/chat.test.mjs"
|
||||
],
|
||||
"wholeTestReaders": [
|
||||
"packages/webui/tests/chat.test.mjs"
|
||||
]
|
||||
},
|
||||
"packages/webui/src/chat.mjs": {
|
||||
"directModules": [
|
||||
"packages/webui/tests/channel-chat.test.mjs",
|
||||
"packages/webui/tests/chat.test.mjs",
|
||||
"packages/webui/tests/tester-fixture.mjs"
|
||||
],
|
||||
"wholeTestReaders": [
|
||||
"packages/webui/tests/channel-chat.test.mjs",
|
||||
"packages/webui/tests/chat.test.mjs",
|
||||
"packages/webui/tests/proof-cli.test.mjs",
|
||||
"packages/webui/tests/tester-browser.test.mjs",
|
||||
"packages/webui/tests/tester-cli.test.mjs",
|
||||
"packages/webui/tests/tester-listener.test.mjs"
|
||||
]
|
||||
},
|
||||
"packages/webui/src/serve.mjs": {
|
||||
"directModules": [
|
||||
"packages/webui/tests/agents-browser.test.mjs",
|
||||
"packages/webui/tests/agents.test.mjs",
|
||||
"packages/webui/tests/assistant.test.mjs",
|
||||
"packages/webui/tests/board-held.test.mjs",
|
||||
"packages/webui/tests/browser-edge.test.mjs",
|
||||
"packages/webui/tests/bus-browser.test.mjs",
|
||||
"packages/webui/tests/bus.test.mjs",
|
||||
"packages/webui/tests/channel-chat.test.mjs",
|
||||
"packages/webui/tests/chat-browser.test.mjs",
|
||||
"packages/webui/tests/chat-instances.test.mjs",
|
||||
"packages/webui/tests/chat.test.mjs",
|
||||
"packages/webui/tests/discord.test.mjs",
|
||||
"packages/webui/tests/docs-browser.test.mjs",
|
||||
"packages/webui/tests/editor-browser.test.mjs",
|
||||
"packages/webui/tests/fixture.mjs",
|
||||
"packages/webui/tests/granted-user.test.mjs",
|
||||
"packages/webui/tests/history-fixture.mjs",
|
||||
"packages/webui/tests/instances-browser.test.mjs",
|
||||
"packages/webui/tests/instances.test.mjs",
|
||||
"packages/webui/tests/limits-browser.test.mjs",
|
||||
"packages/webui/tests/limits-route.test.mjs",
|
||||
"packages/webui/tests/messages.test.mjs",
|
||||
"packages/webui/tests/preview.test.mjs",
|
||||
"packages/webui/tests/processing-browser.test.mjs",
|
||||
"packages/webui/tests/processing.test.mjs",
|
||||
"packages/webui/tests/project-file.test.mjs",
|
||||
"packages/webui/tests/proof.test.mjs",
|
||||
"packages/webui/tests/reads.test.mjs",
|
||||
"packages/webui/tests/relaunch.test.mjs",
|
||||
"packages/webui/tests/return-flow.test.mjs",
|
||||
"packages/webui/tests/routes-browser.test.mjs",
|
||||
"packages/webui/tests/routes.test.mjs",
|
||||
"packages/webui/tests/send-browser.test.mjs",
|
||||
"packages/webui/tests/send.test.mjs",
|
||||
"packages/webui/tests/serve.test.mjs",
|
||||
"packages/webui/tests/settings-form.test.mjs",
|
||||
"packages/webui/tests/settings-write.test.mjs",
|
||||
"packages/webui/tests/shell.test.mjs",
|
||||
"packages/webui/tests/sso-browser.test.mjs",
|
||||
"packages/webui/tests/sso.test.mjs",
|
||||
"packages/webui/tests/tester-listener.test.mjs",
|
||||
"packages/webui/tests/thread-claims-browser.test.mjs",
|
||||
"packages/webui/tests/thread-claims.test.mjs",
|
||||
"packages/webui/tests/usage-browser.test.mjs",
|
||||
"packages/webui/tests/usage.test.mjs",
|
||||
"packages/webui/tests/views.test.mjs"
|
||||
],
|
||||
"wholeTestReaders": [
|
||||
"packages/webui/tests/agents-browser.test.mjs",
|
||||
"packages/webui/tests/agents.test.mjs",
|
||||
"packages/webui/tests/assistant.test.mjs",
|
||||
"packages/webui/tests/board-held.test.mjs",
|
||||
"packages/webui/tests/browser-edge.test.mjs",
|
||||
"packages/webui/tests/browser.test.mjs",
|
||||
"packages/webui/tests/bus-browser.test.mjs",
|
||||
"packages/webui/tests/bus.test.mjs",
|
||||
"packages/webui/tests/channel-chat.test.mjs",
|
||||
"packages/webui/tests/chat-browser.test.mjs",
|
||||
"packages/webui/tests/chat-instances.test.mjs",
|
||||
"packages/webui/tests/chat.test.mjs",
|
||||
"packages/webui/tests/conversation.test.mjs",
|
||||
"packages/webui/tests/discord.test.mjs",
|
||||
"packages/webui/tests/docs-browser.test.mjs",
|
||||
"packages/webui/tests/editor-browser.test.mjs",
|
||||
"packages/webui/tests/editor.test.mjs",
|
||||
"packages/webui/tests/granted-user.test.mjs",
|
||||
"packages/webui/tests/history-return-flow.test.mjs",
|
||||
"packages/webui/tests/instances-browser.test.mjs",
|
||||
"packages/webui/tests/instances.test.mjs",
|
||||
"packages/webui/tests/limits-browser.test.mjs",
|
||||
"packages/webui/tests/limits-route.test.mjs",
|
||||
"packages/webui/tests/messages.test.mjs",
|
||||
"packages/webui/tests/preview.test.mjs",
|
||||
"packages/webui/tests/processing-browser.test.mjs",
|
||||
"packages/webui/tests/processing.test.mjs",
|
||||
"packages/webui/tests/project-file.test.mjs",
|
||||
"packages/webui/tests/proof-cli.test.mjs",
|
||||
"packages/webui/tests/proof.test.mjs",
|
||||
"packages/webui/tests/reads.test.mjs",
|
||||
"packages/webui/tests/relaunch.test.mjs",
|
||||
"packages/webui/tests/return-flow.test.mjs",
|
||||
"packages/webui/tests/routes-browser.test.mjs",
|
||||
"packages/webui/tests/routes.test.mjs",
|
||||
"packages/webui/tests/send-browser.test.mjs",
|
||||
"packages/webui/tests/send.test.mjs",
|
||||
"packages/webui/tests/serve.test.mjs",
|
||||
"packages/webui/tests/settings-form.test.mjs",
|
||||
"packages/webui/tests/settings-write.test.mjs",
|
||||
"packages/webui/tests/shell.test.mjs",
|
||||
"packages/webui/tests/sso-browser.test.mjs",
|
||||
"packages/webui/tests/sso.test.mjs",
|
||||
"packages/webui/tests/tester-browser.test.mjs",
|
||||
"packages/webui/tests/tester-cli.test.mjs",
|
||||
"packages/webui/tests/tester-listener.test.mjs",
|
||||
"packages/webui/tests/thread-claims-browser.test.mjs",
|
||||
"packages/webui/tests/thread-claims.test.mjs",
|
||||
"packages/webui/tests/usage-browser.test.mjs",
|
||||
"packages/webui/tests/usage.test.mjs",
|
||||
"packages/webui/tests/views.test.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"path": "packages/bus/src/broker.mjs",
|
||||
"old": "34f42b312c3d4ae9a975c5d27214414606cb9154",
|
||||
"new": "34f42b312c3d4ae9a975c5d27214414606cb9154"
|
||||
},
|
||||
{
|
||||
"path": "packages/bus/tests/creative.test.mjs",
|
||||
"old": "69deed0c277da57ef36d09097f4164180e744960",
|
||||
"new": "69deed0c277da57ef36d09097f4164180e744960"
|
||||
},
|
||||
{
|
||||
"path": "packages/bus/tests/threads.test.mjs",
|
||||
"old": "83597aea2f53a587b338b32a8e25e9ddac427ca1",
|
||||
"new": "83597aea2f53a587b338b32a8e25e9ddac427ca1"
|
||||
},
|
||||
{
|
||||
"path": "packages/bus/tests/user-principal.test.mjs",
|
||||
"old": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3",
|
||||
"new": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/src/launcher.mjs",
|
||||
"old": "ef04082e6e0ce574ca3af546871838ac34a5e5ee",
|
||||
"new": "ef04082e6e0ce574ca3af546871838ac34a5e5ee"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/launcher.test.mjs",
|
||||
"old": "4449e824038428b8dd4e5c299cc126ab1e842b58",
|
||||
"new": "4449e824038428b8dd4e5c299cc126ab1e842b58"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/thread-launch.test.mjs",
|
||||
"old": "0fef61471873c35652605168dedd7dcd22cb4944",
|
||||
"new": "0fef61471873c35652605168dedd7dcd22cb4944"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/src/chat-http.mjs",
|
||||
"old": "ccf9e56879a90656c7061ce0d614bfaaf04987ba",
|
||||
"new": "ccf9e56879a90656c7061ce0d614bfaaf04987ba"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/src/chat.mjs",
|
||||
"old": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1",
|
||||
"new": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/src/public/chat.js",
|
||||
"old": "f114af96936122f620ebf0a561e3a41a0ff6c955",
|
||||
"new": "f114af96936122f620ebf0a561e3a41a0ff6c955"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/src/serve.mjs",
|
||||
"old": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a",
|
||||
"new": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/chat-browser.test.mjs",
|
||||
"old": "8cb7a6270bc8c0f2191caae713842609df0deb92",
|
||||
"new": "8cb7a6270bc8c0f2191caae713842609df0deb92"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/chat-instances.test.mjs",
|
||||
"old": "8018efd8ad48108293e41b77fc6c786500ef2471",
|
||||
"new": "8018efd8ad48108293e41b77fc6c786500ef2471"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/chat.test.mjs",
|
||||
"old": "acb49d2d659373b56409cf144f56fe6c29ffdac5",
|
||||
"new": "acb49d2d659373b56409cf144f56fe6c29ffdac5"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
1: d5bf46ecf ! 1: 268835a99 wip: publish unverified row 219 inspection candidate
|
||||
@@ packages/webui/src/chat-http.mjs: export async function chatHTTP(req, res, { pat
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
|
||||
## packages/webui/src/chat.mjs ##
|
||||
-@@ packages/webui/src/chat.mjs: export function createChat({ call }) {
|
||||
+@@ packages/webui/src/chat.mjs: export function createChat({ call, owner = null }) {
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
2: c7bf3622d = 2: 994860e70 docs: rebase row 219 gate packet and record run bounds
|
||||
3: b97a3ff97 = 3: 0e040cf94 test: give row 219 persona fixture its explicit session
|
||||
4: 7cbd839d0 = 4: 30cf10301 test: pin the compaction audit before the persona refusal
|
||||
5: 5f2ef92e5 = 5: 28bf34f27 test: assert creative history in its native session store
|
||||
@@ -0,0 +1 @@
|
||||
28bf34f272ec8e0ad0f7155556d2c92cdb3dd5be
|
||||
@@ -0,0 +1,22 @@
|
||||
# Row219 r4 / mos1429
|
||||
|
||||
Approved setup-only creative ops thread binding, exact import pin update,
|
||||
and new direct broker bindLaunch regression. See root BUILD.md and the
|
||||
assertion-preservation JSON/diff. Red-first evidence awaits Sage CLEAR.
|
||||
No product changes; root and this round list16 paths. T24 is new; T01 now
|
||||
targets the direct creative bind refusal. 24 mutants in the full run.
|
||||
|
||||
## Short slot receipt, sage-3302
|
||||
|
||||
2026-10-23 08:41:20Z-08:41:45Z, HEAD 7e70de9e6, manifest
|
||||
451d3fcbeb6a41c3a505600898ba2025985f44d8391e2cd823ab84f1d4b50621.
|
||||
Detached bfa421b6c reds: sage-1908 thread-required at bindLaunch;
|
||||
project-runtime ERR_ASSERTION on exact singular/plural import pin.
|
||||
Whole green files: grant-executor 42/0/0, project-runtime 3/0/0,
|
||||
threads 20/0/0, total 65/0/0, no cancellations or skips.
|
||||
T24 assertion-killed. Its exact failure was "Expected values to be strictly
|
||||
equal", actual 'decision-required', expected 'host-creative-role', code
|
||||
ERR_ASSERTION. Removing the creative guard reaches the later decision-required
|
||||
fence; this is a code-mismatch kill, not proof that host.run executed.
|
||||
All 16 hashes restored, cwd node/chromium sweep empty. Raw TAP and per-stage
|
||||
exit/duration/counts are in short-run/. No unexpected red and no retry.
|
||||
@@ -0,0 +1,26 @@
|
||||
# mos-1429 short slot (not run)
|
||||
|
||||
Wait for Sage CLEAR. Estimate 3-5 minutes. No Docker, browser or suites.
|
||||
Use a fresh short TMPDIR under /mnt/storage/scratch/tmp for each stage,
|
||||
a dead DOCKER_HOST, explicit TAP, timeout -k 10 300 and a process group.
|
||||
Check the green HEAD and 16-path manifest before any stage; check the red
|
||||
worktree HEAD is bfa421b6cd9b2b54d8da119e73b004aeafe606c3.
|
||||
|
||||
1. Red worktree /mnt/storage/scratch/rocko-r219-red-mos1429:
|
||||
grant-executor.test.mjs, pattern `sage-1908: once the agent of an applied grant is a creative role`.
|
||||
Require the named test to fail with thread-required at bindLaunch.
|
||||
2. Same red worktree: project-runtime.test.mjs, pattern
|
||||
`Broker keeps the sandbox client import closure`.
|
||||
Require ERR_ASSERTION on the singular/plural import pin.
|
||||
3. Green worktree: grant-executor.test.mjs, project-runtime.test.mjs,
|
||||
threads.test.mjs, each whole, sequential. Require zero failed/cancelled.
|
||||
4. T24: remove only the host.run creative guard using the table's exact-once
|
||||
anchor. Run the sage-1908 pattern; require its named not-ok and
|
||||
ERR_ASSERTION. Syntax errors and timeouts are invalid, never kills.
|
||||
5. Restore exact bytes in finally, verify all 16 hashes, sweep owned
|
||||
process groups and cwd node/chromium processes, return slot with counts.
|
||||
Preserve raw TAP, exits, durations and skip lines. Push only as granted.
|
||||
|
||||
Any unexpected red stops without retry or edit and goes to Sage for Mos.
|
||||
The later full restart uses 10 touched whole files, bus/cli/webui, all shell
|
||||
suites, 24 mutants and the reader audit, on a separate CLEAR after review.
|
||||
@@ -0,0 +1,52 @@
|
||||
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
|
||||
index e449b7d77..46235d16b 100644
|
||||
--- a/packages/bus/tests/grant-executor.test.mjs
|
||||
+++ b/packages/bus/tests/grant-executor.test.mjs
|
||||
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
|
||||
const f = await setup(t);
|
||||
assert.equal((await f.approve()).end.action, 'grant.applied');
|
||||
const r = await restart(t, f, creativeOps(f.business));
|
||||
- const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
|
||||
+ const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
+ const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
|
||||
+ const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
|
||||
+ const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
|
||||
r.broker.request(cap, { verb: 'role.claim' });
|
||||
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
|
||||
const before = f.requests().length;
|
||||
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
|
||||
index a6086e56c..7275ad902 100644
|
||||
--- a/packages/bus/tests/project-runtime.test.mjs
|
||||
+++ b/packages/bus/tests/project-runtime.test.mjs
|
||||
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
+ // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
|
||||
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
|
||||
index 1410ec3fa..280e03184 100644
|
||||
--- a/packages/bus/tests/threads.test.mjs
|
||||
+++ b/packages/bus/tests/threads.test.mjs
|
||||
@@ -640,3 +640,17 @@ test('C2-09a: explicit missing, non-thread and malformed selectors create no thr
|
||||
const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
|
||||
assert.equal(legacy.instance, 'assistant');
|
||||
});
|
||||
+
|
||||
+
|
||||
+test('C2-09a: creative bindLaunch without a thread refuses thread-required', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`creative${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, {}])),
|
||||
+ } }));
|
||||
+ for (const instance of Object.keys(extra)) {
|
||||
+ assert.throws(() => ctx.launch(`unthreaded-${instance}`, instance), { code: 'thread-required' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM role_claims'), 0);
|
||||
+});
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"baseHead": "bfa421b6cd9b2b54d8da119e73b004aeafe606c3",
|
||||
"ruling": "mos-1429 / sage-3291",
|
||||
"grantTest": {
|
||||
"old": "test('sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record', async (t) => {\n const f = await setup(t);\n assert.equal((await f.approve()).end.action, 'grant.applied');\n const r = await restart(t, f, creativeOps(f.business));\n const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });\n r.broker.request(cap, { verb: 'role.claim' });\n const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });\n const before = f.requests().length;\n await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');\n assert.equal(f.requests().length, before);\n assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);\n assert.notEqual(f.record(), undefined);\n});\n",
|
||||
"new": "test('sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record', async (t) => {\n const f = await setup(t);\n assert.equal((await f.approve()).end.action, 'grant.applied');\n const r = await restart(t, f, creativeOps(f.business));\n const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });\n const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;\n const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;\n const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });\n r.broker.request(cap, { verb: 'role.claim' });\n const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });\n const before = f.requests().length;\n await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');\n assert.equal(f.requests().length, before);\n assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);\n assert.notEqual(f.record(), undefined);\n});\n",
|
||||
"preservedOldLines": [
|
||||
" assert.equal((await f.approve()).end.action, 'grant.applied');",
|
||||
" const r = await restart(t, f, creativeOps(f.business));",
|
||||
" const before = f.requests().length;",
|
||||
" await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');",
|
||||
" assert.equal(f.requests().length, before);",
|
||||
" assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);",
|
||||
" assert.notEqual(f.record(), undefined);"
|
||||
],
|
||||
"preservedNewLines": [
|
||||
" assert.equal((await f.approve()).end.action, 'grant.applied');",
|
||||
" const r = await restart(t, f, creativeOps(f.business));",
|
||||
" const before = f.requests().length;",
|
||||
" await refusesWith(r.broker.requestHost(cap, { verb: 'host.run', args: { host: 'web1', argv: ['systemctl', 'status'] } }, executor), 'host-creative-role');",
|
||||
" assert.equal(f.requests().length, before);",
|
||||
" assert.equal(readOr(join(f.dir, 'sudo.log'), '').includes('systemctl'), false);",
|
||||
" assert.notEqual(f.record(), undefined);"
|
||||
]
|
||||
},
|
||||
"projectRuntime": {
|
||||
"oldPin": [
|
||||
" \"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';\","
|
||||
],
|
||||
"newPin": [
|
||||
" \"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';\","
|
||||
],
|
||||
"oldDoesNotMatch": [
|
||||
" assert.doesNotMatch(source, /\\bimport\\s*\\(/);",
|
||||
" assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
|
||||
" assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
|
||||
" assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);"
|
||||
],
|
||||
"newDoesNotMatch": [
|
||||
" assert.doesNotMatch(source, /\\bimport\\s*\\(/);",
|
||||
" assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
|
||||
" assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);",
|
||||
" assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\\bimport\\b/);"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
80793d824e05baaef4820f4be7790aba55971b6f
|
||||
@@ -0,0 +1,832 @@
|
||||
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
||||
index d76085739..11061c778 100644
|
||||
--- a/packages/bus/src/broker.mjs
|
||||
+++ b/packages/bus/src/broker.mjs
|
||||
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
+import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
+// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
@@ -598,7 +598,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#threaded(business, role) {
|
||||
- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
+ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
|
||||
}
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
@@ -6068,24 +6068,27 @@ export class Broker {
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
}
|
||||
- // A thread belongs to the user who made it and to the business's one thread instance.
|
||||
+ // A thread belongs to the user who made it and to one declared thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
- keys(a, ['project', 'name'], ['project', 'name']);
|
||||
+ keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
+ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
+ const instance = a.instance === undefined
|
||||
+ ? (instances.length === 1 ? instances[0] : null)
|
||||
+ : identifier(a.instance);
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
+ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
|
||||
+ fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
- // The bus never picks between instances; the business file declares exactly one.
|
||||
- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
- if (!instances.length) fail('thread-instance-missing');
|
||||
- if (instances.length > 1) fail('thread-instance-ambiguous');
|
||||
+ // The human selects an instance; omission is compatible only with one candidate.
|
||||
+ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
+ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -6094,13 +6097,13 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.project,
|
||||
- instances[0],
|
||||
+ instance,
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
- return { id: tid, instance: instances[0] };
|
||||
+ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
|
||||
+ return { id: tid, instance };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs
|
||||
index 6cfbf1cd6..6d421ba19 100644
|
||||
--- a/packages/bus/tests/creative.test.mjs
|
||||
+++ b/packages/bus/tests/creative.test.mjs
|
||||
@@ -44,15 +44,18 @@ function setup(t) {
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
+ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
||||
+ const threads = { assistant: thread };
|
||||
for (const role of Object.keys(roles)) {
|
||||
- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
+ if (['bean', 'quill'].includes(role))
|
||||
+ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
||||
+ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
- return { call, count, store };
|
||||
+ return { call, count, store, threads };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
- const { call, count } = setup(t);
|
||||
+ const { call, count, threads } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
- call('human', 'message.send', { to: who, body: 'hello' });
|
||||
+ call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
||||
diff --git a/packages/bus/tests/grant-executor.test.mjs b/packages/bus/tests/grant-executor.test.mjs
|
||||
index e449b7d77..46235d16b 100644
|
||||
--- a/packages/bus/tests/grant-executor.test.mjs
|
||||
+++ b/packages/bus/tests/grant-executor.test.mjs
|
||||
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
|
||||
const f = await setup(t);
|
||||
assert.equal((await f.approve()).end.action, 'grant.applied');
|
||||
const r = await restart(t, f, creativeOps(f.business));
|
||||
- const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
|
||||
+ const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
+ const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
|
||||
+ const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
|
||||
+ const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
|
||||
r.broker.request(cap, { verb: 'role.claim' });
|
||||
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
|
||||
const before = f.requests().length;
|
||||
diff --git a/packages/bus/tests/project-runtime.test.mjs b/packages/bus/tests/project-runtime.test.mjs
|
||||
index a6086e56c..7275ad902 100644
|
||||
--- a/packages/bus/tests/project-runtime.test.mjs
|
||||
+++ b/packages/bus/tests/project-runtime.test.mjs
|
||||
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
+ // Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
|
||||
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs
|
||||
index 8b31da9f9..280e03184 100644
|
||||
--- a/packages/bus/tests/threads.test.mjs
|
||||
+++ b/packages/bus/tests/threads.test.mjs
|
||||
@@ -601,3 +601,56 @@ test('project.create keeps unrelated names with and without a repository', t =>
|
||||
{ id: linked.id, name: 'work', repo: 'stack' },
|
||||
]);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
|
||||
+ } }));
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
|
||||
+ for (const [instance, role] of Object.entries(extra)) {
|
||||
+ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
|
||||
+ assert.equal(receipt.instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
|
||||
+ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
|
||||
+ ctx.launch(instance, instance, { thread: receipt.id });
|
||||
+ assert.equal(ctx.claim(instance).thread, receipt.id);
|
||||
+ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
|
||||
+ const read = ctx.call(instance, 'message.receive');
|
||||
+ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
|
||||
+ assert.equal(ctx.threadOf(sent.id), receipt.id);
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
|
||||
+ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
|
||||
+ { code: 'thread-required-use-thread-send' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
|
||||
+ const ctx = setup(t);
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
|
||||
+ for (const instance of ['not-declared', 'pm', 'coder'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
|
||||
+ for (const instance of [null, 7, {}, [], '', 'bad name'])
|
||||
+ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
|
||||
+ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
|
||||
+ assert.equal(legacy.instance, 'assistant');
|
||||
+});
|
||||
+
|
||||
+
|
||||
+test('C2-09a: creative bindLaunch without a thread refuses thread-required', t => {
|
||||
+ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
|
||||
+ const extra = Object.fromEntries(creative.map((definition, i) => [`creative${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
|
||||
+ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
|
||||
+ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, {}])),
|
||||
+ } }));
|
||||
+ for (const instance of Object.keys(extra)) {
|
||||
+ assert.throws(() => ctx.launch(`unthreaded-${instance}`, instance), { code: 'thread-required' });
|
||||
+ }
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
|
||||
+ assert.equal(ctx.count('SELECT count(*) n FROM role_claims'), 0);
|
||||
+});
|
||||
diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs
|
||||
index 4dcd1ebeb..8471ddf85 100644
|
||||
--- a/packages/bus/tests/user-principal.test.mjs
|
||||
+++ b/packages/bus/tests/user-principal.test.mjs
|
||||
@@ -23,7 +23,7 @@ const grant = (over = {}) => ({
|
||||
});
|
||||
|
||||
// `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does.
|
||||
-function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
+function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) {
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
+ ...threadRoles,
|
||||
},
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
},
|
||||
@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
|
||||
test('R3 r4: a non-function access throws before the store is read', () => {
|
||||
assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => {
|
||||
+ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
+ const project = ctx.call('human', 'project.create', { name: 'shared' }).id;
|
||||
+ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id;
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project] })];
|
||||
+ ctx.user('alice');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused');
|
||||
+ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000'])
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused');
|
||||
+ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0);
|
||||
+ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' });
|
||||
+ assert.equal(allowed.instance, 'assistant');
|
||||
+ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })];
|
||||
+ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' });
|
||||
+ assert.equal(bean.instance, 'bean');
|
||||
+ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused');
|
||||
+});
|
||||
diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs
|
||||
index bfe55b61e..d8248284c 100644
|
||||
--- a/packages/cli/src/launcher.mjs
|
||||
+++ b/packages/cli/src/launcher.mjs
|
||||
@@ -53,7 +53,7 @@
|
||||
// session.json names it as gitSocket.
|
||||
//
|
||||
// Thread sessions (row 121, #1615). A thread instance (an instance whose
|
||||
-// role definition is the assistant) runs once per thread, never on its own:
|
||||
+// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own:
|
||||
// a launch from a session or --pm refuses thread-required. Every tick
|
||||
// (threadTickMs) the host asks the broker for the business's threads
|
||||
// (launch op threadsState) and:
|
||||
@@ -139,6 +139,7 @@ import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
+import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||
@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000;
|
||||
const LINE_MAX = 4096;
|
||||
// How often the host asks for the threads' state; a poll period, not a limit.
|
||||
const THREAD_TICK_MS = 2000;
|
||||
-// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
-const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
@@ -331,7 +330,7 @@ export function createLauncher({
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`);
|
||||
@@ -997,7 +996,7 @@ export function createLauncher({
|
||||
function ending(r) {
|
||||
const runs = r.runs ?? [];
|
||||
const at = Date.parse(r.at);
|
||||
- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION;
|
||||
+ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition);
|
||||
return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) ||
|
||||
(!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at)));
|
||||
}
|
||||
diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs
|
||||
index 2202c7753..baabd843e 100644
|
||||
--- a/packages/cli/tests/launcher.test.mjs
|
||||
+++ b/packages/cli/tests/launcher.test.mjs
|
||||
@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const dir = join(given.root, "config", "personas", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 });
|
||||
- const { f, host, launcher } = await setup(t, undefined, given);
|
||||
- // The chief of staff holds role.launch; a test chief session bound to this process asks.
|
||||
- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
- await new Client({ path: host.path, cap: chief.cap }).call("role.claim");
|
||||
- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log.
|
||||
- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" });
|
||||
- const [e] = log(f);
|
||||
- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]);
|
||||
+ // Row 219: creative instances launch from their thread, not from a chief's standalone request.
|
||||
+ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture.
|
||||
+ const thread = 'persona-thread';
|
||||
+ const SESSION = 'persona-session';
|
||||
+ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({
|
||||
+ ...host,
|
||||
+ op: message => message.op === 'threadsState'
|
||||
+ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }])
|
||||
+ : host.op(message),
|
||||
+ }) });
|
||||
+ // The thread path records row 270's run-start count before the bundle's persona refusal.
|
||||
+ await launcher.tick();
|
||||
+ const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
+ const entries = log(f);
|
||||
+ assert.equal(entries.length, 2);
|
||||
+ const { at, ...compactions } = entries[0];
|
||||
+ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null });
|
||||
+ const e = entries[1];
|
||||
+ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
|
||||
+ assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
- const runs = readdirSync(join(f.dataRoot, "launches", "acme"));
|
||||
assert.equal(runs.length, 1);
|
||||
- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]);
|
||||
+ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false);
|
||||
});
|
||||
|
||||
test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => {
|
||||
diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs
|
||||
index 0d207a0a0..fa3adb866 100644
|
||||
--- a/packages/cli/tests/thread-launch.test.mjs
|
||||
+++ b/packages/cli/tests/thread-launch.test.mjs
|
||||
@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t
|
||||
assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]);
|
||||
});
|
||||
|
||||
+// Admission refuses before bundle construction or a sandbox is needed.
|
||||
+test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => {
|
||||
+ const prepared = prepare(t, { edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ doc.launch.instances.push('bean');
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const { f } = prepared;
|
||||
+ const host = await start(t, prepared);
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+ assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
+ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]);
|
||||
+});
|
||||
+
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the
|
||||
// assistant's limit at 0, so it is at its limit before any turn. The real check runs through the
|
||||
// bus's plug-in loader.
|
||||
@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn:
|
||||
assert.deepEqual(threadRuns(f, A), []);
|
||||
assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null);
|
||||
});
|
||||
+
|
||||
+test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ const vars = { ...doc.roles.assistant.vars };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.roles.bean = { definition: 'companion', vars: { ...vars } };
|
||||
+ doc.roles.chet = { definition: 'continuity', vars: { ...vars } };
|
||||
+ doc.launch.instances = ['coder', 'bean', 'chet'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ const host = await start(t, prepared), { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id;
|
||||
+ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id;
|
||||
+ const send = async (thread, instance, body) => {
|
||||
+ const message = host.call('thread.send', { thread, body });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ const reply = outcome;
|
||||
+ assert.equal(reply.from_role, instance);
|
||||
+ assert.equal(reply.to_role, 'human');
|
||||
+ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]);
|
||||
+ };
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []);
|
||||
+ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []);
|
||||
+ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']);
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n');
|
||||
+ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n');
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false);
|
||||
+ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false);
|
||||
+ const launches = launchLog(f).filter(row => row.event === 'launch');
|
||||
+ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean));
|
||||
+ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet));
|
||||
+ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
+ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim');
|
||||
+ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => {
|
||||
+ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => {
|
||||
+ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' };
|
||||
+ delete doc.roles.assistant;
|
||||
+ doc.launch.instances = ['coder', 'bean'];
|
||||
+ return doc;
|
||||
+ } });
|
||||
+ let requests = [];
|
||||
+ const host = await start(t, prepared, { ops: () => ({
|
||||
+ lifecycleRequests: () => requests,
|
||||
+ lifecycleTake: () => ({ ...requests[0], go: true }),
|
||||
+ lifecycleDone: () => { requests = []; return {}; },
|
||||
+ }) });
|
||||
+ const { f } = prepared;
|
||||
+ const project = host.call('project.create', { name: 'Writing' }).id;
|
||||
+ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id);
|
||||
+ for (const thread of threads) {
|
||||
+ const message = host.call('thread.send', { thread, body: 'Stay in this thread' });
|
||||
+ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) ||
|
||||
+ launchLog(f).find(row => row.thread === thread && row.event === 'refused'));
|
||||
+ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`);
|
||||
+ }
|
||||
+ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run);
|
||||
+ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end',
|
||||
+ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }];
|
||||
+ await host.launcher.tick();
|
||||
+ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0]));
|
||||
+ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]);
|
||||
+ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
+ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
+});
|
||||
diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs
|
||||
index 7980c1442..63d3ca974 100644
|
||||
--- a/packages/webui/src/chat-http.mjs
|
||||
+++ b/packages/webui/src/chat-http.mjs
|
||||
@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
(Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error();
|
||||
action = 'createProject'; args = v;
|
||||
} else if (path === '/api/chat/threads') {
|
||||
- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error();
|
||||
+ 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] === 'messages') {
|
||||
if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error();
|
||||
diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs
|
||||
index 1b0a41dce..5a1ed8bf1 100644
|
||||
--- a/packages/webui/src/chat.mjs
|
||||
+++ b/packages/webui/src/chat.mjs
|
||||
@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) {
|
||||
projects: () => channel('project.list', {}),
|
||||
createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }),
|
||||
threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }),
|
||||
- createThread: ({ project, name }) => channel('thread.create', { project, name }),
|
||||
+ 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 }),
|
||||
diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js
|
||||
index a6b805c1b..15066bdc6 100644
|
||||
--- a/packages/webui/src/public/chat.js
|
||||
+++ b/packages/webui/src/public/chat.js
|
||||
@@ -14,7 +14,7 @@
|
||||
return states.get(id);
|
||||
};
|
||||
const form = key => {
|
||||
- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false });
|
||||
+ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
class ChatError extends Error {
|
||||
@@ -58,7 +58,11 @@
|
||||
function creation(kind, key) {
|
||||
const f = form(key), project = kind === 'project';
|
||||
const repo = project && current.repos.length ? `<label>Repository (optional)<select id="chat-repo" data-chat-field="repo"><option value="">No repository link</option>${current.repos.map(r => `<option value="${esc(r)}"${r === f.repo ? ' selected' : ''}>${txt(r)}</option>`).join('')}</select></label>` : '';
|
||||
- return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}<button class="btn" type="submit"${disabled(f.pending)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(f.receipt)}</p></form>`;
|
||||
+ const choices = current.instances ?? [];
|
||||
+ const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
|
||||
+ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
|
||||
+ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
|
||||
+ return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
|
||||
}
|
||||
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>';
|
||||
@@ -106,7 +110,7 @@
|
||||
const f = document.getElementById(keep.field.id);
|
||||
if (f && !f.disabled) {
|
||||
f.focus({ preventScroll: true });
|
||||
- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
+ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end);
|
||||
}
|
||||
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
|
||||
}
|
||||
@@ -126,7 +130,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], 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 };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
const s = kind === 'thread' ? state(id) : null;
|
||||
@@ -147,6 +151,25 @@
|
||||
s.receipt = 'Message shown in this conversation.'; s.receiptId = null;
|
||||
}
|
||||
}
|
||||
+ if (kind === 'project') {
|
||||
+ try {
|
||||
+ const listed = await request('instances', undefined, signal);
|
||||
+ if (g !== generation || out()) return;
|
||||
+ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string'))
|
||||
+ throw new ChatError('invalid-response');
|
||||
+ current.instances = listed.rows; current.instanceError = '';
|
||||
+ const f = form(id);
|
||||
+ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) {
|
||||
+ f.instance = ''; f.instanceChosen = true;
|
||||
+ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.';
|
||||
+ }
|
||||
+ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance;
|
||||
+ } catch (err) {
|
||||
+ if (err.name === 'AbortError' || out() || g !== generation) return;
|
||||
+ current.instances = [];
|
||||
+ current.instanceError = "Couldn't read the thread instances; reload to try again";
|
||||
+ }
|
||||
+ }
|
||||
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
|
||||
if (kind === 'list') {
|
||||
try {
|
||||
@@ -179,7 +202,11 @@
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-body') state(current.id).draft = event.target.value;
|
||||
const f = event.target.closest('.chat-create');
|
||||
- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value;
|
||||
+ if (f && event.target.dataset.chatField) {
|
||||
+ const draft = form(f.dataset.key), field = event.target.dataset.chatField;
|
||||
+ draft[field] = event.target.value;
|
||||
+ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
|
||||
+ }
|
||||
});
|
||||
document.addEventListener('scroll', event => {
|
||||
if (event.target.matches?.('.chat-log') && current?.kind === 'thread') {
|
||||
@@ -232,7 +259,8 @@
|
||||
void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);
|
||||
} else if (event.target.classList.contains('chat-create')) {
|
||||
const key = event.target.dataset.key, f = form(key), project = key === 'projects';
|
||||
- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name };
|
||||
+ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return;
|
||||
+ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance };
|
||||
void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads');
|
||||
}
|
||||
});
|
||||
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
||||
index cd51a0b6e..56fcdd4e1 100644
|
||||
--- a/packages/webui/src/serve.mjs
|
||||
+++ b/packages/webui/src/serve.mjs
|
||||
@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
|
||||
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
|
||||
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
|
||||
import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
+import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
|
||||
@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
}
|
||||
+ // Row 219: labels only, from this listener's own human or tester bus, after auth above.
|
||||
+ if (path === '/api/chat/instances') {
|
||||
+ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
|
||||
+ if (search) return json(res, 400, { error: 'invalid-request' });
|
||||
+ if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
|
||||
+ try {
|
||||
+ const value = await bus.instances();
|
||||
+ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
|
||||
+ throw new BusReadError('invalid-response');
|
||||
+ const rows = Object.entries(value.instances)
|
||||
+ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
|
||||
+ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
|
||||
+ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
|
||||
+ return json(res, 200, { rows, at: new Date().toISOString() });
|
||||
+ } catch (err) {
|
||||
+ const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
+ return json(res, busStatus(code), { error: code });
|
||||
+ }
|
||||
+ }
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
|
||||
index f3eeeca96..cc3ec093d 100644
|
||||
--- a/packages/webui/tests/chat-browser.test.mjs
|
||||
+++ b/packages/webui/tests/chat-browser.test.mjs
|
||||
@@ -12,6 +12,7 @@ const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
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.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
||||
window.fetch=async (url,init={})=>{
|
||||
@@ -33,6 +34,7 @@ const stub = `(() => {
|
||||
window.__reads.push(path);
|
||||
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
||||
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.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
||||
if(window.__pages.length)return data(window.__pages.shift());
|
||||
@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert
|
||||
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
||||
await click('#chat-thread-form button[type=submit]');
|
||||
await wait('document.querySelector("#chat-send-form")');
|
||||
- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' });
|
||||
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
||||
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
||||
});
|
||||
@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an
|
||||
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
||||
});
|
||||
+
|
||||
+test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
|
||||
+ const { b, wait, set, click, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===3');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
|
||||
+ assert.match(labels[1], /\(bean\)/);
|
||||
+ assert.match(labels[2], /\(chet\)/);
|
||||
+ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
|
||||
+ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
|
||||
+ assert.equal(await b.evaluate('window.injected'), undefined);
|
||||
+ await set('#chat-thread-name', 'Bean discussion');
|
||||
+ await click('#chat-thread-form button');
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
|
||||
+ await click('#chat-thread-form button');
|
||||
+ await wait('window.__writes.length===1');
|
||||
+ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
|
||||
+ const { b, wait, set, refresh } = await setup(t);
|
||||
+ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
|
||||
+ await refresh();
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ await set('#chat-instance', 'bean');
|
||||
+ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
||||
+ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ await refresh();
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
|
||||
+ await set('#chat-instance', 'chet');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
||||
+});
|
||||
+
|
||||
+test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
|
||||
+ const { b, wait } = await setup(t, '?readonly');
|
||||
+ await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
||||
+ await wait('document.querySelectorAll("#chat-instance option").length===2');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
|
||||
+ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
||||
+ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
||||
+ assert.equal(await b.evaluate('window.__writes.length'), 0);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs
|
||||
new file mode 100644
|
||||
index 000000000..893c38f80
|
||||
--- /dev/null
|
||||
+++ b/packages/webui/tests/chat-instances.test.mjs
|
||||
@@ -0,0 +1,87 @@
|
||||
+import test from 'node:test';
|
||||
+import assert from 'node:assert/strict';
|
||||
+import { request } from 'node:http';
|
||||
+import { mkdtempSync, rmSync } from 'node:fs';
|
||||
+import { join } from 'node:path';
|
||||
+import { tmpdir } from 'node:os';
|
||||
+import { startServer } from '../src/serve.mjs';
|
||||
+import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs';
|
||||
+import { close } from './fixture.mjs';
|
||||
+
|
||||
+const path = '/api/chat/instances';
|
||||
+const preview = { host: 'chat.example', header: 'x-user', user: 'jason' };
|
||||
+const snapshot = {
|
||||
+ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' },
|
||||
+ instances: {
|
||||
+ assistant: { definition: 'assistant', vars: { model: 'private-model' } },
|
||||
+ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } },
|
||||
+ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } },
|
||||
+ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } },
|
||||
+ },
|
||||
+};
|
||||
+function get(server, { route = path, method = 'GET', headers = {} } = {}) {
|
||||
+ const address = server.address(), unix = typeof address === 'string';
|
||||
+ return new Promise((resolve, reject) => {
|
||||
+ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }),
|
||||
+ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => {
|
||||
+ let text = '';
|
||||
+ res.on('data', chunk => text += chunk);
|
||||
+ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) }));
|
||||
+ });
|
||||
+ req.on('error', reject); req.end();
|
||||
+ });
|
||||
+}
|
||||
+test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => {
|
||||
+ let calls = 0;
|
||||
+ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const refused = await get(server);
|
||||
+ assert.equal(refused.status, 401);
|
||||
+ assert.equal(calls, 0);
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]);
|
||||
+ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']);
|
||||
+ assert.doesNotMatch(answer.text, /private|Hidden PM/);
|
||||
+ assert.equal(calls, 1);
|
||||
+ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) {
|
||||
+ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok([400, 405].includes(result.status));
|
||||
+ }
|
||||
+ assert.equal(calls, 1, 'invalid metadata requests never call a bus');
|
||||
+});
|
||||
+test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => {
|
||||
+ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
+ let reads = 0;
|
||||
+ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9',
|
||||
+ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const answer = await get(server);
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]);
|
||||
+ assert.equal(reads, 1);
|
||||
+});
|
||||
+test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => {
|
||||
+ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) {
|
||||
+ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server));
|
||||
+ const answer = await get(server, { headers: { 'x-user': 'jason' } });
|
||||
+ assert.ok(answer.status >= 400);
|
||||
+ assert.doesNotMatch(answer.text, /private-path/);
|
||||
+ }
|
||||
+});
|
||||
+
|
||||
+test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => {
|
||||
+ let session = null, reads = 0;
|
||||
+ const touches = [];
|
||||
+ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [],
|
||||
+ session: (_req, { touch }) => { touches.push(touch); return session; } };
|
||||
+ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } });
|
||||
+ t.after(() => close(server));
|
||||
+ const headers = { host: sso.publicHost };
|
||||
+ assert.equal((await get(server, { headers })).status, 401);
|
||||
+ assert.equal(reads, 0);
|
||||
+ session = { name: 'jason' };
|
||||
+ assert.equal((await get(server, { headers })).status, 200);
|
||||
+ assert.equal(reads, 1);
|
||||
+ assert.deepEqual(touches, [false, false]);
|
||||
+});
|
||||
diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs
|
||||
index 8c11611cf..b47ecf1a7 100644
|
||||
--- a/packages/webui/tests/chat.test.mjs
|
||||
+++ b/packages/webui/tests/chat.test.mjs
|
||||
@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste
|
||||
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
|
||||
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
|
||||
});
|
||||
+
|
||||
+test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ c.state.answer = { id: T, instance: 'bean' };
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
|
||||
+ assert.equal(answer.status, 200);
|
||||
+ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
|
||||
+ assert.equal(answer.data.instance, 'bean');
|
||||
+});
|
||||
+test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
|
||||
+ const c = await setup(t);
|
||||
+ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
|
||||
+ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
|
||||
+ assert.equal(answer.status, 400, JSON.stringify(value));
|
||||
+ }
|
||||
+ 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, []);
|
||||
+});
|
||||
@@ -0,0 +1,16 @@
|
||||
142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs
|
||||
b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs
|
||||
3efd0238da60e0bfcd3b25e5256b9c213085119a86a7b852b13e7a60340d3a24 packages/bus/tests/grant-executor.test.mjs
|
||||
b7ea857637947c7a9ca45c56cea47e37e06f0fd8446ea91316990d9fa7299a7a packages/bus/tests/project-runtime.test.mjs
|
||||
264d82944c53b638bf6a14cc0706cf9ba184d668211c2b9c1031cdbe88e84bb9 packages/bus/tests/threads.test.mjs
|
||||
8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs
|
||||
b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs
|
||||
2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs
|
||||
7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs
|
||||
39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs
|
||||
77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs
|
||||
8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js
|
||||
443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs
|
||||
d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs
|
||||
2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs
|
||||
7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,16 @@
|
||||
packages/bus/src/broker.mjs
|
||||
packages/bus/tests/creative.test.mjs
|
||||
packages/bus/tests/grant-executor.test.mjs
|
||||
packages/bus/tests/project-runtime.test.mjs
|
||||
packages/bus/tests/threads.test.mjs
|
||||
packages/bus/tests/user-principal.test.mjs
|
||||
packages/cli/src/launcher.mjs
|
||||
packages/cli/tests/launcher.test.mjs
|
||||
packages/cli/tests/thread-launch.test.mjs
|
||||
packages/webui/src/chat-http.mjs
|
||||
packages/webui/src/chat.mjs
|
||||
packages/webui/src/public/chat.js
|
||||
packages/webui/src/serve.mjs
|
||||
packages/webui/tests/chat-browser.test.mjs
|
||||
packages/webui/tests/chat-instances.test.mjs
|
||||
packages/webui/tests/chat.test.mjs
|
||||
@@ -0,0 +1,21 @@
|
||||
cc98e830b5f8d0a34149e602775f154642e3f5e09cd42427e9deb49c84663fca BUILD.md
|
||||
2ce65e27aeb4cc563dbe179efbf5a120d6ac48f597e477c3648e58d28d829082 SHORT-RUN-PLAN.md
|
||||
5209e39f588a709962baae627fa0e66a3bf6ed7d6fe66ca3ec4dce16850ddc47 approved-tests.diff
|
||||
b05b00cd9f352219b88772e78c055c329fddf5e6b3ad4d4b1067e81ab235e418 assertion-preservation.json
|
||||
d7e1b464a314c7b079385f0a14a53189fe839d72d7666729026ffcb9d16a0faa base.txt
|
||||
deb5e2ff721c3b5adc1390c4aaef2d8aaf4f792edbd4e6b2d4766421222c406a build.patch
|
||||
451d3fcbeb6a41c3a505600898ba2025985f44d8391e2cd823ab84f1d4b50621 candidate-manifest.sha256
|
||||
5222c9f6d7c1da26f5b9447626ba8e005a7ab36006183ed36df40190b9133fb4 changed-paths.txt
|
||||
0cd14e6b9ea80f71ede6757e8430052246bb1f5c96c165133b1fa9b467d05262 short-run/T24-receipt.txt
|
||||
0cd14e6b9ea80f71ede6757e8430052246bb1f5c96c165133b1fa9b467d05262 short-run/T24.tap
|
||||
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 short-run/cwd-sweep-final.json
|
||||
4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945 short-run/cwd-sweep.json
|
||||
37c331032ac90730c9a3cabae0c467fb0dd5c1991cda5ff98b0fc5d1a553e7b4 short-run/green-grant-executor.tap
|
||||
d47f011e509107304b01c7f9037af7dfe1d06a45959309d21066c9755a3353b5 short-run/green-project-runtime.tap
|
||||
f52f3afbd058dd2c6dcf84ed1f3eb69bd4040df599c6f80b30fc60e7837de85b short-run/green-threads.tap
|
||||
b85b47bbd1cea535817b3077f109be91c703fbaacee4a1fd8d1d6f08acf24c8c short-run/head.json
|
||||
fdc8c9854f7dbed2d7ebb3da403dac0c88bc2ae6596fb1fa0d9fc5f865f244a0 short-run/red-grant.tap
|
||||
3033a3f85b738ff2a4c06e90973348fadfa6fc7c16c8192f6c4407bccffdb587 short-run/red-pin.tap
|
||||
1294cda16078dc1800b800fcf4d4410c89bce9687bd510068d22c3b1eed32c16 short-run/restored.json
|
||||
72675fa1a56c9e567bea82b4f6897ee3766472b8545f69b3b9e3909532a24500 short-run/results.jsonl
|
||||
835ab90ef7f52aca6260e13017795cc4102c6dd3267f5ce6d5ca0684aa606029 short-run/runner.py
|
||||
@@ -0,0 +1,38 @@
|
||||
TAP version 13
|
||||
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
not ok 1 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
---
|
||||
duration_ms: 541.339182
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1046:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly equal:
|
||||
+ actual - expected
|
||||
|
||||
+ 'decision-required'
|
||||
- 'host-creative-role'
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 'host-creative-role'
|
||||
actual: 'decision-required'
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
Object.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:703:103)
|
||||
expectedException (node:assert:547:17)
|
||||
expectsError (node:assert:672:21)
|
||||
strict.rejects (node:assert:738:3)
|
||||
async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1057:3)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 619.444691
|
||||
@@ -0,0 +1,38 @@
|
||||
TAP version 13
|
||||
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
not ok 1 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
---
|
||||
duration_ms: 541.339182
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1046:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly equal:
|
||||
+ actual - expected
|
||||
|
||||
+ 'decision-required'
|
||||
- 'host-creative-role'
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected: 'host-creative-role'
|
||||
actual: 'decision-required'
|
||||
operator: 'strictEqual'
|
||||
stack: |-
|
||||
Object.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:703:103)
|
||||
expectedException (node:assert:547:17)
|
||||
expectsError (node:assert:672:21)
|
||||
strict.rejects (node:assert:738:3)
|
||||
async TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs:1057:3)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 619.444691
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,262 @@
|
||||
TAP version 13
|
||||
# Subtest: row 280: an occupied TCP port is attempted, refused, and retried with our sshd
|
||||
ok 1 - row 280: an occupied TCP port is attempted, refused, and retried with our sshd
|
||||
---
|
||||
duration_ms: 84.725551
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 280: every injected picker sample is unprivileged and outside the ephemeral range
|
||||
ok 2 - row 280: every injected picker sample is unprivileged and outside the ephemeral range
|
||||
---
|
||||
duration_ms: 0.343983
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G7: an Approve installs the tagged line, logs in with the new key, and writes the key file, record and grant.applied
|
||||
ok 3 - G7: an Approve installs the tagged line, logs in with the new key, and writes the key file, record and grant.applied
|
||||
---
|
||||
duration_ms: 657.889711
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G7: a commands change on a live grant updates the record in one transaction and keeps the key and line
|
||||
ok 4 - G7: a commands change on a live grant updates the record in one transaction and keeps the key and line
|
||||
---
|
||||
duration_ms: 531.188801
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G8: a failure at install or verify removes the line and leaves no record, key file or reference
|
||||
ok 5 - G8: a failure at install or verify removes the line and leaves no record, key file or reference
|
||||
---
|
||||
duration_ms: 1502.744543
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G8: a failed remove adds hostLineLeft and tells the human to sweep
|
||||
ok 6 - G8: a failed remove adds hostLineLeft and tells the human to sweep
|
||||
---
|
||||
duration_ms: 645.757376
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G8: a failure in step 5 before commit unlinks the key file, drops the reference and removes the line
|
||||
ok 7 - G8: a failure in step 5 before commit unlinks the key file, drops the reference and removes the line
|
||||
---
|
||||
duration_ms: 632.923531
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G8: a store fault in the commit transaction ends host-failed with no record, key file, reference or line
|
||||
ok 8 - G8: a store fault in the commit transaction ends host-failed with no record, key file, reference or line
|
||||
---
|
||||
duration_ms: 648.03379
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G8: a failed step 1 check spends the decision and runs nothing remote
|
||||
ok 9 - G8: a failed step 1 check spends the decision and runs nothing remote
|
||||
---
|
||||
duration_ms: 82.428566
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G9: a registry, definition or commands change between raise and Approve ends decision-mismatch before any remote step
|
||||
ok 10 - G9: a registry, definition or commands change between raise and Approve ends decision-mismatch before any remote step
|
||||
---
|
||||
duration_ms: 519.400918
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G9: an agent no longer a role or no longer declaring host ends with its code, spent
|
||||
ok 11 - G9: an agent no longer a role or no longer declaring host ends with its code, spent
|
||||
---
|
||||
duration_ms: 69.103063
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G10: a second start refuses decision-consumed, and an unresolved or agent-resolved decision not-authorized, writing nothing
|
||||
ok 12 - G10: a second start refuses decision-consumed, and an unresolved or agent-resolved decision not-authorized, writing nothing
|
||||
---
|
||||
duration_ms: 511.259562
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G15: no event, message or decision holds the private key
|
||||
ok 13 - G15: no event, message or decision holds the private key
|
||||
---
|
||||
duration_ms: 490.757332
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G23: a revoke during install ends grant.failed revoked with no key file, no grant and no line
|
||||
ok 14 - G23: a revoke during install ends grant.failed revoked with no key file, no grant and no line
|
||||
---
|
||||
duration_ms: 526.257653
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G23 (row 249): an add that writes after an abort's remove leaves hostLineLeft until the sweep
|
||||
ok 15 - G23 (row 249): an add that writes after an abort's remove leaves hostLineLeft until the sweep
|
||||
---
|
||||
duration_ms: 871.678557
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G23 (row 249): an add that writes after a timed-out install's remove leaves hostLineLeft until the sweep
|
||||
ok 16 - G23 (row 249): an add that writes after a timed-out install's remove leaves hostLineLeft until the sweep
|
||||
---
|
||||
duration_ms: 864.787135
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy
|
||||
ok 17 - G23: a revoke recorded after the raise and before the start ends step 1 revoked, and a held slot ends host-busy
|
||||
---
|
||||
duration_ms: 523.685406
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G24: names at 64 characters with . and _ give distinct tags of the fixed length
|
||||
ok 18 - G24: names at 64 characters with . and _ give distinct tags of the fixed length
|
||||
---
|
||||
duration_ms: 0.44828
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G11: a restart after install removes the line, then ends interrupted with the tag, holding the slot until then
|
||||
ok 19 - G11: a restart after install removes the line, then ends interrupted with the tag, holding the slot until then
|
||||
---
|
||||
duration_ms: 217.311491
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G11: a restart after a new key's commit, with the key file written, ends with no key file, record, reference or line
|
||||
ok 20 - G11: a restart after a new key's commit, with the key file written, ends with no key file, record, reference or line
|
||||
---
|
||||
duration_ms: 213.431823
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G11: a restart after a commands change's commit leaves the record as it was and asks nothing of the host
|
||||
ok 21 - G11: a restart after a commands change's commit leaves the record as it was and asks nothing of the host
|
||||
---
|
||||
duration_ms: 514.743857
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G11 (B2): a restart before step 1, spent or not yet spent, ends interrupted and nothing runs later
|
||||
ok 22 - G11 (B2): a restart before step 1, spent or not yet spent, ends interrupted and nothing runs later
|
||||
---
|
||||
duration_ms: 67.778309
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G11: a remote undo that fails ends interrupted with hostLineLeft and the sweep hint
|
||||
ok 23 - G11: a remote undo that fails ends interrupted with hostLineLeft and the sweep hint
|
||||
---
|
||||
duration_ms: 283.505936
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G11: a restart leaves a finished grant alone, and the reconcile adds nothing for it
|
||||
ok 24 - G11: a restart leaves a finished grant alone, and the reconcile adds nothing for it
|
||||
---
|
||||
duration_ms: 532.803771
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: N1 (G27): the start sweep unlinks host.<id> files with no record in loaded businesses only, and refuses a store dir that is not private
|
||||
ok 25 - N1 (G27): the start sweep unlinks host.<id> files with no record in loaded businesses only, and refuses a store dir that is not private
|
||||
---
|
||||
duration_ms: 10.401722
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G12: the human's hosts.revoke deletes the record, the key file, the reference and the line, with a closed marker
|
||||
ok 26 - G12: the human's hosts.revoke deletes the record, the key file, the reference and the line, with a closed marker
|
||||
---
|
||||
duration_ms: 680.673474
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G12 (N4): a revoke with no record still removes the computed tag's line, with granted false; bad args refuse
|
||||
ok 27 - G12 (N4): a revoke with no record still removes the computed tag's line, with granted false; bad args refuse
|
||||
---
|
||||
duration_ms: 210.775767
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G13: a revoke during install aborts the executor, which ends revoked, and the line goes
|
||||
ok 28 - G13: a revoke during install aborts the executor, which ends revoked, and the line goes
|
||||
---
|
||||
duration_ms: 539.460127
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G26: the human, the assistant and the tester revoke; the chief and a pm refuse; sweep is the human's or the tester's
|
||||
ok 29 - G26: the human, the assistant and the tester revoke; the chief and a pm refuse; sweep is the human's or the tester's
|
||||
---
|
||||
duration_ms: 509.503577
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 281 T8: a tester hosts.revoke refuses tester-dev-only once bus.tester is off, and a human revoke writes no job records
|
||||
ok 30 - row 281 T8: a tester hosts.revoke refuses tester-dev-only once bus.tester is off, and a human revoke writes no job records
|
||||
---
|
||||
duration_ms: 651.604822
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then
|
||||
ok 31 - G27: a restart after step 1 sweeps the key file and runs step 3 once the bus is up; status shows the revoke until then
|
||||
---
|
||||
duration_ms: 660.602104
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G27: after step 2 with the host down, the restart closes the marker with hostLineLeft; a marker without a revoke id is not reopened
|
||||
ok 32 - G27: after step 2 with the host down, the restart closes the marker with hostLineLeft; a marker without a revoke id is not reopened
|
||||
---
|
||||
duration_ms: 960.616547
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G27: a key file a revoke could not unlink is reported left and goes at the next start
|
||||
ok 33 - G27: a key file a revoke could not unlink is reported left and goes at the next start
|
||||
---
|
||||
duration_ms: 659.246715
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G28: a commands change, then a revoke, then a restart leave no record, key, reference or line
|
||||
ok 34 - G28: a commands change, then a revoke, then a restart leave no record, key, reference or line
|
||||
---
|
||||
duration_ms: 679.404671
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G24 (B4): hosts.sweep removes only this business's recorded tags no live grant holds, and records what it left
|
||||
ok 35 - G24 (B4): hosts.sweep removes only this business's recorded tags no live grant holds, and records what it left
|
||||
---
|
||||
duration_ms: 957.711463
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G12: the local steps finish before the host step, so a host that hangs leaves the agent no access
|
||||
ok 36 - G12: the local steps finish before the host step, so a host that hangs leaves the agent no access
|
||||
---
|
||||
duration_ms: 707.712192
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G23 (X5): a revoke right after the spend reaches the executor's claim, so the grant ends revoked
|
||||
ok 37 - G23 (X5): a revoke right after the spend reaches the executor's claim, so the grant ends revoked
|
||||
---
|
||||
duration_ms: 213.272659
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G23 (X5): a revoke that lands as verify returns ends revoked with no record, key file or line
|
||||
ok 38 - G23 (X5): a revoke that lands as verify returns ends revoked with no record, key file or line
|
||||
---
|
||||
duration_ms: 650.086388
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G13: a revoke whose host remove fails still drops the record and key, and reports the line left for a sweep
|
||||
ok 39 - G13: a revoke whose host remove fails still drops the record and key, and reports the line left for a sweep
|
||||
---
|
||||
duration_ms: 656.97494
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: G22: enroll, check, grant, host.run, a commands change the next host.run uses, and a revoke that kills a run in flight
|
||||
ok 40 - G22: enroll, check, grant, host.run, a commands change the next host.run uses, and a revoke that kills a run in flight
|
||||
---
|
||||
duration_ms: 1161.98276
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: mos-1163: a grant whose agent became a creative role after the raise ends host-creative-role at step 1, spent, with nothing remote
|
||||
ok 41 - mos-1163: a grant whose agent became a creative role after the raise ends host-creative-role at step 1, spent, with nothing remote
|
||||
---
|
||||
duration_ms: 64.270391
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
ok 42 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
---
|
||||
duration_ms: 513.54277
|
||||
type: 'test'
|
||||
...
|
||||
1..42
|
||||
# tests 42
|
||||
# suites 0
|
||||
# pass 42
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 21560.094474
|
||||
@@ -0,0 +1,28 @@
|
||||
TAP version 13
|
||||
# Subtest: Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
|
||||
ok 1 - Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
|
||||
---
|
||||
duration_ms: 2.134902
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: without a trusted project adapter all three async paths refuse project-not-configured
|
||||
ok 2 - without a trusted project adapter all three async paths refuse project-not-configured
|
||||
---
|
||||
duration_ms: 40.098809
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: real startBroker injects the project adapter for socket reads and bound-author record writes
|
||||
ok 3 - real startBroker injects the project adapter for socket reads and bound-author record writes
|
||||
---
|
||||
duration_ms: 195.816084
|
||||
type: 'test'
|
||||
...
|
||||
1..3
|
||||
# tests 3
|
||||
# suites 0
|
||||
# pass 3
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 346.858932
|
||||
@@ -0,0 +1,130 @@
|
||||
TAP version 13
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
---
|
||||
duration_ms: 37.881537
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
---
|
||||
duration_ms: 66.86806
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
---
|
||||
duration_ms: 31.783414
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
---
|
||||
duration_ms: 612.146473
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
---
|
||||
duration_ms: 25.313669
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
---
|
||||
duration_ms: 29.786654
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
---
|
||||
duration_ms: 32.078895
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
---
|
||||
duration_ms: 36.18567
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
---
|
||||
duration_ms: 30.574839
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
---
|
||||
duration_ms: 28.11449
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
---
|
||||
duration_ms: 40.031837
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
|
||||
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
|
||||
---
|
||||
duration_ms: 28.629823
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
---
|
||||
duration_ms: 23.227988
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
---
|
||||
duration_ms: 24.842387
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
---
|
||||
duration_ms: 90.053907
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create refuses a repository name without its matching repo before any write
|
||||
ok 16 - project.create refuses a repository name without its matching repo before any write
|
||||
---
|
||||
duration_ms: 16.171096
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create keeps unrelated names with and without a repository
|
||||
ok 17 - project.create keeps unrelated names with and without a repository
|
||||
---
|
||||
duration_ms: 12.013106
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
---
|
||||
duration_ms: 48.182262
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
---
|
||||
duration_ms: 21.103961
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: creative bindLaunch without a thread refuses thread-required
|
||||
ok 20 - C2-09a: creative bindLaunch without a thread refuses thread-required
|
||||
---
|
||||
duration_ms: 10.557239
|
||||
type: 'test'
|
||||
...
|
||||
1..20
|
||||
# tests 20
|
||||
# suites 0
|
||||
# pass 20
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 1330.269155
|
||||
@@ -0,0 +1 @@
|
||||
{"head": "7e70de9e68ba48d761a45894d037239b5d576a38", "red": "bfa421b6cd9b2b54d8da119e73b004aeafe606c3", "manifest": "451d3fcbeb6a41c3a505600898ba2025985f44d8391e2cd823ab84f1d4b50621", "start": "2026-10-23T08:41:20.461140+00:00"}
|
||||
@@ -0,0 +1,26 @@
|
||||
TAP version 13
|
||||
# Subtest: sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
not ok 1 - sage-1908: once the agent of an applied grant is a creative role, host.run refuses host-creative-role, runs nothing remote and keeps the record
|
||||
---
|
||||
duration_ms: 526.66272
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/grant-executor.test.mjs:1046:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: 'thread-required'
|
||||
code: 'thread-required'
|
||||
stack: |-
|
||||
fail (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/src/broker.mjs:209:9)
|
||||
Broker.bindLaunch (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/src/broker.mjs:1258:40)
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/grant-executor.test.mjs:1050:24)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 610.366811
|
||||
@@ -0,0 +1,58 @@
|
||||
TAP version 13
|
||||
# Subtest: Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
|
||||
not ok 1 - Broker keeps the sandbox client import closure: only the listed imports, no lazy project import
|
||||
---
|
||||
duration_ms: 2.490892
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/project-runtime.test.mjs:12:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
[
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
"import { isAbsolute, dirname } from 'node:path';",
|
||||
"import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';",
|
||||
"import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';",
|
||||
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
|
||||
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
|
||||
+ "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
|
||||
- "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
|
||||
]
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
0: "import { randomUUID, randomBytes, createHash } from 'node:crypto';"
|
||||
1: "import { isAbsolute, dirname } from 'node:path';"
|
||||
2: "import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';"
|
||||
3: "import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';"
|
||||
4: "import { loadRequirements } from '../../tasks/src/requirements.mjs';"
|
||||
5: "import { MERGE, LandingRefusal, readLanding } from './landing.mjs';"
|
||||
6: "import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
|
||||
actual:
|
||||
0: "import { randomUUID, randomBytes, createHash } from 'node:crypto';"
|
||||
1: "import { isAbsolute, dirname } from 'node:path';"
|
||||
2: "import { initializeControl, processingStatus } from '../../processing-control/src/state.mjs';"
|
||||
3: "import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';"
|
||||
4: "import { loadRequirements } from '../../tasks/src/requirements.mjs';"
|
||||
5: "import { MERGE, LandingRefusal, readLanding } from './landing.mjs';"
|
||||
6: "import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';"
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219-red-mos1429/packages/bus/tests/project-runtime.test.mjs:14:10)
|
||||
Test.runInAsyncScope (node:async_hooks:226:14)
|
||||
Test.run (node:internal/test_runner/test:1402:25)
|
||||
Test.start (node:internal/test_runner/test:1262:17)
|
||||
startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17)
|
||||
...
|
||||
1..1
|
||||
# tests 1
|
||||
# suites 0
|
||||
# pass 0
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 119.366042
|
||||
@@ -0,0 +1 @@
|
||||
{"paths": 16, "drift": [], "head": "7e70de9e68ba48d761a45894d037239b5d576a38", "end": "2026-10-23T08:41:45.374077+00:00"}
|
||||
@@ -0,0 +1,6 @@
|
||||
{"label": "red-grant", "start": "2026-10-23T08:41:20.461387+00:00", "end": "2026-10-23T08:41:21.126907+00:00", "seconds": 0.666, "exit": 1, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/red-grant.tap"}
|
||||
{"label": "red-pin", "start": "2026-10-23T08:41:21.130846+00:00", "end": "2026-10-23T08:41:21.295380+00:00", "seconds": 0.165, "exit": 1, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/red-pin.tap"}
|
||||
{"label": "green-grant-executor", "start": "2026-10-23T08:41:21.299531+00:00", "end": "2026-10-23T08:41:42.908967+00:00", "seconds": 21.609, "exit": 0, "counts": {"tests": 42, "pass": 42, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/green-grant-executor.tap"}
|
||||
{"label": "green-project-runtime", "start": "2026-10-23T08:41:42.913276+00:00", "end": "2026-10-23T08:41:43.327996+00:00", "seconds": 0.415, "exit": 0, "counts": {"tests": 3, "pass": 3, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/green-project-runtime.tap"}
|
||||
{"label": "green-threads", "start": "2026-10-23T08:41:43.331886+00:00", "end": "2026-10-23T08:41:44.698643+00:00", "seconds": 1.367, "exit": 0, "counts": {"tests": 20, "pass": 20, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/green-threads.tap"}
|
||||
{"label": "T24", "start": "2026-10-23T08:41:44.704293+00:00", "end": "2026-10-23T08:41:45.369841+00:00", "seconds": 0.666, "exit": 1, "counts": {"tests": 1, "pass": 0, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": [], "log": "agents/rocko/work/queue-219/r4/short-run/T24.tap"}
|
||||
@@ -0,0 +1,55 @@
|
||||
from pathlib import Path
|
||||
import os,subprocess,signal,tempfile,json,re,time,hashlib,shlex
|
||||
from datetime import datetime,timezone
|
||||
ROOT=Path('/mnt/storage/scratch/rocko-r219'); RED=Path('/mnt/storage/scratch/rocko-r219-red-mos1429')
|
||||
P=ROOT/'agents/rocko/work/queue-219';OUT=P/'r4/short-run';OUT.mkdir(exist_ok=True)
|
||||
HEAD='7e70de9e68ba48d761a45894d037239b5d576a38';OLD='bfa421b6cd9b2b54d8da119e73b004aeafe606c3'
|
||||
def stamp():return datetime.now(timezone.utc).isoformat()
|
||||
def verify():
|
||||
assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()==HEAD
|
||||
assert subprocess.check_output(['git','rev-parse','HEAD'],cwd=RED,text=True).strip()==OLD
|
||||
for line in (P/'candidate-manifest.sha256').read_text().splitlines():
|
||||
sha,path=line.split(' ',1);assert hashlib.sha256((ROOT/path).read_bytes()).hexdigest()==sha,path
|
||||
|
||||
def execute(label,root,file,pattern=None):
|
||||
args=['node','--test','--test-reporter=tap']
|
||||
if pattern:args+=['--test-name-pattern',pattern]
|
||||
args+=[file]
|
||||
env=os.environ.copy();env.update(TMPDIR=tempfile.mkdtemp(prefix='r',dir='/mnt/storage/scratch/tmp'),DOCKER_HOST='unix:///mnt/storage/scratch/rocko-r219-dead.sock',NO_COLOR='1')
|
||||
env.pop('MOSAIC_GITEA_CREDENTIAL_FILE',None);env.pop('MOSAIC_LIVE_PROVIDER',None)
|
||||
log=OUT/(label+'.tap');start=stamp();begin=time.monotonic()
|
||||
cmd=shlex.join(['timeout','-k','10','300',*args])+' | tee '+shlex.quote(str(log))
|
||||
child=subprocess.Popen(['bash','-o','pipefail','-c',cmd],cwd=root,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.STDOUT,start_new_session=True)
|
||||
try:rc=child.wait(timeout=315)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid,signal.SIGKILL);child.wait();rc=124
|
||||
finally:
|
||||
try:os.killpg(child.pid,signal.SIGTERM)
|
||||
except ProcessLookupError:pass
|
||||
text=log.read_text(errors='replace');counts={k:int(m[-1]) if (m:=re.findall(r'^# '+k+r' (\d+)\s*$',text,re.M)) else None for k in ['tests','pass','fail','cancelled','skipped']}
|
||||
row=dict(label=label,start=start,end=stamp(),seconds=round(time.monotonic()-begin,3),exit=rc,counts=counts,skips=[x for x in text.splitlines() if re.search(r'^skip\b|# SKIP\b',x,re.I)],log=str(log.relative_to(ROOT)))
|
||||
with (OUT/'results.jsonl').open('a') as f:f.write(json.dumps(row)+'\n')
|
||||
print(json.dumps(row),flush=True)
|
||||
assert rc not in (124,137,-9) and 'SyntaxError:' not in text, 'timeout/syntax '+label
|
||||
return row,text
|
||||
|
||||
def green(label,file):
|
||||
verify();r,t=execute(label,ROOT,file)
|
||||
assert r['exit']==0 and r['counts']['fail']==0 and r['counts']['cancelled']==0 and not re.search(r'^\s*not ok\b',t,re.M),'unexpected red '+label
|
||||
|
||||
verify();(OUT/'head.json').write_text(json.dumps(dict(head=HEAD,red=OLD,manifest=hashlib.sha256((P/'candidate-manifest.sha256').read_bytes()).hexdigest(),start=stamp())))
|
||||
pattern='sage-1908: once the agent of an applied grant is a creative role'
|
||||
r,t=execute('red-grant',RED,'packages/bus/tests/grant-executor.test.mjs',pattern)
|
||||
assert r['exit']!=0 and r['counts']['fail']==1 and "code: 'thread-required'" in t and 'bindLaunch' in t,'wrong grant red'
|
||||
verify();r,t=execute('red-pin',RED,'packages/bus/tests/project-runtime.test.mjs','Broker keeps the sandbox client import closure')
|
||||
assert r['exit']!=0 and r['counts']['fail']==1 and 'ERR_ASSERTION' in t and 'THREAD_DEFINITION' in t,'wrong pin red'
|
||||
for name in ['grant-executor','project-runtime','threads']:green('green-'+name,'packages/bus/tests/'+name+'.test.mjs')
|
||||
verify();s=next(x for x in json.loads((P/'mutants.json').read_text()) if x['id']=='T24');path=ROOT/s['file'];saved=path.read_bytes()
|
||||
try:
|
||||
text=path.read_text();assert text.count(s['old'])==1;path.write_text(text.replace(s['old'],s['new'],1))
|
||||
r,t=execute('T24',ROOT,s['test'],s['pattern'])
|
||||
assert r['exit']!=0 and r['counts']['fail']==1 and 'ERR_ASSERTION' in t and re.search(r'^\s*not ok \d+ - '+re.escape(pattern),t,re.M),'T24 invalid or survivor'
|
||||
(OUT/'T24-receipt.txt').write_text(t)
|
||||
finally:path.write_bytes(saved)
|
||||
verify();(OUT/'restored.json').write_text(json.dumps(dict(paths=16,drift=[],head=HEAD,end=stamp())))
|
||||
print('SHORT_RUN_GREEN',stamp(),flush=True)
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"status": "preliminary on 5f2ef92e5; rerun on new base before static handoff",
|
||||
"readers": {
|
||||
"chat-http.mjs": [
|
||||
"packages/webui/tests/chat.test.mjs"
|
||||
],
|
||||
"chat.mjs": [
|
||||
"packages/webui/tests/chat.test.mjs",
|
||||
"packages/webui/tests/tester-fixture.mjs",
|
||||
"packages/webui/tests/channel-chat.test.mjs"
|
||||
],
|
||||
"serve.mjs": [
|
||||
"packages/control-board/tests/discord.test.mjs",
|
||||
"packages/webui/tests/views.test.mjs",
|
||||
"packages/webui/tests/usage-browser.test.mjs",
|
||||
"packages/control-board/tests/serve.test.mjs",
|
||||
"packages/webui/tests/bus.test.mjs",
|
||||
"packages/webui/tests/thread-claims-browser.test.mjs",
|
||||
"packages/webui/tests/history-fixture.mjs",
|
||||
"packages/webui/tests/instances-browser.test.mjs",
|
||||
"packages/webui/tests/chat.test.mjs",
|
||||
"packages/webui/tests/chat-browser.test.mjs",
|
||||
"packages/webui/tests/library-cli.test.mjs",
|
||||
"packages/webui/tests/instances.test.mjs",
|
||||
"packages/webui/tests/settings-form.test.mjs",
|
||||
"packages/webui/tests/assistant.test.mjs",
|
||||
"packages/webui/tests/editor-browser.test.mjs",
|
||||
"packages/webui/tests/limits-route.test.mjs",
|
||||
"packages/webui/tests/limits-browser.test.mjs",
|
||||
"packages/webui/tests/sso-browser.test.mjs",
|
||||
"packages/webui/tests/shell.test.mjs",
|
||||
"packages/webui/tests/proof.test.mjs",
|
||||
"packages/webui/tests/docs-browser.test.mjs",
|
||||
"packages/webui/tests/usage.test.mjs",
|
||||
"packages/webui/tests/messages.test.mjs",
|
||||
"packages/webui/tests/sso.test.mjs",
|
||||
"packages/webui/tests/processing.test.mjs",
|
||||
"packages/webui/tests/send.test.mjs",
|
||||
"packages/webui/tests/fixture.mjs",
|
||||
"packages/webui/tests/project-file.test.mjs",
|
||||
"packages/webui/tests/agents.test.mjs",
|
||||
"packages/webui/tests/return-flow.test.mjs",
|
||||
"packages/webui/tests/browser-edge.test.mjs",
|
||||
"packages/webui/tests/agents-browser.test.mjs",
|
||||
"packages/webui/tests/send-browser.test.mjs",
|
||||
"packages/webui/tests/discord.test.mjs",
|
||||
"packages/webui/tests/bus-browser.test.mjs",
|
||||
"packages/webui/tests/reads.test.mjs",
|
||||
"packages/webui/tests/routes-browser.test.mjs",
|
||||
"packages/webui/tests/preview.test.mjs",
|
||||
"packages/webui/tests/board-held.test.mjs",
|
||||
"packages/webui/tests/tester-listener.test.mjs",
|
||||
"packages/webui/tests/channel-chat.test.mjs",
|
||||
"packages/webui/tests/processing-browser.test.mjs",
|
||||
"packages/webui/tests/serve.test.mjs",
|
||||
"packages/webui/tests/chat-instances.test.mjs",
|
||||
"packages/webui/tests/routes.test.mjs",
|
||||
"packages/webui/tests/settings-write.test.mjs",
|
||||
"packages/webui/tests/thread-claims.test.mjs",
|
||||
"packages/webui/tests/relaunch.test.mjs",
|
||||
"packages/conversation/tests/flows.test.mjs"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Row219 serial gate. Requires Sage CLEAR; all children use a process group."""
|
||||
import os,sys,time,json,signal,subprocess,tempfile,hashlib,re,glob
|
||||
from pathlib import Path
|
||||
from datetime import datetime,timezone
|
||||
ROOT=Path(__file__).resolve().parents[4]
|
||||
PACKET=Path(__file__).resolve().parent
|
||||
OUT=PACKET/'run-4a9c-mos1408';OUT.mkdir(exist_ok=True)
|
||||
def stamp():return datetime.now(timezone.utc).isoformat()
|
||||
def verify():
|
||||
for line in (PACKET/'candidate-manifest.sha256').read_text().splitlines():
|
||||
sha,path=line.split(' ',1)
|
||||
if hashlib.sha256((ROOT/path).read_bytes()).hexdigest()!=sha:raise RuntimeError('candidate drift: '+path)
|
||||
def sweep():
|
||||
found=[]
|
||||
for proc in Path('/proc').glob('[0-9]*'):
|
||||
try:
|
||||
cwd=(proc/'cwd').resolve();name=(proc/'comm').read_text().strip()
|
||||
if (str(cwd).startswith(str(ROOT)) or str(cwd).startswith('/mnt/storage/scratch/tmp/')) and ('node' in name or 'chrom' in name):found.append(dict(pid=int(proc.name),name=name,cwd=str(cwd)))
|
||||
except (FileNotFoundError,PermissionError,ProcessLookupError):pass
|
||||
return found
|
||||
def execute(label,args,cwd=ROOT,deadline=300,docker=False):
|
||||
if args[0]=='node' and '--test' in args and '--test-reporter=tap' not in args:args=[args[0],'--test-reporter=tap',*args[1:]]
|
||||
if args[0]!='timeout':args=['timeout','-k','10',str(deadline),*args]
|
||||
verify();short=tempfile.mkdtemp(prefix='r',dir='/mnt/storage/scratch/tmp')
|
||||
env=os.environ.copy();env.update(TMPDIR=short,NO_COLOR='1',COMPOSE_PROJECT_NAME='rocko-r219-gate')
|
||||
env.pop('MOSAIC_GITEA_CREDENTIAL_FILE',None);env.pop('MOSAIC_LIVE_PROVIDER',None)
|
||||
if docker:env.pop('DOCKER_HOST',None)
|
||||
else:env['DOCKER_HOST']='unix:///mnt/storage/scratch/rocko-r219-dead.sock'
|
||||
log=OUT/(label+'.tap');begin=stamp();t=time.monotonic();timeout=False
|
||||
print('START',label,begin,flush=True)
|
||||
with log.open('wb') as f:
|
||||
child=subprocess.Popen(args,cwd=cwd,env=env,stdout=f,stderr=subprocess.STDOUT,start_new_session=True)
|
||||
try:rc=child.wait(timeout=deadline+15)
|
||||
except subprocess.TimeoutExpired:
|
||||
timeout=True
|
||||
try:os.killpg(child.pid,signal.SIGTERM)
|
||||
except ProcessLookupError:pass
|
||||
try:child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:os.killpg(child.pid,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
child.wait()
|
||||
rc=child.returncode
|
||||
finally:
|
||||
try:os.killpg(child.pid,signal.SIGTERM)
|
||||
except ProcessLookupError:pass
|
||||
text=log.read_text(errors='replace')
|
||||
counts={k:int(m[-1]) if (m:=re.findall(r'^# '+k+r' (\d+)\s*$',text,re.M)) else None for k in ['tests','pass','fail','cancelled','skipped']}
|
||||
row=dict(label=label,args=args,start=begin,end=stamp(),seconds=round(time.monotonic()-t,3),exit=rc,timeout=timeout,counts=counts,skips=[l for l in text.splitlines() if re.search(r'^skip|# SKIP',l,re.I)],log=str(log.relative_to(ROOT)),tmpdir=short)
|
||||
with (OUT/'results.jsonl').open('a') as f:f.write(json.dumps(row)+'\n')
|
||||
print('END',json.dumps(row),flush=True)
|
||||
if rc or timeout or re.search(r'^\s*not ok\b',text,re.M):raise RuntimeError('RED '+label+' '+str(log))
|
||||
return row
|
||||
singles=[line for line in (PACKET/'changed-paths.txt').read_text().splitlines() if '/tests/' in line]
|
||||
def stages():
|
||||
for path in singles:execute('single-'+Path(path).parent.parent.name+'-'+Path(path).stem,['node','--test',path],deadline=300)
|
||||
for package in ['bus','cli','webui']:
|
||||
files=sorted(glob.glob(str(ROOT/'packages'/package/'tests'/'*.test.mjs')))
|
||||
execute('suite-'+package,['node','--test',*files],cwd=ROOT/'packages'/package,deadline=1800)
|
||||
for path in sorted(ROOT.glob('scripts/test-*.sh')):
|
||||
# timeout itself implements TERM then KILL, inside the dedicated child group.
|
||||
execute(path.stem,['timeout','-k','10','7200','bash',str(path)],deadline=7200,docker=True)
|
||||
# Every mutant starts from verified candidate bytes and restores in finally.
|
||||
for spec in json.loads((PACKET/'mutants.json').read_text()):
|
||||
verify();edits=[spec]+spec.get('additional_edits',[]);saved={}
|
||||
try:
|
||||
for e in edits:
|
||||
path=ROOT/e['file'];saved.setdefault(path,path.read_bytes());text=path.read_text()
|
||||
if text.count(e['old'])!=1:raise RuntimeError('anchor '+spec['id'])
|
||||
path.write_text(text.replace(e['old'],e['new'],1))
|
||||
# Mutation intentionally changes candidate bytes; execute has its own verify.
|
||||
oldverify=globals()['verify'];globals()['verify']=lambda:None
|
||||
try:
|
||||
execute('mutant-'+spec['id'],['node','--test','--test-name-pattern',spec['pattern'],spec['test']],deadline=180)
|
||||
status='survived'
|
||||
except RuntimeError:
|
||||
record=json.loads((OUT/'results.jsonl').read_text().splitlines()[-1]);text=(ROOT/record['log']).read_text(errors='replace')
|
||||
failing=re.findall(r'^\s*not ok \d+ - (.*)$',text,re.M)
|
||||
status='killed' if record['exit'] and not record['timeout'] and any(re.search(spec['pattern'],name) for name in failing) and 'SyntaxError:' not in text else 'invalid-failure'
|
||||
finally:globals()['verify']=oldverify
|
||||
finally:
|
||||
for path,data in saved.items():path.write_bytes(data)
|
||||
verify()
|
||||
with (OUT/'mutants.jsonl').open('a') as f:f.write(json.dumps(dict(id=spec['id'],status=status,test=spec['test'],pattern=spec['pattern']))+'\n')
|
||||
if status!='killed':raise RuntimeError(spec['id']+' '+status)
|
||||
if __name__=='__main__':
|
||||
if Path.cwd()!=ROOT:raise SystemExit('wrong cwd')
|
||||
head=subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()
|
||||
expected=(PACKET/'run-head.txt').read_text().strip()
|
||||
if head!=expected:raise SystemExit('wrong HEAD')
|
||||
(OUT/'head.json').write_text(json.dumps(dict(head=head,manifest=hashlib.sha256((PACKET/'candidate-manifest.sha256').read_bytes()).hexdigest(),start=stamp())))
|
||||
verify()
|
||||
try:stages()
|
||||
finally:
|
||||
verify();(OUT/'cwd-sweep.json').write_text(json.dumps(sweep(),indent=2))
|
||||
# Remove only the owned fixture compose network, if any; never daemon/volumes.
|
||||
subprocess.run(['docker','network','rm','rocko-r219-gate_default'],stdout=(OUT/'network-cleanup.log').open('w'),stderr=subprocess.STDOUT)
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
{"head": "b97a3ff9757ec2ac39d9ab4cebb9769e471df932", "manifest": "a973912fe2fb9c64606cd99a385305b8f51498a096511b3a980bca6b0a5b98aa", "start": "2026-10-23T05:48:57.416807+00:00"}
|
||||
@@ -0,0 +1,2 @@
|
||||
Error response from daemon: network rocko-r219-gate_default not found
|
||||
exit status 1
|
||||
@@ -0,0 +1,4 @@
|
||||
{"label": "single-bus-creative.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T05:48:57.418927+00:00", "end": "2026-10-23T05:48:57.834246+00:00", "seconds": 0.415, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rs156uowe"}
|
||||
{"label": "single-bus-threads.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T05:48:57.835837+00:00", "end": "2026-10-23T05:48:59.152912+00:00", "seconds": 1.317, "exit": 0, "timeout": false, "counts": {"tests": 19, "pass": 19, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rbqz65jtz"}
|
||||
{"label": "single-bus-user-principal.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T05:48:59.154288+00:00", "end": "2026-10-23T05:48:59.719646+00:00", "seconds": 0.565, "exit": 0, "timeout": false, "counts": {"tests": 23, "pass": 23, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rztf343rx"}
|
||||
{"label": "single-cli-launcher.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T05:48:59.721051+00:00", "end": "2026-10-23T05:49:26.296570+00:00", "seconds": 26.576, "exit": 1, "timeout": false, "counts": {"tests": 34, "pass": 33, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r8xucbjgl"}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
TAP version 13
|
||||
# organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
# organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
# Subtest: M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
|
||||
ok 1 - M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
|
||||
---
|
||||
duration_ms: 37.235384
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a creative instance sends only to the user and hears only from the user
|
||||
ok 2 - a creative instance sends only to the user and hears only from the user
|
||||
---
|
||||
duration_ms: 44.266739
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
|
||||
ok 3 - M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
|
||||
---
|
||||
duration_ms: 83.079495
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
|
||||
ok 4 - row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
|
||||
---
|
||||
duration_ms: 63.576082
|
||||
type: 'test'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 4
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 356.82109
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
TAP version 13
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
---
|
||||
duration_ms: 37.957781
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
---
|
||||
duration_ms: 68.301373
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
---
|
||||
duration_ms: 31.316512
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
---
|
||||
duration_ms: 593.922455
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
---
|
||||
duration_ms: 24.857971
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
---
|
||||
duration_ms: 27.519976
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
---
|
||||
duration_ms: 30.716822
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
---
|
||||
duration_ms: 33.754906
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
---
|
||||
duration_ms: 29.046481
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
---
|
||||
duration_ms: 26.449042
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
---
|
||||
duration_ms: 38.524966
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
|
||||
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
|
||||
---
|
||||
duration_ms: 28.558392
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
---
|
||||
duration_ms: 23.166467
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
---
|
||||
duration_ms: 28.154835
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
---
|
||||
duration_ms: 86.802213
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create refuses a repository name without its matching repo before any write
|
||||
ok 16 - project.create refuses a repository name without its matching repo before any write
|
||||
---
|
||||
duration_ms: 15.244016
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create keeps unrelated names with and without a repository
|
||||
ok 17 - project.create keeps unrelated names with and without a repository
|
||||
---
|
||||
duration_ms: 11.57841
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
---
|
||||
duration_ms: 46.82113
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
---
|
||||
duration_ms: 20.263528
|
||||
type: 'test'
|
||||
...
|
||||
1..19
|
||||
# tests 19
|
||||
# suites 0
|
||||
# pass 19
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 1283.575562
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
TAP version 13
|
||||
# Subtest: R3: bindUser binds a granted user and refuses one with no live grant
|
||||
ok 1 - R3: bindUser binds a granted user and refuses one with no live grant
|
||||
---
|
||||
duration_ms: 15.216913
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
|
||||
ok 2 - R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
|
||||
---
|
||||
duration_ms: 10.127905
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: each collaborator verb is allowed to a granted user
|
||||
ok 3 - R3: each collaborator verb is allowed to a granted user
|
||||
---
|
||||
duration_ms: 18.491408
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: every verb off the collaborator list refuses a user access-refused before its own checks
|
||||
ok 4 - R3: every verb off the collaborator list refuses a user access-refused before its own checks
|
||||
---
|
||||
duration_ms: 95.840429
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: task, web and host verbs refuse a user access-refused
|
||||
ok 5 - R3: task, web and host verbs refuse a user access-refused
|
||||
---
|
||||
duration_ms: 22.333602
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: project.list shows granted projects only
|
||||
ok 6 - R3 leak: project.list shows granted projects only
|
||||
---
|
||||
duration_ms: 12.494935
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
|
||||
ok 7 - R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
|
||||
---
|
||||
duration_ms: 15.195921
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: thread.list shows the user its own threads that are still granted, never others
|
||||
ok 8 - R3 leak: thread.list shows the user its own threads that are still granted, never others
|
||||
---
|
||||
duration_ms: 33.037437
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: names shows granted instances only
|
||||
ok 9 - R3 leak: names shows granted instances only
|
||||
---
|
||||
duration_ms: 14.891286
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: thread.send is from user:<id> and writes no human.input
|
||||
ok 10 - R3: thread.send is from user:<id> and writes no human.input
|
||||
---
|
||||
duration_ms: 13.366772
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a reply-chain message.send into another user thread is refused
|
||||
ok 11 - R3: a reply-chain message.send into another user thread is refused
|
||||
---
|
||||
duration_ms: 15.523733
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
|
||||
ok 12 - R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
|
||||
---
|
||||
duration_ms: 18.163702
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a reply to: user without a matching in_reply_to is refused user-reply-required
|
||||
ok 13 - R3: a reply to: user without a matching in_reply_to is refused user-reply-required
|
||||
---
|
||||
duration_ms: 26.445744
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a non-thread run, the human and the tester cannot send to: user
|
||||
ok 14 - R3: a non-thread run, the human and the tester cannot send to: user
|
||||
---
|
||||
duration_ms: 17.29104
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
|
||||
ok 15 - R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
|
||||
---
|
||||
duration_ms: 19.258704
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
|
||||
ok 16 - R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
|
||||
---
|
||||
duration_ms: 18.231409
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user refusal is recorded as action.refused by user:<id>
|
||||
ok 17 - R3: a user refusal is recorded as action.refused by user:<id>
|
||||
---
|
||||
duration_ms: 13.797301
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a business with a role keyed user refuses invalid-business
|
||||
ok 18 - R3: a business with a role keyed user refuses invalid-business
|
||||
---
|
||||
duration_ms: 9.468564
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: name.set refuses the name user
|
||||
ok 19 - R3: name.set refuses the name user
|
||||
---
|
||||
duration_ms: 9.752458
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
|
||||
ok 20 - R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
|
||||
---
|
||||
duration_ms: 13.617784
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
|
||||
ok 21 - R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
|
||||
---
|
||||
duration_ms: 14.726171
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: a non-function access throws before the store is read
|
||||
ok 22 - R3 r4: a non-function access throws before the store is read
|
||||
---
|
||||
duration_ms: 0.105564
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
|
||||
ok 23 - C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
|
||||
---
|
||||
duration_ms: 15.883063
|
||||
type: 'test'
|
||||
...
|
||||
1..23
|
||||
# tests 23
|
||||
# suites 0
|
||||
# pass 23
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 523.149354
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
TAP version 13
|
||||
# Subtest: C2-04: launcher workspace clones code but never the library
|
||||
ok 1 - C2-04: launcher workspace clones code but never the library
|
||||
---
|
||||
duration_ms: 1444.885132
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
|
||||
ok 2 - bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
|
||||
---
|
||||
duration_ms: 1312.408111
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r910aab09c0d6","instance":"chief","turn":"368e3045-8783-47d3-8ae1-e429b321d9b1","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r5bb5f0657d94","instance":"coder","turn":"3432d01f-ca94-4a5e-88d3-19588a40a81d","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
|
||||
ok 3 - the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
|
||||
---
|
||||
duration_ms: 745.868459
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"3bd3e4d7-3af4-4a1c-99f1-a8f25b7ab1dd","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"b2db478d-2905-49fd-a81a-0800643bed6a","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"ac2aa380-f3a0-40d4-960f-a543acb8c431","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"e8b5124b-591b-4d5b-829c-e944bb3e24bb","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"6a6e1fa4-5182-42dc-bbc2-26f136eedee3","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"54eb77b5-3880-4afb-9772-0b9d590eb328","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"re94397bc3680","instance":"pm","turn":"11fe8b92-1dae-416a-838b-ae305db74bc6","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r0f3692812967","instance":"chief","turn":"68266357-b477-433c-99ed-f1af37143d91","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
|
||||
ok 4 - a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
|
||||
---
|
||||
duration_ms: 1245.265675
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
|
||||
ok 5 - each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
|
||||
---
|
||||
duration_ms: 4072.119958
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
|
||||
ok 6 - a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
|
||||
---
|
||||
duration_ms: 621.888037
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
|
||||
ok 7 - REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
|
||||
---
|
||||
duration_ms: 417.133166
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r8e91dd37dc4b","instance":"coder","turn":"a7b24e30-ac5e-47cf-a951-90cf0d948b4d","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
|
||||
ok 8 - a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
|
||||
---
|
||||
duration_ms: 730.904044
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
|
||||
ok 9 - a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
|
||||
---
|
||||
duration_ms: 698.470719
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: after a restart, the new host's first tick starts a holder whose decision is still held
|
||||
ok 10 - after a restart, the new host's first tick starts a holder whose decision is still held
|
||||
---
|
||||
duration_ms: 920.432738
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
|
||||
ok 11 - a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
|
||||
---
|
||||
duration_ms: 641.309798
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
|
||||
ok 12 - a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
|
||||
---
|
||||
duration_ms: 267.647539
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner that stops at once ends its launch with the runner's reason
|
||||
ok 13 - a runner that stops at once ends its launch with the runner's reason
|
||||
---
|
||||
duration_ms: 332.423482
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a Claude Code launch with no session credential refuses before it starts
|
||||
ok 14 - a Claude Code launch with no session credential refuses before it starts
|
||||
---
|
||||
duration_ms: 181.931254
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
|
||||
ok 15 - a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
|
||||
---
|
||||
duration_ms: 315.27683
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
# plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
# Subtest: row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
|
||||
ok 16 - row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
|
||||
---
|
||||
duration_ms: 353.028786
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner killed from the host ends its launch as killed, not failed
|
||||
ok 17 - a runner killed from the host ends its launch as killed, not failed
|
||||
---
|
||||
duration_ms: 375.548364
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
|
||||
ok 18 - a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
|
||||
---
|
||||
duration_ms: 187.871873
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
|
||||
ok 19 - row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
|
||||
---
|
||||
duration_ms: 187.807189
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
|
||||
not ok 20 - row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
|
||||
---
|
||||
duration_ms: 255.698016
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r219/packages/cli/tests/launcher.test.mjs:766:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: |-
|
||||
Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
[
|
||||
+ 'compactions',
|
||||
+ undefined,
|
||||
+ 2
|
||||
- 'refused',
|
||||
- 'launch-failed',
|
||||
- 1
|
||||
]
|
||||
|
||||
code: 'ERR_ASSERTION'
|
||||
name: 'AssertionError'
|
||||
expected:
|
||||
0: 'refused'
|
||||
1: 'launch-failed'
|
||||
2: 1
|
||||
actual:
|
||||
0: 'compactions'
|
||||
2: 2
|
||||
operator: 'deepStrictEqual'
|
||||
stack: |-
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/cli/tests/launcher.test.mjs:789:10)
|
||||
process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
|
||||
...
|
||||
# Subtest: row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
|
||||
ok 21 - row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
|
||||
---
|
||||
duration_ms: 194.030218
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r3715d40bd7fb","instance":"chief","turn":"4c85d22f-fdab-4ab7-ad8a-6b4c4d1fbf72","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r16ae936abadf","instance":"coder","turn":"2c6b06f1-8f36-4261-81b7-2689b9bb7ff4","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
|
||||
ok 22 - a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
|
||||
---
|
||||
duration_ms: 1456.773218
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
|
||||
ok 23 - a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
|
||||
---
|
||||
duration_ms: 1063.59923
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
|
||||
ok 24 - a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
|
||||
---
|
||||
duration_ms: 4016.888246
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
|
||||
ok 25 - bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
|
||||
---
|
||||
duration_ms: 237.364574
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
|
||||
ok 26 - bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
|
||||
---
|
||||
duration_ms: 197.775267
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch starts no thread tick; the same host with launches runs it
|
||||
ok 27 - bus start --no-launch starts no thread tick; the same host with launches runs it
|
||||
---
|
||||
duration_ms: 1181.987654
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a malformed sessions.json refuses the host start with exit 3 and stays as it was
|
||||
ok 28 - a malformed sessions.json refuses the host start with exit 3 and stays as it was
|
||||
---
|
||||
duration_ms: 177.111363
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: an over-long launch request is refused at once, not at the 10 s idle timeout
|
||||
ok 29 - an over-long launch request is refused at once, not at the 10 s idle timeout
|
||||
---
|
||||
duration_ms: 183.364128
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a launch client that never closes its side doesn't hold the host's close
|
||||
ok 30 - a launch client that never closes its side doesn't hold the host's close
|
||||
---
|
||||
duration_ms: 179.49662
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner that ignores SIGTERM is killed when the host closes
|
||||
ok 31 - a runner that ignores SIGTERM is killed when the host closes
|
||||
---
|
||||
duration_ms: 1352.083242
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: kill switch refuses chief and hire before launch work and a clear permits a later request
|
||||
ok 32 - kill switch refuses chief and hire before launch work and a clear permits a later request
|
||||
---
|
||||
duration_ms: 319.505559
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
|
||||
ok 33 - kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
|
||||
---
|
||||
duration_ms: 334.076309
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a launch waiting across halt and resume cannot use its stale admission
|
||||
ok 34 - a launch waiting across halt and resume cannot use its stale admission
|
||||
---
|
||||
duration_ms: 201.932098
|
||||
type: 'test'
|
||||
...
|
||||
1..34
|
||||
# tests 34
|
||||
# suites 0
|
||||
# pass 33
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 26522.233904
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
{"head": "7cbd839d037f711de136108882b53597cf16d742", "manifest": "84a8bd9afc5420fc315586584aec91258a28bd728000dcd2cb61ac41b2fe711a", "start": "2026-10-23T06:09:43.704053+00:00"}
|
||||
@@ -0,0 +1,2 @@
|
||||
Error response from daemon: network rocko-r219-gate_default not found
|
||||
exit status 1
|
||||
@@ -0,0 +1,5 @@
|
||||
{"label": "single-bus-creative.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T06:09:43.706182+00:00", "end": "2026-10-23T06:09:44.121877+00:00", "seconds": 0.416, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rmc2uerc6"}
|
||||
{"label": "single-bus-threads.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T06:09:44.123566+00:00", "end": "2026-10-23T06:09:45.490657+00:00", "seconds": 1.367, "exit": 0, "timeout": false, "counts": {"tests": 19, "pass": 19, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rismt3po4"}
|
||||
{"label": "single-bus-user-principal.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T06:09:45.492192+00:00", "end": "2026-10-23T06:09:46.107814+00:00", "seconds": 0.616, "exit": 0, "timeout": false, "counts": {"tests": 23, "pass": 23, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/repq8xlqh"}
|
||||
{"label": "single-cli-launcher.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T06:09:46.109200+00:00", "end": "2026-10-23T06:10:12.635877+00:00", "seconds": 26.527, "exit": 0, "timeout": false, "counts": {"tests": 34, "pass": 34, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rkqztfjx8"}
|
||||
{"label": "single-cli-thread-launch.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/thread-launch.test.mjs"], "start": "2026-10-23T06:10:12.637449+00:00", "end": "2026-10-23T06:10:30.695792+00:00", "seconds": 18.058, "exit": 1, "timeout": false, "counts": {"tests": 14, "pass": 13, "fail": 1, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-thread-launch.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rbyz9a6iu"}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
TAP version 13
|
||||
# organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
# organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
# Subtest: M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
|
||||
ok 1 - M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
|
||||
---
|
||||
duration_ms: 43.416235
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a creative instance sends only to the user and hears only from the user
|
||||
ok 2 - a creative instance sends only to the user and hears only from the user
|
||||
---
|
||||
duration_ms: 43.274527
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
|
||||
ok 3 - M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
|
||||
---
|
||||
duration_ms: 76.669863
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
|
||||
ok 4 - row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
|
||||
---
|
||||
duration_ms: 60.418493
|
||||
type: 'test'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 4
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 374.784911
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
TAP version 13
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
---
|
||||
duration_ms: 37.977187
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
---
|
||||
duration_ms: 68.5376
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
---
|
||||
duration_ms: 33.043064
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
---
|
||||
duration_ms: 616.013932
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
---
|
||||
duration_ms: 22.785202
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
---
|
||||
duration_ms: 27.208648
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
---
|
||||
duration_ms: 29.67741
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
---
|
||||
duration_ms: 33.136315
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
---
|
||||
duration_ms: 30.610794
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
---
|
||||
duration_ms: 27.293744
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
---
|
||||
duration_ms: 39.426851
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
|
||||
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
|
||||
---
|
||||
duration_ms: 30.185753
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
---
|
||||
duration_ms: 25.774336
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
---
|
||||
duration_ms: 26.886111
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
---
|
||||
duration_ms: 90.298865
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create refuses a repository name without its matching repo before any write
|
||||
ok 16 - project.create refuses a repository name without its matching repo before any write
|
||||
---
|
||||
duration_ms: 16.900927
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create keeps unrelated names with and without a repository
|
||||
ok 17 - project.create keeps unrelated names with and without a repository
|
||||
---
|
||||
duration_ms: 11.07789
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
---
|
||||
duration_ms: 47.924595
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
---
|
||||
duration_ms: 19.917458
|
||||
type: 'test'
|
||||
...
|
||||
1..19
|
||||
# tests 19
|
||||
# suites 0
|
||||
# pass 19
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 1316.555344
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
TAP version 13
|
||||
# Subtest: R3: bindUser binds a granted user and refuses one with no live grant
|
||||
ok 1 - R3: bindUser binds a granted user and refuses one with no live grant
|
||||
---
|
||||
duration_ms: 14.967712
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
|
||||
ok 2 - R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
|
||||
---
|
||||
duration_ms: 10.570753
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: each collaborator verb is allowed to a granted user
|
||||
ok 3 - R3: each collaborator verb is allowed to a granted user
|
||||
---
|
||||
duration_ms: 20.155376
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: every verb off the collaborator list refuses a user access-refused before its own checks
|
||||
ok 4 - R3: every verb off the collaborator list refuses a user access-refused before its own checks
|
||||
---
|
||||
duration_ms: 102.151204
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: task, web and host verbs refuse a user access-refused
|
||||
ok 5 - R3: task, web and host verbs refuse a user access-refused
|
||||
---
|
||||
duration_ms: 23.297818
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: project.list shows granted projects only
|
||||
ok 6 - R3 leak: project.list shows granted projects only
|
||||
---
|
||||
duration_ms: 13.028484
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
|
||||
ok 7 - R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
|
||||
---
|
||||
duration_ms: 17.131417
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: thread.list shows the user its own threads that are still granted, never others
|
||||
ok 8 - R3 leak: thread.list shows the user its own threads that are still granted, never others
|
||||
---
|
||||
duration_ms: 34.619156
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: names shows granted instances only
|
||||
ok 9 - R3 leak: names shows granted instances only
|
||||
---
|
||||
duration_ms: 15.416042
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: thread.send is from user:<id> and writes no human.input
|
||||
ok 10 - R3: thread.send is from user:<id> and writes no human.input
|
||||
---
|
||||
duration_ms: 13.82133
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a reply-chain message.send into another user thread is refused
|
||||
ok 11 - R3: a reply-chain message.send into another user thread is refused
|
||||
---
|
||||
duration_ms: 15.982484
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
|
||||
ok 12 - R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
|
||||
---
|
||||
duration_ms: 19.055939
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a reply to: user without a matching in_reply_to is refused user-reply-required
|
||||
ok 13 - R3: a reply to: user without a matching in_reply_to is refused user-reply-required
|
||||
---
|
||||
duration_ms: 28.510227
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a non-thread run, the human and the tester cannot send to: user
|
||||
ok 14 - R3: a non-thread run, the human and the tester cannot send to: user
|
||||
---
|
||||
duration_ms: 18.072201
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
|
||||
ok 15 - R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
|
||||
---
|
||||
duration_ms: 20.4668
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
|
||||
ok 16 - R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
|
||||
---
|
||||
duration_ms: 17.954184
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user refusal is recorded as action.refused by user:<id>
|
||||
ok 17 - R3: a user refusal is recorded as action.refused by user:<id>
|
||||
---
|
||||
duration_ms: 15.466589
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a business with a role keyed user refuses invalid-business
|
||||
ok 18 - R3: a business with a role keyed user refuses invalid-business
|
||||
---
|
||||
duration_ms: 10.080838
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: name.set refuses the name user
|
||||
ok 19 - R3: name.set refuses the name user
|
||||
---
|
||||
duration_ms: 10.585336
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
|
||||
ok 20 - R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
|
||||
---
|
||||
duration_ms: 14.198383
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
|
||||
ok 21 - R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
|
||||
---
|
||||
duration_ms: 16.565998
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: a non-function access throws before the store is read
|
||||
ok 22 - R3 r4: a non-function access throws before the store is read
|
||||
---
|
||||
duration_ms: 0.139037
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
|
||||
ok 23 - C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
|
||||
---
|
||||
duration_ms: 17.431688
|
||||
type: 'test'
|
||||
...
|
||||
1..23
|
||||
# tests 23
|
||||
# suites 0
|
||||
# pass 23
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 550.515661
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
TAP version 13
|
||||
# Subtest: C2-04: launcher workspace clones code but never the library
|
||||
ok 1 - C2-04: launcher workspace clones code but never the library
|
||||
---
|
||||
duration_ms: 1386.548788
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
|
||||
ok 2 - bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
|
||||
---
|
||||
duration_ms: 1321.365393
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rfeb27e280740","instance":"chief","turn":"6c7140a2-fbf2-4eb5-aade-18915e5d6071","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r4d1379ff54ed","instance":"coder","turn":"0828e9f0-3389-4d81-b773-d33ac8ed9436","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
|
||||
ok 3 - the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
|
||||
---
|
||||
duration_ms: 729.440258
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"aa4f1c16-5525-464e-bf32-86dce3279b86","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"9bcd06eb-6a90-41fb-80ae-56a32bc1c628","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"8bbc58a8-f1e1-478c-957b-76e1d7d3a0d4","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"e1f9e391-0762-4643-bdfb-737a3f75468c","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"ee01381b-77c5-4737-9963-cc39bfcf3329","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"48edb0d5-0ec4-4e59-9eae-4f767252eaf9","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r1c029998bdea","instance":"pm","turn":"837ddcc0-6e74-408d-a21f-aeefc287e380","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r6b4b3482e67f","instance":"chief","turn":"702b0c7b-18c4-4f01-b71b-29fdf5ae1d79","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
|
||||
ok 4 - a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
|
||||
---
|
||||
duration_ms: 1260.785468
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
|
||||
ok 5 - each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
|
||||
---
|
||||
duration_ms: 4079.777924
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
|
||||
ok 6 - a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
|
||||
---
|
||||
duration_ms: 602.445534
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
|
||||
ok 7 - REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
|
||||
---
|
||||
duration_ms: 401.42897
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r91e3ad863ad3","instance":"coder","turn":"b80d99c8-e809-4461-9c46-6b36ca47b775","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
|
||||
ok 8 - a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
|
||||
---
|
||||
duration_ms: 676.724798
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
|
||||
ok 9 - a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
|
||||
---
|
||||
duration_ms: 578.985078
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: after a restart, the new host's first tick starts a holder whose decision is still held
|
||||
ok 10 - after a restart, the new host's first tick starts a holder whose decision is still held
|
||||
---
|
||||
duration_ms: 870.835521
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
|
||||
ok 11 - a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
|
||||
---
|
||||
duration_ms: 633.801547
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
|
||||
ok 12 - a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
|
||||
---
|
||||
duration_ms: 281.44642
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner that stops at once ends its launch with the runner's reason
|
||||
ok 13 - a runner that stops at once ends its launch with the runner's reason
|
||||
---
|
||||
duration_ms: 350.182818
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a Claude Code launch with no session credential refuses before it starts
|
||||
ok 14 - a Claude Code launch with no session credential refuses before it starts
|
||||
---
|
||||
duration_ms: 227.256782
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
|
||||
ok 15 - a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
|
||||
---
|
||||
duration_ms: 355.020473
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
# plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
# Subtest: row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
|
||||
ok 16 - row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
|
||||
---
|
||||
duration_ms: 395.228029
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner killed from the host ends its launch as killed, not failed
|
||||
ok 17 - a runner killed from the host ends its launch as killed, not failed
|
||||
---
|
||||
duration_ms: 426.266157
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
|
||||
ok 18 - a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
|
||||
---
|
||||
duration_ms: 224.638023
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
|
||||
ok 19 - row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
|
||||
---
|
||||
duration_ms: 193.63499
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
|
||||
ok 20 - row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
|
||||
---
|
||||
duration_ms: 276.964378
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
|
||||
ok 21 - row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
|
||||
---
|
||||
duration_ms: 199.95263
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r0292ea4215c0","instance":"chief","turn":"e2a42abe-43ec-4873-a349-c93850a0b2d1","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r0292ea4215c0","instance":"chief","turn":"b84fdcd2-08f1-4c7d-a1be-edf183ecf035","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rb645b3bdbf17","instance":"coder","turn":"8c651e69-aef3-428e-9ecb-fa2c53e86f79","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
|
||||
ok 22 - a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
|
||||
---
|
||||
duration_ms: 1419.59525
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
|
||||
ok 23 - a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
|
||||
---
|
||||
duration_ms: 1046.224325
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
|
||||
ok 24 - a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
|
||||
---
|
||||
duration_ms: 3988.178511
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
|
||||
ok 25 - bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
|
||||
---
|
||||
duration_ms: 242.392974
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
|
||||
ok 26 - bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
|
||||
---
|
||||
duration_ms: 218.198607
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch starts no thread tick; the same host with launches runs it
|
||||
ok 27 - bus start --no-launch starts no thread tick; the same host with launches runs it
|
||||
---
|
||||
duration_ms: 1199.857353
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a malformed sessions.json refuses the host start with exit 3 and stays as it was
|
||||
ok 28 - a malformed sessions.json refuses the host start with exit 3 and stays as it was
|
||||
---
|
||||
duration_ms: 181.02391
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: an over-long launch request is refused at once, not at the 10 s idle timeout
|
||||
ok 29 - an over-long launch request is refused at once, not at the 10 s idle timeout
|
||||
---
|
||||
duration_ms: 181.674266
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a launch client that never closes its side doesn't hold the host's close
|
||||
ok 30 - a launch client that never closes its side doesn't hold the host's close
|
||||
---
|
||||
duration_ms: 183.632625
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner that ignores SIGTERM is killed when the host closes
|
||||
ok 31 - a runner that ignores SIGTERM is killed when the host closes
|
||||
---
|
||||
duration_ms: 1365.88402
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: kill switch refuses chief and hire before launch work and a clear permits a later request
|
||||
ok 32 - kill switch refuses chief and hire before launch work and a clear permits a later request
|
||||
---
|
||||
duration_ms: 334.261623
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
|
||||
ok 33 - kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
|
||||
---
|
||||
duration_ms: 337.104395
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a launch waiting across halt and resume cannot use its stale admission
|
||||
ok 34 - a launch waiting across halt and resume cannot use its stale admission
|
||||
---
|
||||
duration_ms: 194.683714
|
||||
type: 'test'
|
||||
...
|
||||
1..34
|
||||
# tests 34
|
||||
# suites 0
|
||||
# pass 34
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 26492.10694
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
TAP version 13
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r2204f4cc976c","instance":"assistant","turn":"fdd36b75-12af-4495-ba60-8a2fdd991676","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r42619c482fdd","instance":"assistant","turn":"4a493440-df70-4de2-a9be-f31bb9efbe67","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r65dae8bd493b","instance":"assistant","turn":"2a27c708-e430-4bcc-82b5-67dc51173d46","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r4f264ceb0a50","instance":"assistant","turn":"dedfd89c-7a7a-4bb8-ba67-ca0e6a417ee8","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
|
||||
ok 1 - REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
|
||||
---
|
||||
duration_ms: 5837.256276
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"red085121d9a7","instance":"assistant","turn":"536c8b62-f126-4b17-a1f2-756e2aede65e","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"ree31908db53d","instance":"assistant","turn":"f23f7966-9c87-4d7b-9d9a-93211c1431c0","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
|
||||
ok 2 - REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
|
||||
---
|
||||
duration_ms: 1713.759995
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r5c82fe368237","instance":"assistant","turn":"081a280e-73b4-48fc-89a9-688e73704186","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
|
||||
ok 3 - REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
|
||||
---
|
||||
duration_ms: 4442.41406
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r008070c44356","instance":"assistant","turn":"3175be8d-24d9-4d46-b9fe-e08c8edd0a5e","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r008070c44356","instance":"assistant","turn":"961030fd-2dd3-4deb-a9be-f10af56ba7f7","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
|
||||
ok 4 - REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
|
||||
---
|
||||
duration_ms: 2897.279317
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
|
||||
ok 5 - REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
|
||||
---
|
||||
duration_ms: 79.744891
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rf65e96bfb0f4","instance":"assistant","turn":"61a6ab74-b6bd-4274-a83e-0f8fb4f61a68","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
|
||||
ok 6 - REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
|
||||
---
|
||||
duration_ms: 374.600592
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
|
||||
ok 7 - REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
|
||||
---
|
||||
duration_ms: 232.164242
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
|
||||
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
|
||||
---
|
||||
duration_ms: 226.367358
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
|
||||
ok 9 - REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
|
||||
---
|
||||
duration_ms: 78.315815
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
|
||||
ok 10 - C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
|
||||
---
|
||||
duration_ms: 74.784926
|
||||
type: 'test'
|
||||
...
|
||||
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
|
||||
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
|
||||
# Subtest: REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
|
||||
ok 11 - REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
|
||||
---
|
||||
duration_ms: 192.227024
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r3ffa99981f81","instance":"assistant","turn":"c194605d-1b92-488c-9afe-2599f205e80e","source":"@none","code":"baseline-unknown"}
|
||||
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mustStop","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":1}
|
||||
# Subtest: REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
|
||||
ok 12 - REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
|
||||
---
|
||||
duration_ms: 346.819478
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r013acfa3de9d","instance":"bean","turn":"26896136-02b5-4948-8c84-7e0be580c485","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r13746c56cf7b","instance":"chet","turn":"2a7d4bed-79e7-4b74-b602-c07fe90be87f","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r013acfa3de9d","instance":"bean","turn":"32222fad-092d-4ac9-b5de-d40de519bf82","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: C2-09a: creative thread messages start distinct runs and keep each instance history separate
|
||||
not ok 13 - C2-09a: creative thread messages start distinct runs and keep each instance history separate
|
||||
---
|
||||
duration_ms: 746.425172
|
||||
type: 'test'
|
||||
location: '/mnt/storage/scratch/rocko-r219/packages/cli/tests/thread-launch.test.mjs:463:1'
|
||||
failureType: 'testCodeFailure'
|
||||
error: "ENOENT: no such file or directory, open '/mnt/storage/scratch/tmp/rbyz9a6iu/mosaic-thread-Cgx1tR/data/threads/acme/0a27cae9-cdb8-436d-8cc8-a59b316577e9/session/history'"
|
||||
code: 'ENOENT'
|
||||
stack: |-
|
||||
readFileSync (node:fs:621:20)
|
||||
TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/cli/tests/thread-launch.test.mjs:489:16)
|
||||
async Test.run (node:internal/test_runner/test:1409:7)
|
||||
async Test.processPendingSubtests (node:internal/test_runner/test:974:7)
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rb9998f7e5721","instance":"bean","turn":"6c83f086-940d-43ab-8219-0ee1b9dd4c7d","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rdf0bd4d3e49a","instance":"bean","turn":"663ed95c-c09b-4053-aeb7-1696b6acf272","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
|
||||
ok 14 - C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
|
||||
---
|
||||
duration_ms: 643.667218
|
||||
type: 'test'
|
||||
...
|
||||
1..14
|
||||
# tests 14
|
||||
# suites 0
|
||||
# pass 13
|
||||
# fail 1
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 18010.958178
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
{"head": "bfa421b6cd9b2b54d8da119e73b004aeafe606c3", "manifest": "2f7dfb7e41a33b89616edc5ce796e5d351c6f436284aa1e2dbfa773915be1820", "start": "2026-10-23T08:08:37.133924+00:00"}
|
||||
@@ -0,0 +1,2 @@
|
||||
Error response from daemon: network rocko-r219-gate_default not found
|
||||
exit status 1
|
||||
@@ -0,0 +1,9 @@
|
||||
{"label": "single-bus-creative.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T08:08:37.137065+00:00", "end": "2026-10-23T08:08:37.553450+00:00", "seconds": 0.416, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/ruqo1xw54"}
|
||||
{"label": "single-bus-threads.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T08:08:37.554878+00:00", "end": "2026-10-23T08:08:38.871766+00:00", "seconds": 1.317, "exit": 0, "timeout": false, "counts": {"tests": 19, "pass": 19, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rc8231nhf"}
|
||||
{"label": "single-bus-user-principal.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T08:08:38.873280+00:00", "end": "2026-10-23T08:08:39.438646+00:00", "seconds": 0.565, "exit": 0, "timeout": false, "counts": {"tests": 23, "pass": 23, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r8q2inpml"}
|
||||
{"label": "single-cli-launcher.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T08:08:39.440174+00:00", "end": "2026-10-23T08:09:06.064398+00:00", "seconds": 26.624, "exit": 0, "timeout": false, "counts": {"tests": 34, "pass": 34, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r1v5ginj7"}
|
||||
{"label": "single-cli-thread-launch.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/cli/tests/thread-launch.test.mjs"], "start": "2026-10-23T08:09:06.066057+00:00", "end": "2026-10-23T08:09:24.118779+00:00", "seconds": 18.053, "exit": 0, "timeout": false, "counts": {"tests": 14, "pass": 14, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-cli-thread-launch.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rkqddfdec"}
|
||||
{"label": "single-webui-chat-browser.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/webui/tests/chat-browser.test.mjs"], "start": "2026-10-23T08:09:24.120216+00:00", "end": "2026-10-23T08:09:43.026298+00:00", "seconds": 18.906, "exit": 0, "timeout": false, "counts": {"tests": 14, "pass": 14, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-webui-chat-browser.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rgr2ixw_2"}
|
||||
{"label": "single-webui-chat-instances.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/webui/tests/chat-instances.test.mjs"], "start": "2026-10-23T08:09:43.027683+00:00", "end": "2026-10-23T08:09:43.191979+00:00", "seconds": 0.164, "exit": 0, "timeout": false, "counts": {"tests": 4, "pass": 4, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-webui-chat-instances.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rjrq7rm3e"}
|
||||
{"label": "single-webui-chat.test", "args": ["timeout", "-k", "10", "300", "node", "--test-reporter=tap", "--test", "packages/webui/tests/chat.test.mjs"], "start": "2026-10-23T08:09:43.193338+00:00", "end": "2026-10-23T08:09:43.708659+00:00", "seconds": 0.515, "exit": 0, "timeout": false, "counts": {"tests": 25, "pass": 25, "fail": 0, "cancelled": 0, "skipped": 0}, "skips": ["# skipped 0"], "log": "agents/rocko/work/queue-219/run-4a9c-mos1408/single-webui-chat.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r2t2knykp"}
|
||||
{"label": "suite-bus", "args": ["timeout", "-k", "10", "1800", "node", "--test-reporter=tap", "--test", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/account-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/agents.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/approval-binding.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/broker.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/business.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/channel-proof.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/chief-of-staff.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/comment-cursors.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/comment-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/creative.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-claim.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-status.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-store.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credential-verbs.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/credentials.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/csuite-messaging.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/daily-quota-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/decision-close.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/decision-route.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/decisions.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/end-launch.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/fire-revokes-grants.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/gitea-grant-docker.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/gitea-grant.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/gitea-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-enroll.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-executor.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-host.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/grant-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/hard-waits.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/host-run.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/host-tools.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/human.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/instances-verb.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/interpreter.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/land.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/launch-notice.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/library-sync.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/library.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/lifecycle-revoke.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/lifecycle-verbs.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limit-checks.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limit-stop.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limits-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/limits.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/message-citation.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/message-filter.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/messages.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/messaging.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/name-migration.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/names.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/peer-transport.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/peercred.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/plugins-verb.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/principal.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/process.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing-timeout.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing-web.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/processing.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/project-await.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/project-files.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/project-runtime.test.mjs", "/mnt/storage/scratch/rocko-r219/packages/bus/tests/publish.test.mjs", "/mnt/storage/scLine truncated
|
||||
@@ -0,0 +1,36 @@
|
||||
TAP version 13
|
||||
# organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
# organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
# Subtest: M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
|
||||
ok 1 - M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises
|
||||
---
|
||||
duration_ms: 34.682331
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a creative instance sends only to the user and hears only from the user
|
||||
ok 2 - a creative instance sends only to the user and hears only from the user
|
||||
---
|
||||
duration_ms: 40.110807
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
|
||||
ok 3 - M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works
|
||||
---
|
||||
duration_ms: 73.937403
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
|
||||
ok 4 - row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb
|
||||
---
|
||||
duration_ms: 55.55064
|
||||
type: 'test'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 4
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 330.224744
|
||||
@@ -0,0 +1,124 @@
|
||||
TAP version 13
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
ok 1 - REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger
|
||||
---
|
||||
duration_ms: 38.52476
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
ok 2 - REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker
|
||||
---
|
||||
duration_ms: 64.995866
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
ok 3 - REQ-MSG-1: a message to or from the assistant names its thread, or is refused
|
||||
---
|
||||
duration_ms: 32.003231
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
ok 4 - REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work
|
||||
---
|
||||
duration_ms: 593.185752
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
ok 5 - REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance
|
||||
---
|
||||
duration_ms: 24.74469
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
ok 6 - REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder
|
||||
---
|
||||
duration_ms: 28.552018
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
ok 7 - REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it
|
||||
---
|
||||
duration_ms: 31.22843
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder
|
||||
---
|
||||
duration_ms: 32.904321
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
ok 9 - REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder
|
||||
---
|
||||
duration_ms: 28.44647
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
ok 10 - REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant
|
||||
---
|
||||
duration_ms: 24.975254
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
ok 11 - Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's
|
||||
---
|
||||
duration_ms: 37.074551
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-USER-2: the human verbs refuse another owner's thread
|
||||
ok 12 - REQ-USER-2: the human verbs refuse another owner's thread
|
||||
---
|
||||
duration_ms: 28.633018
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
ok 13 - REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one
|
||||
---
|
||||
duration_ms: 22.573463
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
ok 14 - REQ-ORG-2: projects and threads are named once, and the repo names a business project
|
||||
---
|
||||
duration_ms: 24.854088
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
ok 15 - REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing
|
||||
---
|
||||
duration_ms: 87.902895
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create refuses a repository name without its matching repo before any write
|
||||
ok 16 - project.create refuses a repository name without its matching repo before any write
|
||||
---
|
||||
duration_ms: 16.647696
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: project.create keeps unrelated names with and without a repository
|
||||
ok 17 - project.create keeps unrelated names with and without a repository
|
||||
---
|
||||
duration_ms: 11.154646
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
ok 18 - C2-09a: explicit creative instances bind independent threads, default sessions and holders
|
||||
---
|
||||
duration_ms: 47.242783
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
ok 19 - C2-09a: explicit missing, non-thread and malformed selectors create no thread or session
|
||||
---
|
||||
duration_ms: 21.669855
|
||||
type: 'test'
|
||||
...
|
||||
1..19
|
||||
# tests 19
|
||||
# suites 0
|
||||
# pass 19
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 1277.767202
|
||||
@@ -0,0 +1,148 @@
|
||||
TAP version 13
|
||||
# Subtest: R3: bindUser binds a granted user and refuses one with no live grant
|
||||
ok 1 - R3: bindUser binds a granted user and refuses one with no live grant
|
||||
---
|
||||
duration_ms: 13.924459
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
|
||||
ok 2 - R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say
|
||||
---
|
||||
duration_ms: 9.64513
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: each collaborator verb is allowed to a granted user
|
||||
ok 3 - R3: each collaborator verb is allowed to a granted user
|
||||
---
|
||||
duration_ms: 18.606176
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: every verb off the collaborator list refuses a user access-refused before its own checks
|
||||
ok 4 - R3: every verb off the collaborator list refuses a user access-refused before its own checks
|
||||
---
|
||||
duration_ms: 97.018766
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: task, web and host verbs refuse a user access-refused
|
||||
ok 5 - R3: task, web and host verbs refuse a user access-refused
|
||||
---
|
||||
duration_ms: 22.278844
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: project.list shows granted projects only
|
||||
ok 6 - R3 leak: project.list shows granted projects only
|
||||
---
|
||||
duration_ms: 12.700951
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
|
||||
ok 7 - R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist
|
||||
---
|
||||
duration_ms: 15.363337
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: thread.list shows the user its own threads that are still granted, never others
|
||||
ok 8 - R3 leak: thread.list shows the user its own threads that are still granted, never others
|
||||
---
|
||||
duration_ms: 31.965026
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 leak: names shows granted instances only
|
||||
ok 9 - R3 leak: names shows granted instances only
|
||||
---
|
||||
duration_ms: 14.127917
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: thread.send is from user:<id> and writes no human.input
|
||||
ok 10 - R3: thread.send is from user:<id> and writes no human.input
|
||||
---
|
||||
duration_ms: 12.458443
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a reply-chain message.send into another user thread is refused
|
||||
ok 11 - R3: a reply-chain message.send into another user thread is refused
|
||||
---
|
||||
duration_ms: 14.424409
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
|
||||
ok 12 - R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice
|
||||
---
|
||||
duration_ms: 19.043152
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a reply to: user without a matching in_reply_to is refused user-reply-required
|
||||
ok 13 - R3: a reply to: user without a matching in_reply_to is refused user-reply-required
|
||||
---
|
||||
duration_ms: 28.314095
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a non-thread run, the human and the tester cannot send to: user
|
||||
ok 14 - R3: a non-thread run, the human and the tester cannot send to: user
|
||||
---
|
||||
duration_ms: 18.345488
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
|
||||
ok 15 - R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9
|
||||
---
|
||||
duration_ms: 19.2905
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
|
||||
ok 16 - R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request
|
||||
---
|
||||
duration_ms: 16.768862
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a user refusal is recorded as action.refused by user:<id>
|
||||
ok 17 - R3: a user refusal is recorded as action.refused by user:<id>
|
||||
---
|
||||
duration_ms: 13.097341
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: a business with a role keyed user refuses invalid-business
|
||||
ok 18 - R3: a business with a role keyed user refuses invalid-business
|
||||
---
|
||||
duration_ms: 9.101012
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3: name.set refuses the name user
|
||||
ok 19 - R3: name.set refuses the name user
|
||||
---
|
||||
duration_ms: 9.51256
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
|
||||
ok 20 - R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers
|
||||
---
|
||||
duration_ms: 13.740514
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
|
||||
ok 21 - R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester
|
||||
---
|
||||
duration_ms: 15.133919
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: R3 r4: a non-function access throws before the store is read
|
||||
ok 22 - R3 r4: a non-function access throws before the store is read
|
||||
---
|
||||
duration_ms: 0.104353
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
|
||||
ok 23 - C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project
|
||||
---
|
||||
duration_ms: 15.763241
|
||||
type: 'test'
|
||||
...
|
||||
1..23
|
||||
# tests 23
|
||||
# suites 0
|
||||
# pass 23
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 519.847477
|
||||
@@ -0,0 +1,229 @@
|
||||
TAP version 13
|
||||
# Subtest: C2-04: launcher workspace clones code but never the library
|
||||
ok 1 - C2-04: launcher workspace clones code but never the library
|
||||
---
|
||||
duration_ms: 1398.187431
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
|
||||
ok 2 - bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest
|
||||
---
|
||||
duration_ms: 1322.865907
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"ra61e58b80d9d","instance":"chief","turn":"3525a183-23ba-49e0-b22d-e3b30769d70b","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r04fb94e1138e","instance":"coder","turn":"98345cf4-0180-45bb-8d5d-c2160653bd22","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
|
||||
ok 3 - the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code
|
||||
---
|
||||
duration_ms: 725.031053
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"5f3f43e7-dd01-49bb-8952-4d4352c5e7a9","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"ea6dd415-3ed9-4ea9-974c-82099bff050a","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"db418684-9d51-4f2b-97aa-094ed9964411","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"a95af522-8a95-40f8-bff1-fbd87ca56f8e","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"889654ce-750b-49ed-9722-9c0f3d7ea0fa","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"85616e3b-f44c-4754-8154-4a4eb28e8008","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rf6fac07d1c93","instance":"pm","turn":"14d21ceb-82a6-43f4-91c0-e96168f2bb5c","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rb6d76aacd130","instance":"chief","turn":"fd1a6765-8c4a-445a-97aa-bceb3fde79dc","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
|
||||
ok 4 - a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold
|
||||
---
|
||||
duration_ms: 1238.624524
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
|
||||
ok 5 - each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged
|
||||
---
|
||||
duration_ms: 4045.23068
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
|
||||
ok 6 - a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none
|
||||
---
|
||||
duration_ms: 597.835066
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
|
||||
ok 7 - REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing
|
||||
---
|
||||
duration_ms: 353.154987
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r4652c5115528","instance":"coder","turn":"51a1a049-358f-438c-b27b-fca9a3396b36","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
|
||||
ok 8 - a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited
|
||||
---
|
||||
duration_ms: 684.451138
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
|
||||
ok 9 - a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder
|
||||
---
|
||||
duration_ms: 629.428309
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: after a restart, the new host's first tick starts a holder whose decision is still held
|
||||
ok 10 - after a restart, the new host's first tick starts a holder whose decision is still held
|
||||
---
|
||||
duration_ms: 904.62602
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
|
||||
ok 11 - a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder
|
||||
---
|
||||
duration_ms: 626.261549
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
|
||||
ok 12 - a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left
|
||||
---
|
||||
duration_ms: 267.648666
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner that stops at once ends its launch with the runner's reason
|
||||
ok 13 - a runner that stops at once ends its launch with the runner's reason
|
||||
---
|
||||
duration_ms: 333.589161
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a Claude Code launch with no session credential refuses before it starts
|
||||
ok 14 - a Claude Code launch with no session credential refuses before it starts
|
||||
---
|
||||
duration_ms: 196.204688
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
|
||||
ok 15 - a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables
|
||||
---
|
||||
duration_ms: 324.7864
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
# plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
# Subtest: row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
|
||||
ok 16 - row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason
|
||||
---
|
||||
duration_ms: 371.866314
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner killed from the host ends its launch as killed, not failed
|
||||
ok 17 - a runner killed from the host ends its launch as killed, not failed
|
||||
---
|
||||
duration_ms: 392.194089
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
|
||||
ok 18 - a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback
|
||||
---
|
||||
duration_ms: 202.884427
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
|
||||
ok 19 - row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts
|
||||
---
|
||||
duration_ms: 194.743064
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
|
||||
ok 20 - row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason
|
||||
---
|
||||
duration_ms: 259.238364
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
|
||||
ok 21 - row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts
|
||||
---
|
||||
duration_ms: 195.621694
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r9cf2ba4aaf31","instance":"chief","turn":"2c73ea95-2667-418b-bc55-e6718bf2dfad","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rc573a1b42fc5","instance":"coder","turn":"bea54efe-2383-4813-95e2-88f78fb5c666","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
|
||||
ok 22 - a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold
|
||||
---
|
||||
duration_ms: 1629.531256
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
|
||||
ok 23 - a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again
|
||||
---
|
||||
duration_ms: 1167.026862
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
|
||||
ok 24 - a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again
|
||||
---
|
||||
duration_ms: 4007.66668
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
|
||||
ok 25 - bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was
|
||||
---
|
||||
duration_ms: 239.242108
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
|
||||
ok 26 - bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned
|
||||
---
|
||||
duration_ms: 199.521317
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: bus start --no-launch starts no thread tick; the same host with launches runs it
|
||||
ok 27 - bus start --no-launch starts no thread tick; the same host with launches runs it
|
||||
---
|
||||
duration_ms: 1177.497521
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a malformed sessions.json refuses the host start with exit 3 and stays as it was
|
||||
ok 28 - a malformed sessions.json refuses the host start with exit 3 and stays as it was
|
||||
---
|
||||
duration_ms: 178.077896
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: an over-long launch request is refused at once, not at the 10 s idle timeout
|
||||
ok 29 - an over-long launch request is refused at once, not at the 10 s idle timeout
|
||||
---
|
||||
duration_ms: 188.573981
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a launch client that never closes its side doesn't hold the host's close
|
||||
ok 30 - a launch client that never closes its side doesn't hold the host's close
|
||||
---
|
||||
duration_ms: 184.053738
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a runner that ignores SIGTERM is killed when the host closes
|
||||
ok 31 - a runner that ignores SIGTERM is killed when the host closes
|
||||
---
|
||||
duration_ms: 1355.882576
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: kill switch refuses chief and hire before launch work and a clear permits a later request
|
||||
ok 32 - kill switch refuses chief and hire before launch work and a clear permits a later request
|
||||
---
|
||||
duration_ms: 313.50548
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
|
||||
ok 33 - kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle
|
||||
---
|
||||
duration_ms: 323.703188
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: a launch waiting across halt and resume cannot use its stale admission
|
||||
ok 34 - a launch waiting across halt and resume cannot use its stale admission
|
||||
---
|
||||
duration_ms: 196.26668
|
||||
type: 'test'
|
||||
...
|
||||
1..34
|
||||
# tests 34
|
||||
# suites 0
|
||||
# pass 34
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 26550.340355
|
||||
@@ -0,0 +1,113 @@
|
||||
TAP version 13
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r3cbaedb93e9e","instance":"assistant","turn":"e9d9b5c9-7927-4afe-bbec-09ebb7e3c2d8","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rdd4958ee00ee","instance":"assistant","turn":"691b3b36-ad3a-4a36-b0c0-c1cbe3bb9718","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r1410d16d8724","instance":"assistant","turn":"44a08edb-5d78-4422-9cc3-3de2dcbcc446","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r985e2dd44866","instance":"assistant","turn":"c19c4a62-eca2-4b50-aa07-a3039c941951","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
|
||||
ok 1 - REQ-THREAD-2 REQ-LAUNCH-1 REQ-MSG-1 a thread message starts the thread's session, which stops when idle; its history survives the stop and a restart and no other thread sees it
|
||||
---
|
||||
duration_ms: 5834.488965
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r04a576c1f8c7","instance":"assistant","turn":"3dc2be1d-3ab4-42eb-96b2-fe2f23f9a098","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r71b29c392723","instance":"assistant","turn":"c4afa895-0e71-4e02-a6a8-93ee33a3149f","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
|
||||
ok 2 - REQ-THREAD-2 REQ-LAUNCH-1 a running thread counts against its model's limit and an idle-stopped one doesn't
|
||||
---
|
||||
duration_ms: 1705.349475
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r6a44bba72669","instance":"assistant","turn":"465d12fa-1123-481e-9f66-13cae16356a9","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
|
||||
ok 3 - REQ-THREAD-2 REQ-LAUNCH-1 a turn longer than the idle time isn't stopped, and the thread idles out after it
|
||||
---
|
||||
duration_ms: 4414.175885
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r754792f99f16","instance":"assistant","turn":"a938a0f9-3876-4178-b3c0-3d993a88b6d1","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r754792f99f16","instance":"assistant","turn":"52f8b51f-968c-4168-b45a-0dbb31a8062e","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
|
||||
ok 4 - REQ-THREAD-2 REQ-LAUNCH-1 a message sent after the idle snapshot keeps the session, which answers it
|
||||
---
|
||||
duration_ms: 2896.8825
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
|
||||
ok 5 - REQ-ROLE-2 REQ-LAUNCH-1 a thread held by a run the launcher didn't start gets no second session
|
||||
---
|
||||
duration_ms: 80.27078
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"raeec2e45fadf","instance":"assistant","turn":"9feca456-d42b-4fef-a90c-e9e0bc42900d","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
|
||||
ok 6 - REQ-LAUNCH-1 a thread run's session file has no landing, even when landHolders names its instance
|
||||
---
|
||||
duration_ms: 375.325022
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
|
||||
ok 7 - REQ-LAUNCH-1 a run that ends without taking its thread's message isn't started again for that message
|
||||
---
|
||||
duration_ms: 224.817609
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
|
||||
ok 8 - REQ-THREAD-2 REQ-LAUNCH-1 a thread id that isn't a safe segment, or a thread of a non-thread instance, launches nothing
|
||||
---
|
||||
duration_ms: 218.942512
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
|
||||
ok 9 - REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a thread
|
||||
---
|
||||
duration_ms: 78.217748
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
|
||||
ok 10 - C2-09a: fromSession refuses a creative instance outside a thread without a sandbox
|
||||
---
|
||||
duration_ms: 77.300657
|
||||
type: 'test'
|
||||
...
|
||||
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
|
||||
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mayLaunch","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":0}
|
||||
# Subtest: REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
|
||||
ok 11 - REQ-LIMIT-3 REQ-LIMIT-4 a thread start whose instance is at its token limit is refused, logged once with the limit and the count
|
||||
---
|
||||
duration_ms: 193.556032
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r88480b9d9bb0","instance":"assistant","turn":"608959cc-73cd-4dec-afb1-b6173eef917a","source":"@none","code":"baseline-unknown"}
|
||||
# limits: {"event":"limit-reached","business":"acme","check":"token-limit","export":"mustStop","subject":{"instance":"assistant","provider":"zai","model":"claude-sonnet-5-5","account":null},"path":["tokens","instances","assistant"],"limit":0,"count":0,"unknownTurns":1}
|
||||
# Subtest: REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
|
||||
ok 12 - REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: the reply lands, and the end record names the limit
|
||||
---
|
||||
duration_ms: 344.672866
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r757ceeb5a4e2","instance":"bean","turn":"aaba8e84-43ad-4d80-9f91-e47c4eb738a7","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r66f34e40ffb8","instance":"chet","turn":"49ba0ae9-1bf3-4f55-9890-89b3517fc2bd","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r757ceeb5a4e2","instance":"bean","turn":"48ba94a2-7b25-444e-a32a-38d665ee402e","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: C2-09a: creative thread messages start distinct runs and keep each instance history separate
|
||||
ok 13 - C2-09a: creative thread messages start distinct runs and keep each instance history separate
|
||||
---
|
||||
duration_ms: 752.885702
|
||||
type: 'test'
|
||||
...
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"rbe602f558670","instance":"bean","turn":"9dd6de47-7f91-48a8-9bcb-4d0e23181d0a","source":"@none","code":"baseline-unknown"}
|
||||
# usage: {"event":"usage-unknown","business":"acme","run":"r5c59dde2201a","instance":"bean","turn":"43b0b391-ccea-406f-bba4-b03f17ebe184","source":"@none","code":"baseline-unknown"}
|
||||
# Subtest: C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
|
||||
ok 14 - C2-09a: terminating one creative thread does not signal an unselected peer of the same instance
|
||||
---
|
||||
duration_ms: 653.641213
|
||||
type: 'test'
|
||||
...
|
||||
1..14
|
||||
# tests 14
|
||||
# suites 0
|
||||
# pass 14
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 17977.782085
|
||||
@@ -0,0 +1,94 @@
|
||||
TAP version 13
|
||||
# Subtest: chat browser: list/create named projects and threads, hostile labels inert and no implicit writes
|
||||
ok 1 - chat browser: list/create named projects and threads, hostile labels inert and no implicit writes
|
||||
---
|
||||
duration_ms: 936.268368
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes
|
||||
ok 2 - chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes
|
||||
---
|
||||
duration_ms: 602.776985
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send
|
||||
ok 3 - chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send
|
||||
---
|
||||
duration_ms: 748.487224
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads
|
||||
ok 4 - chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads
|
||||
---
|
||||
duration_ms: 808.240338
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate
|
||||
ok 5 - chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate
|
||||
---
|
||||
duration_ms: 612.785782
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: scrolled history stays put, New messages and an own send return to the tail
|
||||
ok 6 - chat browser: scrolled history stays put, New messages and an own send return to the tail
|
||||
---
|
||||
duration_ms: 587.664991
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: automatic polling follows a reply without a POST or a lost draft
|
||||
ok 7 - chat browser: automatic polling follows a reply without a POST or a lost draft
|
||||
---
|
||||
duration_ms: 10575.301338
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: display stays bounded while every forward page remains reachable
|
||||
ok 8 - chat browser: display stays bounded while every forward page remains reachable
|
||||
---
|
||||
duration_ms: 465.055105
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown
|
||||
ok 9 - chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown
|
||||
---
|
||||
duration_ms: 543.602626
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: a refusal elsewhere forgets private chat drafts
|
||||
ok 10 - chat browser: a refusal elsewhere forgets private chat drafts
|
||||
---
|
||||
duration_ms: 472.966826
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat browser: a late project creation receipt cannot navigate away from another thread
|
||||
ok 11 - chat browser: a late project creation receipt cannot navigate away from another thread
|
||||
---
|
||||
duration_ms: 549.648649
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST
|
||||
ok 12 - C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST
|
||||
---
|
||||
duration_ms: 608.893729
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching
|
||||
ok 13 - C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching
|
||||
---
|
||||
duration_ms: 653.667293
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread
|
||||
ok 14 - C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread
|
||||
---
|
||||
duration_ms: 584.468742
|
||||
type: 'test'
|
||||
...
|
||||
1..14
|
||||
# tests 14
|
||||
# suites 0
|
||||
# pass 14
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 18856.847319
|
||||
@@ -0,0 +1,34 @@
|
||||
TAP version 13
|
||||
# Subtest: C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads
|
||||
ok 1 - C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads
|
||||
---
|
||||
duration_ms: 16.694095
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a metadata uses the private tester listener own bus and no human transport
|
||||
ok 2 - C2-09a metadata uses the private tester listener own bus and no human transport
|
||||
---
|
||||
duration_ms: 2.7219
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields
|
||||
ok 3 - C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields
|
||||
---
|
||||
duration_ms: 3.349671
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity
|
||||
ok 4 - C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity
|
||||
---
|
||||
duration_ms: 2.024242
|
||||
type: 'test'
|
||||
...
|
||||
1..4
|
||||
# tests 4
|
||||
# suites 0
|
||||
# pass 4
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 122.008979
|
||||
@@ -0,0 +1,160 @@
|
||||
TAP version 13
|
||||
# Subtest: chat reads map only fixed verbs, strict forward paging and pure GET
|
||||
ok 1 - chat reads map only fixed verbs, strict forward paging and pure GET
|
||||
---
|
||||
duration_ms: 32.994704
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat refuses malformed and repeated query/id values before transport
|
||||
ok 2 - chat refuses malformed and repeated query/id values before transport
|
||||
---
|
||||
duration_ms: 4.672731
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat project.create: one verified write, fixed identity/class and private log
|
||||
ok 3 - chat project.create: one verified write, fixed identity/class and private log
|
||||
---
|
||||
duration_ms: 2.784231
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat project.create: each existing Console write fence makes zero bus calls
|
||||
ok 4 - chat project.create: each existing Console write fence makes zero bus calls
|
||||
---
|
||||
duration_ms: 6.854048
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat project.create: refusal or lost/malformed receipt never retries or invents success
|
||||
ok 5 - chat project.create: refusal or lost/malformed receipt never retries or invents success
|
||||
---
|
||||
duration_ms: 3.53835
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.create: one verified write, fixed identity/class and private log
|
||||
ok 6 - chat thread.create: one verified write, fixed identity/class and private log
|
||||
---
|
||||
duration_ms: 1.26663
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.create: each existing Console write fence makes zero bus calls
|
||||
ok 7 - chat thread.create: each existing Console write fence makes zero bus calls
|
||||
---
|
||||
duration_ms: 5.658165
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.create: refusal or lost/malformed receipt never retries or invents success
|
||||
ok 8 - chat thread.create: refusal or lost/malformed receipt never retries or invents success
|
||||
---
|
||||
duration_ms: 3.756541
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.send: one verified write, fixed identity/class and private log
|
||||
ok 9 - chat thread.send: one verified write, fixed identity/class and private log
|
||||
---
|
||||
duration_ms: 1.052634
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.send: each existing Console write fence makes zero bus calls
|
||||
ok 10 - chat thread.send: each existing Console write fence makes zero bus calls
|
||||
---
|
||||
duration_ms: 4.98916
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.send: refusal or lost/malformed receipt never retries or invents success
|
||||
ok 11 - chat thread.send: refusal or lost/malformed receipt never retries or invents success
|
||||
---
|
||||
duration_ms: 2.799871
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.open: one verified write, fixed identity/class and private log
|
||||
ok 12 - chat thread.open: one verified write, fixed identity/class and private log
|
||||
---
|
||||
duration_ms: 0.924357
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.open: each existing Console write fence makes zero bus calls
|
||||
ok 13 - chat thread.open: each existing Console write fence makes zero bus calls
|
||||
---
|
||||
duration_ms: 5.935949
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat thread.open: refusal or lost/malformed receipt never retries or invents success
|
||||
ok 14 - chat thread.open: refusal or lost/malformed receipt never retries or invents success
|
||||
---
|
||||
duration_ms: 4.265801
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat createThread receipt requires an instance even when its id is valid
|
||||
ok 15 - chat createThread receipt requires an instance even when its id is valid
|
||||
---
|
||||
duration_ms: 1.94677
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat write receipts expose only the fields belonging to their operation
|
||||
ok 16 - chat write receipts expose only the fields belonging to their operation
|
||||
---
|
||||
duration_ms: 3.157564
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat validates names, body, project and strict empty open payload
|
||||
ok 17 - chat validates names, body, project and strict empty open payload
|
||||
---
|
||||
duration_ms: 3.454696
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat read failures redact ownership and refuse invalid transport arrays
|
||||
ok 18 - chat read failures redact ownership and refuse invalid transport arrays
|
||||
---
|
||||
duration_ms: 3.332048
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations
|
||||
ok 19 - REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations
|
||||
---
|
||||
duration_ms: 83.632528
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat repeats every POST boundary even when called behind an admitted request
|
||||
ok 20 - chat repeats every POST boundary even when called behind an admitted request
|
||||
---
|
||||
duration_ms: 0.989385
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: configured Console process injects chat through its existing human transport
|
||||
ok 21 - configured Console process injects chat through its existing human transport
|
||||
---
|
||||
duration_ms: 134.070265
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: chat assets, GET purity, method and unknown-route refusals are explicit
|
||||
ok 22 - chat assets, GET purity, method and unknown-route refusals are explicit
|
||||
---
|
||||
duration_ms: 11.62792
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: the chat seam names the transport it is given: the human one and the tester one both pass, anything else is a TypeError
|
||||
ok 23 - the chat seam names the transport it is given: the human one and the tester one both pass, anything else is a TypeError
|
||||
---
|
||||
duration_ms: 0.497628
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a createThread forwards an explicit instance through the existing channel
|
||||
ok 24 - C2-09a createThread forwards an explicit instance through the existing channel
|
||||
---
|
||||
duration_ms: 3.577119
|
||||
type: 'test'
|
||||
...
|
||||
# Subtest: C2-09a malformed instance and extra thread fields refuse before the channel write
|
||||
ok 25 - C2-09a malformed instance and extra thread fields refuse before the channel write
|
||||
---
|
||||
duration_ms: 3.204303
|
||||
type: 'test'
|
||||
...
|
||||
1..25
|
||||
# tests 25
|
||||
# suites 0
|
||||
# pass 25
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0
|
||||
# duration_ms 441.402332
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,130 @@
|
||||
[
|
||||
{
|
||||
"label": "single-bus-creative.test",
|
||||
"args": [
|
||||
"node",
|
||||
"--test",
|
||||
"packages/bus/tests/creative.test.mjs"
|
||||
],
|
||||
"start": "2026-10-23T04:07:22.895462+00:00",
|
||||
"end": "2026-10-23T04:07:23.260724+00:00",
|
||||
"seconds": 0.365,
|
||||
"exit": 0,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": null,
|
||||
"pass": null,
|
||||
"fail": null,
|
||||
"cancelled": null,
|
||||
"skipped": null
|
||||
},
|
||||
"skips": [],
|
||||
"log": "agents/rocko/work/queue-219/run-4a9c/single-bus-creative.test.tap",
|
||||
"tmpdir": "/mnt/storage/scratch/tmp/rsrt5bzap",
|
||||
"derived_spec_counts": {
|
||||
"tests": 4,
|
||||
"pass": 4,
|
||||
"fail": 0,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"failed_test_names": []
|
||||
},
|
||||
{
|
||||
"label": "single-bus-threads.test",
|
||||
"args": [
|
||||
"node",
|
||||
"--test",
|
||||
"packages/bus/tests/threads.test.mjs"
|
||||
],
|
||||
"start": "2026-10-23T04:07:23.262757+00:00",
|
||||
"end": "2026-10-23T04:07:24.629883+00:00",
|
||||
"seconds": 1.367,
|
||||
"exit": 0,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": null,
|
||||
"pass": null,
|
||||
"fail": null,
|
||||
"cancelled": null,
|
||||
"skipped": null
|
||||
},
|
||||
"skips": [],
|
||||
"log": "agents/rocko/work/queue-219/run-4a9c/single-bus-threads.test.tap",
|
||||
"tmpdir": "/mnt/storage/scratch/tmp/r5anojtow",
|
||||
"derived_spec_counts": {
|
||||
"tests": 19,
|
||||
"pass": 19,
|
||||
"fail": 0,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"failed_test_names": []
|
||||
},
|
||||
{
|
||||
"label": "single-bus-user-principal.test",
|
||||
"args": [
|
||||
"node",
|
||||
"--test",
|
||||
"packages/bus/tests/user-principal.test.mjs"
|
||||
],
|
||||
"start": "2026-10-23T04:07:24.631177+00:00",
|
||||
"end": "2026-10-23T04:07:25.246596+00:00",
|
||||
"seconds": 0.615,
|
||||
"exit": 0,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": null,
|
||||
"pass": null,
|
||||
"fail": null,
|
||||
"cancelled": null,
|
||||
"skipped": null
|
||||
},
|
||||
"skips": [],
|
||||
"log": "agents/rocko/work/queue-219/run-4a9c/single-bus-user-principal.test.tap",
|
||||
"tmpdir": "/mnt/storage/scratch/tmp/rg47u_3c2",
|
||||
"derived_spec_counts": {
|
||||
"tests": 23,
|
||||
"pass": 23,
|
||||
"fail": 0,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"failed_test_names": []
|
||||
},
|
||||
{
|
||||
"label": "single-cli-launcher.test",
|
||||
"args": [
|
||||
"node",
|
||||
"--test",
|
||||
"packages/cli/tests/launcher.test.mjs"
|
||||
],
|
||||
"start": "2026-10-23T04:07:25.247904+00:00",
|
||||
"end": "2026-10-23T04:07:51.870129+00:00",
|
||||
"seconds": 26.622,
|
||||
"exit": 1,
|
||||
"timeout": false,
|
||||
"counts": {
|
||||
"tests": null,
|
||||
"pass": null,
|
||||
"fail": null,
|
||||
"cancelled": null,
|
||||
"skipped": null
|
||||
},
|
||||
"skips": [],
|
||||
"log": "agents/rocko/work/queue-219/run-4a9c/single-cli-launcher.test.tap",
|
||||
"tmpdir": "/mnt/storage/scratch/tmp/r8oua_z8g",
|
||||
"derived_spec_counts": {
|
||||
"tests": 34,
|
||||
"pass": 33,
|
||||
"fail": 1,
|
||||
"cancelled": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"failed_test_names": [
|
||||
"row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason",
|
||||
"failing tests:",
|
||||
"row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"head": "c7bf3622dd617ea1e3e68a1c9ce44774e2cfe9da", "manifest": "cd791883449f0d64fca06a9a855f855c93d7db74ced53ccebbcb0bad5a0206a7", "start": "2026-10-23T04:07:22.892671+00:00"}
|
||||
@@ -0,0 +1,2 @@
|
||||
Error response from daemon: network rocko-r219-gate_default not found
|
||||
exit status 1
|
||||
@@ -0,0 +1,4 @@
|
||||
{"label": "single-bus-creative.test", "args": ["node", "--test", "packages/bus/tests/creative.test.mjs"], "start": "2026-10-23T04:07:22.895462+00:00", "end": "2026-10-23T04:07:23.260724+00:00", "seconds": 0.365, "exit": 0, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-bus-creative.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rsrt5bzap"}
|
||||
{"label": "single-bus-threads.test", "args": ["node", "--test", "packages/bus/tests/threads.test.mjs"], "start": "2026-10-23T04:07:23.262757+00:00", "end": "2026-10-23T04:07:24.629883+00:00", "seconds": 1.367, "exit": 0, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-bus-threads.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r5anojtow"}
|
||||
{"label": "single-bus-user-principal.test", "args": ["node", "--test", "packages/bus/tests/user-principal.test.mjs"], "start": "2026-10-23T04:07:24.631177+00:00", "end": "2026-10-23T04:07:25.246596+00:00", "seconds": 0.615, "exit": 0, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-bus-user-principal.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/rg47u_3c2"}
|
||||
{"label": "single-cli-launcher.test", "args": ["node", "--test", "packages/cli/tests/launcher.test.mjs"], "start": "2026-10-23T04:07:25.247904+00:00", "end": "2026-10-23T04:07:51.870129+00:00", "seconds": 26.622, "exit": 1, "timeout": false, "counts": {"tests": null, "pass": null, "fail": null, "cancelled": null, "skipped": null}, "skips": [], "log": "agents/rocko/work/queue-219/run-4a9c/single-cli-launcher.test.tap", "tmpdir": "/mnt/storage/scratch/tmp/r8oua_z8g"}
|
||||
@@ -0,0 +1,14 @@
|
||||
organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
organization: {"event":"reload-needs-restart","business":"demo"}
|
||||
✔ M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises (34.696206ms)
|
||||
✔ a creative instance sends only to the user and hears only from the user (41.081291ms)
|
||||
✔ M10 through the broker: the chief and the tester are refused on bean, the human's limits.set of its models key works (81.11191ms)
|
||||
✔ row 256 T6 the human deletes a creative instance through the broker; the chief and the tester are refused at the verb (57.456912ms)
|
||||
ℹ tests 4
|
||||
ℹ suites 0
|
||||
ℹ pass 4
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 334.223966
|
||||
@@ -0,0 +1,27 @@
|
||||
✔ REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger (36.004676ms)
|
||||
✔ REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker (68.738374ms)
|
||||
✔ REQ-MSG-1: a message to or from the assistant names its thread, or is refused (32.515768ms)
|
||||
✔ REQ-MSG-1: the chief of staff's reply comes back to the thread that sent the work (610.990913ms)
|
||||
✔ REQ-MSG-1: a reply can't carry one thread instance's thread to another thread instance (23.409918ms)
|
||||
✔ REQ-THREAD-1 REQ-USER-2: one thread's messages never reach another thread's holder (28.817937ms)
|
||||
✔ REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it (30.260544ms)
|
||||
✔ REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder (32.327162ms)
|
||||
✔ REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn't hold up the next holder (29.133476ms)
|
||||
✔ REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant (26.476132ms)
|
||||
✔ Q169 REQ-USER-2: assistant.stop ends the human's own assistant thread runs, and only the human's (39.418785ms)
|
||||
✔ REQ-USER-2: the human verbs refuse another owner's thread (28.785396ms)
|
||||
✔ REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one (22.979076ms)
|
||||
✔ REQ-ORG-2: projects and threads are named once, and the repo names a business project (25.496215ms)
|
||||
✔ REQ-THREAD-2: thread.messages returns the thread's history in pages and writes nothing (90.366693ms)
|
||||
✔ project.create refuses a repository name without its matching repo before any write (16.151198ms)
|
||||
✔ project.create keeps unrelated names with and without a repository (11.187274ms)
|
||||
✔ C2-09a: explicit creative instances bind independent threads, default sessions and holders (47.776915ms)
|
||||
✔ C2-09a: explicit missing, non-thread and malformed selectors create no thread or session (20.207317ms)
|
||||
ℹ tests 19
|
||||
ℹ suites 0
|
||||
ℹ pass 19
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 1299.709199
|
||||
@@ -0,0 +1,31 @@
|
||||
✔ R3: bindUser binds a granted user and refuses one with no live grant (14.786261ms)
|
||||
✔ R3: a user named like the business human, the tester or a principal word is refused, whatever the grants say (9.870795ms)
|
||||
✔ R3: each collaborator verb is allowed to a granted user (19.637409ms)
|
||||
✔ R3: every verb off the collaborator list refuses a user access-refused before its own checks (105.182773ms)
|
||||
✔ R3: task, web and host verbs refuse a user access-refused (25.672551ms)
|
||||
✔ R3 leak: project.list shows granted projects only (13.27019ms)
|
||||
✔ R3 leak: thread.create needs the project and the thread instance granted, and an ungranted id reads as one that does not exist (15.965771ms)
|
||||
✔ R3 leak: thread.list shows the user its own threads that are still granted, never others (35.018528ms)
|
||||
✔ R3 leak: names shows granted instances only (15.057536ms)
|
||||
✔ R3: thread.send is from user:<id> and writes no human.input (13.514578ms)
|
||||
✔ R3: a reply-chain message.send into another user thread is refused (16.631267ms)
|
||||
✔ R3: the run reply to a user message reaches that user thread.messages, with no waiting and no notice (18.899841ms)
|
||||
✔ R3: a reply to: user without a matching in_reply_to is refused user-reply-required (28.916725ms)
|
||||
✔ R3: a non-thread run, the human and the tester cannot send to: user (17.965675ms)
|
||||
✔ R3: a user thread run cannot message the owner, while its uncited gated raise reaches only the owner inbox, B9 (19.241443ms)
|
||||
✔ R3: a removed or expired grant refuses the next request access-revoked, and the file is read per request (17.997053ms)
|
||||
✔ R3: a user refusal is recorded as action.refused by user:<id> (14.840437ms)
|
||||
✔ R3: a business with a role keyed user refuses invalid-business (10.081108ms)
|
||||
✔ R3: name.set refuses the name user (10.301262ms)
|
||||
✔ R3 r4: while halted, a granted user's thread.send refuses processing-halted and thread.messages still answers (14.447561ms)
|
||||
✔ R3 r4: the chief's hire refuses an instance named user with the code it gives human and tester (15.786706ms)
|
||||
✔ R3 r4: a non-function access throws before the store is read (0.121803ms)
|
||||
✔ C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project (16.966486ms)
|
||||
ℹ tests 23
|
||||
ℹ suites 0
|
||||
ℹ pass 23
|
||||
ℹ fail 0
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 545.0572
|
||||
@@ -0,0 +1,84 @@
|
||||
✔ C2-04: launcher workspace clones code but never the library (1405.320275ms)
|
||||
✔ bus start --pm: the launcher instance (the chief of staff) runs under its own PID namespace, registered, with a manifest (1322.785792ms)
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r6724498202ca","instance":"chief","turn":"4d5e8541-7110-44bb-8a7a-ed92208a0fd3","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"rd57aefe67267","instance":"coder","turn":"b9afd99d-fad8-48ce-8c03-ca9f9b329cfb","source":"@none","code":"baseline-unknown"}
|
||||
✔ the chief of staff launches a coder through its launch tool; the coder answers; refusals name their code (726.266248ms)
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"0286a736-4a58-46ff-92bb-e974030f5bd7","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"a6496c08-1b00-4b40-8d0e-03bc7e972248","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"cdf4283b-3be0-415c-88d2-ea1e45163617","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"046a20f2-e7eb-477b-b6e6-b7588b603cbd","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"9a07e317-c472-4f17-bb7d-2309cf341af7","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"b0ebda74-f2a9-440a-a173-39dcae896036","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"15238cb8-619f-4a31-b853-fb55d9b86e79","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r7a24261c88d1","instance":"pm","turn":"8a3ef9b0-73bb-4a3f-bf0e-06dcbc0f60c4","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"r6e78d96da940","instance":"chief","turn":"4077ae80-1bfc-4cdd-bfca-27c992282797","source":"@none","code":"baseline-unknown"}
|
||||
✔ a launched pm's request_staff reaches the chief of staff; no other session has the tool and the launch limits still hold (1123.845931ms)
|
||||
✔ each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or fetched and logged (4152.636087ms)
|
||||
✔ a role the broker says holds a merge decision gets land_work, its prompt line, its landing and the git socket; without it, none (617.243337ms)
|
||||
✔ REQ-LAUNCH-1 the tick starts a listed merge decision holder once, with land_work, the Landing line and its landing turn; an unlisted holder starts nothing (412.306281ms)
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"rcf551273e0ad","instance":"coder","turn":"c4220d02-0bf0-4122-aa52-925afe04df92","source":"@none","code":"baseline-unknown"}
|
||||
✔ a holder running since before the resolution is stopped once the broker releases it, land-relaunch, and the next tick starts it: landing turn first, then the message that waited (700.748992ms)
|
||||
✔ a landing run that ended isn't started again for its decision, a spent decision starts nothing, and a new decision starts the holder (638.103132ms)
|
||||
✔ after a restart, the new host's first tick starts a holder whose decision is still held (890.273051ms)
|
||||
✔ a refused landing start is logged once per code with its decision, isn't a start, and the next tick with room starts the holder (632.625787ms)
|
||||
✔ a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left (266.198371ms)
|
||||
✔ a runner that stops at once ends its launch with the runner's reason (345.619813ms)
|
||||
✔ a Claude Code launch with no session credential refuses before it starts (189.183758ms)
|
||||
✔ a pi launch needs no session credential: pi logs in through its own ENV_ALLOW variables (338.515656ms)
|
||||
usage: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
plugins: {"event":"plugin-refused","kind":"harness","name":"down","code":"missing","message":"/mnt/storage/scratch/rocko-r219/packages/plugins/harness/down is missing","kept":[]}
|
||||
✔ row 65 a failed primary preflight launches the fallback on a fixture harness module, recording the route and the reason (364.92973ms)
|
||||
✔ a runner killed from the host ends its launch as killed, not failed (396.074165ms)
|
||||
✔ a missing or failing bwrap refuses sandbox-unavailable before anything starts; there is no unconfined fallback (192.417417ms)
|
||||
✔ row 64 a role skill that isn't enabled refuses skill-not-enabled, and one that isn't a plain directory skill-refused, before anything starts (190.827519ms)
|
||||
✖ row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason (192.548301ms)
|
||||
✔ row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts (189.824532ms)
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"rf7aede34a71b","instance":"chief","turn":"799876e4-fe02-4621-aaac-dfc5cf192082","source":"@none","code":"baseline-unknown"}
|
||||
usage: {"event":"usage-unknown","business":"acme","run":"ref958a5bcbb6","instance":"coder","turn":"ce0a5214-37f5-48d4-ae90-c9f6745e058d","source":"@none","code":"baseline-unknown"}
|
||||
✔ a real Claude Code coder's shell can't read a stand-in secret or anything else its sandbox doesn't hold (1782.388685ms)
|
||||
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (1025.160483ms)
|
||||
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3976.016342ms)
|
||||
✔ bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was (236.101723ms)
|
||||
✔ bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned (195.720542ms)
|
||||
✔ bus start --no-launch starts no thread tick; the same host with launches runs it (1179.765366ms)
|
||||
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (174.297238ms)
|
||||
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (187.137601ms)
|
||||
✔ a launch client that never closes its side doesn't hold the host's close (184.807395ms)
|
||||
✔ a runner that ignores SIGTERM is killed when the host closes (1359.638326ms)
|
||||
✔ kill switch refuses chief and hire before launch work and a clear permits a later request (317.536928ms)
|
||||
✔ kill switch stops an actual managed namespace and records the halt reason without rewriting its bundle (326.035999ms)
|
||||
✔ a launch waiting across halt and resume cannot use its stale admission (196.129785ms)
|
||||
ℹ tests 34
|
||||
ℹ suites 0
|
||||
ℹ pass 33
|
||||
ℹ fail 1
|
||||
ℹ cancelled 0
|
||||
ℹ skipped 0
|
||||
ℹ todo 0
|
||||
ℹ duration_ms 26568.728775
|
||||
|
||||
✖ failing tests:
|
||||
|
||||
test at packages/cli/tests/launcher.test.mjs:766:1
|
||||
✖ row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason (192.548301ms)
|
||||
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
|
||||
+ actual - expected
|
||||
|
||||
[
|
||||
'refused',
|
||||
+ 'session-required',
|
||||
- 'launch-failed',
|
||||
1
|
||||
]
|
||||
|
||||
at TestContext.<anonymous> (file:///mnt/storage/scratch/rocko-r219/packages/cli/tests/launcher.test.mjs:788:10)
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
|
||||
at async Test.run (node:internal/test_runner/test:1409:7)
|
||||
at async Test.processPendingSubtests (node:internal/test_runner/test:974:7) {
|
||||
generatedMessage: true,
|
||||
code: 'ERR_ASSERTION',
|
||||
actual: [ 'refused', 'session-required', 1 ],
|
||||
expected: [ 'refused', 'launch-failed', 1 ],
|
||||
operator: 'deepStrictEqual',
|
||||
diff: 'simple'
|
||||
}
|
||||
+17
-14
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({
|
||||
});
|
||||
// r6 E3: the installer line's comment, then the tag's 32 hex digits.
|
||||
const INSTALLER_COMMENT = 'mosaic-installer-';
|
||||
// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
@@ -610,7 +610,7 @@ export class Broker {
|
||||
);
|
||||
}
|
||||
#threaded(business, role) {
|
||||
return this.#business(business).roles[role]?.definition === THREAD_DEFINITION;
|
||||
return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition);
|
||||
}
|
||||
// Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder.
|
||||
#sessionHolder(session) {
|
||||
@@ -6171,24 +6171,27 @@ export class Broker {
|
||||
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business)
|
||||
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null));
|
||||
}
|
||||
// A thread belongs to the user who made it and to the business's one thread instance.
|
||||
// A thread belongs to the user who made it and to one declared thread instance.
|
||||
// Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's.
|
||||
case 'thread.create': {
|
||||
if (!s.tester && !s.user) this.#human(s);
|
||||
keys(a, ['project', 'name'], ['project', 'name']);
|
||||
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
const instance = a.instance === undefined
|
||||
? (instances.length === 1 ? instances[0] : null)
|
||||
: identifier(a.instance);
|
||||
// FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked
|
||||
// before the project is looked up, so an ungranted id reads the same whether it exists or not.
|
||||
if (s.user) {
|
||||
const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused');
|
||||
if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance))
|
||||
fail('access-refused');
|
||||
}
|
||||
if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
|
||||
fail('project-not-found');
|
||||
// The bus never picks between instances; the business file declares exactly one.
|
||||
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
|
||||
if (!instances.length) fail('thread-instance-missing');
|
||||
if (instances.length > 1) fail('thread-instance-ambiguous');
|
||||
// The human selects an instance; omission is compatible only with one candidate.
|
||||
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
|
||||
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
|
||||
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
|
||||
const tid = randomUUID();
|
||||
this.#store.run(
|
||||
@@ -6197,13 +6200,13 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.project,
|
||||
instances[0],
|
||||
instance,
|
||||
this.#owner(s),
|
||||
a.name,
|
||||
);
|
||||
// Row 264a (#1769, D3): every thread has a default session from the start.
|
||||
this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true);
|
||||
return { id: tid, instance: instances[0] };
|
||||
this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true);
|
||||
return { id: tid, instance };
|
||||
}
|
||||
// Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's,
|
||||
// and the tester's lists only its own (row 175 listed it the business human's).
|
||||
|
||||
@@ -44,15 +44,18 @@ function setup(t) {
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id;
|
||||
const threads = { assistant: thread };
|
||||
for (const role of Object.keys(roles)) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
if (['bean', 'quill'].includes(role))
|
||||
threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id;
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
return { call, count, store };
|
||||
return { call, count, store, threads };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
const { call, count } = setup(t);
|
||||
const { call, count, threads } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user',
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
call('human', 'message.send', { to: who, body: 'hello' });
|
||||
call('human', 'thread.send', { thread: threads[who], body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
||||
|
||||
@@ -1047,7 +1047,10 @@ test('sage-1908: once the agent of an applied grant is a creative role, host.run
|
||||
const f = await setup(t);
|
||||
assert.equal((await f.approve()).end.action, 'grant.applied');
|
||||
const r = await restart(t, f, creativeOps(f.business));
|
||||
const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops' });
|
||||
const human = r.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = r.broker.request(human, { verb: 'project.create', args: { name: 'host grant check' } }).id;
|
||||
const thread = r.broker.request(human, { verb: 'thread.create', args: { project, name: 'ops', instance: 'ops' } }).id;
|
||||
const cap = r.broker.bindLaunch({ business: 'demo', role: 'ops', run: 'ops-run', harness: 'pi', address: 'ops', thread });
|
||||
r.broker.request(cap, { verb: 'role.claim' });
|
||||
const executor = hostExecutor({ credentials: r.credentials, root: join(f.dir, 'host-run') });
|
||||
const before = f.requests().length;
|
||||
|
||||
@@ -32,7 +32,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// (#1692, D2.4) reads unhireableAction, the hire veto's action name.
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
|
||||
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user