Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a131e9567 | ||
|
|
66f7becf21 |
No files matched your search
@@ -5411,10 +5411,3 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
|
||||
|
||||
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
|
||||
|
||||
## 2026-10-23 — Lena, row 296 / #1802 FED R7 start
|
||||
|
||||
Before: R4 and R6 landed in base 45dca69f6; no scratch two-instance acceptance script.
|
||||
Plan: preserve Quackerjack handoff c6ab9bdc9, add isolated script/CLI test harness with real
|
||||
Console proof in transient user services, map assertions and two mutants for Filbert.
|
||||
Untested: all Node/tests and shared gate wait for Sage CLEAR; no production or live state changes.
|
||||
@@ -1,266 +0,0 @@
|
||||
# Row 296: FED R7, scratch two-instance acceptance (#1802)
|
||||
|
||||
## Start
|
||||
|
||||
- Started 2026-10-23 18:53:27Z, on Sage's sage-3583 (mos-1489).
|
||||
- Base: origin/refactor 45dca69f6 (pinned; local copy used, no fetch: fetch runs the credential node script, which
|
||||
is held during Darkwing's re-pin window).
|
||||
- Branch: quackerjack/row296 in worktree quackerjack-fed-r5-row296.
|
||||
- Brief: docs/plans/2026-10-23_mvp-readiness-rows.md at 45dca69f6, section "Brief: FED R7, scratch two-instance
|
||||
acceptance" (line 77).
|
||||
- Constraints from Sage: scratch roots only (mktemp -d under /mnt/storage/scratch/tmp), never ~/.mosaic-dev or
|
||||
the live bus. No push, claim, queue write, node or test run until CLEAR.
|
||||
|
||||
## What the brief asks for (summary)
|
||||
|
||||
- One script test under scripts/ with its harness in packages/*/tests/, as row 190's tests are.
|
||||
- Two configs, two data roots, two Consoles, one fake OP. Consoles start outside agent ancestry in a transient
|
||||
user unit. They never use the live stack's units, ports or sockets. The script removes its units on exit (trap).
|
||||
- Test (design section 7 R7 gate): Jason's test identity, granted collaborator on one hub project, signs in,
|
||||
makes a thread, sends to the granted instance, gets the reply in the thread, is refused on another project and
|
||||
on board reads, is revoked, is refused again, and the run ends. The factory shows nothing of the hub.
|
||||
- Mutants: grant project list widened to `*`; revocation claim end skipped.
|
||||
- Gate: Filbert approves the manifest; the script test and every scripts/test-*.sh green on Sage's gate; no unit,
|
||||
port or socket of the script outlives the run. R8 (live window) stays row 209.
|
||||
|
||||
## Open reads before design
|
||||
|
||||
- R4 and R6 candidates (the brief says it builds on their combined candidate). Confirm what they expose: the
|
||||
hub sign-in, grant and revocation paths, and the Console's board-read refusal.
|
||||
- Row 190's test harness (several instances on one uid): how it starts units and cleans up.
|
||||
- Transient user unit support on this host (systemd-run --user), and how the existing tests detect it.
|
||||
|
||||
## Status
|
||||
|
||||
- 18:53Z: worktree created, brief read. No code yet.
|
||||
|
||||
## Handoff 2026-10-23 19:10:09Z (Mos ruling Q245, mos-1503)
|
||||
|
||||
Row 296 moves to Lena (Codex seat, thread db27faca-85fb-4ca0-9fc7-78d740d3fa3a, branch lena/row296). Quackerjack
|
||||
stops here: no builds, no claim, no queue writes. This design is the handoff; Lena continues it.
|
||||
|
||||
Base: 45dca69f6. R4 landed as 2efd127ce (ancestor of the base, checked with merge-base --is-ancestor). R6 landed as
|
||||
d3059c2c3 (row 294). R7 is claimable once Darkwing's window ends (Sage, sage-3592).
|
||||
|
||||
What I verified by reading (not by running):
|
||||
- packages/cli/src/access.mjs:65 reads `system.federation?.peers ?? []` (the R6 seam).
|
||||
- packages/bus/src/user-cli.mjs exists at base (the user-cli transport).
|
||||
- Row 190's tests (packages/cli/tests/stack-instances.test.mjs, stack-release.test.mjs T56, stack-release-fixture.mjs)
|
||||
drive stand-in units and fixtures. They do not start real transient user units. Not a model for R7's Consoles.
|
||||
- Real transient-unit launch pattern: packages/conversation/src/cohort.mjs (`scopeAvailable()` and `ScopeLauncher`,
|
||||
systemd-run --user --scope -p Delegate=yes --unit=...; MANAGER_ENV = XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS).
|
||||
- Test-only spawn of systemd-run in packages/conversation/tests is the nearest precedent for start/stop cleanup.
|
||||
|
||||
What I did NOT verify (so treat as open): the hub sign-in, grant, revocation and board-read paths in R4/R6 code; the
|
||||
Console startup flags; whether the fake OP can be a local stand-in; the trap-based unit cleanup.
|
||||
|
||||
Design (proposal, not yet checked against code):
|
||||
1. scripts/test-federation-acceptance.sh: the entry point. Creates a mktemp -d scratch root under
|
||||
/mnt/storage/scratch/tmp (never ~/.mosaic-dev, never the live bus). trap removes every unit it started and the
|
||||
scratch root. Refuses to run if a transient unit name collides.
|
||||
2. Two configs (instance A = hub-side granted seat, instance B = collaborator's home), each with its own
|
||||
MOSAIC_CONFIG, data root and federation.self; federation.peers names the other.
|
||||
3. One fake OP: a local stand-in that issues Jason's test identity. Its port and socket are per-run, not the live ones.
|
||||
4. Two Consoles, each in its own transient user unit (systemd-run --user --unit=mosaic-r7-<run>-<A|B>); outside agent
|
||||
ancestry; ports picked per run.
|
||||
5. Test flow (the R7 gate): identity -> grant on one hub project -> sign in -> thread -> send to granted instance ->
|
||||
reply in thread -> refused on another project -> refused on board reads -> revoke -> refused after revoke -> run
|
||||
ends. Factory output must show nothing of the hub.
|
||||
6. Mutants: (a) grant project list widened to `*`, must fail the other-project refusal; (b) revocation claim end
|
||||
skipped, must fail the refusal-after-revoke check.
|
||||
|
||||
Unresolved decisions for Lena (I did not decide these):
|
||||
- Whether the harness lives in packages/cli/tests (matching row 190) or packages/conversation/tests (matching
|
||||
cohort.mjs). Brief says packages/*/tests/; pick the one that owns the Console start path.
|
||||
- Fake OP: in-process HTTP stand-in versus file-backed identity. No preference recorded.
|
||||
- Port selection: fixed range per run vs OS-assigned. Brief forbids the live ports; it does not pick a range.
|
||||
|
||||
R2 and R5 learning:
|
||||
- R2 (row 203, #1701) landed as a4da4e6df. I built it. Relevant to R7: validateFederation in
|
||||
scripts/mosaic-config.mjs rejects unknown keys and bad ids, and test-config.sh has an FED block
|
||||
(expect_fed_refusal). Use federation.self/peers exactly as row 203 validates them.
|
||||
Mutant runner pattern: one textual edit that must occur exactly once, then a named test must fail; restore from git.
|
||||
Name filter gotcha: `node --test --test-name-pattern` skips subtests whose parent does not match; include the
|
||||
parent name (T57 for row 203).
|
||||
- R5: I did not build R5 in this session and have no first-hand notes on it. Ask Sage for R5's landing sha and notes.
|
||||
|
||||
Status: design written; no code, no test runs, no claim, no push of the test files. Next owner: Lena.
|
||||
|
||||
## Lena continuation, 2026-10-23
|
||||
|
||||
Owner: Lena, thread db27faca-85fb-4ca0-9fc7-78d740d3fa3a. Branch lena/row296.
|
||||
Base remains 45dca69f6. Quackerjack's handoff c6ab9bdc9 preserved by cherry-pick ad84c1e45.
|
||||
Issue #1802 exists. Reviewer Filbert. Sage owns queue state, shared test slot and landing.
|
||||
Status: Untested. All Node, tests and credential-helper fetch/push held for Sage CLEAR (sage-3615).
|
||||
The earlier canonical `queue next lena` read finished with `nothing` and a rendered-view warning;
|
||||
no queue state was changed. Lena's owner registration is Sage's action.
|
||||
|
||||
### Design decisions sent to Sage (lena-0003)
|
||||
|
||||
- Harness under packages/cli/tests, beside row 190's instance proof; shell entry under scripts/.
|
||||
- Existing HTTPS fakeOidc helper, its generated scratch CA trusted through NODE_EXTRA_CA_CERTS
|
||||
by each real Console CLI. One fake OP, two client ids; no TLS verification bypass.
|
||||
- Kernel-assigned loopback ports (0), discovered from each Console's startup log. Public origins
|
||||
are distinct *.example.test HTTPS names; HTTP requests model the loopback reverse proxy with
|
||||
exact Host and Origin. This proves the Console boundary, not a deployed HTTPS front end (R8).
|
||||
- Transient user service units, not scopes: a scope leaves ancestry beneath the caller; the real
|
||||
user proof walks ancestry. Each Console runs the existing cli.mjs serve --sso --send verb.
|
||||
- Real startHost broker process and createLauncher. Only the harness adapter is a test stand-in,
|
||||
as in thread-revoke.test.mjs. A real sandboxed run replies through the broker.
|
||||
- Scratch-only setup and access grant/revoke use real human CLI transport from a transient service.
|
||||
No injected principal verifier, direct bindHuman/bindUser, or simulated user transport.
|
||||
- Every service has a unique per-run prefix and is recorded before launch. Shell EXIT/INT/TERM
|
||||
traps stop only these services, wait for collection and verify socket and port closure.
|
||||
- Claim-end assertion reads append-only claim history independently of the launcher's final
|
||||
shutdown so the claim-end mutant cannot be hidden by endLaunch's cleanup.
|
||||
|
||||
### Implementation plan
|
||||
|
||||
Goal: an executable scratch proof of federation section 7's R7 gate, without live state.
|
||||
Architecture: shell lifecycle owner, Node test driver, test-only host fixture, existing real Console CLI.
|
||||
Tech: Bash, Node ESM/node:test, systemd user transient services, existing fake OP and bwrap adapter.
|
||||
Spec: docs/plans/2026-10-23_mvp-readiness-rows.md, FED R7, plus preserved handoff above.
|
||||
|
||||
Constraints: scratch only under /mnt/storage/scratch/tmp; no live units/ports/sockets/configs or
|
||||
credential reads; no production behavior/policy changes; no old test edits without Mos via Sage.
|
||||
|
||||
Review focus: partial startup cleanup; signals; unit name collision; mismatched hub/factory sessions;
|
||||
claim end versus process exit. Each must have explicit assertions or a negative cleanup exercise.
|
||||
|
||||
- [ ] Write acceptance driver assertions: Jason test sub binds as collaborator (owner is another
|
||||
fixture identity), one allowed project/instance, thread send and correlated RESULT, private
|
||||
project creation/read refused, board reads refused, factory has no hub objects, revoke denies
|
||||
session and re-login, claim history records access-revoked and runner ends.
|
||||
- [ ] Write shell lifecycle: scratch HOME/TMPDIR, unique unit ledger, bounded command timeout,
|
||||
traps stop/collect only this run's units, verify recorded sockets and ports no longer listen.
|
||||
- [ ] Write host fixture using startHost(bootConfig), createLauncher with fakeHarnesses and the
|
||||
existing thread adapter. Prepare two named configs through bootstrap before fixture edits;
|
||||
supply only generated test client slots. Setup/grant/revoke through real human transport.
|
||||
- [ ] On Sage CLEAR run only the new script, fix failures in new files and record exact results.
|
||||
- [ ] Prepare explicit once-only mutant patches: R5 projects widened to ['*']; R4 #claimEnd call
|
||||
skipped. On Sage CLEAR prove each gives an assertion failure, restore bytes in finally.
|
||||
- [ ] Publish manifest (paths, hashes, assertion mapping, old-test numstat), request Filbert review,
|
||||
push additive commits to lena/row296 only. Sage runs shared suites and gate and lands the row.
|
||||
|
||||
Pre-existing test changes: none (numstat: empty).
|
||||
|
||||
### Source inspection corrections before first execution
|
||||
|
||||
- The real webui chat is channel-gated. Scratch businesses enable only harness pi and channel webui;
|
||||
the loader fixture re-exports the unchanged webui channel. The adapter replacement is exactly
|
||||
fakeHarnesses' pi.adapter export; no product file gains a fixture branch.
|
||||
- startHost creates a real broker child and a real Unix proof socket; it is hosted in the test
|
||||
driver. The two Console services run the exact same-tree webui CLI, which is required by
|
||||
verifyPrincipal. Human setup runs the exact same-tree human-cli through humanTransport.
|
||||
- The earlier in-process host proposal did not need a separate host fixture: startHost exposes
|
||||
the production launcher API directly, so the only helper file is federation-human.mjs.
|
||||
- Read-only SQLite inspects claim evidence while the broker remains the sole writer. The test
|
||||
also checks the registered runner and engine pids/start times are gone after revocation.
|
||||
- `--kill-after-start` kills the test process after both services have listened; Node teardown
|
||||
hooks cannot run. The shell trap owns service cleanup independently. This is an expected-red
|
||||
exercise; acceptance requires its kill marker, nonzero exit and successful cleanup line.
|
||||
- Main driver environment is a whitelist (PATH, scratch HOME/TMPDIR, systemd manager transport,
|
||||
R7 run ids). Console/human services use env -i. No host preload or credential env is carried.
|
||||
- No Node or test has run. Bash syntax and git diff --check passed during preparation.
|
||||
|
||||
### Binding design correction: sage-3625, 2026-10-23 19:17:35Z
|
||||
|
||||
Sage rejected the transient-unit approach: "no transient systemd user units used to satisfy the
|
||||
human proof"; such a unit makes the ancestry walk miss the development agent, and creates host
|
||||
state under mos-1438. This direction replaces the brief/handoff's unit requirement for this build.
|
||||
No unit was created by the superseded draft, and no Node/test execution occurred.
|
||||
|
||||
Current design, replacing the earlier proposals above:
|
||||
|
||||
- Consoles are ordinary spawned child processes in their own process groups; no systemd commands,
|
||||
units, user manager or manager environment are involved.
|
||||
- Test-only federation-host.mjs follows channel-fixture.mjs's mock.module pattern. It re-exports
|
||||
real readProcess and replaces verifyHuman's readProcess argument with real process fields plus
|
||||
ppid=1. This is explicitly a stand-in for Jason's shell, NOT a real ancestry acceptance proof.
|
||||
Real pid/uid/startTime/nonce/path checks still run; verifyPrincipal still checks the Console's
|
||||
actual direct parent path for user calls. No product source receives this override.
|
||||
- Each fixture host runs real startBroker and createLauncher. Its local host API is the existing
|
||||
thread-revoke.test.mjs pattern, with only the pi adapter replaced via fakeHarnesses.
|
||||
- Setup/grant/revoke are plain children using real CLI wrappers against that fixture proof seam.
|
||||
- Shell records each detached group's leader pid and start time, checks both before signalling,
|
||||
and verifies no live group member, TCP listener or Unix listener remains. Killed-driver mode
|
||||
bypasses every Node teardown hook and still leaves cleanup to the shell trap.
|
||||
- Read-only claim evidence and actual runner/engine exit remain independent assertions.
|
||||
- Gate limitation: this proves the revised scratch integration, not real outside-agent ancestry,
|
||||
transient unit cleanup, TLS proxy deployment or live federation. R8 remains row 209.
|
||||
- R5 landing found in git log: ddfb29af7 (#1700).
|
||||
- Targeted Node/tests remain held. Sage permits the first lena/row296 push in the 19:41Z gap,
|
||||
with MOSAIC_GITEA_CREDENTIAL_FILE unset. 226 and 283 have earlier slots.
|
||||
|
||||
Current files: shell entry; federation-acceptance.test.mjs; fixtures/federation-host.mjs and
|
||||
fixtures/federation-human.mjs; this BUILD; mutant runner; manifest; append-only logs; preserved
|
||||
Quackerjack BUILD at ad84c1e45. No pre-existing test modification (numstat empty).
|
||||
|
||||
### First candidate, 2026-10-23
|
||||
|
||||
Implementation is prepared under sage-3625, not yet executed. Static checks: Bash parser accepts
|
||||
scripts/test-federation-acceptance.sh; Python ast.parse accepts mutants.py; git diff --check clean.
|
||||
No test outcome is claimed. The root node_modules links the existing canonical install so the
|
||||
future targeted run needs no install or network. No webui editor build is involved.
|
||||
|
||||
M1 now requires the user project-list leak assertion (`R7 ungranted project must not appear`),
|
||||
so it proves exposure through the actual Console transport rather than only inspecting grant
|
||||
configuration. The private-project thread-create refusal and exact grant-list check follow it.
|
||||
M2 requires `R7 revoke must end the claim as access-revoked` before any host teardown.
|
||||
|
||||
Sage opened the push gap early in sage-3644 (19:23:04Z). The first candidate is committed as
|
||||
Untested, for push only to lena/row296 with MOSAIC_GITEA_CREDENTIAL_FILE unset. Node, acceptance,
|
||||
negative cleanup exercise and mutants still wait for the test slot. Full suites/gate are Sage's.
|
||||
|
||||
### Filbert static review, filbert-296-first-push-static, 19:28:51Z
|
||||
|
||||
Review pinned f06a1f78c against 45dca69f6. Filbert verified all nine manifest hashes from Git
|
||||
objects, no product/pre-existing test changes, no systemd calls, fixture-only ancestry override,
|
||||
real path/uid/startTime/nonce checks, group ledger identity checks and child-only CA trust.
|
||||
He ran nothing. The verdict remains pending execution and gate evidence.
|
||||
|
||||
- C1 confirmed by inspection: subprocess.run(timeout=280) kills the shell on timeout and can
|
||||
bypass its trap, while the shell's own deadline plus cleanup can exceed 280 seconds. Removed
|
||||
the outer Python timeout (the simpler alternative Filbert explicitly allowed). Python now
|
||||
waits for the shell's bounded run and EXIT/TERM cleanup. Source restoration remains in finally.
|
||||
- N1 retained limitation: a SIGKILL between spawn and the pid/startTime ledger append can leave
|
||||
an unrecorded group. The killed-driver exercise begins after both Consoles are recorded and
|
||||
does not cover that gap. Cleanup claims apply to recorded groups; no universal crash-proof
|
||||
process creation claim is made.
|
||||
- N2 addressed before the mutant run: the named readonly claim-history assertion now runs after
|
||||
revoke and before waiting for the run's end. A skipped #claimEnd therefore cannot first fail
|
||||
the run-end wait. The final latest-op check still runs after engine/runner exit. If M2 instead
|
||||
fails an earlier condition, its runner must say NOT KILLED; diagnose the first failure on the
|
||||
same pinned candidate, retain the intended assertion, and rerun on Sage CLEAR. Never accept a
|
||||
timeout or change the mutant's expected assertion merely to mark it killed.
|
||||
- N3 deferred cosmetic fixture detail: wrap() returns fn(value) without await, so async rejection
|
||||
skips its code mapping. This is not an authorization or cleanup change; the rejection still
|
||||
propagates, and no verdict relies on that fixture mapping.
|
||||
- N4: corrected the exact-grant assertion's indentation in the new test.
|
||||
- N5: prepare() first calls bootstrap --instance, then overwrites that bootstrap-generated
|
||||
config inside the unique disposable scratch HOME with fixture Console/provider settings.
|
||||
This is fixture-only setup, not a product/bootstrap overwrite path or a live-config operation.
|
||||
There remains one selected config per scratch instance, with the same marked data root.
|
||||
|
||||
Still Untested. No Node, acceptance, mutant, negative cleanup or CLI suite run. Static validation
|
||||
only; execution waits for Sage's test window. Follow-up push needs a currently authorized gap.
|
||||
|
||||
### Push receipt and N2 ordering proof, 2026-10-23 19:31Z
|
||||
|
||||
Sage sage-3659 authorized push before 19:38Z. e855f82e1846f094927c3de69aea1a801253736a
|
||||
was pushed to origin/lena/row296 with MOSAIC_GITEA_CREDENTIAL_FILE unset. No Node/tests ran.
|
||||
|
||||
N2, filbert-296-c1-ack: this path is synchronous after a successful reload. accessCommand awaits
|
||||
reload at access.mjs:289; reload awaits the access.reload transport at :297. broker.mjs:4645
|
||||
calls #revokeLost before constructing its reply. #revokeLost (:1025–1034) calls #claimEnd inside
|
||||
Store.transaction, which executes COMMIT before returning (store.mjs:282–302). server.mjs:160–161
|
||||
calls broker.request and then writes the response. Therefore the claim record is committed before
|
||||
the successful CLI call returns; no asynchronous sweep is required for this assertion. A refused
|
||||
reload can be reported by the CLI with exit 0, but then immediate revocation evidence is absent
|
||||
and this test must fail; that is not a race the test should hide. No polling change is needed.
|
||||
|
||||
T3 package comparison (sage-3659): the four planned runs execute only the new dedicated
|
||||
federation-acceptance.test.mjs and its fixture host/human children. They do not execute stack-copy,
|
||||
stack-release or the whole CLI suite. Existing helpers are imported; no old test module is run.
|
||||
Asked Researcher to confirm against row216. Full CLI suite remains a later Sage gate requirement,
|
||||
outside the targeted T3 command list. All test resources are unique scratch state.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Row 296 / #1802 — C1 follow-up candidate (Untested)
|
||||
|
||||
Base: 45dca69f6. Branch: lena/row296. Reviewer: Filbert. Gate owner: Sage.
|
||||
Handoff: c6ab9bdc9 preserved as ad84c1e45, including
|
||||
agents/quackerjack/work/queue-296/BUILD.md. Every changed path is in MANIFEST.sha256,
|
||||
except this manifest and the checksum file itself. No product file or pre-existing test changed.
|
||||
Pre-existing test numstat: empty. BUILD-LOG.md and docs/SESSIONS.md are append-only additions.
|
||||
|
||||
Binding ruling: sage-3625 replaces the brief's transient-unit start with plain child Consoles and
|
||||
explicit test stand-ins. **This candidate does not prove genuine outside-agent ancestry.**
|
||||
It creates no transient systemd unit and makes no systemd CLI call. R8 remains row 209.
|
||||
|
||||
## What is real, and what is a stand-in
|
||||
|
||||
- Two configs created by bootstrap --instance hub/factory, two marked data roots, real Console
|
||||
cli.mjs serve --sso --send, real broker runtime and launcher, real path-bound human-cli and
|
||||
user-cli wrappers, real access loader, CLI writes/reload, grant filters and revocation.
|
||||
- One existing HTTPS fakeOidc server, two issuer/client identities. Its generated CA is trusted
|
||||
only through NODE_EXTRA_CA_CERTS in this test's child environment, never any trust store.
|
||||
- federation-host.mjs replaces verifyHuman's readProcess argument with actual process fields plus
|
||||
ppid=1, exactly the test pattern Sage named. This stands in for Jason's shell on setup,
|
||||
grant/revoke and both Consoles' transports. The actual Console parent/path check remains.
|
||||
There is no product parameter, environment switch or altered verifier default.
|
||||
- fakeHarnesses replaces only the pi harness adapter() with a scratch shell invoking the existing
|
||||
thread-adapter.mjs. The existing webui channel is re-exported unchanged. No network model runs.
|
||||
- Public HTTPS origins are modelled through HTTP loopback requests with matching Host/Origin;
|
||||
reverse-proxy TLS deployment is outside this scratch proof.
|
||||
|
||||
## Acceptance map
|
||||
|
||||
All checks live in `FED R7 scratch two-instance acceptance` in
|
||||
packages/cli/tests/federation-acceptance.test.mjs.
|
||||
|
||||
| Requirement | Assertion/evidence |
|
||||
| --- | --- |
|
||||
| Isolated instances | different config paths/data roots/ports; scratch HOME/TMPDIR; two named markers from bootstrap; port 0, forbidden live ports 7330–7332 checked |
|
||||
| Jason is collaborator, not owner | owner melanie; access user jason-test, home factory, fake sub jason-test-sub; hub /api/config user=true |
|
||||
| One project, one granted instance | actual access grant CLI, projects=[shared], instances=[assistant]; user project list contains shared only |
|
||||
| Thread and reply | create thread on shared, POST message, correlated RESULT with in_reply_to=sent.id, from_role=assistant, to_role=user, body contains question |
|
||||
| Other project refused | private project absent from listing; create on private returns 403 access-refused |
|
||||
| Board reads refused | /api/board, /api/bus/inbox and /api/bus/tasks return 403 owner-only |
|
||||
| Factory sees no hub objects | Jason sub refused at factory; factory owner sees factory-only project and no threads; hub cookie gets 401 at factory |
|
||||
| Revoke takes effect | actual access revoke CLI; existing hub session 401 and re-login 403 |
|
||||
| Claim and process ended | live run before revoke; end log exitCode=0; no registered sessions; engine and runner pid/startTime gone; readonly claim history has revoke/access-revoked before the process-end wait, latest op=revoke afterward |
|
||||
| Normal cleanup | EXIT trap terminates only recorded pid/startTime-checked groups; checks no live group member, no recorded TCP/Unix listener, no other scratch Unix listener, removes scratch directory/socket files |
|
||||
| Killed-driver cleanup | --kill-after-start prints both-Consoles-started marker then SIGKILLs test driver; shell performs the same independent cleanup; expected nonzero run with successful cleanup line |
|
||||
|
||||
## Verification commands (held for Sage CLEAR)
|
||||
|
||||
- scripts/test-federation-acceptance.sh — must exit 0, one acceptance test passes, cleanup line present.
|
||||
- scripts/test-federation-acceptance.sh --kill-after-start — must exit nonzero, print the explicit
|
||||
interruption marker and `R7 cleanup: no child groups, ports or sockets remain; no units created`.
|
||||
- python3 agents/lena/work/queue-296/mutants.py M1 — once-only R5 grant projects -> ['*']; must fail
|
||||
`R7 ungranted project must not appear` and verify cleanup, then restore original source bytes. Python relies on the shell's own run deadline and cleanup; no outer killing timeout.
|
||||
- python3 agents/lena/work/queue-296/mutants.py M2 — once-only R4 claim end omitted; must fail
|
||||
`R7 revoke must end the claim as access-revoked` before shutdown, verify cleanup and restore bytes.
|
||||
- Sage's gate: the new script plus every scripts/test-*.sh; full applicable suites per Sage's slot.
|
||||
|
||||
Status: no Node, acceptance or mutant run. Static Bash syntax, Python parsing and diff checks only.
|
||||
The script never silently skips a missing tool/sandbox/provider: prerequisites fail the acceptance.
|
||||
Ordinary CLI suites skip the dedicated test until invoked by its owning script.
|
||||
|
||||
Static-review follow-up: filbert-296-first-push-static. C1 removes Python's outer timeout so it
|
||||
cannot SIGKILL the shell before EXIT cleanup. N2 moves the named claim-history assertion before
|
||||
the process-end wait. N1's spawn-to-ledger SIGKILL gap is retained and documented in BUILD.md;
|
||||
--kill-after-start exercises the fully recorded groups. N3 remains a cosmetic fixture limitation.
|
||||
N4 indentation corrected. N5 scratch-bootstrap config overwrite is explicitly fixture-only.
|
||||
@@ -1,9 +0,0 @@
|
||||
6e0193ba91cbd20edd3acfbc57ae9af2f016e6d2c3587b434a9085b3a02c493c BUILD-LOG.md
|
||||
72029331a22f33433ad14ff08fe8b24d59f79e784009b12fd9c5b74c9e524f13 docs/SESSIONS.md
|
||||
87239327014c49abbe130c17881bb48e13d96545bf9f3e09b6f4aed46e11453b agents/quackerjack/work/queue-296/BUILD.md
|
||||
6f32d7e5e0c2c1ee086949a68b176a9d1fefb8ad0df562f5ce72b0c01d21e7a0 agents/lena/work/queue-296/BUILD.md
|
||||
bd6e9fd9e3f7aa0ee6702401776ef7bd3e39562059a11b87aaaf2a427904381b agents/lena/work/queue-296/mutants.py
|
||||
89a15e62aa6f951596b764993afeff7e33eeaa3a4f45d31599eba7d27a4000ad packages/cli/tests/federation-acceptance.test.mjs
|
||||
86b76565f5afe9efdd076ef61cc54e40c447d235f77b95818d0d601ce036effa packages/cli/tests/fixtures/federation-host.mjs
|
||||
b12a9bad4be083f2439c3363738b9763fa0061228f84d501a385ade48fea90e4 packages/cli/tests/fixtures/federation-human.mjs
|
||||
4008361f062a2f21b6075c500060196e10a48351fd7d95b25d681f36b4fa801a scripts/test-federation-acceptance.sh
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R7 mutants. Run only on Sage CLEAR in Lena's isolated candidate.
|
||||
|
||||
Every replacement must occur once. Product bytes are restored even on timeout
|
||||
or failure. A killed mutant must contain the named assertion in the script's
|
||||
output; startup errors and timeout alone are not evidence. The shell owns its
|
||||
own timeout and EXIT/TERM cleanup. Do not put a killing Python timeout around it.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
repo = Path(__file__).resolve().parents[4]
|
||||
mutants = {
|
||||
'M1': (
|
||||
'packages/cli/src/access.mjs',
|
||||
'projects: resolveProjects(list(parsed.values["--projects"]), rows),',
|
||||
'projects: ["*"],',
|
||||
'R7 ungranted project must not appear',
|
||||
),
|
||||
'M2': (
|
||||
'packages/bus/src/broker.mjs',
|
||||
"this.#claimEnd({ business: t.business }, h, 'revoke', null, 'access-revoked', by);",
|
||||
"/* R7 mutant: skip ending the revoked claim */",
|
||||
'R7 revoke must end the claim as access-revoked',
|
||||
),
|
||||
}
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in mutants:
|
||||
raise SystemExit('usage: mutants.py M1|M2')
|
||||
name = sys.argv[1]
|
||||
file, before, after, assertion = mutants[name]
|
||||
path = repo / file
|
||||
original = path.read_bytes()
|
||||
text = original.decode()
|
||||
if text.count(before) != 1:
|
||||
raise SystemExit(f'{name}: anchor count {text.count(before)}, expected 1')
|
||||
try:
|
||||
path.write_text(text.replace(before, after))
|
||||
result = subprocess.run(['scripts/test-federation-acceptance.sh'], cwd=repo,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True)
|
||||
print(result.stdout, end='')
|
||||
if result.returncode == 0 or assertion not in result.stdout:
|
||||
raise SystemExit(f'{name}: NOT KILLED by expected assertion')
|
||||
if 'R7 cleanup: no child groups, ports or sockets remain; no units created' not in result.stdout:
|
||||
raise SystemExit(f'{name}: cleanup not verified')
|
||||
print(f'{name}: KILLED by {assertion}')
|
||||
finally:
|
||||
path.write_bytes(original)
|
||||
if path.read_bytes() != original:
|
||||
raise SystemExit(f'{name}: restore failed')
|
||||
@@ -0,0 +1,177 @@
|
||||
# Row 225 (C2-10b) Console chat: replies, citations, write notices, status line
|
||||
|
||||
Owner: Morgana, from sage-3582 (18:53Z 10-23). Base: origin/refactor 45dca69f6. Branch: morgana/row225.
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md, C2-10b. Design: agents/gosalyn/work/case2/DESIGN.md 7.4,
|
||||
5.2 (the reply schema and the check result) and the write notice and `library.revert` (about line 286).
|
||||
|
||||
## Ruling on the dependency gap (sage-3591)
|
||||
|
||||
225 builds against the design contracts. `renderReply` stays pure, and the caller supplies the answers
|
||||
it can't know. The revert route proxies to the bus verb and shows the refusal code until the verb
|
||||
exists. Landing order: 225 lands after 217 (C2-06), not after 288 (C2-02). Sage adds `after 217` to
|
||||
row 225. Before 288 lands, the Revert button gets the bus's refusal and shows its code, as the brief
|
||||
allows.
|
||||
|
||||
## A1. The check result I assume (to 217, Launchpad)
|
||||
|
||||
The broker stores one check result with each agent-to-human message the guard passes. The Console reads
|
||||
it on the message row that `/api/chat/threads/<id>/messages` returns, under the key `check`:
|
||||
|
||||
```json
|
||||
{
|
||||
"check": {
|
||||
"citations": [
|
||||
{ "scope": "canon", "agent": null },
|
||||
{ "scope": "supporting", "agent": "acme/scribe" },
|
||||
{ "scope": "web", "agent": null }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `check` is `null` or absent on a message the guard didn't check: a human's message, a free-text reply,
|
||||
a notice, or a message from before 217.
|
||||
- `check.citations` has one entry per entry of the body's `citations`, in the same order.
|
||||
- `scope` is one of `canon`, `playground`, `supporting` and `web`. A url cite is `web`.
|
||||
- `agent` is the `Mosaic-Agent` trailer value, `<business>/<instance>`, from
|
||||
`git log -1 --format=%(trailers:key=Mosaic-Agent,valueonly) <commit> -- <path>`, or `null` when the
|
||||
file's last commit at `commit` carries none. A url cite has `agent: null`. The Console shows the
|
||||
agent-written label when `agent` is a nonempty string, and names the agent.
|
||||
|
||||
The Console trusts nothing in the body for scope or label. A cite with no matching check entry renders
|
||||
with no badge and no label. It doesn't fall back to a scope it worked out itself.
|
||||
|
||||
If 217 uses other names, I change mine to theirs before either of us pushes (sage-3591 item 1).
|
||||
|
||||
## A2. The revert verb I assume (to 288, Honker)
|
||||
|
||||
- Verb: `library.revert`. A human verb (design M4): from an agent session it refuses `human-required`,
|
||||
like `library.status` (`broker.mjs:5133`).
|
||||
- Args: `{ "project": <business project name>, "commit": <40 lowercase hex> }`. The design writes
|
||||
`library.revert {commit}`. `project` is added to match the other library verbs, and like theirs it is the
|
||||
key of `business.projects` (`mirror.mjs:31`), which is the chat project's `repo` link, not its UUID. Both are required, and no other key
|
||||
is allowed (`invalid-request`), as the other library verbs check with `keys(args, ...)`.
|
||||
- Result: `{ "project", "path", "reverted": <the commit given>, "commit": <the new commit> }`.
|
||||
- Refusal codes the Console shows by name:
|
||||
- `library-revert-not-agent`: `commit` has no `Mosaic-Agent` trailer for this business, or isn't
|
||||
reachable from the served commit.
|
||||
- `stale-base`: a later commit changed that path.
|
||||
- `human-required`, `invalid-request`, `project-not-configured`.
|
||||
- Before 288 lands, the bus refuses the verb (expected `unknown-verb`, `broker.mjs:6578`), and the Console
|
||||
shows that code.
|
||||
- HTTP status in the Console (`packages/webui/src/bus.mjs` `busStatus`): `stale-base` and
|
||||
`library-revert-not-agent` map to 409. Every other code keeps the existing mapping.
|
||||
|
||||
## A3. The status line
|
||||
|
||||
`GET /api/library/status?project=<id>` proxies `library.status {project}` through the existing
|
||||
`libraryStatus` option. `cli.mjs:110` already passes that option, but `startServer` doesn't read it.
|
||||
The status line shows:
|
||||
|
||||
- the served commit (7 characters) and its age;
|
||||
- `library-diverged` when the sync state names it;
|
||||
- the fetch error code;
|
||||
- the reply limits in force, and the keys that fell back;
|
||||
- the count of R-problems, linking to the list.
|
||||
|
||||
It reads the fields `readSyncState` returns at the base (`packages/library/src/mirror.mjs:232`). It shows
|
||||
any field 217 adds (the limits) only when present.
|
||||
|
||||
## A4. "Changed since" (ruled sage-3596)
|
||||
|
||||
Sage ruled that 217 supplies `last`. On a `thread.messages` row that carries `check`, each path cite's
|
||||
entry gets `last`, the file's last commit at the served tip, read when the message is read. When `last`
|
||||
is absent the Console marks nothing changed, and a test pins that.
|
||||
|
||||
One more field is needed, raised in morgana-0200. The cited `commit` is a library commit, not the
|
||||
file's own last commit, so a cite made at a commit that didn't touch the file would differ from `last`
|
||||
while the file is unchanged. I asked 217 to also store `file`, the file's last commit at the cited
|
||||
commit. That's `%H` from the same `git log -1 <commit> -- <path>` call that reads the trailer, so it
|
||||
costs no extra git call. The Console marks a cite changed when both `file` and `last` are 40-hex and
|
||||
differ. The cited commit is reachable from the served one (5.2), so `file` is an ancestor of `last`,
|
||||
and differing means newer. A missing or malformed `file` or `last` marks nothing.
|
||||
|
||||
The entry the Console reads, in full: `{ scope, agent, file?, last? }`.
|
||||
|
||||
## A5. `no-cited-answer` retires
|
||||
|
||||
`renderItems` and its `NOTICES` (`no-cited-answer`, `no-answer`, `turn-failed`) go. 217 sends the
|
||||
runner's fixed texts as plain bodies (design 5.2, "Runner texts"), and renderReply shows those as free
|
||||
text. Design 7.4 names no other notice than `library-write`, so nothing is lost (sage-3596 item 3).
|
||||
|
||||
## Files
|
||||
|
||||
Both deviations below were accepted in sage-3596 and are named in the manifest for Dewey. The brief owns these:
|
||||
|
||||
- `packages/library/src/render.mjs`: `renderReply` replaces `renderItems`.
|
||||
- `packages/library/src/index.mjs`: the export.
|
||||
- `packages/library/README.md`: the section.
|
||||
- `packages/library/tests/render.test.mjs`.
|
||||
|
||||
It also owns `bus.js`, its stylesheet, `serve.mjs` (only the two routes) and new webui tests. Two
|
||||
deviations, both recorded here:
|
||||
|
||||
- The chat view's message list is `packages/webui/src/public/chat.js:85` (`window.MosaicChat`), not
|
||||
`bus.js`. The reply, notice and Revert rendering goes in `chat.js` and `chat.css`. The status line
|
||||
goes in the chat thread view, so it is also in `chat.js`.
|
||||
- `packages/webui/src/console-bus.mjs` and `cli.mjs` each get one `libraryRevert` line, beside the
|
||||
existing `libraryStatus` wiring.
|
||||
|
||||
## Overlap with row 220 (C2-10a, Researcher, 3ef6da90d)
|
||||
|
||||
220 changes one line each in the webui tests discord.test.mjs, fixture.mjs, history-fixture.mjs,
|
||||
relaunch.test.mjs and return-flow.test.mjs. Its other paths are in control-board, conversation, discord
|
||||
and business. 225 doesn't touch those five files or those packages. Its webui tests are new files.
|
||||
|
||||
## Handoff to Fethry (mos-1501, Q245)
|
||||
|
||||
The row moved to Fethry (Codex, fethry/row225). Everything below is **untested**: no suite or single file
|
||||
has run on it, since a run needs Sage's CLEAR. Each changed file passes `node --check`.
|
||||
|
||||
Done in the handoff commit:
|
||||
|
||||
- `render.mjs` `renderReply(body, {check, changed, name})`; index export; README section;
|
||||
`render.test.mjs` rewritten. `renderItems` is gone from every package.
|
||||
- `serve.mjs`: `/render.mjs` static entry (owner and user file maps), `GET /api/library/status`
|
||||
(one `project` query, answer must name that project), `POST /api/library/revert` (save's boundary,
|
||||
strict `{project, commit}`, refusal shows the code, lost or unusable answer is `outcome-unknown`,
|
||||
never retried). Both are nulled for a granted user, and `userRoute` keeps them owner-only. The tester
|
||||
listener refuses `libraryRevert`.
|
||||
- `bus.mjs`: `library-revert-not-agent: 409`. That re-pins `packages/webui/src/bus.mjs` in
|
||||
`packages/bus/tests/channel-proof.test.mjs`, with a comment line, as row 254 did. It's a third deviation,
|
||||
in another package's test.
|
||||
- `console-bus.mjs` and `cli.mjs`: `libraryRevert`, beside `libraryStatus`.
|
||||
- `chat.js`: dynamic `import('/render.mjs')` (CSP is `script-src 'self'`); body text until it loads.
|
||||
Cites with a scope badge, the agent-written label, "changed since" from `file`/`last`, links only for
|
||||
https with `rel="noopener noreferrer"`. A notice gets a Revert button behind `confirm()`. The library
|
||||
status line goes under the thread heading.
|
||||
- `chat.css`: cite, badge, notice and status styles.
|
||||
- New tests: `packages/webui/tests/library-routes.test.mjs` and
|
||||
`packages/webui/tests/chat-replies-browser.test.mjs`. 220's five files are untouched.
|
||||
|
||||
Decisions made in the build:
|
||||
|
||||
- The library name is the chat project's `repo`. A project with no repo reads no status and its
|
||||
Revert does nothing.
|
||||
- These codes hide the status line: `project-not-declared`, `library-mapping-invalid`,
|
||||
`project-not-configured`, `not-configured` and `owner-only`.
|
||||
- Otherwise every code-repo thread, and every granted user, would show "unavailable".
|
||||
- Cost: a broken library mapping also hides the line. `library.status` from the CLI still shows it.
|
||||
- "Age" is the time since `lastFetch` ("fetched 5 min ago"), not the served commit's age, which the
|
||||
status doesn't carry.
|
||||
- The limits fields are assumed to be `replyLimits {requireCitation, citationsPerReply}` and
|
||||
`replyLimitsFallback [keys]`, and are shown only when present. **Not agreed with 217.** Confirm with
|
||||
Beakley and rename.
|
||||
- A notice body's shape is all the renderer checks. An agent in a business without a library could
|
||||
send a body that looks like a notice. The page would show it, but `library.revert` still refuses it.
|
||||
|
||||
Left:
|
||||
|
||||
1. Get CLEAR and run the webui and library suites, `channel-proof.test.mjs`, and the two new files whole.
|
||||
The browser test's selectors and waits have never run.
|
||||
2. Agree the limits field names with 217 (Beakley).
|
||||
3. Granted-user R6 G5 (`granted-user.test.mjs:252`) lists owner verbs by hand. Add `library.revert`
|
||||
once 288 lands.
|
||||
4. BUILD.md: the mutant list and the mos-1275 timeout plan.
|
||||
5. The manifest names the three deviations: chat.js and chat.css in place of bus.js, the console-bus and
|
||||
cli lines, and the channel-proof re-pin.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Row 296: FED R7, scratch two-instance acceptance (#1802)
|
||||
|
||||
## Start
|
||||
|
||||
- Started 2026-10-23 18:53:27Z, on Sage's sage-3583 (mos-1489).
|
||||
- Base: origin/refactor 45dca69f6 (pinned; local copy used, no fetch: fetch runs the credential node script, which
|
||||
is held during Darkwing's re-pin window).
|
||||
- Branch: quackerjack/row296 in worktree quackerjack-fed-r5-row296.
|
||||
- Brief: docs/plans/2026-10-23_mvp-readiness-rows.md at 45dca69f6, section "Brief: FED R7, scratch two-instance
|
||||
acceptance" (line 77).
|
||||
- Constraints from Sage: scratch roots only (mktemp -d under /mnt/storage/scratch/tmp), never ~/.mosaic-dev or
|
||||
the live bus. No push, claim, queue write, node or test run until CLEAR.
|
||||
|
||||
## What the brief asks for (summary)
|
||||
|
||||
- One script test under scripts/ with its harness in packages/*/tests/, as row 190's tests are.
|
||||
- Two configs, two data roots, two Consoles, one fake OP. Consoles start outside agent ancestry in a transient
|
||||
user unit. They never use the live stack's units, ports or sockets. The script removes its units on exit (trap).
|
||||
- Test (design section 7 R7 gate): Jason's test identity, granted collaborator on one hub project, signs in,
|
||||
makes a thread, sends to the granted instance, gets the reply in the thread, is refused on another project and
|
||||
on board reads, is revoked, is refused again, and the run ends. The factory shows nothing of the hub.
|
||||
- Mutants: grant project list widened to `*`; revocation claim end skipped.
|
||||
- Gate: Filbert approves the manifest; the script test and every scripts/test-*.sh green on Sage's gate; no unit,
|
||||
port or socket of the script outlives the run. R8 (live window) stays row 209.
|
||||
|
||||
## Open reads before design
|
||||
|
||||
- R4 and R6 candidates (the brief says it builds on their combined candidate). Confirm what they expose: the
|
||||
hub sign-in, grant and revocation paths, and the Console's board-read refusal.
|
||||
- Row 190's test harness (several instances on one uid): how it starts units and cleans up.
|
||||
- Transient user unit support on this host (systemd-run --user), and how the existing tests detect it.
|
||||
|
||||
## Status
|
||||
|
||||
- 18:53Z: worktree created, brief read. No code yet.
|
||||
|
||||
## Handoff 2026-10-23 19:10:09Z (Mos ruling Q245, mos-1503)
|
||||
|
||||
Row 296 moves to Lena (Codex seat, thread db27faca-85fb-4ca0-9fc7-78d740d3fa3a, branch lena/row296). Quackerjack
|
||||
stops here: no builds, no claim, no queue writes. This design is the handoff; Lena continues it.
|
||||
|
||||
Base: 45dca69f6. R4 landed as 2efd127ce (ancestor of the base, checked with merge-base --is-ancestor). R6 landed as
|
||||
d3059c2c3 (row 294). R7 is claimable once Darkwing's window ends (Sage, sage-3592).
|
||||
|
||||
What I verified by reading (not by running):
|
||||
- packages/cli/src/access.mjs:65 reads `system.federation?.peers ?? []` (the R6 seam).
|
||||
- packages/bus/src/user-cli.mjs exists at base (the user-cli transport).
|
||||
- Row 190's tests (packages/cli/tests/stack-instances.test.mjs, stack-release.test.mjs T56, stack-release-fixture.mjs)
|
||||
drive stand-in units and fixtures. They do not start real transient user units. Not a model for R7's Consoles.
|
||||
- Real transient-unit launch pattern: packages/conversation/src/cohort.mjs (`scopeAvailable()` and `ScopeLauncher`,
|
||||
systemd-run --user --scope -p Delegate=yes --unit=...; MANAGER_ENV = XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS).
|
||||
- Test-only spawn of systemd-run in packages/conversation/tests is the nearest precedent for start/stop cleanup.
|
||||
|
||||
What I did NOT verify (so treat as open): the hub sign-in, grant, revocation and board-read paths in R4/R6 code; the
|
||||
Console startup flags; whether the fake OP can be a local stand-in; the trap-based unit cleanup.
|
||||
|
||||
Design (proposal, not yet checked against code):
|
||||
1. scripts/test-federation-acceptance.sh: the entry point. Creates a mktemp -d scratch root under
|
||||
/mnt/storage/scratch/tmp (never ~/.mosaic-dev, never the live bus). trap removes every unit it started and the
|
||||
scratch root. Refuses to run if a transient unit name collides.
|
||||
2. Two configs (instance A = hub-side granted seat, instance B = collaborator's home), each with its own
|
||||
MOSAIC_CONFIG, data root and federation.self; federation.peers names the other.
|
||||
3. One fake OP: a local stand-in that issues Jason's test identity. Its port and socket are per-run, not the live ones.
|
||||
4. Two Consoles, each in its own transient user unit (systemd-run --user --unit=mosaic-r7-<run>-<A|B>); outside agent
|
||||
ancestry; ports picked per run.
|
||||
5. Test flow (the R7 gate): identity -> grant on one hub project -> sign in -> thread -> send to granted instance ->
|
||||
reply in thread -> refused on another project -> refused on board reads -> revoke -> refused after revoke -> run
|
||||
ends. Factory output must show nothing of the hub.
|
||||
6. Mutants: (a) grant project list widened to `*`, must fail the other-project refusal; (b) revocation claim end
|
||||
skipped, must fail the refusal-after-revoke check.
|
||||
|
||||
Unresolved decisions for Lena (I did not decide these):
|
||||
- Whether the harness lives in packages/cli/tests (matching row 190) or packages/conversation/tests (matching
|
||||
cohort.mjs). Brief says packages/*/tests/; pick the one that owns the Console start path.
|
||||
- Fake OP: in-process HTTP stand-in versus file-backed identity. No preference recorded.
|
||||
- Port selection: fixed range per run vs OS-assigned. Brief forbids the live ports; it does not pick a range.
|
||||
|
||||
R2 and R5 learning:
|
||||
- R2 (row 203, #1701) landed as a4da4e6df. I built it. Relevant to R7: validateFederation in
|
||||
scripts/mosaic-config.mjs rejects unknown keys and bad ids, and test-config.sh has an FED block
|
||||
(expect_fed_refusal). Use federation.self/peers exactly as row 203 validates them.
|
||||
Mutant runner pattern: one textual edit that must occur exactly once, then a named test must fail; restore from git.
|
||||
Name filter gotcha: `node --test --test-name-pattern` skips subtests whose parent does not match; include the
|
||||
parent name (T57 for row 203).
|
||||
- R5: I did not build R5 in this session and have no first-hand notes on it. Ask Sage for R5's landing sha and notes.
|
||||
|
||||
Status: design written; no code, no test runs, no claim, no push of the test files. Next owner: Lena.
|
||||
@@ -1593,4 +1593,3 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
|
||||
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
|
||||
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
|
||||
- 2026-10-23 — lena (Codex, db27faca): row 296 / #1802 FED R7; recovered Quackerjack design on lena/row296, inspecting acceptance paths; Node/tests held pending Sage CLEAR, no live changes.
|
||||
@@ -9,6 +9,8 @@
|
||||
// Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five
|
||||
// MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall,
|
||||
// which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged.
|
||||
// Row 225 (C2-10b) re-pins webui bus.mjs for one STATUS entry, library-revert-not-agent: 409; no proof
|
||||
// or transport change.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -29,7 +31,7 @@ const baselines = {
|
||||
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
|
||||
// organization-tracker-bot-taken 409; no proof or transport change.
|
||||
// Row 301 (#1808, mos-1449): the user transport moved from user-bus.mjs into bus.mjs; humanCall unchanged.
|
||||
"packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
|
||||
"packages/webui/src/bus.mjs": "9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
// FED R7 / #1802. Run through scripts/test-federation-acceptance.sh: it owns
|
||||
// the child process groups and scratch lifetime. Ordinary CLI suites skip it.
|
||||
// sage-3625: federation-host.mjs supplies a labelled readProcess ancestry
|
||||
// stand-in for setup, grant/revoke and both Console transports. This is NOT
|
||||
// a genuine outside-agent human proof. Product transports/grant checks stay real.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { closeSync, cpSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { businessDoc, launchDoc, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { REPO_SKILLS } from '../../harness/tests/helpers.mjs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { SESSION_COOKIE } from '../../webui/src/sso.mjs';
|
||||
import { fakeOidc } from '../../webui/tests/helpers/fake-oidc.mjs';
|
||||
import { socketPath, REPO } from '../src/config.mjs';
|
||||
import { startTimeOf } from '../src/host.mjs';
|
||||
import { launchLogFile, readSessions } from '../../seat/src/session.mjs';
|
||||
|
||||
const root = process.env.MOSAIC_R7_ROOT;
|
||||
const helper = fileURLToPath(new URL('./fixtures/federation-human.mjs', import.meta.url));
|
||||
const adapterModule = fileURLToPath(new URL('./fixtures/thread-adapter.mjs', import.meta.url));
|
||||
const webCli = join(REPO, 'packages/webui/src/cli.mjs');
|
||||
const cli = join(REPO, 'packages/cli/src/cli.mjs');
|
||||
const pause = ms => new Promise(r => setTimeout(r, ms));
|
||||
const json = path => JSON.parse(readFileSync(path, 'utf8'));
|
||||
|
||||
function run(cmd, args, options = {}) {
|
||||
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 30000, ...options });
|
||||
assert.equal(r.status, 0, `${cmd} ${args.join(' ')}: ${r.stderr ?? ''} ${r.error?.message ?? ''}`);
|
||||
return r.stdout;
|
||||
}
|
||||
async function until(read, label, ms = 20000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const value = await read();
|
||||
if (value) return value;
|
||||
await pause(50);
|
||||
}
|
||||
assert.fail(`R7 assertion: ${label}`);
|
||||
}
|
||||
|
||||
function http(instance, path, { cookie, body, method = body === undefined ? 'GET' : 'POST' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const text = body === undefined ? undefined : JSON.stringify(body);
|
||||
const headers = { host: new URL(instance.origin).host };
|
||||
if (cookie) headers.cookie = cookie;
|
||||
if (text !== undefined) Object.assign(headers, { origin: instance.origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
|
||||
const req = request({ host: '127.0.0.1', port: instance.port, path, method, headers, timeout: 5000 }, res => {
|
||||
let data = '';
|
||||
res.on('data', c => data += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data && String(res.headers['content-type']).startsWith('application/json') ? JSON.parse(data) : data }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => req.destroy(new Error('R7 HTTP timeout')));
|
||||
req.end(text);
|
||||
});
|
||||
}
|
||||
let serial = 0;
|
||||
function child(instance, label, command) {
|
||||
const log = join(root, `${instance.name}-${label}.log`);
|
||||
const fd = openSync(log, 'a', 0o600);
|
||||
let proc;
|
||||
try { proc = spawn(command[0], command.slice(1), { detached: true, env: instance.env, stdio: ['ignore', fd, fd] }); }
|
||||
finally { closeSync(fd); }
|
||||
proc.on('error', e => { console.error(`R7 child ${label}: ${e.message}`); });
|
||||
assert.ok(proc.pid, `R7 ${label} spawned`);
|
||||
const start = startTimeOf(proc.pid);
|
||||
assert.ok(start, `R7 ${label} start time recorded`);
|
||||
// EXIT trap owns the detached group even if Node's test driver is killed.
|
||||
writeFileSync(join(root, 'groups'), `${proc.pid} ${start}\n`, { flag: 'a' });
|
||||
proc.unref();
|
||||
return { log, proc };
|
||||
}
|
||||
// Jason-shell ancestry stand-in is in the fixture host; transport/path are real.
|
||||
function human(instance, verb, args = {}) {
|
||||
const out = join(root, `human-${++serial}.json`);
|
||||
run(process.execPath, [helper, socketPath(instance.dataRoot), instance.name, verb, JSON.stringify(args), out], { env: instance.env });
|
||||
return json(out);
|
||||
}
|
||||
// R5/R4 CLI with that same labelled fixture proof, never a live human shell.
|
||||
function access(instance, args) {
|
||||
return run(process.execPath, [cli, 'access', ...args, '--business', instance.name], { env: instance.env });
|
||||
}
|
||||
|
||||
function prepare(name, iss, ca) {
|
||||
const home = process.env.HOME;
|
||||
const env = { HOME: home, PATH: process.env.PATH, TMPDIR: process.env.TMPDIR };
|
||||
run(join(REPO, 'scripts/bootstrap.sh'), ['--instance', name], { env });
|
||||
const config = join(home, '.config', `mosaic-${name}`, 'config.json');
|
||||
const system = json(config), dataRoot = system.dataRoot, configDir = dirname(config);
|
||||
const fixtureRoot = join(root, name);
|
||||
mkdirSync(fixtureRoot, { recursive: true });
|
||||
const doc = businessDoc(fixtureRoot, name);
|
||||
doc.human = 'melanie';
|
||||
doc.plugins = { harness: ['pi'], channel: ['webui'] };
|
||||
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high' };
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
doc.launch = launchDoc('chief', ['coder', 'assistant'], { opus: 1, sonnet: 2 });
|
||||
doc.launch.threadIdleSeconds = 3600;
|
||||
for (const role of Object.values(doc.roles)) role.vars = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high', ...role.vars };
|
||||
writeJson(join(configDir, 'businesses', `${name}.json`), doc);
|
||||
const origin = `https://${name}.example.test`;
|
||||
system.console = { sso: { publicOrigin: origin, providers: [{ id: 'test-op', issuer: iss.issuer, clientId: iss.clientId,
|
||||
clientSecret: { slot: name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'melanie' }] }] } };
|
||||
system.federation = { self: name, peers: [{ id: name === 'hub' ? 'factory' : 'hub', origin: `https://${name === 'hub' ? 'factory' : 'hub'}.example.test`, label: name === 'hub' ? 'Factory' : 'Hub' }] };
|
||||
writeJson(config, system);
|
||||
writeJson(join(configDir, 'access.json'), { accessVersion: 1, revision: 1, users: [], grants: [] });
|
||||
// Generated fake OP material only; never copies a host slot or credential.
|
||||
writeJson(join(configDir, 'secrets/mosaic-stack', name), iss.clientSecret);
|
||||
cpSync(REPO_SKILLS, join(dataRoot, 'skills-enabled'), { recursive: true });
|
||||
const projectRoot = doc.projects.stack.root;
|
||||
const gitEnv = { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
|
||||
const git = (...args) => run('git', ['-c', 'user.name=R7 fixture', '-c', '[email protected]', '-C', projectRoot, ...args], { env: gitEnv });
|
||||
git('init', '-q', '-b', 'main');
|
||||
writeFileSync(join(projectRoot, 'file.txt'), `${name} fixture\n`);
|
||||
git('add', 'file.txt'); git('commit', '-q', '-m', 'scratch fixture');
|
||||
const adapter = join(fixtureRoot, 'adapter.sh');
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${adapterModule}'\n`);
|
||||
return { name, origin, configDir, dataRoot, adapter, env: { ...env, MOSAIC_CONFIG: config, NODE_EXTRA_CA_CERTS: ca }, port: null };
|
||||
}
|
||||
|
||||
async function signIn(instance, op, iss, sub) {
|
||||
const start = await http(instance, '/auth/login');
|
||||
assert.equal(start.status, 303, JSON.stringify(start.body));
|
||||
const location = new URL(start.headers.location);
|
||||
const { code } = op.grant(iss, location.href, { sub });
|
||||
const cookie = start.headers['set-cookie'][0].split(';')[0];
|
||||
const result = await http(instance, `/auth/callback?${new URLSearchParams({ code, state: location.searchParams.get('state') })}`, { cookie });
|
||||
const session = (result.headers['set-cookie'] ?? []).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0];
|
||||
return { ...result, cookie: session };
|
||||
}
|
||||
const logs = i => existsSync(launchLogFile(i.dataRoot, i.name)) ? readFileSync(launchLogFile(i.dataRoot, i.name), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : [];
|
||||
|
||||
function claimHistory(instance, run) {
|
||||
const store = new DatabaseSync(join(instance.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
|
||||
try { return store.prepare('SELECT op, reason FROM thread_claims WHERE holder_run=? ORDER BY seq').all(run); }
|
||||
finally { store.close(); }
|
||||
}
|
||||
|
||||
test('FED R7 scratch two-instance acceptance', { skip: !root && 'run scripts/test-federation-acceptance.sh', timeout: 180000 }, async t => {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const hubIssuer = op.issuer('hub'), factoryIssuer = op.issuer('factory');
|
||||
assert.notEqual(hubIssuer.clientId, factoryIssuer.clientId);
|
||||
const ca = join(root, 'fake-op-ca.pem'); writeFileSync(ca, op.cert);
|
||||
assert.ok(![7330, 7331, 7332].includes(Number(new URL(op.base).port)));
|
||||
writeFileSync(join(root, 'ports'), `${new URL(op.base).port}\n`, { flag: 'a' });
|
||||
const hub = prepare('hub', hubIssuer, ca), factory = prepare('factory', factoryIssuer, ca);
|
||||
assert.notEqual(hub.dataRoot, factory.dataRoot);
|
||||
assert.notEqual(hub.env.MOSAIC_CONFIG, factory.env.MOSAIC_CONFIG);
|
||||
for (const i of [hub, factory]) {
|
||||
const ready = join(root, `${i.name}-host.ready`);
|
||||
const host = child(i, 'host', [process.execPath, '--experimental-test-module-mocks',
|
||||
fileURLToPath(new URL('./fixtures/federation-host.mjs', import.meta.url)), i.name, i.adapter, ready]);
|
||||
await until(() => existsSync(ready), `${i.name} fixture host ready; ${host.log}`);
|
||||
writeFileSync(join(root, 'sockets'), `${socketPath(i.dataRoot)}\n`, { flag: 'a' });
|
||||
}
|
||||
const shared = human(hub, 'project.create', { name: 'hub-shared', repo: 'stack' }).id;
|
||||
const privateProject = human(hub, 'project.create', { name: 'hub-private' }).id;
|
||||
human(factory, 'project.create', { name: 'factory-only' });
|
||||
access(hub, ['user', 'add', 'jason-test', '--home', 'factory', '--subject', 'test-op:jason-test-sub', '--revision', '1']);
|
||||
access(hub, ['grant', '--user', 'jason-test', '--projects', shared, '--instances', 'assistant', '--level', 'collaborator', '--revision', '2']);
|
||||
for (const i of [hub, factory]) {
|
||||
const { log } = child(i, 'console', [process.execPath, webCli, 'serve', '--port', '0', '--business', i.name, '--board', 'http://127.0.0.1:1', '--sso', '--send']);
|
||||
i.port = Number(await until(() => existsSync(log) && readFileSync(log, 'utf8').match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//)?.[1], `${i.name} Console started`));
|
||||
assert.ok(![7330, 7331, 7332].includes(i.port));
|
||||
writeFileSync(join(root, 'ports'), `${i.port}\n`, { flag: 'a' });
|
||||
}
|
||||
assert.notEqual(hub.port, factory.port);
|
||||
if (process.env.MOSAIC_R7_INTERRUPT === '1') {
|
||||
console.log('R7 interruption: both Consoles started; killing test driver');
|
||||
process.kill(process.pid, 'SIGKILL');
|
||||
await pause(60000);
|
||||
}
|
||||
const signed = await signIn(hub, op, hubIssuer, 'jason-test-sub');
|
||||
assert.equal(signed.status, 303, JSON.stringify(signed.body)); assert.ok(signed.cookie);
|
||||
const cookie = signed.cookie;
|
||||
const config = await http(hub, '/api/config', { cookie });
|
||||
assert.equal(config.status, 200); assert.equal(config.body.user, true);
|
||||
const projects = await http(hub, '/api/chat/projects', { cookie });
|
||||
assert.equal(projects.status, 200, JSON.stringify(projects.body));
|
||||
assert.deepEqual(projects.body.rows.map(p => p.id), [shared], 'R7 ungranted project must not appear');
|
||||
const created = await http(hub, '/api/chat/threads', { cookie, body: { project: shared, instance: 'assistant', name: 'Jason scratch thread' } });
|
||||
assert.equal(created.status, 200, JSON.stringify(created.body));
|
||||
const thread = created.body.id;
|
||||
const sent = await http(hub, `/api/chat/threads/${thread}/messages`, { cookie, body: { body: 'R7 scratch question' } });
|
||||
assert.equal(sent.status, 200, JSON.stringify(sent.body));
|
||||
const reply = await until(async () => {
|
||||
const r = await http(hub, `/api/chat/threads/${thread}/messages`, { cookie });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
return r.body.rows.find(m => m.class === 'RESULT' && m.in_reply_to === sent.body.id);
|
||||
}, 'granted instance replies in Jason thread');
|
||||
assert.equal(reply.from_role, 'assistant'); assert.equal(reply.to_role, 'user');
|
||||
assert.match(reply.body, /R7 scratch question/);
|
||||
const refused = await http(hub, '/api/chat/threads', { cookie, body: { project: privateProject, instance: 'assistant', name: 'must refuse' } });
|
||||
assert.equal(refused.status, 403); assert.equal(refused.body.error, 'access-refused');
|
||||
assert.deepEqual(json(join(hub.configDir, 'access.json')).grants[0].projects, [shared], 'R7 grant must name exactly one project');
|
||||
for (const path of ['/api/board', '/api/bus/inbox', '/api/bus/tasks']) {
|
||||
const r = await http(hub, path, { cookie });
|
||||
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'owner-only', path);
|
||||
}
|
||||
const factoryLogin = await signIn(factory, op, factoryIssuer, 'jason-test-sub');
|
||||
assert.equal(factoryLogin.status, 403); assert.equal(factoryLogin.cookie, undefined);
|
||||
const factoryOwner = await signIn(factory, op, factoryIssuer, 'owner-sub');
|
||||
assert.equal(factoryOwner.status, 303); assert.ok(factoryOwner.cookie);
|
||||
const factoryProjects = await http(factory, '/api/chat/projects', { cookie: factoryOwner.cookie });
|
||||
assert.equal(factoryProjects.status, 200);
|
||||
assert.deepEqual(factoryProjects.body.rows.map(p => p.name), ['factory-only']);
|
||||
const factoryThreads = await http(factory, '/api/chat/threads', { cookie: factoryOwner.cookie });
|
||||
assert.equal(factoryThreads.status, 200); assert.deepEqual(factoryThreads.body.rows, []);
|
||||
const hubCookieAtFactory = await http(factory, '/api/chat/projects', { cookie });
|
||||
assert.equal(hubCookieAtFactory.status, 401);
|
||||
const [launch] = logs(hub).filter(e => e.event === 'launch' && e.thread === thread);
|
||||
assert.ok(launch, 'R7 sandboxed run launched');
|
||||
const liveRun = readSessions(hub.dataRoot).find(s => s.run === launch.run);
|
||||
assert.ok(liveRun, 'R7 run remains live before revoke');
|
||||
access(hub, ['revoke', '--user', 'jason-test', '--revision', '3']);
|
||||
const after = await http(hub, '/api/chat/projects', { cookie });
|
||||
assert.equal(after.status, 401); assert.equal(after.body.error, 'sign-in required');
|
||||
assert.equal((await signIn(hub, op, hubIssuer, 'jason-test-sub')).status, 403);
|
||||
// Pin the claim before waiting for process exit: a missing broker revocation
|
||||
// must fail this assertion, even if it also prevents the runner from ending.
|
||||
assert.ok(claimHistory(hub, launch.run).some(r => r.op === 'revoke' && r.reason === 'access-revoked'),
|
||||
'R7 revoke must end the claim as access-revoked');
|
||||
const ended = await until(() => logs(hub).find(e => e.event === 'end' && e.run === launch.run), 'revoked run ended');
|
||||
assert.equal(ended.exitCode, 0, JSON.stringify(ended));
|
||||
assert.deepEqual(readSessions(hub.dataRoot), []);
|
||||
await until(() => startTimeOf(liveRun.pid) !== liveRun.startTime && startTimeOf(liveRun.runnerPid) !== liveRun.runnerStartTime, 'revoked engine and runner processes gone');
|
||||
assert.equal(claimHistory(hub, launch.run).at(-1).op, 'revoke', 'R7 shutdown must not replace missing revocation with release');
|
||||
assert.deepEqual(human(factory, 'thread.list'), []);
|
||||
});
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
// FED R7 fixture, sage-3625: the ancestry walk is a stand-in for Jason's
|
||||
// shell. Keep this override in a test-only process, never in product code.
|
||||
// As channel-fixture.mjs, real pid/uid/argv/startTime/nonce checks remain;
|
||||
// verifyHuman sees ppid=1 instead of walking the development agent's tree.
|
||||
import { mock } from 'node:test';
|
||||
const humanUrl = new URL('../../../bus/src/human.mjs', import.meta.url).href;
|
||||
const real = await import(humanUrl);
|
||||
mock.module(humanUrl, { namedExports: {
|
||||
readProcess: real.readProcess,
|
||||
verifyHuman: (proof, options) => real.verifyHuman(proof, {
|
||||
...options, readProcess: pid => ({ ...real.readProcess(pid), ppid: 1 }),
|
||||
}),
|
||||
} });
|
||||
const { startBroker } = await import('../../../bus/src/runtime.mjs');
|
||||
const { BusError } = await import('../../../bus/src/broker.mjs');
|
||||
const { loadSystem, bootConfig } = await import('../../src/config.mjs');
|
||||
const { createLauncher } = await import('../../src/launcher.mjs');
|
||||
const { fakeHarnesses } = await import('../helpers.mjs');
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
const [business, adapter, ready] = process.argv.slice(2);
|
||||
const system = loadSystem();
|
||||
const runtime = await startBroker(bootConfig({ system, businessId: business }));
|
||||
const hooks = [], cleanups = [];
|
||||
const ops = {
|
||||
identity: m => runtime.identity(m.cap),
|
||||
authorizeLaunch: m => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: m => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: m => runtime.endLaunch(m.record),
|
||||
credentialStatus: m => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: m => runtime.threadsState(m.business),
|
||||
threadIdle: m => runtime.threadIdle(m.record),
|
||||
mayLaunch: m => runtime.mayLaunch(m),
|
||||
landHeld: m => runtime.landHeld(m.record),
|
||||
landHolders: m => runtime.landHolders(m.business),
|
||||
landRelease: m => runtime.landRelease(m.record),
|
||||
sessionClears: m => runtime.sessionClears(m.business),
|
||||
lifecycleRequests: m => runtime.lifecycleRequests(m.business),
|
||||
};
|
||||
const wrap = fn => async value => {
|
||||
try { return fn(value); }
|
||||
catch (e) { throw Object.assign(new Error(e.message), { code: e instanceof BusError ? e.code : 'fixture-host-refused' }); }
|
||||
};
|
||||
const host = {
|
||||
business, path: runtime.path, dataRoot: system.dataRoot,
|
||||
op: wrap(m => { if (!Object.hasOwn(ops, m.op)) throw new BusError('invalid-host-request'); return ops[m.op](m); }),
|
||||
bindLaunch: wrap(record => runtime.bindLaunch(record)),
|
||||
beforeClose: fn => void hooks.push(fn),
|
||||
};
|
||||
const launcher = createLauncher({ host, system,
|
||||
makeLoader: await fakeHarnesses({ after: fn => cleanups.push(fn) }, { pi: adapter }, write => write('channel', 'webui',
|
||||
`export * from ${JSON.stringify(new URL('../../../plugins/channel/webui/index.mjs', import.meta.url).href)};`)),
|
||||
readOnly: [new URL('./thread-adapter.mjs', import.meta.url).pathname],
|
||||
harnessVersion: '0.85.1', sessionDefaults: { pollInterval: 600000 }, threadTickMs: 100,
|
||||
});
|
||||
let closing = false;
|
||||
async function close() {
|
||||
if (closing) return;
|
||||
closing = true;
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
for (const fn of cleanups) await fn();
|
||||
process.exit(0);
|
||||
}
|
||||
process.once('SIGTERM', close);
|
||||
process.once('SIGINT', close);
|
||||
await launcher.listen();
|
||||
writeFileSync(ready, 'ready\n', { mode: 0o600 });
|
||||
@@ -1,7 +0,0 @@
|
||||
// R7 fixture: actual human-cli path with the fixture host's labelled ancestry override.
|
||||
// This stands in for Jason's shell (sage-3625); it does not prove real human ancestry.
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { humanTransport } from '../../src/transport.mjs';
|
||||
const [socket, business, verb, args, out] = process.argv.slice(2);
|
||||
const result = await humanTransport({ socket, business })(verb, JSON.parse(args));
|
||||
writeFileSync(out, JSON.stringify(result), { mode: 0o600 });
|
||||
+17
-10
@@ -104,21 +104,28 @@ in an extension other than `.md`, in any case, embeds an attachment, and it is n
|
||||
a dot and up to 8 letters and digits, at least one a letter. Any other unresolved embed is R4, and so is
|
||||
an unresolved front matter value written as an embed (C2-01b N3, sage-1900).
|
||||
|
||||
## renderItems
|
||||
## renderReply
|
||||
|
||||
`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show.
|
||||
It is pure. Item kinds are `fact`, `finding`, `correction` (with `from` and `to`), `question` and
|
||||
`idea`. Each cite is a path with commit, line range and quote, or a url with a quote.
|
||||
`renderReply(body, { check, changed, name })` turns a stored reply into what both channels show. It
|
||||
replaces C2-01's items renderer and is pure. A body is free text, or the design 5.2 object
|
||||
`{ "text", "citations" }` with library cites `{ path, commit, lines, quote, as? }` and web cites
|
||||
`{ url, quote }`. Any other body, a JSON object with another key or a bad cite included, renders as
|
||||
free text: the whole body, with no citations.
|
||||
|
||||
`check` is the stored check result (C2-06), `{ citations: [{ scope, agent }] }` in body order. A path
|
||||
cite's badge (`canon`, `playground` or `supporting`) and its agent-written label come only from it. A
|
||||
url cite is always `web`, and is a link only when its scheme is `https`. `changed({ path, commit }, i)`
|
||||
says whether the file's last commit is newer than the cited one. `plain` is the rendering as text:
|
||||
|
||||
```
|
||||
1. Correction: Chapter 1 says grey.
|
||||
From: "green eyes"
|
||||
To: "grey eyes"
|
||||
[canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes" (changed since)
|
||||
Mara's eyes are grey in chapter 3.
|
||||
[canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"
|
||||
[supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)
|
||||
[web] https://example.invalid/tides "spring tides"
|
||||
```
|
||||
|
||||
A body of `{ "items": [], "notice": ... }` gives `No cited answer`, `No answer` or `The turn failed`.
|
||||
Any other shape gives the fixed line `This reply could not be shown.` and none of the body's text.
|
||||
The broker's write notice, `{ "notice": "library-write", op, path, commit }`, gives the line
|
||||
`<name> wrote <path> @<commit>` or `<name> deleted <path> @<commit>`.
|
||||
|
||||
## Survey
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } from './scope.mjs';
|
||||
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
|
||||
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
||||
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { renderReply, SCOPES, NOTICE_OPS } from './render.mjs';
|
||||
export { survey, formatSurvey, surveyMain } from './survey.mjs';
|
||||
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
|
||||
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
|
||||
@@ -1,59 +1,72 @@
|
||||
// renderItems(body): how a stored cited reply looks (design 7.4), shared by the Console chat (C2-10b)
|
||||
// and the Discord relay (C2-18). Pure: no git, no clock. The caller passes what it knows about cites.
|
||||
// The guard (C2-06) checks limits before a reply is stored. This only refuses to show a body that
|
||||
// doesn't have the reply shape, and then it shows a fixed line, never the body's text.
|
||||
export const KINDS = Object.freeze({ fact: 'Fact', finding: 'Finding', correction: 'Correction', question: 'Question', idea: 'Idea' });
|
||||
export const NOTICES = Object.freeze({ 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' });
|
||||
export const UNREADABLE = 'This reply could not be shown.';
|
||||
// renderReply(body): how a stored reply looks (design 7.4), shared by the Console chat (C2-10b) and the
|
||||
// Discord relay (C2-18). It replaces C2-01's items renderer. Pure: no git, no clock, no DOM. The caller
|
||||
// passes what only the bus knows: the stored check result (C2-06) and whether a cited file changed.
|
||||
// The guard checks a reply before it is stored. This only decides how a body reads; it trusts nothing
|
||||
// in the body for a scope or the agent-written label.
|
||||
export const SCOPES = Object.freeze(['canon', 'playground', 'supporting', 'web']);
|
||||
export const NOTICE_OPS = Object.freeze({ write: 'wrote', delete: 'deleted' });
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
|
||||
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const line = n => Number.isSafeInteger(n) && n > 0;
|
||||
function cite(value, { scopeOf, changed }) {
|
||||
if (!plain(value) || typeof value.quote !== 'string') return null;
|
||||
if (Object.hasOwn(value, 'url')) {
|
||||
if (typeof value.url !== 'string' || Object.hasOwn(value, 'path')) return null;
|
||||
return { badge: 'web', url: value.url, ref: value.url, quote: value.quote, changed: false };
|
||||
}
|
||||
const { path, commit, lines } = value;
|
||||
if (typeof path !== 'string' || !SHA.test(commit ?? '') || !Array.isArray(lines) || lines.length !== 2
|
||||
|| !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]) return null;
|
||||
const badge = scopeOf?.(path);
|
||||
const ref = `${path}:${lines[0] === lines[1] ? lines[0] : `${lines[0]}-${lines[1]}`} @${commit.slice(0, 7)}`;
|
||||
return { badge: badge === 'canon' || badge === 'playground' ? badge : null, path, commit, lines: [...lines], ref,
|
||||
quote: value.quote, changed: changed?.({ path, commit }) === true };
|
||||
}
|
||||
const only = (value, keys) => Object.keys(value).every(k => keys.includes(k));
|
||||
const quoted = text => JSON.stringify(text);
|
||||
// webby-0041 N5: only an https url is ever a link. Anything else, javascript: and http: included, is text.
|
||||
function https(url) {
|
||||
try { return new URL(url).protocol === 'https:' ? url : null; } catch { return null; }
|
||||
}
|
||||
|
||||
// body: the stored message body (a JSON string or the parsed object).
|
||||
// options.scopeOf(path) -> 'canon' | 'playground' | undefined, for the scope badge.
|
||||
// options.changed({path, commit}) -> true when the file's last commit is newer than the cited one.
|
||||
// Returns { kind: 'items', items, text } | { kind: 'notice', notice, text } | { kind: 'unreadable', text }.
|
||||
export function renderItems(body, options = {}) {
|
||||
const unreadable = { kind: 'unreadable', text: UNREADABLE };
|
||||
// The design 5.2 cite shapes. null when the value isn't one, and then the body is not a cited reply.
|
||||
function cite(value) {
|
||||
if (!plain(value) || typeof value.quote !== 'string') return null;
|
||||
if (Object.hasOwn(value, 'url')) return typeof value.url === 'string' && only(value, ['url', 'quote']) ? { url: value.url, quote: value.quote } : null;
|
||||
const { path, commit, lines } = value;
|
||||
if (!only(value, ['path', 'commit', 'lines', 'quote', 'as']) || typeof path !== 'string' || !SHA.test(commit ?? '')
|
||||
|| !Array.isArray(lines) || lines.length !== 2 || !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]
|
||||
|| (Object.hasOwn(value, 'as') && value.as !== 'canon')) return null;
|
||||
return { path, commit, lines: [...lines], quote: value.quote, canon: value.as === 'canon' };
|
||||
}
|
||||
|
||||
// check: the stored entry for this cite, { scope, agent }, or undefined. A path cite takes its badge only
|
||||
// from it; a url cite is web by its shape. agent is the Mosaic-Agent trailer value, never the body's.
|
||||
function shown(c, check, changed, i) {
|
||||
if (c.url !== undefined) return { badge: 'web', url: c.url, link: https(c.url), ref: c.url, quote: c.quote, agent: null, changed: false };
|
||||
const scope = plain(check) && SCOPES.includes(check.scope) && check.scope !== 'web' ? check.scope : null;
|
||||
const agent = plain(check) && typeof check.agent === 'string' && check.agent !== '' ? check.agent : null;
|
||||
const ref = `${c.path}:${c.lines[0] === c.lines[1] ? c.lines[0] : `${c.lines[0]}-${c.lines[1]}`} @${c.commit.slice(0, 7)}`;
|
||||
return { badge: scope, path: c.path, commit: c.commit, lines: c.lines, ref, quote: c.quote, canon: c.canon, agent,
|
||||
changed: changed?.({ path: c.path, commit: c.commit }, i) === true };
|
||||
}
|
||||
|
||||
// The broker's write notice (design, writes): {"notice": "library-write", op, path, commit}, no agent text.
|
||||
function notice(value, name) {
|
||||
if (!plain(value) || value.notice !== 'library-write' || !only(value, ['notice', 'op', 'path', 'commit'])
|
||||
|| !Object.hasOwn(NOTICE_OPS, value.op) || typeof value.path !== 'string' || !SHA.test(value.commit ?? '')) return null;
|
||||
const who = typeof name === 'string' && name !== '' ? name : 'An agent';
|
||||
return { kind: 'notice', op: value.op, path: value.path, commit: value.commit,
|
||||
text: `${who} ${NOTICE_OPS[value.op]} ${value.path} @${value.commit.slice(0, 7)}` };
|
||||
}
|
||||
|
||||
// body: the stored message body, a string (or an already parsed object).
|
||||
// options.check: the stored check result, { citations: [{ scope, agent }] } in body order, or null.
|
||||
// options.changed({path, commit}, index) -> true when the file's last commit is newer than the cited one.
|
||||
// options.name: the sender's name for a notice line.
|
||||
// Returns { kind: 'text', text, plain } | { kind: 'reply', text, citations, plain } | { kind: 'notice', op, path, commit, text, plain }.
|
||||
// plain is the whole rendering as text, for a channel that shows no markup.
|
||||
export function renderReply(body, options = {}) {
|
||||
const raw = typeof body === 'string' ? body : JSON.stringify(body) ?? String(body);
|
||||
let value = body;
|
||||
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { return unreadable; } }
|
||||
if (!plain(value) || !Array.isArray(value.items)) return unreadable;
|
||||
const keys = Object.keys(value);
|
||||
if (value.items.length === 0) {
|
||||
if (keys.length !== 2 || !Object.hasOwn(NOTICES, value.notice)) return unreadable;
|
||||
return { kind: 'notice', notice: value.notice, text: NOTICES[value.notice] };
|
||||
}
|
||||
if (keys.length !== 1) return unreadable;
|
||||
const items = [];
|
||||
for (const item of value.items) {
|
||||
if (!plain(item) || !Object.hasOwn(KINDS, item.kind) || typeof item.text !== 'string' || !Array.isArray(item.cites)) return unreadable;
|
||||
const correction = item.kind === 'correction';
|
||||
if (correction && (typeof item.from !== 'string' || typeof item.to !== 'string')) return unreadable;
|
||||
const cites = item.cites.map(c => cite(c, options));
|
||||
if (cites.some(c => c === null)) return unreadable;
|
||||
items.push({ kind: item.kind, label: KINDS[item.kind], text: item.text,
|
||||
...(correction ? { from: item.from, to: item.to } : {}), cites });
|
||||
}
|
||||
const text = items.map((item, i) => [
|
||||
`${i + 1}. ${item.label}: ${item.text}`,
|
||||
...(item.kind === 'correction' ? [` From: ${quoted(item.from)}`, ` To: ${quoted(item.to)}`] : []),
|
||||
...item.cites.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.changed ? ' (changed since)' : ''}`),
|
||||
].join('\n')).join('\n');
|
||||
return { kind: 'items', items, text };
|
||||
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { value = undefined; } }
|
||||
const n = notice(value, options.name);
|
||||
if (n) return { ...n, plain: n.text };
|
||||
// Design 5.2: a JSON object must be {text, citations} and nothing else. Any other body is a reply whose
|
||||
// text is the whole body and which carries no citation.
|
||||
const free = { kind: 'text', text: raw, plain: raw };
|
||||
if (!plain(value) || typeof value.text !== 'string' || !Array.isArray(value.citations) || !only(value, ['text', 'citations'])) return free;
|
||||
const cites = value.citations.map(cite);
|
||||
if (cites.some(c => c === null)) return free;
|
||||
const checks = plain(options.check) && Array.isArray(options.check.citations) ? options.check.citations : [];
|
||||
const citations = cites.map((c, i) => shown(c, checks[i], options.changed, i));
|
||||
const lines = citations.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.agent ? ` (agent-written: ${c.agent})` : ''}${c.changed ? ' (changed since)' : ''}`);
|
||||
return { kind: 'reply', text: value.text, citations, plain: [value.text, ...lines].join('\n') };
|
||||
}
|
||||
@@ -1,80 +1,94 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderItems, UNREADABLE } from '../src/index.mjs';
|
||||
import { renderReply } from '../src/index.mjs';
|
||||
|
||||
// Row 225 (C2-10b): renderReply replaces renderItems (design 7.4, the reply schema of 5.2).
|
||||
const A = 'a'.repeat(40), B = 'b'.repeat(40);
|
||||
const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote });
|
||||
const options = {
|
||||
scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined,
|
||||
changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A,
|
||||
};
|
||||
const render = items => renderItems(JSON.stringify({ items }), options);
|
||||
const cite = (path, lines, quote, commit = A, extra = {}) => ({ path, commit, lines, quote, ...extra });
|
||||
const web = (url, quote = 'spring tides') => ({ url, quote });
|
||||
const reply = (text, citations) => JSON.stringify({ text, citations });
|
||||
const CITED = reply("Mara's eyes are grey in chapter 3.", [
|
||||
cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', A, { as: 'canon' }),
|
||||
cite('Book One/Playground/What If.md', [5, 6], 'a storm', B),
|
||||
cite('Notes/Eyes.md', [9, 10], 'green eyes'),
|
||||
web('https://example.invalid/tides'),
|
||||
]);
|
||||
const CHECK = { citations: [{ scope: 'canon', agent: null }, { scope: 'playground', agent: null },
|
||||
{ scope: 'supporting', agent: 'acme/scribe' }, { scope: 'web', agent: null }] };
|
||||
const changed = ({ path, commit }) => path === 'Notes/Eyes.md' && commit === A;
|
||||
|
||||
// One snapshot per item kind: the plain text both channels show.
|
||||
const SNAPSHOTS = {
|
||||
fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }],
|
||||
finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'),
|
||||
cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }],
|
||||
correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes',
|
||||
cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }],
|
||||
question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }],
|
||||
idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B),
|
||||
{ url: 'https://example.invalid/tides', quote: 'spring tides' }] }],
|
||||
};
|
||||
const EXPECTED = {
|
||||
fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"',
|
||||
finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)',
|
||||
correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"',
|
||||
question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"',
|
||||
idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n'
|
||||
+ ' [web] https://example.invalid/tides "spring tides"',
|
||||
};
|
||||
|
||||
for (const kind of Object.keys(SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: ${kind}`, () => {
|
||||
const out = render(SNAPSHOTS[kind]);
|
||||
assert.equal(out.kind, 'items');
|
||||
assert.equal(out.text, EXPECTED[kind]);
|
||||
assert.equal(out.items[0].kind, kind);
|
||||
});
|
||||
}
|
||||
|
||||
test('renderItems numbers several items and takes a parsed body as well as a string', () => {
|
||||
const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question];
|
||||
const out = renderItems({ items }, options);
|
||||
assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`);
|
||||
assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4],
|
||||
ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false });
|
||||
test('free text renders as itself with no citations', () => {
|
||||
for (const body of ['Grey, in chapter 3.', '', '"a JSON string"', '[1, 2]', '42', 'null'])
|
||||
assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body });
|
||||
});
|
||||
|
||||
test('without options a path cite has no badge and is never marked changed', () => {
|
||||
assert.equal(renderItems({ items: SNAPSHOTS.finding }).text,
|
||||
'1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"');
|
||||
test('a cited reply renders its text, then each cite with the badge from the check result', () => {
|
||||
const out = renderReply(CITED, { check: CHECK, changed });
|
||||
assert.equal(out.kind, 'reply');
|
||||
assert.equal(out.text, "Mara's eyes are grey in chapter 3.");
|
||||
assert.deepEqual(out.citations.map(c => c.badge), ['canon', 'playground', 'supporting', 'web']);
|
||||
assert.equal(out.plain, "Mara's eyes are grey in chapter 3.\n"
|
||||
+ ' [canon] Book One/Chapters/Chapter 03.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' [playground] Book One/Playground/What If.md:5-6 @bbbbbbb "a storm"\n'
|
||||
+ ' [supporting] Notes/Eyes.md:9-10 @aaaaaaa "green eyes" (agent-written: acme/scribe) (changed since)\n'
|
||||
+ ' [web] https://example.invalid/tides "spring tides"');
|
||||
assert.deepEqual(out.citations[0], { badge: 'canon', path: 'Book One/Chapters/Chapter 03.md', commit: A, lines: [41, 41],
|
||||
ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', quote: 'her grey eyes', canon: true, agent: null, changed: false });
|
||||
assert.deepEqual(out.citations[3], { badge: 'web', url: 'https://example.invalid/tides', link: 'https://example.invalid/tides',
|
||||
ref: 'https://example.invalid/tides', quote: 'spring tides', agent: null, changed: false });
|
||||
});
|
||||
|
||||
const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' };
|
||||
for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: notice ${notice}`, () => {
|
||||
assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text });
|
||||
});
|
||||
}
|
||||
test('a parsed body renders as its string does', () => {
|
||||
assert.deepEqual(renderReply(JSON.parse(CITED), { check: CHECK, changed }), renderReply(CITED, { check: CHECK, changed }));
|
||||
});
|
||||
|
||||
test('a body without the reply shape shows a fixed line and none of its text', () => {
|
||||
const secret = 'free prose the guard would refuse';
|
||||
const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }),
|
||||
JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }),
|
||||
JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }),
|
||||
null, 42, '[]'];
|
||||
for (const body of bodies) {
|
||||
const out = renderItems(body, options);
|
||||
assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body));
|
||||
assert.ok(!JSON.stringify(out).includes(secret));
|
||||
test('without a check result a path cite has no badge, no label and is never changed', () => {
|
||||
const out = renderReply(CITED);
|
||||
assert.deepEqual(out.citations.map(c => [c.badge, c.agent, c.changed]),
|
||||
[[null, null, false], [null, null, false], [null, null, false], ['web', null, false]]);
|
||||
});
|
||||
|
||||
test('the label and the badge come from the check result only, never from a bad entry', () => {
|
||||
const bad = { citations: [{ scope: 'web', agent: '' }, { scope: 'story', agent: 7 }, null, { scope: 'canon', agent: 'x/y' }] };
|
||||
const out = renderReply(CITED, { check: bad });
|
||||
// A path cite can't be web; a url cite is web by its shape whatever the check says, and never agent-written.
|
||||
assert.deepEqual(out.citations.map(c => [c.badge, c.agent]), [[null, null], [null, null], [null, null], ['web', null]]);
|
||||
});
|
||||
|
||||
test('a web cite is a link only for https', () => {
|
||||
const urls = { 'https://example.invalid/a': 'https://example.invalid/a', 'http://example.invalid/a': null,
|
||||
'javascript:alert(1)': null, 'JAVASCRIPT:alert(1)': null, 'data:text/html,x': null, 'not a url': null, '//example.invalid': null };
|
||||
for (const [url, link] of Object.entries(urls)) {
|
||||
const out = renderReply(reply('t', [web(url)]));
|
||||
assert.equal(out.kind, 'reply', url);
|
||||
assert.equal(out.citations[0].link, link, url);
|
||||
assert.equal(out.citations[0].ref, url);
|
||||
}
|
||||
});
|
||||
|
||||
test('a write notice renders its fixed line', () => {
|
||||
const body = op => JSON.stringify({ notice: 'library-write', op, path: 'Notes/Eyes.md', commit: B });
|
||||
assert.deepEqual(renderReply(body('write'), { name: 'Scribe' }),
|
||||
{ kind: 'notice', op: 'write', path: 'Notes/Eyes.md', commit: B, text: 'Scribe wrote Notes/Eyes.md @bbbbbbb', plain: 'Scribe wrote Notes/Eyes.md @bbbbbbb' });
|
||||
assert.equal(renderReply(body('delete'), { name: 'Scribe' }).text, 'Scribe deleted Notes/Eyes.md @bbbbbbb');
|
||||
assert.equal(renderReply(body('write')).text, 'An agent wrote Notes/Eyes.md @bbbbbbb');
|
||||
});
|
||||
|
||||
test('a body without the reply or notice shape is free text: the whole body, no citations', () => {
|
||||
const bodies = [
|
||||
JSON.stringify({ text: 't' }), JSON.stringify({ citations: [] }), JSON.stringify({ text: 1, citations: [] }),
|
||||
JSON.stringify({ text: 't', citations: [], extra: 1 }), JSON.stringify({ items: [] }),
|
||||
reply('t', [cite('a.md', [2, 1], 'q')]), reply('t', [cite('a.md', [0, 1], 'q')]), reply('t', [cite('a.md', [1], 'q')]),
|
||||
reply('t', [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }]), reply('t', [cite('a.md', [1, 1], 'q', A, { as: 'playground' })]),
|
||||
reply('t', [cite('a.md', [1, 1], 'q', A, { scope: 'canon' })]), reply('t', [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }]),
|
||||
reply('t', [{ url: 'https://x.invalid' }]), reply('t', [{ url: 'https://x.invalid', quote: 'q', agent: 'x/y' }]),
|
||||
JSON.stringify({ notice: 'library-write', op: 'move', path: 'a.md', commit: A }),
|
||||
JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: 'abc' }),
|
||||
JSON.stringify({ notice: 'library-write', op: 'write', path: 'a.md', commit: A, by: 'x' }),
|
||||
];
|
||||
for (const body of bodies) assert.deepEqual(renderReply(body, { check: CHECK }), { kind: 'text', text: body, plain: body }, body);
|
||||
});
|
||||
|
||||
test('an empty citations list is a reply with no cites', () => {
|
||||
assert.deepEqual(renderReply(reply('Grey.', [])), { kind: 'reply', text: 'Grey.', citations: [], plain: 'Grey.' });
|
||||
});
|
||||
@@ -189,5 +189,7 @@ const STATUS = {
|
||||
// leaves the remote unreachable, like no bus to ask.
|
||||
'library-sync-busy': 409, 'library-diverged': 409, 'library-accept-stale': 409, 'library-accept-invalid': 400,
|
||||
'library-credential-missing': 503, 'library-fetch-failed': 503,
|
||||
// Row 225 (C2-10b): a Revert of a commit that isn't an agent's is hers to read, like stale-base.
|
||||
'library-revert-not-agent': 409,
|
||||
};
|
||||
export const busStatus = code => STATUS[code] ?? 502;
|
||||
@@ -68,14 +68,14 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
||||
let processing = null, ownerCall = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
|
||||
// --sso and --send.
|
||||
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
@@ -107,7 +107,7 @@ try {
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
let testerServer = null;
|
||||
if (testerSocket) {
|
||||
|
||||
@@ -30,10 +30,10 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||
projectFiles: args => call('project.files', args),
|
||||
projectFile: args => call('project.file', args),
|
||||
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
|
||||
send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
send: null, librarySync: null, libraryRevert: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
};
|
||||
if (sendOn) {
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.projectSave = args => call('project.file.save', args); o.nameWrite = args => call('name.set', args); o.assistantStop = args => call('assistant.stop', args);
|
||||
o.instancesWrite = args => call('instances.set', args); o.routesWrite = args => call('routes.set', args); o.agentWrite = agentCaller(call, AGENT_VERBS);
|
||||
}
|
||||
|
||||
@@ -23,3 +23,20 @@
|
||||
.chat-session-controls label { display: block; }
|
||||
.chat-session-controls select, .chat-session-controls input { max-width: 100%; }
|
||||
.chat-session-controls form { margin: .6rem 0; }
|
||||
|
||||
/* Row 225 (C2-10b, design 7.4): a reply's cites, a write notice and the library status line. The
|
||||
badge and labels are text, so they read the same without colour. */
|
||||
.chat-cites { list-style: none; margin: .5rem 0 0; padding-inline-start: 1rem; display: grid; gap: .35rem; }
|
||||
.chat-log .chat-cites li { padding: 0; border-bottom: 0; overflow-wrap: anywhere; font-size: .92rem; }
|
||||
.chat-cites code { font-family: var(--mono); font-size: .85rem; }
|
||||
.chat-scope, .chat-label { display: inline-block; padding: 0 .4rem; border: 1px solid var(--line); border-radius: var(--r); font-size: .78rem; }
|
||||
.chat-scope-canon { border-color: var(--success); color: var(--success); }
|
||||
.chat-scope-playground { border-color: var(--accent); color: var(--accent); }
|
||||
.chat-scope-web { border-color: var(--action); color: var(--action); }
|
||||
.chat-agent-written { color: var(--muted); }
|
||||
.chat-changed { border-color: var(--warning); color: var(--warning); }
|
||||
.chat-notice { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; white-space: normal; }
|
||||
.chat-revert-receipt { overflow-wrap: anywhere; }
|
||||
.chat-library { font-size: .9rem; margin-block: .5rem; overflow-wrap: anywhere; }
|
||||
.chat-library p { margin: 0; }
|
||||
.chat-library-problems ul { margin: .4rem 0 0; padding-inline-start: 1.25rem; }
|
||||
@@ -17,11 +17,22 @@
|
||||
if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false });
|
||||
return forms.get(key);
|
||||
};
|
||||
// Row 225 (C2-10b, design 7.4): a reply renders through the library's renderReply, the one renderer the
|
||||
// Discord relay shares. Until it loads, or if it can't, a body shows as its text. Badges, the
|
||||
// agent-written label and "changed since" come from the bus's stored check (C2-06), never the body.
|
||||
const SHA = /^[0-9a-f]{40}$/, CODE = /^[a-z][a-z-]{0,63}$/, NAME = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/;
|
||||
// The library verbs name the business project, which is the chat project's repository link. These
|
||||
// codes mean that repository is no library, or this page may not read it: no status line at all.
|
||||
const NO_LIBRARY = ['project-not-declared', 'library-mapping-invalid', 'project-not-configured', 'not-configured', 'owner-only'];
|
||||
const libraryOf = (c, t) => { const repo = c?.projects.find(p => p.id === t?.project)?.repo; return typeof repo === 'string' && NAME.test(repo) ? repo : null; };
|
||||
let renderReply = null;
|
||||
import('/render.mjs').then(m => { renderReply = m.renderReply; if (current && !out()) paint(); }, () => {});
|
||||
const libraries = new Map(), reverts = new Map();
|
||||
class ChatError extends Error {
|
||||
constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; }
|
||||
}
|
||||
function forget() {
|
||||
generation++; controller?.abort(); states.clear(); forms.clear();
|
||||
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
|
||||
if (current) current.container.replaceChildren();
|
||||
current = null;
|
||||
}
|
||||
@@ -80,11 +91,92 @@
|
||||
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
||||
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
|
||||
}
|
||||
// A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not
|
||||
// its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing.
|
||||
const changedBy = check => (cite, i) => {
|
||||
const e = Array.isArray(check?.citations) ? check.citations[i] : null;
|
||||
return SHA.test(e?.file ?? '') && SHA.test(e?.last ?? '') && e.file !== e.last;
|
||||
};
|
||||
function cites(list) {
|
||||
return `<ul class="chat-cites">${list.map(c => `<li>${c.badge ? `<span class="chat-scope chat-scope-${c.badge}">${txt(c.badge)}</span> ` : ''}${c.link ? `<a href="${esc(c.link)}" rel="noopener noreferrer" target="_blank">${txt(c.ref)}</a>` : `<code>${txt(c.ref)}</code>`} <q>${txt(c.quote)}</q>${c.agent ? ` <span class="chat-label chat-agent-written">agent-written: ${txt(c.agent)}</span>` : ''}${c.changed ? ' <span class="chat-label chat-changed">changed since</span>' : ''}</li>`).join('')}</ul>`;
|
||||
}
|
||||
function messageBody(m) {
|
||||
const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) });
|
||||
if (r?.kind === 'reply') return `<div class="chat-body">${txt(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
|
||||
if (r?.kind === 'notice') {
|
||||
const v = reverts.get(r.commit) ?? {};
|
||||
return `<div class="chat-body chat-notice"><span>${txt(r.text)}</span> <button class="btn" type="button" data-revert="${esc(r.commit)}"${disabled(v.pending)}>Revert</button> <span class="chat-revert-receipt" role="status">${txt(v.receipt ?? '')}</span></div>`;
|
||||
}
|
||||
return `<div class="chat-body">${txt(m.body)}</div>`;
|
||||
}
|
||||
const ago = iso => {
|
||||
const minutes = Math.floor((Date.now() - Date.parse(iso)) / 60000);
|
||||
return !Number.isFinite(minutes) ? 'at an unknown time' : minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : minutes < 2880 ? `${Math.floor(minutes / 60)} h ago` : `${Math.floor(minutes / 1440)} days ago`;
|
||||
};
|
||||
// library.status (C2-04): the served commit and when it was last fetched, a held divergence, the last
|
||||
// fetch error, the reply limits in force (C2-06) and the R-problems, listed one click away.
|
||||
function libraryLine(project) {
|
||||
const l = libraries.get(project);
|
||||
if (!l || l.hidden) return '';
|
||||
if (l.error) return `<div class="chat-library"><p role="status">Library status unavailable (${txt(l.error)}).</p></div>`;
|
||||
const v = l.value, parts = [];
|
||||
parts.push(SHA.test(v.served ?? '') ? `Library at <code>${txt(v.served.slice(0, 7))}</code>` : 'Library not served yet');
|
||||
if (typeof v.lastFetch === 'string') parts.push(`fetched ${txt(ago(v.lastFetch))}`);
|
||||
if (SHA.test(v.diverged?.commit ?? '')) parts.push(`<b>library-diverged</b> at <code>${txt(v.diverged.commit.slice(0, 7))}</code>`);
|
||||
if (CODE.test(v.lastError?.code ?? '')) parts.push(`fetch error <code>${txt(v.lastError.code)}</code>`);
|
||||
const limits = v.replyLimits;
|
||||
if (limits && typeof limits === 'object') {
|
||||
parts.push(`citations ${limits.requireCitation === true ? 'required' : 'optional'}${Number.isSafeInteger(limits.citationsPerReply) ? `, up to ${limits.citationsPerReply} per reply` : ''}`);
|
||||
const fell = Array.isArray(v.replyLimitsFallback) ? v.replyLimitsFallback.filter(k => typeof k === 'string') : [];
|
||||
if (fell.length) parts.push(`limits fell back: ${fell.map(k => `<code>${txt(k)}</code>`).join(', ')}`);
|
||||
}
|
||||
const problems = Array.isArray(v.problems) ? v.problems : [];
|
||||
if (!problems.length) parts.push('no problems');
|
||||
const list = problems.length ? `<details class="chat-library-problems"><summary>${problems.length} problem${problems.length === 1 ? '' : 's'}</summary><ul>${problems.map(p => `<li><code>${txt(p?.code)}</code> ${txt(p?.path)}${Number.isSafeInteger(p?.line) ? ':' + p.line : ''} ${txt(p?.message)}</li>`).join('')}</ul></details>` : '';
|
||||
return `<div class="chat-library"><p role="status">${parts.join(' · ')}</p>${list}</div>`;
|
||||
}
|
||||
async function loadLibrary(project, g, signal) {
|
||||
if (project === null) return;
|
||||
let entry;
|
||||
try {
|
||||
const res = await fetch('/api/library/status?project=' + encodeURIComponent(project), { cache: 'no-store', redirect: 'manual', signal });
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; }
|
||||
const data = await res.json().catch(() => null);
|
||||
entry = res.ok && data && typeof data === 'object' && !Array.isArray(data) ? { value: data } : { error: CODE.test(data?.error ?? '') ? data.error : 'invalid-response' };
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
entry = { error: 'unreachable' };
|
||||
}
|
||||
if (g !== generation || out()) return;
|
||||
libraries.set(project, NO_LIBRARY.includes(entry.error) ? { hidden: true } : entry);
|
||||
paint();
|
||||
}
|
||||
// design (writes): Revert is library.revert {commit}, her act. A refusal shows its code; before
|
||||
// C2-02's verb exists that is the bus's refusal of the verb.
|
||||
async function revert(commit) {
|
||||
const c = current, t = c?.threads.find(t => t.id === c.id), v = reverts.get(commit) ?? {};
|
||||
const project = libraryOf(c, t);
|
||||
if (!c || !c.canSend || out() || v.pending || !SHA.test(commit) || project === null) return;
|
||||
if (!window.confirm(`Revert ${commit.slice(0, 7)}? This makes a new commit in the library that puts the file back as it was before it.`)) return;
|
||||
const unknown = 'Outcome unknown. Read the library status before trying again; nothing was retried.';
|
||||
reverts.set(commit, { pending: true, receipt: 'Reverting…' }); paint();
|
||||
let receipt;
|
||||
try {
|
||||
const res = await fetch('/api/library/revert', { method: 'POST', cache: 'no-store', redirect: 'manual', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ project, commit }) });
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) { signOut(); return; }
|
||||
const data = await res.json().catch(() => null);
|
||||
receipt = res.ok && SHA.test(data?.commit ?? '') ? `Reverted in ${data.commit.slice(0, 7)}.`
|
||||
: data?.sent === false && typeof data.error === 'string' ? `Not reverted (${inert(data.error.slice(0, 80))}).` : unknown;
|
||||
} catch { receipt = unknown; }
|
||||
if (out()) return;
|
||||
reverts.set(commit, { pending: false, receipt });
|
||||
if (current) { paint(); void loadLibrary(project, generation); }
|
||||
}
|
||||
function conversation(t) {
|
||||
const s = state(current.key);
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div><div class="chat-body">${txt(m.body)}</div></li>`).join('');
|
||||
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}</li>`).join('');
|
||||
const receipt = s.receipt;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
}
|
||||
function capture() {
|
||||
if (!current) return null;
|
||||
@@ -233,7 +325,11 @@
|
||||
}
|
||||
} catch (err) { if (err.name === 'AbortError') return; }
|
||||
}
|
||||
if (g === generation) paint(navigated);
|
||||
if (g === generation) {
|
||||
paint(navigated);
|
||||
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
|
||||
if (t) void loadLibrary(libraryOf(current, t), g, signal);
|
||||
}
|
||||
} catch (err) {
|
||||
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
|
||||
current.error = readError(err);
|
||||
@@ -340,6 +436,8 @@
|
||||
if (event.target.id === 'chat-cancel') void cancelClear();
|
||||
if (event.target.id === 'chat-clear') void clearContext();
|
||||
if (event.target.id === 'chat-next') void refresh(true);
|
||||
const revertButton = event.target.closest?.('[data-revert]');
|
||||
if (revertButton) void revert(revertButton.dataset.revert);
|
||||
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
|
||||
@@ -32,6 +32,8 @@ const files = new Map([
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||||
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
|
||||
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
|
||||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
@@ -42,6 +44,7 @@ const files = new Map([
|
||||
const USER_FILES = new Map([
|
||||
['/', ['user.html', 'text/html; charset=utf-8']],
|
||||
...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
['/render.mjs', files.get('/render.mjs')],
|
||||
...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'),
|
||||
]);
|
||||
@@ -596,12 +599,55 @@ async function projectFileRead(res, read, path, search) {
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// Row 225 (C2-10b, design 7.4): the chat's library status line and a write notice's Revert. The status
|
||||
// is a read like the file list. The revert is her write (C2-02's `library.revert`), with the save's
|
||||
// boundary: header user, exact Origin, same-origin Fetch Metadata, JSON, strict keys. Until the verb
|
||||
// exists the bus refuses it, and the page shows that code.
|
||||
const REVERT_KEYS = ['project', 'commit'];
|
||||
async function libraryRevertSend(req, res, { libraryRevert, preview, log }) {
|
||||
let target = null;
|
||||
const line = outcome => log(`${new Date().toISOString()} library-revert user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} commit=${quoted(target?.commit)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a revert needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a revert needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== REVERT_KEYS.length ||!REVERT_KEYS.every(k => typeof value[k] === 'string') || !PATH_SHAPE.test(value.project) || !COMMIT.test(value.commit)) {
|
||||
return refuse(400, 'invalid-request', 'a revert takes a project and a 40-hex commit, nothing else');
|
||||
}
|
||||
target = value;
|
||||
let result;
|
||||
try { result = await libraryRevert({ project: value.project, commit: value.commit }); } catch (err) {
|
||||
const { code, sent } = sendOutcome(err);
|
||||
return sent === false
|
||||
? refuse(busStatus(code), code, `not reverted: the bus refused it (${code}).`)
|
||||
: refuse(503, 'outcome-unknown', 'the revert may have been made; nothing was retried. Read the library status before trying again', 'unknown');
|
||||
}
|
||||
if (!COMMIT.test(result?.commit) || result.reverted !== value.commit || typeof result.path !== 'string') return refuse(503, 'outcome-unknown', 'the bus answered without the new commit; the revert may have been made', 'unknown');
|
||||
line(`commit=${result.commit}`);
|
||||
return json(res, 200, { project: value.project, path: result.path, reverted: result.reverted, commit: result.commit, at: new Date().toISOString() });
|
||||
}
|
||||
async function libraryStatusRead(res, read, search) {
|
||||
const query = new URLSearchParams(search);
|
||||
if ([...query.keys()].some(k => k !== 'project') || query.getAll('project').length !== 1 || !PATH_SHAPE.test(query.get('project')))
|
||||
return json(res, 400, { error: 'invalid-request', message: 'a library status read takes a project' });
|
||||
if (!read) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const result = await read({ project: query.get('project') });
|
||||
if (result === null || typeof result !== 'object' || Array.isArray(result) || result.project !== query.get('project')) throw new BusReadError('invalid-response');
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
|
||||
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, userOptions = null, federation = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
@@ -609,7 +655,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
|
||||
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
|
||||
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
if (tester && [send, settingsWrite, projectSave, libraryRevert, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
|
||||
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
|
||||
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
|
||||
@@ -619,7 +665,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||||
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
||||
const hub = federationView(federation);
|
||||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||||
@@ -629,7 +675,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
const server = createServer(async (req, res) => {
|
||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
@@ -696,7 +742,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
reads = null; upstream = null; files = USER_FILES; granted = true;
|
||||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true;
|
||||
}
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
@@ -748,6 +794,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return routesSend(req, res, { routesWrite, preview: writer, log });
|
||||
}
|
||||
if (path === '/api/library/revert') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if (!send || !libraryRevert || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return libraryRevertSend(req, res, { libraryRevert, preview: signer, log });
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview: signer, log });
|
||||
@@ -816,6 +867,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return consoleRead(res, () => fieldTable(fieldsFile));
|
||||
}
|
||||
if (path === '/api/library/status') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return libraryStatusRead(res, libraryStatus, search);
|
||||
}
|
||||
if (path === '/api/project-files' || path === '/api/project-file') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
return projectFileRead(res, path === '/api/project-file' ? projectFile : projectFiles, path, search);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Row 225 (C2-10b, design 7.4): replies, cites, write notices and the library status line in the
|
||||
// Console chat. Fixture only: the chat and library answers are stubbed; /render.mjs is the real one.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
const P = '10000000-0000-4000-8000-000000000001';
|
||||
const A = '20000000-0000-4000-8000-000000000002';
|
||||
const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40), C3 = 'c'.repeat(40), C4 = 'd'.repeat(40);
|
||||
const HOSTILE = '<img src=x onerror="window.injected=1">';
|
||||
const cite = (path, lines, quote, commit = C1, extra = {}) => ({ path, commit, lines, quote, ...extra });
|
||||
const reply = (text, citations) => JSON.stringify({ text, citations });
|
||||
const row = (id, body, check, from = 'assistant') => ({ id: `5000000${id}-0000-4000-8000-000000000000`, from_role: from, at: '2026-10-23T18:00:00.000Z', body, ...(check === undefined ? {} : { check }) });
|
||||
const ROWS = [
|
||||
row(1, 'Plain text answer.', undefined, 'jason'),
|
||||
row(2, reply("Mara's eyes are grey.", [
|
||||
cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', C1, { as: 'canon' }),
|
||||
cite('Book One/Playground/What If.md', [5, 6], 'a storm'),
|
||||
cite('Notes/Eyes.md', [9, 10], 'green eyes'),
|
||||
{ url: 'https://example.invalid/tides', quote: 'spring tides' },
|
||||
{ url: 'http://example.invalid/plain', quote: 'plain http' },
|
||||
{ url: 'javascript:window.injected=2', quote: 'script' },
|
||||
]), { citations: [
|
||||
{ scope: 'canon', agent: null, file: C1, last: C1 },
|
||||
{ scope: 'playground', agent: null, file: C2, last: C3 },
|
||||
{ scope: 'supporting', agent: 'acme/scribe', file: C2 },
|
||||
{ scope: 'web', agent: null }, { scope: 'web', agent: null }, { scope: 'web', agent: null },
|
||||
] }),
|
||||
row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])),
|
||||
row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'),
|
||||
];
|
||||
const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO',
|
||||
lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' },
|
||||
problems: [{ code: 'R1', path: 'Notes/Eyes.md', message: 'no heading', line: 3 }, { code: 'R2', path: HOSTILE, message: 'bad name' }] };
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window);
|
||||
window.__reverts=[];window.__revertAnswers=[];window.__statusReads=[];window.__confirms=[];
|
||||
window.__status={status:200,body:${JSON.stringify(STATUS)}};
|
||||
window.confirm=text=>{window.__confirms.push(text);return window.__confirm!==false;};
|
||||
window.fetch=async (url,init={})=>{
|
||||
const path=String(url), method=init.method||'GET';
|
||||
const answer=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'content-type':'application/json'}});
|
||||
if(path==='/api/config')return answer({send:!location.search.includes('readonly')});
|
||||
if(path.startsWith('/api/bus/'))return answer({rows:[],unread:0,at:new Date().toISOString()});
|
||||
if(path==='/api/business')return answer({projects:['books']});
|
||||
if(path.startsWith('/api/library/status')){
|
||||
window.__statusReads.push(path);
|
||||
const body={...window.__status.body};
|
||||
if(body.lastFetch==='FIVE-MINUTES-AGO')body.lastFetch=new Date(Date.now()-330000).toISOString();
|
||||
return answer(body,window.__status.status);
|
||||
}
|
||||
if(path==='/api/library/revert'){
|
||||
window.__reverts.push({method,body:JSON.parse(init.body),headers:init.headers,redirect:init.redirect});
|
||||
const a=window.__revertAnswers.shift();if(!a)throw new Error('No revert answer');
|
||||
if(a.reject)throw new TypeError('network');
|
||||
return answer(a.body,a.status||200);
|
||||
}
|
||||
if(!path.startsWith('/api/chat'))return real(url,init);
|
||||
const data=value=>answer({...value,at:new Date().toISOString()});
|
||||
if(path==='/api/chat/instances')return data({rows:[{instance:'assistant',name:'assistant'}]});
|
||||
if(path==='/api/chat/projects')return data({rows:[{id:'${P}',name:'Books',repo:window.__repo===undefined?'books':window.__repo}]});
|
||||
if(path.endsWith('/sessions'))return data({rows:[{id:'${A}',name:'default',default:true}]});
|
||||
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}]});
|
||||
return data({rows:${JSON.stringify(ROWS)},after:null,hasMore:false});
|
||||
};
|
||||
})();`;
|
||||
async function setup(t, { suffix = '', before = '' } = {}) {
|
||||
const f = await fixture(); t.after(() => f.close());
|
||||
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + before });
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`);
|
||||
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
|
||||
const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`);
|
||||
const message = n => `.chat-log > li:nth-child(${n})`;
|
||||
await wait('document.querySelector(".chat-cites")');
|
||||
return { b, wait, text, message };
|
||||
}
|
||||
|
||||
test('chat replies: free text, cites with badges, the agent label, changed since and safe links', async t => {
|
||||
const { b, text, message } = await setup(t);
|
||||
assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.');
|
||||
assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey.");
|
||||
const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({
|
||||
badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent,
|
||||
href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null,
|
||||
quote: li.querySelector('q').textContent, agent: li.querySelector('.chat-agent-written')?.textContent ?? null,
|
||||
changed: !!li.querySelector('.chat-changed') }))`);
|
||||
assert.deepEqual(cites, [
|
||||
{ badge: 'canon', ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', href: null, rel: null, quote: 'her grey eyes', agent: null, changed: false },
|
||||
{ badge: 'playground', ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa', href: null, rel: null, quote: 'a storm', agent: null, changed: true },
|
||||
// `last` absent: nothing is marked changed (sage-3596).
|
||||
{ badge: 'supporting', ref: 'Notes/Eyes.md:9-10 @aaaaaaa', href: null, rel: null, quote: 'green eyes', agent: 'agent-written: acme/scribe', changed: false },
|
||||
{ badge: 'web', ref: 'https://example.invalid/tides', href: 'https://example.invalid/tides', rel: 'noopener noreferrer', quote: 'spring tides', agent: null, changed: false },
|
||||
{ badge: 'web', ref: 'http://example.invalid/plain', href: null, rel: null, quote: 'plain http', agent: null, changed: false },
|
||||
{ badge: 'web', ref: 'javascript:window.injected=2', href: null, rel: null, quote: 'script', agent: null, changed: false },
|
||||
]);
|
||||
// A reply with no check result: no badge, no label, never changed; its quote is inert text.
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(message(3) + ' .chat-scope, ' + message(3) + ' .chat-label')}).length`), 0);
|
||||
assert.equal(await text(`${message(3)} q`), HOSTILE);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-log img") || window.injected'), false);
|
||||
});
|
||||
|
||||
test('chat replies: a write notice reads as its line with a Revert; a refused revert shows its code', async t => {
|
||||
const { b, wait, text, message } = await setup(t);
|
||||
assert.equal(await text(`${message(4)} .chat-notice > span`), 'scribe wrote Notes/Eyes.md @ddddddd');
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).dataset.revert`), C4);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("[data-revert]").length'), 1, 'only the notice has a Revert');
|
||||
// Cancelled at the confirm: nothing is sent.
|
||||
await b.evaluate('window.__confirm=false;document.querySelector("[data-revert]").click();true');
|
||||
assert.equal(await b.evaluate('window.__reverts.length'), 0);
|
||||
assert.match(await b.evaluate('window.__confirms[0]'), /^Revert ddddddd\?/);
|
||||
// Before C2-02 the bus refuses the verb; the page shows that code and doesn't retry.
|
||||
await b.evaluate(`window.__confirm=true;window.__revertAnswers.push({status:502,body:{error:'unknown-verb',message:'not reverted: the bus refused it (unknown-verb).',sent:false}});document.querySelector("[data-revert]").click();true`);
|
||||
await wait('document.querySelector(".chat-revert-receipt").textContent.startsWith("Not")');
|
||||
assert.equal(await text('.chat-revert-receipt'), 'Not reverted (unknown-verb).');
|
||||
assert.deepEqual(await b.evaluate('window.__reverts'), [{ method: 'POST', body: { project: 'books', commit: C4 }, headers: { 'content-type': 'application/json' }, redirect: 'manual' }]);
|
||||
for (const [answer, receipt] of [
|
||||
[{ status: 409, body: { error: 'library-revert-not-agent', sent: false } }, 'Not reverted (library-revert-not-agent).'],
|
||||
[{ status: 409, body: { error: 'stale-base', sent: false } }, 'Not reverted (stale-base).'],
|
||||
[{ status: 503, body: { error: 'outcome-unknown', sent: 'unknown' } }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'],
|
||||
[{ reject: true }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'],
|
||||
[{ body: { project: 'books', path: 'Notes/Eyes.md', reverted: C4, commit: C1 } }, 'Reverted in aaaaaaa.'],
|
||||
]) {
|
||||
const n = await b.evaluate('window.__reverts.length');
|
||||
await b.evaluate(`window.__revertAnswers.push(${JSON.stringify(answer)});document.querySelector("[data-revert]").click();true`);
|
||||
await wait(`window.__reverts.length===${n + 1} && !document.querySelector("[data-revert]").disabled`);
|
||||
assert.equal(await text('.chat-revert-receipt'), receipt);
|
||||
}
|
||||
assert.ok(await b.evaluate('window.__statusReads.length') >= 2, 'the status line is read again after a revert');
|
||||
});
|
||||
|
||||
test('chat replies: a read-only Console shows the notice with Revert disabled', async t => {
|
||||
const { b, message } = await setup(t, { suffix: '?readonly' });
|
||||
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).disabled`), true);
|
||||
await b.evaluate('document.querySelector("[data-revert]").click();true');
|
||||
assert.equal(await b.evaluate('window.__reverts.length + window.__confirms.length'), 0);
|
||||
});
|
||||
|
||||
test('chat status line: served commit and age, divergence, fetch error and the problems one click away', async t => {
|
||||
const { b, wait, text } = await setup(t);
|
||||
await wait('document.querySelector(".chat-library")');
|
||||
assert.equal(await b.evaluate('window.__statusReads[0]'), '/api/library/status?project=books');
|
||||
assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · library-diverged at bbbbbbb · fetch error library-fetch-failed');
|
||||
assert.equal(await text('.chat-library-problems summary'), '2 problems');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".chat-library-problems li")].map(li => li.textContent)'),
|
||||
['R1 Notes/Eyes.md:3 no heading', `R2 ${HOSTILE} bad name`]);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-library img")'), false);
|
||||
});
|
||||
|
||||
test('chat status line: limits when the status carries them, no problems, and refusals', async t => {
|
||||
const limits = { ...STATUS, lastError: null, diverged: null, problems: [], replyLimits: { requireCitation: true, citationsPerReply: 4 }, replyLimitsFallback: ['citationsPerReply'] };
|
||||
const { b, wait, text } = await setup(t, { before: `window.__status={status:200,body:${JSON.stringify(limits)}};` });
|
||||
await wait('document.querySelector(".chat-library")');
|
||||
assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems');
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-library-problems")'), false);
|
||||
for (const [status, error, shown] of [[502, 'library-state-invalid', 'Library status unavailable (library-state-invalid).'], [502, 'Not A Code', 'Library status unavailable (invalid-response).'],
|
||||
[403, 'owner-only', null], [502, 'project-not-declared', null], [502, 'library-mapping-invalid', null], [503, 'project-not-configured', null], [503, 'not-configured', null]]) {
|
||||
await wait('!document.querySelector("#refresh").disabled');
|
||||
await b.evaluate(`window.__status={status:${status},body:{error:${JSON.stringify(error)}}};document.querySelector("#refresh").click();true`);
|
||||
await wait(`window.__statusReads.length && document.querySelector(".chat-conversation") && ${shown === null ? '!document.querySelector(".chat-library")' : `document.querySelector(".chat-library")?.textContent===${JSON.stringify(shown)}`}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat status line: a project with no repository link reads no library status', async t => {
|
||||
const { b } = await setup(t, { before: 'window.__repo=null;' });
|
||||
assert.equal(await b.evaluate('window.__statusReads.length'), 0);
|
||||
assert.equal(await b.evaluate('!!document.querySelector(".chat-library")'), false);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
// Row 225 (C2-10b, design 7.4): the chat's library status read, a write notice's Revert and the
|
||||
// shared reply renderer served to the page. The revert is C2-02's `library.revert`; until that verb
|
||||
// exists the bus refuses it and the route reports the code.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { startServer, userRoute } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
||||
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40);
|
||||
const payload = { project: 'books', commit: C1 };
|
||||
const STATUS = { project: 'books', version: 1, branch: 'main', served: C2, lastFetch: '2026-10-23T18:00:00.000Z', lastError: null, diverged: null, problems: [] };
|
||||
async function setup(t, { enabled = true, revert, status, lines = [] } = {}) {
|
||||
const calls = { revert: [], status: [] };
|
||||
const server = await startServer({
|
||||
port: 0, preview, log: l => lines.push(l),
|
||||
send: enabled ? async () => ({}) : null,
|
||||
libraryStatus: status === null ? null : async args => { calls.status.push(args); return status ? status(args) : STATUS; },
|
||||
libraryRevert: enabled ? async args => { calls.revert.push(args); return revert ? revert(args) : { project: args.project, path: 'Notes/Eyes.md', reverted: args.commit, commit: C2 }; } : null,
|
||||
});
|
||||
t.after(() => new Promise(r => server.close(r)));
|
||||
return { calls, lines, port: server.address().port };
|
||||
}
|
||||
function call(s, { method = 'POST', path = '/api/library/revert', hs = headers, data = payload } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
||||
let text = '';
|
||||
r.on('data', c => text += c);
|
||||
r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: text.startsWith('{') ? JSON.parse(text) : null }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
}
|
||||
const get = (s, path, hs = headers) => call(s, { method: 'GET', path, hs, data: null });
|
||||
const refusal = code => { throw new BusReadError(code); };
|
||||
|
||||
test('a signed-in same-origin revert reaches exactly one library.revert and returns the new commit', async t => {
|
||||
const s = await setup(t), r = await call(s);
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.deepEqual(s.calls.revert, [payload]);
|
||||
assert.deepEqual({ ...r.body, at: undefined }, { project: 'books', path: 'Notes/Eyes.md', reverted: C1, commit: C2, at: undefined });
|
||||
assert.ok(s.lines.some(l => l.includes('library-revert') && l.includes(`commit=${C2}`) && l.includes('project="books"')));
|
||||
});
|
||||
|
||||
test('a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
||||
const off = await setup(t, { enabled: false });
|
||||
assert.deepEqual((await call(off)).body, { error: 'read-only', sent: false });
|
||||
const s = await setup(t);
|
||||
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403],
|
||||
['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['x-user', ['jason', 'jason'], 401]]) {
|
||||
const hs = { ...headers };
|
||||
if (value === undefined) delete hs[key]; else hs[key] = value;
|
||||
const r = await call(s, { hs });
|
||||
assert.equal(r.status, status, key);
|
||||
assert.notEqual(r.body?.sent, true);
|
||||
}
|
||||
assert.equal((await call(s, { method: 'GET', data: null })).status, 405);
|
||||
assert.deepEqual(s.calls.revert, []);
|
||||
});
|
||||
|
||||
test('a revert takes a project and a 40-hex commit, nothing else, before the bus is asked', async t => {
|
||||
const s = await setup(t);
|
||||
for (const data of ['not json', 'null', '[]', '5', { project: 'books' }, { commit: C1 }, { ...payload, path: 'a.md' },
|
||||
{ project: 'books', commit: 'abc' }, { project: 'books', commit: C1.toUpperCase() }, { project: '../x', commit: C1 }, { project: 7, commit: C1 }]) {
|
||||
const r = await call(s, { data });
|
||||
assert.equal(r.status, 400, JSON.stringify(data));
|
||||
assert.equal(r.body.error, 'invalid-request');
|
||||
assert.equal(r.body.sent, false);
|
||||
}
|
||||
assert.deepEqual(s.calls.revert, []);
|
||||
});
|
||||
|
||||
test('a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal', async t => {
|
||||
const codes = { 'unknown-verb': 502, 'library-revert-not-agent': 409, 'stale-base': 409, 'human-required': 403, 'project-not-configured': 503, 'invalid-request': 400 };
|
||||
for (const [code, status] of Object.entries(codes)) {
|
||||
const s = await setup(t, { revert: () => refusal(code) }), r = await call(s);
|
||||
assert.equal(r.status, status, code);
|
||||
assert.equal(r.body.error, code);
|
||||
assert.equal(r.body.sent, false);
|
||||
assert.match(r.body.message, new RegExp(`not reverted: the bus refused it \\(${code}\\)`));
|
||||
assert.equal(s.calls.revert.length, 1, 'never retried');
|
||||
}
|
||||
});
|
||||
|
||||
test('a revert whose answer is lost or unusable is reported unknown and is never retried', async t => {
|
||||
const answers = [() => refusal('outcome-unknown'), () => { throw new Error('socket closed'); }, () => ({ project: 'books', path: 'a.md', reverted: C1 }),
|
||||
() => ({ project: 'books', path: 'a.md', reverted: C2, commit: C2 }), () => ({ project: 'books', reverted: C1, commit: C2 }), () => null];
|
||||
for (const revert of answers) {
|
||||
const s = await setup(t, { revert }), r = await call(s);
|
||||
assert.equal(r.status, 503, revert.toString());
|
||||
assert.equal(r.body.error, 'outcome-unknown');
|
||||
assert.equal(r.body.sent, 'unknown');
|
||||
assert.equal(s.calls.revert.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('the status read takes one project query and answers the bus result for that project', async t => {
|
||||
const s = await setup(t), r = await get(s, '/api/library/status?project=books');
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.deepEqual(s.calls.status, [{ project: 'books' }]);
|
||||
assert.deepEqual({ ...r.body, at: undefined }, { ...STATUS, at: undefined });
|
||||
for (const path of ['/api/library/status', '/api/library/status?project=books&x=1', '/api/library/status?project=books&project=other', '/api/library/status?project=../x'])
|
||||
assert.equal((await get(s, path)).status, 400, path);
|
||||
assert.equal((await call(s, { path: '/api/library/status?project=books' })).status, 405);
|
||||
assert.equal(s.calls.status.length, 1);
|
||||
});
|
||||
|
||||
test('a status answer for another project or of the wrong shape is refused; a refusal keeps its status', async t => {
|
||||
for (const answer of [{ ...STATUS, project: 'other' }, null, [], 'books']) {
|
||||
const s = await setup(t, { status: () => answer }), r = await get(s, '/api/library/status?project=books');
|
||||
assert.equal(r.status, 502, JSON.stringify(answer));
|
||||
assert.equal(r.body.error, 'invalid-response');
|
||||
}
|
||||
for (const [code, status] of Object.entries({ 'human-required': 403, 'project-not-configured': 503, 'project-not-declared': 502, 'library-mapping-invalid': 502 })) {
|
||||
const s = await setup(t, { status: () => refusal(code) }), r = await get(s, '/api/library/status?project=books');
|
||||
assert.equal(r.status, status, code);
|
||||
assert.equal(r.body.error, code);
|
||||
}
|
||||
const none = await setup(t, { status: null });
|
||||
assert.equal((await get(none, '/api/library/status?project=books')).body.error, 'not-configured');
|
||||
});
|
||||
|
||||
test('the status read needs no send; a read-only Console still has no revert', async t => {
|
||||
const s = await setup(t, { enabled: false });
|
||||
assert.equal((await get(s, '/api/library/status?project=books')).status, 200);
|
||||
assert.equal((await call(s)).status, 403);
|
||||
});
|
||||
|
||||
test('library routes are the owner\'s: a granted user is refused them, and the tester never reverts', async t => {
|
||||
for (const [path, method] of [['/api/library/status', 'GET'], ['/api/library/revert', 'POST']]) assert.equal(userRoute(path, method), false, path);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: '/nonexistent', libraryRevert: async () => ({}) }), /tester listener permits only/);
|
||||
});
|
||||
|
||||
test('the page gets the one library renderer, byte for byte, as a JavaScript module', async t => {
|
||||
const s = await setup(t), r = await get(s, '/render.mjs');
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.type, /^text\/javascript/);
|
||||
assert.equal(r.text, readFileSync(new URL('../../library/src/render.mjs', import.meta.url), 'utf8'));
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# FED R7 / #1802. Scratch acceptance with labelled ancestry stand-in (sage-3625).
|
||||
# No systemd units. All children run in owned process groups, removed on exit.
|
||||
set -euo pipefail
|
||||
R7_INTERRUPT=0
|
||||
case "${1:-}" in
|
||||
'') ;;
|
||||
--kill-after-start) R7_INTERRUPT=1 ;;
|
||||
*) echo 'usage: scripts/test-federation-acceptance.sh [--kill-after-start]' >&2; exit 2 ;;
|
||||
esac
|
||||
[ "$#" -le 1 ] || exit 2
|
||||
for R7_TOOL in ss rg timeout node ps awk; do
|
||||
command -v "$R7_TOOL" >/dev/null || { echo "R7 requires $R7_TOOL" >&2; exit 1; }
|
||||
done
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
R7_ROOT="$(mktemp -d /mnt/storage/scratch/tmp/r7.XXXXXXXX)"
|
||||
mkdir -p "$R7_ROOT/home" "$R7_ROOT/tmp"
|
||||
touch "$R7_ROOT/groups" "$R7_ROOT/ports" "$R7_ROOT/sockets"
|
||||
|
||||
cleanup() {
|
||||
local result=$? pid start raw port socket listeners cleanup_failed=0 attempt
|
||||
local -a fields
|
||||
trap - EXIT INT TERM
|
||||
if [ -n "${R7_DRIVER:-}" ]; then
|
||||
kill -TERM "$R7_DRIVER" 2>/dev/null || true
|
||||
wait "$R7_DRIVER" 2>/dev/null || true
|
||||
fi
|
||||
# A detached child is its group's leader. Confirm pid/start time/group before
|
||||
# signalling only groups in this run's ledger; never a live stack process.
|
||||
while read -r pid start; do
|
||||
[ -n "$pid" ] || continue
|
||||
if [ -r "/proc/$pid/stat" ]; then
|
||||
raw="$(cat "/proc/$pid/stat")"
|
||||
read -r -a fields <<< "${raw##*) }"
|
||||
if [ "${fields[19]}" != "$start" ] || [ "${fields[2]}" != "$pid" ]; then
|
||||
echo "R7 cleanup failed: group identity changed: $pid" >&2
|
||||
result=1; cleanup_failed=1
|
||||
continue
|
||||
fi
|
||||
kill -TERM -- "-$pid" 2>/dev/null || true
|
||||
for ((attempt=0; attempt<800; attempt++)); do
|
||||
ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}' || break
|
||||
sleep 0.05
|
||||
done
|
||||
kill -KILL -- "-$pid" 2>/dev/null || true
|
||||
for ((attempt=0; attempt<40; attempt++)); do
|
||||
ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}' || break
|
||||
sleep 0.05
|
||||
done
|
||||
fi
|
||||
# Zombies hold no tools/listeners and are reaped by their parent. The test
|
||||
# requires no live member of an owned group, including orphan descendants.
|
||||
if ps -eo pgid=,stat= | awk -v g="$pid" '$1==g && $2 !~ /^Z/ {found=1} END {exit !found}'; then
|
||||
echo "R7 cleanup failed: group $pid still has a live process" >&2
|
||||
result=1; cleanup_failed=1
|
||||
fi
|
||||
done < "$R7_ROOT/groups"
|
||||
while IFS= read -r port; do
|
||||
[ -n "$port" ] || continue
|
||||
if ! listeners="$(ss -H -ltn "sport = :$port")"; then
|
||||
echo "R7 cleanup failed: cannot inspect port $port" >&2
|
||||
result=1; cleanup_failed=1
|
||||
elif [ -n "$listeners" ]; then
|
||||
echo "R7 cleanup failed: port $port still listens" >&2
|
||||
result=1; cleanup_failed=1
|
||||
fi
|
||||
done < "$R7_ROOT/ports"
|
||||
while IFS= read -r socket; do
|
||||
[ -n "$socket" ] || continue
|
||||
if ! listeners="$(ss -H -lx)"; then
|
||||
echo "R7 cleanup failed: cannot inspect socket $socket" >&2
|
||||
result=1; cleanup_failed=1
|
||||
elif printf '%s\n' "$listeners" | rg -F -- "$socket" >/dev/null; then
|
||||
echo "R7 cleanup failed: socket $socket still listens" >&2
|
||||
result=1; cleanup_failed=1
|
||||
fi
|
||||
done < "$R7_ROOT/sockets"
|
||||
if listeners="$(ss -H -lx)"; then
|
||||
if printf '%s\n' "$listeners" | rg -F -- "$R7_ROOT/" >/dev/null; then
|
||||
echo 'R7 cleanup failed: another scratch Unix listener remains' >&2
|
||||
result=1; cleanup_failed=1
|
||||
fi
|
||||
else
|
||||
result=1; cleanup_failed=1
|
||||
fi
|
||||
if [ "$result" -ne 0 ]; then
|
||||
for raw in "$R7_ROOT"/*.log; do
|
||||
[ -f "$raw" ] || continue
|
||||
echo "R7 diagnostic: ${raw##*/}" >&2
|
||||
tail -40 "$raw" >&2
|
||||
done
|
||||
fi
|
||||
rm -rf -- "$R7_ROOT"
|
||||
if [ -e "$R7_ROOT" ]; then
|
||||
echo 'R7 cleanup failed: scratch directory remains' >&2
|
||||
result=1; cleanup_failed=1
|
||||
fi
|
||||
if [ "$cleanup_failed" -eq 0 ]; then echo 'R7 cleanup: no child groups, ports or sockets remain; no units created'; fi
|
||||
exit "$result"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
# No host configuration, preload hooks, credentials or agent env in fixtures.
|
||||
# The ancestry stand-in lives only in federation-host.mjs, per sage-3625.
|
||||
timeout -k 10 210 env -i PATH="$PATH" HOME="$R7_ROOT/home" TMPDIR="$R7_ROOT/tmp" \
|
||||
MOSAIC_R7_ROOT="$R7_ROOT" MOSAIC_R7_INTERRUPT="$R7_INTERRUPT" \
|
||||
node --test --test-concurrency=1 packages/cli/tests/federation-acceptance.test.mjs &
|
||||
R7_DRIVER=$!
|
||||
wait "$R7_DRIVER"
|
||||
R7_DRIVER=
|
||||
Reference in new issue
Block a user