Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde5b84964 | ||
|
|
b6c3129d2e | ||
|
|
c25f5b8dae | ||
|
|
e25ef0373c |
No files matched your search
@@ -5411,3 +5411,9 @@ 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 | Beakley (88b97f65) | Row 217 #1718 takeover, before implementation | Clean base 45dca69f6 on beakley/row217. Recovering Launchpad handoff for citation guard, live settings, page store and runner retry. Acceptance is C2-06/design 5.2 plus sage-3595/3599/3602. Tracking agents/beakley/work/queue-217/BUILD.md; no product changes or Node/tests; Sage CLEAR required.
|
||||
|
||||
2026-10-23 | Beakley (88b97f65) | Row 217 #1718 untested draft checkpoint | Continued Launchpad e25ef0373; added runner retry/text/note implementation, pure protocol constants and sage-3640 approved single read-only mount, CLI documentation and new acceptance test files. Static checks caught missing projects compatibility, async request mutation and Git pathspec matching; bounded fixes and regression tests authored. git diff --check clean. No Node/test/mutant executed; Sage holds CLEAR. 283/216/285 dependency checks and all acceptance execution remain open. Packet agents/beakley/work/queue-217/MANIFEST.md.
|
||||
|
||||
2026-10-23 | Beakley (88b97f65) | Row 217 early-look follow-up, Untested | Webby 0939c0682 E1 adds ordinary mallory human-swap test; E2 adds stored trailer-derived agent/file event evidence; E6 creative-sender matrix and E7 runtime readRaw tests authored. Sage-3668/3670 accepts direct writer substitution because setter keys exclude replyLimits (organization.set exists, correcting review premise), and requires distinct availability refusal for E4. Real held-lock/socket and runner-reason regressions plus M5 authored before that production fix; expected RED awaits Sage CLEAR. No Node/tests/mutants, no old-test edits, no green claim.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Row 217 — C2-06 citation guard, page store and runner retry
|
||||
|
||||
Owner: Beakley (88b97f65-97db-4f05-8c6e-081f92bc3501), assigned by Mos Q245.
|
||||
Issue: #1718. Reviewer: Webby. Branch: `beakley/row217`.
|
||||
Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-06.
|
||||
Status: Untested draft on Launchpad handoff `e25ef0373`; targeted execution awaits Sage CLEAR.
|
||||
|
||||
## Plan and boundaries
|
||||
|
||||
Continue Launchpad's implementation from its handed-off ref. Complete the guard,
|
||||
live reply limits and M11 writer fence, broker page store and check evidence,
|
||||
CLI setter and runner retry/note behavior against the brief and design 5.2.
|
||||
Supply acceptance tests and mutants, then request Sage's test slot and Webby's
|
||||
review. Sage owns queue movement, gate and landing. Push only `beakley/*`, add
|
||||
commits without amend/force or attribution lines. No credential reads.
|
||||
|
||||
## Standing rulings received from Sage (sage-3610)
|
||||
|
||||
- sage-3595: stub supporting scope until row 283 lands, then rebase onto it and
|
||||
add real-fixture tests. Landing follows 283. `runtime.mjs` wiring approved.
|
||||
- Check evidence belongs in an `action.allowed` event in the same transaction
|
||||
as message insertion; no new event kind. Read the library at the cited commit.
|
||||
- In library businesses only, JSON over 32,768 characters gets the
|
||||
`request-too-large` retry. `role.claim` adds `library` only for a library business.
|
||||
- Store `check.citations` entries with `scope`, `agent`, and `file` (the file's
|
||||
last commit at the cited commit). On `thread.messages`, add `last` at the served
|
||||
tip, one git call per distinct path per read. Scope/agent are broker evidence.
|
||||
- sage-3599: optional `BusError.reason`, guard refusals only, capped at 300
|
||||
characters. Add bus server/client to manifest. Reasonless envelopes stay
|
||||
byte-identical.
|
||||
- `render.mjs` obsolete notice hits belong to row 225.
|
||||
- Node and tests require Sage CLEAR (sage-3610 supersedes the initial permission
|
||||
for targeted new tests). Full suites, webui builds and gate require shared slot.
|
||||
- Existing test edits require Mos GO through Sage; list every file and numstat
|
||||
below before seeking that approval.
|
||||
|
||||
## Recovery and coordination
|
||||
|
||||
Initial base `45dca69f6`, clean. `scripts/mosaic queue next beakley` refused:
|
||||
linked worktrees cannot run the queue. Reported to Sage/Mos in beakley-0001;
|
||||
Sage will change ownership at a queue gap. No queue files changed.
|
||||
Read AGENTS.md, agents/README.md, C2-06 brief, design 5.2 and the row 225 BUILD
|
||||
contract. Requested Launchpad handoff in beakley-0002. Its header timestamp was
|
||||
mistakenly typed ahead of the actual clock; sequence key identifies the message.
|
||||
|
||||
## Existing test changes
|
||||
|
||||
None yet. No tests or Node commands run.
|
||||
|
||||
|
||||
## Handoff adopted and independent work (2026-10-23)
|
||||
|
||||
Fast-forwarded `beakley/row217` to Launchpad `e25ef0373` on `45dca69f6`.
|
||||
The full original handoff/rulings remain at
|
||||
`agents/launchpad/work/queue-217/BUILD.md`. No handoff code was rebuilt from scratch.
|
||||
|
||||
Implemented the runner path: role.claim library switch; exact fixed texts; note
|
||||
inside JSON text with citations retained and no JSON clip; fixed texts defer
|
||||
notes; one same-session retry for guard codes and both existing size caps;
|
||||
retry fallback chosen from the second refusal; separate usage turn id for retry.
|
||||
Outside library businesses the existing send path remains intact.
|
||||
|
||||
Added the CLI README usage paragraph. New acceptance tests cover library parsing,
|
||||
real Git reachability/quotes/scope/origin, settings fallback, M11 direct writer
|
||||
and serialization, CLI dispatch, broker insert/check atomicity, live settings,
|
||||
page bounds/lifecycle, socket reasons, per-path last reads, request snapshots,
|
||||
and runner RESULT/typed send_message, fixed texts, long JSON and unslop behavior.
|
||||
These are authored evidence only: none has executed.
|
||||
|
||||
Static defects corrected locally: a missing projects object now means no library;
|
||||
the guard sends its captured request arguments after the asynchronous check;
|
||||
origin/last Git logs use literal pathspecs; a web quote can match any retained
|
||||
fetch of the cited URL. Each has new-file regression coverage.
|
||||
|
||||
## Scope additions and cross-row contract
|
||||
|
||||
- `packages/library/src/reply-protocol.mjs`: shared GUARD_CODES/FIXED_TEXTS,
|
||||
import-free, so runner imports do not reach host Git modules. guard.mjs
|
||||
reexports them to preserve Launchpad's API. Fethry confirmed no renderer import
|
||||
is needed when the renderer simply displays fixed texts as ordinary text.
|
||||
- `packages/seat/src/sandbox.mjs`: one RUNNER_FILES entry for that pure file,
|
||||
approved by sage-3640. No other mount change. The existing sandbox.test.mjs
|
||||
import-closure test must pass unchanged; seat joins the gate list.
|
||||
- Runtime hunks remain exactly Launchpad's import and library guard/limits/last
|
||||
adapters, plus library.limits.set dispatch. Drift checks wait on 216 and 285.
|
||||
- Status contract confirmed with Fethry (beakley-0004, fethry-0009): nested
|
||||
`replyLimits: {requireCitation, citationsPerReply, fallback: [keys],
|
||||
problem: "reply-limits-unreadable" | null}`. Check entries remain ordered
|
||||
`scope/agent/file`, plus `last` on reads; no changed boolean.
|
||||
|
||||
## Verification and remaining acceptance
|
||||
|
||||
- Static `git diff --check`: clean so far. No Node, suite, test or mutant run.
|
||||
- Existing test file edits: none (numstat 0/0 for every pre-existing test).
|
||||
All new test files are listed in manifest.r1.sha256; no Mos approval requested.
|
||||
- `mutants.py` is authored for isolated HEAD archives with green controls:
|
||||
M1 remove both writer fences; M2 compare incoming human; M3 split evidence
|
||||
from insert; M4 drop client reason. Not executed.
|
||||
- Still required: Sage CLEAR for targeted runs; fix first-run failures; supporting
|
||||
scope acceptance against row 283; 216 index/readLibrary fixture comparison;
|
||||
285/runtime drift check; full library/business/bus/cli/harness/seat suites and
|
||||
every scripts/test-*.sh on Sage's gate; mutant execution; Webby approval.
|
||||
- Before in-review, perform brief's obsolete-code/key search and report outside
|
||||
ownership hits to Sage. render.mjs/render.test.mjs hits belong to 225.
|
||||
- No queue changes, protected branch pushes, gate claims or acceptance claims.
|
||||
|
||||
Static obsolete-code/key search completed: only packages/library/src/render.mjs:6 and packages/library/tests/render.test.mjs:58 match no-cited-answer; both belong to row 225 per standing ruling. No owned-path hit. Repeat before in-review after dependency changes.
|
||||
|
||||
|
||||
## Webby early look 0939c0682 / webby-0249 and sage-3668/3670
|
||||
|
||||
This remains an untested draft. The follow-up adds tests only; production E4
|
||||
changes wait for the held-lock regression's first RED under Sage CLEAR.
|
||||
|
||||
- E1: added an ordinary actor `mallory` that sets both `human` to itself and
|
||||
`replyLimits`. The existing role/tester actors did not distinguish M2 because
|
||||
the actor-shape exclusions rejected them independently of the human comparison.
|
||||
M2 is still unrun; no kill claimed.
|
||||
- E2: added a real bus event assertion for `agent: "acme/scribe"` from a file's
|
||||
Mosaic-Agent trailer. The cited tip includes a later unrelated commit, so the
|
||||
event must store the file commit rather than the library tip. thread.messages
|
||||
must show `last === file` for that unchanged path. Supporting scope still waits
|
||||
for row 283's real fixture, as ruled.
|
||||
- E3: sage-3668 accepts direct writer.write tests under chief and tester actors
|
||||
as substitutes for the brief's setter cases (brief lines 483-491 at base
|
||||
45dca69f6). Correction to the early-look premise: organization.set DOES exist,
|
||||
broker.mjs:4701/4709, but ORGANIZATION_KEYS excludes replyLimits; instances.set
|
||||
also cannot carry it. Direct writes exercise the shared M11 boundary those
|
||||
setters cannot reach with that input. Sage confirmed the accurate reason in
|
||||
sage-3670; the unrelated actor case remains required.
|
||||
- E4: traced readSyncState -> withLibraryLock -> library-sync-busy after five
|
||||
seconds -> servedMirror catch-all -> null served -> misleading citation-invalid.
|
||||
New real-flock/socket test first proves the cite valid, holds the lock, asserts
|
||||
the exact citation-unavailable reason the agent receives, verifies no insert,
|
||||
and checks fixed fallback delivery and recovery after unlock. Additional tests
|
||||
distinguish malformed sync state from absent/new mirrors, and assert that the
|
||||
actual runner retry request contains that reason. These tests are expected RED
|
||||
until the fix. Planned production change: only an absent mirror or legitimate
|
||||
empty state yields null; other failures become GuardError citation-unavailable;
|
||||
bodies without path cites do not require the mirror lock. Add the code to
|
||||
GUARD_CODES so the runner sends the agent that reason on its one retry. Per
|
||||
sage-3670, existing four fallback bodies can remain only if that delivery is
|
||||
verified. M5 restores catch-all behavior; authored, not run.
|
||||
- E5: first-library-project selection is a Launchpad handoff implementation
|
||||
choice, not a validated rule that a business can only have one library.
|
||||
Cites into another library remain a limitation. No mapping/schema rule added.
|
||||
- E6: the broker's full guard table now runs as both coder and research.
|
||||
- E7: new citation-runtime.test.mjs starts the real runtime, reads limits from
|
||||
the real organization file, then changes, corrupts and removes that fixture
|
||||
file and checks library.status without replacing the library adapter.
|
||||
|
||||
No Node, tests or mutants ran. No test that predated row 217 changed.
|
||||
|
||||
|
||||
## Webby follow-up 192049c98 / webby-0258
|
||||
|
||||
E1, E2, E6 and E7 closed statically; no executed acceptance claimed. E3 correction and E5 limitation accepted. E4 tests accepted statically; real RED then production fix/green remain on hold for Sage CLEAR, with supporting scope waiting on 283. F1: citation-helpers status now uses its mutable served value, matching guard/last after setServed instead of reporting the original fixture commit. Test-only fixture correction, unrun. manifest.r3.sha256 records the updated helper; no production or old-test changes.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Row 217 draft manifest — Untested
|
||||
|
||||
Issue #1718, Case 2 C2-06. Base `45dca69f6`, continued from Launchpad
|
||||
`e25ef0373`. This is a checkpoint for static review, not an in-review or
|
||||
acceptance claim. `manifest.r1.sha256` freezes the product and test files.
|
||||
|
||||
## Product scope
|
||||
|
||||
| Path | Change |
|
||||
| --- | --- |
|
||||
| packages/library/src/guard.mjs | Schema and seven checks, real cited-commit reads, origins and latest path commits |
|
||||
| packages/library/src/limits.mjs | Pure defaults, validation and conservative unreadable-file fallback |
|
||||
| packages/library/src/reply-protocol.mjs | Import-free guard codes and four runner fixed texts |
|
||||
| packages/library/src/index.mjs | Guard and limits exports |
|
||||
| packages/business/src/business.mjs | Permit any replyLimits value without widening another schema |
|
||||
| packages/business/src/organization.mjs | M11 fence on input and serialized output, human setter, raw live reader |
|
||||
| packages/bus/src/broker.mjs | Async pre-insert guard, atomic existing-kind check event, per-session pages, settings verb/status, optional library claim flag and read-time last evidence |
|
||||
| packages/bus/src/runtime.mjs | Inject guard/live limits/last and dispatch human setter (sage-3595) |
|
||||
| packages/bus/src/server.mjs | Await guarded sends and thread reads; optional guard reason (sage-3599) |
|
||||
| packages/bus/src/client.mjs | Preserve optional capped reason (sage-3599) |
|
||||
| packages/harness/src/runner.mjs | Library texts, one retry, JSON note/clip behavior and retry usage tracking |
|
||||
| packages/seat/src/sandbox.mjs | Exactly one read-only RUNNER_FILES addition for the pure protocol (sage-3640) |
|
||||
| packages/cli/src/limits.mjs | Human limits replies file command |
|
||||
| packages/cli/README.md | Reply settings usage |
|
||||
|
||||
No dependency, SQL schema, event kind, role authority or credential changes.
|
||||
The additional sandbox file carries only constants and has no imports.
|
||||
|
||||
## New tests and fixtures
|
||||
|
||||
| Path | Acceptance focus |
|
||||
| --- | --- |
|
||||
| packages/library/tests/guard.test.mjs | Seven table checks, cap boundaries, real Git origins/reachability/literal names, web quotes and limits fallback |
|
||||
| packages/business/tests/reply-limits.test.mjs | Human-only M11, old-human comparison, dotted agent identity, serialization, invalid values and raw reads |
|
||||
| packages/bus/tests/citation-helpers.mjs | Shared made-up repository and real broker fixture |
|
||||
| packages/bus/tests/citation-guard.test.mjs | Refusal insertion counts, transaction failure, live settings/status, page bounds, socket envelopes, file/last and async snapshot |
|
||||
| packages/cli/tests/reply-limits.test.mjs | Revision read, exact setter args and usage refusals |
|
||||
| packages/harness/tests/fixtures/citation-adapter.mjs | Scripted local harness, typed calls and unslop events |
|
||||
| packages/harness/tests/citation-runner.test.mjs | Every RESULT refusal and typed send_message, one retry, both size caps, plain multibyte answers, fixed texts and notes, old non-library behavior |
|
||||
|
||||
No existing test changed. Existing role.claim pins and the seat import-closure
|
||||
test stay untouched. Existing test numstat: none.
|
||||
|
||||
## Review focus and outstanding evidence
|
||||
|
||||
The check event and message insert must roll back together. Scope/agent/file
|
||||
come from Git; `last` is read once per distinct path per read. Guard refusals
|
||||
alone have a reason, bounded at 300 characters without quote or URL content.
|
||||
Captured arguments must be the ones inserted after async validation. Every
|
||||
plain or JSON length refusal shares the same single retry budget.
|
||||
|
||||
All tests and four isolated mutants are unrun pending Sage CLEAR. The supporting
|
||||
scope tests require row 283; the index-set comparison requires row 216; runtime
|
||||
drift must be checked against 216/285 before in-review. Landing follows 283.
|
||||
Full gate: library, business, bus, cli, harness and seat, plus every
|
||||
scripts/test-*.sh. Webby reviews the final tested manifest; Sage owns the gate,
|
||||
queue and landing. No push to a protected branch is authorized.
|
||||
|
||||
|
||||
## Early-look follow-up packet (manifest.r2.sha256)
|
||||
|
||||
Tests-only follow-up to Webby 0939c0682. Adds ordinary-actor M2 discrimination,
|
||||
stored agent/file evidence at an unrelated tip, creative-sender table coverage,
|
||||
and real runtime readRaw coverage. New citation-availability.test.mjs and runner
|
||||
availability case are intentionally unrun regressions for E4/sage-3668/3670.
|
||||
Production availability handling is not implemented yet: run that targeted RED
|
||||
on CLEAR before applying its fix, then rerun the same cases. M5 restores the
|
||||
catch-all. This packet is not ready for a green gate or manifest approval.
|
||||
@@ -0,0 +1,21 @@
|
||||
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
|
||||
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
|
||||
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
|
||||
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
|
||||
29114051a9bc5271e48978a9552d64b51b0ac4810f94a1f98940bf617b148df8 packages/bus/tests/citation-guard.test.mjs
|
||||
dc2d5836f10f591fc174aaf9587a3bff793b24ac29efe9ab68c6a9243d1d1c2c packages/bus/tests/citation-helpers.mjs
|
||||
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
|
||||
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
|
||||
bec04a894bc2af000548283ed7719e4de1c6d56688f42238a113390e9b2d3fbe packages/business/tests/reply-limits.test.mjs
|
||||
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
|
||||
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
|
||||
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
|
||||
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
|
||||
ec7ef0cbf493217eb9229895cae2fe7ef603f41913840e59a73220df387c81ca packages/harness/tests/citation-runner.test.mjs
|
||||
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
|
||||
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
|
||||
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
|
||||
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
|
||||
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
|
||||
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
|
||||
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
|
||||
@@ -0,0 +1,23 @@
|
||||
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
|
||||
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
|
||||
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
|
||||
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
|
||||
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
|
||||
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
|
||||
62d8a55945d4b700e22c8c49b6420a5e25719d266901a2ad20c72514735e65e9 packages/bus/tests/citation-helpers.mjs
|
||||
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
|
||||
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
|
||||
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
|
||||
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
|
||||
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
|
||||
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
|
||||
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
|
||||
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
|
||||
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
|
||||
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
|
||||
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
|
||||
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
|
||||
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
|
||||
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
|
||||
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
|
||||
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
|
||||
@@ -0,0 +1,23 @@
|
||||
68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs
|
||||
ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs
|
||||
49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs
|
||||
66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs
|
||||
cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs
|
||||
8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs
|
||||
8a2d4b636f8d4f926c484254c33ecf7b7e4722acef3250d2730593984d5ac06b packages/bus/tests/citation-helpers.mjs
|
||||
168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs
|
||||
825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs
|
||||
dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs
|
||||
4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs
|
||||
1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md
|
||||
2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs
|
||||
f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs
|
||||
6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs
|
||||
a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs
|
||||
330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs
|
||||
0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs
|
||||
aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs
|
||||
3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs
|
||||
c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs
|
||||
dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs
|
||||
2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only on Sage CLEAR. Mutate isolated HEAD archives; never edit the checkout.
|
||||
|
||||
A green control run is required before each negative result counts as a kill.
|
||||
Usage: python3 agents/beakley/work/queue-217/mutants.py OUTPUT_DIRECTORY
|
||||
"""
|
||||
from pathlib import Path
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
OUT = Path(sys.argv[1]).resolve()
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
WRITER = "packages/business/src/organization.mjs"
|
||||
BROKER = "packages/bus/src/broker.mjs"
|
||||
CLIENT = "packages/bus/src/client.mjs"
|
||||
ATOMIC = "if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));"
|
||||
REENTER = "return this.#reenter(cap, s, { verb: 'message.send', args }, check);"
|
||||
SPLIT = """const result = this.#reenter(cap, s, { verb: 'message.send', args }, check);
|
||||
if (check) this.#store.transaction(() => this.#event(s, 'action.allowed', { action: 'message.checked', message: result.id, check }, result.id, checkEventId(result.id)));
|
||||
return result;"""
|
||||
MUTANTS = [
|
||||
("M1-writer-fence", [(WRITER, " replyLimitsFence(old, document, actor);", ""),
|
||||
(WRITER, " replyLimitsFence(old, written, actor);", "")],
|
||||
["packages/business/tests/reply-limits.test.mjs"]),
|
||||
("M2-incoming-human", [(WRITER, "actor === old.human", "actor === document.human")],
|
||||
["packages/business/tests/reply-limits.test.mjs"]),
|
||||
("M3-split-check-transaction", [(BROKER, ATOMIC, ""), (BROKER, REENTER, SPLIT)],
|
||||
["packages/bus/tests/citation-guard.test.mjs"]),
|
||||
("M5-hide-library-unavailable", [("packages/library/src/guard.mjs",
|
||||
"refuse('citation-unavailable', 'The library is busy or unavailable. Retry the answer when the library is available.');",
|
||||
"served = null;")], ["packages/bus/tests/citation-availability.test.mjs"]),
|
||||
("M4-drop-client-reason", [(CLIENT, "new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined)", "new BusError(r.error)")],
|
||||
["packages/bus/tests/citation-guard.test.mjs"]),
|
||||
]
|
||||
|
||||
archive = subprocess.check_output(["git", "archive", "HEAD", "packages", "roles", "scripts", "tools", "docs", "package.json", "RELEASE"], cwd=ROOT)
|
||||
summary = []
|
||||
for name, changes, tests in MUTANTS:
|
||||
with tempfile.TemporaryDirectory(prefix="row217-mutant-") as directory:
|
||||
work = Path(directory)
|
||||
with tarfile.open(fileobj=io.BytesIO(archive)) as source:
|
||||
source.extractall(work, filter="data")
|
||||
modules = ROOT / "node_modules"
|
||||
if modules.exists():
|
||||
os.symlink(modules.resolve(), work / "node_modules", target_is_directory=True)
|
||||
cmd = ["node", "--test", *tests]
|
||||
with (OUT / f"{name}-control.log").open("w") as log:
|
||||
control = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
|
||||
if control.returncode:
|
||||
raise SystemExit(f"{name}: control RED, no mutant result claimed")
|
||||
for file, before, after in changes:
|
||||
path = work / file
|
||||
text = path.read_text()
|
||||
if text.count(before) != 1:
|
||||
raise SystemExit(f"{name}: expected exactly one mutation anchor in {file}")
|
||||
path.write_text(text.replace(before, after))
|
||||
with (OUT / f"{name}.log").open("w") as log:
|
||||
mutant = subprocess.run(cmd, cwd=work, stdout=log, stderr=subprocess.STDOUT, timeout=120)
|
||||
if mutant.returncode == 0:
|
||||
raise SystemExit(f"{name}: SURVIVED")
|
||||
summary.append(f"{name}: killed (control green, mutant exit {mutant.returncode})")
|
||||
(OUT / "summary.txt").write_text("\n".join(summary) + "\n")
|
||||
print(summary[-1], flush=True)
|
||||
@@ -0,0 +1,124 @@
|
||||
# Row 217 (Case 2 C2-06): build notes
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
|
||||
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
|
||||
the row went to Beakley on beakley/row217.
|
||||
|
||||
## State at handoff
|
||||
|
||||
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
|
||||
`packages/harness/src/runner.mjs` is not started.
|
||||
|
||||
Done, untested:
|
||||
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
|
||||
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
|
||||
An unreadable business file forces `requireCitation` true. A key falls back to its default when
|
||||
`replyLimits` is not an object or the key holds an invalid value.
|
||||
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
|
||||
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
|
||||
`lastCommits`, `guardReply`.
|
||||
- `packages/library/src/index.mjs`: exports for both.
|
||||
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
|
||||
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
|
||||
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
|
||||
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
|
||||
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
|
||||
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
|
||||
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
|
||||
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
|
||||
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
|
||||
|
||||
Left:
|
||||
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
|
||||
- `packages/cli/README.md`: a line for `limits replies`.
|
||||
- Every test in the brief and the rulings below, then the mutants.
|
||||
- The rebases onto rows 283, 216 and 285, with the drift checks below.
|
||||
- This file's gate results and the manifest.
|
||||
|
||||
## Rulings in force
|
||||
|
||||
sage-3595:
|
||||
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
|
||||
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
|
||||
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
|
||||
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
|
||||
name it in the manifest, and drift-check it after the 216 and 285 rebases.
|
||||
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
|
||||
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
|
||||
The event and the message INSERT share one transaction: a mutant that splits them must fail.
|
||||
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
|
||||
set with readLibrary's for the fixture and record the result here.
|
||||
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
|
||||
the retry; outside a library business nothing changes.
|
||||
6. Old `role.claim` pins stay as they are.
|
||||
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
|
||||
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
|
||||
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
|
||||
|
||||
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
|
||||
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
|
||||
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
|
||||
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
|
||||
pin; a mutant that drops the client.mjs pass-through fails.
|
||||
|
||||
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
|
||||
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
|
||||
|
||||
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
|
||||
|
||||
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
|
||||
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
|
||||
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
|
||||
- A url cite: `{scope: "web", agent: null}`.
|
||||
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
|
||||
The Console compares `last` with `file`.
|
||||
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
|
||||
|
||||
## Hunks outside the brief's file list
|
||||
|
||||
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
|
||||
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
|
||||
the organization dispatch gains `library.limits.set`.
|
||||
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
|
||||
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
|
||||
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
|
||||
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
|
||||
|
||||
## Decisions and their tradeoffs
|
||||
|
||||
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
|
||||
message.send's insert and the check event stay one synchronous transaction.
|
||||
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
|
||||
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
|
||||
through the unique index on `events.id`.
|
||||
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
|
||||
- With more than one library project, the guard uses the first in the business file's order.
|
||||
- A library business without guard deps refuses guarded sends with `project-not-configured`.
|
||||
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
|
||||
delete or disconnect and at run end.
|
||||
- The CLI `replies` verb reads the revision itself from `limits`.
|
||||
- Guard reasons never carry a url or a quote (sage-3599).
|
||||
|
||||
## Runner plan (not built)
|
||||
|
||||
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
|
||||
business, at the reply site (`runner.mjs:582-587`):
|
||||
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
|
||||
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
|
||||
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
|
||||
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
|
||||
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
|
||||
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
|
||||
can tell it by the body length it sent), run one new turn in the same
|
||||
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
|
||||
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
|
||||
`(the answer was too long to send)`. One retry per answer, whatever the cause.
|
||||
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
|
||||
- Outside a library business nothing changes.
|
||||
|
||||
## Mutants planned
|
||||
|
||||
- The M11 writer check removed.
|
||||
- `document.human` compared instead of `old.human`.
|
||||
- The check event split from the insert transaction.
|
||||
- The client.mjs reason pass-through dropped.
|
||||
@@ -1,177 +0,0 @@
|
||||
# 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.
|
||||
@@ -1593,3 +1593,5 @@ 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 | Beakley (88b97f65, Codex gpt-6.1-sol) | Row 217 #1718 takeover under Q245 | Registered beakley/row217; read brief/design and standing Sage rulings; awaiting Launchpad ref before implementation. Queue next refused linked worktree; reported to Sage. Untested, no Node/test run pending CLEAR.
|
||||
+134
-7
@@ -203,15 +203,26 @@ const canonical = (x) =>
|
||||
Number.isFinite(Date.parse(x)) &&
|
||||
new Date(x).toISOString() === x;
|
||||
export class BusError extends Error {
|
||||
constructor(code) {
|
||||
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
|
||||
constructor(code, reason) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
if (reason !== undefined) this.reason = reason;
|
||||
}
|
||||
}
|
||||
const fail = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
|
||||
const PAGE_COUNT = 20;
|
||||
const PAGE_BYTES = 200 * 1024;
|
||||
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
|
||||
// through the unique index on events.id (there is none on subject).
|
||||
const checkEventId = (mid) => {
|
||||
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
||||
};
|
||||
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
|
||||
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
|
||||
function keys(x, allowed, required = []) {
|
||||
@@ -389,6 +400,11 @@ export class Broker {
|
||||
#credentialVerbs;
|
||||
#project;
|
||||
#library;
|
||||
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
|
||||
// In memory, dropped with the session.
|
||||
#pages = new Map();
|
||||
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
|
||||
#guardPass = null;
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
@@ -1403,7 +1419,10 @@ export class Broker {
|
||||
this.#launcherReleased.delete(key);
|
||||
this.#accessRevoked.delete(key);
|
||||
for (const [cap, s] of this.#sessions)
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
|
||||
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
const h = this.#own(session);
|
||||
const released = h?.op === 'claim' && h.holder_run === session.run;
|
||||
@@ -1485,6 +1504,7 @@ export class Broker {
|
||||
}
|
||||
disconnect(cap) {
|
||||
this.#sessions.delete(cap);
|
||||
this.#pages.delete(cap);
|
||||
}
|
||||
identity(cap) {
|
||||
const s = this.#session(cap);
|
||||
@@ -4666,10 +4686,19 @@ export class Broker {
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (LIBRARY_VERBS.includes(request.verb))
|
||||
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
|
||||
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
|
||||
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
|
||||
// then re-enter request() for the synchronous path below.
|
||||
if (this.#guardPass?.s !== s) {
|
||||
if (request.verb === 'message.send' && this.#guarded(s, args.to))
|
||||
return this.#guardedSend(cap, s, request, structuredClone(args));
|
||||
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
|
||||
return this.#withLast(cap, s, request);
|
||||
}
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -4715,6 +4744,12 @@ export class Broker {
|
||||
keys(args, VETO_KEYS, VETO_KEYS);
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, "veto.set", args.name);
|
||||
}
|
||||
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
|
||||
// session by human-required; the writer's fence refuses any other actor.
|
||||
else if (request.verb === "library.limits.set") {
|
||||
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
|
||||
this.#human(s);
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
@@ -5138,7 +5173,14 @@ export class Broker {
|
||||
const context = { business: this.#business(s.business), project: args.project };
|
||||
let result;
|
||||
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
|
||||
else if (verb === 'library.status') result = await this.#library.status(context);
|
||||
else if (verb === 'library.status') {
|
||||
result = await this.#library.status(context);
|
||||
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
|
||||
if (this.#library.replyLimits) {
|
||||
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
|
||||
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
|
||||
}
|
||||
}
|
||||
else {
|
||||
// A fetch that did not move served still answers with the code, so the refusal is recorded.
|
||||
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
|
||||
@@ -5152,6 +5194,78 @@ export class Broker {
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
|
||||
}
|
||||
}
|
||||
// C2-06: the business's library project, the first in file order, or null.
|
||||
#libraryProject(business) {
|
||||
return Object.entries(this.#business(business).projects ?? {}).find(([, p]) => p.kind === 'library')?.[0] ?? null;
|
||||
}
|
||||
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
|
||||
// is guarded, whatever the sender's definition.
|
||||
#guarded(s, to) {
|
||||
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
|
||||
}
|
||||
#reenter(cap, s, request, check) {
|
||||
this.#guardPass = { s, check };
|
||||
try {
|
||||
return this.request(cap, request);
|
||||
} finally {
|
||||
this.#guardPass = null;
|
||||
}
|
||||
}
|
||||
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
|
||||
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
|
||||
// result in its own transaction.
|
||||
async #guardedSend(cap, s, request, args) {
|
||||
let check;
|
||||
try {
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
|
||||
if (typeof args.body !== 'string') fail('invalid-request');
|
||||
const business = this.#business(s.business);
|
||||
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
|
||||
limits: this.#library.replyLimits(s.business),
|
||||
definition: business.roles[s.role]?.definition ?? null,
|
||||
pages: this.#pages.get(cap) ?? [],
|
||||
});
|
||||
} catch (e) {
|
||||
const Guard = this.#library?.GuardError;
|
||||
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
|
||||
}
|
||||
return this.#reenter(cap, s, { verb: 'message.send', args }, check);
|
||||
}
|
||||
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
|
||||
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
|
||||
async #withLast(cap, s, request) {
|
||||
const rows = this.#reenter(cap, s, request, null);
|
||||
if (!rows.some((m) => m.check)) return rows;
|
||||
const cites = (m) => {
|
||||
try {
|
||||
const c = JSON.parse(m.body).citations;
|
||||
return Array.isArray(c) ? c : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
let last = new Map();
|
||||
try {
|
||||
const paths = rows.filter((m) => m.check).flatMap((m) => cites(m).filter((c) => typeof c?.path === 'string').map((c) => c.path));
|
||||
last = await this.#library.last({ business: this.#business(s.business), project: this.#libraryProject(s.business) }, paths);
|
||||
} catch {
|
||||
last = new Map();
|
||||
}
|
||||
return rows.map((m) => {
|
||||
if (!m.check) return m;
|
||||
const body = cites(m);
|
||||
return { ...m, check: { ...m.check, citations: m.check.citations.map((c, i) =>
|
||||
c.scope === 'web' ? c : { ...c, last: last.get(body[i]?.path) ?? null }) } };
|
||||
});
|
||||
}
|
||||
// C2-06 (B7): a fetch's text, kept for url cites in a library business only.
|
||||
#keepPage(cap, s, url, page) {
|
||||
if (!this.#sessions.has(cap) || !this.#libraryProject(s.business) || typeof page?.text !== 'string') return;
|
||||
const text = Buffer.from(page.text).subarray(0, PAGE_BYTES).toString();
|
||||
const kept = { url, finalUrl: typeof page.finalUrl === 'string' ? page.finalUrl : null, text };
|
||||
this.#pages.set(cap, [...(this.#pages.get(cap) ?? []), kept].slice(-PAGE_COUNT));
|
||||
}
|
||||
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
|
||||
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
|
||||
async requestTask(cap, request, handler) {
|
||||
@@ -5235,6 +5349,7 @@ export class Broker {
|
||||
throw refuse(new BusError('web-failed'));
|
||||
}
|
||||
complete(out.summary);
|
||||
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
|
||||
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
|
||||
}
|
||||
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
|
||||
@@ -5361,7 +5476,7 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeSession(s.session);
|
||||
return { role: s.role, run: s.run, thread: s.thread };
|
||||
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
if (
|
||||
this.#store.get(
|
||||
@@ -5385,7 +5500,8 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
this.#routeWaiting(s.business, s.role);
|
||||
return { role: s.role, run: s.run };
|
||||
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
|
||||
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
|
||||
}
|
||||
case 'role.release': {
|
||||
keys(a, []);
|
||||
@@ -5749,6 +5865,10 @@ export class Broker {
|
||||
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
|
||||
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
|
||||
string(a.body, 32768);
|
||||
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
|
||||
const guarded = this.#guarded(s, to);
|
||||
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
|
||||
const check = guarded ? this.#guardPass.check : null;
|
||||
if (
|
||||
a.class !== undefined &&
|
||||
![
|
||||
@@ -5795,6 +5915,9 @@ export class Broker {
|
||||
a.corrects ?? null,
|
||||
a.body,
|
||||
);
|
||||
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
|
||||
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
|
||||
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
|
||||
const cancelled = this.#route(s.business, mid, to, thread);
|
||||
const request = s.human
|
||||
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
|
||||
@@ -6414,11 +6537,15 @@ export class Broker {
|
||||
)?.seq;
|
||||
if (after === undefined) fail('message-not-found');
|
||||
}
|
||||
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
|
||||
return this.#store.all(
|
||||
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
|
||||
session,
|
||||
after,
|
||||
);
|
||||
).map((m) => {
|
||||
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
|
||||
return e ? { ...m, check: JSON.parse(e.body).check } : m;
|
||||
});
|
||||
}
|
||||
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
|
||||
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
|
||||
|
||||
@@ -41,7 +41,8 @@ export class Client {
|
||||
const r = JSON.parse(input.toString('utf8'));
|
||||
if (r.ok === true) end(null, r.result);
|
||||
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
|
||||
end(new BusError(r.error));
|
||||
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
|
||||
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
|
||||
else end(new BusError('invalid-response'));
|
||||
} catch {
|
||||
end(new BusError('invalid-response'));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadRole } from '../../business/src/role.mjs';
|
||||
@@ -305,6 +305,13 @@ export async function startBroker({
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
// C2-06: the citation guard, the reply limits read from the business file at each check (none
|
||||
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
|
||||
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
|
||||
replyLimits: id => libraryModule.resolveReplyLimits(organization
|
||||
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
|
||||
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
|
||||
GuardError: libraryModule.GuardError,
|
||||
},
|
||||
project: {
|
||||
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
||||
@@ -334,6 +341,8 @@ export async function startBroker({
|
||||
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
|
||||
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
|
||||
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
|
||||
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||
|
||||
@@ -7,6 +7,11 @@ const LIMIT = 65536;
|
||||
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
|
||||
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
|
||||
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
|
||||
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
|
||||
const refusal = (e, other) => e instanceof BusError
|
||||
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
|
||||
: { ok: false, error: other };
|
||||
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
@@ -15,6 +20,7 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
|
||||
// host-not-configured.
|
||||
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
|
||||
// (row 252) proof.
|
||||
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
@@ -96,7 +102,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
|
||||
(e) => finish(refusal(e, 'adapter-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -114,7 +120,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
|
||||
(e) => finish(refusal(e, 'web-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -130,7 +136,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
|
||||
(e) => finish(refusal(e, 'host-failed')),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) release();
|
||||
@@ -153,14 +159,26 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
socket.setTimeout(120_000);
|
||||
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
|
||||
result => { if (!socket.destroyed) finish({ ok: true, result }); },
|
||||
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
|
||||
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
|
||||
// the broker answers them with a promise. Every other verb stays synchronous.
|
||||
if (result instanceof Promise) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
socket.setTimeout(120_000);
|
||||
result.then(
|
||||
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
|
||||
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
|
||||
).finally(() => { if (own) release(); });
|
||||
return;
|
||||
}
|
||||
finish({ ok: true, result });
|
||||
} catch (e) {
|
||||
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
|
||||
finish(refusal(e, 'invalid-envelope'));
|
||||
} finally {
|
||||
if (transient) release();
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
// 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";
|
||||
@@ -31,7 +29,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": "9f33e9ca4feb7265ae33fd3ddf0ead0b75375d07ecbe9d8e55c618d29edea658",
|
||||
"packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// E4/sage-3668: real flock contention must not turn a valid cite into an invalid-cite refusal.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ensureMirror, mirrorPath, SYNC_STATE_FILE, withLibraryLock } from '../../library/src/mirror.mjs';
|
||||
import { guardReply } from '../../library/src/guard.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { citationWorld, citedBody, fixtureCite } from './citation-helpers.mjs';
|
||||
|
||||
test('C2-06 held library lock gives the agent citation-unavailable and its retry reason', { timeout: 25000 }, async t => {
|
||||
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const context = { business: f.business, project: 'books', dataRoot };
|
||||
await ensureMirror(context); const mirror = mirrorPath(context);
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
writeFileSync(join(mirror, SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
|
||||
lastFetch: null, lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
|
||||
f.adapter.guard = (ctx, body, opts) => guardReply({ ...ctx, dataRoot }, body, opts);
|
||||
f.call(f.agent, 'role.claim');
|
||||
const socket = join(f.root, 'availability.sock'), server = await serve({ broker: f.broker, path: socket });
|
||||
t.after(() => server.close()); const client = new Client({ path: socket, cap: f.agent, timeout: 15000 });
|
||||
const args = { to: 'human', body: citedBody([fixtureCite(f.commit)]) };
|
||||
await client.call('message.send', args); // Establish that the cite and real mirror are valid.
|
||||
const before = f.store.get('SELECT count(*) n FROM messages').n;
|
||||
await withLibraryLock(mirror, async () => {
|
||||
await assert.rejects(client.call('message.send', args), e => {
|
||||
assert.equal(e.code, 'citation-unavailable');
|
||||
assert.equal(e.reason, 'The library is busy or unavailable. Retry the answer when the library is available.');
|
||||
return true;
|
||||
});
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
|
||||
// A fixed fallback carries no library cite and must not need the contested lock.
|
||||
await client.call('message.send', { to: 'human', body: '(no answer passed the citation check)' });
|
||||
});
|
||||
await client.call('message.send', args); // No lock mutation or lost valid cite after release.
|
||||
});
|
||||
|
||||
test('C2-06 malformed sync state is availability failure, never a fabricated invalid cite', async t => {
|
||||
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const context = { business: f.business, project: 'books', dataRoot };
|
||||
await ensureMirror(context); const mirror = mirrorPath(context);
|
||||
writeFileSync(join(mirror, SYNC_STATE_FILE), '{broken', { mode: 0o600 });
|
||||
await assert.rejects(guardReply(context, citedBody([fixtureCite(f.commit)]), {
|
||||
limits: f.limits(), definition: 'coder', pages: [],
|
||||
}), { code: 'citation-unavailable' });
|
||||
});
|
||||
|
||||
test('C2-06 no mirror or freshly initialized empty mirror still means no served citation', async t => {
|
||||
const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const context = { business: f.business, project: 'books', dataRoot };
|
||||
const check = () => guardReply(context, citedBody([fixtureCite(f.commit)]), { limits: f.limits(), definition: 'coder', pages: [] });
|
||||
await assert.rejects(check(), { code: 'citation-invalid' });
|
||||
await ensureMirror(context); await assert.rejects(check(), { code: 'citation-invalid' });
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { connect } from 'node:net';
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
import { organizationWriter, setReplyLimits } from '../../business/src/organization.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { resolveReplyLimits } from '../../library/src/limits.mjs';
|
||||
import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs';
|
||||
|
||||
for (const definition of ['coder', 'research']) test(`C2-06 ${definition} send_message guard covers every table refusal; inserts nothing`, async t => {
|
||||
const f = citationWorld(t, { definition }); f.call(f.agent, 'role.claim');
|
||||
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
|
||||
for (const [code, body] of guardCases(f.commit)) {
|
||||
const count = f.store.get('SELECT count(*) n FROM messages').n;
|
||||
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body }), e => e.code === code && e.reason.length <= 300);
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, count, code);
|
||||
assert.equal(f.store.all("SELECT * FROM events WHERE json_extract(body,'$.action')='message.checked'").length, 0);
|
||||
}
|
||||
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
|
||||
const row = f.store.get("SELECT body,subject FROM events WHERE json_extract(body,'$.action')='message.checked'");
|
||||
assert.equal(row.subject, sent.id);
|
||||
assert.deepEqual(JSON.parse(row.body).check, { citations: [{ scope: 'canon', agent: null, file: f.commit }] });
|
||||
});
|
||||
|
||||
test('C2-06 stored check carries the file author trailer at an unrelated cited tip', async t => {
|
||||
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
|
||||
const path = 'Book One/Characters/Mara Vell.md';
|
||||
writeFileSync(join(f.repo, path), readFileSync(join(f.repo, path), 'utf8') + 'A fixture note.\n');
|
||||
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'Fixture note\n\nMosaic-Agent: acme/scribe');
|
||||
const file = git(f.repo, 'rev-parse', 'HEAD');
|
||||
writeFileSync(join(f.repo, 'Inbox Note.md'), 'Unrelated fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'unrelated');
|
||||
const cited = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(cited);
|
||||
assert.notEqual(cited, file);
|
||||
const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(cited)]) });
|
||||
const event = f.store.get("SELECT body FROM events WHERE subject=? AND json_extract(body,'$.action')='message.checked'", sent.id);
|
||||
assert.deepEqual(JSON.parse(event.body).check, { citations: [{ scope: 'canon', agent: 'acme/scribe', file }] });
|
||||
const [row] = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
|
||||
assert.deepEqual(row.check.citations, [{ scope: 'canon', agent: 'acme/scribe', file, last: file }]);
|
||||
});
|
||||
|
||||
test('C2-06 check evidence and insert are atomic when storing evidence fails', async t => {
|
||||
const f = citationWorld(t); f.call(f.agent, 'role.claim');
|
||||
f.store.run(`CREATE TRIGGER fixture_check_refused BEFORE INSERT ON events
|
||||
WHEN json_extract(NEW.body,'$.action')='message.checked' BEGIN SELECT RAISE(ABORT,'fixture check failure'); END`);
|
||||
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) }));
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, 0);
|
||||
});
|
||||
|
||||
test('C2-06 live limits change both directions; unreadable file keeps checks and exact fixed texts', async t => {
|
||||
const f = citationWorld(t); f.call(f.agent, 'role.claim');
|
||||
const send = body => f.call(f.agent, 'message.send', { to: 'human', body });
|
||||
await send('free');
|
||||
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } }));
|
||||
await assert.rejects(send('free'), { code: 'citation-missing' });
|
||||
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: false } })); await send('free');
|
||||
for (const invalid of ['{broken', 'null', '[]']) {
|
||||
writeFileSync(f.settings, invalid);
|
||||
const before = f.store.get('SELECT count(*) n FROM messages').n;
|
||||
await assert.rejects(send('free'), { code: 'citation-missing' });
|
||||
await assert.rejects(send(citedBody(Array(21).fill(fixtureCite(f.commit)))), { code: 'citations-over-limit' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before);
|
||||
for (const text of Object.values(FIXED_TEXTS)) await send(text);
|
||||
assert.equal((await f.call(f.human, 'library.status', { project: 'books' })).replyLimits.problem, 'reply-limits-unreadable');
|
||||
}
|
||||
rmSync(f.settings);
|
||||
await assert.rejects(send('free'), { code: 'citation-missing' });
|
||||
});
|
||||
|
||||
test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at disconnect', async t => {
|
||||
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
|
||||
const fetch = async (n, text = `page ${n}`) => f.broker.requestWeb(f.agent, { verb: 'web.fetch', args: { url: `https://fixture.invalid/${n}` } },
|
||||
{ handle: async () => ({ result: { text }, summary: { outcome: 'ok' } }) });
|
||||
const send = (n, quote) => f.call(f.agent, 'message.send', { to: 'human', body: citedBody([{ url: `https://fixture.invalid/${n}`, quote }]) });
|
||||
await fetch(0); await send(0, 'page 0');
|
||||
for (let n = 1; n <= 20; n++) await fetch(n);
|
||||
await assert.rejects(send(0, 'page 0'), { code: 'citation-unfetched' }); await send(1, 'page 1');
|
||||
await fetch(21, 'x'.repeat(200 * 1024) + 'AFTER_CAP');
|
||||
await send(21, 'xxxx'); await assert.rejects(send(21, 'AFTER_CAP'), { code: 'citation-unfetched' });
|
||||
f.broker.disconnect(f.agent);
|
||||
f.broker.endLaunch({ business: 'acme', run: 'coder-run', reason: 'fixture', exitCode: 0 });
|
||||
const replacement = f.broker.bindLaunch({ ...f.launch, run: 'next-run' });
|
||||
f.call(replacement, 'role.claim');
|
||||
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
|
||||
});
|
||||
|
||||
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
|
||||
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
|
||||
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const client = new Client({ path, cap: f.agent });
|
||||
await assert.rejects(client.call('message.send', { to: 'human', body: 'fixture' }), e => e.code === 'citation-invalid' && e.reason === ('guard reason '.repeat(50)).slice(0, 300));
|
||||
const raw = await new Promise((resolve, reject) => {
|
||||
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
|
||||
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
|
||||
});
|
||||
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
|
||||
});
|
||||
|
||||
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
|
||||
const f = citationWorld(t, { library: false });
|
||||
assert.deepEqual(f.call(f.agent, 'role.claim'), { role: 'coder', run: 'coder-run' });
|
||||
assert.ok(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }).id);
|
||||
});
|
||||
|
||||
test('C2-06 thread messages carry file/last evidence with one read per distinct path', async t => {
|
||||
const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim');
|
||||
const path = 'Book One/Characters/Mara Vell.md';
|
||||
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit), fixtureCite(f.commit)]) });
|
||||
await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(f.commit)]) });
|
||||
const rows = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
|
||||
assert.equal(rows.length, 2); assert.deepEqual(f.reads, [path]);
|
||||
for (const row of rows) for (const c of row.check.citations) {
|
||||
assert.equal(c.file, f.commit); assert.equal(c.last, f.commit); assert.equal(c.agent, null);
|
||||
}
|
||||
writeFileSync(join(f.repo, path), 'A changed fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'changed fixture');
|
||||
const changed = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(changed); f.reads.length = 0;
|
||||
const newer = await f.call(f.human, 'thread.messages', { thread: f.thread.id });
|
||||
assert.deepEqual(f.reads, [path]); assert.equal(newer[0].check.citations[0].file, f.commit); assert.equal(newer[0].check.citations[0].last, changed);
|
||||
});
|
||||
|
||||
test('C2-06 guarded request snapshot cannot change between check and insert', async t => {
|
||||
let finish;
|
||||
const f = citationWorld(t, { guard: () => new Promise(r => { finish = r; }) }); f.call(f.agent, 'role.claim');
|
||||
const request = { verb: 'message.send', args: { to: 'human', body: 'checked fixture' } };
|
||||
const sent = f.broker.request(f.agent, request); request.args.body = 'unchecked replacement';
|
||||
finish(null); await sent;
|
||||
assert.equal(f.store.get("SELECT body FROM messages WHERE from_role='coder'").body, 'checked fixture');
|
||||
});
|
||||
|
||||
test('C2-06 library.limits.set is human-only and invalid settings leave file and receipts unchanged', async t => {
|
||||
let writer;
|
||||
const f = citationWorld(t, { organization: (id, verb, args, actor) => {
|
||||
assert.equal(verb, 'library.limits.set'); return setReplyLimits(writer, id, args, actor);
|
||||
} });
|
||||
const dir = join(f.root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), businessDoc(f.root));
|
||||
writer = organizationWriter({ dir, rolesDir: rolesCopy(f.root) });
|
||||
f.adapter.replyLimits = () => resolveReplyLimits(writer.readRaw('acme'));
|
||||
f.call(f.agent, 'role.claim');
|
||||
await f.call(f.human, 'library.limits.set', { revision: writer.read('acme').revision, replyLimits: { requireCitation: true } });
|
||||
await assert.rejects(f.call(f.agent, 'message.send', { to: 'human', body: 'free' }), { code: 'citation-missing' });
|
||||
const before = readFileSync(file), log = readFileSync(writer.logFile('acme'));
|
||||
const args = { revision: writer.read('acme').revision, replyLimits: { citationsPerReply: 0 } };
|
||||
for (const [cap, code] of [[f.human, 'invalid-request'], [f.agent, 'human-required'], [f.tester, 'tester-refused']]) {
|
||||
assert.throws(() => f.call(cap, 'library.limits.set', args), { code });
|
||||
assert.deepEqual(readFileSync(file), before); assert.deepEqual(readFileSync(writer.logFile('acme')), log);
|
||||
}
|
||||
assert.throws(() => f.call(f.human, 'library.limits.set', { ...args, replyLimits: { invented: true } }), { code: 'invalid-request' });
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// C2-06: real broker, real Git reads and fixture-only library. Reused by runner acceptance tests.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { checkReply, GuardError, libraryReader } from '../../library/src/guard.mjs';
|
||||
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { resolveReplyLimits } from '../../library/src/limits.mjs';
|
||||
import { mapping, repo, MARKER } from '../../library/tests/helpers.mjs';
|
||||
|
||||
export const citedBody = citations => JSON.stringify({ text: 'A fixture answer.', citations });
|
||||
export const fixtureCite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
|
||||
|
||||
export function citationWorld(t, { definition = 'coder', library = true, guard: overrideGuard, organization = null, sendAuthority = true } = {}) {
|
||||
const r = repo(t), root = r.root, state = join(root, 'bus'); mkdirSync(state, { mode: 0o700 });
|
||||
const store = new Store(state); t.after(() => store.close());
|
||||
const map = mapping(), settings = join(root, 'business.json');
|
||||
writeFileSync(settings, JSON.stringify({ replyLimits: { requireCitation: false, citationsPerReply: 20 } }));
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
...(library ? { projects: { books: map } } : {}),
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition, web: true, authority: { withinRole: sendAuthority ? ['message.send'] : [], crossRole: [] } } } };
|
||||
const limits = () => {
|
||||
try { return resolveReplyLimits({ readable: true, document: JSON.parse(readFileSync(settings, 'utf8')) }); }
|
||||
catch { return resolveReplyLimits({ readable: false }); }
|
||||
};
|
||||
let served = r.commit;
|
||||
const reads = [];
|
||||
const adapter = {
|
||||
GuardError,
|
||||
replyLimits: limits,
|
||||
guard: overrideGuard ?? ((context, body, opts) => checkReply(body, {
|
||||
limits: opts.limits, mapping: map, library: libraryReader(r.repo, served, map),
|
||||
web: { allowed: ['companion', 'research'].includes(opts.definition), pages: opts.pages },
|
||||
})),
|
||||
last: async (context, paths) => {
|
||||
const out = new Map(); for (const path of new Set(paths)) { reads.push(path); out.set(path, await libraryReader(r.repo, served, map).last(path)); } return out;
|
||||
},
|
||||
readFile: async () => ({}), status: async () => ({ project: 'books', served }),
|
||||
};
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library: adapter, organization });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = THREAD_DEFINITIONS.includes(definition)
|
||||
? broker.request(human, { verb: 'project.create', args: { name: 'Citation fixture' } }) : null;
|
||||
const thread = project
|
||||
? broker.request(human, { verb: 'thread.create', args: { project: project.id, name: 'Fixture thread', instance: 'coder' } }) : null;
|
||||
const launch = { business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi', ...(thread ? { thread: thread.thread ?? thread.id } : {}) };
|
||||
const agent = broker.bindLaunch(launch);
|
||||
const tester = broker.bindTester({ business: 'acme' });
|
||||
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
|
||||
return { ...r, root, store, business, broker, agent, human, tester, settings, limits, call, adapter, reads, launch, thread, setServed: commit => { served = commit; } };
|
||||
}
|
||||
|
||||
export function guardCases(commit) {
|
||||
const good = fixtureCite(commit);
|
||||
return [
|
||||
['reply-invalid', JSON.stringify({ text: 'x', citations: [], invented: true })],
|
||||
['citation-missing', 'Uncited fixture answer.'],
|
||||
['citations-over-limit', citedBody(Array(21).fill(good))],
|
||||
['citation-invalid', citedBody([{ ...good, quote: 'wrong fixture quote' }])],
|
||||
['playground-cited-as-canon', citedBody([{ ...good, path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER, as: 'canon' }])],
|
||||
['citation-cross-universe', citedBody([good, { ...good, path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER }])],
|
||||
['citation-unfetched', citedBody([{ url: 'https://fixture.invalid/page', quote: 'no fetch' }])],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// E7: exercise startBroker's real live readRaw adapter, rather than replacing the library adapter.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { validateBusinessDocument } from '../../business/src/business.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
||||
|
||||
test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'citation-runtime-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const doc = businessDoc(root), rolesDir = rolesCopy(root), dir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
doc.projects.books = mappingEntry(); doc.replyLimits = { requireCitation: false, citationsPerReply: 3 };
|
||||
const file = writeJson(join(dir, 'businesses/acme.json'), doc);
|
||||
const business = validateBusinessDocument(doc, file, { rolesDir }); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir } });
|
||||
t.after(() => runtime.close());
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const status = () => runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
|
||||
assert.deepEqual((await status()).replyLimits, { requireCitation: false, citationsPerReply: 3, fallback: [], problem: null });
|
||||
doc.replyLimits = { requireCitation: true, citationsPerReply: 7 }; writeJson(file, doc);
|
||||
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 7, fallback: [], problem: null });
|
||||
writeFileSync(file, '{broken');
|
||||
assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 20, fallback: [], problem: 'reply-limits-unreadable' });
|
||||
rmSync(file);
|
||||
assert.equal((await status()).replyLimits.problem, 'reply-limits-unreadable');
|
||||
});
|
||||
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { join, dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
|
||||
import { requireId, sha256, canonicalJson } from "./util.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -80,6 +81,33 @@ function changes(old, next, path = "") {
|
||||
}];
|
||||
}
|
||||
|
||||
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
|
||||
// the business's human as the file stood before the write, and no agent or the tester under that
|
||||
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
|
||||
function replyLimitsFence(old, document, actor) {
|
||||
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
|
||||
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
|
||||
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
|
||||
// The reason names each changed key and its value in force (the brief's M11).
|
||||
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const before = old.replyLimits, after = document?.replyLimits;
|
||||
const named = plain(before) && plain(after)
|
||||
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
|
||||
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
|
||||
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
|
||||
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
|
||||
}
|
||||
|
||||
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
|
||||
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
|
||||
// M11 fence refuses any actor but the human.
|
||||
export function setReplyLimits(writer, id, args, actor) {
|
||||
const replyLimits = checkReplyLimits(args?.replyLimits);
|
||||
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
|
||||
const { document } = writer.read(id);
|
||||
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const evidence = join(dir, "businesses", ".history");
|
||||
const logFile = id => join(evidence, id, "changes.jsonl");
|
||||
@@ -93,6 +121,19 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
return { document, revision: sha256(bytes) };
|
||||
}
|
||||
|
||||
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
|
||||
// file that fails validation still yields its limits. { readable: false } when the file is missing,
|
||||
// unreadable, not JSON or not an object.
|
||||
function readRaw(id) {
|
||||
try {
|
||||
const document = JSON.parse(regular(businessFilePath(id, dir)));
|
||||
return document !== null && typeof document === "object" && !Array.isArray(document)
|
||||
? { readable: true, document } : { readable: false };
|
||||
} catch {
|
||||
return { readable: false };
|
||||
}
|
||||
}
|
||||
|
||||
function withLock(id, fn) {
|
||||
const parent = join(dir, "businesses");
|
||||
directory(evidence);
|
||||
@@ -191,6 +232,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
// mapping, including library playgrounds and hybrid universes.
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
@@ -198,6 +240,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
const written = JSON.parse(bytes);
|
||||
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
|
||||
fail('organization-projects-protected');
|
||||
replyLimitsFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir });
|
||||
const after = sha256(bytes);
|
||||
const transaction = randomUUID();
|
||||
@@ -245,5 +288,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { organizationWriter, setReplyLimits } from '../src/organization.mjs';
|
||||
import { validateBusinessDocument } from '../src/business.mjs';
|
||||
import { businessDoc, rolesCopy, tmp, writeJson } from './helpers.mjs';
|
||||
|
||||
function fixture(t, human = 'jason') {
|
||||
const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const doc = businessDoc(root); doc.human = human; doc.replyLimits = { requireCitation: false, citationsPerReply: 20 };
|
||||
const dir = join(root, 'config'), file = writeJson(join(dir, 'businesses/acme.json'), doc), rolesDir = rolesCopy(root);
|
||||
const writer = organizationWriter({ dir, rolesDir });
|
||||
const next = structuredClone(doc); next.vars['tracker.pollSeconds'] = 61;
|
||||
writer.write('acme', next, { revision: writer.read('acme').revision, actor: human });
|
||||
return { root, file, writer, rolesDir, log: writer.logFile('acme') };
|
||||
}
|
||||
|
||||
function refused(f, actor, change) {
|
||||
const before = readFileSync(f.file), log = readFileSync(f.log), { document, revision } = f.writer.read('acme');
|
||||
change(document);
|
||||
assert.throws(() => f.writer.write('acme', document, { revision, actor }), { code: 'organization-reply-limits-human-only' });
|
||||
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
|
||||
}
|
||||
|
||||
test('C2-06 M11 human replacement works; omitted settings return to defaults', t => {
|
||||
const f = fixture(t);
|
||||
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: { requireCitation: true } }, 'jason');
|
||||
assert.deepEqual(f.writer.read('acme').document.replyLimits, { requireCitation: true });
|
||||
setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits: {} }, 'jason');
|
||||
assert.deepEqual(f.writer.read('acme').document.replyLimits, {});
|
||||
});
|
||||
|
||||
test('C2-06 M11 agents and tester cannot change settings, including by changing human', t => {
|
||||
const f = fixture(t);
|
||||
for (const actor of ['chief-of-staff.chief', 'tester', 'coder.coder'])
|
||||
refused(f, actor, d => { d.replyLimits.requireCitation = true; });
|
||||
refused(f, 'chief-of-staff.chief', d => { d.human = 'chief-of-staff.chief'; d.replyLimits.requireCitation = true; });
|
||||
const dotted = fixture(t, 'coder.coder');
|
||||
refused(dotted, 'coder.coder', d => { d.replyLimits.citationsPerReply = 1; });
|
||||
});
|
||||
|
||||
test('C2-06 M11 serialization cannot replace settings after the first fence', t => {
|
||||
const f = fixture(t);
|
||||
refused(f, 'chief-of-staff.chief', d => {
|
||||
Object.defineProperty(d.replyLimits, 'toJSON', { value: () => ({ requireCitation: true }) });
|
||||
});
|
||||
});
|
||||
|
||||
// E1: unlike tester/role actors, mallory passes the other actor-shape exclusions.
|
||||
// Comparing document.human instead of old.human must fail this case.
|
||||
test('C2-06 M11 an unrelated actor cannot appoint itself human and change reply limits', t => {
|
||||
const f = fixture(t);
|
||||
refused(f, 'mallory', d => { d.human = 'mallory'; d.replyLimits.requireCitation = true; });
|
||||
});
|
||||
|
||||
test('C2-06 invalid setter values preserve the business and changes.jsonl', t => {
|
||||
const f = fixture(t), before = readFileSync(f.file), log = readFileSync(f.log);
|
||||
for (const replyLimits of [{ citationsPerReply: 0 }, { citationsPerReply: 1001 }, { requireCitation: 'true' }, { invented: true }, []]) {
|
||||
assert.throws(() => setReplyLimits(f.writer, 'acme', { revision: f.writer.read('acme').revision, replyLimits }, 'jason'), { code: 'invalid-request' });
|
||||
assert.deepEqual(readFileSync(f.file), before); assert.deepEqual(readFileSync(f.log), log);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-06 validator accepts hand-edited settings; raw reader fails closed on invalid files', t => {
|
||||
const f = fixture(t), doc = f.writer.read('acme').document;
|
||||
for (const replyLimits of ['true', 1, null, [true], {}, { requireCitation: 'true' }, { citationsPerReply: 0 }]) {
|
||||
const next = { ...doc, replyLimits };
|
||||
assert.doesNotThrow(() => validateBusinessDocument(next, f.file, { rolesDir: f.rolesDir }));
|
||||
writeJson(f.file, next); assert.deepEqual(f.writer.readRaw('acme').document.replyLimits, replyLimits);
|
||||
}
|
||||
for (const bad of ['{broken', 'null', '[]', '1']) {
|
||||
writeFileSync(f.file, bad); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
|
||||
}
|
||||
rmSync(f.file); assert.deepEqual(f.writer.readRaw('acme'), { readable: false });
|
||||
});
|
||||
@@ -959,6 +959,12 @@ writer adds `since`, the time the limit was set, and keeps it on later saves.
|
||||
A patch that sends a different `since` is refused. Only these paths are token
|
||||
limits; the agent limits above are never read as one, whatever their names.
|
||||
|
||||
`mosaic limits replies --file LIMITS [--business ID]` replaces the citation settings
|
||||
through the human-only `library.limits.set` verb. LIMITS is a JSON object with optional
|
||||
`requireCitation` (boolean, default false) and `citationsPerReply` (integer 1–1000,
|
||||
default 20). Omitted keys use their defaults; unknown keys and invalid values refuse.
|
||||
The next library reply uses the new settings without restarting the bus.
|
||||
|
||||
`mosaic limits reset WORD... [--business ID]` moves one token limit's `since`
|
||||
to now, so its count restarts. The path is one word per level, as the plain
|
||||
output prints it: `mosaic limits reset tokens models anthropic claude-opus-5-5`.
|
||||
|
||||
@@ -7,9 +7,10 @@ import { CliError } from "./errors.mjs";
|
||||
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
|
||||
|
||||
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
|
||||
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
|
||||
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants";
|
||||
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
|
||||
|
||||
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
|
||||
|
||||
@@ -67,8 +68,9 @@ export async function limitsCommand(args, io, callFor) {
|
||||
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
|
||||
return;
|
||||
}
|
||||
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
|
||||
const fileVerb = verb === "set" || verb === "replies";
|
||||
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
||||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
|
||||
(verb === "reset" && parsed.positional.length < 2)) {
|
||||
throw new CliError(LIMITS_USAGE, 4);
|
||||
}
|
||||
@@ -78,6 +80,11 @@ export async function limitsCommand(args, io, callFor) {
|
||||
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
|
||||
let result;
|
||||
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
|
||||
else if (verb === "replies") {
|
||||
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("library.limits.set", { revision, replyLimits });
|
||||
}
|
||||
else if (verb === "reset") {
|
||||
const { revision } = await call("limits", {});
|
||||
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { limitsCommand } from '../src/limits.mjs';
|
||||
|
||||
test('C2-06 CLI replies reads revision and sends only the JSON settings to the human verb', async t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'reply-cli-')); t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'settings.json'); writeFileSync(file, JSON.stringify({ requireCitation: true, citationsPerReply: 7 }));
|
||||
const calls = [], output = [];
|
||||
await limitsCommand(['replies', '--file', file, '--business', 'acme'], { stdout: { write: s => output.push(s) } }, parsed => {
|
||||
assert.equal(parsed.values['--business'], 'acme');
|
||||
return async (verb, args) => { calls.push([verb, args]); return verb === 'limits' ? { revision: 'r1' } : { revision: 'r2' }; };
|
||||
});
|
||||
assert.deepEqual(calls, [['limits', {}], ['library.limits.set', { revision: 'r1', replyLimits: { requireCitation: true, citationsPerReply: 7 } }]]);
|
||||
assert.match(output.join(''), /r2/);
|
||||
});
|
||||
|
||||
test('C2-06 CLI replies requires a file and refuses tester writes before a bus call', async () => {
|
||||
for (const args of [['replies'], ['replies', '--tester', '--file', 'unused'], ['replies', '--file', 'unused', '--models', 'unused']])
|
||||
await assert.rejects(limitsCommand(args, { stdout: { write() {} } }, () => { throw Error('bus touched'); }), { exitCode: 4 });
|
||||
});
|
||||
@@ -38,6 +38,7 @@ import { createServer, connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { GUARD_CODES, FIXED_TEXTS } from "../../library/src/reply-protocol.mjs";
|
||||
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
|
||||
|
||||
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
|
||||
@@ -458,8 +459,9 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
return EXIT.founder;
|
||||
}
|
||||
const client = new Client({ path: session.brokerSocket, cap, timeout: 30_000 });
|
||||
let library = false;
|
||||
try {
|
||||
await client.call("role.claim");
|
||||
library = (await client.call("role.claim"))?.library === true;
|
||||
} catch (e) {
|
||||
// No answer is not a refusal: the broker is down or the outcome is unknown.
|
||||
if (e.code === "outcome-unknown") {
|
||||
@@ -582,9 +584,60 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
|
||||
if (!turn.ok) log(`runner: turn ${n} for message ${m.id} failed: ${turn.reason}`);
|
||||
try {
|
||||
if (m.class !== "RESULT") {
|
||||
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
|
||||
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
|
||||
unslop.sent();
|
||||
if (!library) {
|
||||
const body = turn.ok ? turn.text || "(the session gave no answer)" : `The turn failed: ${turn.reason}`;
|
||||
await client.call("message.send", { to: m.from_role, body: clip(body) + unslop.note(), class: "RESULT", in_reply_to: m.id });
|
||||
unslop.sent();
|
||||
} else {
|
||||
const send = async (answer) => {
|
||||
const raw = answer.ok ? answer.text || FIXED_TEXTS.empty : FIXED_TEXTS.failed;
|
||||
const fixed = Object.values(FIXED_TEXTS).includes(raw);
|
||||
let body = raw;
|
||||
if (!fixed) {
|
||||
let value;
|
||||
try { value = JSON.parse(raw); } catch {}
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||||
// Malformed objects still reach the guard and its reply-invalid refusal.
|
||||
if (typeof value.text === "string") value.text += unslop.note();
|
||||
body = JSON.stringify(value);
|
||||
} else body = clip(raw) + unslop.note();
|
||||
}
|
||||
try {
|
||||
await client.call("message.send", { to: m.from_role, body, class: "RESULT", in_reply_to: m.id });
|
||||
if (!fixed) unslop.sent();
|
||||
} catch (error) {
|
||||
// The existing broker body cap is smaller than the socket's 64 KiB cap.
|
||||
// Both follow the one-retry rule, without changing non-library sends.
|
||||
if (error.code === "invalid-request" && body.length > 32768) {
|
||||
error = Object.assign(new Error("request-too-large"), { code: "request-too-large" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
try { await send(turn); }
|
||||
catch (error) {
|
||||
if (!GUARD_CODES.includes(error.code) && error.code !== "request-too-large") throw error;
|
||||
log(`runner: reply to ${m.id} refused: ${error.code}${error.reason ? `: ${error.reason}` : ""}`);
|
||||
const reason = error.code === "request-too-large"
|
||||
? "The message exceeds the 64 KiB request limit or the 32768-character body limit. Send a shorter answer."
|
||||
: error.reason ?? "The reply did not pass the citation check.";
|
||||
++n;
|
||||
try { await client.call("usage.begin", { turn: `${m.id}:retry` }); }
|
||||
catch (e) { log(`runner: usage baseline unavailable: ${e.code ?? "failed"}`); }
|
||||
const retry = await runTurn({ session, request: `${block ?? ""}\n${turnRequest(m)}\n\nYour answer was refused with ${error.code}: ${reason}\nReply again in this same session.`, n, env: turnEnv, onChild: (c) => (child = c) });
|
||||
unslop.read();
|
||||
try { await client.call("usage.end", { turn: `${m.id}:retry` }); }
|
||||
catch (e) { log(`runner: usage outcome unavailable: ${e.code ?? "failed"}`); }
|
||||
if (stopping) break loop;
|
||||
if (!retry.ok) log(`runner: retry turn ${n} for message ${m.id} failed: ${retry.reason}`);
|
||||
try { await send(retry); }
|
||||
catch (again) {
|
||||
if (!GUARD_CODES.includes(again.code) && again.code !== "request-too-large") throw again;
|
||||
log(`runner: retry to ${m.id} refused: ${again.code}`);
|
||||
await send({ ok: true, text: again.code === "request-too-large" ? FIXED_TEXTS.tooLong : FIXED_TEXTS.uncited });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await client.call("message.read", { id: m.id });
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { serve } from '../../bus/src/server.mjs';
|
||||
import { FIXED_TEXTS } from '../../library/src/reply-protocol.mjs';
|
||||
import { GuardError } from '../../library/src/guard.mjs';
|
||||
import { citationWorld, citedBody, fixtureCite, guardCases } from '../../bus/tests/citation-helpers.mjs';
|
||||
|
||||
const RUNNER = new URL('../src/runner.mjs', import.meta.url).pathname;
|
||||
const ADAPTER = new URL('./fixtures/citation-adapter.mjs', import.meta.url).pathname;
|
||||
async function until(fn) {
|
||||
const stop = Date.now() + 15000;
|
||||
while (Date.now() < stop) { const value = fn(); if (value) return value; await new Promise(r => setTimeout(r, 15)); }
|
||||
throw Error('fixture timed out');
|
||||
}
|
||||
|
||||
async function setup(t, stepsFor, { library = true, requireCitation = true, sendAuthority = true, guard } = {}) {
|
||||
const f = citationWorld(t, { library, sendAuthority, guard }), runDir = join(f.root, 'run'), bundle = join(runDir, 'bundle');
|
||||
mkdirSync(bundle, { recursive: true }); mkdirSync(join(f.root, 'ws'));
|
||||
const steps = stepsFor(f.commit), brokerSocket = join(f.root, 'broker.sock');
|
||||
writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation } }));
|
||||
const server = await serve({ broker: f.broker, path: brokerSocket });
|
||||
writeFileSync(join(runDir, 'fixture-steps.json'), JSON.stringify(steps));
|
||||
const adapter = join(runDir, 'adapter.sh'); writeFileSync(adapter, `exec '${process.execPath}' '${ADAPTER}'\n`);
|
||||
writeFileSync(join(bundle, 'prompt.md'), 'Fixture only.\n');
|
||||
writeFileSync(join(bundle, 'tools.json'), JSON.stringify([{ name: 'send_message', verb: 'message.send', action: 'message.send' }]));
|
||||
writeFileSync(join(bundle, 'policy.json'), JSON.stringify({ needs: [], credentials: [] }));
|
||||
writeFileSync(join(runDir, 'session.json'), JSON.stringify({ business: 'acme', instance: 'coder', run: 'coder-run', harness: 'pi',
|
||||
provider: 'fixture', model: 'fixture', brokerSocket, workspace: join(f.root, 'ws'), sessionDir: join(runDir, 'session'), adapter,
|
||||
bundle: { prompt: join(bundle, 'prompt.md'), tools: join(bundle, 'tools.json'), policy: join(bundle, 'policy.json') },
|
||||
toolSocket: join(runDir, 'tools.sock'), turnMarker: join(runDir, 'turn.done'), turnEnd: 'marker', harnessEnv: {}, pollInterval: 20, turnTimeout: 10 }));
|
||||
const child = spawn(process.execPath, [RUNNER, runDir], { stdio: ['pipe', 'ignore', 'pipe'], env: { PATH: process.env.PATH } });
|
||||
let log = ''; child.stderr.on('data', b => { log += b; }); child.stdin.on('error', () => {}); child.stdin.end(JSON.stringify({ cap: f.agent }) + '\n');
|
||||
const exited = once(child, 'exit');
|
||||
t.after(async () => { child.kill('SIGTERM'); await exited; await server.close(); });
|
||||
await until(() => f.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
|
||||
const ask = async () => {
|
||||
const sent = await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
|
||||
const reply = await until(() => f.store.get('SELECT * FROM messages WHERE in_reply_to=?', sent.id));
|
||||
await until(() => f.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
|
||||
return reply;
|
||||
};
|
||||
return { ...f, runDir, ask, log: () => log, count: () => Number(readFileSync(join(runDir, 'fixture-count'), 'utf8')) };
|
||||
}
|
||||
|
||||
for (const code of ['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid', 'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']) {
|
||||
test(`C2-06 RESULT ${code} retries once, delivers only the valid answer and names reason`, async t => {
|
||||
const f = await setup(t, commit => [{ text: guardCases(commit).find(c => c[0] === code)[1] }, { text: citedBody([fixtureCite(commit)]) }]);
|
||||
const reply = await f.ask(); assert.equal(reply.body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
|
||||
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.match(requests[1], new RegExp(code)); assert.match(requests[1], /refused with/);
|
||||
assert.match(f.log(), new RegExp(code));
|
||||
});
|
||||
}
|
||||
|
||||
test('C2-06 RESULT two guard refusals use the citation fixed text and exactly one retry', async t => {
|
||||
const f = await setup(t, () => [{ text: 'uncited' }, { text: 'uncited again' }]);
|
||||
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
|
||||
});
|
||||
|
||||
for (const size of [34000, 70000]) {
|
||||
test(`C2-06 RESULT JSON ${size} characters retries with a shorter valid answer`, async t => {
|
||||
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(size), citations: [fixtureCite(commit)] }) }, { text: citedBody([fixtureCite(commit)]) }]);
|
||||
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
|
||||
assert.match(readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8'), /64 KiB/);
|
||||
});
|
||||
}
|
||||
|
||||
test('C2-06 RESULT two oversized JSON bodies use the length fixed text with citations required', async t => {
|
||||
const f = await setup(t, commit => Array(2).fill({ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }));
|
||||
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
|
||||
});
|
||||
|
||||
test('C2-06 multibyte plain bodies also receive the one request-size retry', async t => {
|
||||
const f = await setup(t, commit => [{ text: '界'.repeat(32000) }, { text: citedBody([fixtureCite(commit)]) }]);
|
||||
assert.equal((await f.ask()).body, citedBody([fixtureCite(f.commit)])); assert.equal(f.count(), 2);
|
||||
});
|
||||
|
||||
test('C2-06 two multibyte plain bodies use the length fallback', async t => {
|
||||
const f = await setup(t, () => Array(2).fill({ text: '界'.repeat(32000) }));
|
||||
assert.equal((await f.ask()).body, FIXED_TEXTS.tooLong); assert.equal(f.count(), 2);
|
||||
});
|
||||
|
||||
test('C2-06 RESULT a length refusal then citation refusal shares one retry and citation fallback', async t => {
|
||||
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(70000), citations: [fixtureCite(commit)] }) }, { text: 'uncited' }]);
|
||||
assert.equal((await f.ask()).body, FIXED_TEXTS.uncited); assert.equal(f.count(), 2);
|
||||
});
|
||||
|
||||
test('C2-06 fixed empty/failed replies defer unslop note until the next cited reply', async t => {
|
||||
const f = await setup(t, commit => [{ text: '', unslop: true }, { fail: true }, { text: citedBody([fixtureCite(commit)]) }]);
|
||||
assert.equal((await f.ask()).body, FIXED_TEXTS.empty); assert.equal((await f.ask()).body, FIXED_TEXTS.failed);
|
||||
const reply = JSON.parse((await f.ask()).body);
|
||||
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 3);
|
||||
});
|
||||
|
||||
test('C2-06 cited JSON above the old 32000 clip retains its citations and text', async t => {
|
||||
const f = await setup(t, commit => [{ text: JSON.stringify({ text: 'x'.repeat(32200), citations: [fixtureCite(commit)] }) }]);
|
||||
const reply = JSON.parse((await f.ask()).body); assert.equal(reply.text.length, 32200); assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.equal(f.count(), 1);
|
||||
});
|
||||
|
||||
test('C2-06 a flagged cited answer keeps the note inside text and citations whole', async t => {
|
||||
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), unslop: true }]);
|
||||
const reply = JSON.parse((await f.ask()).body);
|
||||
assert.deepEqual(reply.citations, [fixtureCite(f.commit)]); assert.match(reply.text, /unslop/i); assert.equal(f.count(), 1);
|
||||
});
|
||||
|
||||
test('C2-06 send_message typed tool runs every guard code before the valid RESULT', async t => {
|
||||
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]), tools: guardCases(commit).map(([, body]) => ({ to: 'human', body })) }]);
|
||||
await f.ask(); const results = readFileSync(join(f.runDir, 'fixture-tools.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(results.length, 7); results.forEach((r, i) => { assert.equal(r.ok, false); assert.match(r.error, new RegExp(guardCases(f.commit)[i][0])); });
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 1);
|
||||
});
|
||||
|
||||
test('C2-06 outside library the old failed text, clip and unslop behavior hold', async t => {
|
||||
const f = await setup(t, () => [{ fail: true }, { text: 'x'.repeat(40000), unslop: true }], { library: false });
|
||||
assert.match((await f.ask()).body, /^The turn failed:/);
|
||||
const reply = (await f.ask()).body; assert.match(reply, /answer cut at 32000 characters/); assert.match(reply, /unslop/i); assert.equal(f.count(), 2);
|
||||
});
|
||||
|
||||
test('C2-06 outside library a refused send keeps the existing no-retry behavior', async t => {
|
||||
const f = await setup(t, () => [{ text: 'answer without send authority' }], { library: false, sendAuthority: false });
|
||||
await f.call(f.human, 'message.send', { to: 'coder', body: 'Fixture question.', class: 'REQUEST' });
|
||||
await until(() => /runner: reply to .* failed:/.test(f.log()));
|
||||
assert.equal(f.count(), 1); assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 0);
|
||||
assert.equal(existsSync(join(f.runDir, 'fixture-requests.jsonl')), true);
|
||||
});
|
||||
|
||||
|
||||
// E4: the agent must receive the availability reason in its actual retry turn,
|
||||
// rather than being told only that the source citation was invalid.
|
||||
test('C2-06 the retry turn delivers citation-unavailable reason to the agent', async t => {
|
||||
let sends = 0;
|
||||
const reason = 'The library is busy or unavailable. Retry the answer when the library is available.';
|
||||
const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]) }, { text: citedBody([fixtureCite(commit)]) }], {
|
||||
guard: async () => { if (sends++ === 0) throw new GuardError('citation-unavailable', reason); return null; },
|
||||
});
|
||||
await f.ask(); assert.equal(f.count(), 2);
|
||||
const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(requests[1].includes(`citation-unavailable: ${reason}`), true);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// A fixture-only scripted harness. No provider or real credential is used.
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { callTool } from '../../src/tools.mjs';
|
||||
|
||||
const root = dirname(process.env.MOSAIC_TURN_MARKER), countFile = join(root, 'fixture-count');
|
||||
const n = existsSync(countFile) ? Number(readFileSync(countFile, 'utf8')) : 0;
|
||||
const steps = JSON.parse(readFileSync(join(root, 'fixture-steps.json'), 'utf8'));
|
||||
const step = steps[n] ?? { fail: true };
|
||||
writeFileSync(countFile, String(n + 1));
|
||||
appendFileSync(join(root, 'fixture-requests.jsonl'), JSON.stringify(process.env.MOSAIC_REQUEST) + '\n');
|
||||
if (step.unslop) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ev: 'flagged', findings: [{ source: 'fixture', id: 'fixture' }] }) + '\n');
|
||||
for (const args of step.tools ?? []) {
|
||||
let result;
|
||||
try { result = { ok: true, result: await callTool(process.env.MOSAIC_TOOL_SOCKET, 'send_message', args) }; }
|
||||
catch (e) { result = { ok: false, error: e.message }; }
|
||||
appendFileSync(join(root, 'fixture-tools.jsonl'), JSON.stringify(result) + '\n');
|
||||
}
|
||||
if (step.fail) process.exit(3);
|
||||
process.stdout.write(step.text ?? '');
|
||||
writeFileSync(process.env.MOSAIC_TURN_MARKER, 'done\n');
|
||||
+10
-17
@@ -104,28 +104,21 @@ 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).
|
||||
|
||||
## renderReply
|
||||
## renderItems
|
||||
|
||||
`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:
|
||||
`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.
|
||||
|
||||
```
|
||||
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"
|
||||
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)
|
||||
```
|
||||
|
||||
The broker's write notice, `{ "notice": "library-write", op, path, commit }`, gives the line
|
||||
`<name> wrote <path> @<commit>` or `<name> deleted <path> @<commit>`.
|
||||
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.
|
||||
|
||||
## Survey
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it
|
||||
// before it stores an agent's message to the human in a business with a library project. It reads
|
||||
// only the citations, never what the text says. Codes are design 5.2's table.
|
||||
import { git } from '../../project/src/git.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { mirrorPath, readSyncState } from './mirror.mjs';
|
||||
import { plain } from './limits.mjs';
|
||||
|
||||
import { FIXED_TEXTS } from './reply-protocol.mjs';
|
||||
export { GUARD_CODES, FIXED_TEXTS } from './reply-protocol.mjs';
|
||||
|
||||
// Web cites are for these definitions only (design 5.2).
|
||||
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
|
||||
|
||||
// The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the
|
||||
// limit, never a quote or file text, and at most REASON_MAX characters.
|
||||
export const REASON_MAX = 300;
|
||||
export class GuardError extends LibraryError {
|
||||
constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; }
|
||||
}
|
||||
const refuse = (code, reason) => { throw new GuardError(code, reason); };
|
||||
|
||||
const SHA = /^[a-f0-9]{40}$/;
|
||||
|
||||
const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote'];
|
||||
const WEB_CITE = ['url', 'quote'];
|
||||
const only = (value, required, optional = []) => plain(value)
|
||||
&& required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k));
|
||||
const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0');
|
||||
|
||||
function libraryCite(c) {
|
||||
return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit)
|
||||
&& Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1]
|
||||
&& text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon');
|
||||
}
|
||||
const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote);
|
||||
|
||||
// A body is free text, or a JSON object {text, citations} that must fit the schema. Returns
|
||||
// { text, citations, cited } where `cited` says the body was the JSON form.
|
||||
export function parseReply(body) {
|
||||
let value;
|
||||
try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; }
|
||||
if (!plain(value)) return { text: body, citations: [], cited: false };
|
||||
if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations))
|
||||
refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list');
|
||||
value.citations.forEach((c, i) => {
|
||||
if (!libraryCite(c) && !webCite(c))
|
||||
refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`);
|
||||
});
|
||||
return { text: value.text, citations: value.citations, cited: true };
|
||||
}
|
||||
|
||||
const squash = value => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads
|
||||
// git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether
|
||||
// its definition may cite a url, and the session's kept fetches. Returns the stored check result,
|
||||
// { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and
|
||||
// morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a
|
||||
// plain body. Throws GuardError.
|
||||
export async function checkReply(body, { limits, mapping, library, web }) {
|
||||
if (Object.values(FIXED_TEXTS).includes(body)) return null;
|
||||
const reply = parseReply(body);
|
||||
const { citations } = reply;
|
||||
if (limits.requireCitation && citations.length === 0)
|
||||
refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation');
|
||||
if (citations.length > limits.citationsPerReply)
|
||||
refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`);
|
||||
if (!reply.cited) return null;
|
||||
const found = [];
|
||||
for (const [i, c] of citations.entries()) {
|
||||
if (!Object.hasOwn(c, 'path')) { found.push(null); continue; }
|
||||
const at = `citation ${i + 1} (${c.path})`;
|
||||
const where = classify(mapping, c.path);
|
||||
if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`);
|
||||
const lines = await library.lines(c.commit, c.path);
|
||||
if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`);
|
||||
if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`);
|
||||
const quote = squash(c.quote);
|
||||
if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote))
|
||||
refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`);
|
||||
found.push(where);
|
||||
}
|
||||
citations.forEach((c, i) => {
|
||||
if (c.as === 'canon' && found[i].scope !== 'canon')
|
||||
refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`);
|
||||
});
|
||||
const universes = [...new Set(found.filter(Boolean).map(w => w.universe))];
|
||||
if (universes.length > 1) {
|
||||
const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid');
|
||||
const sources = hybrid ? mapping.universes[hybrid].sources : [];
|
||||
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
|
||||
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
|
||||
}
|
||||
citations.forEach((c, i) => {
|
||||
if (!Object.hasOwn(c, 'url')) return;
|
||||
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
|
||||
const quote = squash(c.quote);
|
||||
const pages = web.pages.filter(p => p.url === c.url || p.finalUrl === c.url);
|
||||
if (!pages.length) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
|
||||
if (!quote || !pages.some(page => squash(page.text).includes(quote))) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
|
||||
});
|
||||
const out = [];
|
||||
for (const [i, c] of citations.entries())
|
||||
out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null });
|
||||
return { citations: out };
|
||||
}
|
||||
|
||||
// Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the
|
||||
// commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4:
|
||||
// readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per
|
||||
// commit for the one check.
|
||||
export function libraryReader(mirror, served, mapping) {
|
||||
const indexed = new Map(), reach = new Map();
|
||||
const once = (cache, key, read) => {
|
||||
if (!cache.has(key)) cache.set(key, read().catch(() => null));
|
||||
return cache.get(key);
|
||||
};
|
||||
const reachable = commit => once(reach, commit, async () => {
|
||||
if (!mirror || !served) return false;
|
||||
if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false;
|
||||
await git(mirror, ['merge-base', '--is-ancestor', commit, served]);
|
||||
return true;
|
||||
});
|
||||
const index = commit => once(indexed, commit, async () => {
|
||||
const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]);
|
||||
return { files: new Set(read.files.map(file => file.path)), entries };
|
||||
});
|
||||
return {
|
||||
async lines(commit, path) {
|
||||
if (!await reachable(commit)) return null;
|
||||
const at = await index(commit);
|
||||
if (!at?.files.has(path)) return null;
|
||||
const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map());
|
||||
return texts.has(path) ? splitLines(texts.get(path)) : null;
|
||||
},
|
||||
// One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the
|
||||
// served tip's, as the cited library commit needn't touch the file) and that commit's trailer.
|
||||
async origin(commit, path) {
|
||||
if (!mirror) return { agent: null, file: null };
|
||||
try {
|
||||
const [file, trailer = ''] = (await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
|
||||
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
|
||||
} catch { return { agent: null, file: null }; }
|
||||
},
|
||||
// The file's last commit at the served tip, for thread.messages (sage-3595), or null.
|
||||
async last(path) {
|
||||
if (!mirror || !served) return null;
|
||||
try {
|
||||
const value = await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H', served, '--', path]);
|
||||
return SHA.test(value) ? value : null;
|
||||
} catch { return null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per
|
||||
// distinct path. A path maps to null when there is no served commit or the read fails.
|
||||
export async function lastCommits(context, paths) {
|
||||
const { mirror, served } = await servedMirror(context);
|
||||
const reader = libraryReader(mirror, served, null);
|
||||
const out = new Map();
|
||||
for (const path of new Set(paths)) out.set(path, await reader.last(path));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The library project's bare mirror and its served commit. Either is null when there is none yet, so
|
||||
// a plain reply still passes and any library cite refuses citation-invalid.
|
||||
async function servedMirror(context) {
|
||||
const mapping = context.business.projects[context.project];
|
||||
let mirror = null, served = null;
|
||||
try {
|
||||
mirror = mirrorPath(context);
|
||||
served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null;
|
||||
} catch { served = null; }
|
||||
return { mapping, mirror, served };
|
||||
}
|
||||
|
||||
// The broker's entry: the library project's mirror at its served commit, then checkReply. `context`
|
||||
// is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only
|
||||
// whether a url cite may pass; `pages` is the broker's page store for the sender's session.
|
||||
export async function guardReply(context, body, { limits, definition, pages }) {
|
||||
const { mapping, mirror, served } = await servedMirror(context);
|
||||
const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages };
|
||||
return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web });
|
||||
}
|
||||
@@ -4,7 +4,9 @@ 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 { renderReply, SCOPES, NOTICE_OPS } from './render.mjs';
|
||||
export { renderItems, KINDS, NOTICES, UNREADABLE } 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';
|
||||
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
|
||||
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
|
||||
@@ -0,0 +1,41 @@
|
||||
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
|
||||
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
|
||||
|
||||
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
|
||||
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
|
||||
export const REPLY_LIMITS = Object.freeze({
|
||||
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
|
||||
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
|
||||
});
|
||||
|
||||
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
|
||||
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
|
||||
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
|
||||
export function resolveReplyLimits(read) {
|
||||
const limits = {}, fallback = [];
|
||||
const unreadable = !read?.readable || !plain(read.document);
|
||||
const raw = unreadable ? undefined : read.document.replyLimits;
|
||||
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
|
||||
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
|
||||
else {
|
||||
limits[key] = value;
|
||||
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
|
||||
// outside the table. A key left out is simply its default.
|
||||
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
|
||||
}
|
||||
}
|
||||
if (unreadable) limits.requireCitation = true;
|
||||
return { ...limits, fallback, unreadable };
|
||||
}
|
||||
|
||||
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
|
||||
// unknown key or a value outside the table refuses. Returns the object to write.
|
||||
export function checkReplyLimits(value) {
|
||||
if (!plain(value)) return null;
|
||||
for (const [key, v] of Object.entries(value))
|
||||
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
@@ -1,72 +1,59 @@
|
||||
// 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' });
|
||||
// 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.';
|
||||
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;
|
||||
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; }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
function cite(value, { scopeOf, changed }) {
|
||||
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;
|
||||
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 (!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' };
|
||||
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 quoted = text => JSON.stringify(text);
|
||||
|
||||
// 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);
|
||||
// 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 };
|
||||
let value = body;
|
||||
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') };
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// C2-06: shared pure protocol constants for the host guard and sandboxed runner.
|
||||
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
|
||||
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
|
||||
|
||||
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
|
||||
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
|
||||
export const FIXED_TEXTS = Object.freeze({
|
||||
empty: '(the session gave no answer)',
|
||||
failed: '(the turn failed)',
|
||||
uncited: '(no answer passed the citation check)',
|
||||
tooLong: '(the answer was too long to send)',
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { checkReply, GuardError, libraryReader, parseReply, FIXED_TEXTS } from '../src/guard.mjs';
|
||||
import { resolveReplyLimits, checkReplyLimits } from '../src/limits.mjs';
|
||||
import { BASE, MARKER, mapping, repo, git } from './helpers.mjs';
|
||||
|
||||
const defaults = resolveReplyLimits({ readable: true, document: {} });
|
||||
const body = citations => JSON.stringify({ text: 'A fixture answer.', citations });
|
||||
const cite = (commit, extra = {}) => ({ path: 'Book One/Characters/Mara Vell.md', commit, lines: [9, 9], quote: 'Grey eyes.', ...extra });
|
||||
const context = r => ({ limits: defaults, mapping: mapping(), library: libraryReader(r.repo, r.commit, mapping()), web: { allowed: true, pages: [] } });
|
||||
const refusal = code => e => e instanceof GuardError && e.code === code && typeof e.reason === 'string' && e.reason.length <= 300;
|
||||
|
||||
test('C2-06 reply schema: free bodies, exact objects and safe refusal reasons', async t => {
|
||||
const c = context(repo(t));
|
||||
for (const text of ['plain', 'null', '42', '[]', '"text"', '{broken']) {
|
||||
assert.equal(parseReply(text).cited, false);
|
||||
assert.equal(await checkReply(text, c), null);
|
||||
}
|
||||
assert.deepEqual(await checkReply(body([]), c), { citations: [] });
|
||||
for (const value of [{}, { text: 'x', citations: [], scope: 'canon' }, { text: 1, citations: [] },
|
||||
{ text: 'x', citations: {} }, { text: 'x', citations: [{ url: 'https://invalid.test', quote: 'x', agent: 'forged' }] },
|
||||
{ text: 'x', citations: [cite('a'.repeat(40), { as: 'supporting' })] }])
|
||||
await assert.rejects(checkReply(JSON.stringify(value), c), refusal('reply-invalid'));
|
||||
});
|
||||
|
||||
test('C2-06 citation setting and all four exact fixed texts', async t => {
|
||||
const c = context(repo(t)); c.limits = { ...defaults, requireCitation: true };
|
||||
await assert.rejects(checkReply('plain', c), refusal('citation-missing'));
|
||||
await assert.rejects(checkReply(body([]), c), refusal('citation-missing'));
|
||||
for (const text of Object.values(FIXED_TEXTS)) {
|
||||
assert.equal(await checkReply(text, c), null);
|
||||
await assert.rejects(checkReply(text + ' ', c), refusal('citation-missing'));
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-06 default citation cap passes 20 and refuses 21, even with optional citations', async t => {
|
||||
const r = repo(t), c = context(r);
|
||||
assert.equal((await checkReply(body(Array(20).fill(cite(r.commit))), c)).citations.length, 20);
|
||||
await assert.rejects(checkReply(body(Array(21).fill(cite(r.commit))), c), refusal('citations-over-limit'));
|
||||
});
|
||||
|
||||
test('C2-06 real Git citation scope, reachability, lines and normalized quotes', async t => {
|
||||
const r = repo(t), c = context(r);
|
||||
assert.equal((await checkReply(body([cite(r.commit, { quote: 'Grey\n eyes.' })]), c)).citations[0].scope, 'canon');
|
||||
for (const extra of [{ path: 'Inbox Note.md', lines: [1, 1], quote: MARKER },
|
||||
{ path: 'Book One/Board.canvas' }, { path: 'Book One/Characters/Missing.md' },
|
||||
{ commit: '0'.repeat(40) }, { commit: git(r.repo, 'rev-parse', 'HEAD^{tree}') },
|
||||
{ lines: [999, 999] }, { quote: 'not in her fixture' }])
|
||||
await assert.rejects(checkReply(body([cite(r.commit, extra)]), c), refusal('citation-invalid'));
|
||||
// A future commit exists locally but is not reachable from the served commit.
|
||||
writeFileSync(join(r.repo, 'Inbox Note.md'), 'new\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'future');
|
||||
await assert.rejects(checkReply(body([cite(git(r.repo, 'rev-parse', 'HEAD'))]), c), refusal('citation-invalid'));
|
||||
assert.equal((await checkReply(body([cite(r.commit)]), c)).citations[0].scope, 'canon');
|
||||
});
|
||||
|
||||
test('C2-06 playground cites pass but cannot state canon', async t => {
|
||||
const r = repo(t), c = context(r), p = cite(r.commit, { path: 'Book One/Playground/What If.md', lines: [4, 4], quote: MARKER });
|
||||
assert.equal((await checkReply(body([p]), c)).citations[0].scope, 'playground');
|
||||
await assert.rejects(checkReply(body([{ ...p, as: 'canon' }]), c), refusal('playground-cited-as-canon'));
|
||||
});
|
||||
|
||||
test('C2-06 independent universes refuse; a hybrid and only its sources pass', async t => {
|
||||
const r = repo(t, { ...BASE, 'Crossover/Chapters/01.md': 'A hybrid fixture.\n' }), c = context(r);
|
||||
const other = cite(r.commit, { path: 'Second World/Chapters/01.md', lines: [1, 1], quote: MARKER });
|
||||
await assert.rejects(checkReply(body([cite(r.commit), other]), c), refusal('citation-cross-universe'));
|
||||
c.mapping = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; });
|
||||
c.library = libraryReader(r.repo, r.commit, c.mapping);
|
||||
const hybrid = cite(r.commit, { path: 'Crossover/Chapters/01.md', lines: [1, 1], quote: 'hybrid fixture' });
|
||||
assert.equal((await checkReply(body([hybrid, cite(r.commit), other]), c)).citations.length, 3);
|
||||
});
|
||||
|
||||
test('C2-06 web cites require allowed definition, this session fetch and kept quote', async t => {
|
||||
const c = context(repo(t)), web = { url: 'https://fixture.invalid/page', quote: 'kept text' };
|
||||
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
|
||||
c.web.pages = [{ url: web.url, finalUrl: 'https://fixture.invalid/final', text: 'The kept\n text is here.' }];
|
||||
assert.deepEqual(await checkReply(body([web]), c), { citations: [{ scope: 'web', agent: null }] });
|
||||
assert.equal((await checkReply(body([{ ...web, url: c.web.pages[0].finalUrl }]), c)).citations.length, 1);
|
||||
c.web.pages.push({ url: web.url, text: 'A later fetch.' });
|
||||
assert.equal((await checkReply(body([{ ...web, quote: 'later fetch' }]), c)).citations.length, 1);
|
||||
await assert.rejects(checkReply(body([{ ...web, quote: 'discarded bytes' }]), c), refusal('citation-unfetched'));
|
||||
c.web.allowed = false;
|
||||
await assert.rejects(checkReply(body([web]), c), refusal('citation-unfetched'));
|
||||
});
|
||||
|
||||
test('C2-06 file origin is the path commit and trailer, while last follows served changes', async t => {
|
||||
const r = repo(t), path = 'Book One/Characters/Mara Vell.md';
|
||||
writeFileSync(join(r.repo, path), 'Agent fixture.\n'); git(r.repo, 'add', '-A');
|
||||
git(r.repo, 'commit', '-qm', 'Note\n\nMosaic-Agent: acme/scribe');
|
||||
const file = git(r.repo, 'rev-parse', 'HEAD');
|
||||
writeFileSync(join(r.repo, 'Inbox Note.md'), 'Unrelated.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'unrelated');
|
||||
const cited = git(r.repo, 'rev-parse', 'HEAD'), reader = libraryReader(r.repo, cited, mapping());
|
||||
assert.notEqual(cited, file);
|
||||
assert.deepEqual(await reader.origin(cited, path), { agent: 'acme/scribe', file });
|
||||
assert.equal(await reader.last(path), file);
|
||||
writeFileSync(join(r.repo, path), 'Human fixture.\n'); git(r.repo, 'add', '-A'); git(r.repo, 'commit', '-qm', 'human');
|
||||
assert.notEqual(await libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping()).last(path), file);
|
||||
});
|
||||
|
||||
test('C2-06 origin uses literal paths even when a filename contains a glob', async t => {
|
||||
const path = 'Book One/Characters/Mara*.md';
|
||||
const r = repo(t, { ...BASE, [path]: 'Literal fixture.\n' }), original = r.commit;
|
||||
writeFileSync(join(r.repo, 'Book One/Characters/Mara Vell.md'), 'Other fixture.\n'); git(r.repo, 'add', '-A');
|
||||
git(r.repo, 'commit', '-qm', 'Other path\n\nMosaic-Agent: acme/other');
|
||||
const reader = libraryReader(r.repo, git(r.repo, 'rev-parse', 'HEAD'), mapping());
|
||||
assert.deepEqual(await reader.origin(git(r.repo, 'rev-parse', 'HEAD'), path), { agent: null, file: original });
|
||||
assert.equal(await reader.last(path), original);
|
||||
});
|
||||
|
||||
test('C2-06 reply limits fall back independently without coercion or inherited keys', () => {
|
||||
for (const value of ['true', 1, null, [true], {}]) {
|
||||
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: value, citationsPerReply: 5 } } });
|
||||
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 5); assert.deepEqual(r.fallback, ['requireCitation']);
|
||||
}
|
||||
for (const value of [0, 1001, 2.5, true, '5']) {
|
||||
const r = resolveReplyLimits({ readable: true, document: { replyLimits: { requireCitation: true, citationsPerReply: value } } });
|
||||
assert.equal(r.requireCitation, true); assert.equal(r.citationsPerReply, 20); assert.deepEqual(r.fallback, ['citationsPerReply']);
|
||||
}
|
||||
for (const value of [null, [], 1, 'settings', undefined]) {
|
||||
const r = resolveReplyLimits({ readable: true, document: { replyLimits: value } });
|
||||
assert.equal(r.requireCitation, false); assert.equal(r.citationsPerReply, 20);
|
||||
}
|
||||
assert.equal(resolveReplyLimits({ readable: true, document: { replyLimits: Object.create({ requireCitation: true }) } }).requireCitation, false);
|
||||
for (const document of [undefined, null, [], 'invalid']) assert.equal(resolveReplyLimits({ readable: true, document }).requireCitation, true);
|
||||
assert.equal(resolveReplyLimits({ readable: false }).requireCitation, true);
|
||||
assert.deepEqual(checkReplyLimits({ requireCitation: true, citationsPerReply: 1000 }), { requireCitation: true, citationsPerReply: 1000 });
|
||||
for (const value of [{ unknown: 1 }, { requireCitation: 'true' }, { citationsPerReply: 0 }, null, []]) assert.equal(checkReplyLimits(value), null);
|
||||
});
|
||||
@@ -1,94 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderReply } from '../src/index.mjs';
|
||||
import { renderItems, UNREADABLE } 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, 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;
|
||||
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);
|
||||
|
||||
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 });
|
||||
// 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('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 });
|
||||
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 parsed body renders as its string does', () => {
|
||||
assert.deepEqual(renderReply(JSON.parse(CITED), { check: CHECK, changed }), renderReply(CITED, { check: CHECK, changed }));
|
||||
});
|
||||
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('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 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('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.' });
|
||||
});
|
||||
@@ -60,6 +60,7 @@ export const RUNNER_FILES = Object.freeze([
|
||||
"packages/bus/src/landing.mjs",
|
||||
"packages/business/src/vocabulary.mjs",
|
||||
"packages/plugins/src/kinds.mjs",
|
||||
"packages/library/src/reply-protocol.mjs",
|
||||
"packages/processing-control/src/state.mjs",
|
||||
"packages/tasks/src/requirements.mjs",
|
||||
].map((p) => join(REPO, p)));
|
||||
|
||||
@@ -189,7 +189,5 @@ 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, libraryRevert = 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, 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, libraryRevert, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, 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, libraryRevert, 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, 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, libraryRevert: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
send: null, librarySync: 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.libraryRevert = args => call('library.revert', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', 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,20 +23,3 @@
|
||||
.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,22 +17,11 @@
|
||||
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(); libraries.clear(); reverts.clear();
|
||||
generation++; controller?.abort(); states.clear(); forms.clear();
|
||||
if (current) current.container.replaceChildren();
|
||||
current = null;
|
||||
}
|
||||
@@ -91,92 +80,11 @@
|
||||
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>${messageBody(m)}</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><div class="chat-body">${txt(m.body)}</div></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)}${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>`;
|
||||
return `<section class="chat-conversation" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${heading(t.name)}${sessionControls(t)}<p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p><button class="btn" type="button" id="chat-resume"${disabledSession(s.pending)}>Resume assistant</button><ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-send-form"><label for="chat-body">Message this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><button class="btn primary" type="submit"${disabledSession(s.pending)}>Send</button></form><p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
}
|
||||
function capture() {
|
||||
if (!current) return null;
|
||||
@@ -325,11 +233,7 @@
|
||||
}
|
||||
} catch (err) { if (err.name === 'AbortError') return; }
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (g === generation) paint(navigated);
|
||||
} catch (err) {
|
||||
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
|
||||
current.error = readError(err);
|
||||
@@ -436,8 +340,6 @@
|
||||
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,8 +32,6 @@ 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']]),
|
||||
@@ -44,7 +42,6 @@ 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'),
|
||||
]);
|
||||
@@ -599,55 +596,12 @@ 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, 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 } = {}) {
|
||||
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 } = {}) {
|
||||
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');
|
||||
@@ -655,7 +609,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, libraryRevert, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
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 && !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.
|
||||
@@ -665,7 +619,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, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files };
|
||||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, 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 }))) {
|
||||
@@ -675,7 +629,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, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, upstream, files } = owner, granted = false;
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, 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
|
||||
@@ -742,7 +696,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; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true;
|
||||
reads = null; upstream = null; files = USER_FILES; granted = true;
|
||||
}
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
@@ -794,11 +748,6 @@ 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 });
|
||||
@@ -867,10 +816,6 @@ 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);
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
// 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'));
|
||||
});
|
||||
Reference in new issue
Block a user