Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c53adf106 | ||
|
|
4fd790faa5 | ||
|
|
b2a1f40926 | ||
|
|
c18ad3e12e | ||
|
|
31fd2faebd | ||
|
|
98ca25ab1a | ||
|
|
f8f79ea2f7 | ||
|
|
cd2f50daec | ||
|
|
5d587a552d | ||
|
|
29455c0c5d | ||
|
|
c0ca3894c0 | ||
|
|
47db04eba8 | ||
|
|
77eb46ff5e | ||
|
|
cef503cb93 | ||
|
|
0ed4fbab9b | ||
|
|
fc05bcbc39 | ||
|
|
7c01a5ad7a | ||
|
|
48781a8421 | ||
|
|
46bad7ea3b | ||
|
|
7dc4fb2cca | ||
|
|
c867cada97 | ||
|
|
436b27f662 | ||
|
|
e67c27b322 | ||
|
|
539079b045 | ||
|
|
76d651dba4 | ||
|
|
e66ec9f42d | ||
|
|
a45459a1c9 | ||
|
|
c5759f5ac1 | ||
|
|
88ec5e8e47 | ||
|
|
b9ddb6c5e2 | ||
|
|
bd2c1c4ea1 | ||
|
|
28918824ca | ||
|
|
331653b385 | ||
|
|
53db6b7cac | ||
|
|
8f8b8cf153 | ||
|
|
573fa0be06 | ||
|
|
cf8ba6ef45 | ||
|
|
66e3c3d616 | ||
|
|
aabfdfc604 | ||
|
|
115889e4a0 | ||
|
|
9a77fff7a7 | ||
|
|
4d6c8234fa | ||
|
|
a1876928f4 | ||
|
|
ae48662afa | ||
|
|
44edead6d8 | ||
|
|
b0d21c41a9 | ||
|
|
61ef385e4a | ||
|
|
d46b6d6ef6 | ||
|
|
edec640d29 | ||
|
|
dd3e3c0963 | ||
|
|
b3cf04b371 | ||
|
|
4faa3f6e9a | ||
|
|
4f66dccdec | ||
|
|
398314cf4d | ||
|
|
45dca69f6e | ||
|
|
293b42fee9 | ||
|
|
369607e92d | ||
|
|
59f013247c | ||
|
|
e8bb13df84 | ||
|
|
dba2ebdb85 | ||
|
|
45ea3fa0d2 | ||
|
|
f69c962009 | ||
|
|
5b6c878143 | ||
|
|
8c2c5303cf | ||
|
|
23e3d09fa9 | ||
|
|
795a645e65 | ||
|
|
076ca8d6e4 | ||
|
|
68be863248 | ||
|
|
264fad2e26 | ||
|
|
24672fd9d6 | ||
|
|
30e6bb6803 | ||
|
|
7defb58e97 | ||
|
|
9c8877bdf3 | ||
|
|
2d0318a44a | ||
|
|
bd3fa2a484 | ||
|
|
ab575ee553 | ||
|
|
1676ad0de8 | ||
|
|
4874d1e83a | ||
|
|
e909ef51a2 | ||
|
|
4607e15b83 | ||
|
|
8b42010551 | ||
|
|
4863e2caf4 | ||
|
|
7b25a60633 | ||
|
|
e86df37d76 | ||
|
|
23816cf285 | ||
|
|
38e322ea01 | ||
|
|
4f3fbc25a0 |
No files matched your search
@@ -5403,3 +5403,15 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
2026-10-23 | Huey | Row 272 round 2 on row 219 landing 5fdfd7275, after 295. Replayed round 1 and B1/N1/N2; seven conflicts resolved retaining both log histories, both test sets, 219 instance chooser and 295 owner-settings-only transport. Static comparison confirms old tests differ only by mos-1388 approved hunks. Sage3357/Mos1442 authorize bounded granted-user legacy fallback: only session.list access-refused and omitted-session route; no new session grants or owner routing. RED first under3358 on new file only: 1 expected legacy assertion failure, 3 guards pass, 3.504s, TMPDIR mktemp/no underscore, no compose network; slot returned as huey0035. Then implemented fallback and added timeout/bus/malformed guards plus isolated-copy mutant runner, all unrun afterward. Candidate commit/push precedes Sage's full gate by3354; no green claim.
|
||||
|
||||
2026-10-23 | Huey | Row 272 round 3 per mos-1455 / sage-3435. Gate run 1 on b44332a93 RED: bus 891/0, business 231/0, CLI 940/1, plugins 110/1, webui 372/20; no mutants. Fixed only new CLI fixture role authority and three null-old render guards. Catching browser tests unchanged. manifest.r3 freezes the same 17 paths. Authorized pre-gate follow-up commit/push; no local tests, Node or queue writes; separate compactions dependency and Sage gate remain pending.
|
||||
|
||||
2026-10-23 | Sage (1ef1e4f8) | Row 272 landing (#1777, 264e) | Gate r4 on bea0c8e1c over 1e629c892: bus 891/0, cli 941/0, webui 392/0, business 231/0; plugins 110/1, the single red being row 65 harness.test.mjs:192 naming compactions.mjs, which row 307 owned and fixed. M1-M6 and F1-F4 killed, restore clean. Landed d33989230, queue commit 8dc132bec.
|
||||
|
||||
2026-10-23 | Sage (1ef1e4f8) | Row 307 landing (#1814, Vikunja 598) | Six first-parent commits replayed onto 8dc132bec with matching patch-ids, 16 paths equal to 6fdfe3790, manifest digest 17c9f8d9 rebuilt from git. Gate green 14:37:21Z: bus 891/0, cli 941/0, plugins 119/0, webui 392/0, business 231/0, every other package 0 failed 0 skipped, every scripts/test-*.sh passed (skips: test-queue canonical root, test-task live provider x2). M1-M4 killed, restore clean. Landed d243f6b25, DEFERRED 307 N1 0621c439e, queue commit fe4ee8c7b. Correction: my first note said Vikunja 598 done when I had only commented (951); Mos closed it (952).
|
||||
|
||||
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 — Row 288 C2-02 build starts (Bubba, #1793)
|
||||
|
||||
Before: agents can read the library; supporting-path and push-credential dependencies are separate unlanded heads. After intended: per-seat supporting writes/deletes with exact notices, human revert and one commit per Console save. Work is on bubba/row288, chained on original 283/285/286 commits; 284 tool base still pending. Plan and acceptance evidence: agents/bubba/work/queue-288/BUILD.md. Untested; no Node/test/build/gate run before Sage CLEAR. Landing waits for every dependency's own gate.
|
||||
@@ -47,6 +47,14 @@ its launch configuration remains fixed, including on resume. Use Filbert's
|
||||
wrapper to select that configuration; a direct shared-helper invocation uses
|
||||
its own supplied flags or the system defaults.
|
||||
|
||||
Any run that includes the webui suite, whether a seat's slot run or a landing
|
||||
gate, sets `MOSAIC_REQUIRE_EDITOR_BUILD=1`, so a missing editor toolchain fails
|
||||
the run instead of skipping the rebuild test. A worktree that links the
|
||||
canonical `node_modules` also runs `npm ci --ignore-scripts --no-audit --no-fund`
|
||||
in its own `packages/webui` first. The root install carries esbuild 0.28.1
|
||||
through pi-coding-agent, and the rebuild needs the 0.28.2 that
|
||||
`packages/webui/package-lock.json` pins (Vikunja 597, mos-1453).
|
||||
|
||||
All agents follow repository governance and current user direction. Team
|
||||
leadership does not add deployment or push authority. Coordinate overlapping
|
||||
work with Sage and preserve other sessions' changes.
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
# Row 288 (#1793): C2-02 agent writes, notices, revert and Console saves
|
||||
|
||||
Owner: Bubba, Codex gpt-6.1-sol, thread 3d6672b5-5544-4b95-9e09-e79574f1aa14.
|
||||
Reviewer: Webby. Gate and landing: Sage. Append-only build record.
|
||||
|
||||
## 2026-10-23 start and implementation plan
|
||||
|
||||
Base 45dca69f6 on bubba/row288. Direct mandate mos-1499 (Q245), confirmed sage-3613.
|
||||
The canonical `scripts/mosaic queue next bubba` returned `nothing`; Sage owns the owner transition.
|
||||
The initial worktree invocation refused linked-worktree use; retried in canonical checkout.
|
||||
|
||||
Spec: docs/plans/2026-10-20_case2-rows.md, C2-02; agents/gosalyn/work/case2/DESIGN.md ruling after r11.
|
||||
Handoffs: Honker queue-283 BUILD.md Q4 (missing/non-100644 delete refuses); Morgana
|
||||
queue-225 BUILD.md A2 at 66f7becf2 (strict revert arguments/result), confirmed by Fethry fethry-0005.
|
||||
`project` is the business.projects key, never a chat UUID.
|
||||
|
||||
Dependencies merged with their commits intact, no amend or force-push:
|
||||
- 283 origin/honker/row283-build 88ec5e8e4 (new E4 head to follow).
|
||||
- 285 origin/scrooge/row285-r2 53db6b7ca.
|
||||
- 286 origin/ludwig/row286-r2 573fa0be0.
|
||||
- 284: no named remote branch yet. Tool changes come last.
|
||||
All merge commits are marked Untested. Nothing has run under Node. Sage's latest instruction
|
||||
sage-3613 reserves **all Node and test runs** for CLEAR; this supersedes the initial targeted-test
|
||||
permission. Build untested now as instructed, then run RED/GREEN checks/mutants on CLEAR.
|
||||
|
||||
Architecture: one host-only plumbing writer under the existing library lock; broker authorizes and
|
||||
selects credentials, then inserts the fixed notice directly. Human save/revert use editor +
|
||||
library-human independently of seat lifecycle. No worktree, deferred push, role expansion or UI.
|
||||
|
||||
### Tasks and acceptance
|
||||
|
||||
- [ ] Plumbing: new library/src/write.mjs, mirror state publication and package exports; new
|
||||
library/tests/write.test.mjs. One-parent write/delete, byte-exact content, seat identity/trailer,
|
||||
stale-base, nonregular/missing deletes, retry once on an unrelated remote change, push failure
|
||||
without local ref movement, human editor and revert reachability/single-path checks.
|
||||
- [ ] Broker + runtime: library.doc.write/delete, library.file.save, library.revert; strict keys,
|
||||
creative + library.write + live unheld project grant; direct exact notice; human-only save/revert.
|
||||
New bus/tests/library-write.test.mjs covers authorization, unchanged refs, A2 contract and socket.
|
||||
- [ ] Transport: only human library.file.save joins the existing large-save exception. New tests
|
||||
prove >64 KiB save accepted, agent writes retain 64 KiB bound, malformed/oversize saves refuse.
|
||||
- [ ] Tools after 284: library_doc_write/delete in harness tools; exact eight/ten-name snapshots
|
||||
in a new harness test; cli dispatch wiring.
|
||||
- [ ] Verify on CLEAR: new tests, library/bus/harness suites and all scripts/test-*.sh on Sage's
|
||||
assigned slot. Mutants remove supporting guard and force push; both must fail. Fixture-token
|
||||
search over captured output. No gate, landing or queue move claimed before Sage's acceptance.
|
||||
|
||||
Pre-flight interfaces: broker consumes library writer {project,path,commit}; revert adds reverted;
|
||||
writer consumes C2-03b push {pushed} or {raced,tip} and supportingPath(mapping,universe,path,tree).
|
||||
State publication must add optional lastPush while preserving old state readability. Refusals must
|
||||
restore fetched remote-tracking refs as well as preserve served. Runtime must bind credential file
|
||||
references itself; no file, identity, message or credential is supplied by the agent.
|
||||
|
||||
Necessary file-scope additions: packages/bus/src/runtime.mjs (actual adapter injection),
|
||||
packages/library/src/index.mjs (exports), and CLI's existing console adapter if saves require it.
|
||||
Reported to Sage in bubba-0002. Existing tests will not be edited without Mos's OK via Sage.
|
||||
|
||||
Review focus: remote tip races, nonregular entries and folded paths, unsafe human credential before
|
||||
git, revert after revoked/fired writer, and large-request exception never extending agent writes.
|
||||
|
||||
### Existing-test changes
|
||||
|
||||
None authored by Bubba yet. Dependency test changes remain their owners' original commits.
|
||||
|
||||
## 2026-10-23 first static build (not a gate result)
|
||||
|
||||
283 E4 merged intact at 76d651dba. Honker next reports e67c27b32 (test environment only), to merge
|
||||
before push. Della reports row 284 has not been built/pushed; row224's 23816cf28 is not a 284 head.
|
||||
No speculative 284 pin. Typed tools and served-blob lookup integration remain pending 284.
|
||||
|
||||
Implemented (unrun): bare plumbing writer, one-parent commit and fixed trailer, exact-content blobs,
|
||||
base comparison, supporting path guard and delete regular-blob check, one remote-race retry,
|
||||
ref restoration on refusal, immediate served publication/lastPush; human save and reachable one-path
|
||||
business-trailer revert; broker grants/identity/notice path; runtime adapter wiring; large-save socket
|
||||
exception. New tests: library/tests/write.test.mjs and bus/tests/library-write.test.mjs. No old test edits.
|
||||
|
||||
### Approved scope additions, sage-3628
|
||||
|
||||
Every hunk outside the brief's owned list:
|
||||
- `packages/bus/src/runtime.mjs`: `humanWriteOptions` checks editor before choosing the human slot;
|
||||
three library adapter entries bind dataRoot, command and the host-only seat store reference. These
|
||||
are needed to make actual bus calls reach the new writer without accepting agent-supplied files.
|
||||
- `packages/library/src/index.mjs`: export writeLibraryDoc/saveLibraryFile/revertLibraryCommit so
|
||||
runtime's existing library module adapter can call them.
|
||||
- `packages/library/src/sync.mjs`: shared write guard with opaque token compared by identity;
|
||||
push's race fetch recognizes only that owner and does not clear its guard; ordinary sync and a
|
||||
forged token still refuse library-sync-busy. Narrow publishLibraryWrite validates ids, reads the
|
||||
locked state, rebuilds the index via readLibrary, moves served/tracking and writes lastPush. The
|
||||
raw writeState remains private and is not exported through index.mjs.
|
||||
|
||||
Ludwig ludwig-0064 reviewed the intended interface and requested the opaque token (implemented),
|
||||
narrow publication (implemented), whole library + bus library-credential checks and a token mutant
|
||||
(planned). His 573fa0be0 remains current and unrun. 288's additions are separate commits, not folded
|
||||
into 286. Tracking restores only if the whole write refuses, not between the first race and retry.
|
||||
|
||||
Known tradeoff, accepted by Sage sage-3628 for MVP: the brief holds the flock through network Git;
|
||||
readers can hit the existing 5-second library-sync-busy while it runs. Structural supportingPath
|
||||
preflight is pure and precedes Git; served/fetched listTree checks repeat it with the real tree before
|
||||
any remote write. A tree-dependent check necessarily reads the tree first (Honker honker-0184 item2).
|
||||
|
||||
### Mutants for CLEAR
|
||||
|
||||
- Remove the `pathOf` calls for agent operations: the M12 broker/bare-ref tests must fail on a
|
||||
story/unsupported path while checking remote, tracking and served refs.
|
||||
- Prefix the push refspec in syncCommand with `+`: write.test moved-target race must fail, because
|
||||
its command seam preserves production syncCommand's refspec (only remote URL/protocol are fixture).
|
||||
- Replace `writers.get(mirror) === writeToken` with `true`: write.test forged-token case must fail.
|
||||
|
||||
No Node, test, build or mutant has run. `git diff --check` is clean. Counts are not claimed.
|
||||
|
||||
### Console dispatch integration
|
||||
|
||||
The existing cli delegates its transport wiring to console-bus.mjs, so the save dispatch needs that
|
||||
file too: one null `librarySave` option and one `--send` assignment to library.file.save. cli.mjs
|
||||
threads that option into startServer; row C2-11b owns the actual route/page that consumes it. New
|
||||
webui/tests/library-write-options.test.mjs covers --send and the user transport boundary. 225 owns
|
||||
libraryRevert dispatch; 288 adds only librarySave to avoid overlapping that ownership. Additional
|
||||
scope reported to Sage and Fethry. No changes to an existing test.
|
||||
|
||||
## 2026-10-23 second static pass and dependency review
|
||||
|
||||
First push: c867cada9 on origin/bubba/row288, reported bubba-0005 to Sage/Webby/Mos and
|
||||
bubba-0006 to Fethry/Ludwig. No PR created and no review/gate requested as complete.
|
||||
|
||||
Ludwig ludwig-0065 checked the exact sync diff against 286 without running anything. Token identity,
|
||||
ordinary-sync exclusion, 286 R1 preservation, guard key and end-of-write tracking restoration fit.
|
||||
F1: state publication could fail after served moved, leaving old state/new served. Root cause is
|
||||
publication order without rollback, compared against sync's existing rollback. Added an unrun
|
||||
filesystem rename-failure regression, then changed the narrow helper: on failure it validates the
|
||||
state under the same lock, CAS-restoring served if state has not published the new commit. If rename
|
||||
succeeded but directory fsync failed and the state validates against new served, it keeps that pair.
|
||||
All failure cases answer outcome-unknown; tracking remains the commit already pushed remotely.
|
||||
Verification is pending CLEAR, so this is a proposed fix, not a tested resolution.
|
||||
|
||||
Added the actual broker-to-bare tests for each M12 path refusal (both agent verbs), unchanged remote
|
||||
and all mirror refs, exact write/delete notices, and human revert after fixture grant removal/hold.
|
||||
The bare fixture moved to new library/tests/write-fixture.mjs so broker tests reuse real plumbing.
|
||||
Added the raw socket proof-nested-save-name case to pin the parsed-verb large-request check.
|
||||
Missing editor/slot tests now make the mirror metadata invalid first: reaching Git would change the
|
||||
refusal, so the tests check the pre-Git gate rather than merely count remote commands.
|
||||
|
||||
Additional required doc hunk: packages/library/README.md appends the four verb contracts,
|
||||
lastPush shape, exact notice, lock tradeoff and publication failure behavior. No existing test from
|
||||
the dependency/base has changed. All new/modified tests in this pass were authored by this row.
|
||||
`git diff --check` remains clean. No Node/tests/build/mutants run; 284 and CLEAR remain pending.
|
||||
|
||||
## 2026-10-23 early review follow-up (Webby E1-E6; Ludwig F2)
|
||||
|
||||
Sage sage-3652 approves console-bus.mjs wiring (null option + --send dispatch), confirms editor.user
|
||||
as the sole human login source and reserves CLEAR for T3/T4 after T2. Ordinary sync()'s separate
|
||||
post-rename fsync gap stays out of 288 per that ruling; no change to sync()'s existing rollback.
|
||||
|
||||
Honker 283 head 46bad7ea3 merged intact: mapping editor.user is optional, and if present uses the
|
||||
existing plain Gitea-user pattern. The omission at mapping level is valid; human writes refuse it.
|
||||
|
||||
- Ludwig F2 / Webby E6: confirmed the previous runtime used the fetch bot's mapping.user wrongly.
|
||||
Runtime now passes mapping.editor.user to humanCredential, never mapping.user. Missing user gives
|
||||
library-human-missing before Git. New real-runtime cases pin both save and revert with a separate
|
||||
fetch-bot login, positive pushes under her-gitea-login, and no command for an absent editor login.
|
||||
The broker's credential.agent is s.role, not args: added an exact assertion and strict-key refusal
|
||||
cases for caller-supplied agent and credential.
|
||||
- E1: replaced the business-id RegExp interpolation with a literal startsWith(business.id + '/')
|
||||
check and ID_PATTERN on only the instance suffix. New acme.co vs acme-co trailer test.
|
||||
- E2: new broker→bare case holds the seat in the fetch command. beforePush must refuse
|
||||
library-write-no-grant with all local refs and the remote unchanged and no notice. New mutant:
|
||||
remove `options.beforePush?.();` in write.mjs; the E2 test must fail.
|
||||
- E3: new broker cases for expired rotate_by, fire.stopped without fire.done, missing role.publish
|
||||
project, grant/identity Gitea-user mismatch, and missing identity name/email, for both agent verbs.
|
||||
- E4: the exceptional publication failure currently returns outcome-unknown before the broker has
|
||||
a successful result to put in a notice, so a remote agent commit can exist without a Console
|
||||
notice. There is no durable notice outbox/retry in this row. The trailer commit remains human
|
||||
revertible by id after sync; this is an explicit known failure-mode gap for the manifest, not
|
||||
a claim that every failed publication emits a notice. A new real-push test breaks state decoding
|
||||
after push admission, pins outcome-unknown, old served and no notice. It also found a local error
|
||||
propagation gap: errors before publishLibraryWrite's internal try now normalize to outcome-unknown
|
||||
in the writer once push has succeeded, so the caller is never told an ordinary refused-write code.
|
||||
- E5: 284 still has no build. Tools/8-or-10 snapshot and served-blob integration remain pending.
|
||||
librarySave is intentionally only an adapter option until C2-11b supplies its route/page.
|
||||
|
||||
All tests above are newly authored by row288 and remain unrun; no changes to baseline/dependency
|
||||
tests. RED/GREEN and mutants await CLEAR. Proposed fixes are not claimed verified. The new host-only
|
||||
library write module imports the existing pure ID_PATTERN to match the broker's accepted instance ids.
|
||||
|
||||
## 2026-10-23 mos-1520 ruling: post-push notice is required
|
||||
|
||||
Correction to the E4 interpretation above: Mos rejected the known-gap treatment. The requirement
|
||||
stays: a pushed agent commit has the exact notice even if local publication then fails. This is not
|
||||
an outbox or retry. The writer now calls the broker's trusted synchronous onPushed callback as soon
|
||||
as push returns pushed:true, before publication. The callback records the exact library-write notice
|
||||
and action.allowed in the normal Console-store transaction. Publication failure still answers
|
||||
outcome-unknown. The notice's Revert can refuse library-revert-not-agent until sync makes that commit
|
||||
reachable from served; README now says that explicitly and removes the rejected gap description.
|
||||
|
||||
The row's own real-push corruption test now pins the exact notice body with the pushed id, old
|
||||
served and outcome-unknown; after restoring readable state it pins Revert's refusal before sync and
|
||||
success after sync. The successful-write adapter fixture calls onPushed too, matching runtime's
|
||||
writer contract. No existing baseline/dependency test changed (mos-1520 explicitly authorizes this
|
||||
row's test correction without mos-1272).
|
||||
|
||||
Residual Console-store failure: if notice insertion/routing/its event transaction fails, the broker
|
||||
calls a separate host log sink with {event:library-notice-store-failed,code:outcome-unknown,business,
|
||||
instance,op,commit}. It logs no args, path content, credential, raw SQL or exception output. Runtime's
|
||||
new library.log adapter writes this metadata to stderr. The caller gets outcome-unknown even if
|
||||
recording action.refused in that same broken store fails. A new real-push test uses temporary SQLite
|
||||
abort triggers on notice insertion and refusal evidence, checks remote advanced/served unchanged,
|
||||
no message, the exact external log and outcome-unknown. It does not claim a notice can be stored in
|
||||
a failed Console store. If the host log sink itself also fails, no durable log can be promised; the
|
||||
caller still receives outcome-unknown. The runtime log hunk is the required residual-failure wiring.
|
||||
|
||||
Mutant added: move `options.onPushed?.({ project: context.project, path: change.path, commit });`
|
||||
from before `await publishLibraryWrite(...)` to immediately after that await inside the same try.
|
||||
Successful operations keep their notice, but the outcome-unknown publication path drops it. The
|
||||
C2-02 E4 real-push corruption test must fail on its exact notice assertion. Run on CLEAR and restore.
|
||||
|
||||
No Node/tests/build/mutants run. Static git diff --check clean. Della's first284 head0ed4fbab9 is
|
||||
explicitly incomplete and not a finished dependency; waiting for her complete-head pin before tools.
|
||||
|
||||
Webby webby-0253 (static, cef503cb9) closes E4 per mos-1520; no tests ran. N1: a failed notice insert throws from onPushed before publication, so served stays at the prior commit until sync; the store-failure test pins this safe direction. Only E5 (284 tools/snapshot integration) remains open in this early review.
|
||||
|
||||
## 2026-10-23 complete284 merge and final tool integration (unrun)
|
||||
|
||||
Della della-0037 supplied complete untested head47db04eba85ac3ceb7e81385603f97bb6f8c1a38,
|
||||
replacing the incomplete head. Merged intact as c0ca3894c. Its22423816cf28 and2164f3fbc25a
|
||||
lineage is retained. Three conflicts were in overlapping production wiring only:
|
||||
- broker.mjs: keep Della's LIBRARY_LOOKUPS object and #requestLibraryLookup byte-for-byte, and
|
||||
keep288 writer/notice methods; LIBRARY_VERBS contains both sets and request dispatch calls lookup.
|
||||
- runtime.mjs: keep lookup adapter plus288's metadata log and write adapters.
|
||||
- library/index.mjs: keep index/query exports,286's push/humanCredential exports and288's writer exports.
|
||||
No dependency test body was changed in the merge. These are overlapping consumer integrations,
|
||||
not rewrites of dependency commits. Every prerequisite still needs its own gate (mos-1351).
|
||||
|
||||
Added library_doc_write/library_doc_delete after the five lookups, only for a creative definition
|
||||
with a within-role library.write grant and library enabled. Closed schemas carry exactly
|
||||
{universe,path,base,content} and {universe,path,base}; base is string-or-null with 40-lowercase-hex
|
||||
pattern. No project, business, author, credential, commit or message. tools.action is library.write.
|
||||
New harness/tests/library-write-tools.test.mjs pins per-role eight/ten names, both schema invariants,
|
||||
no repo/commit/business/accept, exact required/verb/action, nullable base, and missing library/grant
|
||||
or noncreative exclusion. This satisfies sage-3678 and mos-1523's replacement coverage condition.
|
||||
|
||||
Publication's narrow helper now calls indexFor for the new served commit instead of only readLibrary,
|
||||
so the284 lookup index is rebuilt before publication. New broker integration uses library.get's
|
||||
served blob as write/delete base, independently checks served:path, reads the new text/blob immediately,
|
||||
preserves default-canon null, then sees null after delete. No change to284's query behavior.
|
||||
|
||||
### Authorized baseline-test migration, own commit (mos-1523 via sage-3677)
|
||||
|
||||
File: packages/harness/tests/library-tools.test.mjs. Numstat: +4/-4, exactly.
|
||||
The noncreative LOOKUPS assertion at :33 is unchanged. Every existing lookup schema assertion stays.
|
||||
The four old/new lines are:
|
||||
|
||||
```diff
|
||||
- ? ['web_search', 'web_fetch'] : []), ...LOOKUPS];
|
||||
+ ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
-test('M3: per-role snapshots contain six names, or eight with the C2-08 web grant', t => {
|
||||
+test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
- for (const [i, tool] of tools.filter(t => t.name.startsWith('library_')).entries()) {
|
||||
+ for (const [i, tool] of tools.filter(t => LOOKUPS.includes(t.name)).entries()) {
|
||||
- assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS]);
|
||||
+ assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
```
|
||||
|
||||
Additional schema mutant (mos-1523): add `commit: str("caller commit")` to writeBase in tools.mjs,
|
||||
thereby adding it to either/both write schemas. The new-file schema test must fail both its exact
|
||||
property set and forbidden-property assertions. Run on CLEAR and restore. No further assertion
|
||||
change in library-tools.test.mjs is authorized or made.
|
||||
|
||||
All code/tests/mutants remain unrun. No Node, build or syntax check before Sage's named CLEAR.
|
||||
Static diff --check is clean. Scope additions stay listed above, now including indexFor import and
|
||||
call in the previously approved narrow publication helper. E5 implementation is present; review and
|
||||
acceptance are pending evidence, not claimed complete.
|
||||
|
||||
## 2026-10-23 testing and landing boundary (sage-3682)
|
||||
|
||||
Latest complete dependency2845d587a552 (test/docs only over47db04eba) merged intact as f8f79ea2f.
|
||||
Own tool implementation cd2f50dae and separate mos-1523 migration29455c0c5 are pushed; Webby reads
|
||||
E5 and Dewey checks the migration. Static manifest-r1 covered21 own paths at f8f79ea2f.
|
||||
|
||||
Sage reserves first focused runs/RED-GREEN/mutants for T7, provisionally about22:25Z, unless he
|
||||
explicitly frees an earlier lane. No Node before his CLEAR. This is a schedule, not CLEAR. Full
|
||||
library/bus/harness and shell gates still need their own authorized slot; no gate result is claimed.
|
||||
|
||||
Before the gate, incorporate landed refactor after283/224/284 land while preserving every
|
||||
prerequisite. Under the standing add-commits/no-force-push rule, update this published branch by merge
|
||||
rather than rewriting its pushed history. 288 lands last in the chain; no landing is authorized here.
|
||||
|
||||
## 2026-10-23 E5 static closure and N2 wording
|
||||
|
||||
Dewey dewey-0948: the mos-1523 migration at29455c0c5 is static CLEAN, exactly+4/-4, with the
|
||||
noncreative assertion unchanged and new-file schema coverage meeting every condition. Webby
|
||||
webby-0263: E5 closed on29455c0c5/cd2f50dae (f8f79ea2f;98ca25ab1 only BUILD.md), nothing blocking.
|
||||
Both ran nothing; Webby did not read the scratch manifest, so no manifest verification is implied.
|
||||
|
||||
Webby N2: delete inherited the write base description "or null to create". Delete now overrides only
|
||||
that description: "The existing file's blob from library_get; a missing file cannot be deleted".
|
||||
The reviewed nullable-base schema/required keys/verbs remain unchanged. The row's new schema test
|
||||
pins create wording only on write and existing-file wording without create on delete. No further
|
||||
assertion change in the baseline library-tools.test.mjs. No Node/tests run; still await Sage CLEAR.
|
||||
|
||||
## 2026-10-23 dependency283 follow-up (honker-0195)
|
||||
|
||||
Merged origin/honker/row283-build b2a1f4092 intact, fast-forward lineage from46bad7ea3. It changes
|
||||
supporting.test's helper and Honker's BUILD receipts only; no production source changed. Honker
|
||||
reports library98/98, business231/231 and14/14 mutants killed on that dependency head. Those are
|
||||
his dependency receipts, not verification of288's combined branch. No288 Node/tests run; Sage's
|
||||
CLEAR is still required. The dependency's own gate/landing remains Sage's responsibility.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Row 224 — C2-05b broker lookups and typed tools (#1725)
|
||||
|
||||
Owner: Della; reviewer: Dewey. Assignment sage-2795; scope extension sage-2804.
|
||||
Base: `4f3fbc25a` (Researcher C2-05a). Branch: `della/row224`.
|
||||
Brief: C2-05b in `docs/plans/2026-10-20_case2-rows.md` at `aca16404c`
|
||||
and `51cc0445c`; design section 3, 5.3 M3 and 7.2; C2-05a BUILD rulings.
|
||||
|
||||
## Execution boundary and progress
|
||||
|
||||
Code/docs and local Git only while row 253 holds the gate. No Node (including
|
||||
syntax checks), HTTPS Git, queue claim, test or push until Sage's CLEAR.
|
||||
Tests are written first; their RED/GREEN runs are deferred, not claimed.
|
||||
Queue next is deferred too: its executable uses Node. Sage assigned this row
|
||||
explicitly. After 253 and 216 land, rebase under a new branch name.
|
||||
|
||||
- Pre-flight: query.mjs consumes `{repo,commit,mapping}`; broker cannot import
|
||||
Git/filesystem library code because it is in the sandbox import closure.
|
||||
Runtime injects the host lookup adapter; existing human file/status/sync stay.
|
||||
- Pre-flight: resolved has no project list and a library is never a checkout.
|
||||
Bundle needs an explicit library flag from launcher business configuration.
|
||||
- M3 reading: exact creative base is five lookups plus send_message; retain
|
||||
explicitly granted broker web_search/web_fetch for research/companion under
|
||||
landed C2-08 and sage-2804. No generic spec, task, evidence or decision tools. Residual (mos-1308): those
|
||||
reads lack broker action checks; their typed-tool absence is the only fence
|
||||
for creative roles. M1/M2 deny bash; broker-side refusal is outside this row.
|
||||
Mos confirmed this interpretation in mos-1308; design wording fix belongs to Gosalyn. Other definitions retain their current tools.
|
||||
- Multi-library businesses use an optional project ID; omit it only with one
|
||||
configured library. Universe is required for all five tools, including get.
|
||||
- Missing library uses the brief's `library-not-configured`; argument errors
|
||||
reuse `invalid-request` and C2-05a query codes. No new action or authority.
|
||||
|
||||
## Manifest (draft code, unrun)
|
||||
|
||||
- bus/src/broker.mjs: five lookup verbs, strict args, holder/business rechecks.
|
||||
- bus/src/runtime.mjs: host lookup adapter, stale-before-read sync, served pin.
|
||||
- harness/src/tools.mjs: five schemas; creative exact-set filtering.
|
||||
- harness/src/bundle.mjs: library flag forwarded to typedTools.
|
||||
- cli/src/launcher.mjs: one flag derived from trusted business projects.
|
||||
- bus/tests/library-lookups.test.mjs: real query/broker/socket/runtime cases.
|
||||
- harness/tests/library-tools.test.mjs: schemas, role snapshots, bundle parity.
|
||||
- cli/tests/thread-launch.test.mjs: flag reaches a real launched bundle with no library checkout.
|
||||
- agents/della/work/queue-224/BUILD.md: manifest, rulings and deferred verification ledger.
|
||||
|
||||
## Verification ledger
|
||||
|
||||
No test or syntax-check run yet. Acceptance requires full bus/cli/harness suites
|
||||
and every scripts/test-*.sh on Sage's gate; focused files and affected CLI/
|
||||
seat import-closure tests precede that only in an authorized CLEAR.
|
||||
|
||||
## Coordination
|
||||
|
||||
Huey272 reports no runtime/bundle/launcher overlap. Rocko219 changes launcher
|
||||
THREAD_DEFINITIONS checks only, separate from our buildBundle flag argument.
|
||||
The launcher argument is required: bundle cannot derive library presence from
|
||||
checkout IDs (a library has no agent clone). No role, authority or config schema
|
||||
changes; library project entries are already validated and protected by M9.
|
||||
|
||||
## Mount and pin impact (inspection only)
|
||||
|
||||
No new imports in broker.mjs or tools.mjs; no RUNNER_FILES or sandbox mount
|
||||
addition. runtime.mjs is host-only and already imports library index.mjs.
|
||||
Bundle forwards only a boolean, never a root/mapping/slot/credential. The
|
||||
existing import-closure tests are bus/tests/project-runtime.test.mjs and
|
||||
seat/tests/sandbox.test.mjs. Grep found no literal sha256 pin for runtime.mjs
|
||||
or bundle.mjs; bundle tests derive current source digests through SOURCES.
|
||||
Library sources remain in the existing whole-directory release copy.
|
||||
|
||||
## Runs to schedule only after CLEAR
|
||||
|
||||
Set TMPDIR=/mnt/storage/scratch/tmp. Focused new bus/harness files first,
|
||||
then existing library.test.mjs, library-sync.test.mjs, project-runtime.test.mjs,
|
||||
harness creative/tools/spec-tools/bundle files and affected CLI launch tests.
|
||||
Follow with the required whole bus/cli/harness suites and Sage's full shell gate.
|
||||
No timing claim yet: use the repository/Sage per-file limits and record every
|
||||
command, outcome, elapsed time and timeout before acceptance. No focused skip
|
||||
or pattern run substitutes for whole-file gate evidence.
|
||||
|
||||
## Read-through findings and residuals
|
||||
|
||||
- Library get requires universe although the older design table omitted it;
|
||||
section 3 isolation rule and C2-05a's accepted API are binding.
|
||||
- Query type enum matches mapping.TYPES (five values); manuscript is a distinct
|
||||
lookup and not a find type. Unknown host-path/commit/business/accept fields
|
||||
are refused; explicit project is restricted to this business's library list.
|
||||
- Async reads recheck the live holder and current project revision. A stale
|
||||
read attempts sync, then takes the new served snapshot; a returned fetch
|
||||
failure/divergence keeps serving the old pin. Busy sync uses the current pin.
|
||||
Unreadable state, no served commit or unsafe configuration refuse. Query
|
||||
and sync never hold a SQLite transaction over await.
|
||||
- Processing halt refuses new agent lookup work and rechecks before returning
|
||||
async results. Existing human library file/status/sync behavior is unchanged.
|
||||
- Timer sync and reads still use C2-03a's 60-second contract. No timer tuning or
|
||||
resource-limit schema is introduced by this row. Q222 creative reply settings
|
||||
are in C2-06; no reply guard or writing capability is implemented here.
|
||||
- The generic tool socket's existing 60-second client timeout can precede the
|
||||
server's 120-second Git timeout. This row does not change the generic timeout;
|
||||
a timed-out lookup is read-only and retryable, never a write/outcome claim.
|
||||
- All tests are authored but unrun. No RED, GREEN, syntax, runtime performance,
|
||||
sandbox, broker/harness suite, shell gate, claim or push evidence exists yet.
|
||||
|
||||
## Candidate review checklist (read-through only)
|
||||
|
||||
- [x] Five verbs mapped to real C2-05a methods, all require universe.
|
||||
- [x] Default sole library or explicit owned project; no caller repo/commit.
|
||||
- [x] Human-only file/status/sync gates kept; lookup holder rechecked.
|
||||
- [x] Async socket dispatch includes lookup verbs through LIBRARY_VERBS.
|
||||
- [x] Stale/unknown fetch syncs first; results use current immutable served pin.
|
||||
- [x] Six/eight creative names per mos-1308; other roles retain their tools.
|
||||
- [x] Launcher -> bundle -> typedTools passes presence without a library clone.
|
||||
- [x] No imports added to sandbox source closure; no mount change.
|
||||
- [ ] Execute new tests, RED characterization, full files/suites after CLEAR.
|
||||
- [ ] Rebase after rows 253/216 land, under a new branch name.
|
||||
- [ ] Claim/push only in Sage-authorized gap; Dewey review and Sage gate.
|
||||
|
||||
Candidate preservation commits are explicitly unrun under sage-2795; they are
|
||||
not completion, green-suite or landing claims. No dependencies installed.
|
||||
|
||||
Correction: the first committed-range diff --check found a blank line at EOF
|
||||
in the new library-tools test, missed while it was untracked. Removed in a
|
||||
follow-up local commit; the complete range must be checked again. No tests ran.
|
||||
|
||||
## Shared-log receipts for Sage (sage-2815)
|
||||
|
||||
The shared logs are unchanged from the base in this row. Sage appends these
|
||||
receipts in the landing sweep; they are preserved here, not in the manifest.
|
||||
|
||||
For `BUILD-LOG.md`:
|
||||
|
||||
```text
|
||||
2026-10-22T16:36:30Z | Della row224 C2-05b #1725 preparation | Before: C2-05a queries exist at 4f3fbc25a but no broker lookup verbs or typed tools. Scope: broker/tools plus Sage-authorized runtime/bundle integration, tests and docs. Existing human-only library reads retained; five agent queries require a live holder and trusted configured source. Code/docs only under row253 gate; no Node/test/push/claim. Runtime ownership coordinated with Rocko219/Huey272.
|
||||
```
|
||||
|
||||
For `docs/SESSIONS.md`:
|
||||
|
||||
```text
|
||||
2026-10-22T16:36:30Z | Della (sage-2795 / sage-2804, Q220) | Row 224 C2-05b #1725, della/row224 base 4f3fbc25a | Read both pinned briefs and C2-05a source; preparing broker/typed tools and authorized host runtime/bundle seams. No queue command, Node, HTTPS Git, claim, test or push during Gander gate. Dewey reviewer; Rocko/Huey runtime coordination sent. Tests and integration verification deferred to CLEAR.
|
||||
```
|
||||
|
||||
Local-only draft pins 19c0a7adf/7cf39c390 are superseded by the
|
||||
log-free candidate. Saved as local branch della/row224-draft-with-logs before
|
||||
rebuilding the unpublished candidate commit; no remote history changed.
|
||||
|
||||
## Dewey dewey-0852 and Sage sage-2819 — focused corrections
|
||||
|
||||
B1: inline freshness uses max(lastFetch, lastError.at), not lastFetch alone.
|
||||
An error within 60 seconds counts as a recent attempt, so later lookups serve
|
||||
the old served pin without another fetch. Keep inline sync: the brief requires
|
||||
it and businesses without timed libraries have no sync timer. The extended
|
||||
failed-fetch test makes reads at +1, +59,999 and +60,000 ms (one attempt total),
|
||||
then +60,001 ms (second attempt). All reads must return the old commit.
|
||||
|
||||
Required mutant M-B1: remove lastError.at from recentAttempt. The repeated
|
||||
reads must fail the attempted-count assertion. This mutant has NOT been run.
|
||||
|
||||
Residual (Sage will defer): the first inline fetch in each outage window can
|
||||
still exceed the tool timeout (60 seconds, or the runner broker client’s
|
||||
30 seconds). Bounding that fetch is out of scope for 224. Subsequent reads
|
||||
are paced by persisted lastError.at. No claim of first-read outage latency.
|
||||
|
||||
N1: whole bus, cli and harness suites are required, plus the shell gate.
|
||||
If the new thread-launch integration test skips due to sandboxProblem(),
|
||||
name the skip; a skip is not launcher integration proof.
|
||||
N2: cli/tests/thread-launch.test.mjs now depends on library/tests/helpers.mjs
|
||||
mappingEntry() and library/tests/fixtures/mapping.json. Changes to that fixture
|
||||
must rerun the CLI launch file.
|
||||
N3: harness/tests/library-tools.test.mjs compares the type enum to mapping.mjs
|
||||
TYPES, so adding a mapped type cannot silently leave the typed tool behind.
|
||||
|
||||
These edits are local code/docs only. No Node, RED/GREEN run, mutant, claim,
|
||||
HTTPS Git or push. New local pin goes to Dewey/Mos/Sage for focused review.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Row284 C2-05c — supporting scope and served-tree blob (#1789)
|
||||
|
||||
Owner Della; reviewer Dewey; gate Sage. Build start sage-3658/3660 (10-23).
|
||||
Branch della/row284. Original prerequisite base Honker28346bad7ea3;
|
||||
merge fc05bcbc3 carries22423816cf28 and2164f3fbc25a intact. Only conflict was
|
||||
CLI thread-launch's added footer tests: retained both full test bodies and
|
||||
assertions. No prerequisite code copied. Every prerequisite needs its own
|
||||
review/mutants/landing under mos1351; rebase after283 lands.
|
||||
|
||||
## Execution status
|
||||
|
||||
UNTESTED. No Node, node--check, test or mutant run before Sage CLEAR. First
|
||||
own commit is the authorized old-test migration; the implementation follows.
|
||||
Sage authorizes an immediate untested push. No queue write or shared-log edit.
|
||||
224 stays first;284 is critical to Bubba288. T4 for224 is not a CLEAR yet.
|
||||
|
||||
## Binding readings
|
||||
|
||||
Brief C2-05c at94c33a43c:1737-1789, Q226, sage2918 and mos1330 item3.
|
||||
get defaults canon; explicit playground/supporting opt-in; mismatch null.
|
||||
Sort full outgoing/incoming lists supporting, playground, canon before paging,
|
||||
keep cross-scope links and universe filter. Manuscript stays canon/playground.
|
||||
No all/array scope, write tool, guard, mapping or creative-name-count change.
|
||||
|
||||
## mos-1330 old-test migration (separate commit)
|
||||
|
||||
All paths below are packages/library/tests/query.test.mjs. Baseline file at
|
||||
fc05bcbc3 matches216's file. Source assertions retained; negative calls receive
|
||||
explicit scope too, so universe isolation cannot pass on default-scope denial.
|
||||
|
||||
Call/assertion 348 -> 348:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md' });
|
||||
// NEW
|
||||
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });
|
||||
```
|
||||
|
||||
Call/assertion 350 -> 350:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md' }), null);
|
||||
// NEW
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);
|
||||
```
|
||||
|
||||
Call/assertion 366 -> 366:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path }), null);
|
||||
// NEW
|
||||
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);
|
||||
```
|
||||
|
||||
Call/assertion 367 -> 367:
|
||||
|
||||
```js
|
||||
// OLD
|
||||
const own = await get(src(ref), { universe: 'harbor', path });
|
||||
// NEW
|
||||
const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });
|
||||
```
|
||||
|
||||
Assertion 349 retained, OLD == NEW:
|
||||
|
||||
```js
|
||||
assert.equal(rin.scope, 'playground');
|
||||
```
|
||||
|
||||
Assertion 368 retained, OLD == NEW:
|
||||
|
||||
```js
|
||||
assert.equal(own.scope, 'playground');
|
||||
```
|
||||
|
||||
The null assertions at350 and366 are the complete OLD/NEW expressions above:
|
||||
expectation remains null. Added one separate no-scope playground-get -> null
|
||||
behavior test. It is unrun and expected to expose old get behavior until the
|
||||
new scope implementation lands. This migration is authorized by mos1330 under
|
||||
mos1272; no check removed or weakened. Dewey checks this list in manifest review.
|
||||
|
||||
## 283 surface and blob choice
|
||||
|
||||
Inspected actual46bad7ea3: optional editor.user is present. reader's parsed
|
||||
file records and index entries still discard tree oid. In owned query.mjs use
|
||||
the existing bounded listTree(repo, servedCommit) to obtain exact path oid,
|
||||
not HEAD and not a user-supplied revision expression. No reader/index edit.
|
||||
Tradeoff: one additional bounded tree listing per get; latency unmeasured.
|
||||
Recheck after rebasing to283's actual landing.
|
||||
|
||||
## Intended owned paths
|
||||
|
||||
query.mjs; broker.mjs lookup branch; tools.mjs lookup schemas/descriptions;
|
||||
library query.test.mjs/query-fixture.mjs; bus library-lookups.test.mjs;
|
||||
harness library-tools.test.mjs; this BUILD.md. Other files are prerequisites.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey manifest approval, whole library/bus/harness suites and every
|
||||
scripts/test-*.sh on Sage gate. Before review grep all package tests for enum
|
||||
pins and canon/playground pairs. Keep shared logs out; send receipts to Sage.
|
||||
No test or mutant evidence claimed. Manifest/digest follows complete code.
|
||||
|
||||
## Implementation head (UNTESTED, sage-3658)
|
||||
|
||||
- query scope accepts supporting for find/search/get. get defaults canon,
|
||||
mismatch null. Manuscript passes a separate two-value scope allowlist.
|
||||
- get returns blob from listTree(source.repo,index.commit) after scope/universe
|
||||
filtering. The same index.commit supplies the text; source HEAD is irrelevant.
|
||||
- Full outgoing and incoming link lists are stably sorted by supporting,
|
||||
playground,canon before combining/paging. All visible cross-scope links stay;
|
||||
universe/hybrid visibility still applies. Scope metadata isn't added to links.
|
||||
- Broker only adds optional scope to library.get's strict fields. No new verbs.
|
||||
- Typed scope expands find/search/get only; manuscript retains its own two-value
|
||||
enum. Names/M3 counts remain the row224 six/eight, no write tools added.
|
||||
- New tests: book/universe/draft supporting find/search/get; defaults/mismatch;
|
||||
absent supporting prefix; source HEAD advances but each scope's blob equals
|
||||
served:path; 300 outgoing plus300 incoming links ordered across bounded pages,
|
||||
retaining cross-scope links and dropping other-universe links; hybrid source
|
||||
stays canon-only; no all/array scope; manuscript rejects supporting; broker
|
||||
scope/blob forwarding; typed enums and unchanged names. All unrun.
|
||||
|
||||
## Additional old assertion pin (brief Gate)
|
||||
|
||||
harness/tests/library-tools.test.mjs:49 at23816cf28 (line49, unchanged in this head):
|
||||
OLD find scope enum ['canon','playground']; NEW ['canon','playground','supporting'].
|
||||
This is the exact scope-enum pin the brief directs updating. Existing six/eight
|
||||
name snapshots and TYPES equality assertion are unchanged. The new manuscript
|
||||
pin intentionally remains ['canon','playground']. No other two-scope test pin
|
||||
was found by rg over packages/*/tests at this build's prerequisite merge.
|
||||
|
||||
## Proposed mutation evidence (reviewer confirms final set)
|
||||
|
||||
All unrun. Require assertion failures with whole-file runs and pristine restore:
|
||||
M1 remove supporting from query SCOPES -> supporting lookup cases.
|
||||
M2 drop get's requested-scope comparison -> default/mismatch null cases.
|
||||
M3 use source HEAD for blob lookup -> served-tree/advanced-HEAD equality case.
|
||||
M4 remove link rank sort -> multi-page outgoing/incoming expected order.
|
||||
M5 restrict links to requested scope -> cross-scope link counts/order case.
|
||||
M6 drop sees() from visible/link filtering -> second-universe/hybrid cases.
|
||||
M7 allow supporting in manuscript validator -> manuscript refusal case.
|
||||
M8 omit get scope from broker fields -> real broker supporting get case.
|
||||
M9 widen typed manuscript enum -> typed-scope separation case.
|
||||
|
||||
## Inspection and pending gate
|
||||
|
||||
No production imports change in broker/tools, so no new sandbox closure file
|
||||
or mount. query.mjs is host library code and adds existing reader.listTree;
|
||||
no new package/export/copy-path file. rg found the one old find scope pin plus
|
||||
the intentional new manuscript two-scope pin. Whole library/bus/harness and
|
||||
shell gates remain required. CLI integration/merge context is prerequisite224/
|
||||
219, not this row's source delta; any changed gate expectation goes to Sage.
|
||||
Node checks, tests, mutants and elapsed-time evidence remain absent pending
|
||||
CLEAR. The first test-migration commit and this implementation are distinct.
|
||||
|
||||
Sage sweep receipt: Della row284 builds on28346bad7ea3 plus intact224 merge;
|
||||
mos1330 old-test migration separate, supporting scope/blob implementation and
|
||||
fixtures authored, completely UNTESTED. No shared-log/queue write or live action.
|
||||
|
||||
## Current assertion anchors after implementation imports
|
||||
|
||||
The separate migration commit0ed4fbab9 retains its complete OLD/NEW text
|
||||
above. Adding only the fs/path imports in the implementation shifts anchors:
|
||||
|
||||
query.test.mjs:130: `const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });`
|
||||
query.test.mjs:350: `const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });`
|
||||
query.test.mjs:351: `assert.equal(rin.scope, 'playground');`
|
||||
query.test.mjs:352: `assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);`
|
||||
query.test.mjs:368: `assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);`
|
||||
query.test.mjs:369: `const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });`
|
||||
query.test.mjs:370: `assert.equal(own.scope, 'playground');`
|
||||
|
||||
No result/null assertion changed; scope arguments carry the explicit opt-in.
|
||||
|
||||
## Dewey dewey-0941 static review — round2
|
||||
|
||||
Static scope fc05bcbc3..47db04eba CLEAN; not a final verdict or test evidence.
|
||||
M1-M9 accepted as the final set. Optional M10 not added to this gate.
|
||||
N2 taken: keep the existing canon-path supporting-scope mismatch assertion,
|
||||
and ADD get(Book One/Support/Notes/Helper.md,scope:supporting) -> null under
|
||||
the default mapping with no supporting prefix. The supporting-positive test
|
||||
already returns this same path under withSupporting, so the added probe
|
||||
pins the missing-prefix case directly. No assertion removed or weakened;
|
||||
mos1330's separate migration commit and its retained assertions unchanged.
|
||||
|
||||
N1 proposed DEFERRED/follow-up for Sage: retain each tree oid in reader/index
|
||||
entries so get can return blob without cat-file/whole ls-tree -r -l per page.
|
||||
Current get's result is pinned correctly, but per-page cost grows with the
|
||||
library. No performance measurement yet. This optimization needs reader/index
|
||||
ownership and separate acceptance/performance evidence; do not silently expand
|
||||
284's manifest or change its served-snapshot contract for it.
|
||||
|
||||
No Node, tests, mutants or syntax checks run in this round; hold still applies.
|
||||
@@ -0,0 +1,260 @@
|
||||
# Row 283 (#1788) C2-01d build notes (Honker)
|
||||
|
||||
Append-only. Design: DESIGN.md d2 16e809457, APPROVE webby-0172. Gate owner Sage, reviewer Webby.
|
||||
|
||||
## 2026-10-23 build on 45dca69f6 (sage-3577)
|
||||
|
||||
Branch honker/row283-build from origin/refactor 45dca69f6e4bce52834587f66f2ff33ba839ab8a. The old branch
|
||||
honker/row283 (16e809457) held only the design, so its two commits were cherry-picked as 398314cf4
|
||||
(d1) and 4f66dccde (d2). The DESIGN.md blob equals 16e809457's.
|
||||
|
||||
Commits:
|
||||
- 331653b38: code, README and tests T1-T8.
|
||||
- 28918824c: the survey.test:73 re-pin, alone (mos-1330 item 4).
|
||||
- this file.
|
||||
|
||||
Nothing has run. No node, test or mutant ran during the build: Darkwing held the slot. Every count
|
||||
below is a plan until Sage's CLEAR.
|
||||
|
||||
### Drift check (sage-3577)
|
||||
|
||||
`git diff --stat 94c33a43c 45dca69f6` (design base to build base) over the paths the tests read or
|
||||
hash-pin:
|
||||
- No change in packages/library, packages/business/src/business.mjs or
|
||||
packages/business/tests/business.test.mjs.
|
||||
- No test hash-pins a library file. bus library.test and library-sync.test import index.mjs, and
|
||||
stack-copy.test only checks that index.mjs exists.
|
||||
- webui library-cli.test changed one line and doesn't read scope. The `classify` in webui reads.mjs is
|
||||
the business one, not the library's.
|
||||
|
||||
I redo the drift check on BASE..refactor before the in-review move.
|
||||
|
||||
### N3 (webby-0169): deepEquals of the mapping output outside packages/library
|
||||
|
||||
`git grep -n playground -- 'packages/*/tests/*' ':!packages/library/*'` and the files naming
|
||||
`universes` give two files:
|
||||
- business.test.mjs:20 compares `validateBusinessDocument(...).projects.books` with
|
||||
`checkMapping(mappingEntry())`. That is output against output, so `supporting: []` shows up on both
|
||||
sides. :27 compares only `.playground`.
|
||||
- library-projects-protected.test.mjs compares file bytes (:31, :32) and change paths (:58), never the
|
||||
mapping output.
|
||||
|
||||
No re-pin is needed outside library. Inside library, only the draft deepEqual at mapping.test:48 gains
|
||||
`supporting: []`, as the design says.
|
||||
|
||||
### Q1 departure (sage-2923) and the :73 re-pin (mos-1330 item 4)
|
||||
|
||||
survey.mjs:65 counts `supporting: 0` and formatSurvey prints it. T8 is the only added test
|
||||
(survey.test "C2-01d: with --mapping the survey counts supporting files").
|
||||
|
||||
mos-1330 item 4, packages/library/tests/survey.test.mjs:73, commit 28918824c:
|
||||
- old: `'scopes canon 5, playground 1, unmapped 3; indexed 6',`
|
||||
- new: `'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',`
|
||||
|
||||
N2: the pin gains only `supporting 0`. No other text on the line or in that test moved.
|
||||
|
||||
### Q2 tradeoff (webby-0169)
|
||||
|
||||
A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. That goes past
|
||||
the brief, but it is the fail-closed reading, and it uses an existing code: classify checks supporting
|
||||
first, so that playground prefix could never match. The cost is that she can't nest a playground under
|
||||
a supporting folder, though she can nest the other way (a playground holding a supporting folder).
|
||||
|
||||
### Handoffs
|
||||
|
||||
- Q4 (webby-0169, sage-2930): the M12 delete rule ("A delete also needs the path to exist at the tip as
|
||||
a 100644 blob", case2 DESIGN.md :308) is not in supportingPath. It binds row 288 (C2-02) via Gitea
|
||||
#1793 comment 30738, with tests for a missing path and a non-100644 entry.
|
||||
- A save with no `editor` refusing is C2-02's (DESIGN.md section 1).
|
||||
|
||||
### Choices the design left open
|
||||
|
||||
- The book loop is now `againstBooks(owners, list, code)` in mapping.mjs, called once per list. The
|
||||
playground's messages and codes are byte-equal to before.
|
||||
- A non-string path refuses `library-write-story-path` ("the path must be a string") as part of
|
||||
check 1. A non-array `tree` is a TypeError (DESIGN.md section 3).
|
||||
- Check 8 builds the tree's names as every path and every folder above it, keyed by
|
||||
`normalize('NFC').toLowerCase()`. A name on the way refuses if its key holds any name other than
|
||||
itself, so a tree that already holds case twins refuses a write to either (R7).
|
||||
|
||||
### Mutants (exact strings)
|
||||
|
||||
Each applies one replacement to the build tip. Run one at a time, and restore with `git checkout --`
|
||||
on the one file before the next.
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M1 | src/scope.mjs | `const prefix = entry.supporting.find(p => inside(p, path));` | `const prefix = entry.supporting.find(p => inside(p, path) && !entry.playground.some(q => inside(q, path)));` | scope.test "supporting comes before playground", supporting.test "passes at book level" (both-lists path) |
|
||||
| M2 | src/mapping.mjs | `if ((inside \|\| holding) && owner !== id)` | `if ((inside \|\| holding) && owner !== id && list !== 'supporting')` | mapping.test "each supporting prefix rule", business.test C2-04 |
|
||||
| M3 | src/supporting.mjs | `for (const p of own) if (LINKS.has(modes.get(p)))` | `for (const p of own) if (false && LINKS.has(modes.get(p)))` | supporting.test "symlink or gitlink" |
|
||||
| M4 | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.normalize('NFC');` | supporting.test "case or Unicode form" |
|
||||
| M4b | src/supporting.mjs | `const fold = name => name.normalize('NFC').toLowerCase();` | `const fold = name => name.toLowerCase();` | supporting.test "case or Unicode form" (NFD case) |
|
||||
| M5 | src/supporting.mjs | `Buffer.byteLength(path, 'utf8') > MAX_BYTES` | `path.length > MAX_BYTES` | supporting.test "1025 bytes" |
|
||||
| M6 | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[<>]/;` | mapping.test "editor is optional" |
|
||||
| M6b | src/mapping.mjs | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;` | `const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f]/;` | mapping.test "editor is optional" |
|
||||
| M7 | src/supporting.mjs | `if (place.scope !== 'supporting' \|\| place.universe !== universe)` | `if (place.scope !== 'supporting')` | supporting.test "outside the universe's supporting folders" |
|
||||
| M8 | src/mapping.mjs | `if (sid === pid && s !== p && holds(s, p))` | `if (false && sid === pid && s !== p && holds(s, p))` | mapping.test "each supporting prefix rule" (Workshop case) |
|
||||
|
||||
(The `\|` in the table is an escaped `|` in the source text.) Each mutant runs the one test file named,
|
||||
plus business.test for M2, under `timeout -k 10 600`. A mutant passes the gate only when its run is red
|
||||
on the named test and the restored file's sha256 equals the build tip's.
|
||||
|
||||
### Gate plan (on Sage's CLEAR)
|
||||
|
||||
1. The touched files whole, each under `timeout -k 10 600`: library mapping, scope, supporting and
|
||||
survey tests, and business.test.mjs. Counts go below.
|
||||
2. M1-M8 as above.
|
||||
3. The full gate, under mos-1275 timeouts: `node --test packages/library/tests/` and
|
||||
`node --test packages/business/tests/` each under `timeout -k 10 1800`, and every scripts/test-*.sh
|
||||
under `timeout -k 10 300`. pipefail and tee, with skips named (test-task Docker groups).
|
||||
4. Before in-review: grep packages/*/tests for a 64-hex pin on any changed file and for the changed
|
||||
message text, then the drift check on BASE..refactor.
|
||||
|
||||
## 2026-10-23 Webby early look (webby-0227, static, not a verdict)
|
||||
|
||||
- E1: business.test.mjs is an existing test file with a 4-line addition after the playground refusal.
|
||||
The in-review request lists it next to survey.test:73.
|
||||
- E3 fixed: supportingPath now runs the string check before `JSON.stringify`, so a BigInt (or any
|
||||
non-string) path refuses `library-write-story-path` instead of throwing a TypeError.
|
||||
supporting.test's string case now covers 7, null, undefined, 1n and an array.
|
||||
- E2 open: `editor` takes surrounding whitespace, and git trims it at commit, so the saved ident and
|
||||
the commit ident can differ. DESIGN.md section 1 says "non-empty after trim". Refusing
|
||||
`value.trim() !== value` would change that approved rule, so it went to Sage instead of into the
|
||||
build.
|
||||
|
||||
## 2026-10-23 E2 departure (sage-3601)
|
||||
|
||||
A departure from DESIGN.md section 1 (which stays unedited). The design says each `editor` value is
|
||||
"non-empty after trim". Sage ruled that surrounding whitespace refuses too, because git trims an
|
||||
ident at commit, so a padded value would save one name and commit another. mapping.mjs now adds
|
||||
`if (value.trim() !== value) bad(`editor.${key} must not start or end with whitespace`)` after the
|
||||
IDENT_BAD check. `trim()` covers Unicode whitespace, so U+00A0 and U+3000 refuse as well. Inner
|
||||
whitespace is kept ("Her Middle Name" passes).
|
||||
|
||||
T3 gains a case per key for leading, trailing and both-ends spaces, U+00A0 and U+3000, plus one inner
|
||||
whitespace pass.
|
||||
|
||||
M6 grows a third part:
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6c | src/mapping.mjs | `if (value.trim() !== value) bad(` | `if (false && value.trim() !== value) bad(` | mapping.test "editor is optional" (padded cases) |
|
||||
|
||||
Fixture grep: `git grep -nE "\beditor\b|\bauthor\b" -- 'packages/library/tests/*' 'packages/business/tests/*'`,
|
||||
leaving out mapping.test.mjs, finds nothing. packages/business/tests has no fixtures directory. The
|
||||
library fixture mapping.json has no `editor`. The positive control (`vaultPrefix` in mapping.json)
|
||||
matches once. No fixture maps an editor or author with surrounding whitespace.
|
||||
|
||||
## 2026-10-23 E4 (webby-0230, sage-3606), same departure as E2
|
||||
|
||||
Claimed: `queue move 283 in-progress`, op honker-283-start, rev 3642, 19:10:49Z (briefed→in-progress).
|
||||
|
||||
Git's ident.c `crud()` strips any byte <= 0x20 and `, : ; < > " \ '` from both ends of an ident. I
|
||||
checked that with git 2.55.0: `Jane,` `"Jane"` `'Jane'` `Jane;` `Jane:` `Jane\` and `,Jane` all
|
||||
commit as `Jane`, while `Jane.` and `Ja,ne` are kept. mapping.mjs now takes the set from crud()
|
||||
rather than from that list: `IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/`.
|
||||
The E2 check became `if (value.trim() !== value || IDENT_CRUD_END.test(value))`, which refuses
|
||||
library-mapping-invalid with "editor.<key> must not start or end with whitespace or any of , : ; " ' \".
|
||||
`trim()` still catches Unicode whitespace (U+00A0, U+3000), which crud() leaves alone.
|
||||
|
||||
Inner characters: IDENT_BAD (`/[\u0000-\u001f\u007f-\u009f<>]/`) already refuses `<`, `>`, newline,
|
||||
tab and every other control anywhere in the value. T3 now pins that at the start, the end and the
|
||||
middle. Inner `, : ; " ' \` are kept, since git keeps them, and T3 pins that too.
|
||||
|
||||
Tests:
|
||||
- T3 has one case per character in [space, U+00A0, U+3000, `,`, `:`, `;`, `"`, `'`, `\`], at the start
|
||||
and at the end, for each key. `<`, `>`, newline and tab are pinned at the start, end and middle.
|
||||
Four accepted names have inner crud characters.
|
||||
- New round-trip test, "an accepted editor round-trips through a real git commit unchanged". It
|
||||
commits in a scratch repo with editor `Doe, Jane Q.` / `[email protected].` as author and
|
||||
committer, with GIT_AUTHOR_* and GIT_COMMITTER_* stripped from the env, and asserts `git log`
|
||||
shows exactly the saved values. I preflighted that commit with git 2.55.0 by hand: both values came
|
||||
back unchanged.
|
||||
|
||||
M6c now replaces the whole end check (this supersedes the earlier M6c row):
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6c | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (false)` | mapping.test "editor is optional" (end cases) |
|
||||
| M6d | src/mapping.mjs | `if (value.trim() !== value \|\| IDENT_CRUD_END.test(value))` | `if (value.trim() !== value)` | mapping.test "editor is optional" (`,` `:` `;` `"` `'` `\` cases) |
|
||||
|
||||
## 2026-10-23 E5 (webby-0236, not blocking), fixed
|
||||
|
||||
The round-trip test stripped only GIT_AUTHOR_* and GIT_COMMITTER_*. With GIT_DIR, GIT_WORK_TREE or
|
||||
GIT_INDEX_FILE set (inside a hook, say), GIT_DIR beats `-C root` and the commit would land in the
|
||||
outer repo, and the test read global and system config. It now deletes GIT_DIR, GIT_WORK_TREE,
|
||||
GIT_COMMON_DIR and GIT_INDEX_FILE (as queue/tests/helpers.mjs gitEnv does) and sets
|
||||
GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1 (as ledger.test.mjs does). The `-c user.*`
|
||||
ident still applies. Test only; no mutant changes.
|
||||
|
||||
## 2026-10-23 sage-2925: optional editor.user (sage-3649)
|
||||
|
||||
sage-2925 (recorded in ludwig/row286-r2 agents/ludwig/work/queue-286/BUILD.md, Q4) ruled that row 283
|
||||
adds an optional `editor.user`, her Gitea login for the `library-human` slot, which row 288 (C2-02)
|
||||
passes to `humanCredential(context, {user})`. It reached this row after the DESIGN.md d2 approval
|
||||
(ludwig-0068, bubba-0009, sage-3649), so it departs from DESIGN.md section 1 ("exactly the keys
|
||||
`name` and `email`"). DESIGN.md stays unedited.
|
||||
|
||||
- mapping.mjs: `only(entry.editor, ['name', 'email', 'user'], 'editor')`. When present, `user` must be
|
||||
a string matching USER (`/^[A-Za-z0-9_.-]{1,100}$/`, the entry `user` check), else
|
||||
library-mapping-invalid "editor.user must be a plain account name". The output carries `user` only
|
||||
when given. An absent `editor.user` refusing library-human-missing is 288's (bubba-0009).
|
||||
- README: the `editor` line names `user` and the end-character rule from E2/E4.
|
||||
- Tests: T3 (mapping.test "editor is optional") gains one accepted case (`her.login-1`, kept in the
|
||||
output) and refused cases for '', an inner space, '@', 101 characters, 7 and null. They go in
|
||||
mapping.test with the other editor cases, not in the new supporting.test, which tests write paths.
|
||||
- No test outside mapping.test reads `editor` or pins mapping.mjs or README (git grep).
|
||||
|
||||
| Mutant | File | Replace | With | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M6e | src/mapping.mjs | `if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' \|\| !USER.test(entry.editor.user)))` | `if (false)` | mapping.test "editor is optional" (editor.user refusals) |
|
||||
| M6f | src/mapping.mjs | `...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {})` | `...({})` | mapping.test "editor is optional" (`her.login-1` kept) |
|
||||
|
||||
## 2026-10-23 T2 run (sage-3695 CLEAR, lane A, library and business only)
|
||||
|
||||
Node v26.8.1. refactor was e2b873390 (226 landed); `git diff --stat 45dca69f6 e2b873390 -- packages/library
|
||||
packages/business` is empty. TMPDIR was a mktemp -d under /mnt/storage/scratch/tmp, with pipefail.
|
||||
Singles and mutants ran under `timeout -k 10 600`, suites under `timeout -k 10 1800`.
|
||||
|
||||
First-run red (mos-1272, placed): the first base run on 46bad7ea3 at 20:01:09Z was red in
|
||||
supporting.test "malformed paths refuse by class, in order". 5 of 6 passed, with `TypeError: Do not know
|
||||
how to serialize a BigInt`. The cause was the test helper, not the product: `refuses()` labelled each
|
||||
assertion with `JSON.stringify(path)`, and the 1n case added for E3 can't be stringified. In c18ad3e12
|
||||
the label uses `util.inspect(path)`, a 2-line test-only change. The product's 1n refusal was never in
|
||||
question: supportingPath type-checks before it stringifies (E3). The whole run below is on c18ad3e12.
|
||||
|
||||
Base, the touched files whole (20:01:43Z), all rc=0:
|
||||
- library mapping.test 16/16, scope.test 6/6, supporting.test 6/6, survey.test 7/7;
|
||||
- business business.test 21/21;
|
||||
- 0 skipped, 0 cancelled, 0 todo.
|
||||
|
||||
Mutants (20:01:44Z). Each was applied by exact string with count 1, then restored with `git checkout --`.
|
||||
Every restored sha256 equals HEAD's blob, and the tree was clean after.
|
||||
|
||||
| Mutant | Run | Result | Failing test |
|
||||
|---|---|---|---|
|
||||
| M1 | scope.test | rc=1, 5/6 | supporting comes before playground; a folder in both lists is supporting… |
|
||||
| M1 | supporting.test | rc=1, 5/6 | a path passes at book level, universe level, in a draft and in a folder in both lists |
|
||||
| M2 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
|
||||
| M2 | business.test | rc=1, 20/21 | C2-04: library mappings load without roots, remain frozen, and retain mapping refusals |
|
||||
| M3 | supporting.test | rc=1, 5/6 | a symlink or gitlink on the way, or at the path itself, refuses |
|
||||
| M4 | supporting.test | rc=1, 5/6 | a name that differs from a tree name only in case or Unicode form refuses… |
|
||||
| M4b | supporting.test | rc=1, 5/6 | the same case/Unicode test |
|
||||
| M5 | supporting.test | rc=1, 5/6 | a path of exactly 1024 bytes passes, and 1025 bytes… refuses |
|
||||
| M6 | mapping.test | rc=1, 15/16 | editor is optional, kept when given, and refuses anything but a plain name and email |
|
||||
| M6b | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6c | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6d | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6e | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M6f | mapping.test | rc=1, 15/16 | the same editor test |
|
||||
| M7 | supporting.test | rc=1, 5/6 | a path outside the universe's supporting folders refuses |
|
||||
| M8 | mapping.test | rc=1, 15/16 | each supporting prefix rule refuses with its code |
|
||||
|
||||
14 of 14 mutants killed, each on its named test only.
|
||||
|
||||
Suites (20:01:47Z):
|
||||
- `packages/library`: `node --test tests/*.test.mjs` rc=0, 98/98, 0 skipped.
|
||||
- `packages/business`: `node --test tests/` rc=0, 231/231, 0 skipped.
|
||||
|
||||
Not run here: every scripts/test-*.sh and the other packages. Those are Sage's 283 gate, after Webby's
|
||||
APPROVE.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Row 283 (#1788) C2-01d design, draft 2 (Honker)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01d" at refactor 94c33a43c. Sources: the design at
|
||||
agents/gosalyn/work/case2/DESIGN.md b68d79663, the ruling after r11 (:207), the M12 paragraph (:300),
|
||||
1.1 (:755) and 1.2 (:817). Base 94c33a43c. Reviewer Webby, gate owner Sage. Nothing is built yet. The
|
||||
build waits for 282 to land (sage-2906).
|
||||
|
||||
## 1. Mapping (packages/library/src/mapping.mjs)
|
||||
|
||||
`supporting`:
|
||||
- Every universe kind takes an optional `supporting` list: plain `['name','books','playground','supporting']`,
|
||||
hybrid adds it to its seven keys, and a draft gets `['kind','name','playground','supporting']`.
|
||||
- Each value is checked as a playground prefix is: a string ending in `/` whose body passes `folder()`.
|
||||
- The output carries `supporting` on every universe, `[]` when absent, the way a draft carries
|
||||
`books: []`. classify then needs no `?? []`. The business writer writes the raw document
|
||||
(organization.mjs:195), not this output, so no business file changes shape.
|
||||
- The prefix rules, across every entry (ruling after r11):
|
||||
- A supporting prefix holding another supporting prefix, or one listed twice, refuses
|
||||
`library-mapping-invalid`.
|
||||
- A prefix equal to one of its own entry's book folders, or holding one, refuses `library-mapping-invalid`.
|
||||
- A prefix inside or holding another entry's book folder refuses `library-supporting-other-universe`.
|
||||
- Level comes from where the prefix sits, as for the playground: inside one of its own entry's books
|
||||
is book level, and outside every book is universe level. A draft's prefixes are all universe level.
|
||||
- Between the two lists (my choice, Q2):
|
||||
- A supporting prefix may equal a playground prefix of the same entry. That folder is writable.
|
||||
- A playground prefix may hold a supporting prefix of the same entry. The inner folder is supporting
|
||||
and the rest stays playground.
|
||||
- A supporting prefix that holds a playground prefix refuses `library-mapping-invalid`. classify
|
||||
checks supporting first, so that playground prefix could never match.
|
||||
- A supporting prefix equal to, holding or inside a playground prefix of another entry refuses
|
||||
`library-mapping-invalid`. Otherwise one path would have two universes.
|
||||
- The book loop at mapping.mjs:128-140 becomes a helper that both lists call with their own code and
|
||||
label. The playground's messages and codes stay byte-equal, so no existing test text moves.
|
||||
|
||||
`editor`:
|
||||
- The entry takes an optional `editor`. When present, it is an object with exactly the keys `name` and
|
||||
`email` (via `only()`). Each value is a string that is non-empty after trim and has no control
|
||||
character (`[\u0000-\u001f\u007f-\u009f]`), `<` or `>` (webby-0169 R1: git drops them from an ident
|
||||
and commits under a different name, so C2-02 would never see a failure). Anything else refuses
|
||||
`library-mapping-invalid`.
|
||||
- The output carries `editor: { name, email }` only when present, as `user` and `vaultPrefix` do. A save
|
||||
with no `editor` refusing is C2-02's job.
|
||||
|
||||
## 2. Scope (scope.mjs)
|
||||
|
||||
- `classify` gets a supporting loop before the playground loop (design 1.2). It returns `place(...)`
|
||||
with `{ scope: 'supporting', universe, book }`. The book is the book folder holding the prefix, or
|
||||
null at universe level and in a draft. The type and folder come from the first folder under the
|
||||
prefix, as for the playground.
|
||||
- The `.md` check stays first, so a `.canvas` or `.base` under a supporting prefix stays unmapped.
|
||||
- The comment above `classify` names the three prefix scopes and the order.
|
||||
|
||||
## 3. supportingPath (new supporting.mjs)
|
||||
|
||||
`supportingPath(mapping, universe, path, tree)` returns `path` or throws
|
||||
`LibraryError('library-write-story-path', ...)`. It is pure and reads no git.
|
||||
|
||||
- `tree` is the array `listTree` returns (reader.mjs:26, `{ mode, type, oid, size, path }`), so the
|
||||
C2-02 broker passes its listing as it is (Q3). A non-array `tree` throws a TypeError, because that is
|
||||
a caller bug, not a path class.
|
||||
- The checks run in this order, each with its own message naming the class:
|
||||
1. The path must be a string of at most 1,024 UTF-8 bytes (`Buffer.byteLength`).
|
||||
2. It must have no control character, using the same control class as `editor` (not `<` or `>`).
|
||||
3. It must have no backslash.
|
||||
4. It must not be absolute (a leading `/`).
|
||||
5. It must have no empty, `.` or `..` segment. That also catches `//` and a trailing `/`.
|
||||
6. `classify(mapping, path)` must give scope `supporting` and that same `universe`. This one check
|
||||
covers an unknown universe, an unmapped path, a `.canvas`, `.base` or attachment, a canon or
|
||||
story path, a playground path that isn't also supporting, and another universe's prefix. The scope
|
||||
rule then has one source, and M1 below hits both.
|
||||
7. Every proper prefix of the path (`a`, `a/b`, ...) and the path itself must not be in `tree` with
|
||||
mode `120000` (symlink) or `160000` (gitlink).
|
||||
8. No prefix of the path, and not the path itself, may equal a tree path or a tree directory
|
||||
(derived from the entries' paths) after `normalize('NFC').toLowerCase()` on both sides, unless
|
||||
it equals that tree path exactly. NFC as well as case, because APFS treats NFC and NFD forms of
|
||||
a name as one file (webby-0169 N1).
|
||||
Directories are checked too, because the folders under a supporting prefix are hers to name. A
|
||||
path in a tree that holds both `a.md` and `A.md` refuses, which matches R7.
|
||||
- The refusal messages quote the path with JSON, except for the control-character class, whose message
|
||||
leaves the path out.
|
||||
- The delete rule ("A delete also needs the path to exist at the tip as a 100644 blob", M12) isn't in
|
||||
the brief's supportingPath list. It stays with C2-02's delete (Q4).
|
||||
The C2-02 brief doesn't name it (only design :308), so BUILD.md carries it to C2-02 under Handoffs
|
||||
(webby-0169 Q4).
|
||||
- index.mjs exports `supportingPath`.
|
||||
|
||||
## 4. README (packages/library/README.md)
|
||||
|
||||
- Mapping: `supporting` and its rules, the cross-list rules, and `editor`.
|
||||
- Refusals: a `library-supporting-other-universe` row, and the new cases in the
|
||||
`library-mapping-invalid` row.
|
||||
- Scope: supporting first, then playground, then canon.
|
||||
- A new section, "Agent write paths", for supportingPath and its classes.
|
||||
|
||||
## 5. Callers of classify outside files owned (Q1 for Sage)
|
||||
|
||||
- survey.mjs:65 builds `{ canon: 0, playground: 0, unmapped: 0 }` and increments
|
||||
`scopes[classify(...).scope]`. A supporting file adds `supporting: NaN`, and formatSurvey (:92) never
|
||||
prints it. I propose a departure: add `supporting: 0` and print it, so the line becomes
|
||||
`scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6`. That re-pins survey.test:73 and
|
||||
adds a count test. Without it, the survey reports garbage once she marks a folder. The departure
|
||||
covers two files, packages/library/src/survey.mjs and packages/library/tests/survey.test.mjs.
|
||||
- No change in the rest, each for a reason:
|
||||
- reader.mjs:183 skips only unmapped, so supporting files are indexed like playground files.
|
||||
- R5 (:207) flags only canon to playground, as design 1.3 R5 says. A canon link to a supporting file
|
||||
raises nothing.
|
||||
- render.mjs:23 gives supporting a null badge. The supporting badge belongs to C2-10b (design :1415).
|
||||
- mirror.mjs:246 refuses only unmapped. Reading supporting files by scope is C2-05c's.
|
||||
- No bus or webui source filters on scope (git grep at 94c33a43c).
|
||||
- M9 already protects the whole `projects` key from agent edits (library-projects-protected.test). No
|
||||
test is added there.
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- mapping.test:
|
||||
- T1: `supporting` at book level, at universe level, in a hybrid and in a draft. The output carries
|
||||
it, with `[]` when absent. The draft `deepEqual` at :48 gains `supporting: []`.
|
||||
- T2: one test per prefix rule.
|
||||
- These refuse `library-mapping-invalid`: no trailing `/`, a bad folder, one holding another, one
|
||||
listed twice, one equal to its own book, one holding its own book, one holding a playground
|
||||
prefix, and one overlapping another entry's playground.
|
||||
- These refuse `library-supporting-other-universe`: one inside another universe's book, and one
|
||||
holding another universe's book.
|
||||
- An equal same-entry playground passes.
|
||||
- T3: `editor` passes, and absent passes. It refuses `library-mapping-invalid` for a non-object, an
|
||||
array, an extra key, a missing key, an empty or blank value, a non-string, and a control character,
|
||||
`<` or `>` in either value.
|
||||
- scope.test, T4:
|
||||
- Supporting at book level, universe level and in a draft each give their universe and book.
|
||||
- A folder in both lists is `supporting`, and a playground-only folder is `playground`.
|
||||
- A supporting `.canvas` or `.base` is unmapped.
|
||||
- supporting.test (new):
|
||||
- T5 passes at book level, universe level, in a draft, in a both-lists folder, and at exactly 1,024 bytes.
|
||||
- T6 has one refusal per class against a fixture tree with a symlink, a gitlink and a case variant.
|
||||
The classes are:
|
||||
- unmapped, `.canvas`, `.base`, an attachment, canon, playground-only, another universe's
|
||||
supporting prefix, and an unknown universe;
|
||||
- absolute, `..`, `.`, `//`, a trailing `/`, a backslash, a C0 control and DEL;
|
||||
- a symlink folder, a gitlink folder, and a symlink at the path itself;
|
||||
- a case-variant file, a case-variant folder, and an NFD-form variant of an NFC tree name;
|
||||
- 1,025 bytes made with a multi-byte character, so bytes and characters differ.
|
||||
- Each asserts the code `library-write-story-path`.
|
||||
- business.test: T7 adds one assertion that `validateBusinessDocument` refuses
|
||||
`library-supporting-other-universe`.
|
||||
- survey.test (only if Q1 is approved): T8 counts one supporting file, and :73 is re-pinned.
|
||||
|
||||
## 7. Mutants (exact strings go in BUILD.md)
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | classify checks playground before supporting (the brief's mutant) | T4 both-lists, T5 both-lists |
|
||||
| M2 | the other-universe fail for supporting is dropped | T2 |
|
||||
| M3 | the symlink and gitlink check is dropped | T6 |
|
||||
| M4 | the case check is dropped | T6 |
|
||||
| M4b | check 8 drops `normalize('NFC')` | T6 NFD variant |
|
||||
| M5 | the byte length uses `path.length` | T6 1,025 bytes |
|
||||
| M6 | the `editor` control-character check is dropped | T3 |
|
||||
| M6b | the `editor` `<` and `>` check is dropped | T3 |
|
||||
| M7 | supportingPath skips the universe match | T6 other universe |
|
||||
| M8 | the supporting-holds-playground check is dropped | T2 |
|
||||
|
||||
## 8. Gate
|
||||
|
||||
- Webby approves the manifest.
|
||||
- The library and business suites and every scripts/test-*.sh are green on Sage's gate.
|
||||
- Timeouts (mos-1275): each package `timeout -k 10 1800`, each script `timeout -k 10 300`, each single
|
||||
file and mutant `timeout -k 10 600`. These come from my row 256 gate at 10:34Z.
|
||||
- Before in-review, grep packages/*/tests for 64-hex pins on the changed files and for the changed
|
||||
message text, then drift-check BASE..refactor.
|
||||
|
||||
## 9. Questions
|
||||
|
||||
- Q1 (Sage): approve the survey.mjs and survey.test.mjs departure in section 5?
|
||||
- Q2 (Webby): the cross-list rules in section 1?
|
||||
- Q3 (Webby): `tree` as the `listTree` array?
|
||||
- Q4 (Webby): the delete's 100644 check stays with C2-02?
|
||||
- Q5 (Webby): `editor` refuses only blank values and control characters, as the brief says. `<` and `>`
|
||||
would break a git ident. I'd leave them to C2-02's commit step unless you want them refused here.
|
||||
|
||||
## 10. Draft 2 (webby-0169)
|
||||
|
||||
- R1 folded: `editor` refuses `<` and `>` too, with T3 cases and mutant M6b.
|
||||
- Q2 accepted. BUILD.md records the tradeoff: refusing a supporting prefix that holds a playground
|
||||
prefix goes past the brief, but it is the fail-closed reading and uses an existing code. The cost
|
||||
is that she can't nest a playground under a supporting folder, though she can nest the other way.
|
||||
- Q3 accepted. Q4: carried to C2-02 under Handoffs in BUILD.md, and Sage was asked to add it to C2-02's brief.
|
||||
- N1 folded into check 8 (NFC and case), with an NFD case in T6 and mutant M4b.
|
||||
- N2: the survey.test:73 pin gains only `supporting`, and the count test is the only addition
|
||||
(mos-1330, its own commit).
|
||||
- N3: before the build, re-grep tests outside packages/library for a `deepEqual` of the mapping output
|
||||
on the build base.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Row 286: C2-03b push and per-seat credentials for the library mirror (#1791)
|
||||
|
||||
Owner Ludwig. Reviewer Dewey. Gate owner Sage (sage-2909).
|
||||
Brief: `docs/plans/2026-10-20_case2-rows.md`, "## C2-03b", at 94c33a43c. Design 7.2 and the ruling
|
||||
after r11 "agents never write story" in `agents/gosalyn/work/case2/DESIGN.md` at b68d79663.
|
||||
Base: `refactor` at 94c33a43c. The brief cites aca16404c, and lines have moved since:
|
||||
`GRANT_EVIDENCE` is `broker.mjs:253` now, not `:246`.
|
||||
|
||||
Status: code written, local and unrun (18:48Z on). No node until Sage's CLEAR, after row 258's run.
|
||||
|
||||
## What the code does today (94c33a43c)
|
||||
|
||||
- `packages/library/src/sync.mjs:32` `syncCommand(kind, url, {mirror, branch, user, slot})`. It refuses
|
||||
any kind but `fetch`. The env passes `MOSAIC_LIBRARY_HOST`, `_USER` and `_SLOT` to `bin/askpass`.
|
||||
- `bin/askpass` checks `-f` and `! -L` on the slot, then reads it. It doesn't check the uid or the mode.
|
||||
- `sync.mjs:51` `slotPresent` checks lstat: a regular file, our uid, no group or other bits.
|
||||
- The slot path is built in `packages/bus/src/runtime.mjs:211-217` (`syncOptions`) as
|
||||
`<configDir>/secrets/<business>/library-gitea`. The broker only calls `this.#library.sync(context,
|
||||
{accept})` (`broker.mjs:4630`), and the timer calls `libraryModule.sync` directly (`runtime.mjs:533`).
|
||||
- The sync state file has a strict key list in `packages/library/src/mirror.mjs:222`.
|
||||
- `chief-of-staff` is a definition (`STAFF_TO`, `broker.mjs:240`), not a fixed instance name.
|
||||
- A seat's Gitea user is in `gitea_grants.user` and `gitea_users`. Its token file is
|
||||
`<storeRoot>/<business>/<agent>/gitea` (`gitea-grant.mjs:379`). `giteaKeys.held(business, agent)`
|
||||
answers the held case.
|
||||
|
||||
## Plan
|
||||
|
||||
1. `syncCommand(kind, url, {mirror, branch, user, file, commit})`. `kind` is `fetch` or `push`. Same
|
||||
`SAFE` prefix, `-c` keys and env for both. A push is
|
||||
`push --porcelain --no-verify --end-of-options <url> <commit>:refs/heads/<branch>`, with no `+`.
|
||||
`commit` must be a full sha. The env var stays `MOSAIC_LIBRARY_SLOT` but now names whichever file
|
||||
answers: a seat file, the chief's file, slot `library-gitea` or slot `library-human`.
|
||||
2. `push(context, {commit, credential, retried, command})` in `sync.mjs`. git's porcelain line for
|
||||
the ref decides:
|
||||
- `!` with `[rejected]` and `(fetch first)` or `(non-fast-forward)` means the tip moved. Fetch once
|
||||
with the same credential and return `{pushed: false, raced: true, tip}`. With `retried: true`, the
|
||||
same rejection refuses `stale-base`. C2-02 owns the re-apply.
|
||||
- Any other failure refuses `library-push-failed`. git's stderr isn't kept.
|
||||
- A remote that fails C2-04's URL rules refuses `library-remote-invalid` before any git call.
|
||||
3. `bin/askpass` gets `readSecretFile`'s checks, the same way: no symlink, the bus's uid, mode 0600.
|
||||
In sh: `test -f && test ! -L && test -O`, plus a mode check through `stat -c %a`. Only the path
|
||||
reaches that argv. The user check stays an exact match on the one configured user. The check and the
|
||||
read happen at different times (TOCTOU). The bus owns the directories, so I take that as the
|
||||
accepted limit (Q1).
|
||||
4. Credential choice for the fetch, as a pure function `fetchCredential(store, business, keys)` in the
|
||||
bus. The broker exposes it, and `runtime.mjs` passes its answer to `sync` for the verb and the timer:
|
||||
- The chief is the one instance whose definition is `chief-of-staff`.
|
||||
- Evidence: `SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND
|
||||
json_extract(body,'$.method')='gitea' AND json_extract(body,'$.agent')=?`, of any action. It
|
||||
names no project and never reads `gitea_grants`.
|
||||
- No evidence: slot `library-gitea`, `{kind: 'slot'}`.
|
||||
- Evidence, a live `gitea_grants` row for the chief and not held: `{kind: 'chief', user, file}`,
|
||||
with the user from that row. The fallback test never reads the table. This path does, because it
|
||||
needs the user.
|
||||
- Evidence and anything else (revoked, fired or held): `library-fetch-no-grant`, before any file is
|
||||
opened.
|
||||
- No chief instance (fired): Q2.
|
||||
5. The sync state gets `credential: {kind, user} | null`. `library.status` shows it.
|
||||
`library-fetch-no-grant` shows as `lastError.code`. This changes `mirror.mjs`'s key check (Q3).
|
||||
6. Slot `library-human` is `<configDir>/secrets/<business>/library-human`. `humanCredential(...)`
|
||||
refuses `library-human-missing` before any git call when the slot is missing or fails the file checks
|
||||
(lstat: regular file, our uid, 0600). Her Gitea user: Q4.
|
||||
|
||||
## Files owned extension (sage-2922)
|
||||
|
||||
Added to the brief's list: `packages/bus/src/runtime.mjs` (`syncOptions` at :211-217 and the timer's
|
||||
call at :533) and `packages/library/src/mirror.mjs` (the state key list at :222). sage-2922 wrote
|
||||
`packages/library/src/runtime.mjs`, but the file is `packages/bus/src/runtime.mjs`. In mirror.mjs
|
||||
the row 222 contract holds: keys are added and none renamed. Before in-review I grep `packages/*/tests`
|
||||
for hash pins and path reads on both files and check them against every in-review manifest.
|
||||
|
||||
## Scope questions (for Dewey and Sage before code)
|
||||
|
||||
- Q1. The askpass TOCTOU above. Is it accepted as is, or should the helper be a node script that does
|
||||
O_NOFOLLOW plus fstat? If node, it would read the token in process and print it to git's pipe.
|
||||
- Q2. Fired chief. The instance and its `gitea_grants` row are gone, so I can't name the agent for the
|
||||
evidence check. My recommendation fails safe: with no chief instance, the fetch refuses
|
||||
`library-fetch-no-grant` when any gitea grant event exists in the business, and uses the slot only
|
||||
when none exists.
|
||||
- Q3. Files owned names `sync.mjs`, `askpass`, `broker.mjs` and the tests. The choice also needs
|
||||
`packages/bus/src/runtime.mjs` (`syncOptions` and the timer's call) and
|
||||
`packages/library/src/mirror.mjs` (the state key list for `credential`). I'd add both to the
|
||||
manifest, with only those hunks changed.
|
||||
- Q3: approved, sage-2922. See above.
|
||||
- Q4: sage-2922 allowed `user` only if design 7.2 or the ruling after r11 makes it her account. Neither
|
||||
does. `packages/library/README.md:31` at 94c33a43c says "`user` is the bot account name from design
|
||||
step 3, required for sync", so it is the fetch identity and can't be reused for her save. Back to Sage.
|
||||
My recommendation: `humanCredential(context, {user})` takes her login as a parameter, so this row adds no
|
||||
mapping field. The row that adds the save, C2-02 or C2-11a with `editor`, names the field. That
|
||||
needs a mapping schema change, which is Sage's call.
|
||||
Ruled sage-2925: accepted. `humanCredential(context, {user})` refuses `library-human-missing` before
|
||||
any git call when the slot is missing or bad or `user` is missing. This row adds no mapping field.
|
||||
Row 283 (C2-01d) adds an optional `editor.user`, and row 288 (C2-02) passes it in.
|
||||
- Q4 (original). Her Gitea user for `library-human`. The entry's `user` (`mapping.mjs:52`) is today the sync
|
||||
user, the bot's. The design names `editor {name, email}` (C2-11a) but no Gitea login. My
|
||||
recommendation: the entry's `user` becomes her Gitea login, used only with slot `library-human`. The
|
||||
fallback fetch keeps that same user too, because the bot slot was set up for it. Otherwise this row
|
||||
takes the user as a parameter and C2-02 or C2-12b names the field.
|
||||
|
||||
## Tests (brief list, mapped)
|
||||
|
||||
T1 push lands a fast-forward. T2 a moved tip reports the race. T3 a second race refuses `stale-base`.
|
||||
T4 a dead remote refuses `library-push-failed`. T5 askpass answers with the named seat's user and file,
|
||||
and refuses another seat's user, a symlink, 0644 and another uid (with a fallback note if the test can't
|
||||
chown). T6 the fetch uses the chief's file with a live grant, and the slot only with no chief event.
|
||||
T7 revoked (through `grants.revoke`), fired (through `instance.fire`) and held each refuse
|
||||
`library-fetch-no-grant` with a valid slot present, and each asserts the `gitea_grants` row is gone
|
||||
first. T8 a chief grant on another project ends the fallback. T9 T7's three cases with the slot at 0000.
|
||||
T10 a missing `library-human` refuses `library-human-missing` and runs no git. T11 a token search of
|
||||
argv, the mirror config, the run log and the test output. Mutant: a `+` added to the push refspec fails.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The library and bus suites, plus every `scripts/test-*.sh`, pass on Sage's
|
||||
gate. The mutant dies. A token search of the run's output finds nothing.
|
||||
|
||||
## Early look (dewey-0860), folded
|
||||
|
||||
Q1, askpass: sh, with no gap between the check and the read. The helper keeps `test -f` and then:
|
||||
- opens the slot with `exec 3<"$slot"`;
|
||||
- stats the open file with `stat -L -c '%d:%i %u %a %s %F' /dev/fd/3`, and the path itself (an lstat)
|
||||
with `stat -c '%d:%i' -- "$slot"`;
|
||||
- refuses unless the two dev:inode values match, the type is regular, the uid is `id -u`, the mode is
|
||||
600 and the size is at most 16384;
|
||||
- reads the token from fd 3.
|
||||
A symlink or a swap at either moment changes the inode, and the token comes from the file that was
|
||||
checked. This matches `readSecretFile` (`gitea-grant.mjs:167-181`).
|
||||
Residual, the same on both paths: a FIFO swapped in after `test -f` blocks the open. Neither side uses
|
||||
O_NONBLOCK.
|
||||
Swap test T5b: the direct helper test puts a `stat` shim on its own PATH. On its first call the shim
|
||||
renames another file over the slot, then runs the real stat. The helper must exit 1 and print nothing.
|
||||
Mutant M2 drops the inode compare and must fail T5b.
|
||||
|
||||
Q2, the chief: found through `roles` by definition, not through `launch.instances`. A fire only delists
|
||||
the name (`packages/business/src/instance-settings.mjs:355`), and `roles[name]` stays. Only a delete
|
||||
removes it (`deletedDocument`, :70).
|
||||
- Gitea grant evidence for any `roles` entry whose definition is `chief-of-staff` ends the fallback.
|
||||
That includes a fired chief beside a new listed one.
|
||||
- A live credential comes only from the listed chief's live, unheld `gitea_grants` row.
|
||||
- With no `roles` entry of that definition (deleted, or never a chief), any gitea grant event in the
|
||||
business refuses `library-fetch-no-grant`.
|
||||
- Cost, for Sage: row 236 lets seat gitea grants exist before C2-21. So a business with no chief
|
||||
entry and one seat grant loses the slot fallback. That fails safe, but it narrows "an install that
|
||||
runs before C2-21 still syncs".
|
||||
- T7's fired case also asserts that `roles[chief]` still exists after `instance.fire`.
|
||||
|
||||
N1: the `credential` key is optional in `mirror.mjs`'s exact `keys(...)` check (:221), so a pre-286
|
||||
state file still reads. The old fixtures have no `credential`: `library-sync.test.mjs:18`,
|
||||
`library.test.mjs:106` and `mirror.test.mjs:13`. New test T12 reads a pre-286 state file. Row 224
|
||||
(Della, 23816cf28) also changes `packages/bus/src/runtime.mjs`. Whichever lands second rebases and reruns
|
||||
bus whole.
|
||||
|
||||
N2: `[remote rejected]`, a hook refusal, is `library-push-failed`, not a race. Only `[rejected]` with
|
||||
`(fetch first)` or `(non-fast-forward)` counts as a race. The evidence query depends on
|
||||
`body.method` and `body.agent`. gitea `grant.applied` carries both at `broker.mjs:2479` and
|
||||
`grant.revoked` at :1862 and :2797. The spend event (`operation: 'grant.gitea'`, :1093) carries
|
||||
neither, so it doesn't match.
|
||||
|
||||
N3: `fetchCredential` reads the store, so it is the bus's choice function, not a pure one.
|
||||
|
||||
## Rulings sage-2928
|
||||
|
||||
- Q1: Dewey's fix, in sh. On dragon-lin `/bin/sh` is bash, and GNU coreutils stat 9.11 gives
|
||||
`stat -L -c '%d:%i %u %a %s %F' /dev/fd/3` on an open fd. Checked at 18:47Z with a scratch file, not
|
||||
the repo. The helper runs as git's child in the bus process, not under bwrap. T5b stages the swap with
|
||||
the PATH stat shim. If a run shows the swap can't be staged reliably, BUILD.md names M2, which drops the
|
||||
fd compare, and it gets killed.
|
||||
- Q2: chief by `roles[*].definition === 'chief-of-staff'`. The blanket refusal applies only with no such
|
||||
entry. Departure from the brief's "an install that runs before C2-21 still syncs", accepted in
|
||||
sage-2928: it refuses rather than widens, and every install enrolls the chief at initial configuration
|
||||
(F40, Q226). The refusal's message names the fix: "add the chief of staff, or grant the library
|
||||
through C2-21".
|
||||
- N1: as above.
|
||||
|
||||
## Build notes (code written; nothing run yet; dewey-0864 folded)
|
||||
|
||||
Files changed:
|
||||
- `packages/library/src/sync.mjs`: `syncCommand` takes `push` with a full-sha `commit`; `push()`;
|
||||
`humanCredential()`; `sync()` takes `credential`; `HUMAN_SLOT`, `CREDENTIAL_KINDS`. The `slot` opt
|
||||
and `MOSAIC_LIBRARY_SLOT` keep their names and now name whichever file answers.
|
||||
- `packages/library/src/index.mjs`: the new exports.
|
||||
- `packages/library/bin/askpass`: Dewey's fd 3 check.
|
||||
- `packages/library/src/mirror.mjs`: optional `credential` in the state key check. Nothing renamed.
|
||||
- `packages/bus/src/broker.mjs`: `LIBRARY_FETCH_FIX` and `libraryFetchCredential(business)`.
|
||||
- `packages/bus/src/runtime.mjs`: `syncOptions` passes the broker's choice to the verb and the timer.
|
||||
The timer's held line adds `fix` for `library-fetch-no-grant`.
|
||||
- `packages/library/README.md`: "Push and credentials (C2-03b)".
|
||||
- Tests: `packages/library/tests/sync.test.mjs` (appended), `packages/bus/tests/library-credential.test.mjs` (new).
|
||||
|
||||
Choices made without a ruling. Each is small and reversible:
|
||||
- `humanCredential(context, { user, configDir })`: sage-2925 named `{user}`. The slot path needs the
|
||||
config dir, and the library package doesn't know it, so the caller passes it. That's the same
|
||||
source runtime's `slotDir` uses.
|
||||
- `BusError` carries only a code, so the sage-2928 fix text can't ride on the verb's refusal. It
|
||||
goes in the timer's log line (`fix`) and in the README. The verb answers `library-fetch-no-grant`.
|
||||
- T7 and T9 run the real verbs (dewey-0864, ruled by sage-2947: no SQL staging; replaces the
|
||||
staged state in cefebf65b). Until row 285 lands, the test passes its own definitions; once it lands
|
||||
the real chief definition carries gitea. Whichever of 285 and 286 lands second rebases and reruns
|
||||
library and bus whole (sage-2947). The chief's grant goes through decision.raise and the human's yes against fake-gitea-api.mjs. The test's own
|
||||
definitions give chief-of-staff a gitea credential, as gitea-grant.test.mjs gives coder one. The
|
||||
real roles/chief-of-staff.json has `credentials: []`, so a live chief can't get one before C2-21.
|
||||
- Revoked: `grants.revoke`, then the row is gone and the fetch refuses.
|
||||
- Fired: `instance.fire` can't fire the launch.by chief. Only the launch.by role calls it, and it
|
||||
refuses its own name (`organization-instance-referenced`, broker.mjs `#chiefLifecycle`). So the
|
||||
test's launch.by is `boss` (definition pm, the chief's authority), and boss fires the one listed
|
||||
chief-of-staff instance. Step G deletes the row (row 281), `roles.chief` stays, the fetch refuses.
|
||||
Only launch.by may call instance.fire (`role-not-chief`, broker.mjs:4308 at 94c33a43c) and it
|
||||
refuses its own name (:4335), so this is the only fired chief the verb allows. Accepted by
|
||||
sage-2951 as brief owner and read the same way in dewey-0869.
|
||||
- T9's revoked case runs `grants.revoke` on the runtime's own data root and store, between starts.
|
||||
T9's runtime gets a scratch roles/ whose chief-of-staff carries `{service: gitea}`, as row 285
|
||||
ships it (case2-rows.md:1823-1824), so its definitions match the grant's (dewey-0869). After a
|
||||
rebase onto 285 the copy needs no edit.
|
||||
- Delisted with the row still there, a `fire.stopped` with no `fire.done`, held, two chiefs and
|
||||
another project stay unit checks on the choice function with SQL fixtures.
|
||||
- "Another uid" in T5: the suite can't chown, so a PATH `stat` shim reports uid+1 for the open file.
|
||||
The real `id -u` compare is covered only through that shim (dewey-0864).
|
||||
- Two listed chiefs, or none listed, refuse `library-fetch-no-grant` (T8).
|
||||
- The race fetch inside `push()` takes sync's `running` guard (dewey-0864 R1, in this row per
|
||||
sage-2947). While a sync of the mirror runs it refuses `library-sync-busy`, fetches nothing, and
|
||||
the caller writes again. Test: "race fetch: while a sync of the mirror runs". Its sync fetch is a
|
||||
git alias that touches a marker, then waits for a release file. The push starts after the marker
|
||||
exists and the release comes after the refusal, so nothing depends on timing (dewey-0869).
|
||||
- The slot at 0000: T9 covers the revoked case at runtime. Fired and held refuse inside the choice
|
||||
function, which takes no path and opens no file. `sync` with any `{ refused }` runs no git (library
|
||||
test, valid slot present).
|
||||
- First-run reds in the new tests are mine to fix in setup and report afterwards (mos-1272).
|
||||
- The chief's evidence is any gitea grant event naming the chief, of any action (grant.applied,
|
||||
grant.revoked and the rest), per the plan. The spend event names no agent, so it doesn't count.
|
||||
|
||||
Test map:
|
||||
- library sync.test.mjs: argv (push), T1, T2+T3, T4 (dead remote, hook, wrong token), refused or
|
||||
unsafe credential runs no git, T10, fetch credential and state with N1/T12, T5+T5b.
|
||||
- library sync.test.mjs also: race fetch busy (R1).
|
||||
- bus library-credential.test.mjs: T6, T7 (real revoke and fire), T7 choice checks, T8, departure,
|
||||
T9 runtime (real grant, chief file and user via timer and verb; real revoke, slot 0000: no git,
|
||||
log `fix`, state `credential: null`).
|
||||
- webby-0151 R3: T9 reads `library.status` for `credential` and `lastError`.
|
||||
- T11 token search: T1 scans the data root and every file but the seat's; T9 scans the log lines.
|
||||
The gate's output search uses the fixture tokens `fixture-token-*-made-up-00{42..49}`.
|
||||
|
||||
Mutants (each must die on a semantic assertion):
|
||||
- M1: `+` before the push refspec in `syncCommand`. Expected to fail: the argv test, and T2 (the
|
||||
forced push lands, so `raced` is never reported and her commit is gone).
|
||||
- M2: drop `test "$1" = "$path"` from askpass. Expected to fail: T5b (the helper prints the checked
|
||||
file's token).
|
||||
|
||||
## r2 rebase (sage-3579)
|
||||
|
||||
Sage ruled that 286 now goes before 258, because 222 is its only after row. Branch ludwig/row286-r2
|
||||
rebases b9506b352 (base 94c33a43c) onto origin/refactor 45dca69f6. The rebase had no conflicts.
|
||||
|
||||
- Range-diff 94c33a43c..b9506b352 against 45dca69f6..r2: all 11 commits are `=`, so no patch
|
||||
changed.
|
||||
- Rows 254 (331597709) and 268 (c27207f6e) were already in the old base 94c33a43c. Between the two
|
||||
bases nothing changed under packages/library.
|
||||
- Paths the two test files import or read, diffed 94c33a43c..45dca69f6: bus broker.mjs, runtime.mjs
|
||||
and store.mjs changed. None of the others changed: library/*, bus credentials, gitea-grant,
|
||||
mapping, mirror, reader, survey, sync, the fake-gitea-api, helpers and host-fixtures modules,
|
||||
business helpers (rolesCopy, systemFor), project git, seat checkout and fake-gitea, and roles/.
|
||||
- broker.mjs: the constructor gains `accessStamp` and `compactions`, both defaulting to null, and
|
||||
my tests pass neither. No base hunk falls in the fire job, the revoke job or the instance.fire
|
||||
verb. Old :4308/:4335 are now :4852/:4879, with the same text. The new console-cap check refuses
|
||||
a non-console session only on CONSOLE_VERBS, and the library verbs are not among them. The
|
||||
request path's access re-read applies only to `s.user` sessions, and my tests open none.
|
||||
- runtime.mjs: consoleOwners (default []), the access timer, compactions and the session-clear
|
||||
hooks. The Credentials and giteaStart wiring that T9 uses is unchanged.
|
||||
- store.mjs: schema 3l to 3n (row 206 thread_shares, row 271 session_ops and message_cancels).
|
||||
Each test opens a fresh Store, and none pins a schema version or step count.
|
||||
- Hash pins: no test or script line pairs a 64-hex value with a path I change. The positive
|
||||
control finds 64-hex pins in, for example, account-migration.test.mjs.
|
||||
- Row 285 has not landed. roles/chief-of-staff.json at 45dca69f6 still names no gitea credential,
|
||||
so T9 keeps its scratch roles copy (sage-2947).
|
||||
- sage-3589 freezes stack-release.sh, webui-service.sh, bus-service.sh, stack-copy.sh and common.sh.
|
||||
This row changes none of them.
|
||||
- Still to run, on CLEAR: library and bus whole, every scripts/test-*.sh, M1 and M2, and the token
|
||||
search, all on r2.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Row 216 build notes (C2-05a, lookup index and queries, #1717)
|
||||
|
||||
Phase A only: code, tests and this file. No test, suite or mutant has been run. `node --check` passed on every changed `.mjs`.
|
||||
|
||||
- Branch: `researcher/row216`
|
||||
- Base: `d372102882d6858fa77af4aee453f866c886ad5c` (origin/refactor)
|
||||
- Design: `agents/gosalyn/work/case2/DESIGN.md` on origin/gosalyn/case2-design, section 3 (table at lines 625-631, bullets 633-641), 1.2-1.4, B12, D2
|
||||
- Brief: `docs/plans/2026-10-20_case2-rows.md`, C2-05a at lines 320-357
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Status |
|
||||
|---|---|
|
||||
| `packages/library/src/index-build.mjs` | new |
|
||||
| `packages/library/src/query.mjs` | new |
|
||||
| `packages/library/src/index.mjs` | edit: 2 export lines added |
|
||||
| `packages/library/tests/query-fixture.mjs` | new, test helper |
|
||||
| `packages/library/tests/query.test.mjs` | new |
|
||||
| `packages/library/tests/index-build.test.mjs` | new |
|
||||
| `agents/researcher/work/queue-216/BUILD.md` | new (this file) |
|
||||
|
||||
No existing test or source file changed beyond `index.mjs`.
|
||||
|
||||
## Design rows and where each is tested
|
||||
|
||||
| Design row | Code | Tests (query.test.mjs unless noted) |
|
||||
|---|---|---|
|
||||
| `library_find` | `find` | find is bound to its universe; every result carries its scope; long list pages; large items page under the cap |
|
||||
| `library_get` | `get` | hybrid gets a source file; get lists visible links both ways; get pages its lines |
|
||||
| `library_search` | `search` | search returns scoped line hits; item that cannot fit alone is refused |
|
||||
| `library_manuscript` | `manuscript` | one chapter at most 400 lines; refused without chapter; hybrid reads a source chapter |
|
||||
| `library_universe` | `universeInfo` | universe info reports a hybrid's sources, constraints, notes; unknown universe refused |
|
||||
| Hybrid semantics (1.4) | `sees` | a hybrid sees its sources canon minus exclusions, no playground |
|
||||
| Unmapped never appears | index build + `sees` | unmapped files never appear (query.test); index holds mapped files only (index-build.test) |
|
||||
| Per-commit cache (3) | `indexFor` | same commit, path, mapping return one index; different path, mapping or commit get their own; eight-entry bound; failed read not kept; unchecked mapping refused (index-build.test) |
|
||||
| Rebuild time (3, C2-05a gate) | `indexFor` | 3,000-file rebuild, time reported with `t.diagnostic`, no threshold asserted |
|
||||
| REPLY_CAP parity | `QUERY_REPLY_CAP` | equals `REPLY_CAP` from harness tools.mjs |
|
||||
|
||||
## Choices where the design table is silent or differs
|
||||
|
||||
These are departures or gaps. Each is built as stated, so Sage can rule before Phase B. A ruling that changes one of them is a small edit.
|
||||
|
||||
1. **Paging arguments (gap a).** The table has no offset. `find` and `search` take an optional `offset` (a position in the filtered list, default 0). A reply carries `next`: the position of the next item, or `null`.
|
||||
2. **`library_get` has no universe (gap b).** Built with a required `universe`. Without it the reply could not be isolated. A missing file and a file the universe cannot see both return `null`, so a caller cannot tell them apart. `get` also takes an optional `from` line.
|
||||
3. **`get` over the cap (gap c).** Lines page by `from` with `next`. `links_out` and `links_in` come back whole on every page. If the frontmatter and links alone exceed the cap, the call is refused with `query-item-too-large`. A hub note with many backlinks may be refused. This needs a ruling: link paging, or accept the refusal.
|
||||
4. **`manuscript` without a chapter (gap d).** Refused with `query-chapter-required`. The design names no list form. A chapter name that matches two visible files is refused with `query-chapter-ambiguous`.
|
||||
5. **`search` excerpt (gap e).** The excerpt is the whole line, not a truncation. A line that alone exceeds the cap is refused. Truncating it would cut an item.
|
||||
6. **`exclude` form (gap f).** `checkMapping` accepts only a folder path for `exclude` (`folder()`), so an exclusion is a path prefix. There is no type or source form to build. Confirm.
|
||||
7. **Pin constraints.** `pin` does not hide any file. It is reported by `universeInfo` only. Confirm.
|
||||
8. **Playground through `get`.** `scope` applies to lists. `get` by an explicit path returns a playground file of the caller's own universe. Confirm.
|
||||
9. **Links.** A link into a file the universe cannot see is dropped from `links_out` and `links_in`, in line with N1. Links from a visible file that point into another universe are kept in the index (the reader keeps them under R6), and the query layer drops them for universes that cannot see them.
|
||||
10. **`find` matching.** Case-insensitive substring over the front matter `name` and the file name. An empty `query` matches every file in scope.
|
||||
11. **Cache key.** `repo`, `commit` and a sha256 of the canonical mapping. Keying by commit alone would return the wrong scopes after a mapping change. The cache holds eight entries, least recently used out. Failed reads are not kept.
|
||||
12. **`universeInfo`.** Reads only the checked mapping and the served commit. It builds no index.
|
||||
13. **Reply commit.** Every reply carries the index commit, which is the served commit passed in.
|
||||
|
||||
## Provisional refusal codes (pending Sage approval)
|
||||
|
||||
The brief says new refusal codes need Sage's approval first. These are provisional and must not be treated as settled:
|
||||
|
||||
- `query-argument-invalid`
|
||||
- `query-universe-unknown`
|
||||
- `query-chapter-required`
|
||||
- `query-chapter-ambiguous`
|
||||
- `query-item-too-large`
|
||||
|
||||
Reused, existing codes: `library-mapping-invalid`, plus the reader's `library-git`, `library-commit` through `indexFor`.
|
||||
|
||||
## Grep results (grep only, not a test run)
|
||||
|
||||
- Test references to the changed paths: `packages/library/tests/*.mjs` import `../src/index.mjs` and `../src/mapping.mjs`. `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs` import the library index as a namespace. Neither enumerates its exports, so the two new export lines break no assertion.
|
||||
- `packages/cli/tests/stack-copy.test.mjs:52` checks that `packages/library/src/index.mjs` exists, and it still does.
|
||||
- 64-hex pins: none in `packages/library/tests`, none on `index.mjs`, `query.mjs` or `index-build.mjs`.
|
||||
- `scripts/test-*.sh`: no reference to the new modules.
|
||||
|
||||
## Flag for Sage
|
||||
|
||||
- `packages/library/tests/helpers.mjs` `repo()` builds its fixtures under `tmpdir()`, which is `/tmp`. The launcher rules forbid that. The new tests do not use it; they use `tests/query-fixture.mjs`, which builds under `/mnt/storage/scratch/tmp`. The existing helper should be fixed in a separate row, not here.
|
||||
|
||||
## Phase B commands (not run)
|
||||
|
||||
Run only after Sage sends "slot clear". Set the scratch TMPDIR first, per the launcher rule.
|
||||
|
||||
```
|
||||
cd /mnt/storage/src/mosaic-stack/.claude/worktrees/agent-aa3cc98787bc5152c/packages/library
|
||||
export TMPDIR=/mnt/storage/scratch/tmp
|
||||
node --test tests/query.test.mjs tests/index-build.test.mjs
|
||||
node --test tests/*.test.mjs
|
||||
```
|
||||
|
||||
Also run `packages/bus/tests/library.test.mjs` and `library-sync.test.mjs`, since they import the index.
|
||||
|
||||
## Status
|
||||
|
||||
Not yet verified. Nothing has been tested and nothing is committed. The working tree holds the Phase A changes. AGENTS.md invariant 8 allows a commit only after the applicable suites are green, so the commit waits for Phase B. The dispatch asked for a local commit in Phase A; Sage to confirm that the commit waits for Phase B.
|
||||
|
||||
## Round 2 (2026-10-22, sage-2708, sage-2716): rulings applied, rebased onto aca16404c
|
||||
|
||||
Added after the text above; nothing above is edited. "Nothing is committed", "Provisional refusal codes",
|
||||
`query-chapter-required` and the tmpdir flag above were true when written and are superseded here.
|
||||
|
||||
- Branch `researcher/row216-r2`, one local commit on origin/refactor `aca16404c`. Row 268 (C2-01c) is in
|
||||
that base. Row 253 has not landed; the work rebases onto 253's tip before any gate. Not pushed, not run.
|
||||
- Rulings: `agents/sage/work/queue-216/RULINGS.md` at da9555ca2, rulings 1-13 and the tmpdir flag.
|
||||
- Drift check, d37210288..aca16404c against my paths: the library files that changed are `mapping.mjs`,
|
||||
`scope.mjs`, `README.md` and the mapping and scope tests. None is a file I add or edit. `query.mjs` reads
|
||||
the checked mapping through them, so the whole library package runs in phase B.
|
||||
|
||||
### What changed from phase A
|
||||
|
||||
| Ruling | Change in `query.mjs` | Test |
|
||||
|---|---|---|
|
||||
| 3 `get` over the cap | `links_out` and `links_in` page together with `links_from`; every reply carries `links_next`, `next` and the front matter. A page with lines left carries at least one line. Links take at most half the cap while lines remain. `query-item-too-large` is left for front matter alone or one line alone (or one link alone) | hub note with 900 backlinks pages and adds up; front matter alone and a line alone refused; bad `links_from` |
|
||||
| 4 `manuscript` no chapter | `chapter` optional: the first visible chapter in path order. Every reply carries `next_chapter`. `query-chapter-required` removed. Ambiguity still refuses | first chapter and `next_chapter`; ambiguous name refused |
|
||||
| 5 `search` long line | hit comes back `{path, line, excerpt: null, excerpt_too_long: true, scope}`, other hits still return | marked hit and unmarked hits in one reply |
|
||||
| 6 `exclude` | path-prefix reading only. The test names `EXCLUSION` and `EXCLUDED_PATHS` in `query-fixture.mjs`, so C2-15 (row 267) swaps two constants | exclusion hides from the hybrid and not from the source |
|
||||
| 8 hybrid `get` | unchanged code; test added | hybrid gets a source's playground file as `null`; the source gets it with `scope: "playground"` |
|
||||
| PRD 1.14 (row 268) | `universeInfo` reports `kind` from the mapping (`draft` included) | four tests below |
|
||||
|
||||
PRD 1.14 tests, one each: a lookup naming a `book` leaves `book: null` files out (find and search);
|
||||
`library_manuscript` never reads a `book: null` file; a draft universe has no canon, so its lookups return
|
||||
files only with `scope: "playground"`; `library_universe` on a draft returns kind, name and prefixes.
|
||||
The levels fixture is `WITH_LEVELS` in `query.test.mjs`: harbor gets `Notes/Harbor/` as a universe-level
|
||||
playground prefix, and `idea` is a draft over `Drafts/New World/`.
|
||||
|
||||
`query-fixture.mjs` builds under `tmpdir()`, as `helpers.mjs` does (ruling "tmpdir flag"). The earlier
|
||||
flag about `helpers.mjs` building under `/tmp` is closed by that ruling: the gate sets `TMPDIR`.
|
||||
|
||||
Refusal codes now: `query-argument-invalid`, `query-universe-unknown`, `query-chapter-ambiguous`,
|
||||
`query-item-too-large`. All four approved in the rulings.
|
||||
|
||||
### Grep results (grep only, not a test run)
|
||||
|
||||
- `git grep` over `packages/*/tests/*` and `scripts/*` for `query.mjs` and `index-build`: no hit outside
|
||||
`packages/library/tests/query*.test.mjs` and `index-build.test.mjs`.
|
||||
- 64-hex pins naming a library path: none. Positive control: the same pathspec finds the sha256 lines in
|
||||
`packages/cli/tests/case1-hostcheck.test.mjs` and the `packages/library/src/index.mjs` existence check at
|
||||
`packages/cli/tests/stack-copy.test.mjs:52`.
|
||||
- Tests that read `packages/library/src` as a directory: `packages/cli/tests/stack-release-fixture.mjs:309`
|
||||
copies `packages/library/src` whole into a release fixture. It carries no file list, so two new files break
|
||||
no assertion. `stack-release.test.mjs` is not in the gate unless Sage wants it.
|
||||
- Tests that import the library index as a namespace: `packages/bus/tests/library.test.mjs` and
|
||||
`library-sync.test.mjs`. Neither enumerates exports, so the two export lines add no break. Both run
|
||||
whole in phase B.
|
||||
|
||||
### Run limits (fixed before any run)
|
||||
|
||||
Every node file runs as its own `setsid -w timeout -k 10 LIMIT node --test FILE`; nothing runs unbounded.
|
||||
|
||||
- Node test files: LIMIT 900 s. The library test files have no recorded time. 900 s is above the longest
|
||||
recorded whole-package run I know of (cli, 591 s, row 281 BUILD.md "Runs" step 3), so it holds the 3x rule
|
||||
for any single file. The first run records each file's time, and a file past 300 s gets a limit re-derived
|
||||
from it before the next run. `query.test.mjs` builds a 900-file repo and a 3,000-file index rebuild is in
|
||||
`index-build.test.mjs`; both are the files most likely to need it.
|
||||
- No shell script is in this row's gate, so no script limit applies.
|
||||
|
||||
Any timeout goes to Sage, then Mos, unplaced (mos-1275).
|
||||
|
||||
### Phase B (not run; Sage's CLEAR first, then a rebase onto 253's tip)
|
||||
|
||||
```
|
||||
export TMPDIR=/mnt/storage/scratch/tmp
|
||||
cd <worktree>/packages/library
|
||||
for f in tests/*.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
|
||||
cd ../bus
|
||||
for f in tests/library.test.mjs tests/library-sync.test.mjs; do setsid -w timeout -k 10 900 node --test "$f"; done
|
||||
cd ../cli
|
||||
setsid -w timeout -k 10 900 node --test tests/stack-copy.test.mjs
|
||||
```
|
||||
|
||||
Mutants I propose (my list, not read off the brief; each is meant to be killed by the named test, and none
|
||||
has been thrown; the kill claims are unverified until CLEAR):
|
||||
|
||||
- M-a: `sees()` returns true for a file of another universe: killed by "find is bound to its universe".
|
||||
- M-b: the playground filter dropped: killed by "playground appears only when asked for".
|
||||
- M-c: the hybrid reads a source's playground: killed by "a hybrid gets a source playground file as null".
|
||||
- M-d: `links_next` always null: killed by the hub paging test.
|
||||
- M-e: the book filter lets `book: null` through: killed by the PRD 1.14 book test.
|
||||
|
||||
### Status
|
||||
|
||||
Built, not run. `node --check` has not been run on this round's files (no node before the CLEAR); the
|
||||
phase A pass above covered the earlier text only. The commit is local and waits for no gate under Sage's
|
||||
sage-2716 instruction, which allows git and docs work only; push and claim wait for Sage's go.
|
||||
@@ -0,0 +1,652 @@
|
||||
# PRD 1.17 draft: Case 2 story files are DOCX (Q235 to Q237, P1 to P6)
|
||||
|
||||
Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 9. It is d8 plus Jason's Q41 answer O2 (DECISIONS Q239, mos-1467), which makes J-b yes, Webby's round 5 N15 to N17 (webby-0210) and Webby's round 6 B1, N19 and N20 (webby-0212) (see "d9 changes"). d8 is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
|
||||
- Mos's d4 check ruling mos-1372 (D1 to D14; D15 was dropped).
|
||||
- mos-1374, which replaces J-c.
|
||||
- The impact map mos-1368 (jason-master-restart d183246, `projects/mosaic-mvp/case2-docx-impact-map.md`: A1 to A16, critic items C1 to C6, items 2 to 7).
|
||||
- Mos's F4 measurement.
|
||||
|
||||
Review order (mos-1367 B5): Webby, then Mos, then Jason. Base: PRD 1.16, `docs/prd/mosaic-stack.md` blob a6db9b4a at 482e865ac.
|
||||
1.16 lands first (row 291), and this text is a new version on top of it. Nothing here is built or queued. The impact map's queue changes (A24, A57 to A59) go through `scripts/mosaic queue` after 1.17 is approved (C9).
|
||||
|
||||
Sources, verbatim in Mos's DECISIONS:
|
||||
- Q235, Jason's Q36 answer.
|
||||
- Q236: chapters are DOCX, Markdown for agent files and docs.
|
||||
- Q237: "It is correct for now (subject to future change)".
|
||||
- The P1 to P6 reading as restated to Jason in Q38.
|
||||
|
||||
## Settled points (mos-1367)
|
||||
|
||||
These stand as ruled. d5 changes only what mos-1372 changed.
|
||||
|
||||
- **O1. Where a converted chapter lives.** The DOCX lives in the writing store of
|
||||
O2 design D12, and the archived Markdown stays in the user's repository under a
|
||||
human-marked archive folder. Ruled yes in B1, on the condition that the text
|
||||
says plainly that a converted chapter leaves the Gitea-mirrored repository and
|
||||
lives in the stack's writing store. mos-1372 D7 and mos-1375 add a remote for
|
||||
the store, off the stack's host.
|
||||
- **O2. The unit of conversion.** One story file per import, refusing a file
|
||||
that is already converted. Ruled yes in B2: each file gets its own fidelity
|
||||
report, human confirmation, manifest and commit.
|
||||
- **O3. Whether conversion is a vocabulary action.** A human-only verb outside
|
||||
the agent vocabulary, with no role grant and no agent request path. Ruled yes
|
||||
in B3.
|
||||
- **O4. Reading archived Markdown.** A new `archived` scope. Lookups leave it out
|
||||
unless the caller asks, every answer marks it archived, and a canon citation
|
||||
can't name it. Ruled yes in B4.
|
||||
|
||||
## d3 choices after mos-1372
|
||||
|
||||
- **C1 is replaced by D1, keep-source-scope.** A DOCX keeps its source's scope:
|
||||
universe, book, and canon or playground. A playground DOCX follows the
|
||||
playground rules and is never returned as canon. The import accepts canon and
|
||||
playground story files. Type-folder files and supporting files stay Markdown
|
||||
(P4). This also settles the impact map's Q18, item 4. It leaves one narrowing
|
||||
for Jason, J-i. A playground DOCX can't become canon in 1.17.
|
||||
- **C2 stands.** The move keeps the file's path under the archive folder:
|
||||
`book1/ch3.md` goes to `<archive>/book1/ch3.md`. An archived file's universe
|
||||
and book are those of its old path.
|
||||
- **C3 is replaced by D3 and D14.** The archive commit is written by a new
|
||||
human-only bus step (Vikunja 571) on the C2-02 write plumbing and pushed through
|
||||
C2-03b. The order is the store commit and the remote's confirmation that it
|
||||
holds that commit, then the archive commit and its push, then publication to the
|
||||
lookups.
|
||||
- **N4 stands.** REQ-LIB-8 says "a real user's books". The PRD names no user
|
||||
anywhere in Case 2.
|
||||
|
||||
## Text changes
|
||||
|
||||
### Status and sources
|
||||
|
||||
In the Status bullet's first line, "- Status: approved, version 1.16." becomes
|
||||
"- Status: approved, version 1.17." Status gains, after the two 1.16 sentences at
|
||||
the end of the bullet: "Version 1.17 records Jason's rulings Q235 to Q237 of
|
||||
2026-10-23: Case 2 story files are DOCX, Markdown stays for agent files and
|
||||
documentation, and a human-started import converts a Markdown story file one way.
|
||||
It also adds REQ-LIB-9, the Console's DOCX editor, from Jason's rulings Q195, Q226
|
||||
and Q234. It changes what Case 2 requirements allow, so Mos puts the text to Jason
|
||||
as written, and it lands only after his yes (mos-1366, Vikunja 570)."
|
||||
|
||||
New source line, above the 1.16 one: "Version 1.17 source: Jason's Q235, answering
|
||||
Q36: 'How about we see a markdown file, we convert the markdown file to docx, then
|
||||
archive or mark the markdown file as converted. The new standard for files in the
|
||||
creating writing use case is to use docx to handle versioning and WYSIWYG. We
|
||||
eliminate the potential for confusion based on the files. Agents must understand
|
||||
which files are what format and where they live. The system must allow for import
|
||||
and conversion. We will not run the systems in parallel. There will be an
|
||||
irreversible conversion of the files.' His Q236 says: 'For creative writing
|
||||
chapters, we need to utilize docx as the standard. Markdown is acceptable for
|
||||
agent files, instructions, PRDs, documentation, etc. The creative writing files
|
||||
should be docx for familiarity for the user.' His Q237 says 'Q38: It is correct
|
||||
for now (subject to future change)', which confirms Mos's reading P1 to P6.
|
||||
REQ-LIB-9 rests on his Q195 request for 'a full WYSIWYG editor within Mosaic
|
||||
Stack', his Q226 answer 'F41: We want to move editing into the WYSIWYG in the
|
||||
WebUI. All changes are instantly committed and available to the agents.' and his
|
||||
Q234 answer 'Q37: yes' to the exact security header for the editor's frame. The
|
||||
storage follows the O2 design D10 to D12 as rewritten under Vikunja 571, at
|
||||
b99ca86b9 on `refactor`."
|
||||
|
||||
### Creative library, section lead
|
||||
|
||||
Replace "The library is the user's own writing repository." with "The library is
|
||||
the user's own writing repository plus the stack's writing store, which holds the
|
||||
DOCX story files (REQ-LIB-8, version 1.17)."
|
||||
|
||||
### REQ-LIB-1
|
||||
|
||||
Add, before "(Design 1.1": "The mapping also names one archive folder, set by the
|
||||
human, where converted Markdown story files go. A book's DOCX story files are in
|
||||
the stack's writing store, under the same universe and book (REQ-LIB-8)." Version
|
||||
tag, inside the closing parenthesis: "Version 1.15: supporting folders.)" becomes
|
||||
"Version 1.15: supporting folders. Version 1.17: the archive folder and the
|
||||
writing store.)"
|
||||
|
||||
### REQ-LIB-2
|
||||
|
||||
Replace "A file's scope comes from its path alone, prefixes first: a Markdown
|
||||
file under a supporting prefix is supporting, one under a playground prefix at
|
||||
any level is playground, any other Markdown file in a book folder is canon, and
|
||||
everything else is unmapped and never returned. A folder in both lists is
|
||||
supporting." with:
|
||||
|
||||
"A Markdown file's scope comes from its path and from past imports, prefixes
|
||||
first: a Markdown file under the archive folder is archived, one under a
|
||||
supporting prefix is supporting, a Markdown file at a converted file's old path is
|
||||
unmapped under REQ-LIB-8 but flagged in the format map, one under a playground
|
||||
prefix at any level is playground, any other Markdown file in a book folder is
|
||||
canon, and everything else is unmapped and never returned. A folder in both lists
|
||||
is supporting. The archive folder follows the same placement rules and may not
|
||||
overlap another prefix or a book folder. An archived file keeps the path it had
|
||||
under the archive folder, and its universe and book are those of that old path. A
|
||||
DOCX document in the writing store keeps its source's scope: the universe, the
|
||||
book if there is one, and canon or playground. Its source is the Markdown file it
|
||||
was converted from or, for one the human starts in the editor, the universe and
|
||||
book or the playground the human picks (REQ-LIB-8). A playground DOCX follows the
|
||||
playground rules and is never returned as canon. It stays playground after its
|
||||
draft universe is made real. In version 1.17 no promotion or mapping change makes
|
||||
a playground DOCX canon. A later version of this PRD says how one becomes canon."
|
||||
|
||||
Replace "Lookups return canon unless the caller asks for playground or
|
||||
supporting." with "Lookups return canon unless the caller asks for playground,
|
||||
supporting or archived. Every answer marks an archived file as archived and names
|
||||
the DOCX that replaced it."
|
||||
|
||||
Replace "so an agent can't cite a playground or supporting file as canon" with "so
|
||||
an agent can't cite a playground, supporting or archived file as canon".
|
||||
|
||||
In REQ-LIB-2 only, replace "The Console shows each cite's scope." with "The Console shows each cite's
|
||||
scope and format, and whether a cited Markdown file was converted."
|
||||
|
||||
Version tag, inside the closing parenthesis: "Version 1.15: supporting.)" becomes
|
||||
"Version 1.15: supporting. Version 1.17: archived scope and DOCX scope.)"
|
||||
|
||||
### REQ-LIB-4
|
||||
|
||||
Replace "The user commits and pushes with their own tools, today Obsidian with
|
||||
obsidian-git." with "The user commits and pushes Markdown with their own tools,
|
||||
today Obsidian with obsidian-git. A converted story file's current text is no
|
||||
longer in that repository, which keeps only its archived Markdown (REQ-LIB-8)."
|
||||
|
||||
Add, after "Bus writes and pushes come in later rows (C2-02, C2-03b).": "A DOCX
|
||||
story file is saved in the Console's editor, and each accepted save is one commit
|
||||
by the human in the stack's writing store (REQ-LIB-9). Each writing-store commit
|
||||
is also pushed to a remote off the stack's host, never by force-push. The remote
|
||||
is a setting only a human changes, and it starts unset. A repository on the
|
||||
stack's host never counts as that remote. A missing remote or a failed push never
|
||||
undoes a save, and the user sees each push that is pending or failed. Only the
|
||||
stack's host pushes to that remote, and no agent can reach it with any credential.
|
||||
Agents read the local commit."
|
||||
|
||||
Replace "Agents read only the mirror's served commit and never hold a copy of
|
||||
their own, so they see the user's push within about a minute." with "Agents read
|
||||
the user's repository only at the mirror's served commit, except a story file
|
||||
during its import under REQ-LIB-8. They read a DOCX story file only at a committed
|
||||
version in the writing store, under REQ-LIB-5. They never hold a copy of their
|
||||
own, so they see the user's Markdown push within about a minute and a DOCX save at
|
||||
their next lookup after the editor reports it as committed."
|
||||
|
||||
Version tag: "and the sync items.)" becomes "and the sync items. Version 1.17,
|
||||
with Q222 for the remote setting.)"
|
||||
|
||||
### REQ-LIB-5
|
||||
|
||||
Add, after "Each answer names the commit it read.": "Each answer also names the
|
||||
file's format and where it lives: its path, or its document id in the writing
|
||||
store. A DOCX story file is read from its latest committed save, never from
|
||||
unsaved text. A lookup that starts after the editor reports a save as committed
|
||||
returns that save or a later one, even while its push to the remote is pending or
|
||||
has failed, and the answer names the version. Get, search and manuscript read a
|
||||
DOCX story file as its extracted text with DOCX locators, never as lines. That
|
||||
text is the file with its tracked changes accepted. An agent reads a story file's
|
||||
comments, or its text with tracked deletions shown, only after a human turns on
|
||||
the matching setting. Both settings are off by default, and only a human changes
|
||||
them. The reader in this version can't read some things a DOCX story file can
|
||||
hold, such as a web link, a text box, a field or hidden text. The editor warns the
|
||||
human when an edit adds one, and the human can still save it. Until a later save
|
||||
takes it out, lookups give agents none of the text of the part that holds it,
|
||||
whether that is the chapter's body, a note or a comment. The manuscript lookup
|
||||
follows the book's chapter order across both formats (O2 design D10), and the next
|
||||
chapter it names is never an archived file. Links out and in resolve across both
|
||||
formats. The universe lookup returns the format map (REQ-LIB-8)."
|
||||
|
||||
Replace "The user sees them in Obsidian's graph and backlinks." with "The user sees
|
||||
a Markdown file's links in Obsidian's graph and backlinks. A DOCX story file's
|
||||
wikilinks stay clickable in the Console's editor (O2 design D11), and no graph
|
||||
shows them yet. The Console's Connections view lists the current links and
|
||||
backlinks of each story file the user may see, in both formats, with each one's
|
||||
scope and format."
|
||||
|
||||
Version tag: 'and "Connections are always visible".)' becomes 'and "Connections
|
||||
are always visible". Version 1.17, with Q222 for the two settings and Q239 for
|
||||
the Connections view.)'
|
||||
|
||||
### REQ-LIB-6
|
||||
|
||||
Replace "A reply may cite lines of the repository at a commit or pages the agent
|
||||
fetched, and the broker checks every citation before it stores the message." with
|
||||
"A reply may cite lines of a Markdown file at a commit, a place in a committed
|
||||
DOCX story file, or pages the agent fetched, and the broker checks every citation
|
||||
before it stores the message. A DOCX citation never calls a paragraph a line. It
|
||||
names the document id, the committed version and a DOCX locator. A citation that
|
||||
names an archived file as current is refused. The `requireCitation` setting below
|
||||
applies to Markdown and DOCX citations alike, and so does the rule for a citation
|
||||
marked as canon."
|
||||
|
||||
Add, after "the broker refuses such a write before any git call.": "The writing
|
||||
store and the archive folder are read-only to a creative agent as well, and the
|
||||
broker's path check treats the archive folder as story. No agent creates, edits,
|
||||
deletes, converts, archives or restores a story file in any format, and no agent
|
||||
writes a DOCX. No creative agent starts or undoes a conversion, and none asks for
|
||||
one through a tool or a decision it raises, as REQ-LIB-8 says. An agent may still
|
||||
suggest a conversion to the human in chat. Each creative role's contract states
|
||||
which files are DOCX and which are Markdown, and cites a DOCX story file by its
|
||||
locator, never by line."
|
||||
|
||||
Version tag, inside the closing parenthesis: "Version 1.15.)" becomes "Version
|
||||
1.15. Version 1.17.)"
|
||||
|
||||
### New REQ-LIB-8
|
||||
|
||||
This bullet goes after REQ-LIB-7 and before the "### Installer" heading.
|
||||
|
||||
"- **REQ-LIB-8.** Story files become DOCX. DOCX is the standard for chapters and
|
||||
the other story text in a Case 2 writing project, canon and playground alike. A
|
||||
story file becomes DOCX through a human's import (below), or when the human
|
||||
starts it in the editor. From then on DOCX is its only working format, edited in
|
||||
the Console's editor (REQ-LIB-9). A Markdown story file stays Markdown until its
|
||||
import. Files in a type folder (REQ-LIB-1), such as character and place notes,
|
||||
stay Markdown. So do supporting files, agent files, instructions, PRDs and
|
||||
documentation, including the character sheets, summaries and cross-links agents
|
||||
keep under REQ-LIB-6.
|
||||
- A converted story file's current text leaves the user's Gitea-mirrored
|
||||
repository. Its DOCX lives in the stack's writing store, on the stack's host,
|
||||
and not in the repository the user pushes or opens in Obsidian. Only its
|
||||
archived Markdown stays there.
|
||||
- An import converts one Markdown story file, canon or playground, outside every
|
||||
type folder (REQ-LIB-1), to a DOCX document in the writing store. It refuses
|
||||
an unmapped, supporting, archived or type-folder file, and a file that is
|
||||
already converted. Each import has its own fidelity report, the human's
|
||||
confirmation of that report, a manifest naming the source file and its commit,
|
||||
and one commit in the store. The DOCX keeps every wiki link of the source as
|
||||
written, and a lost link to another library file fails the fidelity report. A
|
||||
web link becomes plain text that keeps its label and address, and the fidelity
|
||||
report lists it for the human to confirm. A web link that can't be kept safely
|
||||
as plain text refuses the import.
|
||||
- The human can also start a new story file in the editor, in a universe and
|
||||
book or a playground the human picks. It is a DOCX document in the writing
|
||||
store from its first commit, with no Markdown copy, and it opens under the
|
||||
same gate as the import.
|
||||
- The same import marks the Markdown file converted, naming the document, and
|
||||
moves it to the archive folder under its old path (REQ-LIB-1, REQ-LIB-2).
|
||||
The mark and the move are one commit authored by the human in the user's
|
||||
repository. A new human-only bus step (Vikunja 571) on the C2-02 write
|
||||
plumbing writes it, and C2-03b pushes it. The move keeps the bytes and the
|
||||
git history, so it isn't `data.delete`.
|
||||
- The store commit comes first, and the writing store's remote must confirm it
|
||||
holds that commit. Then come the archive commit and its push, then publication
|
||||
to the lookups. Until publication the Markdown at its old path keeps its canon
|
||||
or playground scope. Agents read that file at the commit the import started
|
||||
from, even after the mirror's served commit reaches the archive commit or a
|
||||
later one. No lookup returns the file's archived copy before publication, even
|
||||
one that asks for archived files. A refusal or failure before the archive push
|
||||
leaves the Markdown unmoved and no DOCX that a lookup returns. A failure after
|
||||
the archive push leaves a pending import that the human sees and can resume,
|
||||
never a claim that nothing moved. An archive push whose result is unknown
|
||||
counts as a failure after the archive push until the stack reads the result
|
||||
back from the user's remote.
|
||||
- Each stage of an import writes a logged event. The import's record on the
|
||||
stack's host names the source file's path, commit and blob and the new
|
||||
document id, and each event points to that record.
|
||||
- After a conversion, a Markdown file at the converted file's old path is
|
||||
neither canon nor playground. Lookups leave it out, the format map flags it,
|
||||
and the human is told.
|
||||
- A new Markdown story file in a book folder that holds converted files stays
|
||||
canon until its import, unless it sits at a converted file's old path or under
|
||||
a playground prefix. The Console flags it and offers the import. Nothing
|
||||
converts it on its own.
|
||||
- A hybrid's exclusion that covers a converted file's old path also covers its
|
||||
DOCX document (REQ-LIB-3). An exclusion of a whole book folder also covers
|
||||
every DOCX document in that book, including one started in the editor. An
|
||||
exclusion can also name a DOCX document by its id.
|
||||
- Only a human starts an import or a new story file, from the CLI or a Console
|
||||
session signed in as that human. Both are human verbs outside the agent action
|
||||
vocabulary. Neither is added to `ACTIONS` in the business vocabulary or to the
|
||||
broker's always-gated list, including the import's action, `library.convert`.
|
||||
No role holds them, no agent can request them, and the hard-wait list in
|
||||
REQ-WAIT-1 doesn't change. In an organization with several humans, who may
|
||||
start them is a setting only a human changes, and the default is the owner
|
||||
only.
|
||||
- Conversion is one way, with no parallel run. Neither the Console nor any agent
|
||||
edits a converted story file as Markdown, and no DOCX edit syncs back to
|
||||
Markdown. The Console's Markdown save refuses every file under the archive
|
||||
folder. The user's own tools can still change the archived Markdown, and no
|
||||
such change reaches the DOCX. The old Markdown stays in git history, and
|
||||
nothing rewrites history. A promotion into a book folder (C2-19) runs the
|
||||
import for a Markdown story file or is refused. A promotion of a playground
|
||||
DOCX is refused in version 1.17.
|
||||
- Agents get a format map: for each mapped folder and story file, its format,
|
||||
scope and where it lives, archived files, flagged files and pending imports
|
||||
included.
|
||||
- The import and new story files ship closed. They open only after four things.
|
||||
The editor ships. The human has set the writing store's remote, and the stack
|
||||
has checked that it can push there. Agents can read committed DOCX story files
|
||||
through the lookups and cite them (REQ-LIB-5, REQ-LIB-6). Any earlier agent
|
||||
system that writes the user's files has stopped, which for Case 2 is the
|
||||
Hermes retirement (#423 step 5). So the import itself never cuts agents off
|
||||
from a chapter, the chapter's current text never lives only on the stack's
|
||||
host, and no second system writes the story. The first import of a real user's
|
||||
books waits for Jason's go.
|
||||
(Q235, Q236, Q237: P1 to P6. Q222 for the setting. O2 design D10 to D12.
|
||||
Version 1.17.)"
|
||||
|
||||
### New REQ-LIB-9
|
||||
|
||||
This bullet goes after REQ-LIB-8.
|
||||
|
||||
"- **REQ-LIB-9.** The Console's DOCX editor. The human edits a DOCX story file in
|
||||
the Console's editor, in a split pane with the agent chat alongside it. The
|
||||
human can pin an agent chat to a document. The human can select a word,
|
||||
right-click it, and choose a synonym from a thesaurus. Only a human saves. Saves
|
||||
happen on their own while the human types (Q226), at intervals only a human
|
||||
changes, and each accepted save is one commit with the human as author, pushed
|
||||
as REQ-LIB-4 says. A save made against an older version than the current one is
|
||||
refused. Agents never write a DOCX. Versioning is the editor's tracked changes
|
||||
plus one git commit per save. The editor's code is vendored and pinned, with a
|
||||
hash per file and the licenses that its independent security review accepted. At
|
||||
runtime the editor loads only files that the stack itself serves, and it
|
||||
requests nothing from any other host. The editor's frame sends exactly the
|
||||
content security policy Jason approved in Q234, and the WebUI shell's own header
|
||||
is unchanged. REQ-PROJ-3's project docs editor is unchanged. (Q195, Q226, Q234,
|
||||
Q236. O2 design D3, D4, D6, D8, D10, D13, D14, D16 and D22. Version 1.17.)"
|
||||
|
||||
### Version 1.17 notes
|
||||
|
||||
Added after the 1.16 notes:
|
||||
|
||||
"- Version 1.17:
|
||||
- Jason confirmed the reading 'for now (subject to future change)' (Q237). The
|
||||
rulings stand until he revises them.
|
||||
- The 10-27 Case 2 release ships on Markdown as built (P6). The 1.11 line 'The
|
||||
Case 2 MVP for 10-27 is the Markdown library (#416). The WYSIWYG editor (Q195,
|
||||
Q196) is an add-on that doesn't gate it.' still holds. Conversion opens after
|
||||
10-27, and only once REQ-LIB-8's four conditions are met. The editor ships,
|
||||
the writing store's remote is set and the stack has checked that it can push
|
||||
there, agents can read and cite committed DOCX story files through the
|
||||
lookups, and Hermes is retired, which is #423 step 5.
|
||||
- The 1.12 note says five bus write and Console editor rows, C2-02, C2-03b,
|
||||
C2-11a, C2-11b and C2-19, don't gate 10-27. Jason's Q226 F42 later moved
|
||||
C2-02, C2-03b and C2-11b into the 10-27 slice. The Case 2 design brings C2-11a
|
||||
in with them, because C2-11b needs it. C2-19 stays outside the slice. Mos's
|
||||
reading of F42 is that C2-02 and C2-03b now gate 10-27, because design 9.2
|
||||
puts them in 'the smallest slice that lets her start'. C2-11a and C2-11b are
|
||||
slice rows that still don't gate 10-27, because the editor is the Q195 add-on.
|
||||
The DOCX change in 1.17 leaves this slice as it is, and C2-11b's Markdown
|
||||
editor never edits a converted story file.
|
||||
- Q236 narrows Q176's editor choice to DOCX for story files (REQ-LIB-9). The
|
||||
1.12 note that the Case 2 design keeps Obsidian now holds only for Markdown
|
||||
files, including story files not yet imported. A DOCX story file is edited
|
||||
only in the Console's editor under REQ-LIB-9.
|
||||
- This replaces two parts of the O2 design at 25c09f19f. The superseded-notice
|
||||
proposal from Fenton's F1 in D12 is withdrawn, and the
|
||||
keep-Markdown-at-its-path model of D11 is replaced. Gyro's rewrite of the O2
|
||||
design for Vikunja 571, landed on `refactor` at b99ca86b9, carries the
|
||||
details, and where it differs from this text, this text holds:
|
||||
- the import and archive steps, the mark, and the new bus step;
|
||||
- links from supporting files to a moved chapter;
|
||||
- the DOCX reader;
|
||||
- the user's unpushed or later changes to an archived file or at its old path,
|
||||
and pushing and closing the file in Obsidian before each import;
|
||||
- the writing store's remote in W4 section 7.3, which replaces the no-remote
|
||||
and no-backup rule that W4 section 7.4 held before the rewrite, at
|
||||
062646487;
|
||||
- import events counted by a bus restore, and recovery when a bus restore or a
|
||||
restore of the whole host predates an import;
|
||||
- manifest order for the manuscript lookup and its next chapter;
|
||||
- `library.convert` kept out of `ACTIONS`, out of the hard-wait note and out
|
||||
of the pinned `GATED_ONLY` list in packages/business/src/vocabulary.mjs.
|
||||
- Agents read a converted story file through the Case 2 lookups under the same
|
||||
project and universe access as Markdown canon, in any thread or room. W4's
|
||||
earlier O2-only conversations, per-document grants and per-send sharing don't
|
||||
gate these lookups. The 571 rewrite replaced them in W4 sections 7.1 and 7.2.
|
||||
The creative tool list in REQ-LIB-6 doesn't change.
|
||||
- The six creative role contracts row 213 landed say 'Every item cites the
|
||||
lines of the user's material it is about'. A build row under 571 changes that
|
||||
line for DOCX story files (REQ-LIB-6).
|
||||
- A new Markdown file in a book folder is still canon in 1.17. The 1.17
|
||||
exception is a file at a converted file's old path, which REQ-LIB-2 makes
|
||||
unmapped.
|
||||
- Story files convert whether canon or playground. Type-folder and supporting
|
||||
files stay Markdown.
|
||||
- Measured 2026-10-23 at about 01:21Z: the Case 2 user's library repository
|
||||
tracks 213 files, 203 of them Markdown, and none is a .docx or .doc. It has no
|
||||
untracked files. The user's .doc files (Q196) are outside it. So the landed
|
||||
code has no DOCX there to report as unreadable at 10-27.
|
||||
- Text replaced in 1.17, kept here as in earlier notes:
|
||||
- Creative library lead, the 1.16 sentence: "The library is the user's own
|
||||
writing repository."
|
||||
- REQ-LIB-2, the 1.16 sentences: "A file's scope comes from its path alone,
|
||||
prefixes first: a Markdown file under a supporting prefix is supporting,
|
||||
one under a playground prefix at any level is playground, any other
|
||||
Markdown file in a book folder is canon, and everything else is unmapped
|
||||
and never returned. A folder in both lists is supporting." "Lookups return
|
||||
canon unless the caller asks for playground or supporting." "so an agent
|
||||
can't cite a playground or supporting file as canon" "The Console shows
|
||||
each cite's scope."
|
||||
- REQ-LIB-4, the 1.16 sentences: "The user commits and pushes with their own
|
||||
tools, today Obsidian with obsidian-git." "Agents read only the mirror's
|
||||
served commit and never hold a copy of their own, so they see the user's
|
||||
push within about a minute."
|
||||
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
|
||||
backlinks." A converted chapter's connections show in Obsidian only as they
|
||||
stood at conversion, and the Console's Connections view shows their
|
||||
current links.
|
||||
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
|
||||
a commit or pages the agent fetched, and the broker checks every citation
|
||||
before it stores the message."
|
||||
- REQ-LIB-1 only gains sentences.
|
||||
- REQ-LIB-3's text and REQ-LIB-7 are unchanged. REQ-LIB-8 adds one form of
|
||||
exclusion REQ-LIB-3 doesn't have: naming a DOCX document by its id. A path
|
||||
exclusion also covers DOCX documents, through a converted file's old path or a
|
||||
whole book folder. An archived file and a DOCX document keep a universe, and a
|
||||
book where their source has one, so every lookup still names one universe.
|
||||
REQ-PROJ-3, the Console editor for a project's spec and docs, is unchanged.
|
||||
C2-11b opens the library's Markdown files in that same editor.
|
||||
- In `packages/tasks/requirements.json` the titles of REQ-LIB-1, REQ-LIB-2 and
|
||||
REQ-LIB-4 change, and those of REQ-LIB-5 and REQ-LIB-6 stay. REQ-LIB-1 becomes
|
||||
'A human-set library maps the user's repository onto universes, books, types,
|
||||
playgrounds, supporting and archive folders'. REQ-LIB-2 becomes 'Scope comes
|
||||
from the path or a DOCX's source, lookups default to canon, and only canon
|
||||
files are ever cited as canon'. REQ-LIB-4 becomes 'Git holds every change. The
|
||||
bus fast-forwards a read-only mirror every 60 seconds, the one place agents
|
||||
read Markdown'. REQ-LIB-8 and REQ-LIB-9 add two ids, so the count goes from 85
|
||||
to 87. REQ-LIB-8 is titled 'Case 2 story files become DOCX by a human-only
|
||||
one-way import or in the editor; Markdown stays for notes and docs', and
|
||||
REQ-LIB-9 'The Console's DOCX editor: only a human saves, each save is one
|
||||
commit, and the editor code is vendored and pinned'.
|
||||
- Open points O1 to O4 of draft 1 were ruled by Mos in mos-1367 B1 to B4.
|
||||
- Sage files the build rows under Vikunja 570 and 571 after the rewrite is
|
||||
approved. This amendment adds the requirements only."
|
||||
|
||||
### Risks
|
||||
|
||||
The new lines go at the end of "## Risks and open questions", after the bullet
|
||||
that begins "Trusted mode lets an agent" and before the "## Milestones" heading.
|
||||
- "A DOCX file is a zip archive, so git can't show a line diff of a chapter.
|
||||
Review of a story change goes through the editor and the committed reader,
|
||||
not `git diff`. (Version 1.17.)"
|
||||
- "A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
|
||||
chapter only through its archived Markdown, and no DOCX edit reaches that file's
|
||||
links. The DOCX's current links show in the Console's editor and its
|
||||
Connections view, and no graph shows them yet. (Version 1.17.)"
|
||||
- "Conversion can't be undone in place. Front matter, wiki link forms and
|
||||
unsupported blocks may not survive it, and the mirror can't refuse the user's
|
||||
push to an archived file. The controls are the fidelity report, the human's
|
||||
confirmation, an untouched original on refusal, and the kept archive.
|
||||
(Version 1.17.)"
|
||||
- "A restore of the stack's bus alone from a backup taken before an import drops
|
||||
the import's logged events, while the archive commit and the DOCX stay. A
|
||||
restore of the stack's whole host from such a backup also takes the writing
|
||||
store and the host's import records back to before the import. The archive
|
||||
commit stays in the user's Gitea repository and the DOCX stays on the writing
|
||||
store's remote, but the host keeps no record of the import. A bus restore counts
|
||||
import events, and the import's recovery covers both cases (Vikunja 571).
|
||||
(Version 1.17.)"
|
||||
|
||||
## d9 changes (Q239 and Webby round 5)
|
||||
|
||||
- J-b yes (Q239, mos-1467), in the five edits mos-1443 R3 set for a yes (Webby round 6 B1). R3.1: REQ-LIB-5's replacement gains the Connections view sentence after "no graph shows them yet.". R3.2: the graph Risks line ends with the editor and the Connections view. R3.3: the A6 line moves from "Not taken" under A6. R3.5: the REQ-LIB-5 note ends with the Connections view, which drops "until a Console view of their links exists" and "Q226 records that view as a later option". R3.4 is Gyro's design edit at 767bb62c7 (writing-editor.md:188-190, :240-244 and :1272, docx-citations.md:66), and it follows in his docs round. Beyond these and N21, nothing else in the PRD text changes.
|
||||
- N19 and N20 (Webby round 6): J-b's question text is in the past tense, and the line that the import doesn't wait for the view is cited as Mos's R3 default. Mos confirmed it at his J-b check (mos-1470).
|
||||
- N21 (Webby round 6, Mos yes in mos-1470): REQ-LIB-5's version tag cites Q239 for the Connections view beside Q222 for the two settings.
|
||||
- N15: the D7 landing line gives R8's wording beside the impact map's quote.
|
||||
- N16: the d7 changes line notes that R8 replaces "no agent credential reaches it" in d8.
|
||||
- N17: the REQ-LIB-2 Replace quote and the REQ-LIB-8 old-path bullet are rewrapped under 82 columns. Their words are unchanged.
|
||||
|
||||
## d8 changes (Mos's d8 fix list, check wf_63bba879-180)
|
||||
|
||||
- R1: REQ-LIB-4's tail covers DOCX saves, which agents see at their next lookup after the editor reports them as committed. The notes quote the whole 1.16 sentence. Source: nit/jason/F10.
|
||||
- R2: REQ-LIB-4's new title says the mirror is "the one place agents read Markdown". Source: decisions.n14.
|
||||
- R3: J-b asks whether 1.17 requires the Console Connections view, with Mos's recommendation of yes. Sources: mos1372/F9, jason/F2, anchors/F5.
|
||||
- R4: C2-02 and C2-03b gate 10-27, C2-11a and C2-11b are slice rows that don't, and "F42 wins" is gone. New J-k. Sources: anchors/F6, jason/F5.
|
||||
- R5: where the landed 571 design differs, this text holds. Sources: anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6, G16's design follow-on.
|
||||
- R6: `library.convert` stays out of `ACTIONS`, and REQ-WAIT-1's hard-wait list doesn't change. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
|
||||
- R7: a repository on the stack's host never counts as the writing store's remote, and Item 2 says so. Sources: mos1372/F2, adversarial/F1, gyro571/F2.
|
||||
- R8: "no agent credential reaches it" becomes "no agent can reach it with any credential". Source: Fenton's B1 under mos-1379.
|
||||
- R9: no playground DOCX becomes canon in 1.17, and a promotion of one is refused. New J-i. Sources: mos1372/F8, adversarial/F2.
|
||||
- R10: REQ-LIB-9 restates Q195's pin and right-click thesaurus, and its cite gains D4 and D22. Source: critic/C4.
|
||||
- R11: REQ-LIB-9's license bar is the licenses its independent security review accepted. New J-l. Source: critic/C3.
|
||||
- R12: no text for D16's narrow-window tabs. Mos records that reading in STATE. Sources: jason/F1, second verifier vote.
|
||||
- R13: no lookup returns a file's archived copy before publication. Source: adversarial/F5 part 3.
|
||||
- R14: a restore of the whole host from a backup taken before an import is a fourth recovery case. Source: adversarial/F6.
|
||||
- M1: REQ-LIB-4's writing-store sentences follow the later bus rows sentence and add the off-host remote rules and DOCX read timing. The note quotes both 1.16 sentences. Sources: mos1372/F2, adversarial/F1, gyro571/F2, anchors/F4, nit/jason/F10, adversarial/F5 part 1, Fenton B1.
|
||||
- M2: REQ-LIB-8's order bullet adds the remote's confirmation, the old-path read rule and "archive push". Sources: mos1372/F2 and the other remote findings, mos1372/F7, adversarial/F5 parts 2 to 4.
|
||||
- M3: REQ-LIB-8's gate bullet has four conditions, adding the remote. Sources: the remote findings, critic/C1 and C2 edit 2.
|
||||
- M4: the 1.17 note on when conversion opens restates the four conditions and drops W4b. Sources: jason/F5, the remote findings.
|
||||
- M5: Item 2 rewritten with the remote rules. Sources: the remote findings.
|
||||
- M6: the 1.12 gating note rewritten. Sources: anchors/F6, jason/F5.
|
||||
- M7: REQ-LIB-9 gains the split pane, the pin, the thesaurus, the license bar and the runtime load rule, and cites more of the design. Sources: jason/F1, critic/C4, critic/C3, nit/anchors/F12, nit/gyro571/F9.
|
||||
- M8: REQ-LIB-2, the one-way bullet and the C1 note keep a playground DOCX out of canon in 1.17. Sources: mos1372/F8, adversarial/F2.
|
||||
- M9: REQ-LIB-5 says what the reader can't read, and the import turns web links into plain text. New J-j. Sources: critic/C1, critic/C2.
|
||||
- M10: an exclusion of a whole book folder covers that book's DOCX documents. Source: adversarial/F3, with B5.
|
||||
- M11: the human-verbs sentence and the 571 list item name `library.convert` and REQ-WAIT-1. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
|
||||
- M12: J-b rewritten. Sources: mos1372/F9, jason/F2, anchors/F5, gyro571/F3, critic/C6.
|
||||
- M13: J-d puts the agent read model to Jason as his call. Source: mos1372/F1.
|
||||
- M14: Status moves to version 1.17 and names REQ-LIB-9. Sources: anchors/F2, mos1372/F4, critic/C7.
|
||||
- M15: the 1.17 source line quotes Q235 in full (mos-1446) and the REQ-LIB-9 sources, at b99ca86b9. Sources: critic/C5, anchors/F9, jason/F8 pin part, nit/gyro571/F8, critic/C6 pin part.
|
||||
- M16: the 571 list lead names the two replaced design parts and the landed design. Sources: anchors/F9, anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6.
|
||||
- Landing swap: Gyro's docs round for R5, R6 and R14 landed as b99ca86b9 (Fenton fenton-0015), so M15 and M16 cite b99ca86b9 in place of 767bb62c7, as the d8 fix list's header says. J-b keeps 767bb62c7 for its line numbers.
|
||||
- M17: W4 section numbers in the remote item and the lookups note. Sources: gyro571/F5, nit/adversarial/F11.
|
||||
- M18: the D7 landing line. Sources: gyro571/F5, the remote findings.
|
||||
- M19: a logged event for each import stage, in REQ-LIB-8 and A2. Source: gyro571/F7.
|
||||
- M20: the Risks line on restores adds the whole-host case. Sources: adversarial/F6, gyro571/F7.
|
||||
- M21: the 571 restore item adds the whole-host case. Source: adversarial/F6.
|
||||
- M22: the graph Risks line and the REQ-LIB-5 note name a Console view. Sources: anchors/F5, jason/F2.
|
||||
- M23: the d6 B3 line drops "F42 wins". Source: anchors/F6.
|
||||
- M24: new Markdown in a converted book, with the old-path and playground exceptions. Source: mos1372/F6.
|
||||
- M25: J-c's last sentence is Mos's reading. Source: mos1372/F10.
|
||||
- M26: J-h rewritten with the D13 defaults. Source: jason/F3.
|
||||
- M27: Item 4 rewritten. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
|
||||
- M28: the "Not taken" note on unmarked folders. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
|
||||
- M29: the import refuses an unmapped file. Sources: gyro571/F4, decisions.unmarked_folders.
|
||||
- M30: placement notes for REQ-LIB-8, REQ-LIB-9 and the Risks lines. Sources: anchors/F3, nit/anchors/F14.
|
||||
- M31: REQ-LIB-4's title and the D11 landing line. Source: decisions.n14.
|
||||
- M32: the Q236 note limits the 1.12 Obsidian note to Markdown files. Source: critic/C8.
|
||||
- M33: the exclusion note adds path exclusions of DOCX documents. Source: anchors/F7.
|
||||
- M34: A9 moves from "Mine" to the impact-map list. Source: decisions.exclusion_by_id.
|
||||
- M35: the D5 landing line. Sources: anchors/F10, mos1372/F5, nit/adversarial/F12.
|
||||
- M36: the D9 and D14 landing lines. Source: mos1372/F1.
|
||||
- M37: the O1 and C3 notes add the off-host remote and its confirmation. Sources: the remote findings.
|
||||
- M38: the REQ-PROJ-3 note. Source: nit/anchors/F11.
|
||||
- M39: REQ-LIB-5's freshness sentence keys on the editor reporting a save as committed. Source: nit/adversarial/F9.
|
||||
- M40: REQ-LIB-6 bars agent requests for a conversion and allows a suggestion in chat. Source: nit/jason/F9.
|
||||
- M41: REQ-LIB-2's opening and old-path rule, and the "Mine" note. Source: nit/adversarial/F10.
|
||||
- M42: REQ-LIB-8's lead sentence on Markdown story files. Source: nit/mos1372/F12.
|
||||
- M43: REQ-LIB-6's citation sentence. Source: nit/anchors/F13.
|
||||
- M44: REQ-LIB-5's version tag uses plain quotes. Source: nit/anchors/F15.
|
||||
- M45: reference codes B5, D12, D10 and B4 name their source. Source: nit/mos1372/F11.
|
||||
- Files the row would own: four files, with the id count going to 87. Sources: anchors/F1, adversarial/F4, decisions.n14 F4.
|
||||
|
||||
## d7 changes (mos-1379, Fenton's 571 B2)
|
||||
|
||||
- REQ-LIB-5 gains Mos's three sentences after the extracted-text sentence. The text agents read has tracked changes accepted. Comments, and text with tracked deletions shown, each wait for a setting only a human turns on, off by default. Its version tag cites Q222 for the two settings, which is the only text here beyond Mos's.
|
||||
- J-d gains Mos's sentence on comments and tracked deletions.
|
||||
- Fenton's B1 is design only. d6's "no agent credential reaches it" holds (R8 replaces it in d8), and the shared helper stays #431.
|
||||
|
||||
## d6 changes (Webby round 3, webby-0207)
|
||||
|
||||
- B1: titles for REQ-LIB-8 and REQ-LIB-9 in the requirements.json note and the file list. REQ-LIB-9 takes Webby's title. REQ-LIB-8 keeps mine, because Webby's ("...one-way import that archives the Markdown under its old path") leaves out D2's story files started in the editor.
|
||||
- B2: REQ-LIB-2 makes a Markdown file at a converted file's old path unmapped, ahead of the playground and canon rules, so a playground conversion is covered too. REQ-LIB-8's old-path bullet says "neither canon nor playground".
|
||||
- B3: the slice note names Q226 F42's three rows (C2-02, C2-03b, C2-11b). C2-11a joined the slice with C2-11b, because C2-11b needs it. Both are slice rows that don't gate 10-27, as design 9.2, case2-rows.md:1655 and the impact map's A57 say. The 'F42 wins' sentence is gone from the note in d8, so no queue change follows from it.
|
||||
- N10: the cite-scope replacement is scoped to REQ-LIB-2. The 1.14 note's copy stays.
|
||||
- N11: the notes say REQ-LIB-8 adds the by-id exclusion form REQ-LIB-3 doesn't have.
|
||||
- N12: J-h marks the autosave intervals for Jason.
|
||||
- N13: J-b names REQ-LIB-5's "Connections are always visible" tag and Q226's "another way to handle the visual brain map".
|
||||
- N14, not taken: REQ-LIB-4's title stays internal F3's text, as mos-1372 D11 ruled. Webby's alternative, "...and agents read Markdown from it alone" (120 chars), is Mos's call.
|
||||
|
||||
## Where each mos-1372 item landed
|
||||
|
||||
- D1: REQ-LIB-2's DOCX sentences, the REQ-LIB-8 lead and import bullet, C1 above, and the notes. "Playground files stay Markdown in 1.17" is gone.
|
||||
- D2: REQ-LIB-2's "for one the human starts in the editor" and the REQ-LIB-8 new-story-file bullet. The universe-and-book note is now true.
|
||||
- D3: the REQ-LIB-8 order bullet. This also settles N8.
|
||||
- D4: REQ-LIB-8's one-way bullet, which carries the archive refusal and consequence F2's text, and the 571 list item.
|
||||
- D5: it landed in REQ-LIB-2's "a Markdown file at a converted file's old path is unmapped" in Webby's B2 form, and in the REQ-LIB-8 old-path bullet.
|
||||
- D6: REQ-LIB-5's freshness sentences.
|
||||
- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it", which R8 words in d8 as "no agent can reach it with any credential".
|
||||
- D8: the REQ-LIB-5 replacement, the import bullet's wiki-link sentence, the Risks line, the note quoting the old sentence, and "REQ-LIB-1 only gains sentences".
|
||||
- D9: the 1.17 note that agents read a converted story file through the Case 2 lookups, from the d4 check's consistency B2, and J-d, which puts the read model to Jason as his call.
|
||||
- D10: the d4 check's B5 sentence, in REQ-LIB-8.
|
||||
- D11: internal F3's clause in the REQ-LIB-4 section, and in the notes the title as Mos amended it for N14.
|
||||
- D12: the whole 1.16 sentence replaced, and quoted in full in the notes.
|
||||
- D13: consequence F1's text, in REQ-LIB-4 and REQ-LIB-8's first bullet.
|
||||
- D14: the row text from the d4 check's consistency B1, in the mark-and-move bullet and C3 above.
|
||||
|
||||
## What d5 adds beyond mos-1372 (for Webby and Mos to check)
|
||||
|
||||
From the impact map, folded as mos-1368 item 1 asked:
|
||||
- A1: the Console shows a cite's format and converted state (REQ-LIB-2).
|
||||
- A2: the logged events and the several-humans setting, default owner only, under Q222.
|
||||
- A3: the import is outside the vocabulary, the hard-wait list is unchanged, and the archive move isn't `data.delete`. The C6 code site is in the 571 list.
|
||||
- A4: the section lead and REQ-LIB-1's writing-store sentence.
|
||||
- A6: format and location in every answer, extracted text with DOCX locators, links across formats.
|
||||
- A6's named chapter-links view. D8 took B8's "no graph shows them yet", and J-b put the view to Jason. His O2 (Q239) takes it in d9 as REQ-LIB-5's Connections view (mos-1443 R3).
|
||||
- A7: read-only extends to the store and the archive, no agent action on a story file in any format, no agent DOCX write, and role contracts state the formats. C1's role contract line is a 571 build item.
|
||||
- A8: the DOCX cite shape, and a cite naming an archived file as current is refused.
|
||||
- A9: a hybrid sees a source's DOCX canon through REQ-LIB-2's source scope, and REQ-LIB-8 lets an exclusion name a DOCX document by its id. The id form lets the human exclude a single DOCX started in the editor, which has no old path. An archived file isn't canon, so no hybrid sees it.
|
||||
- A10: REQ-LIB-9, so the count is 87, not 86.
|
||||
- A11: the Hermes gate, from item 3.
|
||||
- A12: the C2-19 promotion sentence.
|
||||
- A13: the broker's path check treats the archive folder as story.
|
||||
- A14 and C5: two Risks lines.
|
||||
- A15: the flagged-new-file bullet. It sits beside the note from the d4 check's B4, "a new Markdown file in a book folder is still canon". The file stays canon and is offered for import.
|
||||
- A16: three titles change and two stay.
|
||||
- C2: manifest order for the manuscript lookup.
|
||||
- F3: the C2-02 gating note.
|
||||
- F4: Mos's measurement.
|
||||
|
||||
Mine, beyond either source:
|
||||
- REQ-LIB-2 opens "A Markdown file's scope comes from its path and from past imports". DOCX scope comes from the source, so "a file's" would be false.
|
||||
- REQ-LIB-9's autosave sentence. It is Mos's reading of F41's "instantly committed", with human-set intervals. The exact intervals stay in the design.
|
||||
|
||||
Not taken:
|
||||
- Impact map item 4's "files in unmarked folders are eligible one at a time", for folders outside every book folder and playground only. Their files are unmapped under REQ-LIB-2, so a DOCX made from one would have no universe or book to keep. The human maps such a folder into a book folder or a playground first, under REQ-LIB-1. A file in an unmarked folder inside a book folder or a playground is canon or playground, and the import takes it one file at a time.
|
||||
|
||||
## Marked for Jason (additions in the text he sees)
|
||||
|
||||
- J-a. Whether "We will not run the systems in parallel" applies per chapter or as one switch per book. His call. As written, it applies per chapter.
|
||||
- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading was that 1.17 as d8 wrote it met neither line for converted chapters. Their current links showed to the user only as clickable text inside the editor, and no graph or list showed them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 held a Console Connections view and its tests as later work that waited for this answer. His call was whether 1.17 requires that view. As d8 was written, it did not. Mos recommended that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible". Jason answered O2 (Q239): 1.17 requires the view. d9 writes it into REQ-LIB-5, its note and the Risks line, in the text of mos-1443 R3. The view comes with the conversion work after 10-27, and the 10-27 Case 2 release stays on Markdown (Q235 P6, Q237). That the import does not wait for the view is Mos's default in mos-1443 R3. Q239 doesn't address it, and Mos confirmed it at his J-b check (mos-1470).
|
||||
- J-c (mos-1374): "The store pushes to a remote (D7), and the Case 2 VM is in the nightly PBS backup. For a fixed restore point before her first real import, a manual backup of VM 1012 with PBS protect runs first. That step is reversible and needs no reboot." Mos's reading is that this sits inside the P5 go step, so it needs no new question.
|
||||
- J-d. How agents read a converted chapter, D9. His call. As written, an agent reads it through the ordinary Case 2 lookups in any thread or room, under the same project and universe access as Markdown canon. This is Mos's reading of F41 and REQ-LIB-7. The other choice is the model in the earlier O2 design, W4 sections 7.1 and 7.2. Under it, an agent reads a converted chapter only in a new writing conversation that holds one human and one agent, only after the business owner grants that agent access to that document, and by default only what the human attaches to each message. Comments and tracked deletions stay hidden from agents until a human turns on a setting for each. Markdown has neither, so the default keeps agents at the access Markdown gives them. Those two sentences are Mos's ruling mos-1379. A yes to the text as written settles it.
|
||||
- J-e. Versioning means the editor's tracked changes plus one git commit per save.
|
||||
- J-f. A converted chapter can be edited only in the Console while the stack is reachable. Obsidian no longer edits it offline.
|
||||
- J-g. The import takes Markdown only. Importing .doc files stays a separate task.
|
||||
- J-h. Autosave under REQ-LIB-9. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." Mos's reading, recorded with that ruling, is "Each save is a commit at once, and agents read it at their next lookup." At the O2 design's D13 defaults, a save commits at most 2 seconds after a pause in typing or 15 seconds into steady typing, with no human action needed. Mos's reading in mos-1359 counts that delay as "instantly committed". Only a human changes the intervals. The PRD names no values, and the defaults stay in the design. Whether a delay of seconds fits his word "instantly" is his call, so this is marked for him.
|
||||
- J-i. In 1.17 a playground story that is DOCX can't become canon. That covers a playground Markdown file the human imports, a story the human starts in a playground in the editor, and the DOCX of a draft universe after it is made real. Today Melanie promotes a playground file by moving it into a book folder in Obsidian, and a DOCX in the writing store can't be moved that way. She can start a new story in the book and paste the text in, but it gets a new document id and the playground copy keeps the history. A later version adds DOCX promotion with C2-19, which comes after the slice.
|
||||
- J-j. Web links, text boxes and fields in a converted chapter. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." The reader in this version can't read a web link, a text box, a field or hidden text. As written, the import turns each web link into plain text that keeps its label and address, and the human confirms that in the fidelity report. A web link that can't be kept safely as plain text refuses the import. If a later edit adds one of these, the save keeps it and the editor warns the human. Agents then get none of that part's text until a save takes it out.
|
||||
- J-k. The 1.12 note Jason approved says bus writes, C2-02 and C2-03b, don't gate 10-27. His Q226 "F42: ok" moved them into the 10-27 slice, and design 9.2 counts them in the smallest slice that lets Melanie start. Mos reads that as making them gate 10-27. The editor rows C2-11a and C2-11b still don't gate it, per his Q195 words "we need to get it moving as an add-on".
|
||||
- J-l. REQ-LIB-9 does not use REQ-PROJ-3's "permissive license" bar. It takes the licenses that the editor's independent security review accepted, because that review cleared fonts under the SIL Open Font License and the Ubuntu Font License, which not every reader counts as permissive. Mos's reading.
|
||||
|
||||
Marked as changes beyond P1 to P6, from the impact map (Q17, item 5):
|
||||
- A1 (archived scope and canon cites)
|
||||
- A2 (the conversion requirement, REQ-LIB-8)
|
||||
- A3 (outside the vocabulary, hard-wait list unchanged)
|
||||
- A6 (lookups across formats)
|
||||
- A7 (agent bounds)
|
||||
- A8 (DOCX cites)
|
||||
- Item 2 and mos-1375: each writing-store commit is pushed to a remote off the stack's host. Only a human sets it, it starts unset, and a repository on the stack's host doesn't count. Only the stack's host pushes to it, and no agent can reach it with any credential. Import and new story files stay closed until the remote is set and checked, and each import's archive push waits until the remote holds the DOCX commit. This keeps "Git is used for all changes -- local and remote" true for DOCX chapters.
|
||||
- Item 3 (the Hermes gate)
|
||||
- Item 4. Canon and playground story files convert one file at a time, including files in unmarked folders inside a book folder or a playground. Type-folder and supporting files stay Markdown. A story file outside every book folder and playground has no universe or book, so it converts only after a human maps its folder into a book folder or a playground. The mapping step is Mos's reading of P1 and Q30.
|
||||
|
||||
The F4 measurement goes with them.
|
||||
|
||||
## Files the row would own
|
||||
|
||||
The id count goes from 85 to 87. 1.16 owned three files because its count stayed at 85. Like 1.13, the last version that added ids, the row owns four files:
|
||||
- `docs/prd/mosaic-stack.md`
|
||||
- `packages/tasks/requirements.json`: the version 1.17, the two new ids REQ-LIB-8 and REQ-LIB-9 with their titles, and the changed titles of REQ-LIB-1, REQ-LIB-2 and REQ-LIB-4. The new ids go after REQ-LIB-7 and before REQ-INST-1, in the same order as the PRD's bullets.
|
||||
- `packages/tasks/tests/requirements.test.mjs`: the version pin 1.17, the id count of 87, and the duplicate-id message, which goes from `requirements[85].id: REQ-ROLE-1 twice` to `requirements[87].id: REQ-ROLE-1 twice`.
|
||||
- `packages/bus/tests/requirements.test.mjs`: the list length, which goes from 85 to 87.
|
||||
|
||||
The gate would match 1.16's:
|
||||
1. A reviewer approves the text.
|
||||
2. Jason says yes to the exact text through Mos.
|
||||
3. Both `requirements.test.mjs` files, in `packages/tasks/tests` and `packages/bus/tests`, and every `scripts/test-*.sh` that reads the PRD or `requirements.json` are green on Sage's gate.
|
||||
4. Mos gets the landing commit.
|
||||
@@ -0,0 +1,652 @@
|
||||
# PRD 1.17 draft: Case 2 story files are DOCX (Q235 to Q237, P1 to P6)
|
||||
|
||||
Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 9. It is d8 plus Jason's Q41 answer O2 (DECISIONS Q239, mos-1467), which makes J-b yes, Webby's round 5 N15 to N17 (webby-0210) and Webby's round 6 B1, N19 and N20 (webby-0212) (see "d9 changes"). d8 is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
|
||||
- Mos's d4 check ruling mos-1372 (D1 to D14; D15 was dropped).
|
||||
- mos-1374, which replaces J-c.
|
||||
- The impact map mos-1368 (jason-master-restart d183246, `projects/mosaic-mvp/case2-docx-impact-map.md`: A1 to A16, critic items C1 to C6, items 2 to 7).
|
||||
- Mos's F4 measurement.
|
||||
|
||||
Review order (mos-1367 B5): Webby, then Mos, then Jason. Base: PRD 1.16, `docs/prd/mosaic-stack.md` blob a6db9b4a at 482e865ac.
|
||||
1.16 lands first (row 291), and this text is a new version on top of it. Nothing here is built or queued. The impact map's queue changes (A24, A57 to A59) go through `scripts/mosaic queue` after 1.17 is approved (C9).
|
||||
|
||||
Sources, verbatim in Mos's DECISIONS:
|
||||
- Q235, Jason's Q36 answer.
|
||||
- Q236: chapters are DOCX, Markdown for agent files and docs.
|
||||
- Q237: "It is correct for now (subject to future change)".
|
||||
- The P1 to P6 reading as restated to Jason in Q38.
|
||||
|
||||
## Settled points (mos-1367)
|
||||
|
||||
These stand as ruled. d5 changes only what mos-1372 changed.
|
||||
|
||||
- **O1. Where a converted chapter lives.** The DOCX lives in the writing store of
|
||||
O2 design D12, and the archived Markdown stays in the user's repository under a
|
||||
human-marked archive folder. Ruled yes in B1, on the condition that the text
|
||||
says plainly that a converted chapter leaves the Gitea-mirrored repository and
|
||||
lives in the stack's writing store. mos-1372 D7 and mos-1375 add a remote for
|
||||
the store, off the stack's host.
|
||||
- **O2. The unit of conversion.** One story file per import, refusing a file
|
||||
that is already converted. Ruled yes in B2: each file gets its own fidelity
|
||||
report, human confirmation, manifest and commit.
|
||||
- **O3. Whether conversion is a vocabulary action.** A human-only verb outside
|
||||
the agent vocabulary, with no role grant and no agent request path. Ruled yes
|
||||
in B3.
|
||||
- **O4. Reading archived Markdown.** A new `archived` scope. Lookups leave it out
|
||||
unless the caller asks, every answer marks it archived, and a canon citation
|
||||
can't name it. Ruled yes in B4.
|
||||
|
||||
## d3 choices after mos-1372
|
||||
|
||||
- **C1 is replaced by D1, keep-source-scope.** A DOCX keeps its source's scope:
|
||||
universe, book, and canon or playground. A playground DOCX follows the
|
||||
playground rules and is never returned as canon. The import accepts canon and
|
||||
playground story files. Type-folder files and supporting files stay Markdown
|
||||
(P4). This also settles the impact map's Q18, item 4. It leaves one narrowing
|
||||
for Jason, J-i. A playground DOCX can't become canon in 1.17.
|
||||
- **C2 stands.** The move keeps the file's path under the archive folder:
|
||||
`book1/ch3.md` goes to `<archive>/book1/ch3.md`. An archived file's universe
|
||||
and book are those of its old path.
|
||||
- **C3 is replaced by D3 and D14.** The archive commit is written by a new
|
||||
human-only bus step (Vikunja 571) on the C2-02 write plumbing and pushed through
|
||||
C2-03b. The order is the store commit and the remote's confirmation that it
|
||||
holds that commit, then the archive commit and its push, then publication to the
|
||||
lookups.
|
||||
- **N4 stands.** REQ-LIB-8 says "a real user's books". The PRD names no user
|
||||
anywhere in Case 2.
|
||||
|
||||
## Text changes
|
||||
|
||||
### Status and sources
|
||||
|
||||
In the Status bullet's first line, "- Status: approved, version 1.16." becomes
|
||||
"- Status: approved, version 1.17." Status gains, after the two 1.16 sentences at
|
||||
the end of the bullet: "Version 1.17 records Jason's rulings Q235 to Q237 of
|
||||
2026-10-23: Case 2 story files are DOCX, Markdown stays for agent files and
|
||||
documentation, and a human-started import converts a Markdown story file one way.
|
||||
It also adds REQ-LIB-9, the Console's DOCX editor, from Jason's rulings Q195, Q226
|
||||
and Q234. It changes what Case 2 requirements allow, so Mos puts the text to Jason
|
||||
as written, and it lands only after his yes (mos-1366, Vikunja 570)."
|
||||
|
||||
New source line, above the 1.16 one: "Version 1.17 source: Jason's Q235, answering
|
||||
Q36: 'How about we see a markdown file, we convert the markdown file to docx, then
|
||||
archive or mark the markdown file as converted. The new standard for files in the
|
||||
creating writing use case is to use docx to handle versioning and WYSIWYG. We
|
||||
eliminate the potential for confusion based on the files. Agents must understand
|
||||
which files are what format and where they live. The system must allow for import
|
||||
and conversion. We will not run the systems in parallel. There will be an
|
||||
irreversible conversion of the files.' His Q236 says: 'For creative writing
|
||||
chapters, we need to utilize docx as the standard. Markdown is acceptable for
|
||||
agent files, instructions, PRDs, documentation, etc. The creative writing files
|
||||
should be docx for familiarity for the user.' His Q237 says 'Q38: It is correct
|
||||
for now (subject to future change)', which confirms Mos's reading P1 to P6.
|
||||
REQ-LIB-9 rests on his Q195 request for 'a full WYSIWYG editor within Mosaic
|
||||
Stack', his Q226 answer 'F41: We want to move editing into the WYSIWYG in the
|
||||
WebUI. All changes are instantly committed and available to the agents.' and his
|
||||
Q234 answer 'Q37: yes' to the exact security header for the editor's frame. The
|
||||
storage follows the O2 design D10 to D12 as rewritten under Vikunja 571, at
|
||||
b99ca86b9 on `refactor`."
|
||||
|
||||
### Creative library, section lead
|
||||
|
||||
Replace "The library is the user's own writing repository." with "The library is
|
||||
the user's own writing repository plus the stack's writing store, which holds the
|
||||
DOCX story files (REQ-LIB-8, version 1.17)."
|
||||
|
||||
### REQ-LIB-1
|
||||
|
||||
Add, before "(Design 1.1": "The mapping also names one archive folder, set by the
|
||||
human, where converted Markdown story files go. A book's DOCX story files are in
|
||||
the stack's writing store, under the same universe and book (REQ-LIB-8)." Version
|
||||
tag, inside the closing parenthesis: "Version 1.15: supporting folders.)" becomes
|
||||
"Version 1.15: supporting folders. Version 1.17: the archive folder and the
|
||||
writing store.)"
|
||||
|
||||
### REQ-LIB-2
|
||||
|
||||
Replace "A file's scope comes from its path alone, prefixes first: a Markdown
|
||||
file under a supporting prefix is supporting, one under a playground prefix at
|
||||
any level is playground, any other Markdown file in a book folder is canon, and
|
||||
everything else is unmapped and never returned. A folder in both lists is
|
||||
supporting." with:
|
||||
|
||||
"A Markdown file's scope comes from its path and from past imports, prefixes
|
||||
first: a Markdown file under the archive folder is archived, one under a
|
||||
supporting prefix is supporting, a Markdown file at a converted file's old path is
|
||||
unmapped under REQ-LIB-8 but flagged in the format map, one under a playground
|
||||
prefix at any level is playground, any other Markdown file in a book folder is
|
||||
canon, and everything else is unmapped and never returned. A folder in both lists
|
||||
is supporting. The archive folder follows the same placement rules and may not
|
||||
overlap another prefix or a book folder. An archived file keeps the path it had
|
||||
under the archive folder, and its universe and book are those of that old path. A
|
||||
DOCX document in the writing store keeps its source's scope: the universe, the
|
||||
book if there is one, and canon or playground. Its source is the Markdown file it
|
||||
was converted from or, for one the human starts in the editor, the universe and
|
||||
book or the playground the human picks (REQ-LIB-8). A playground DOCX follows the
|
||||
playground rules and is never returned as canon. It stays playground after its
|
||||
draft universe is made real. In version 1.17 no promotion or mapping change makes
|
||||
a playground DOCX canon. A later version of this PRD says how one becomes canon."
|
||||
|
||||
Replace "Lookups return canon unless the caller asks for playground or
|
||||
supporting." with "Lookups return canon unless the caller asks for playground,
|
||||
supporting or archived. Every answer marks an archived file as archived and names
|
||||
the DOCX that replaced it."
|
||||
|
||||
Replace "so an agent can't cite a playground or supporting file as canon" with "so
|
||||
an agent can't cite a playground, supporting or archived file as canon".
|
||||
|
||||
In REQ-LIB-2 only, replace "The Console shows each cite's scope." with "The Console shows each cite's
|
||||
scope and format, and whether a cited Markdown file was converted."
|
||||
|
||||
Version tag, inside the closing parenthesis: "Version 1.15: supporting.)" becomes
|
||||
"Version 1.15: supporting. Version 1.17: archived scope and DOCX scope.)"
|
||||
|
||||
### REQ-LIB-4
|
||||
|
||||
Replace "The user commits and pushes with their own tools, today Obsidian with
|
||||
obsidian-git." with "The user commits and pushes Markdown with their own tools,
|
||||
today Obsidian with obsidian-git. A converted story file's current text is no
|
||||
longer in that repository, which keeps only its archived Markdown (REQ-LIB-8)."
|
||||
|
||||
Add, after "Bus writes and pushes come in later rows (C2-02, C2-03b).": "A DOCX
|
||||
story file is saved in the Console's editor, and each accepted save is one commit
|
||||
by the human in the stack's writing store (REQ-LIB-9). Each writing-store commit
|
||||
is also pushed to a remote off the stack's host, never by force-push. The remote
|
||||
is a setting only a human changes, and it starts unset. A repository on the
|
||||
stack's host never counts as that remote. A missing remote or a failed push never
|
||||
undoes a save, and the user sees each push that is pending or failed. Only the
|
||||
stack's host pushes to that remote, and no agent can reach it with any credential.
|
||||
Agents read the local commit."
|
||||
|
||||
Replace "Agents read only the mirror's served commit and never hold a copy of
|
||||
their own, so they see the user's push within about a minute." with "Agents read
|
||||
the user's repository only at the mirror's served commit, except a story file
|
||||
during its import under REQ-LIB-8. They read a DOCX story file only at a committed
|
||||
version in the writing store, under REQ-LIB-5. They never hold a copy of their
|
||||
own, so they see the user's Markdown push within about a minute and a DOCX save at
|
||||
their next lookup after the editor reports it as committed."
|
||||
|
||||
Version tag: "and the sync items.)" becomes "and the sync items. Version 1.17,
|
||||
with Q222 for the remote setting.)"
|
||||
|
||||
### REQ-LIB-5
|
||||
|
||||
Add, after "Each answer names the commit it read.": "Each answer also names the
|
||||
file's format and where it lives: its path, or its document id in the writing
|
||||
store. A DOCX story file is read from its latest committed save, never from
|
||||
unsaved text. A lookup that starts after the editor reports a save as committed
|
||||
returns that save or a later one, even while its push to the remote is pending or
|
||||
has failed, and the answer names the version. Get, search and manuscript read a
|
||||
DOCX story file as its extracted text with DOCX locators, never as lines. That
|
||||
text is the file with its tracked changes accepted. An agent reads a story file's
|
||||
comments, or its text with tracked deletions shown, only after a human turns on
|
||||
the matching setting. Both settings are off by default, and only a human changes
|
||||
them. The reader in this version can't read some things a DOCX story file can
|
||||
hold, such as a web link, a text box, a field or hidden text. The editor warns the
|
||||
human when an edit adds one, and the human can still save it. Until a later save
|
||||
takes it out, lookups give agents none of the text of the part that holds it,
|
||||
whether that is the chapter's body, a note or a comment. The manuscript lookup
|
||||
follows the book's chapter order across both formats (O2 design D10), and the next
|
||||
chapter it names is never an archived file. Links out and in resolve across both
|
||||
formats. The universe lookup returns the format map (REQ-LIB-8)."
|
||||
|
||||
Replace "The user sees them in Obsidian's graph and backlinks." with "The user sees
|
||||
a Markdown file's links in Obsidian's graph and backlinks. A DOCX story file's
|
||||
wikilinks stay clickable in the Console's editor (O2 design D11), and no graph
|
||||
shows them yet. The Console's Connections view lists the current links and
|
||||
backlinks of each story file the user may see, in both formats, with each one's
|
||||
scope and format."
|
||||
|
||||
Version tag: 'and "Connections are always visible".)' becomes 'and "Connections
|
||||
are always visible". Version 1.17, with Q222 for the two settings and Q239 for
|
||||
the Connections view.)'
|
||||
|
||||
### REQ-LIB-6
|
||||
|
||||
Replace "A reply may cite lines of the repository at a commit or pages the agent
|
||||
fetched, and the broker checks every citation before it stores the message." with
|
||||
"A reply may cite lines of a Markdown file at a commit, a place in a committed
|
||||
DOCX story file, or pages the agent fetched, and the broker checks every citation
|
||||
before it stores the message. A DOCX citation never calls a paragraph a line. It
|
||||
names the document id, the committed version and a DOCX locator. A citation that
|
||||
names an archived file as current is refused. The `requireCitation` setting below
|
||||
applies to Markdown and DOCX citations alike, and so does the rule for a citation
|
||||
marked as canon."
|
||||
|
||||
Add, after "the broker refuses such a write before any git call.": "The writing
|
||||
store and the archive folder are read-only to a creative agent as well, and the
|
||||
broker's path check treats the archive folder as story. No agent creates, edits,
|
||||
deletes, converts, archives or restores a story file in any format, and no agent
|
||||
writes a DOCX. No creative agent starts or undoes a conversion, and none asks for
|
||||
one through a tool or a decision it raises, as REQ-LIB-8 says. An agent may still
|
||||
suggest a conversion to the human in chat. Each creative role's contract states
|
||||
which files are DOCX and which are Markdown, and cites a DOCX story file by its
|
||||
locator, never by line."
|
||||
|
||||
Version tag, inside the closing parenthesis: "Version 1.15.)" becomes "Version
|
||||
1.15. Version 1.17.)"
|
||||
|
||||
### New REQ-LIB-8
|
||||
|
||||
This bullet goes after REQ-LIB-7 and before the "### Installer" heading.
|
||||
|
||||
"- **REQ-LIB-8.** Story files become DOCX. DOCX is the standard for chapters and
|
||||
the other story text in a Case 2 writing project, canon and playground alike. A
|
||||
story file becomes DOCX through a human's import (below), or when the human
|
||||
starts it in the editor. From then on DOCX is its only working format, edited in
|
||||
the Console's editor (REQ-LIB-9). A Markdown story file stays Markdown until its
|
||||
import. Files in a type folder (REQ-LIB-1), such as character and place notes,
|
||||
stay Markdown. So do supporting files, agent files, instructions, PRDs and
|
||||
documentation, including the character sheets, summaries and cross-links agents
|
||||
keep under REQ-LIB-6.
|
||||
- A converted story file's current text leaves the user's Gitea-mirrored
|
||||
repository. Its DOCX lives in the stack's writing store, on the stack's host,
|
||||
and not in the repository the user pushes or opens in Obsidian. Only its
|
||||
archived Markdown stays there.
|
||||
- An import converts one Markdown story file, canon or playground, outside every
|
||||
type folder (REQ-LIB-1), to a DOCX document in the writing store. It refuses
|
||||
an unmapped, supporting, archived or type-folder file, and a file that is
|
||||
already converted. Each import has its own fidelity report, the human's
|
||||
confirmation of that report, a manifest naming the source file and its commit,
|
||||
and one commit in the store. The DOCX keeps every wiki link of the source as
|
||||
written, and a lost link to another library file fails the fidelity report. A
|
||||
web link becomes plain text that keeps its label and address, and the fidelity
|
||||
report lists it for the human to confirm. A web link that can't be kept safely
|
||||
as plain text refuses the import.
|
||||
- The human can also start a new story file in the editor, in a universe and
|
||||
book or a playground the human picks. It is a DOCX document in the writing
|
||||
store from its first commit, with no Markdown copy, and it opens under the
|
||||
same gate as the import.
|
||||
- The same import marks the Markdown file converted, naming the document, and
|
||||
moves it to the archive folder under its old path (REQ-LIB-1, REQ-LIB-2).
|
||||
The mark and the move are one commit authored by the human in the user's
|
||||
repository. A new human-only bus step (Vikunja 571) on the C2-02 write
|
||||
plumbing writes it, and C2-03b pushes it. The move keeps the bytes and the
|
||||
git history, so it isn't `data.delete`.
|
||||
- The store commit comes first, and the writing store's remote must confirm it
|
||||
holds that commit. Then come the archive commit and its push, then publication
|
||||
to the lookups. Until publication the Markdown at its old path keeps its canon
|
||||
or playground scope. Agents read that file at the commit the import started
|
||||
from, even after the mirror's served commit reaches the archive commit or a
|
||||
later one. No lookup returns the file's archived copy before publication, even
|
||||
one that asks for archived files. A refusal or failure before the archive push
|
||||
leaves the Markdown unmoved and no DOCX that a lookup returns. A failure after
|
||||
the archive push leaves a pending import that the human sees and can resume,
|
||||
never a claim that nothing moved. An archive push whose result is unknown
|
||||
counts as a failure after the archive push until the stack reads the result
|
||||
back from the user's remote.
|
||||
- Each stage of an import writes a logged event. The import's record on the
|
||||
stack's host names the source file's path, commit and blob and the new
|
||||
document id, and each event points to that record.
|
||||
- After a conversion, a Markdown file at the converted file's old path is
|
||||
neither canon nor playground. Lookups leave it out, the format map flags it,
|
||||
and the human is told.
|
||||
- A new Markdown story file in a book folder that holds converted files stays
|
||||
canon until its import, unless it sits at a converted file's old path or under
|
||||
a playground prefix. The Console flags it and offers the import. Nothing
|
||||
converts it on its own.
|
||||
- A hybrid's exclusion that covers a converted file's old path also covers its
|
||||
DOCX document (REQ-LIB-3). An exclusion of a whole book folder also covers
|
||||
every DOCX document in that book, including one started in the editor. An
|
||||
exclusion can also name a DOCX document by its id.
|
||||
- Only a human starts an import or a new story file, from the CLI or a Console
|
||||
session signed in as that human. Both are human verbs outside the agent action
|
||||
vocabulary. Neither is added to `ACTIONS` in the business vocabulary or to the
|
||||
broker's always-gated list, including the import's action, `library.convert`.
|
||||
No role holds them, no agent can request them, and the hard-wait list in
|
||||
REQ-WAIT-1 doesn't change. In an organization with several humans, who may
|
||||
start them is a setting only a human changes, and the default is the owner
|
||||
only.
|
||||
- Conversion is one way, with no parallel run. Neither the Console nor any agent
|
||||
edits a converted story file as Markdown, and no DOCX edit syncs back to
|
||||
Markdown. The Console's Markdown save refuses every file under the archive
|
||||
folder. The user's own tools can still change the archived Markdown, and no
|
||||
such change reaches the DOCX. The old Markdown stays in git history, and
|
||||
nothing rewrites history. A promotion into a book folder (C2-19) runs the
|
||||
import for a Markdown story file or is refused. A promotion of a playground
|
||||
DOCX is refused in version 1.17.
|
||||
- Agents get a format map: for each mapped folder and story file, its format,
|
||||
scope and where it lives, archived files, flagged files and pending imports
|
||||
included.
|
||||
- The import and new story files ship closed. They open only after four things.
|
||||
The editor ships. The human has set the writing store's remote, and the stack
|
||||
has checked that it can push there. Agents can read committed DOCX story files
|
||||
through the lookups and cite them (REQ-LIB-5, REQ-LIB-6). Any earlier agent
|
||||
system that writes the user's files has stopped, which for Case 2 is the
|
||||
Hermes retirement (#423 step 5). So the import itself never cuts agents off
|
||||
from a chapter, the chapter's current text never lives only on the stack's
|
||||
host, and no second system writes the story. The first import of a real user's
|
||||
books waits for Jason's go.
|
||||
(Q235, Q236, Q237: P1 to P6. Q222 for the setting. O2 design D10 to D12.
|
||||
Version 1.17.)"
|
||||
|
||||
### New REQ-LIB-9
|
||||
|
||||
This bullet goes after REQ-LIB-8.
|
||||
|
||||
"- **REQ-LIB-9.** The Console's DOCX editor. The human edits a DOCX story file in
|
||||
the Console's editor, in a split pane with the agent chat alongside it. The
|
||||
human can pin an agent chat to a document. The human can select a word,
|
||||
right-click it, and choose a synonym from a thesaurus. Only a human saves. Saves
|
||||
happen on their own while the human types (Q226), at intervals only a human
|
||||
changes, and each accepted save is one commit with the human as author, pushed
|
||||
as REQ-LIB-4 says. A save made against an older version than the current one is
|
||||
refused. Agents never write a DOCX. Versioning is the editor's tracked changes
|
||||
plus one git commit per save. The editor's code is vendored and pinned, with a
|
||||
hash per file and the licenses that its independent security review accepted. At
|
||||
runtime the editor loads only files that the stack itself serves, and it
|
||||
requests nothing from any other host. The editor's frame sends exactly the
|
||||
content security policy Jason approved in Q234, and the WebUI shell's own header
|
||||
is unchanged. REQ-PROJ-3's project docs editor is unchanged. (Q195, Q226, Q234,
|
||||
Q236. O2 design D3, D4, D6, D8, D10, D13, D14, D16 and D22. Version 1.17.)"
|
||||
|
||||
### Version 1.17 notes
|
||||
|
||||
Added after the 1.16 notes:
|
||||
|
||||
"- Version 1.17:
|
||||
- Jason confirmed the reading 'for now (subject to future change)' (Q237). The
|
||||
rulings stand until he revises them.
|
||||
- The 10-27 Case 2 release ships on Markdown as built (P6). The 1.11 line 'The
|
||||
Case 2 MVP for 10-27 is the Markdown library (#416). The WYSIWYG editor (Q195,
|
||||
Q196) is an add-on that doesn't gate it.' still holds. Conversion opens after
|
||||
10-27, and only once REQ-LIB-8's four conditions are met. The editor ships,
|
||||
the writing store's remote is set and the stack has checked that it can push
|
||||
there, agents can read and cite committed DOCX story files through the
|
||||
lookups, and Hermes is retired, which is #423 step 5.
|
||||
- The 1.12 note says five bus write and Console editor rows, C2-02, C2-03b,
|
||||
C2-11a, C2-11b and C2-19, don't gate 10-27. Jason's Q226 F42 later moved
|
||||
C2-02, C2-03b and C2-11b into the 10-27 slice. The Case 2 design brings C2-11a
|
||||
in with them, because C2-11b needs it. C2-19 stays outside the slice. Mos's
|
||||
reading of F42 is that C2-02 and C2-03b now gate 10-27, because design 9.2
|
||||
puts them in 'the smallest slice that lets her start'. C2-11a and C2-11b are
|
||||
slice rows that still don't gate 10-27, because the editor is the Q195 add-on.
|
||||
The DOCX change in 1.17 leaves this slice as it is, and C2-11b's Markdown
|
||||
editor never edits a converted story file.
|
||||
- Q236 narrows Q176's editor choice to DOCX for story files (REQ-LIB-9). The
|
||||
1.12 note that the Case 2 design keeps Obsidian now holds only for Markdown
|
||||
files, including story files not yet imported. A DOCX story file is edited
|
||||
only in the Console's editor under REQ-LIB-9.
|
||||
- This replaces two parts of the O2 design at 25c09f19f. The superseded-notice
|
||||
proposal from Fenton's F1 in D12 is withdrawn, and the
|
||||
keep-Markdown-at-its-path model of D11 is replaced. Gyro's rewrite of the O2
|
||||
design for Vikunja 571, landed on `refactor` at b99ca86b9, carries the
|
||||
details, and where it differs from this text, this text holds:
|
||||
- the import and archive steps, the mark, and the new bus step;
|
||||
- links from supporting files to a moved chapter;
|
||||
- the DOCX reader;
|
||||
- the user's unpushed or later changes to an archived file or at its old path,
|
||||
and pushing and closing the file in Obsidian before each import;
|
||||
- the writing store's remote in W4 section 7.3, which replaces the no-remote
|
||||
and no-backup rule that W4 section 7.4 held before the rewrite, at
|
||||
062646487;
|
||||
- import events counted by a bus restore, and recovery when a bus restore or a
|
||||
restore of the whole host predates an import;
|
||||
- manifest order for the manuscript lookup and its next chapter;
|
||||
- `library.convert` kept out of `ACTIONS`, out of the hard-wait note and out
|
||||
of the pinned `GATED_ONLY` list in packages/business/src/vocabulary.mjs.
|
||||
- Agents read a converted story file through the Case 2 lookups under the same
|
||||
project and universe access as Markdown canon, in any thread or room. W4's
|
||||
earlier O2-only conversations, per-document grants and per-send sharing don't
|
||||
gate these lookups. The 571 rewrite replaced them in W4 sections 7.1 and 7.2.
|
||||
The creative tool list in REQ-LIB-6 doesn't change.
|
||||
- The six creative role contracts row 213 landed say 'Every item cites the
|
||||
lines of the user's material it is about'. A build row under 571 changes that
|
||||
line for DOCX story files (REQ-LIB-6).
|
||||
- A new Markdown file in a book folder is still canon in 1.17. The 1.17
|
||||
exception is a file at a converted file's old path, which REQ-LIB-2 makes
|
||||
unmapped.
|
||||
- Story files convert whether canon or playground. Type-folder and supporting
|
||||
files stay Markdown.
|
||||
- Measured 2026-10-23 at about 01:21Z: the Case 2 user's library repository
|
||||
tracks 213 files, 203 of them Markdown, and none is a .docx or .doc. It has no
|
||||
untracked files. The user's .doc files (Q196) are outside it. So the landed
|
||||
code has no DOCX there to report as unreadable at 10-27.
|
||||
- Text replaced in 1.17, kept here as in earlier notes:
|
||||
- Creative library lead, the 1.16 sentence: "The library is the user's own
|
||||
writing repository."
|
||||
- REQ-LIB-2, the 1.16 sentences: "A file's scope comes from its path alone,
|
||||
prefixes first: a Markdown file under a supporting prefix is supporting,
|
||||
one under a playground prefix at any level is playground, any other
|
||||
Markdown file in a book folder is canon, and everything else is unmapped
|
||||
and never returned. A folder in both lists is supporting." "Lookups return
|
||||
canon unless the caller asks for playground or supporting." "so an agent
|
||||
can't cite a playground or supporting file as canon" "The Console shows
|
||||
each cite's scope."
|
||||
- REQ-LIB-4, the 1.16 sentences: "The user commits and pushes with their own
|
||||
tools, today Obsidian with obsidian-git." "Agents read only the mirror's
|
||||
served commit and never hold a copy of their own, so they see the user's
|
||||
push within about a minute."
|
||||
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
|
||||
backlinks." A converted chapter's connections show in Obsidian only as they
|
||||
stood at conversion, and the Console's Connections view shows their
|
||||
current links.
|
||||
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
|
||||
a commit or pages the agent fetched, and the broker checks every citation
|
||||
before it stores the message."
|
||||
- REQ-LIB-1 only gains sentences.
|
||||
- REQ-LIB-3's text and REQ-LIB-7 are unchanged. REQ-LIB-8 adds one form of
|
||||
exclusion REQ-LIB-3 doesn't have: naming a DOCX document by its id. A path
|
||||
exclusion also covers DOCX documents, through a converted file's old path or a
|
||||
whole book folder. An archived file and a DOCX document keep a universe, and a
|
||||
book where their source has one, so every lookup still names one universe.
|
||||
REQ-PROJ-3, the Console editor for a project's spec and docs, is unchanged.
|
||||
C2-11b opens the library's Markdown files in that same editor.
|
||||
- In `packages/tasks/requirements.json` the titles of REQ-LIB-1, REQ-LIB-2 and
|
||||
REQ-LIB-4 change, and those of REQ-LIB-5 and REQ-LIB-6 stay. REQ-LIB-1 becomes
|
||||
'A human-set library maps the user's repository onto universes, books, types,
|
||||
playgrounds, supporting and archive folders'. REQ-LIB-2 becomes 'Scope comes
|
||||
from the path or a DOCX's source, lookups default to canon, and only canon
|
||||
files are ever cited as canon'. REQ-LIB-4 becomes 'Git holds every change. The
|
||||
bus fast-forwards a read-only mirror every 60 seconds, the one place agents
|
||||
read Markdown'. REQ-LIB-8 and REQ-LIB-9 add two ids, so the count goes from 85
|
||||
to 87. REQ-LIB-8 is titled 'Case 2 story files become DOCX by a human-only
|
||||
one-way import or in the editor; Markdown stays for notes and docs', and
|
||||
REQ-LIB-9 'The Console's DOCX editor: only a human saves, each save is one
|
||||
commit, and the editor code is vendored and pinned'.
|
||||
- Open points O1 to O4 of draft 1 were ruled by Mos in mos-1367 B1 to B4.
|
||||
- Sage files the build rows under Vikunja 570 and 571 after the rewrite is
|
||||
approved. This amendment adds the requirements only."
|
||||
|
||||
### Risks
|
||||
|
||||
The new lines go at the end of "## Risks and open questions", after the bullet
|
||||
that begins "Trusted mode lets an agent" and before the "## Milestones" heading.
|
||||
- "A DOCX file is a zip archive, so git can't show a line diff of a chapter.
|
||||
Review of a story change goes through the editor and the committed reader,
|
||||
not `git diff`. (Version 1.17.)"
|
||||
- "A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
|
||||
chapter only through its archived Markdown, and no DOCX edit reaches that file's
|
||||
links. The DOCX's current links show in the Console's editor and its
|
||||
Connections view, and no graph shows them yet. (Version 1.17.)"
|
||||
- "Conversion can't be undone in place. Front matter, wiki link forms and
|
||||
unsupported blocks may not survive it, and the mirror can't refuse the user's
|
||||
push to an archived file. The controls are the fidelity report, the human's
|
||||
confirmation, an untouched original on refusal, and the kept archive.
|
||||
(Version 1.17.)"
|
||||
- "A restore of the stack's bus alone from a backup taken before an import drops
|
||||
the import's logged events, while the archive commit and the DOCX stay. A
|
||||
restore of the stack's whole host from such a backup also takes the writing
|
||||
store and the host's import records back to before the import. The archive
|
||||
commit stays in the user's Gitea repository and the DOCX stays on the writing
|
||||
store's remote, but the host keeps no record of the import. A bus restore counts
|
||||
import events, and the import's recovery covers both cases (Vikunja 571).
|
||||
(Version 1.17.)"
|
||||
|
||||
## d9 changes (Q239 and Webby round 5)
|
||||
|
||||
- J-b yes (Q239, mos-1467), in the five edits mos-1443 R3 set for a yes (Webby round 6 B1). R3.1: REQ-LIB-5's replacement gains the Connections view sentence after "no graph shows them yet.". R3.2: the graph Risks line ends with the editor and the Connections view. R3.3: the A6 line moves from "Not taken" under A6. R3.5: the REQ-LIB-5 note ends with the Connections view, which drops "until a Console view of their links exists" and "Q226 records that view as a later option". R3.4 is Gyro's design edit at 767bb62c7 (writing-editor.md:188-190, :240-244 and :1272, docx-citations.md:66), and it follows in his docs round. Beyond these and N21, nothing else in the PRD text changes.
|
||||
- N19 and N20 (Webby round 6): J-b's question text is in the past tense, and the line that the import doesn't wait for the view is cited as Mos's R3 default. Mos confirmed it at his J-b check (mos-1470).
|
||||
- N21 (Webby round 6, Mos yes in mos-1470): REQ-LIB-5's version tag cites Q239 for the Connections view beside Q222 for the two settings.
|
||||
- N15: the D7 landing line gives R8's wording beside the impact map's quote.
|
||||
- N16: the d7 changes line notes that R8 replaces "no agent credential reaches it" in d8.
|
||||
- N17: the REQ-LIB-2 Replace quote and the REQ-LIB-8 old-path bullet are rewrapped under 82 columns. Their words are unchanged.
|
||||
|
||||
## d8 changes (Mos's d8 fix list, check wf_63bba879-180)
|
||||
|
||||
- R1: REQ-LIB-4's tail covers DOCX saves, which agents see at their next lookup after the editor reports them as committed. The notes quote the whole 1.16 sentence. Source: nit/jason/F10.
|
||||
- R2: REQ-LIB-4's new title says the mirror is "the one place agents read Markdown". Source: decisions.n14.
|
||||
- R3: J-b asks whether 1.17 requires the Console Connections view, with Mos's recommendation of yes. Sources: mos1372/F9, jason/F2, anchors/F5.
|
||||
- R4: C2-02 and C2-03b gate 10-27, C2-11a and C2-11b are slice rows that don't, and "F42 wins" is gone. New J-k. Sources: anchors/F6, jason/F5.
|
||||
- R5: where the landed 571 design differs, this text holds. Sources: anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6, G16's design follow-on.
|
||||
- R6: `library.convert` stays out of `ACTIONS`, and REQ-WAIT-1's hard-wait list doesn't change. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
|
||||
- R7: a repository on the stack's host never counts as the writing store's remote, and Item 2 says so. Sources: mos1372/F2, adversarial/F1, gyro571/F2.
|
||||
- R8: "no agent credential reaches it" becomes "no agent can reach it with any credential". Source: Fenton's B1 under mos-1379.
|
||||
- R9: no playground DOCX becomes canon in 1.17, and a promotion of one is refused. New J-i. Sources: mos1372/F8, adversarial/F2.
|
||||
- R10: REQ-LIB-9 restates Q195's pin and right-click thesaurus, and its cite gains D4 and D22. Source: critic/C4.
|
||||
- R11: REQ-LIB-9's license bar is the licenses its independent security review accepted. New J-l. Source: critic/C3.
|
||||
- R12: no text for D16's narrow-window tabs. Mos records that reading in STATE. Sources: jason/F1, second verifier vote.
|
||||
- R13: no lookup returns a file's archived copy before publication. Source: adversarial/F5 part 3.
|
||||
- R14: a restore of the whole host from a backup taken before an import is a fourth recovery case. Source: adversarial/F6.
|
||||
- M1: REQ-LIB-4's writing-store sentences follow the later bus rows sentence and add the off-host remote rules and DOCX read timing. The note quotes both 1.16 sentences. Sources: mos1372/F2, adversarial/F1, gyro571/F2, anchors/F4, nit/jason/F10, adversarial/F5 part 1, Fenton B1.
|
||||
- M2: REQ-LIB-8's order bullet adds the remote's confirmation, the old-path read rule and "archive push". Sources: mos1372/F2 and the other remote findings, mos1372/F7, adversarial/F5 parts 2 to 4.
|
||||
- M3: REQ-LIB-8's gate bullet has four conditions, adding the remote. Sources: the remote findings, critic/C1 and C2 edit 2.
|
||||
- M4: the 1.17 note on when conversion opens restates the four conditions and drops W4b. Sources: jason/F5, the remote findings.
|
||||
- M5: Item 2 rewritten with the remote rules. Sources: the remote findings.
|
||||
- M6: the 1.12 gating note rewritten. Sources: anchors/F6, jason/F5.
|
||||
- M7: REQ-LIB-9 gains the split pane, the pin, the thesaurus, the license bar and the runtime load rule, and cites more of the design. Sources: jason/F1, critic/C4, critic/C3, nit/anchors/F12, nit/gyro571/F9.
|
||||
- M8: REQ-LIB-2, the one-way bullet and the C1 note keep a playground DOCX out of canon in 1.17. Sources: mos1372/F8, adversarial/F2.
|
||||
- M9: REQ-LIB-5 says what the reader can't read, and the import turns web links into plain text. New J-j. Sources: critic/C1, critic/C2.
|
||||
- M10: an exclusion of a whole book folder covers that book's DOCX documents. Source: adversarial/F3, with B5.
|
||||
- M11: the human-verbs sentence and the 571 list item name `library.convert` and REQ-WAIT-1. Sources: gyro571/F1, adversarial/F8 item 3, critic/C6.
|
||||
- M12: J-b rewritten. Sources: mos1372/F9, jason/F2, anchors/F5, gyro571/F3, critic/C6.
|
||||
- M13: J-d puts the agent read model to Jason as his call. Source: mos1372/F1.
|
||||
- M14: Status moves to version 1.17 and names REQ-LIB-9. Sources: anchors/F2, mos1372/F4, critic/C7.
|
||||
- M15: the 1.17 source line quotes Q235 in full (mos-1446) and the REQ-LIB-9 sources, at b99ca86b9. Sources: critic/C5, anchors/F9, jason/F8 pin part, nit/gyro571/F8, critic/C6 pin part.
|
||||
- M16: the 571 list lead names the two replaced design parts and the landed design. Sources: anchors/F9, anchors/F8, adversarial/F8 item 1, jason/F8, critic/C6.
|
||||
- Landing swap: Gyro's docs round for R5, R6 and R14 landed as b99ca86b9 (Fenton fenton-0015), so M15 and M16 cite b99ca86b9 in place of 767bb62c7, as the d8 fix list's header says. J-b keeps 767bb62c7 for its line numbers.
|
||||
- M17: W4 section numbers in the remote item and the lookups note. Sources: gyro571/F5, nit/adversarial/F11.
|
||||
- M18: the D7 landing line. Sources: gyro571/F5, the remote findings.
|
||||
- M19: a logged event for each import stage, in REQ-LIB-8 and A2. Source: gyro571/F7.
|
||||
- M20: the Risks line on restores adds the whole-host case. Sources: adversarial/F6, gyro571/F7.
|
||||
- M21: the 571 restore item adds the whole-host case. Source: adversarial/F6.
|
||||
- M22: the graph Risks line and the REQ-LIB-5 note name a Console view. Sources: anchors/F5, jason/F2.
|
||||
- M23: the d6 B3 line drops "F42 wins". Source: anchors/F6.
|
||||
- M24: new Markdown in a converted book, with the old-path and playground exceptions. Source: mos1372/F6.
|
||||
- M25: J-c's last sentence is Mos's reading. Source: mos1372/F10.
|
||||
- M26: J-h rewritten with the D13 defaults. Source: jason/F3.
|
||||
- M27: Item 4 rewritten. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
|
||||
- M28: the "Not taken" note on unmarked folders. Sources: jason/F4, gyro571/F4, decisions.unmarked_folders.
|
||||
- M29: the import refuses an unmapped file. Sources: gyro571/F4, decisions.unmarked_folders.
|
||||
- M30: placement notes for REQ-LIB-8, REQ-LIB-9 and the Risks lines. Sources: anchors/F3, nit/anchors/F14.
|
||||
- M31: REQ-LIB-4's title and the D11 landing line. Source: decisions.n14.
|
||||
- M32: the Q236 note limits the 1.12 Obsidian note to Markdown files. Source: critic/C8.
|
||||
- M33: the exclusion note adds path exclusions of DOCX documents. Source: anchors/F7.
|
||||
- M34: A9 moves from "Mine" to the impact-map list. Source: decisions.exclusion_by_id.
|
||||
- M35: the D5 landing line. Sources: anchors/F10, mos1372/F5, nit/adversarial/F12.
|
||||
- M36: the D9 and D14 landing lines. Source: mos1372/F1.
|
||||
- M37: the O1 and C3 notes add the off-host remote and its confirmation. Sources: the remote findings.
|
||||
- M38: the REQ-PROJ-3 note. Source: nit/anchors/F11.
|
||||
- M39: REQ-LIB-5's freshness sentence keys on the editor reporting a save as committed. Source: nit/adversarial/F9.
|
||||
- M40: REQ-LIB-6 bars agent requests for a conversion and allows a suggestion in chat. Source: nit/jason/F9.
|
||||
- M41: REQ-LIB-2's opening and old-path rule, and the "Mine" note. Source: nit/adversarial/F10.
|
||||
- M42: REQ-LIB-8's lead sentence on Markdown story files. Source: nit/mos1372/F12.
|
||||
- M43: REQ-LIB-6's citation sentence. Source: nit/anchors/F13.
|
||||
- M44: REQ-LIB-5's version tag uses plain quotes. Source: nit/anchors/F15.
|
||||
- M45: reference codes B5, D12, D10 and B4 name their source. Source: nit/mos1372/F11.
|
||||
- Files the row would own: four files, with the id count going to 87. Sources: anchors/F1, adversarial/F4, decisions.n14 F4.
|
||||
|
||||
## d7 changes (mos-1379, Fenton's 571 B2)
|
||||
|
||||
- REQ-LIB-5 gains Mos's three sentences after the extracted-text sentence. The text agents read has tracked changes accepted. Comments, and text with tracked deletions shown, each wait for a setting only a human turns on, off by default. Its version tag cites Q222 for the two settings, which is the only text here beyond Mos's.
|
||||
- J-d gains Mos's sentence on comments and tracked deletions.
|
||||
- Fenton's B1 is design only. d6's "no agent credential reaches it" holds (R8 replaces it in d8), and the shared helper stays #431.
|
||||
|
||||
## d6 changes (Webby round 3, webby-0207)
|
||||
|
||||
- B1: titles for REQ-LIB-8 and REQ-LIB-9 in the requirements.json note and the file list. REQ-LIB-9 takes Webby's title. REQ-LIB-8 keeps mine, because Webby's ("...one-way import that archives the Markdown under its old path") leaves out D2's story files started in the editor.
|
||||
- B2: REQ-LIB-2 makes a Markdown file at a converted file's old path unmapped, ahead of the playground and canon rules, so a playground conversion is covered too. REQ-LIB-8's old-path bullet says "neither canon nor playground".
|
||||
- B3: the slice note names Q226 F42's three rows (C2-02, C2-03b, C2-11b). C2-11a joined the slice with C2-11b, because C2-11b needs it. Both are slice rows that don't gate 10-27, as design 9.2, case2-rows.md:1655 and the impact map's A57 say. The 'F42 wins' sentence is gone from the note in d8, so no queue change follows from it.
|
||||
- N10: the cite-scope replacement is scoped to REQ-LIB-2. The 1.14 note's copy stays.
|
||||
- N11: the notes say REQ-LIB-8 adds the by-id exclusion form REQ-LIB-3 doesn't have.
|
||||
- N12: J-h marks the autosave intervals for Jason.
|
||||
- N13: J-b names REQ-LIB-5's "Connections are always visible" tag and Q226's "another way to handle the visual brain map".
|
||||
- N14, not taken: REQ-LIB-4's title stays internal F3's text, as mos-1372 D11 ruled. Webby's alternative, "...and agents read Markdown from it alone" (120 chars), is Mos's call.
|
||||
|
||||
## Where each mos-1372 item landed
|
||||
|
||||
- D1: REQ-LIB-2's DOCX sentences, the REQ-LIB-8 lead and import bullet, C1 above, and the notes. "Playground files stay Markdown in 1.17" is gone.
|
||||
- D2: REQ-LIB-2's "for one the human starts in the editor" and the REQ-LIB-8 new-story-file bullet. The universe-and-book note is now true.
|
||||
- D3: the REQ-LIB-8 order bullet. This also settles N8.
|
||||
- D4: REQ-LIB-8's one-way bullet, which carries the archive refusal and consequence F2's text, and the 571 list item.
|
||||
- D5: it landed in REQ-LIB-2's "a Markdown file at a converted file's old path is unmapped" in Webby's B2 form, and in the REQ-LIB-8 old-path bullet.
|
||||
- D6: REQ-LIB-5's freshness sentences.
|
||||
- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it", which R8 words in d8 as "no agent can reach it with any credential".
|
||||
- D8: the REQ-LIB-5 replacement, the import bullet's wiki-link sentence, the Risks line, the note quoting the old sentence, and "REQ-LIB-1 only gains sentences".
|
||||
- D9: the 1.17 note that agents read a converted story file through the Case 2 lookups, from the d4 check's consistency B2, and J-d, which puts the read model to Jason as his call.
|
||||
- D10: the d4 check's B5 sentence, in REQ-LIB-8.
|
||||
- D11: internal F3's clause in the REQ-LIB-4 section, and in the notes the title as Mos amended it for N14.
|
||||
- D12: the whole 1.16 sentence replaced, and quoted in full in the notes.
|
||||
- D13: consequence F1's text, in REQ-LIB-4 and REQ-LIB-8's first bullet.
|
||||
- D14: the row text from the d4 check's consistency B1, in the mark-and-move bullet and C3 above.
|
||||
|
||||
## What d5 adds beyond mos-1372 (for Webby and Mos to check)
|
||||
|
||||
From the impact map, folded as mos-1368 item 1 asked:
|
||||
- A1: the Console shows a cite's format and converted state (REQ-LIB-2).
|
||||
- A2: the logged events and the several-humans setting, default owner only, under Q222.
|
||||
- A3: the import is outside the vocabulary, the hard-wait list is unchanged, and the archive move isn't `data.delete`. The C6 code site is in the 571 list.
|
||||
- A4: the section lead and REQ-LIB-1's writing-store sentence.
|
||||
- A6: format and location in every answer, extracted text with DOCX locators, links across formats.
|
||||
- A6's named chapter-links view. D8 took B8's "no graph shows them yet", and J-b put the view to Jason. His O2 (Q239) takes it in d9 as REQ-LIB-5's Connections view (mos-1443 R3).
|
||||
- A7: read-only extends to the store and the archive, no agent action on a story file in any format, no agent DOCX write, and role contracts state the formats. C1's role contract line is a 571 build item.
|
||||
- A8: the DOCX cite shape, and a cite naming an archived file as current is refused.
|
||||
- A9: a hybrid sees a source's DOCX canon through REQ-LIB-2's source scope, and REQ-LIB-8 lets an exclusion name a DOCX document by its id. The id form lets the human exclude a single DOCX started in the editor, which has no old path. An archived file isn't canon, so no hybrid sees it.
|
||||
- A10: REQ-LIB-9, so the count is 87, not 86.
|
||||
- A11: the Hermes gate, from item 3.
|
||||
- A12: the C2-19 promotion sentence.
|
||||
- A13: the broker's path check treats the archive folder as story.
|
||||
- A14 and C5: two Risks lines.
|
||||
- A15: the flagged-new-file bullet. It sits beside the note from the d4 check's B4, "a new Markdown file in a book folder is still canon". The file stays canon and is offered for import.
|
||||
- A16: three titles change and two stay.
|
||||
- C2: manifest order for the manuscript lookup.
|
||||
- F3: the C2-02 gating note.
|
||||
- F4: Mos's measurement.
|
||||
|
||||
Mine, beyond either source:
|
||||
- REQ-LIB-2 opens "A Markdown file's scope comes from its path and from past imports". DOCX scope comes from the source, so "a file's" would be false.
|
||||
- REQ-LIB-9's autosave sentence. It is Mos's reading of F41's "instantly committed", with human-set intervals. The exact intervals stay in the design.
|
||||
|
||||
Not taken:
|
||||
- Impact map item 4's "files in unmarked folders are eligible one at a time", for folders outside every book folder and playground only. Their files are unmapped under REQ-LIB-2, so a DOCX made from one would have no universe or book to keep. The human maps such a folder into a book folder or a playground first, under REQ-LIB-1. A file in an unmarked folder inside a book folder or a playground is canon or playground, and the import takes it one file at a time.
|
||||
|
||||
## Marked for Jason (additions in the text he sees)
|
||||
|
||||
- J-a. Whether "We will not run the systems in parallel" applies per chapter or as one switch per book. His call. As written, it applies per chapter.
|
||||
- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading was that 1.17 as d8 wrote it met neither line for converted chapters. Their current links showed to the user only as clickable text inside the editor, and no graph or list showed them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 held a Console Connections view and its tests as later work that waited for this answer. His call was whether 1.17 requires that view. As d8 was written, it did not. Mos recommended that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible". Jason answered O2 (Q239): 1.17 requires the view. d9 writes it into REQ-LIB-5, its note and the Risks line, in the text of mos-1443 R3. The view comes with the conversion work after 10-27, and the 10-27 Case 2 release stays on Markdown (Q235 P6, Q237). That the import does not wait for the view is Mos's default in mos-1443 R3. Q239 doesn't address it, and Mos confirmed it at his J-b check (mos-1470).
|
||||
- J-c (mos-1374): "The store pushes to a remote (D7), and the Case 2 VM is in the nightly PBS backup. For a fixed restore point before her first real import, a manual backup of VM 1012 with PBS protect runs first. That step is reversible and needs no reboot." Mos's reading is that this sits inside the P5 go step, so it needs no new question.
|
||||
- J-d. How agents read a converted chapter, D9. His call. As written, an agent reads it through the ordinary Case 2 lookups in any thread or room, under the same project and universe access as Markdown canon. This is Mos's reading of F41 and REQ-LIB-7. The other choice is the model in the earlier O2 design, W4 sections 7.1 and 7.2. Under it, an agent reads a converted chapter only in a new writing conversation that holds one human and one agent, only after the business owner grants that agent access to that document, and by default only what the human attaches to each message. Comments and tracked deletions stay hidden from agents until a human turns on a setting for each. Markdown has neither, so the default keeps agents at the access Markdown gives them. Those two sentences are Mos's ruling mos-1379. A yes to the text as written settles it.
|
||||
- J-e. Versioning means the editor's tracked changes plus one git commit per save.
|
||||
- J-f. A converted chapter can be edited only in the Console while the stack is reachable. Obsidian no longer edits it offline.
|
||||
- J-g. The import takes Markdown only. Importing .doc files stays a separate task.
|
||||
- J-h. Autosave under REQ-LIB-9. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." Mos's reading, recorded with that ruling, is "Each save is a commit at once, and agents read it at their next lookup." At the O2 design's D13 defaults, a save commits at most 2 seconds after a pause in typing or 15 seconds into steady typing, with no human action needed. Mos's reading in mos-1359 counts that delay as "instantly committed". Only a human changes the intervals. The PRD names no values, and the defaults stay in the design. Whether a delay of seconds fits his word "instantly" is his call, so this is marked for him.
|
||||
- J-i. In 1.17 a playground story that is DOCX can't become canon. That covers a playground Markdown file the human imports, a story the human starts in a playground in the editor, and the DOCX of a draft universe after it is made real. Today Melanie promotes a playground file by moving it into a book folder in Obsidian, and a DOCX in the writing store can't be moved that way. She can start a new story in the book and paste the text in, but it gets a new document id and the playground copy keeps the history. A later version adds DOCX promotion with C2-19, which comes after the slice.
|
||||
- J-j. Web links, text boxes and fields in a converted chapter. Jason's Q226 F41 says "All changes are instantly committed and available to the agents." The reader in this version can't read a web link, a text box, a field or hidden text. As written, the import turns each web link into plain text that keeps its label and address, and the human confirms that in the fidelity report. A web link that can't be kept safely as plain text refuses the import. If a later edit adds one of these, the save keeps it and the editor warns the human. Agents then get none of that part's text until a save takes it out.
|
||||
- J-k. The 1.12 note Jason approved says bus writes, C2-02 and C2-03b, don't gate 10-27. His Q226 "F42: ok" moved them into the 10-27 slice, and design 9.2 counts them in the smallest slice that lets Melanie start. Mos reads that as making them gate 10-27. The editor rows C2-11a and C2-11b still don't gate it, per his Q195 words "we need to get it moving as an add-on".
|
||||
- J-l. REQ-LIB-9 does not use REQ-PROJ-3's "permissive license" bar. It takes the licenses that the editor's independent security review accepted, because that review cleared fonts under the SIL Open Font License and the Ubuntu Font License, which not every reader counts as permissive. Mos's reading.
|
||||
|
||||
Marked as changes beyond P1 to P6, from the impact map (Q17, item 5):
|
||||
- A1 (archived scope and canon cites)
|
||||
- A2 (the conversion requirement, REQ-LIB-8)
|
||||
- A3 (outside the vocabulary, hard-wait list unchanged)
|
||||
- A6 (lookups across formats)
|
||||
- A7 (agent bounds)
|
||||
- A8 (DOCX cites)
|
||||
- Item 2 and mos-1375: each writing-store commit is pushed to a remote off the stack's host. Only a human sets it, it starts unset, and a repository on the stack's host doesn't count. Only the stack's host pushes to it, and no agent can reach it with any credential. Import and new story files stay closed until the remote is set and checked, and each import's archive push waits until the remote holds the DOCX commit. This keeps "Git is used for all changes -- local and remote" true for DOCX chapters.
|
||||
- Item 3 (the Hermes gate)
|
||||
- Item 4. Canon and playground story files convert one file at a time, including files in unmarked folders inside a book folder or a playground. Type-folder and supporting files stay Markdown. A story file outside every book folder and playground has no universe or book, so it converts only after a human maps its folder into a book folder or a playground. The mapping step is Mos's reading of P1 and Q30.
|
||||
|
||||
The F4 measurement goes with them.
|
||||
|
||||
## Files the row would own
|
||||
|
||||
The id count goes from 85 to 87. 1.16 owned three files because its count stayed at 85. Like 1.13, the last version that added ids, the row owns four files:
|
||||
- `docs/prd/mosaic-stack.md`
|
||||
- `packages/tasks/requirements.json`: the version 1.17, the two new ids REQ-LIB-8 and REQ-LIB-9 with their titles, and the changed titles of REQ-LIB-1, REQ-LIB-2 and REQ-LIB-4. The new ids go after REQ-LIB-7 and before REQ-INST-1, in the same order as the PRD's bullets.
|
||||
- `packages/tasks/tests/requirements.test.mjs`: the version pin 1.17, the id count of 87, and the duplicate-id message, which goes from `requirements[85].id: REQ-ROLE-1 twice` to `requirements[87].id: REQ-ROLE-1 twice`.
|
||||
- `packages/bus/tests/requirements.test.mjs`: the list length, which goes from 85 to 87.
|
||||
|
||||
The gate would match 1.16's:
|
||||
1. A reviewer approves the text.
|
||||
2. Jason says yes to the exact text through Mos.
|
||||
3. Both `requirements.test.mjs` files, in `packages/tasks/tests` and `packages/bus/tests`, and every `scripts/test-*.sh` that reads the PRD or `requirements.json` are green on Sage's gate.
|
||||
4. Mos gets the landing commit.
|
||||
@@ -0,0 +1,132 @@
|
||||
--- DRAFT-d8.md 2026-10-23 06:16:54.374694135 -0500
|
||||
+++ DRAFT-d9.md 2026-10-23 10:40:54.806553704 -0500
|
||||
@@ -1,6 +1,6 @@
|
||||
# PRD 1.17 draft: Case 2 story files are DOCX (Q235 to Q237, P1 to P6)
|
||||
|
||||
-Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 8. It is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
|
||||
+Sage, 2026-10-23, for mos-1366 item 1 and Vikunja 570. Draft 9. It is d8 plus Jason's Q41 answer O2 (DECISIONS Q239, mos-1467), which makes J-b yes, Webby's round 5 N15 to N17 (webby-0210) and Webby's round 6 B1, N19 and N20 (webby-0212) (see "d9 changes"). d8 is d7 plus Mos's d8 fix list mos-1443 (see "d8 changes"). d7 is d6 plus Mos's ruling mos-1379 on Fenton's 571 B2 (see "d7 changes"). d6 is d5 with Webby's round 3 (webby-0207) fixed: B1 to B3 and N10 to N13, with N14 left for Mos (see "d6 changes"). d5 folds in:
|
||||
- Mos's d4 check ruling mos-1372 (D1 to D14; D15 was dropped).
|
||||
- mos-1374, which replaces J-c.
|
||||
- The impact map mos-1368 (jason-master-restart d183246, `projects/mosaic-mvp/case2-docx-impact-map.md`: A1 to A16, critic items C1 to C6, items 2 to 7).
|
||||
@@ -103,10 +103,11 @@
|
||||
|
||||
### REQ-LIB-2
|
||||
|
||||
-Replace "A file's scope comes from its path alone, prefixes first: a Markdown file
|
||||
-under a supporting prefix is supporting, one under a playground prefix at any level
|
||||
-is playground, any other Markdown file in a book folder is canon, and everything
|
||||
-else is unmapped and never returned. A folder in both lists is supporting." with:
|
||||
+Replace "A file's scope comes from its path alone, prefixes first: a Markdown
|
||||
+file under a supporting prefix is supporting, one under a playground prefix at
|
||||
+any level is playground, any other Markdown file in a book folder is canon, and
|
||||
+everything else is unmapped and never returned. A folder in both lists is
|
||||
+supporting." with:
|
||||
|
||||
"A Markdown file's scope comes from its path and from past imports, prefixes
|
||||
first: a Markdown file under the archive folder is archived, one under a
|
||||
@@ -191,10 +192,13 @@
|
||||
Replace "The user sees them in Obsidian's graph and backlinks." with "The user sees
|
||||
a Markdown file's links in Obsidian's graph and backlinks. A DOCX story file's
|
||||
wikilinks stay clickable in the Console's editor (O2 design D11), and no graph
|
||||
-shows them yet."
|
||||
+shows them yet. The Console's Connections view lists the current links and
|
||||
+backlinks of each story file the user may see, in both formats, with each one's
|
||||
+scope and format."
|
||||
|
||||
Version tag: 'and "Connections are always visible".)' becomes 'and "Connections
|
||||
-are always visible". Version 1.17, with Q222 for the two settings.)'
|
||||
+are always visible". Version 1.17, with Q222 for the two settings and Q239 for
|
||||
+the Connections view.)'
|
||||
|
||||
### REQ-LIB-6
|
||||
|
||||
@@ -273,8 +277,9 @@
|
||||
- Each stage of an import writes a logged event. The import's record on the
|
||||
stack's host names the source file's path, commit and blob and the new
|
||||
document id, and each event points to that record.
|
||||
- - After a conversion, a Markdown file at the converted file's old path is neither
|
||||
- canon nor playground. Lookups leave it out, the format map flags it, and the human is told.
|
||||
+ - After a conversion, a Markdown file at the converted file's old path is
|
||||
+ neither canon nor playground. Lookups leave it out, the format map flags it,
|
||||
+ and the human is told.
|
||||
- A new Markdown story file in a book folder that holds converted files stays
|
||||
canon until its import, unless it sits at a converted file's old path or under
|
||||
a playground prefix. The Console flags it and offers the import. Nothing
|
||||
@@ -413,8 +418,8 @@
|
||||
push within about a minute."
|
||||
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
|
||||
backlinks." A converted chapter's connections show in Obsidian only as they
|
||||
- stood at conversion, until a Console view of their links exists. Q226
|
||||
- records that view as a later option.
|
||||
+ stood at conversion, and the Console's Connections view shows their
|
||||
+ current links.
|
||||
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
|
||||
a commit or pages the agent fetched, and the broker checks every citation
|
||||
before it stores the message."
|
||||
@@ -451,8 +456,8 @@
|
||||
not `git diff`. (Version 1.17.)"
|
||||
- "A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
|
||||
chapter only through its archived Markdown, and no DOCX edit reaches that file's
|
||||
- links. The DOCX's current links show only in the Console's editor until a
|
||||
- Console view of them exists. (Version 1.17.)"
|
||||
+ links. The DOCX's current links show in the Console's editor and its
|
||||
+ Connections view, and no graph shows them yet. (Version 1.17.)"
|
||||
- "Conversion can't be undone in place. Front matter, wiki link forms and
|
||||
unsupported blocks may not survive it, and the mirror can't refuse the user's
|
||||
push to an archived file. The controls are the fidelity report, the human's
|
||||
@@ -467,6 +472,15 @@
|
||||
import events, and the import's recovery covers both cases (Vikunja 571).
|
||||
(Version 1.17.)"
|
||||
|
||||
+## d9 changes (Q239 and Webby round 5)
|
||||
+
|
||||
+- J-b yes (Q239, mos-1467), in the five edits mos-1443 R3 set for a yes (Webby round 6 B1). R3.1: REQ-LIB-5's replacement gains the Connections view sentence after "no graph shows them yet.". R3.2: the graph Risks line ends with the editor and the Connections view. R3.3: the A6 line moves from "Not taken" under A6. R3.5: the REQ-LIB-5 note ends with the Connections view, which drops "until a Console view of their links exists" and "Q226 records that view as a later option". R3.4 is Gyro's design edit at 767bb62c7 (writing-editor.md:188-190, :240-244 and :1272, docx-citations.md:66), and it follows in his docs round. Beyond these and N21, nothing else in the PRD text changes.
|
||||
+- N19 and N20 (Webby round 6): J-b's question text is in the past tense, and the line that the import doesn't wait for the view is cited as Mos's R3 default. Mos confirmed it at his J-b check (mos-1470).
|
||||
+- N21 (Webby round 6, Mos yes in mos-1470): REQ-LIB-5's version tag cites Q239 for the Connections view beside Q222 for the two settings.
|
||||
+- N15: the D7 landing line gives R8's wording beside the impact map's quote.
|
||||
+- N16: the d7 changes line notes that R8 replaces "no agent credential reaches it" in d8.
|
||||
+- N17: the REQ-LIB-2 Replace quote and the REQ-LIB-8 old-path bullet are rewrapped under 82 columns. Their words are unchanged.
|
||||
+
|
||||
## d8 changes (Mos's d8 fix list, check wf_63bba879-180)
|
||||
|
||||
- R1: REQ-LIB-4's tail covers DOCX saves, which agents see at their next lookup after the editor reports them as committed. The notes quote the whole 1.16 sentence. Source: nit/jason/F10.
|
||||
@@ -535,7 +549,7 @@
|
||||
|
||||
- REQ-LIB-5 gains Mos's three sentences after the extracted-text sentence. The text agents read has tracked changes accepted. Comments, and text with tracked deletions shown, each wait for a setting only a human turns on, off by default. Its version tag cites Q222 for the two settings, which is the only text here beyond Mos's.
|
||||
- J-d gains Mos's sentence on comments and tracked deletions.
|
||||
-- Fenton's B1 is design only. d6's "no agent credential reaches it" holds, and the shared helper stays #431.
|
||||
+- Fenton's B1 is design only. d6's "no agent credential reaches it" holds (R8 replaces it in d8), and the shared helper stays #431.
|
||||
|
||||
## d6 changes (Webby round 3, webby-0207)
|
||||
|
||||
@@ -556,7 +570,7 @@
|
||||
- D4: REQ-LIB-8's one-way bullet, which carries the archive refusal and consequence F2's text, and the 571 list item.
|
||||
- D5: it landed in REQ-LIB-2's "a Markdown file at a converted file's old path is unmapped" in Webby's B2 form, and in the REQ-LIB-8 old-path bullet.
|
||||
- D6: REQ-LIB-5's freshness sentences.
|
||||
-- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it".
|
||||
+- D7 and mos-1375: REQ-LIB-4's remote sentences, the remote step in REQ-LIB-8's order bullet, the fourth opening condition and the 1.17 note on when conversion opens, plus the 571 list item on W4 section 7.3, which replaces the no-remote rule of the old W4 section 7.4. The impact map's item 2 adds "only the stack's host pushes, and no agent credential reaches it", which R8 words in d8 as "no agent can reach it with any credential".
|
||||
- D8: the REQ-LIB-5 replacement, the import bullet's wiki-link sentence, the Risks line, the note quoting the old sentence, and "REQ-LIB-1 only gains sentences".
|
||||
- D9: the 1.17 note that agents read a converted story file through the Case 2 lookups, from the d4 check's consistency B2, and J-d, which puts the read model to Jason as his call.
|
||||
- D10: the d4 check's B5 sentence, in REQ-LIB-8.
|
||||
@@ -573,6 +587,7 @@
|
||||
- A3: the import is outside the vocabulary, the hard-wait list is unchanged, and the archive move isn't `data.delete`. The C6 code site is in the 571 list.
|
||||
- A4: the section lead and REQ-LIB-1's writing-store sentence.
|
||||
- A6: format and location in every answer, extracted text with DOCX locators, links across formats.
|
||||
+ - A6's named chapter-links view. D8 took B8's "no graph shows them yet", and J-b put the view to Jason. His O2 (Q239) takes it in d9 as REQ-LIB-5's Connections view (mos-1443 R3).
|
||||
- A7: read-only extends to the store and the archive, no agent action on a story file in any format, no agent DOCX write, and role contracts state the formats. C1's role contract line is a 571 build item.
|
||||
- A8: the DOCX cite shape, and a cite naming an archived file as current is refused.
|
||||
- A9: a hybrid sees a source's DOCX canon through REQ-LIB-2's source scope, and REQ-LIB-8 lets an exclusion name a DOCX document by its id. The id form lets the human exclude a single DOCX started in the editor, which has no old path. An archived file isn't canon, so no hybrid sees it.
|
||||
@@ -593,12 +608,11 @@
|
||||
|
||||
Not taken:
|
||||
- Impact map item 4's "files in unmarked folders are eligible one at a time", for folders outside every book folder and playground only. Their files are unmapped under REQ-LIB-2, so a DOCX made from one would have no universe or book to keep. The human maps such a folder into a book folder or a playground first, under REQ-LIB-1. A file in an unmarked folder inside a book folder or a playground is canon or playground, and the import takes it one file at a time.
|
||||
-- A6's named chapter-links view. D8 takes B8's "no graph shows them yet", and J-b puts the view to Jason.
|
||||
|
||||
## Marked for Jason (additions in the text he sees)
|
||||
|
||||
- J-a. Whether "We will not run the systems in parallel" applies per chapter or as one switch per book. His call. As written, it applies per chapter.
|
||||
-- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading is that 1.17 as written meets neither line for converted chapters. Their current links show to the user only as clickable text inside the editor, and no graph or list shows them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 holds a Console Connections view and its tests as later work that waits for this answer. His call whether 1.17 requires that view. As written, it does not. Mos recommends that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible".
|
||||
+- J-b. A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the chapter only through its archived Markdown, and no DOCX edit reaches that file's links. REQ-LIB-5 still cites two TARGET lines, "Visualization of data via Obsidian or equivalent is required" and "Connections are always visible". Mos's reading was that 1.17 as d8 wrote it met neither line for converted chapters. Their current links showed to the user only as clickable text inside the editor, and no graph or list showed them. Q226 leaves room for "another way to handle the visual brain map". The O2 design that landed for Vikunja 571 at 767bb62c7 held a Console Connections view and its tests as later work that waited for this answer. His call was whether 1.17 requires that view. As d8 was written, it did not. Mos recommended that it does, because impact map A6 says "Recommended: name the view" and TARGET says "Connections are always visible". Jason answered O2 (Q239): 1.17 requires the view. d9 writes it into REQ-LIB-5, its note and the Risks line, in the text of mos-1443 R3. The view comes with the conversion work after 10-27, and the 10-27 Case 2 release stays on Markdown (Q235 P6, Q237). That the import does not wait for the view is Mos's default in mos-1443 R3. Q239 doesn't address it, and Mos confirmed it at his J-b check (mos-1470).
|
||||
- J-c (mos-1374): "The store pushes to a remote (D7), and the Case 2 VM is in the nightly PBS backup. For a fixed restore point before her first real import, a manual backup of VM 1012 with PBS protect runs first. That step is reversible and needs no reboot." Mos's reading is that this sits inside the P5 go step, so it needs no new question.
|
||||
- J-d. How agents read a converted chapter, D9. His call. As written, an agent reads it through the ordinary Case 2 lookups in any thread or room, under the same project and universe access as Markdown canon. This is Mos's reading of F41 and REQ-LIB-7. The other choice is the model in the earlier O2 design, W4 sections 7.1 and 7.2. Under it, an agent reads a converted chapter only in a new writing conversation that holds one human and one agent, only after the business owner grants that agent access to that document, and by default only what the human attaches to each message. Comments and tracked deletions stay hidden from agents until a human turns on a setting for each. Markdown has neither, so the default keeps agents at the access Markdown gives them. Those two sentences are Mos's ruling mos-1379. A yes to the text as written settles it.
|
||||
- J-e. Versioning means the editor's tracked changes plus one git commit per save.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Row 252 build notes: Console inbox Approve and Deny for grant requests
|
||||
|
||||
Seat: Scrooge. Reviewer: Webby. Issue #1753, Vikunja #435 (J2).
|
||||
Brief: `docs/plans/2026-10-21_console-inbox-approve.md` (blob b41676d06).
|
||||
Design: Gander's DESIGN.md at d218e14bd, approved by Webby at 5d8f0f4b5 (webby-0082) with manifest checks N1-N5.
|
||||
Base: aca16404c on branch `scrooge/row252`. Rebase onto the row 253 tip under a new branch name once 253 lands.
|
||||
|
||||
## Status
|
||||
|
||||
- Code, tests and docs are written. No test has been run yet, because Gander holds the gate slot for row 253 run 5 and Sage ruled no node until a CLEAR.
|
||||
- Everything in the Results section is empty until that run. Nothing below claims a pass.
|
||||
|
||||
## Q188
|
||||
|
||||
No agent creates a grant. Approve and Deny are human-only Console actions.
|
||||
|
||||
- Only a console cap reaches `decision.preview.console` and `decision.resolve.console`. A console cap reaches nothing else (console-decide `J1, Q188`).
|
||||
- The socket refuses a console proof for any other verb before the proof check (`server.mjs`).
|
||||
- A console cap binds only through `authenticateConsole`, which needs the console proof (section 2): the same-tree `console-cli.mjs` wrapper, its own nonce, a direct same-tree `serve` parent, and a configured owner subject from the spawn environment.
|
||||
- `recordEvent` keeps `human.input` reserved, so no agent writes the record that lets a console close start the executor (console-decide `recordEvent keeps human.input reserved`).
|
||||
- A `via console` close without its matching `human.input` approves nothing (console-decide `J14`).
|
||||
|
||||
## Manifest checks N1-N5
|
||||
|
||||
- **N1.** Every `console.sso` subject is an owner of the business. Name is display only.
|
||||
- `cli/src/config.mjs` carries `{provider, iss, sub}` per subject into the boot as `consoleOwners`. No sso means no key.
|
||||
- `bus/src/runtime.mjs` validates the list (array, at most 512, exactly three non-empty strings each) and passes it to `verifyPrincipal`.
|
||||
- Tests: cli config.test `Row 252 (Webby N1)` and principal.test.
|
||||
- **N2.** `human.input` is written in the same transaction, before `#approvedGrant`. The executor starts once, and a deny starts none.
|
||||
- Tests: console-decide `N2, J13` checks the order by seq and that the grant runs once. `J15` checks that a deny starts nothing.
|
||||
- **N3.** The close notice comes from `from_run 'human-console'`, and `public/bus.js` shows "via console". `landing` and `#protectedEnd` stay CLI-only.
|
||||
- Tests: console-decide `N2, J13` checks the notice. decide-browser `J17` checks the close-elsewhere line.
|
||||
- **N4.** `decision.resolve` is not a Console verb.
|
||||
- `CONSOLE_VERBS` is exactly the two console verbs. The webui serve `BUS_VERBS` read list has no decision verb, and webui `cli.mjs` has no `decision.` string. `consoleCall` refuses anything outside `CONSOLE_VERBS`.
|
||||
- Tests: console-decide `N4`, and decide-sso `N4`, which hits `/api/bus/decision.*` for 404 and covers the cli.mjs scan and the consoleCall refusals.
|
||||
- **N5.** `readProcess` keeps the five `MOSAIC_BUS_CONSOLE_*` keys in the env allowlist (`bus/src/human.mjs`). The human proof and its spawn are unchanged.
|
||||
|
||||
## Design choices and tradeoffs
|
||||
|
||||
- **Webui imports from the broker.** `webui/src/bus.mjs` imports `CONSOLE_VERBS` from `bus/src/broker.mjs`, which loads the broker module into the Console at import.
|
||||
- I chose one source for the verb list over a second copy that could drift.
|
||||
- If Webby prefers the copy, the alternative is a literal list plus a test pinning it equal to `CONSOLE_VERBS`.
|
||||
- **The Console transport shares humanCall's spawn.** `consoleCall` and `humanCall` share it as `transport()`, so `webui/src/bus.mjs` changed. The busCaller `business` default keeps `||` (the old falsy semantics), not `??`.
|
||||
- **Confirmations are held in memory in the serve process.** Their lifetime is 2 minutes, they are single use, and they bind the session, decision, choice and digest. The cap is a limit that never drops a live confirmation.
|
||||
- A restart loses all held confirmations. The user clicks again and gets a new preview. Nothing is written until the resolve.
|
||||
- **No retry anywhere.** A resolve whose bus reply is lost answers `outcome-unknown`, the confirmation is spent, and the page says "Nothing was sent again."
|
||||
|
||||
## Old tests changed (for Mos, per mos-1272)
|
||||
|
||||
Mos ruled both changes allowed in mos-1321 (17:12:28Z), each with one condition.
|
||||
- **shell.test condition:** reword the comment that opened "No Resolve control", since it is false once the row lands. It now says row 252's Approve and Deny is the one decision control, that its labels come from ACTIONS, and that the doesNotMatch keeps any literal label out of the markup. Webby checks the wording in review.
|
||||
- **channel-proof condition:** a header comment is not enough, because human.mjs changes what readProcess keeps. In the review, Webby reads the human.mjs and webui bus.mjs diffs and states two things:
|
||||
- the human proof's checks are unchanged
|
||||
- only the console verifier reads the five MOSAIC_BUS_CONSOLE_* keys, and each case is covered by a named test in console-decide or decide-sso
|
||||
- **Second row to land (252 or 239):** it recomputes the webui bus.mjs hash, adds its own header line, and its reviewer re-reads bus.mjs from the base to the combined tip.
|
||||
|
||||
- **`webui/tests/shell.test.mjs`**, write-boundary test. Allowed under mos-1321, with the comment reworded.
|
||||
- The `writes` list gains the new `fetch(url, ...)` in `decidePost`. It is first in file order (bus.js:362).
|
||||
- The `method:` count goes from 7 to 8.
|
||||
- A new assertion pins `decidePost`'s callers to `url` plus the two literal `/api/decisions/${enc(a.id)}/...` routes.
|
||||
- No existing entry was loosened.
|
||||
- **`bus/tests/channel-proof.test.mjs`**, sha256 re-pins with a header comment:
|
||||
- `bus/src/human.mjs` (N5 env keys) to bb13c146…c885.
|
||||
- `webui/src/bus.mjs` (consoleCall and `transport()`) to 701f009e…5272.
|
||||
- Any later edit to either file needs a re-pin.
|
||||
- `bus/tests/principal.test.mjs`, `webui/tests/tester-listener.test.mjs` and `cli/tests/config.test.mjs` have new tests appended only.
|
||||
|
||||
## Overlap with row 239 (Honker, b0c28366f, sage-2835)
|
||||
|
||||
- Row 239 also re-pins `channel-proof.test.mjs`:
|
||||
- `packages/webui/src/bus.mjs`, to 1eba9774…e5cf. That is the pin both rows change.
|
||||
- `packages/cli/src/transport.mjs`, which is 239 only.
|
||||
- `packages/bus/src/human.mjs` is a 252-only pin.
|
||||
- The two rows also share source files: `bus/src/broker.mjs`, `bus/src/runtime.mjs`, `webui/src/bus.mjs` and `webui/src/serve.mjs`.
|
||||
- Whichever row lands second rebases onto the first and recomputes the webui `bus.mjs` pin from the merged file. Both header comments are kept. It then reruns every touched test file whole.
|
||||
- Mos needs to see that re-pin (re-pins need Mos).
|
||||
|
||||
## Mutant plan (design section 6, J1-J17)
|
||||
|
||||
Each mutant is one edit to the named source. Run the killing test, then restore and compare the sha256 against the pre-mutant hash with `git status` clean apart from the build. Kill rules are written before the runs.
|
||||
|
||||
| J | Mutant | Source | Expected kill |
|
||||
|---|---|---|---|
|
||||
| J1 | eligibility from the request, `#consoleResolution` returns non-null for any decision | broker.mjs | console-decide `J1, J2` (ineligible) |
|
||||
| J2 | hardcode `approve = yes`, `deny = no` | broker.mjs | console-decide `J2` (go/stop, deny listed first) |
|
||||
| J3 | owner from name or header, skip the owner match in verifyPrincipal | principal.mjs | principal.test `console requires…` |
|
||||
| J4 | skip the serve-parent walk | principal.mjs | principal.test `console requires…` |
|
||||
| J5 | accept the human nonce namespace | principal.mjs | principal.test `console requires…` |
|
||||
| J6 | reuse the session from the confirmation, skip the live session check at resolve | decision-http.mjs | decision-http `session that ends`, decide-sso `listener gates` (new session) |
|
||||
| J7 | keep the console cap bound after the call (skip `release` for kind console) | server.mjs | **open**: no new test pins release for a console proof. Before the run, check whether an existing server test kills it. If not, add one and name it in the report. |
|
||||
| J8 | skip the same-origin check | decision-http.mjs | decision-http `gates`, decide-sso `listener gates` |
|
||||
| J9 | resolve without a confirmation token | decision-http.mjs | decision-http `a confirmation then a resolve`, `bad body` |
|
||||
| J10 | drop the choice, decision or session binding | decision-http.mjs | decision-http `binds the session, decision and choice` |
|
||||
| J11 | skip the digest, closed or business check | broker.mjs | console-decide `J11`, `N2, J13` (second answer closed) |
|
||||
| J12 | do not spend the confirmation | decision-http.mjs | decision-http `a second resolve refuses`, `confirmations… one use` |
|
||||
| J13 | write via cli, or omit the subject in human.input | broker.mjs | console-decide `N2, J13` |
|
||||
| J14 | `#approvedGrant` accepts via console without the human.input | broker.mjs | console-decide `J14` |
|
||||
| J15 | start the executor on any resolved choice | broker.mjs | console-decide `J15` and `J2` (stop starts nothing) |
|
||||
| J16 | `decide` true without sso or send | serve.mjs, public/bus.js | decide-sso `J16`, decide-browser `J16` |
|
||||
| J17 | retry on outcome-unknown or auto-submit after preview | public/bus.js | decide-browser `J17` (post count stays at 7) |
|
||||
|
||||
## Manifest
|
||||
|
||||
Source:
|
||||
- packages/bus/src/broker.mjs
|
||||
- packages/bus/src/client.mjs
|
||||
- packages/bus/src/console-cli.mjs (new)
|
||||
- packages/bus/src/human.mjs
|
||||
- packages/bus/src/principal.mjs
|
||||
- packages/bus/src/runtime.mjs
|
||||
- packages/bus/src/server.mjs
|
||||
- packages/cli/src/config.mjs
|
||||
- packages/webui/src/bus.mjs
|
||||
- packages/webui/src/cli.mjs
|
||||
- packages/webui/src/decision-http.mjs (new)
|
||||
- packages/webui/src/public/bus.js
|
||||
- packages/webui/src/serve.mjs
|
||||
|
||||
Tests:
|
||||
- packages/bus/tests/channel-proof.test.mjs (re-pins)
|
||||
- packages/bus/tests/console-decide.test.mjs (new)
|
||||
- packages/bus/tests/principal.test.mjs
|
||||
- packages/cli/tests/config.test.mjs
|
||||
- packages/webui/tests/decide-browser.test.mjs (new)
|
||||
- packages/webui/tests/decide-sso.test.mjs (new)
|
||||
- packages/webui/tests/decision-http.test.mjs (new)
|
||||
- packages/webui/tests/shell.test.mjs (old test changed, see above)
|
||||
- packages/webui/tests/tester-listener.test.mjs
|
||||
|
||||
Docs:
|
||||
- packages/webui/README.md
|
||||
- agents/scrooge/work/queue-252/BUILD.md (this file)
|
||||
|
||||
## Results
|
||||
|
||||
Not run. At the first CLEAR:
|
||||
1. Run each touched test file whole, with TMPDIR under /mnt/storage/scratch/tmp.
|
||||
2. Run the bus, webui and cli suites.
|
||||
3. Run the mutants above, then the drift check against refactor.
|
||||
|
||||
## Rebase onto fe4ee8c7b (sage-3482, 2026-10-23)
|
||||
|
||||
The old base aca16404c had drifted. Since then refactor changed 8 of the row's 24 paths, across 15 commits. I made a new branch, scrooge/row252-fe4ee8c7b, so scrooge/row252 22cc74645 is untouched and nothing was force-pushed. I rebased with local git, `-c core.hooksPath=/dev/null`, and no stash.
|
||||
|
||||
Commit map:
|
||||
- 4bd273bce -> 1891d36e4 (product and tests; six files conflicted)
|
||||
- ca4537cc9 -> 9e5980acd (BUILD.md; clean)
|
||||
- 22cc74645 -> 6fd206e80 (shell.test comment and BUILD.md; clean)
|
||||
|
||||
Conflicts, all in 4bd273bce, and how each was resolved:
|
||||
1. packages/bus/src/broker.mjs, verb lists. On the base, row 206 added the share verbs to USER_VERBS, and row 275 added VIEWER_VERBS, userLevel and BOARD_VERBS and removed the USER_LEVEL const. I kept the base's lists and dropped USER_LEVEL, because nothing on the base reads it. CONSOLE_VERBS goes in after USER_VERBS with its comment unchanged. The request() gate (`!!s.console !== CONSOLE_VERBS.includes(verb)`) auto-merged after the FED R4 access sweep and the user check. Its order relative to the user gate is the same as on the old base.
|
||||
2. packages/bus/src/runtime.mjs, startBroker. FED R4 (#1801) added `access.intervalMs` to the access check. I kept the consoleOwners validation and the frozen owners first, as in the row, and then the base's access check with intervalMs.
|
||||
3. packages/cli/src/config.mjs. FED R6 (#1800) added accessFor and `config.access`. I kept both: accessFor and then consoleOwners, and in bootConfig `config.access` and then `config.consoleOwners`. process.mjs passes the boot config to startBroker whole, so both keys reach it.
|
||||
4. packages/webui/src/cli.mjs. FED R6 moved the owner's bus options into consoleBus() (console-bus.mjs) and added userCaller and userOptions. I kept the base's form, added consoleCall to the bus.mjs import, and added decisionWrite to the declarations. I kept the row's `if (ssoOn && sendOn) decisionWrite = consoleCall(...)` after the consoleBus() call, and pass decisionWrite to startServer next to userOptions and federation.
|
||||
5. packages/webui/src/serve.mjs. FED R6 added granted-user sessions: userRoute gates them, and userOptions rebinds the options per request.
|
||||
- The signature now has both decisionWrite and userOptions/federation.
|
||||
- The session hunk keeps the base's signer and granted-user block. ssoSession is set only for a session with no `user`, which is the owner's. A granted user is refused at userRoute before the decision route anyway, because /api/decisions/* is not on userRoute's list. Under the new base this keeps the design section 2 rule that only the owner answers a decision.
|
||||
- /api/config keeps the base's `user` and `federation` keys. `decide` gains `!granted`, matching how the base gates `edit`, so a granted user's config never offers Approve and Deny.
|
||||
- The decisionWrite startup check and createConfirmations() auto-merged unchanged.
|
||||
6. packages/bus/tests/channel-proof.test.mjs, webui bus.mjs pin. Row 301 (#1808, mos-1449) moved the user transport into bus.mjs and re-pinned it to 39fcb27c. The row's consoleCall auto-merged on top. I kept row 301's comment and re-pinned to the merged blob's sha256, 604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c. The human.mjs pin (bb13c146) auto-merged and matches the blob at the new tip. The row's existing header comment (Webby N5) still describes why bus.mjs is re-pinned. humanCall and transport() are unchanged.
|
||||
|
||||
Auto-merged without conflict: webui bus.mjs (consoleCall next to row 301's userCaller), webui README.md, bus client.mjs, human.mjs, principal.mjs, server.mjs and public/bus.js.
|
||||
|
||||
Two new-base reads I checked:
|
||||
- granted-user.test.mjs reads `/api/config` key by key and has no deepEqual on the whole object, so the extra `decide` key doesn't break it.
|
||||
- No other test pins one of the row's source files by sha256, apart from channel-proof.
|
||||
|
||||
Not run: no test, node or syntax check during this step. Sage's next CLEAR covers the push, the whole webui, bus and cli suites, every scripts/test-*.sh, and J1-J17. Plugins must show 0 failures (mos-1456/1462). J7 still needs a killer test. The serve.mjs `!granted` and ssoSession lines are new since the build, so they get mutants too. I'll add them to the J list at the run.
|
||||
|
||||
## Runs (sage-3488 CLEAR, 2026-10-23)
|
||||
|
||||
Gate tree: detached worktree of 3e6de9807 with the canonical node_modules linked, so
|
||||
`MOSAIC_REQUIRE_EDITOR_BUILD=1` is set (mos-1453). TMPDIR comes from mktemp under
|
||||
/mnt/storage/scratch/tmp. Each step runs under `timeout -k 10` (mos-1275):
|
||||
|
||||
| Step | Limit (s) |
|
||||
|---|---|
|
||||
| pkg bus, cli, plugins, webui, business (`node --test tests/*.test.mjs`) | 1800 each |
|
||||
| test-auth / conductor / config / discord / extension-package | 126 / 145 / 130 / 147 / 130 |
|
||||
| test-foundation / land-work / queue / release / task | 269 / 171 / 226 / 157 / 231 |
|
||||
| mutant single file | 300, chat-browser 600 |
|
||||
|
||||
The script limits are 3x the longest runs recorded in queue-253 (receipt 3b91cfdaf).
|
||||
|
||||
### First-run reds placed (mos-1272)
|
||||
|
||||
- pkg-bus at 3e6de9807: 902 pass, 1 fail. The failure is `N2, J13` in console-decide.test.mjs:180, a new
|
||||
test (0 human.input rows found, 1 expected). The cause is in the test, not the product. `events()` spread
|
||||
the parsed body after the row's `kind`, so an answer body's own `kind: 'answer'` overwrote
|
||||
`'human.input'` and the filter could never match. Three more filters on the same field had the same
|
||||
defect, :250 and :290 (count of human.input must be 0) and :252 (an action.refused exists). The two
|
||||
zero counts passed vacuously. Fix: the row's kind is also kept as `event`, set after the body, and all
|
||||
four filters read `event`. The deepEqual at :181 now names `event: 'human.input'`. The file then ran
|
||||
whole at 11 pass, 0 fail.
|
||||
- J7 (open in the plan): no existing test bound a console proof over the socket. The new test
|
||||
`J7: a console proof over the socket binds a cap for its one call and the reply releases it` in
|
||||
console-decide.test.mjs serves a real socket with authenticateConsole, makes one console call that
|
||||
answers and one that refuses (`decision-not-found`), and checks that both caps are released
|
||||
(`unauthenticated`). J7 is applied as `boundCap = kind === 'console' ? null : transient;` in server.mjs,
|
||||
which skips every release path for a console cap (finish, the finally, and socket close). It ran
|
||||
at 11 pass, 1 fail, failing only the new J7 test. KILLED. The restored server.mjs matched its pre-mutant sha256.
|
||||
- decide-sso.test.mjs (new) ran 2 pass, 2 fail before any mutant:
|
||||
- `J16`: its `{ send: null }` case reached `setup`, and startServer refused it, because a decision
|
||||
transport without `--send` never starts (serve.mjs guard). That case now asserts the refusal, and the
|
||||
`{ decisionWrite: null }` case still checks the read-only routes and `decide: false`.
|
||||
- `N4`: a POST to `/api/bus/decision.*` gets row 74's preview refusal (403 `read-only`, for every
|
||||
write without a route of its own) before any verb lookup. POST now asserts that refusal and GET
|
||||
still asserts 404. Either way the transport sees no call.
|
||||
- The file then ran whole at 4 pass, 0 fail.
|
||||
- Old-test reds, sent to Sage before any fix (status 2):
|
||||
- cli `access-boot.test.mjs` R6 B1-B3 come from this row. The fixture's SSO provider has no issuer,
|
||||
so `consoleOwners` maps `iss: undefined` and startBroker refuses the owner list
|
||||
(`invalid-host-request`). The file is 4/0 on fe4ee8c7b.
|
||||
- cli `compactions-launch.test.mjs` REQ-THREAD-2 T3, 5 tests, each "timed out waiting". They also fail
|
||||
0/5 on a fe4ee8c7b tree, so they are not this row's.
|
||||
- Correction (sage-3492): the compactions-launch reds were the environment, not a base red. The gate
|
||||
TMPDIR (`<gate>/tmp`) was long enough to break the launcher/bwrap paths. Alone in a short
|
||||
`mktemp -d /mnt/storage/scratch/tmp/scrXXXXXX` TMPDIR the file ran 5 pass, 0 fail, 0 skipped, and
|
||||
Sage's own gate ran it green. The file is unchanged.
|
||||
|
||||
### Rulings and further placements (sage-3492 to sage-3496)
|
||||
|
||||
- access-boot (Mos GO mos-1464, relayed in sage-3494): `access-boot.test.mjs:17`'s `authentik` provider
|
||||
gains `issuer: "https://idp.test"`, and nothing else changes. numstat against fe4ee8c7b reads 1 1.
|
||||
It is committed alone (b316e0a55). access.test.mjs:273 uses the same fixture but never reaches
|
||||
bootConfig, so it is untouched. The file ran 4 pass, 0 fail.
|
||||
- decide-browser.test.mjs (new) ran 0 pass, 3 fail before any mutant, then 1 pass, 2 fail after the
|
||||
first fix:
|
||||
- setup's `UPDATE decisions SET question=?` hit migration 3b's `decisions_no_update` trigger, because
|
||||
decisions is append-only. The test now writes its own open credential.mint grant decision, with the
|
||||
hostile question in the INSERT. It is the same two rows host-fixtures' grantDecision writes, and
|
||||
host-fixtures.mjs is unchanged.
|
||||
- `page()` visiting the URL it was already on was a same-document hash change, not a load. So J17's
|
||||
"closed elsewhere" step never re-read the bus, and the redirect test's second visit never redrew
|
||||
after signing out. `page()` now loads about:blank first, as editor-browser.test.mjs:216 does.
|
||||
- The file then ran whole at 3 pass, 0 fail (9e1601a20).
|
||||
- decide-sso J16 (sage-3492): the startServer refusal stays. The one config that starts without send
|
||||
also lacks a decision transport, since startServer refuses a transport without `--send`. That config,
|
||||
`{ decisionWrite: null, send: null }`, is now in the J16 loop and gets `decide: false` and the
|
||||
read-only routes. Webby rules in the manifest round.
|
||||
- J19 killer (new test in decide-sso.test.mjs): an access document with one granted user (alice). The
|
||||
listener has `--send`, a decision transport and a user transport. The owner's config says
|
||||
`decide: true`. Alice's says `user: true`, `send: true` and `decide: false`. Her POSTs to both
|
||||
decision steps get 403 `owner-only`, and the transport sees no call.
|
||||
- J18 is equivalent alone, because a granted session never reaches decisionHTTP. At serve.mjs:694 a
|
||||
session with `user !== undefined` meets `userRoute` before the decision branch. userRoute admits only
|
||||
`/api/chat*` and GETs of `/api/config` and the user files, never `/api/decisions/*`, so the 403
|
||||
`owner-only` comes before `ssoSession` is read. The `session.user === undefined` guard is a second
|
||||
layer behind that. The J19 test's owner-only POSTs pin the first layer.
|
||||
- J11c is equivalent. `s.human !== b.human` at broker.mjs:5604 can't hold for a console cap:
|
||||
- bindConsole (broker.mjs:1459) refuses `record.human !== b.human` with `human-required`.
|
||||
- Its cap carries `human: b.human` for its own business, and #cap freezes the session (:1094).
|
||||
- At resolve, `b` is `this.#business(s.business)` (:5324), the same business.
|
||||
- reloadBusiness refuses a document whose human differs (:517).
|
||||
So for any session with `s.console` set, the two humans are equal. Webby rules on it in the
|
||||
manifest round.
|
||||
- Mutants, part 2, on the tip test files (decide-sso 5/0, decide-browser 3/0 as the baseline).
|
||||
Every restore matched its pre-mutant sha256.
|
||||
- J16a: KILLED by decide-sso J16.
|
||||
- J16b: KILLED by decide-browser J16.
|
||||
- J17a: KILLED by decide-browser J17 and the redirect test.
|
||||
- J17b: KILLED by decide-browser J17.
|
||||
- J19: KILLED by decide-sso J19. granted-user stays 19/0 under it.
|
||||
- J18: SURVIVED decide-sso 5/0 and granted-user 19/0. It is equivalent alone, for the reason above.
|
||||
- Old webui stubs (Mos GO mos-1465, relayed in sage-3497): library-cli.test.mjs:17 and
|
||||
thread-claims.test.mjs:58 build a loader-hook stub of bus.mjs with no `consoleCall` export, and cli.mjs
|
||||
now imports it. Each busSource gains `export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`
|
||||
in the neighbours' throwing form, and nothing else changes. numstat against fe4ee8c7b is 1 1 for each.
|
||||
Alone they ran 1/0 and 4/0. No mos-1316 item for compactions-launch: the whole cli rerun is the evidence.
|
||||
@@ -0,0 +1,229 @@
|
||||
# Row 285 build notes: C2-08b creative and chief role changes
|
||||
|
||||
Seat: Scrooge. Reviewer: Webby. Gate owner: Sage. Issue #1790 (mos-1309 item 5).
|
||||
Brief: `docs/plans/2026-10-20_case2-rows.md` at 94c33a43c, row 285 section, gate under "### Gate".
|
||||
Base: 94c33a43c on branch `scrooge/row285`.
|
||||
|
||||
## Status
|
||||
|
||||
- The code, tests, fixtures and docs are written locally. No test has run. Sage's order puts row 252 first at the next CLEAR, and nothing runs during another seat's slot.
|
||||
- The Results section is empty until that run. Nothing below claims a pass.
|
||||
- Mos ruled the mos-1272 points in mos-1332 (18:55Z). mos-1333 (19:04Z) withdrew item 3, the business.mjs publish fence. See "Old tests" below.
|
||||
|
||||
## What changes
|
||||
|
||||
- The six creative roles (companion, ideas, research, proofreader, fact-checker, continuity):
|
||||
- withinRole is `["message.send", "library.write"]`.
|
||||
- credentials is `[{"service": "gitea", "scopes": ["read:user", "write:repository"]}]`.
|
||||
- Each contract's Authority and Protocol lines describe the two library write tools, which are limited to `.md` files in supporting folders the user marked. The agent never holds a token.
|
||||
- The chief of staff:
|
||||
- credentials is `[{"service": "gitea", "scopes": ["read:repository", "read:user"]}]`, with no library.write.
|
||||
- The contract says the broker holds that token and it can't publish or push.
|
||||
- `vocabulary.mjs` drops CREATIVE_KINDS and adds CREATIVE_ACTIONS (`message.send`, `library.write`) and CREATIVE_SERVICE (`gitea`).
|
||||
- `role.mjs` checkCreative refuses a creative role that holds any withinRole action outside CREATIVE_ACTIONS, any crossRole action, or any credential service other than gitea.
|
||||
- `broker.mjs` #giteaRole (:2138) also refuses the chief of staff with `gitea-not-declared` (sage-2921).
|
||||
- business.mjs does not change (mos-1333 item 1). The fence added in 5f81d0da0 is removed in a later commit, so the branch's business.mjs equals the base.
|
||||
|
||||
## Rulings
|
||||
|
||||
- **webby-0164:** PRD 1.15 rewrites REQ-LIB-6 so creative agents write `.md` files in supporting folders. The REQ-WAIT-3 routing line is the missing piece.
|
||||
- **mos-1328:**
|
||||
- library.write routing goes in PRD 1.16, which needs Jason's explicit yes (REQ-WAIT-2).
|
||||
- library.write stays out of ROUTED until 1.16 lands.
|
||||
- If Jason changes the bounds, row 285 gets a new brief.
|
||||
- **sage-2915:**
|
||||
- The scopes a role declares must equal what any token minted for that role holds, at the commit where 285 lands.
|
||||
- The creative scopes `["read:user","write:repository"]` are approved.
|
||||
- The ROUTED edit gets its own commit, citing the paragraph.
|
||||
- **sage-2921:**
|
||||
- Refuse the chief in #giteaRole, with a gitea-grant.test case next to X-G1. broker.mjs (#giteaRole only) and gitea-grant.test join the manifest.
|
||||
- Grep the tests for the gitea-not-declared text and for pins on broker.mjs before in-review.
|
||||
- The ROUTED commit cites 1.16 (row #1796).
|
||||
- **mos-1330:**
|
||||
- Item 1: the fixture change gets its own commit. Only fixture data changes, and named tests cover a missing gitea ref.
|
||||
- Item 2: option A is approved. The live chief gets one gitea ref. Every repo business writer gets the ref.
|
||||
- **sage-2937:** if Mos rules (b), business.mjs joins the files owned, scoped to the refusal near :166 and clear of :283-291 (row 287, Megavolt).
|
||||
- **webby-0174:** each changed expected value carries Mos's ruling key. The load fence needs a design line, its own test, a mutant and a grep of the tests for the N10 text.
|
||||
- **mos-1332:** the mos-1272 rulings, items 1-5, cited under "Old tests". The ROUTED commit and the landing still wait on Jason's yes on PRD 1.16 (Q32, Vikunja 546). The build, the gate and Webby's review go ahead.
|
||||
- **mos-1333:** withdraws mos-1332 item 3, since the fence contradicted the brief (:1825, :1829, :1839, and row 287's chief publish ["books"] at :1998). Items 1, 2, 4 and 5 stand.
|
||||
- The fence leaves business.mjs in a new commit, no force-push. business.mjs leaves the manifest and its fence mutants are dropped.
|
||||
- business.test "row 285: a creative instance with identity and publish loads" is back in its 8c529fb4d form.
|
||||
- N10 takes option (a). See "Old tests" item 3.
|
||||
- A new named test: a chief with its gitea ref, identity and publish ["stack"] loads.
|
||||
- Row 287 has nothing to reshape. Its binding note below still applies.
|
||||
- **sage-2946:** the bus suite runs whole (broker.mjs is in the manifest and bus tests read roles/chief-of-staff.json), plus the cli example readers. Item 2 is answered below under "Scopes field required".
|
||||
|
||||
## Scopes rule evidence (sage-2915 q2)
|
||||
|
||||
At 94c33a43c, exactly one path mints a seat token. That is the Gitea grant, `broker.mjs:2420`, where `api.tokenCreate(user, tokenName)` takes the default scopes. The default is SEAT_SCOPES `['read:user', 'write:repository']` (`gitea-grant.mjs:29`, `:263`).
|
||||
|
||||
The only other tokenCreate caller is the admin rotate (`gitea-grant.mjs:331`), which uses ADMIN_SCOPES.
|
||||
|
||||
The creative declaration therefore equals SEAT_SCOPES. The chief's read declaration would be narrower than a minted token. #giteaRole refuses the chief, so no grant path mints the chief a token at the landing commit.
|
||||
|
||||
### Scopes field required (sage-2946 item 2, Megavolt's question)
|
||||
|
||||
role.mjs refuses a gitea credential entry with no scopes. `{"service": "gitea"}` goes through checkGiteaScopes (role.mjs:100-111), which calls `requireDistinctList(scopes, "gitea scopes", ..., {nonEmpty: true})`. util.mjs:42 refuses a non-array with "gitea scopes must be an array". Only a `host` entry may omit scopes (role.mjs:134). So every gitea entry names its scopes, and for a minted token they must equal SEAT_SCOPES (sage-2915).
|
||||
|
||||
### Binding note for row 287 (C2-21)
|
||||
|
||||
- The library read grant row must mint the chief's declared scopes (`read:repository`, `read:user`) and not SEAT_SCOPES.
|
||||
- It needs a test showing the chief's token lacks `write:repository`.
|
||||
- If it reuses #giteaRole, it has to lift the chief refusal and pass the declared scopes in the same change.
|
||||
|
||||
## Live step (mos-1330 item 2, option A)
|
||||
|
||||
The live dragon-lin chief holds no credentials, and there are no creative instances. After row 285 lands, the chief entry in the live business file needs one gitea reference, or the file refuses at load with "must reference exactly the services its role definition needs (gitea; got none)".
|
||||
|
||||
Exact JSON, added to the chief instance (store form, the form the human's `instances.set` add writes):
|
||||
|
||||
```json
|
||||
"credentials": { "gitea": { "store": true, "rotateBy": "YYYY-MM-DD" } }
|
||||
```
|
||||
|
||||
The file form also works: `{ "gitea": { "file": "<configDir>/secrets/<business>/chief-of-staff-gitea.token", "rotateBy": "YYYY-MM-DD" } }`. That file must exist, mode 0600.
|
||||
|
||||
- The reference creates no account, mints no token and grants nothing by itself.
|
||||
- The window owner adds it after a backup of the business file, and before P3.
|
||||
- **Precondition for the store form:** the store root `<configDir>/secrets/agents` must exist and be private, since `bus/src/credentials.mjs:212` calls privateDir on it when the reference is read.
|
||||
|
||||
## Repo business writers (mos-1330 item 2)
|
||||
|
||||
A git grep of scripts, templates, src, tools and packages outside tests found two repo files that write a business file with a chief. Both get the gitea file reference:
|
||||
|
||||
- `packages/business/examples/mosaic-stack.example.json:114`, the chief entry. Two test files read it (corrected after webby-0178 B2):
|
||||
- `packages/business/tests/business.test.mjs:256`, "the shipped example refuses as written and validates once filled in". It replaces `/home/you` with its tmp root and checks the role list and the sync token path. The chief's new file ref goes through the same replacement.
|
||||
- `packages/cli/tests/stack-release-fixture.mjs:351` (EXAMPLE, through `biz()`). It fills botId and the dates but keeps the `/home/you` token paths, as it already does for the other roles' refs. Its readers are stack-release.test, stack-release-external.test and stack-upgrade.test.
|
||||
- All four test files run whole at the gate.
|
||||
- `packages/business/README.md:174`, the chief example line, plus the prose at :163 and :183 that said the chief holds no credentials.
|
||||
|
||||
No script, template or case installer writes a chief or creative instance. `scripts/case1-accept.sh` only talks to `chief`.
|
||||
|
||||
## Fixture changes (mos-1330 item 1, own commit)
|
||||
|
||||
Data only, with one named exception listed under "Old tests".
|
||||
|
||||
- `packages/business/tests/helpers.mjs:71-74`
|
||||
- Old: `// The chief of staff launches (REQ-LAUNCH-1, decision 139); it holds no` / `// credential and no tracker bot.` / `chief: { definition: "chief-of-staff" },`
|
||||
- New: `// The chief of staff launches (REQ-LAUNCH-1, decision 139); it has no` / `// tracker bot. Its one credential reference is gitea, for the library` / `// read grant (row 285, mos-1330).` / `chief: { definition: "chief-of-staff", credentials: { gitea: ref("chief-of-staff", "gitea") } },`
|
||||
- Creative and chief instances loaded through the loader each gain `credentials: { gitea: { store: true, rotateBy: FAR_DATE } }`, and the import adds FAR_DATE where needed:
|
||||
- `packages/business/tests/instance-settings.test.mjs:334`, `:335`, `:380` (import :12)
|
||||
- `packages/business/tests/lifecycle-settings.test.mjs:237` (import :11)
|
||||
- `packages/business/tests/launch-settings.test.mjs:184`, bean (FAR_DATE already imported)
|
||||
- `packages/business/tests/resolve.test.mjs:117`, chief2 (import :7)
|
||||
- `packages/bus/tests/creative.test.mjs:98`, bean in the written business file (import :10). The `startBroker` businesses object at :111 doesn't go through the loader and is unchanged.
|
||||
- `packages/cli/tests/launcher.test.mjs:769`, bean (import :1)
|
||||
- `packages/business/tests/business.test.mjs`: the persona test's bean fixtures get `credentials: CREATIVE_GITEA` (4 places).
|
||||
|
||||
New named tests (mos-1330 item 1):
|
||||
|
||||
- business.test "row 285: a chief or creative instance without credentials.gitea refuses at load". Each case refuses with the checkCredentialMap "(gitea; got none)" message, and a positive control loads.
|
||||
- business.test "row 285: a creative instance with identity and publish loads" (brief :1825, :1839, restored by mos-1333 item 2). The grant still refuses it at raise (#giteaRole).
|
||||
- agent-identity.test "row 285: a chief with its gitea reference, identity and publish loads" (mos-1333 item 4, brief :1829). It also checks the chief's credentials are exactly gitea.
|
||||
|
||||
## Old tests (mos-1272, ruled in mos-1332 and mos-1333)
|
||||
|
||||
Webby (webby-0174) passes each item only with Mos's ruling key next to it.
|
||||
|
||||
1. **`packages/business/tests/chief-of-staff.test.mjs`** (mos-1332 item 1, accepted as pushed). Expected values follow the role file:
|
||||
- APPROVED.credentials (:24). Old: `[]`. New: `[{service: "gitea", scopes: ["read:repository", "read:user"]}]`.
|
||||
- The resolved-record test (:90). Old: `{}`. New: `{gitea: {service: "gitea", file: <root>/secrets/chief-of-staff-gitea.token, rotateBy: FAR_DATE}}`.
|
||||
- :155. Old name: "the instance takes no credential references and no tracker", expecting "(none; got gitea)". New name: "the instance takes only the gitea credential reference and no tracker", adding a vikunja ref and expecting "(gitea; got gitea, vikunja)". The tracker refusal is unchanged.
|
||||
2. **`packages/business/tests/role.test.mjs`** (mos-1332 item 2, accepted). checkCreative checks the service and not the scopes (webby-0178 N1). The scopes are pinned by the load test, which deepEquals each of the six creative role files' credentials to `[{service: "gitea", scopes: ["read:user", "write:repository"]}]`, so a widened scope in any one file is red.
|
||||
- The six-creative-roles load test now pins withinRole `[message.send, library.write]` and credentials `[{gitea, [read:user, write:repository]}]` exactly. Old: `[message.send]` and `[]`.
|
||||
- The M1 block checked record.write and one crossRole case. It now loops over every routed action outside CREATIVE_ACTIONS, keeps the crossRole refusal, and adds the vikunja and host credential refusals. The refusal text changes from "may hold only withinRole message.send" to "may hold only withinRole message.send and library.write".
|
||||
3. **`packages/business/tests/agent-identity.test.mjs:49-50` (N10)** (mos-1333 item 3, option (a); replaces mos-1332 item 3). After 285 the chief's definition declares gitea, so the old second case no longer refuses. Only the fixture instance and the instance name in the regex change. The text after the name is unchanged.
|
||||
- Old: `refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),` / `/roles\.chief\.publish needs identity and a gitea definition/);`
|
||||
- New: `refuses(() => check((d) => { d.roles.helper = { definition: "assistant", holder: "mos", identity: structuredClone(IDENTITY), publish: ["stack"] }; }),` / `/roles\.helper\.publish needs identity and a gitea definition/);`
|
||||
- The standalone helper test added in 5f81d0da0 is removed. The fence and its business.test change are reverted.
|
||||
- Grep of `packages/*/tests/*` for "needs identity and a gitea definition": only agent-identity.test :47, :48 and :52. No other reader.
|
||||
- Mutant M9 below.
|
||||
4. **`packages/business/tests/instance-settings.test.mjs:289`** (mos-1332 item 4, accepted). Old: `{ services: [], accepted: true }`. New: `{ services: ["gitea"], accepted: true }`. If the :298 listing changes at the run, the exact new value goes here and Webby hears about it. Anything that removes or weakens a check goes back to Mos.
|
||||
5. **Other fixture edits** (mos-1332 item 5): data only, no ruling needed.
|
||||
|
||||
## Commit plan
|
||||
|
||||
1. Role files, contracts, vocabulary, role.mjs, broker #giteaRole, gitea-grant.test and role.test, plus this file.
|
||||
2a. The mos-1332 commit (5f81d0da0): instance-settings.test:289 and this file. Its business.mjs fence and two fence tests are reverted by the mos-1333 commit.
|
||||
2b. The webby-0178 commit (d79cfe674): B1, B2 and notes.
|
||||
2c. The mos-1333 commit: the fence removed, business.test restored, N10 option (a), the named chief test and this file.
|
||||
2. The fixture commit (mos-1330): helpers.mjs, chief-of-staff.test, business.test (fixture refs and the two new named tests), the loader fixtures above, the example json and README. Mos ruled the old-test points in mos-1332.
|
||||
3. The 1.16 commit: `library.write` in ACTIONS and ROUTED in both `business/src/vocabulary.mjs` and `bus/src/broker.mjs`, citing the PRD 1.16 paragraph (row #1796), with a hard-waits test that it is routed. It's held until Jason's yes and 1.16 lands.
|
||||
|
||||
Until commit 3, every creative role file refuses at load (REQ-WAIT-2: a GATED_ONLY action in a role file). The branch is green only with all three commits.
|
||||
|
||||
## Mutant plan
|
||||
|
||||
Kill rules are written before the runs. Each mutant is one edit, then a restore, a sha256 compare and a clean `git status`.
|
||||
|
||||
| M | Mutant | Source | Expected kill |
|
||||
|---|---|---|---|
|
||||
| M1 | drop `role.definition === STAFF_TO` from #giteaRole | broker.mjs:2138 | gitea-grant.test "X-G1: the chief of staff refuses at raise" |
|
||||
| M2 | CREATIVE_ACTIONS gains `role.end` | vocabulary.mjs | role.test M1 routed-action loop |
|
||||
| M3 | drop the creative services check | role.mjs checkCreative | role.test M1 vikunja and host refusals |
|
||||
| M4 | drop the crossRole check | role.mjs checkCreative | role.test M1 crossRole refusals |
|
||||
| M5 | creative role file without library.write | roles/companion.json | role.test creative-load test |
|
||||
| M6 | chief scopes widened with write:repository | roles/chief-of-staff.json | chief-of-staff.test "the shipped file is exactly the approved list" |
|
||||
| M9 | drop `\|\| !services.includes("gitea")` from the N10 check | business.mjs:166 | agent-identity.test N10's helper case :51-52 (mos-1333 item 5) |
|
||||
|
||||
M7 and M8 targeted the withdrawn fence and are dropped (mos-1333 item 1). M9 mutates a file outside the manifest only to show the N10 test still guards it.
|
||||
|
||||
## Manifest
|
||||
|
||||
Source and roles:
|
||||
- packages/business/src/vocabulary.mjs
|
||||
- packages/business/src/role.mjs
|
||||
- packages/bus/src/broker.mjs (#giteaRole only, sage-2921)
|
||||
- roles/{companion,ideas,research,proofreader,fact-checker,continuity}.{json,md}
|
||||
- roles/chief-of-staff.{json,md}
|
||||
|
||||
Tests:
|
||||
- packages/bus/tests/gitea-grant.test.mjs (sage-2921)
|
||||
- packages/business/tests/role.test.mjs
|
||||
- packages/business/tests/business.test.mjs
|
||||
- packages/business/tests/helpers.mjs (fixture, mos-1330)
|
||||
- packages/business/tests/chief-of-staff.test.mjs (old test, mos-1272)
|
||||
- packages/business/tests/instance-settings.test.mjs
|
||||
- packages/business/tests/lifecycle-settings.test.mjs
|
||||
- packages/business/tests/launch-settings.test.mjs
|
||||
- packages/business/tests/resolve.test.mjs
|
||||
- packages/bus/tests/creative.test.mjs
|
||||
- packages/cli/tests/launcher.test.mjs
|
||||
- packages/business/tests/agent-identity.test.mjs (N10 :51-52 and one named test, mos-1333 items 3 and 4)
|
||||
|
||||
Docs:
|
||||
- packages/business/examples/mosaic-stack.example.json
|
||||
- packages/business/README.md
|
||||
- agents/scrooge/work/queue-285/BUILD.md (this file)
|
||||
|
||||
## Gate checks before in-review
|
||||
|
||||
- `git grep CREATIVE_KINDS -- packages roles scripts` is empty. Checked locally.
|
||||
- Grep the tests for `gitea-not-declared`, and for 64-hex pins on broker.mjs and each changed role file.
|
||||
- Run business.test, stack-release.test, stack-release-external.test and stack-upgrade.test whole, since they read the example json (webby-0178 B2).
|
||||
- Run the bus suite whole (sage-2946 item 1): broker.mjs is in the manifest, and these bus tests read roles/chief-of-staff.json by name: chief-of-staff, fire-revokes-grants, host-run, instances-verb, names, reload, routes-verb. Sage listed five. A git grep finds the seven.
|
||||
- Run each fixture-importing suite whole: the business suite, the bus suite, the cli suite, `scripts/test-config.sh` and every `scripts/test-*.sh`, with TMPDIR under /mnt/storage/scratch/tmp.
|
||||
- Sage confirms the result against mos-1309 item 5.
|
||||
|
||||
## r2 rebase (sage-3578, 10-23)
|
||||
|
||||
- Rebased 34ee4df7d onto origin/refactor 45dca69f6 as scrooge/row285-r2; the old branch is untouched and nothing was force-pushed.
|
||||
- Commit map: 0e369895b to 9a77fff7a, 8c529fb4d to 115889e4a, 5f81d0da0 to aabfdfc60, d79cfe674 to 66e3c3d61, 34ee4df7d to cf8ba6ef4.
|
||||
- `git range-diff 94c33a43c..34ee4df7d 45dca69f6..cf8ba6ef4` gives `=` on all five. `git patch-id --stable` is unchanged for the whole range (72cc8ef5) and for broker.mjs (caa0e756). merge-tree was clean.
|
||||
- Drift 94c33a43c..45dca69f6 (262 commits). On this row's paths only broker.mjs (row 306), bus creative.test and cli launcher.test moved, and they merged clean. roles/, packages/business/ and contracts/ have no upstream change. Row 291 (4874d1e83) and row 308 (30e6bb680) touch the PRD, the requirements json and the tasks and bus requirements tests, which this row does not change. Row 306 touches broker.mjs, access-revoke.test, revoke-window.test and harness runner.mjs.
|
||||
- New and changed upstream tests that read `roles/<name>.json`: bus access-revoke, launcher-released, revoke-window, session-clear, session-compactions, sessions, thread-shares, chief-of-staff and creative. Bus chief-of-staff, creative and tester-writes import business tests/helpers.mjs, as do many cli, webui, harness and plugins tests. No 64-hex pin names a role file or broker.mjs. CREATIVE_KINDS has no reader at 45dca69f6. project-runtime.test pins vocabulary.mjs import-free; this row adds no import.
|
||||
- Gate scope therefore grows: run the bus, business, cli, webui, harness and plugins suites whole, plus the scripts above.
|
||||
|
||||
## Commit 3: library.write routed
|
||||
|
||||
PRD 1.16 landed with row 291 (4874d1e83) and Jason's yes is mos-1354 Q32, so the hold on this commit is gone. `library.write` goes last in ACTIONS in both business/src/vocabulary.mjs and bus/src/broker.mjs, keeping bus hard-waits.test's order-sensitive ACTIONS equal VOCABULARY, and into ROUTED in both, so HARD_WAITS is unchanged. A new business hard-waits test (REQ-WAIT-3, REQ-LIB-6) checks it is in ACTIONS, routed and not GATED_ONLY.
|
||||
|
||||
Open for review, not in scope: the PRD says library.write is granted to the six creative roles only. role.mjs checkCreative bounds what a creative role holds, but nothing refuses library.write in a non-creative role's withinRole. The brief asks only that the chief refuses it under an allowlist validator, and the chief has none. Flagged for Webby and Sage.
|
||||
|
||||
N1 (sage-3593): role validator should refuse library.write on a non-creative role (PRD 1.16 creative-only). Not part of 285. Role files change only through reviewed commits (invariant 7), so a review would see such a grant today, but a refusal at load is the fail-closed form. Webby confirms it in the round, and Sage files it in DEFERRED as a follow-up row at landing.
|
||||
|
||||
Review scope (sage-3593): commits 1-5 are a rebase, so check the range-diff. Commit 3 (8f8b8cf15) is new code, so review it in full.
|
||||
|
||||
## Results
|
||||
|
||||
Not run.
|
||||
@@ -1585,3 +1585,12 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 round 2, sage-3354/3357/3358/3361 | Rebased onto 5fdfd7275 on huey/row272-5fdfd7275, preserving 219/295 and approved old-test hunks; rev3510 in-progress. New fallback test ran RED first in Sage's slot: expected legacy failure, 3 guards pass; slot returned. Bounded fallback and extra guards/mutants prepared for Sage's full gate; no post-implementation run or green claim.
|
||||
|
||||
2026-10-23 | Huey (cabd45ca) | Row 272 round 3, mos-1455 / sage-3435 | Added empty role authority arrays to new CLI fixture and guarded null old at all three chat render sites; browser tests byte-identical. Same 17-path manifest.r3, no local execution or queue writes. Follow-up candidate awaits Scrooge delta review and Sage gate; gate run 1 remains RED.
|
||||
|
||||
2026-10-23 | Sage (1ef1e4f8) | Rows 272 and 307 landings, row 306 design, row 252 rebase check | 272 landed d33989230 and 307 landed d243f6b25, both on full five-suite gates with mutants killed. Row 306 design r2 approved by Dewey (c31197), build going under mos-1463. Row 252 rebase 3e6de9807 on fe4ee8c7b checked and Scrooge given the slot (sage-3488).
|
||||
2026-10-23 | Sage (1ef1e4f8) | Rows 291 and 306 landings, row 308 launch | 291 (PRD 1.16, #1796) landed 4874d1e83 and 306 (#1813) landed 1676ad0de..2d0318a44 by temp-index replay, patch-ids equal, both on all-suite gates (mos-1455, plugins 0 fail, 306 mutants M1-M3 killed). 306 N1/N2 in DEFERRED 9c8877bdf. Row 308 (PRD 1.17, #1815) briefed and launched to Gosalyn; slot passed to them for claim, push and in-review. Scrooge told to rebase 252 on 9c8877bdf. Mos told (sage-3515, sage-3520).
|
||||
2026-10-23T17:29:46Z | Gyro (Codex gpt-6.1-sol, sage-3542) | 571 REQ-LIB-5/Q239 Connections design amendment | Proposed W8 plus parent/W4 alignment; Mos build order kept distinct from import acceptance. Static docs checks only, no repo Node/tests/live action. Fenton review, Sage landing.
|
||||
2026-10-23T17:32:37Z | Gyro (Codex gpt-6.1-sol, sage-3544) | 571 Connections N1/N2 | W7 opening-order cross-references and human-view comment/deletion exclusion clarified. Static docs checks only; no repo Node/tests/live action. Delta to Fenton, Sage lands.
|
||||
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 | Bubba (Codex gpt-6.1-sol, 3d6672b5) | Row 288 / #1793 C2-02 in bubba/row288; mos-1499, sage-3613 | Started; 283/285/286 merged intact, static build pending Sage CLEAR; queue ownership remains with Sage.
|
||||
@@ -187,8 +187,10 @@ canon Markdown chapter takes its position by that rule or a human's committed or
|
||||
import preserves its chapter ID and position. Human reordering commits the manifest.
|
||||
library_manuscript and next_chapter use this order, never archived paths. Each
|
||||
answer/page names format, owned location/ID and captured commit. DOCX wikilinks stay clickable
|
||||
in the Console editor; no graph shows their current links yet. A Console Connections view
|
||||
and its tests are later work, waiting for Jason's J-b answer in PRD 1.17. Obsidian's archive
|
||||
in the Console editor, and no graph shows them yet. Under PRD 1.17 REQ-LIB-5 and Q239,
|
||||
the Console's Connections view lists the current links and backlinks of each story file
|
||||
the user may see, in both formats, with each one's scope and format. W8 owns that view
|
||||
and its tests. Obsidian's archive
|
||||
graph is historical, not a live DOCX graph. No graph implementation is in these W rows. New Story chooses universe/book or playground and gets
|
||||
its place in that manifest from its first DOCX commit, without an MD copy.
|
||||
|
||||
@@ -242,8 +244,8 @@ follow stable identities without an agent rewriting story links.
|
||||
Obsidian's old Markdown graph is historical after conversion. It does not track later DOCX
|
||||
edits, and it is not a parallel writing surface. The format map names current files and
|
||||
clickable editor links resolve across formats; it is not a graph view. No graph shows DOCX
|
||||
links yet. The later Connections view waits for Jason's J-b answer; it and its tests are
|
||||
outside the current W3/W7 scope. No Markdown export or Obsidian graph parity is promised. `.doc`/`.qxd` imports remain separate D23 work.
|
||||
links yet. W8 supplies REQ-LIB-5's Console Connections view: current links and backlinks
|
||||
of each story file the user may see, in both formats, with each one's scope and format. No Markdown export or Obsidian graph parity is promised. `.doc`/`.qxd` imports remain separate D23 work.
|
||||
|
||||
D12. Q235–Q237 withdraw the superseded-notice proposal and its open-Q36 boundary. Current
|
||||
Case 2 chapter reads after conversion use W4b's committed DOCX reader/citations. Agent
|
||||
@@ -579,12 +581,19 @@ runs Jason's layout agent nor automatically grants it access or sends book text
|
||||
|
||||
All rows follow BRIEF-TEMPLATE. They are not queue entries. S0 M1–M14 remain binding throughout W2/W6; source/target
|
||||
changes return to security review. W7 adds the policy conversion/routing row. S0 and the original W4 design have reviewed verdicts; this policy revision goes to Fenton.
|
||||
Their implementation gates remain separate. No code row W1/W2/W6/W3a/W3b/W3/W4b/W5/W7 enters the queue
|
||||
Their implementation gates remain separate. No code row W1/W2/W6/W3a/W3b/W3/W4b/W5/W7/W8 enters the queue
|
||||
before S0 PASS (recorded for this baseline, with M1–M14 still binding). Each also observes its explicit dependencies, source ownership, the CSP gate
|
||||
and the MVP gate-slot priority. Q177 permits chaining reviewed candidates, not overlapping
|
||||
owners. Sage assigns model-only seats. Each implementation reviewer is Opus 5.5 and did not
|
||||
author the code. Fenton reviews this design and can review code only when not its author.
|
||||
|
||||
Build order (mos-1470, Vikunja 571 comment 961): W7 first supplies reviewed conversion/format
|
||||
routing code with production import still closed, then W8 lands the Connections view, then
|
||||
W7's human opening step may open the first real chapter import. W8 is after W3/W4b and
|
||||
W7's closed routing candidate, before that W7 opening step. This is Mos's build-order rule,
|
||||
not a new REQ-LIB-8 condition or an addition to import acceptance. No converted real chapter
|
||||
exists without the view. O2 code remains after 10-27 and never ahead of MVP rows.
|
||||
|
||||
G1. Future code acceptance runs each touched test file independently and affected package
|
||||
suites on Sage's slot after MVP priority, plus required repository checks. Source builds need
|
||||
pinned tools, reproducible hashes, license/source manifests and independent review. Integration browser
|
||||
@@ -1258,6 +1267,7 @@ Independent conversion/parser/authorization review, writing/library/bus/harness/
|
||||
pinned-copy and sandbox closure tests, plus W4 A19/A20/A33–A35 and G1. Source archive, off-host acknowledged DOCX and
|
||||
format-map snapshot must match the new DOCX publication before completion. No real import
|
||||
opens before all D12 gates. The October 27 Markdown and supporting-doc fixtures still pass.
|
||||
The human opening step follows W8's landing (mos-1470, build order, not an acceptance condition).
|
||||
|
||||
### Handoffs
|
||||
|
||||
@@ -1265,6 +1275,83 @@ Owner sends commit/blob and fixture publication/crash evidence directly to the r
|
||||
Sage through T3. Reviewer sends verdict to owner/Sage. Copy Mos for the later first-real-book
|
||||
go or any proposed policy change, never ask an agent to execute that go.
|
||||
|
||||
## W8 Console Connections view for Markdown and DOCX story files
|
||||
|
||||
### Problem
|
||||
|
||||
PRD 1.17 REQ-LIB-5 at 30e6bb680, approved through Q239, requires the Console to list
|
||||
current links and backlinks of every story file the user may see, in both formats, with
|
||||
scope and format. Clickable editor wikilinks and Obsidian's historical archive graph do
|
||||
not supply this view.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Sage assigns the seat, using Codex gpt-6.1-sol for cross-format lookup and authorization.
|
||||
- Reviewer: an independent Opus 5.5 seat assigned by Sage, not the author.
|
||||
- After: W3, W4b and W7's reviewed closed conversion/format-routing candidate; serial handoff
|
||||
from W7 for shared library/WebUI files and from C2-11a/11b and C2-10b for shared UI paths.
|
||||
- Before: W7's human opening step for the first real chapter import, under mos-1470. This
|
||||
orders builds, not REQ-LIB-8 acceptance, and does not change its four opening conditions.
|
||||
Post-10-27 only, with no gate slot ahead of MVP rows.
|
||||
|
||||
### Files owned
|
||||
|
||||
New: `packages/library/src/connections.mjs`, `packages/library/tests/connections.test.mjs`,
|
||||
`packages/webui/src/connections-http.mjs`,
|
||||
`packages/webui/src/public/{writing-connections.js,writing-connections.css}`,
|
||||
`packages/webui/tests/{writing-connections.test.mjs,writing-connections-browser.test.mjs}`.
|
||||
Existing or handed off from W7/W3: `packages/library/src/{index.mjs,links.mjs}`,
|
||||
`packages/webui/src/{serve.mjs,bus.mjs}`,
|
||||
`packages/webui/src/public/{index.html,writing-pane.js,writing-pane.css}`,
|
||||
`packages/webui/README.md`.
|
||||
|
||||
### What ships
|
||||
|
||||
A Console Connections control beside story navigation opens a keyboard-accessible list
|
||||
with Links and Backlinks sections for the selected story. Each item shows its title,
|
||||
scope and format, and opens the current authorized story through the existing resolver.
|
||||
It works for Markdown, DOCX and a partly converted book. It uses committed link metadata
|
||||
and W7's stable identities/format routing, never unsaved editor buffers or archive content.
|
||||
The response names the captured versions; after a committed save or conversion publication,
|
||||
a refresh uses the current authorized snapshot. An in-flight stale result cannot replace
|
||||
that refresh. An empty section says there are no visible links; it does not reveal hidden
|
||||
counts or titles. Unresolved links remain visibly unresolved and cannot open guessed paths.
|
||||
|
||||
A human-only Console read route uses existing authenticated project/universe/source access
|
||||
and exclusions. Authorize both endpoints of every displayed edge before returning titles,
|
||||
paths or counts. Backlinks cannot reveal another business, a denied universe, excluded
|
||||
story or archived source. Links inside comments or tracked-deletion text are not current
|
||||
story links, so the human Connections view never shows them and never depends on an agent
|
||||
setting. Final-view story links use the same committed extraction rules as W4b. Archive links are historical
|
||||
and excluded from this current-story view. No new agent grant, tool or conversation gate.
|
||||
|
||||
`connections.test.mjs` covers forward/backlinks across both formats, mixed-book identity
|
||||
preservation through conversion, current committed saves, scope/format labels, exclusions,
|
||||
archive/old-path refusal, unresolved targets and denial without metadata leaks. The HTTP/UI
|
||||
suite in `writing-connections.test.mjs` covers authentication, access revocation, response
|
||||
version binding, stale refresh rejection and refusal rendering. The browser fixture suite
|
||||
covers keyboard/focus, Links/Backlinks empty states, labels, cross-format navigation and
|
||||
refresh after save/publication. Fixtures only, never Melanie's text or a live stack.
|
||||
|
||||
### Out of scope
|
||||
|
||||
A node graph, Obsidian graph parity, editing links or story text, agent tools, private-book
|
||||
conversion, changes to REQ-LIB-8 acceptance, Q234's header, mos-1363 or S0 M1–M14.
|
||||
|
||||
### Gate
|
||||
|
||||
Independent Opus 5.5 review of access filtering and the human view, the named fixture suites
|
||||
and G1 on Sage's slot. Evidence shows both formats and mixed conversion state without
|
||||
archive or denied-source leakage. Sage records W8 landing before W7's first-real-import
|
||||
opening step per mos-1470; no import acceptance test or PRD condition is added here.
|
||||
|
||||
### Handoffs
|
||||
|
||||
Owner sends pushed commit/blob and fixture evidence directly to the reviewer and Sage via
|
||||
T3. Reviewer sends verdict to owner and Sage. Sage orders W8 landing before the W7 human
|
||||
opening step, and hands the shared files back before further W7 edits. Copy Mos for the
|
||||
first-real-book go or any proposed policy change.
|
||||
|
||||
## Engineering choices from mos-1368
|
||||
|
||||
This table resolves the map's engineering prompts; these are design decisions, not questions
|
||||
@@ -1282,7 +1369,7 @@ Later mos-1372/1375 override A32/A33's per-document/dedicated-session assumption
|
||||
| Q7 / A32/A33 | mos-1372 D9: ordinary Case 2 access in any thread/room, no per-document grant/share/dedicated session; existing supporting writes continue; marked for PRD 1.17 approval |
|
||||
| Q8 / A36 | W4b/W7: preserve Case 2 find/get/search/manuscript/universe tools via host committed-reader adapters, not fewer tools or raw ZIP access |
|
||||
| Q9 / A73 / C2 | W7: D10 manifest order, typed next_chapter, paragraph cursors bound to snapshot/access; both W4 byte cap and harness REPLY_CAP apply, never cut paragraphs or invent line equivalents |
|
||||
| Q10 / A22 | W3/W7 keep clickable cross-format editor links; no DOCX graph yet. Connections view/tests are later work, waiting on Jason's J-b answer; Obsidian archive graph is historical |
|
||||
| Q10 / A22 | W3/W7 keep clickable cross-format editor links; W8 supplies current links/backlinks for visible story files in both formats with scope/format (REQ-LIB-5, Q239); no DOCX graph yet; Obsidian archive graph is historical |
|
||||
| Q11 / A37 | W7: Console archive saves refuse; sync flags later archive edits or recreated old paths and excludes them from current canon; no remote-push refusal/protected-pattern guarantee |
|
||||
| Q12 / A21 | W7: reviewed markdown-it AST + first-party DOCX adapter; no new runtime converter; input cap 1 MiB default, W4 ZIP/XML validation, construct/token/extractor fidelity before archiving |
|
||||
| Q13 | W7 survey: synthetic construct corpus and aggregate-only count capability; no seat reads real text or claims a corpus census happened |
|
||||
@@ -1317,7 +1404,9 @@ renderer does not enable it. No change is imposed on the October27 release.
|
||||
|
||||
Alignment note: sage-3121 checks this revision against PRD 1.17 d6,
|
||||
SHA-256 `886b93c571f87bfbb2f27ee3113b6800a32d8f89196d95ace33566031bee380a`.
|
||||
Connections graph/tests wait for J-b; new non-old-path Markdown stays canon until import;
|
||||
That historical d6 alignment is superseded for Connections by landed PRD 1.17 at 30e6bb680:
|
||||
REQ-LIB-5/Q239 require W8's list view, ordered before real import under mos-1470.
|
||||
New non-old-path Markdown stays canon until import;
|
||||
archive folder is human-set in the library mapping with no default.
|
||||
|
||||
## Current verification and remaining gates
|
||||
@@ -1332,4 +1421,5 @@ append-only logs. No build, browser run, conversion, queue entry or live change
|
||||
Jason's format and CSP choices are settled by Q234–Q237. No question repeats Q1 or Q36.
|
||||
Before real conversion: Sage's Case 2 PRD amendment (Vikunja 570) needs Jason's approval;
|
||||
W4b/W7, Hermes-retired and post-10-27 gates must pass; Jason must explicitly give the first real Melanie-book
|
||||
conversion go. These are later authorization gates, not requests to convert anything now.
|
||||
conversion go. W8 lands before the human opening step under mos-1470 as build order, not
|
||||
an acceptance condition. These are later authorization gates, not requests to convert anything now.
|
||||
@@ -66,7 +66,11 @@ A Markdown file recreated at a converted old path is unmapped, neither canon nor
|
||||
excluded from lookups and flagged to the human. A new Markdown story at a different path in
|
||||
a converted book stays canon until import, flagged and offered for human import; no automatic
|
||||
conversion. Existing playground scope rules remain. Clickable DOCX editor links stay; no
|
||||
DOCX graph exists yet. A Connections graph and its tests wait for Jason's J-b answer.
|
||||
DOCX graph exists yet. PRD 1.17 REQ-LIB-5 and Q239 require the Console's Connections view
|
||||
to list the current links and backlinks of each story file the user may see, in both
|
||||
formats, with each one's scope and format. Parent design W8 owns the view and its tests.
|
||||
Mos-1470 orders its landing before W7's first-real-import opening step as build order,
|
||||
not an additional REQ-LIB-8 acceptance condition.
|
||||
|
||||
Every conversion runs the pinned extractor before archiving and refuses unsupported output.
|
||||
An independently reviewed importer preserves every wiki token and front-matter key.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# PRD 1.17 brief: Case 2 story files are DOCX
|
||||
|
||||
Sage, 2026-10-23, for mos-1366 item 1, mos-1467 and mos-1470. This follows
|
||||
`docs/plans/BRIEF-TEMPLATE.md`. Gitea #1815, Vikunja 570.
|
||||
|
||||
## PRD 1.17 Case 2 story files are DOCX (Q235 to Q237, Q239)
|
||||
|
||||
### Problem
|
||||
|
||||
Jason's Q235 to Q237 make Case 2 story files DOCX, with Markdown kept for notes
|
||||
and docs. PRD 1.16 still says every story file is Markdown in the user's
|
||||
repository, seen in Obsidian. The 1.17 text is approved: Jason approved d8 at
|
||||
435a7458d in Q239 with O2, which adds the Console Connections view (mark J-b).
|
||||
d9 carries that view in Mos's mos-1443 R3 text. Webby approved d9 in rounds 7
|
||||
and 8 (webby-0214, webby-0217), and Mos passed the J-b check in mos-1470.
|
||||
|
||||
The approved text is `agents/sage/work/prd-1-17/DRAFT-d9.md` at 4607e15b8, blob
|
||||
sha256 02f28decad7f946f. Its base is PRD 1.16, `docs/prd/mosaic-stack.md` blob
|
||||
a6db9b4a at 482e865ac (row 291).
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Gosalyn.
|
||||
- Reviewer: Webby.
|
||||
- After: row 291 (PRD 1.16).
|
||||
|
||||
### Files owned
|
||||
|
||||
The four files in DRAFT-d9.md's "Files the row would own", as it lists them:
|
||||
- `docs/prd/mosaic-stack.md`.
|
||||
- `packages/tasks/requirements.json`: version 1.17, the titles of REQ-LIB-1,
|
||||
REQ-LIB-2 and REQ-LIB-4, and the new ids REQ-LIB-8 and REQ-LIB-9 after
|
||||
REQ-LIB-7 and before REQ-INST-1.
|
||||
- `packages/tasks/tests/requirements.test.mjs`: the version pin 1.17, the count
|
||||
87 (:21), and the duplicate-id message `requirements[87].id: REQ-ROLE-1 twice`
|
||||
(:77).
|
||||
- `packages/bus/tests/requirements.test.mjs`: the list length 87 (:48).
|
||||
|
||||
### What ships
|
||||
|
||||
- Every "Replace", "Add, after", "becomes" and "New" instruction in DRAFT-d9.md's
|
||||
PRD sections, applied to the 1.16 text word for word. Each quoted old text
|
||||
occurs once in a6db9b4a, except "The Console shows each cite's scope.", which
|
||||
d9 scopes to REQ-LIB-2. Webby checked those counts in round 6.
|
||||
- The 1.17 header, Status line, source lines and notes as d9 writes them.
|
||||
- requirements.json as d9's requirements.json note says: REQ-LIB-1, REQ-LIB-2 and
|
||||
REQ-LIB-4 take the new titles, REQ-LIB-5 and REQ-LIB-6 keep theirs, and
|
||||
REQ-LIB-8 and REQ-LIB-9 are added with the titles d9 gives, so the count is 87.
|
||||
REQ-LIB-4's title is 117 characters, under the 120 limit in `requirements.mjs`
|
||||
(mos-1443 R2).
|
||||
- Nothing from d9's review sections goes into the PRD. That covers "Settled
|
||||
points", the "d2 changes" through "d9 changes" lists, "What d5 adds", "Marked
|
||||
for Jason" and the impact map notes.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- The build rows for Vikunja 570 and 571, which Sage files after the landing.
|
||||
- The queue changes in the impact map (A24, A57 to A59), which go through
|
||||
`scripts/mosaic queue` (C9).
|
||||
- Gyro's design edit R3.4 at 767bb62c7.
|
||||
- Mos's build-order call (mos-1470): the Connections view lands before the first
|
||||
real chapter import opens. That belongs to Gyro's 571 rows, not the PRD.
|
||||
|
||||
### Gate
|
||||
|
||||
- Webby checks the candidate's PRD diff against DRAFT-d9.md, one instruction at a
|
||||
time, and checks that nothing else changed.
|
||||
- No new Jason question. Q239 approved the text, and a change beyond d9 would
|
||||
need a new version and his approval.
|
||||
- On Sage's gate:
|
||||
- tasks, run whole;
|
||||
- bus, run whole, because the broker loads requirements.json;
|
||||
- cli `stack-copy.test.mjs`;
|
||||
- every `scripts/test-*.sh`, under `timeout -k 10`.
|
||||
- Mos gets the landing commit (mos-1470).
|
||||
|
||||
### Handoffs
|
||||
|
||||
Every seat hands off directly (DECISIONS Q210, mos-1221). When the owner pushes a
|
||||
round or the reviewer posts a verdict, that seat messages the next seat's T3
|
||||
thread and Sage's with `mcp__t3__t3_send_message`. Mos has one step here, which
|
||||
is receiving the landing commit.
|
||||
@@ -664,3 +664,17 @@ collection never checks it again and the count reads as known with 0. The row 30
|
||||
behavior as it stands, so the move didn't change it. Bound to a follow-up row on row 270's
|
||||
store-unreadable path. The fix keeps the unreadable state until the header reads clean, or re-checks the
|
||||
header on every collection. Either way it's an old-test change and goes to Mos.
|
||||
|
||||
## Row306 N1: the revoke Set doesn't survive a broker restart
|
||||
|
||||
Morgana, row 306 BUILD.md (#1813, landed 2d0318a44; Dewey agrees, dewey-0918). `#accessRevoked` lives in
|
||||
memory. After a broker restart, recover() rebinds an earlier host's run, and if that run makes a bus call
|
||||
before the launcher's SIGTERM, it answers `not-holder` and the run exits 22, as every revoked run did before
|
||||
306. Seeding the Set at a rebind needs a mark only `#revokeLost` writes; `reason` and `by` on the ended
|
||||
claim aren't one (design D1's alternative). Bound to the next row that changes the broker's recover() path.
|
||||
|
||||
## Row306 N2: a pending limit stop answers before the revoke
|
||||
|
||||
Morgana, row 306 BUILD.md (#1813, landed 2d0318a44). A revoked run that also has a pending limit stop gets
|
||||
`#limitStop`'s `not-holder` on `message.receive` and `state`, because that check runs before `#agent`. It's
|
||||
rare and the run still stops. Bound to the next row that changes `#limitStop` or the order of those checks.
|
||||
+15
-4
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 3588 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3636 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -288,7 +288,7 @@ Generated from `docs/plans/queue.json` revision 3588 by `queue render`. Do not e
|
||||
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
|
||||
| 250 | K12 (cohort.test.mjs) reads cgroup.events after cgroup.kill under load: print the shim's reason, then fix the cause at the source (Vikunja #450) | gizmoduck; reviewers filbert | #1755 | done | 197 done | reviewer approves the manifest; conversation and control-board suites whole on Sage's slot (sage) | `docs/plans/2026-10-21_k12-cohort-events.md` § Brief: K12 reads cgroup.events after cgroup.kill under load | LANDED 1b6158ce2 on refactor 19:42Z: candidate c1a858975 on 3b0aef995, manifest eca0b8e2, Filbert APPROVE rev 2957 (#1755 c30130). patch-id 29786caa = candidate; blob diff empty; tree 59f1e4781 = merge-tree. Gate on Sage slot sage-2109 (gizmoduck-0150): P1/P2/M1 as predicted, conversation whole 3x 183/183 plus concurrent pair, control-board 124/124. No drift on paths since base. |
|
||||
| 251 | T27 (host-run.test.mjs:453) lists no identities right after ssh-add -t 1: print timing and agent state, then fix the cause at the source (Vikunja #453) | darkwing; reviewers dewey | #1756 | done | 197 done | reviewer approves the manifest; bus suite whole on Sage's slot (sage) | `docs/plans/2026-10-21_t27-ssh-add-lifetime.md` § Brief: T27 lists no identities right after ssh-add -t 1 | LANDED d37210288 on b79ad5f2c: candidate 27f01ef63 manifest f2bdd684, patch-id 440d2442c976 equal, blob diff empty, tree matches merge-tree. Dewey r2 approve rev 2996 (c30168). Gate: bus suite whole in a clean worktree, 627/627 at 21:30:33Z on Sage's slot. Overlap check vs 194/198/244/253/254 branches: none. DEFERRED sleep-orphan entry follows as a doc commit. |
|
||||
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | scrooge; reviewers webby | — | in-progress | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | mos-1193. Chained on row 196's candidate (Q177): design starts now on Gander's row 196 build; code rebases onto 196's landing. Vikunja #435. |
|
||||
| 252 | Console inbox Approve and Deny for grant requests (#435, J2) | scrooge; reviewers webby | #1753 | done | — | Webby approves DESIGN.md then manifest; webui, bus, cli suites and every test-*.sh green on Sage's gate; DESIGN mutants killed; REQ-WEB-2 PRD amendment landed first (sage) | `docs/plans/2026-10-21_console-inbox-approve.md` § Gate | Landed 45ea3fa0d (replay of Scrooge 580037105 onto 30e6bb680, range-diff all =, 10 commits). Webby APPROVE c31229 rev 3612. Sage gate on 45ea3fa0d: every package suite 0 fail 0 skipped (bus 907, cli 943, webui 410), every scripts/test-*.sh rc 0, J1-J19 all killed except J11c and J18 (equivalent per BUILD.md), all restores match. Old-test numstat 1 1 vs fe4ee8c7b held. |
|
||||
| 253 | F27: case2-unit.sh and case1-accept.sh move to --tester-socket and refuse --tester-port with stack-release's migration wording; no .53/.54 copy with 196 until this lands (mos-1198 item 4) | gander; reviewers duckworth | #1758 | done | 196 done | reviewer approves DESIGN.md then the manifest; cli and webui suites whole and every scripts/test-*.sh on Sage's slot; mutants per DESIGN.md; static check covers scripts and tests (sage) | `docs/plans/2026-10-21_tester-socket-scripts.md` § Brief: case2-unit.sh and case1-accept.sh move to --tester-socket (F27) | Landed 48e450ce8 on refactor (fast-forward from aca16404c, 22 commits; landed sha = candidate, so range-diff and patch-id are identical by construction). Manifest 591020a1 (7 paths) rechecked against the commit: 0 mismatches. Gate: run 5 whole, cli 55 files, webui 58 files, all 10 scripts/test-*.sh green; M1-M40 killed 40/40. Duckworth APPROVE round 1, Gitea #1758 comment 30696. |
|
||||
| 254 | Library sync follow-up: client timeout, STATUS codes, README (222 deferrals, #1759) | megavolt; reviewers dewey | #1759 | done | 196 done | Dewey approves the manifest; bus, library and webui suites whole and every scripts/test-*.sh green on Sage's slot; the timeout and STATUS mutants killed (sage) | `docs/plans/2026-10-21_library-sync-followup.md` § Gate | Landed 331597709 (candidate b31300d79 blobs and modes on 41b0cf755; patch-id 96216216 equal, range-diff differs only in the message) plus doc eb9672c91 (Dewey N1 README sentence closed; N2 four 502 codes to DEFERRED, binding the Console library routes row). Merged-tree rerun on Sage's slot 23:44-23:55Z: library 77/0, bus 628/0, webui 305/0 with 1 skip, cli 749/0. Megavolt's gate megavolt-0102 covers scripts and both mutants. |
|
||||
| 255 | Bundled Pocket ID for users with no OIDC provider, design (Q203, #1760) | launchpad; reviewers webby | — | queued | 252 done | Webby approves DESIGN.md; design only, build rows follow after Sage checks it against Q203, O6, REQ-WEB-2 and REQ-INST-1 (sage) | `docs/plans/2026-10-21_bundled-pocket-id.md` § Gate | Vikunja 458, Gitea #1760, mos-1202 item 2. REQ-INST-2 lands in PRD 1.10 as post-v1 (mos-1204). Design only: no install, no download, no .53/.54, dragon-lin keeps Authentik. |
|
||||
@@ -327,7 +327,7 @@ Generated from `docs/plans/queue.json` revision 3588 by `queue render`. Do not e
|
||||
| 288 | Case 2 C2-02 agent writes, the notice, revert and her Console saves | honker; reviewers webby | #1793 | briefed | 215 done, 282 done, 283 done, 284 done, 285 done, 286 done | Webby approves the manifest. Library, bus and harness suites and every scripts/test-*.sh green on Sage's gate, mutants killed. A search of the run output for the fixture tokens finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-02 agent writes, the notice, revert and her Console saves | — |
|
||||
| 289 | Case 2 C2-11b Console library pages and her saves (Haiku subagent, decision 129) | researcher; reviewers filbert | #1794 | briefed | 214 done, 288 done | Filbert approves the manifest. The webui suite and every scripts/test-*.sh are green on Sage's gate. Grep for hash pins on bus.js and serve.mjs before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11b Console library pages and her saves | — |
|
||||
| 290 | Case 2 C2-12b the setup sheet rewrite for free chat, supporting folders and saves | louie; reviewers webby | #1795 | briefed | 217 done, 218 done, 285 done, 291 done | Doc only. Webby approves it against the ruling. Before landing, grep packages/*/tests and scripts/test-*.sh to confirm no test reads the file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12b the setup sheet rewrite for free chat, supporting folders and saves | — |
|
||||
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | in-review, round 1, request posted | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | — |
|
||||
| 291 | PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | gosalyn; reviewers webby | #1796 | done | 282 done | Webby approves the text. Jason says yes to the exact text (Mos asks him, mos-1328 step 3); no landing before that. requirements.test.mjs and every scripts/test-*.sh that reads the PRD or requirements.json are green on Sage's gate. Mos gets the landing commit. (sage) | `docs/plans/2026-10-22_prd-1-16-brief.md` § PRD 1.16 library.write routes under REQ-LIB-6 (mos-1328) | Landed 4874d1e83 on e909ef51a: 482e865ac replayed, patch-id equal (cef7de74), range-diff =. Webby APPROVE c30848 on 482e865ac; Jason approved the text in Q229. Gate 16:15Z on the tip tree: every package suite but queue, 0 fail 0 skipped (tasks 100, bus 891, cli 941, plugins 119, webui 392, business 231), every scripts/test-*.sh rc 0 (queue-root and row 244 skips only). PRD 1.16. 1.17 is row 308. |
|
||||
| 292 | stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | gizmoduck; reviewers duckworth | #1797 | done | 203 done, 270 done, 271 done | Duckworth approves the manifest. The touched test files run whole, the cli suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_config-checks-any-key-brief.md` § stack-release.sh config_checks runs the target loader on any config (row 270 rollback hazard) | Landed 6aab979e6 (cherry-pick of c902a767d..90be6cccd, range-diff all =, patch-id a45c9ba4 equal). Tested head 0d0021454: stack-release.test 224/224, cli suite r2 red only compactions-launch:229 session-invalid 22 (idle-stop race, Vikunja 588, row 302), r3 rerun 910/910 under mos-1411, ten shells green, M1-M9 killed. Duckworth APPROVE rev 3433 on 2fa7ae17. |
|
||||
| 293 | 574: maintenance-free test fixture commits (Vikunja 574) | gander; reviewers filbert | #1805 | done | — | Filbert approves. The four whole files, then the seat and cli suites, are green on Sage's gate. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: 574, maintenance-free test fixture commits (Vikunja 574) | LANDED e51d11640 03:55Z 10-23 (cherry-pick of gander b23783f39..a9cae2bd6 onto 464010fbe; range-diff 6/6 equal, range patch-id equal). Filbert APPROVE c30982 cdda41f5; run 2 4 files 153/0, seat 202/0, cli 905/0, 312 flagged/0 maintenance children. N1: lifecycle.test needs TMPDIR of 50 bytes or less. Residual: queue store.mjs, discord git.mjs, harness tools.mjs unflagged. #1805 closed c31013, P19 574 c888. |
|
||||
| 294 | FED R6: Console granted-user sign-in, user-cli transport, peer links | launchpad; reviewers duckworth | #1800 | done | — | Duckworth approves the manifest. The webui, cli and bus suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-23_mvp-readiness-rows.md` § Brief: FED R6, Console granted-user sign-in | landed d3059c2c3 on refactor 07:04Z: cherry-pick of c902a767d..24b745447 onto 09d0d6faf, range-diff 11 =, patch-id 8371405a equal. Duckworth approve c31053 rev 3469 on a1ff5d67, C1/C2 met. Run 4 on 8616b9a4f from npm ci: webui 365, cli 909, bus 873, 10 scripts, M1-M11 killed. Merged tree: stack-release-external 25, stack-upgrade 21, stack-release 223/224 (R9 transient, row 1810) then 224/224 rerun under mos-1422. Follow-ups rows 300, 301. Closes #1800. |
|
||||
@@ -342,8 +342,19 @@ Generated from `docs/plans/queue.json` revision 3588 by `queue render`. Do not e
|
||||
| 303 | R9 shows its output when rehearse makes no directory (Vikunja 589) | launchpad; reviewers duckworth | #1810 | done | 294 done | Duckworth approves the diff; stack-release.test.mjs whole and green on Sage's gate, in a gap (sage) | `docs/plans/2026-10-23_r9-guard-row.md` § Brief: R9 shows its output when rehearse makes no directory (Vikunja 589) | Landed e551cd846 from launchpad 88ffecaf7, patch-id 559c802b equal, test blob e936808b matches the manifest. Gate: Duckworth approve c31079; Sage gate on c13c337e1 plus the test blob, stack-release.test.mjs whole 224/0 rc 0 in 595 s, alnum mktemp TMPDIR, default networks 6 before and 6 after. No overlap with in-review 272, 304, 305. |
|
||||
| 304 | claude-session test builds the memory slug the way the CLI does (Vikunja 590) | rocko; reviewers dewey | #1811 | done | — | Dewey approves the diff; claude-session.test.mjs whole and green 3x (mktemp, underscore, space TMPDIRs) on Sage's gate, in a gap; old-regex mutant red at :298 (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: claude-session test builds the memory slug the way the CLI does (Vikunja 590) | Landed a863015a5 from rocko 5e8354ebb, range-diff equal, patch-id 808fa765. Gate: Dewey approve c31129 rev 3533; Sage gate on e551cd846 with file 7690a4a9, claude 2.1.309: claude-session.test.mjs whole 10/0 under mktemp, underscore and space TMPDIRs; old-regex mutant under underscore 9/1 at :298. Gates may drop the alnum TMPDIR rule (mos-1432 ends here). |
|
||||
| 305 | Shell suites remove the compose network they create (Vikunja 591) | gizmoduck; reviewers filbert | #1812 | done | — | Filbert approves the diff; test-release.sh and test-task.sh whole and green on Sage's gate, in a gap, project network absent after each run; dropped-rm mutant red (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: shell suites remove the compose network they create (Vikunja 591) | Landed 353d65f57..d52d9f1f2 from gizmoduck 541dee2b7..7e8218a2e, range-diff all equal, on top of filbert 2c289f6f0. Gate: Filbert approve c31127 rev 3534; Sage gate on e551cd846 with task 7d6fbf67, release b29850ce: test-task 92/0, test-release 15/0, network check OK; mutants a and b red in both, c a known survivor; own mutant networks removed, none left. Gates stop the manual network rm (mos-1438 ends here). |
|
||||
| 306 | A revoked run's late bus call stops it instead of exiting 22 (295 N1) | morgana; reviewers dewey | #1813 | briefed | 272 done | Dewey approves the design, then the manifest; bus, harness and cli suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: a revoked run's late bus call stops it instead of exiting 22 (295 N1) | — |
|
||||
| 306 | A revoked run's late bus call stops it instead of exiting 22 (295 N1) | morgana; reviewers dewey | #1813 | done | 272 done | Dewey approves the design, then the manifest; bus, harness and cli suites and every scripts/test-*.sh green on Sage's gate (sage) | `docs/plans/2026-10-23_gate-env-rows.md` § Brief: a revoked run's late bus call stops it instead of exiting 22 (295 N1) | Landed 2d0318a44 on 4874d1e83: 33d3844a0..683833205 replayed, 4 patch-ids equal (a1960f36 55fadbc3 af1a3d88 35cf81a3), range-diff all =. Dewey APPROVE c31206 rev 3596 on 683833205. access-revoke.test numstat 7 7 (mos-1463). Sage gate 16:33Z: every package suite 0 fail 0 skipped (bus 894, cli 942, plugins 119, webui 392, business 231, harness 151), every scripts/test-*.sh rc 0, M1-M3 each killed on every named file, restores equal. N1/N2 to DEFERRED next. |
|
||||
| 307 | Row 270's pi compaction reading moves behind the harness kind (REQ-PLUG-2, Vikunja 598) | gizmoduck; reviewers duckworth | #1814 | done | — | Duckworth approves the design, then the manifest; bus, cli, plugins (0 failures), webui and business suites, every scripts/test-*.sh green and the mutants killed on Sage's gate (sage) | `docs/plans/2026-10-23_harness-kind-compactions.md` § Brief: row 270's pi compaction reading moves behind the harness kind (Vikunja 598) | LANDED d243f6b25: 6 row commits replayed onto 8dc132bec, every non-log patch-id equal, candidate paths = 6fdfe3790 (manifest 17c9f8d9). Duckworth APPROVE rev 3586 c31189 round 1, C2 met. Sage gate: bus, cli 0 skipped, plugins fully green incl harness.test.mjs:192, webui, business, seat, every scripts/test-*.sh; M1-M4 killed. P19 598 commented. DEFERRED: 307 N1 store-unreadable not sticky. N3 doc-only BUILD.md red list one short, next BUILD.md change. |
|
||||
| 308 | PRD 1.17 Case 2 story files are DOCX (Q235 to Q237, Q239) | gosalyn; reviewers webby | #1815 | done | 291 done | Webby checks the PRD diff against DRAFT-d9.md at 4607e15b8 one instruction at a time and that nothing else changed. No new Jason question (Q239 approved the text). tasks whole, bus whole, cli stack-copy.test and every scripts/test-*.sh green on Sage's gate. Mos gets the landing commit (mos-1470). (sage) | `docs/plans/2026-10-23_prd-1-17-brief.md` § PRD 1.17 Case 2 story files are DOCX (Q235 to Q237, Q239) | Landed 30e6bb680: Gosalyn 45fe7b3ce replayed onto 7defb58e9, patch-id 4658c831 equal, range-diff =, four blobs equal. Webby r1 APPROVE c31224 rev 3606, d9 at 4607e15b8 applied word for word. Sage gate 17:19Z: every package suite 0 fail 0 skipped (tasks 100, bus 894, cli 942, plugins 119, webui 392), every scripts/test-*.sh rc 0. Mos gets the sha (mos-1470). 570/571 build rows next. |
|
||||
| 309 | 571 W1: reviewed local thesaurus data (post-10-27) | unassigned | — | queued | — | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W1 reviewed local thesaurus data | — |
|
||||
| 310 | 571 W2: WordCraft source import and reproducible restricted web build (post-10-27) | unassigned | — | queued | — | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W2 WordCraft source import and reproducible restricted web build | — |
|
||||
| 311 | 571 W3a: bus pins and human-only editor settings (post-10-27) | unassigned | — | queued | — | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W3a bus pins and human-only editor settings | — |
|
||||
| 312 | 571 W3b: human DOCX store and instant commits (post-10-27) | unassigned | — | queued | 311 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W3b human DOCX store and instant commits | — |
|
||||
| 313 | 571 W6: owned browser bridge and editor-only route (post-10-27) | unassigned | — | queued | 309 done, 310 done, 312 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W6 owned browser bridge and editor-only route | — |
|
||||
| 314 | 571 W3: full editor pane, navigation, pins and right-click synonyms (post-10-27) | unassigned | — | queued | 311 done, 312 done, 313 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W3 full editor pane, navigation, pins and right-click synonyms | — |
|
||||
| 315 | 571 W4b: committed reader, citation resolver and typed tools (post-10-27) | unassigned | — | queued | 314 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W4b committed reader, citation resolver and typed tools | — |
|
||||
| 316 | 571 W5: read-only flags, citations and chat suggestions (post-10-27) | unassigned | — | queued | 315 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W5 read-only flags, citations and chat suggestions | — |
|
||||
| 317 | 571 W7: human one-way chapter import and Case 2 format routing, production import closed; the first real import opens only after W8 lands (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W7 human one-way chapter import and Case 2 format routing | — |
|
||||
| 318 | 571 W8: Console Connections view for Markdown and DOCX story files, before W7's first real import (post-10-27, mos-1470) | unassigned | — | queued | 314 done, 315 done, 317 done | Independent Opus 5.5 reviewer, not the author, approves the manifest; the brief section's Gate and G1 pass on Sage's slot: touched test files whole, affected package suites green, named mutants killed. Post-10-27; MVP rows keep slot priority (sage) | `docs/plans/2026-10-21_q195-writing-editor.md` § W8 Console Connections view for Markdown and DOCX story files | fenton-0017 carry: connections.test.mjs must test that links inside comments and tracked deletions are excluded. W8 follows W7's closed routing candidate and lands before W7's first real import opens (mos-1470) |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+1828
-23
File diff suppressed because it is too large.
Load diff
+399
-44
@@ -1,6 +1,6 @@
|
||||
# PRD: Mosaic Stack
|
||||
|
||||
- Status: approved, version 1.15. Jason approved draft 0.4 unchanged as
|
||||
- Status: approved, version 1.17. Jason approved draft 0.4 unchanged as
|
||||
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
|
||||
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
|
||||
on 2026-10-14. They approved version 1.2, the chief of staff, the
|
||||
@@ -44,7 +44,44 @@
|
||||
as 1.11 (mos-1233, mos-1238, mos-1241, vikunja:19/464). Version 1.15
|
||||
rewrites REQ-LIB-6 to Jason's Q223 and Q226 rulings and adds supporting
|
||||
folders to REQ-LIB-1 and REQ-LIB-2. Mos ordered it in mos-1320 item 8,
|
||||
and Jason gets a report.
|
||||
and Jason gets a report. Version 1.16 routes the new action
|
||||
`library.write` under REQ-LIB-6 (REQ-WAIT-3). A change to the hard waits
|
||||
needs Jason's approval under REQ-WAIT-2, so Mos puts the text to him as
|
||||
written, and it lands only after his yes (mos-1328, mos-1329). Version
|
||||
1.17 records Jason's rulings Q235 to Q237 of 2026-10-23: Case 2 story
|
||||
files are DOCX, Markdown stays for agent files and documentation, and a
|
||||
human-started import converts a Markdown story file one way. It also
|
||||
adds REQ-LIB-9, the Console's DOCX editor, from Jason's rulings Q195,
|
||||
Q226 and Q234. It changes what Case 2 requirements allow, so Mos puts
|
||||
the text to Jason as written, and it lands only after his yes (mos-1366,
|
||||
Vikunja 570).
|
||||
- Version 1.17 source: Jason's Q235, answering Q36: 'How about we see a
|
||||
markdown file, we convert the markdown file to docx, then archive or
|
||||
mark the markdown file as converted. The new standard for files in the
|
||||
creating writing use case is to use docx to handle versioning and
|
||||
WYSIWYG. We eliminate the potential for confusion based on the files.
|
||||
Agents must understand which files are what format and where they live.
|
||||
The system must allow for import and conversion. We will not run the
|
||||
systems in parallel. There will be an irreversible conversion of the
|
||||
files.' His Q236 says: 'For creative writing chapters, we need to
|
||||
utilize docx as the standard. Markdown is acceptable for agent files,
|
||||
instructions, PRDs, documentation, etc. The creative writing files
|
||||
should be docx for familiarity for the user.' His Q237 says 'Q38: It is
|
||||
correct for now (subject to future change)', which confirms Mos's
|
||||
reading P1 to P6. REQ-LIB-9 rests on his Q195 request for 'a full
|
||||
WYSIWYG editor within Mosaic Stack', his Q226 answer 'F41: We want to
|
||||
move editing into the WYSIWYG in the WebUI. All changes are instantly
|
||||
committed and available to the agents.' and his Q234 answer 'Q37: yes'
|
||||
to the exact security header for the editor's frame. The storage follows
|
||||
the O2 design D10 to D12 as rewritten under Vikunja 571, at b99ca86b9 on
|
||||
`refactor`.
|
||||
- Version 1.16 source: Webby's review webby-0164 B2, from Scrooge's row
|
||||
285. Version 1.15 lets creative agents write in supporting folders, but
|
||||
the write action stayed a hard wait under REQ-WAIT-3, and REQ-WAIT-2
|
||||
refuses every role file that grants one. Mos ruled in mos-1328 and
|
||||
mos-1329 that the routing is its own version. The bounds come from
|
||||
REQ-LIB-6 and the Case 2 design's ruling after r11, f6e3e7f20 on
|
||||
`gosalyn/case2-design`.
|
||||
- Version 1.15 source: Jason's Q223, answering Q29: "The agents are not
|
||||
allowed to write the prose into the writing projects. Agents are
|
||||
allowed to CRUD character sheets, info summaries, cross-link info, and
|
||||
@@ -706,6 +743,14 @@ Added in version 1.5 (Q167).
|
||||
`host.sudo` was added as a hard wait and routes under REQ-CRED-4 since
|
||||
version 1.8.
|
||||
|
||||
`library.write` was added as a hard wait and routes under REQ-LIB-6
|
||||
since version 1.16. It is granted within-role to the six creative roles
|
||||
only, and only for Markdown files under the supporting folders a human
|
||||
marks. It never writes a story file. A write also needs the writing
|
||||
agent's own live Gitea grant on the library project. Each write is one
|
||||
commit by that agent's own Gitea account with a trailer naming the
|
||||
agent, and the user sees a notice for it and can revert it.
|
||||
|
||||
### Messages
|
||||
|
||||
- **REQ-MSG-1.** Messages are addressed to a role. They are append-only
|
||||
@@ -1178,7 +1223,8 @@ design `docs/plans/2026-10-21_t1-sessions.md`, 2c843d190 on `huey/t1-sessions-de
|
||||
Added in version 1.12 (Q176 Case 2, Q211). Drafted from the Case 2
|
||||
design r11, fb20a61b4 on `gosalyn/case2-design`, approved by Webby in
|
||||
82f50d127, with the rulings recorded after r11 up to 624212842. The
|
||||
library is the user's own writing repository.
|
||||
library is the user's own writing repository plus the stack's writing
|
||||
store, which holds the DOCX story files (REQ-LIB-8, version 1.17).
|
||||
|
||||
- **REQ-LIB-1.** A library project maps the user's writing repository
|
||||
onto universes, books, entity types such as character and place, the
|
||||
@@ -1187,9 +1233,12 @@ library is the user's own writing repository.
|
||||
human changes it, and an agent's organization write that changes it
|
||||
is refused. So
|
||||
`universe.book.{character,place,...}` reads as universe, book folder,
|
||||
type folder. (Design 1.1 and M9. Q176 Case 2 "Data is stored in
|
||||
little-endian organizational scheme". Version 1.15: supporting
|
||||
folders.)
|
||||
type folder. The mapping also names one archive folder, set by the
|
||||
human, where converted Markdown story files go. A book's DOCX story
|
||||
files are in the stack's writing store, under the same universe and book
|
||||
(REQ-LIB-8). (Design 1.1 and M9. Q176 Case 2 "Data is stored in
|
||||
little-endian organizational scheme". Version 1.15: supporting folders.
|
||||
Version 1.17: the archive folder and the writing store.)
|
||||
- **REQ-LIB-2.** Playgrounds are never confused with canon. A playground
|
||||
has one of three levels, set by where its prefix sits. A book-level
|
||||
prefix lies inside one of its universe's book folders, and its files
|
||||
@@ -1204,18 +1253,34 @@ library is the user's own writing repository.
|
||||
its own universe, when two prefixes overlap, when a draft has books or
|
||||
no prefix, and when a hybrid names a draft as a source. Supporting
|
||||
prefixes, the folders agents may write in (REQ-LIB-6), follow the same
|
||||
placement rules. A file's scope comes from its path alone, prefixes
|
||||
first: a Markdown file under a supporting prefix is supporting, one
|
||||
under a playground prefix at any level is playground, any other
|
||||
Markdown file in a book folder is canon, and everything else is
|
||||
unmapped and never returned. A folder in both lists is supporting.
|
||||
Lookups return canon unless the caller asks for playground or
|
||||
supporting. A citation marked as canon must name a canon file, so an
|
||||
agent can't cite a playground or supporting file as canon. The Console
|
||||
shows each cite's scope. (Design 1.2, 5.2 and 7.4, the playground
|
||||
ruling, 51fdfb7e2, and the ruling after r11. Q176 Case 2, the
|
||||
playground items, TARGET.md:92-94. Version 1.14: the levels and the
|
||||
draft. Version 1.15: supporting.)
|
||||
placement rules. A Markdown file's scope comes from its path and from
|
||||
past imports, prefixes first: a Markdown file under the archive folder
|
||||
is archived, one under a supporting prefix is supporting, a Markdown
|
||||
file at a converted file's old path is unmapped under REQ-LIB-8 but
|
||||
flagged in the format map, one under a playground prefix at any level is
|
||||
playground, any other Markdown file in a book folder is canon, and
|
||||
everything else is unmapped and never returned. A folder in both lists
|
||||
is supporting. The archive folder follows the same placement rules and
|
||||
may not overlap another prefix or a book folder. An archived file keeps
|
||||
the path it had under the archive folder, and its universe and book are
|
||||
those of that old path. A DOCX document in the writing store keeps its
|
||||
source's scope: the universe, the book if there is one, and canon or
|
||||
playground. Its source is the Markdown file it was converted from or,
|
||||
for one the human starts in the editor, the universe and book or the
|
||||
playground the human picks (REQ-LIB-8). A playground DOCX follows the
|
||||
playground rules and is never returned as canon. It stays playground
|
||||
after its draft universe is made real. In version 1.17 no promotion or
|
||||
mapping change makes a playground DOCX canon. A later version of this
|
||||
PRD says how one becomes canon. Lookups return canon unless the caller
|
||||
asks for playground, supporting or archived. Every answer marks an
|
||||
archived file as archived and names the DOCX that replaced it. A
|
||||
citation marked as canon must name a canon file, so an agent can't cite
|
||||
a playground, supporting or archived file as canon. The Console shows
|
||||
each cite's scope and format, and whether a cited Markdown file was
|
||||
converted. (Design 1.2, 5.2 and 7.4, the playground ruling, 51fdfb7e2,
|
||||
and the ruling after r11. Q176 Case 2, the playground items,
|
||||
TARGET.md:92-94. Version 1.14: the levels and the draft. Version 1.15:
|
||||
supporting. Version 1.17: archived scope and DOCX scope.)
|
||||
- **REQ-LIB-3.** Universes stay separate. Every lookup names one universe,
|
||||
and its results never leave it. A draft universe (REQ-LIB-2) counts as
|
||||
a universe here. It has no canon, so a lookup in a draft asks for
|
||||
@@ -1231,23 +1296,62 @@ library is the user's own writing repository.
|
||||
A pin doesn't stop the change. The continuity agent reads the human's other
|
||||
constraints and cites them. (Design 1.4 and 3. Q176 Case 2, the
|
||||
crossover items.)
|
||||
- **REQ-LIB-4.** Git holds every change. The user commits and pushes with
|
||||
their own tools, today Obsidian with obsidian-git. The bus keeps a
|
||||
- **REQ-LIB-4.** Git holds every change. The user commits and pushes
|
||||
Markdown with their own tools, today Obsidian with obsidian-git. A
|
||||
converted story file's current text is no longer in that repository,
|
||||
which keeps only its archived Markdown (REQ-LIB-8). The bus keeps a
|
||||
mirror of the remote, fetched with a read-only token every 60 seconds
|
||||
and before any lookup when the last fetch is older than that. It only
|
||||
fast-forwards. A force-push is held as `library-diverged` until a human
|
||||
accepts it. Agents read only the mirror's served commit and never hold
|
||||
a copy of their own, so they see the user's push within about a minute.
|
||||
Bus writes and pushes come in later rows (C2-02, C2-03b). (Design D5,
|
||||
D6, 7.1, 7.2 and 7.5. Q176 Case 2 "Git is used for all changes -- local
|
||||
and remote" and the sync items.)
|
||||
accepts it. Agents read the user's repository only at the mirror's
|
||||
served commit, except a story file during its import under REQ-LIB-8.
|
||||
They read a DOCX story file only at a committed version in the writing
|
||||
store, under REQ-LIB-5. They never hold a copy of their own, so they see
|
||||
the user's Markdown push within about a minute and a DOCX save at their
|
||||
next lookup after the editor reports it as committed. Bus writes and
|
||||
pushes come in later rows (C2-02, C2-03b). A DOCX story file is saved in
|
||||
the Console's editor, and each accepted save is one commit by the human
|
||||
in the stack's writing store (REQ-LIB-9). Each writing-store commit is
|
||||
also pushed to a remote off the stack's host, never by force-push. The
|
||||
remote is a setting only a human changes, and it starts unset. A
|
||||
repository on the stack's host never counts as that remote. A missing
|
||||
remote or a failed push never undoes a save, and the user sees each push
|
||||
that is pending or failed. Only the stack's host pushes to that remote,
|
||||
and no agent can reach it with any credential. Agents read the local
|
||||
commit. (Design D5, D6, 7.1, 7.2 and 7.5. Q176 Case 2 "Git is used for
|
||||
all changes -- local and remote" and the sync items. Version 1.17, with
|
||||
Q222 for the remote setting.)
|
||||
- **REQ-LIB-5.** Agents check the library through typed lookup tools:
|
||||
find, get, search, manuscript and universe. Each answer names the
|
||||
commit it read. Get returns a file's links out and links in, so the
|
||||
connections are visible to agents. The user sees them in Obsidian's
|
||||
graph and backlinks. (Design 3 and 7.1. Q176 Case 2 "The agents have
|
||||
tools to check character, place, and other data", the visualization
|
||||
item and "Connections are always visible".)
|
||||
find, get, search, manuscript and universe. Each answer names the commit
|
||||
it read. Each answer also names the file's format and where it lives:
|
||||
its path, or its document id in the writing store. A DOCX story file is
|
||||
read from its latest committed save, never from unsaved text. A lookup
|
||||
that starts after the editor reports a save as committed returns that
|
||||
save or a later one, even while its push to the remote is pending or has
|
||||
failed, and the answer names the version. Get, search and manuscript
|
||||
read a DOCX story file as its extracted text with DOCX locators, never
|
||||
as lines. That text is the file with its tracked changes accepted. An
|
||||
agent reads a story file's comments, or its text with tracked deletions
|
||||
shown, only after a human turns on the matching setting. Both settings
|
||||
are off by default, and only a human changes them. The reader in this
|
||||
version can't read some things a DOCX story file can hold, such as a web
|
||||
link, a text box, a field or hidden text. The editor warns the human
|
||||
when an edit adds one, and the human can still save it. Until a later
|
||||
save takes it out, lookups give agents none of the text of the part that
|
||||
holds it, whether that is the chapter's body, a note or a comment. The
|
||||
manuscript lookup follows the book's chapter order across both formats
|
||||
(O2 design D10), and the next chapter it names is never an archived
|
||||
file. Links out and in resolve across both formats. The universe lookup
|
||||
returns the format map (REQ-LIB-8). Get returns a file's links out and
|
||||
links in, so the connections are visible to agents. The user sees a
|
||||
Markdown file's links in Obsidian's graph and backlinks. A DOCX story
|
||||
file's wikilinks stay clickable in the Console's editor (O2 design D11),
|
||||
and no graph shows them yet. The Console's Connections view lists the
|
||||
current links and backlinks of each story file the user may see, in both
|
||||
formats, with each one's scope and format. (Design 3 and 7.1. Q176 Case
|
||||
2 "The agents have tools to check character, place, and other data", the
|
||||
visualization item and "Connections are always visible". Version 1.17,
|
||||
with Q222 for the two settings and Q239 for the Connections view.)
|
||||
- **REQ-LIB-6.** The creative roles are ideas, research, proofreader,
|
||||
fact-checker and continuity, plus a companion role that the user's own
|
||||
named agents run as. Agents never write the story. A creative agent may
|
||||
@@ -1255,22 +1359,37 @@ library is the user's own writing repository.
|
||||
folders a human marks (REQ-LIB-1), such as character sheets, summaries,
|
||||
cross-link notes and research notes. Every other path in the user's
|
||||
repository is read-only to it, chapters and unmarked folders included,
|
||||
and the broker refuses such a write before any git call. Each agent
|
||||
write is one commit by that agent's own Gitea account, with a trailer
|
||||
naming the agent. The user sees a notice for it and can revert it. Its
|
||||
tools are the lookups, a message to the human and the two write tools,
|
||||
and research and companion also fetch web pages through the broker.
|
||||
Agents talk with the user in free text. A reply may cite lines of the
|
||||
repository at a commit or pages the agent fetched, and the broker
|
||||
checks every citation before it stores the message. A setting,
|
||||
`requireCitation`, makes every reply carry a citation, except the
|
||||
runner's fixed notices for no answer, a failed turn, no answer passing
|
||||
the citation check and an answer too long to send. Only the human
|
||||
changes it, and it is off by default. The user sees only stored
|
||||
and the broker refuses such a write before any git call. The writing
|
||||
store and the archive folder are read-only to a creative agent as well,
|
||||
and the broker's path check treats the archive folder as story. No agent
|
||||
creates, edits, deletes, converts, archives or restores a story file in
|
||||
any format, and no agent writes a DOCX. No creative agent starts or
|
||||
undoes a conversion, and none asks for one through a tool or a decision
|
||||
it raises, as REQ-LIB-8 says. An agent may still suggest a conversion to
|
||||
the human in chat. Each creative role's contract states which files are
|
||||
DOCX and which are Markdown, and cites a DOCX story file by its locator,
|
||||
never by line. Each agent write is one commit by that agent's own Gitea
|
||||
account, with a trailer naming the agent. The user sees a notice for it
|
||||
and can revert it. Its tools are the lookups, a message to the human and
|
||||
the two write tools, and research and companion also fetch web pages
|
||||
through the broker. The write tools need the action `library.write`,
|
||||
which routes under this requirement within the bounds REQ-WAIT-3 names
|
||||
(version 1.16). Agents talk with the user in free text. A reply may cite
|
||||
lines of a Markdown file at a commit, a place in a committed DOCX story
|
||||
file, or pages the agent fetched, and the broker checks every citation
|
||||
before it stores the message. A DOCX citation never calls a paragraph a
|
||||
line. It names the document id, the committed version and a DOCX
|
||||
locator. A citation that names an archived file as current is refused.
|
||||
The `requireCitation` setting below applies to Markdown and DOCX
|
||||
citations alike, and so does the rule for a citation marked as canon. A
|
||||
setting, `requireCitation`, makes every reply carry a citation, except
|
||||
the runner's fixed notices for no answer, a failed turn, no answer
|
||||
passing the citation check and an answer too long to send. Only the
|
||||
human changes it, and it is off by default. The user sees only stored
|
||||
messages, on every channel. (Design 4, 5.2 and the ruling after r11.
|
||||
Q223 and Q226. Q180 for Bean and Chet. Q176 Case 2, the agent list,
|
||||
"User sets strict guardrails for agent capabilities" and TARGET.md:101
|
||||
"Agents NEVER write prose for the user". Version 1.15.)
|
||||
"Agents NEVER write prose for the user". Version 1.15. Version 1.17.)
|
||||
- **REQ-LIB-7.** The user talks to several creative agents in one chat. A
|
||||
room holds one thread per creative agent in a project. A message goes
|
||||
to every agent in the room, or only to the agents named by @tags at its
|
||||
@@ -1278,6 +1397,110 @@ library is the user's own writing repository.
|
||||
the threads as one timeline, and each agent sees only its own thread.
|
||||
(Design D4 and 6, row C2-09b. Q176 Case 2 "converse with N agents in
|
||||
one chat session with @tagged messages".)
|
||||
- **REQ-LIB-8.** Story files become DOCX. DOCX is the standard for chapters and
|
||||
the other story text in a Case 2 writing project, canon and playground alike. A
|
||||
story file becomes DOCX through a human's import (below), or when the human
|
||||
starts it in the editor. From then on DOCX is its only working format, edited in
|
||||
the Console's editor (REQ-LIB-9). A Markdown story file stays Markdown until its
|
||||
import. Files in a type folder (REQ-LIB-1), such as character and place notes,
|
||||
stay Markdown. So do supporting files, agent files, instructions, PRDs and
|
||||
documentation, including the character sheets, summaries and cross-links agents
|
||||
keep under REQ-LIB-6.
|
||||
- A converted story file's current text leaves the user's Gitea-mirrored
|
||||
repository. Its DOCX lives in the stack's writing store, on the stack's host,
|
||||
and not in the repository the user pushes or opens in Obsidian. Only its
|
||||
archived Markdown stays there.
|
||||
- An import converts one Markdown story file, canon or playground, outside every
|
||||
type folder (REQ-LIB-1), to a DOCX document in the writing store. It refuses
|
||||
an unmapped, supporting, archived or type-folder file, and a file that is
|
||||
already converted. Each import has its own fidelity report, the human's
|
||||
confirmation of that report, a manifest naming the source file and its commit,
|
||||
and one commit in the store. The DOCX keeps every wiki link of the source as
|
||||
written, and a lost link to another library file fails the fidelity report. A
|
||||
web link becomes plain text that keeps its label and address, and the fidelity
|
||||
report lists it for the human to confirm. A web link that can't be kept safely
|
||||
as plain text refuses the import.
|
||||
- The human can also start a new story file in the editor, in a universe and
|
||||
book or a playground the human picks. It is a DOCX document in the writing
|
||||
store from its first commit, with no Markdown copy, and it opens under the
|
||||
same gate as the import.
|
||||
- The same import marks the Markdown file converted, naming the document, and
|
||||
moves it to the archive folder under its old path (REQ-LIB-1, REQ-LIB-2).
|
||||
The mark and the move are one commit authored by the human in the user's
|
||||
repository. A new human-only bus step (Vikunja 571) on the C2-02 write
|
||||
plumbing writes it, and C2-03b pushes it. The move keeps the bytes and the
|
||||
git history, so it isn't `data.delete`.
|
||||
- The store commit comes first, and the writing store's remote must confirm it
|
||||
holds that commit. Then come the archive commit and its push, then publication
|
||||
to the lookups. Until publication the Markdown at its old path keeps its canon
|
||||
or playground scope. Agents read that file at the commit the import started
|
||||
from, even after the mirror's served commit reaches the archive commit or a
|
||||
later one. No lookup returns the file's archived copy before publication, even
|
||||
one that asks for archived files. A refusal or failure before the archive push
|
||||
leaves the Markdown unmoved and no DOCX that a lookup returns. A failure after
|
||||
the archive push leaves a pending import that the human sees and can resume,
|
||||
never a claim that nothing moved. An archive push whose result is unknown
|
||||
counts as a failure after the archive push until the stack reads the result
|
||||
back from the user's remote.
|
||||
- Each stage of an import writes a logged event. The import's record on the
|
||||
stack's host names the source file's path, commit and blob and the new
|
||||
document id, and each event points to that record.
|
||||
- After a conversion, a Markdown file at the converted file's old path is
|
||||
neither canon nor playground. Lookups leave it out, the format map flags it,
|
||||
and the human is told.
|
||||
- A new Markdown story file in a book folder that holds converted files stays
|
||||
canon until its import, unless it sits at a converted file's old path or under
|
||||
a playground prefix. The Console flags it and offers the import. Nothing
|
||||
converts it on its own.
|
||||
- A hybrid's exclusion that covers a converted file's old path also covers its
|
||||
DOCX document (REQ-LIB-3). An exclusion of a whole book folder also covers
|
||||
every DOCX document in that book, including one started in the editor. An
|
||||
exclusion can also name a DOCX document by its id.
|
||||
- Only a human starts an import or a new story file, from the CLI or a Console
|
||||
session signed in as that human. Both are human verbs outside the agent action
|
||||
vocabulary. Neither is added to `ACTIONS` in the business vocabulary or to the
|
||||
broker's always-gated list, including the import's action, `library.convert`.
|
||||
No role holds them, no agent can request them, and the hard-wait list in
|
||||
REQ-WAIT-1 doesn't change. In an organization with several humans, who may
|
||||
start them is a setting only a human changes, and the default is the owner
|
||||
only.
|
||||
- Conversion is one way, with no parallel run. Neither the Console nor any agent
|
||||
edits a converted story file as Markdown, and no DOCX edit syncs back to
|
||||
Markdown. The Console's Markdown save refuses every file under the archive
|
||||
folder. The user's own tools can still change the archived Markdown, and no
|
||||
such change reaches the DOCX. The old Markdown stays in git history, and
|
||||
nothing rewrites history. A promotion into a book folder (C2-19) runs the
|
||||
import for a Markdown story file or is refused. A promotion of a playground
|
||||
DOCX is refused in version 1.17.
|
||||
- Agents get a format map: for each mapped folder and story file, its format,
|
||||
scope and where it lives, archived files, flagged files and pending imports
|
||||
included.
|
||||
- The import and new story files ship closed. They open only after four things.
|
||||
The editor ships. The human has set the writing store's remote, and the stack
|
||||
has checked that it can push there. Agents can read committed DOCX story files
|
||||
through the lookups and cite them (REQ-LIB-5, REQ-LIB-6). Any earlier agent
|
||||
system that writes the user's files has stopped, which for Case 2 is the
|
||||
Hermes retirement (#423 step 5). So the import itself never cuts agents off
|
||||
from a chapter, the chapter's current text never lives only on the stack's
|
||||
host, and no second system writes the story. The first import of a real user's
|
||||
books waits for Jason's go.
|
||||
(Q235, Q236, Q237: P1 to P6. Q222 for the setting. O2 design D10 to D12.
|
||||
Version 1.17.)
|
||||
- **REQ-LIB-9.** The Console's DOCX editor. The human edits a DOCX story file in
|
||||
the Console's editor, in a split pane with the agent chat alongside it. The
|
||||
human can pin an agent chat to a document. The human can select a word,
|
||||
right-click it, and choose a synonym from a thesaurus. Only a human saves. Saves
|
||||
happen on their own while the human types (Q226), at intervals only a human
|
||||
changes, and each accepted save is one commit with the human as author, pushed
|
||||
as REQ-LIB-4 says. A save made against an older version than the current one is
|
||||
refused. Agents never write a DOCX. Versioning is the editor's tracked changes
|
||||
plus one git commit per save. The editor's code is vendored and pinned, with a
|
||||
hash per file and the licenses that its independent security review accepted. At
|
||||
runtime the editor loads only files that the stack itself serves, and it
|
||||
requests nothing from any other host. The editor's frame sends exactly the
|
||||
content security policy Jason approved in Q234, and the WebUI shell's own header
|
||||
is unchanged. REQ-PROJ-3's project docs editor is unchanged. (Q195, Q226, Q234,
|
||||
Q236. O2 design D3, D4, D6, D8, D10, D13, D14, D16 and D22. Version 1.17.)
|
||||
|
||||
### Installer
|
||||
|
||||
@@ -1750,6 +1973,118 @@ latest-by date and a hard target (Q176).
|
||||
ruling after r11 and in the Q226 briefs of
|
||||
`docs/plans/2026-10-20_case2-rows.md`. Sage files the rows under
|
||||
Vikunja 416. This amendment adds the requirements only.
|
||||
- Version 1.16:
|
||||
- REQ-WAIT-3 gains one paragraph, the `library.write` routing and its
|
||||
bounds. REQ-LIB-6 gains one sentence naming the action. No other
|
||||
requirement text changes.
|
||||
- `packages/tasks/requirements.json` changes its version to 1.16 and
|
||||
nothing else. The titles stay and the id count stays 85.
|
||||
- The commit that adds `library.write` to `ROUTED` in the business
|
||||
vocabulary and the broker is C2-08b, row 285. It lands only after this
|
||||
version.
|
||||
- Version 1.17:
|
||||
- Jason confirmed the reading 'for now (subject to future change)' (Q237). The
|
||||
rulings stand until he revises them.
|
||||
- The 10-27 Case 2 release ships on Markdown as built (P6). The 1.11 line 'The
|
||||
Case 2 MVP for 10-27 is the Markdown library (#416). The WYSIWYG editor (Q195,
|
||||
Q196) is an add-on that doesn't gate it.' still holds. Conversion opens after
|
||||
10-27, and only once REQ-LIB-8's four conditions are met. The editor ships,
|
||||
the writing store's remote is set and the stack has checked that it can push
|
||||
there, agents can read and cite committed DOCX story files through the
|
||||
lookups, and Hermes is retired, which is #423 step 5.
|
||||
- The 1.12 note says five bus write and Console editor rows, C2-02, C2-03b,
|
||||
C2-11a, C2-11b and C2-19, don't gate 10-27. Jason's Q226 F42 later moved
|
||||
C2-02, C2-03b and C2-11b into the 10-27 slice. The Case 2 design brings C2-11a
|
||||
in with them, because C2-11b needs it. C2-19 stays outside the slice. Mos's
|
||||
reading of F42 is that C2-02 and C2-03b now gate 10-27, because design 9.2
|
||||
puts them in 'the smallest slice that lets her start'. C2-11a and C2-11b are
|
||||
slice rows that still don't gate 10-27, because the editor is the Q195 add-on.
|
||||
The DOCX change in 1.17 leaves this slice as it is, and C2-11b's Markdown
|
||||
editor never edits a converted story file.
|
||||
- Q236 narrows Q176's editor choice to DOCX for story files (REQ-LIB-9). The
|
||||
1.12 note that the Case 2 design keeps Obsidian now holds only for Markdown
|
||||
files, including story files not yet imported. A DOCX story file is edited
|
||||
only in the Console's editor under REQ-LIB-9.
|
||||
- This replaces two parts of the O2 design at 25c09f19f. The superseded-notice
|
||||
proposal from Fenton's F1 in D12 is withdrawn, and the
|
||||
keep-Markdown-at-its-path model of D11 is replaced. Gyro's rewrite of the O2
|
||||
design for Vikunja 571, landed on `refactor` at b99ca86b9, carries the
|
||||
details, and where it differs from this text, this text holds:
|
||||
- the import and archive steps, the mark, and the new bus step;
|
||||
- links from supporting files to a moved chapter;
|
||||
- the DOCX reader;
|
||||
- the user's unpushed or later changes to an archived file or at its old path,
|
||||
and pushing and closing the file in Obsidian before each import;
|
||||
- the writing store's remote in W4 section 7.3, which replaces the no-remote
|
||||
and no-backup rule that W4 section 7.4 held before the rewrite, at
|
||||
062646487;
|
||||
- import events counted by a bus restore, and recovery when a bus restore or a
|
||||
restore of the whole host predates an import;
|
||||
- manifest order for the manuscript lookup and its next chapter;
|
||||
- `library.convert` kept out of `ACTIONS`, out of the hard-wait note and out
|
||||
of the pinned `GATED_ONLY` list in packages/business/src/vocabulary.mjs.
|
||||
- Agents read a converted story file through the Case 2 lookups under the same
|
||||
project and universe access as Markdown canon, in any thread or room. W4's
|
||||
earlier O2-only conversations, per-document grants and per-send sharing don't
|
||||
gate these lookups. The 571 rewrite replaced them in W4 sections 7.1 and 7.2.
|
||||
The creative tool list in REQ-LIB-6 doesn't change.
|
||||
- The six creative role contracts row 213 landed say 'Every item cites the
|
||||
lines of the user's material it is about'. A build row under 571 changes that
|
||||
line for DOCX story files (REQ-LIB-6).
|
||||
- A new Markdown file in a book folder is still canon in 1.17. The 1.17
|
||||
exception is a file at a converted file's old path, which REQ-LIB-2 makes
|
||||
unmapped.
|
||||
- Story files convert whether canon or playground. Type-folder and supporting
|
||||
files stay Markdown.
|
||||
- Measured 2026-10-23 at about 01:21Z: the Case 2 user's library repository
|
||||
tracks 213 files, 203 of them Markdown, and none is a .docx or .doc. It has no
|
||||
untracked files. The user's .doc files (Q196) are outside it. So the landed
|
||||
code has no DOCX there to report as unreadable at 10-27.
|
||||
- Text replaced in 1.17, kept here as in earlier notes:
|
||||
- Creative library lead, the 1.16 sentence: "The library is the user's own
|
||||
writing repository."
|
||||
- REQ-LIB-2, the 1.16 sentences: "A file's scope comes from its path alone,
|
||||
prefixes first: a Markdown file under a supporting prefix is supporting,
|
||||
one under a playground prefix at any level is playground, any other
|
||||
Markdown file in a book folder is canon, and everything else is unmapped
|
||||
and never returned. A folder in both lists is supporting." "Lookups return
|
||||
canon unless the caller asks for playground or supporting." "so an agent
|
||||
can't cite a playground or supporting file as canon" "The Console shows
|
||||
each cite's scope."
|
||||
- REQ-LIB-4, the 1.16 sentences: "The user commits and pushes with their own
|
||||
tools, today Obsidian with obsidian-git." "Agents read only the mirror's
|
||||
served commit and never hold a copy of their own, so they see the user's
|
||||
push within about a minute."
|
||||
- REQ-LIB-5, the 1.16 sentence: "The user sees them in Obsidian's graph and
|
||||
backlinks." A converted chapter's connections show in Obsidian only as they
|
||||
stood at conversion, and the Console's Connections view shows their
|
||||
current links.
|
||||
- REQ-LIB-6, the 1.16 sentence: "A reply may cite lines of the repository at
|
||||
a commit or pages the agent fetched, and the broker checks every citation
|
||||
before it stores the message."
|
||||
- REQ-LIB-1 only gains sentences.
|
||||
- REQ-LIB-3's text and REQ-LIB-7 are unchanged. REQ-LIB-8 adds one form of
|
||||
exclusion REQ-LIB-3 doesn't have: naming a DOCX document by its id. A path
|
||||
exclusion also covers DOCX documents, through a converted file's old path or a
|
||||
whole book folder. An archived file and a DOCX document keep a universe, and a
|
||||
book where their source has one, so every lookup still names one universe.
|
||||
REQ-PROJ-3, the Console editor for a project's spec and docs, is unchanged.
|
||||
C2-11b opens the library's Markdown files in that same editor.
|
||||
- In `packages/tasks/requirements.json` the titles of REQ-LIB-1, REQ-LIB-2 and
|
||||
REQ-LIB-4 change, and those of REQ-LIB-5 and REQ-LIB-6 stay. REQ-LIB-1 becomes
|
||||
'A human-set library maps the user's repository onto universes, books, types,
|
||||
playgrounds, supporting and archive folders'. REQ-LIB-2 becomes 'Scope comes
|
||||
from the path or a DOCX's source, lookups default to canon, and only canon
|
||||
files are ever cited as canon'. REQ-LIB-4 becomes 'Git holds every change. The
|
||||
bus fast-forwards a read-only mirror every 60 seconds, the one place agents
|
||||
read Markdown'. REQ-LIB-8 and REQ-LIB-9 add two ids, so the count goes from 85
|
||||
to 87. REQ-LIB-8 is titled 'Case 2 story files become DOCX by a human-only
|
||||
one-way import or in the editor; Markdown stays for notes and docs', and
|
||||
REQ-LIB-9 'The Console's DOCX editor: only a human saves, each save is one
|
||||
commit, and the editor code is vendored and pinned'.
|
||||
- Open points O1 to O4 of draft 1 were ruled by Mos in mos-1367 B1 to B4.
|
||||
- Sage files the build rows under Vikunja 570 and 571 after the rewrite is
|
||||
approved. This amendment adds the requirements only.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
@@ -1788,6 +2123,26 @@ latest-by date and a hard target (Q176).
|
||||
seat sandbox with its own network namespace closes it. Until then the
|
||||
host's own audit log is the check after the fact (row 210 design section
|
||||
6).
|
||||
- A DOCX file is a zip archive, so git can't show a line diff of a chapter.
|
||||
Review of a story change goes through the editor and the committed reader,
|
||||
not `git diff`. (Version 1.17.)
|
||||
- A converted chapter's DOCX is not in Obsidian's graph. Obsidian shows the
|
||||
chapter only through its archived Markdown, and no DOCX edit reaches that file's
|
||||
links. The DOCX's current links show in the Console's editor and its
|
||||
Connections view, and no graph shows them yet. (Version 1.17.)
|
||||
- Conversion can't be undone in place. Front matter, wiki link forms and
|
||||
unsupported blocks may not survive it, and the mirror can't refuse the user's
|
||||
push to an archived file. The controls are the fidelity report, the human's
|
||||
confirmation, an untouched original on refusal, and the kept archive.
|
||||
(Version 1.17.)
|
||||
- A restore of the stack's bus alone from a backup taken before an import drops
|
||||
the import's logged events, while the archive commit and the DOCX stay. A
|
||||
restore of the stack's whole host from such a backup also takes the writing
|
||||
store and the host's import records back to before the import. The archive
|
||||
commit stays in the user's Gitea repository and the DOCX stays on the writing
|
||||
store's remote, but the host keeps no record of the import. A bus restore counts
|
||||
import events, and the import's recovery covers both cases (Vikunja 571).
|
||||
(Version 1.17.)
|
||||
|
||||
## Milestones
|
||||
|
||||
|
||||
+314
-37
@@ -46,6 +46,8 @@ export const ACTIONS = Object.freeze([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
// PRD 1.5 REQ-WAIT-3 (row 139, #1633): every action is a hard wait unless listed here, so a new
|
||||
// action stays one until a reviewed commit names it routed. Matches the business vocabulary's ROUTED.
|
||||
@@ -75,6 +77,8 @@ const ROUTED = new Set([
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write.
|
||||
'library.write',
|
||||
]);
|
||||
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
@@ -96,7 +100,16 @@ export const TASK_PAGE_MAX = 200;
|
||||
// request allowed past the ordinary 64 KiB envelope (server.mjs and client.mjs), up to its content cap.
|
||||
export const PROJECT_FILE_VERBS = Object.freeze(['project.files', 'project.file', 'project.file.save']);
|
||||
// C2-03a: library.sync is the human's fetch, and with `accept` the one way past library-diverged.
|
||||
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync']);
|
||||
// Row 224: pure agent lookups are separate from the three human-only verbs. Listing them
|
||||
// here also makes server.mjs await their host effects instead of serializing a Promise as {}.
|
||||
const LIBRARY_LOOKUPS = Object.freeze({
|
||||
'library.find': { method: 'find', fields: ['universe', 'query', 'type', 'book', 'scope', 'offset'], required: ['universe', 'query'] },
|
||||
'library.get': { method: 'get', fields: ['universe', 'path', 'scope', 'from', 'links_from'], required: ['universe', 'path'] },
|
||||
'library.search': { method: 'search', fields: ['universe', 'text', 'book', 'scope', 'offset'], required: ['universe', 'text'] },
|
||||
'library.manuscript': { method: 'manuscript', fields: ['universe', 'book', 'scope', 'chapter', 'from', 'to'], required: ['universe', 'book'] },
|
||||
'library.universe': { method: 'universeInfo', fields: ['universe'], required: ['universe'] },
|
||||
});
|
||||
export const LIBRARY_VERBS = Object.freeze(['library.file', 'library.status', 'library.sync', 'library.file.save', 'library.revert', 'library.doc.write', 'library.doc.delete', ...Object.keys(LIBRARY_LOOKUPS)]);
|
||||
export const PROJECT_SAVE_VERB = 'project.file.save';
|
||||
export const PROJECT_SAVE_REQUEST_MAX = 1024 * 1024 + 64 * 1024;
|
||||
export const WEB_VERBS =Object.freeze(['web.search', 'web.fetch']);
|
||||
@@ -137,6 +150,9 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
// collaborator grant, so a viewer gets access-refused on each.
|
||||
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']);
|
||||
// Row 275 (193b1b, #1780, row 193 design 4.3 and 9): the user verbs a viewer grant reaches. They
|
||||
// read, so a live grant at any level covers them. thread.create, thread.open and thread.send need a
|
||||
// collaborator grant on the project and instance, so a viewer gets access-refused on each.
|
||||
@@ -256,6 +272,9 @@ const grantName = (body) => [body?.action, body?.operation].some(n => typeof n =
|
||||
// Every grant-evidence read filters on this (duckworth-0067 N1): a look-alike under another kind or
|
||||
// actor is not evidence.
|
||||
export const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'";
|
||||
// C2-03b: what a library-fetch-no-grant refusal tells the human (sage-2928). A BusError carries only its
|
||||
// code, so the timer's log line and the library README carry this text.
|
||||
export const LIBRARY_FETCH_FIX = 'add the chief of staff, or grant the library through C2-21';
|
||||
// r6 R4: the host record's identity, hashed at raise and at each later check.
|
||||
export const registrySha256 = ({ address, port, user, sudo, hostKeys }) =>
|
||||
createHash('sha256').update(JSON.stringify({ address, port, user, sudo: !!sudo, hostKeys })).digest('hex');
|
||||
@@ -1456,6 +1475,26 @@ export class Broker {
|
||||
if (!this.#userGrants(s).length) fail('access-revoked');
|
||||
return this.#cap(s);
|
||||
}
|
||||
// Row 252 (#1753, design section 2): the trusted Console transport invokes this only after
|
||||
// verifyPrincipal checked the console-cli wrapper, its webui `serve` parent and the signed-in owner.
|
||||
// The cap is the business human's with via 'console' and the owner subject. request() lets it reach
|
||||
// CONSOLE_VERBS only, and #human refuses it, so no cli verb accepts it.
|
||||
bindConsole(record) {
|
||||
keys(record, ['business', 'human', 'subject'], ['business', 'human', 'subject']);
|
||||
const b = this.#business(record.business);
|
||||
const x = record.subject;
|
||||
if (record.human !== b.human || !object(x) || Object.keys(x).length !== 3 ||
|
||||
!['provider', 'iss', 'sub'].every((k) => typeof x[k] === 'string' && x[k].length > 0 && x[k].length <= 512))
|
||||
fail('human-required');
|
||||
return this.#cap({
|
||||
business: record.business,
|
||||
human: b.human,
|
||||
via: 'console',
|
||||
console: Object.freeze({ provider: x.provider, iss: x.iss, sub: x.sub }),
|
||||
role: null,
|
||||
run: null,
|
||||
});
|
||||
}
|
||||
bindReader({ business }) {
|
||||
this.#business(business);
|
||||
return this.#cap({ business, reader: true, role: null, run: null, human: false });
|
||||
@@ -1504,10 +1543,61 @@ export class Broker {
|
||||
const raise = this.#raised(business, decision);
|
||||
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
|
||||
const r = this.#closed(decision);
|
||||
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human)
|
||||
return null;
|
||||
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null;
|
||||
// Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input,
|
||||
// which only the broker writes (recordEvent reserves the kind).
|
||||
if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null;
|
||||
return raise.grant;
|
||||
}
|
||||
// Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice,
|
||||
// no role or run, and a three-string owner subject.
|
||||
#consoleInput(business, decision, r) {
|
||||
const e = this.#store.get(
|
||||
`SELECT body FROM events WHERE business=? AND kind='human.input' AND subject=? AND actor_role IS NULL AND actor_run IS NULL
|
||||
AND json_extract(body,'$.via')='console' AND json_extract(body,'$.decisionSeq')=? ORDER BY seq LIMIT 1`,
|
||||
business,
|
||||
decision,
|
||||
r.seq,
|
||||
);
|
||||
const x = e && JSON.parse(e.body);
|
||||
return (
|
||||
!!x &&
|
||||
x.kind === 'answer' &&
|
||||
x.decision === decision &&
|
||||
x.op === 'resolved' &&
|
||||
x.choice === r.choice &&
|
||||
object(x.subject) &&
|
||||
['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0)
|
||||
);
|
||||
}
|
||||
// Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's
|
||||
// check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding
|
||||
// and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is
|
||||
// the other. The summary carries only the binding's method, agent, host and target projects. The
|
||||
// digest binds the id, business, options, approval key and binding, so any change reads stale.
|
||||
#consoleResolution(d) {
|
||||
if (d.route_to !== 'human' || d.action !== 'credential.mint') return null;
|
||||
const raise = this.#raised(d.business, d.id);
|
||||
if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null;
|
||||
const options = JSON.parse(d.options);
|
||||
if (options.length !== 2 || options[0].key === options[1].key) return null;
|
||||
const approve = options.find((o) => o.key === raise.approvalChoice);
|
||||
if (!approve) return null;
|
||||
const deny = options.find((o) => o.key !== raise.approvalChoice);
|
||||
const g = raise.grant;
|
||||
const grant = {
|
||||
method: g.method,
|
||||
...(typeof g.agent === 'string' ? { agent: g.agent } : {}),
|
||||
...(typeof g.host === 'string' ? { host: g.host } : {}),
|
||||
...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}),
|
||||
};
|
||||
return {
|
||||
approve: { key: approve.key, text: approve.text },
|
||||
deny: { key: deny.key, text: deny.text },
|
||||
grant,
|
||||
digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])),
|
||||
};
|
||||
}
|
||||
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
|
||||
// guarded body with `decision` (#436b section 7), so #spent counts it.
|
||||
#consumeGrant(business, decision, method) {
|
||||
@@ -1877,6 +1967,33 @@ export class Broker {
|
||||
// grant.host.enrolled or grant.failed died after its registry step: the key file and the record
|
||||
// go, and it ends grant.failed interrupted. A resolved, unspent one is spent and ends the same way.
|
||||
// Neither runs late.
|
||||
// C2-03b (#1791, design 7.2): the credential the library fetch runs with, the bus's choice and not a
|
||||
// pure one: it reads the store. The chief is any roles entry of definition STAFF_TO; a fire keeps it
|
||||
// there (dewey-0860 Q2). Gitea grant evidence for a chief ends the slot fallback, and only the one
|
||||
// listed chief's live, unheld gitea_grants row answers. With no chief entry, any gitea grant evidence
|
||||
// refuses: a departure from the brief's pre-C2-21 fallback, accepted in sage-2928.
|
||||
libraryFetchCredential(business) {
|
||||
const b = this.#business(business);
|
||||
const chiefs = Object.entries(b.roles ?? {}).filter(([, r]) => r?.definition === STAFF_TO).map(([name]) => name);
|
||||
const evidence = (agent) =>
|
||||
this.#store.get(
|
||||
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.method')='gitea' AND (? IS NULL OR json_extract(body,'$.agent')=?) LIMIT 1`,
|
||||
business,
|
||||
agent,
|
||||
agent,
|
||||
);
|
||||
const refused = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
|
||||
if (!chiefs.length) return evidence(null) ? refused : { kind: 'slot' };
|
||||
if (!chiefs.some((agent) => evidence(agent))) return { kind: 'slot' };
|
||||
const listed = chiefs.filter(
|
||||
(agent) => !this.#firing(business, agent) && (!Array.isArray(b.launch?.instances) || b.launch.instances.includes(agent) || b.launch.by === agent),
|
||||
);
|
||||
if (listed.length !== 1) return refused;
|
||||
const [agent] = listed;
|
||||
const live = this.#store.get('SELECT user FROM gitea_grants WHERE business=? AND agent=?', business, agent);
|
||||
if (!live || !this.#giteaKeys || this.#giteaKeys.held(business, agent)) return refused;
|
||||
return { kind: 'chief', agent, user: live.user };
|
||||
}
|
||||
reconcileGrants() {
|
||||
const ended = [];
|
||||
for (const business of Object.keys(this.#businesses)) {
|
||||
@@ -2552,12 +2669,14 @@ export class Broker {
|
||||
}
|
||||
// Row 236 (#1742, #436b r5 section 2): the agent's role and definition, if the agent is a role of the
|
||||
// business whose definition declares gitea and which has identity and a publish grant (199 D8). A
|
||||
// creative role gets no grant, whatever its definition declares (mos-1163).
|
||||
// creative role gets no grant, whatever its definition declares (mos-1163). Row 285 (#1790, sage-2921):
|
||||
// nor does the chief of staff, whose definition declares gitea for a read grant only, since this path
|
||||
// mints SEAT_SCOPES, write:repository included.
|
||||
#giteaRole(business, agent) {
|
||||
const b = this.#business(business);
|
||||
if (!Object.hasOwn(b.roles, agent)) fail('gitea-not-declared');
|
||||
const role = b.roles[agent];
|
||||
if (CREATIVE_DEFINITIONS.includes(role.definition)) fail('gitea-not-declared');
|
||||
if (CREATIVE_DEFINITIONS.includes(role.definition) || role.definition === STAFF_TO) fail('gitea-not-declared');
|
||||
const definition = Object.hasOwn(b.definitions ?? {}, role.definition) ? b.definitions[role.definition] : null;
|
||||
if (
|
||||
!definition?.credentials?.some((c) => c.service === 'gitea') ||
|
||||
@@ -4551,6 +4670,9 @@ export class Broker {
|
||||
grants = this.#userGrants(s, doc);
|
||||
if (!grants.length) fail('access-revoked');
|
||||
}
|
||||
// Row 252 (design section 2, N4): a console cap reaches the two console verbs only, and nothing
|
||||
// else reaches them. decision.resolve stays off the list.
|
||||
if (!!s.console !== CONSOLE_VERBS.includes(request.verb)) fail('human-required');
|
||||
const args = request.args ?? {};
|
||||
if (!object(args)) fail('invalid-request');
|
||||
this.#secretCheck(args);
|
||||
@@ -5048,31 +5170,147 @@ export class Broker {
|
||||
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
|
||||
}
|
||||
}
|
||||
// C2-02: all authority and credential choice stays on the host. Neither an agent's tool
|
||||
// arguments nor the human's save may supply a credential, identity or commit message.
|
||||
#libraryWriter(s, project) {
|
||||
if (s.tester) fail('tester-refused');
|
||||
const b = this.#business(s.business), role = b.roles?.[s.role];
|
||||
if (s.human || s.reader || !CREATIVE_DEFINITIONS.includes(role?.definition)
|
||||
|| !role.authority?.withinRole?.includes('library.write')) fail('library-write-refused');
|
||||
this.#agent(s);
|
||||
const live = this.#store.get('SELECT user,projects,rotate_by FROM gitea_grants WHERE business=? AND agent=?', s.business, s.role);
|
||||
if (!live || !this.#giteaKeys || this.#giteaKeys.held(s.business, s.role)
|
||||
|| this.#firing(s.business, s.role) || live.rotate_by < this.#now().slice(0, 10)
|
||||
|| !role.publish?.includes(project)) fail('library-write-no-grant');
|
||||
let projects;
|
||||
try { projects = JSON.parse(live.projects); } catch { fail('library-write-no-grant'); }
|
||||
if (!Array.isArray(projects) || !projects.includes(project) || live.user !== role.identity?.gitea?.user)
|
||||
fail('library-write-no-grant');
|
||||
if (!role.identity?.name || !role.identity?.email) fail('library-write-refused');
|
||||
return { identity: { name: role.identity.name, email: role.identity.email },
|
||||
agent: `${s.business}/${s.role}`, credential: { kind: 'seat', agent: s.role, user: live.user } };
|
||||
}
|
||||
#libraryWriteNotice(s, op, result) {
|
||||
const mid = randomUUID();
|
||||
this.#store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
mid, this.#now(), s.business, s.role, s.run, 'human', 'INFO', null, null, null,
|
||||
JSON.stringify({ notice: 'library-write', op, path: result.path, commit: result.commit }));
|
||||
this.#route(s.business, mid, 'human', s.session);
|
||||
}
|
||||
// Row 224 C2-05b: the agent never supplies a host repo, commit or business. Its current
|
||||
// holder and business entry are rechecked before/after awaits; runtime owns Git and queries.
|
||||
async #requestLibraryLookup(s, { verb, args }) {
|
||||
try {
|
||||
this.#agent(s);
|
||||
const business = this.#business(s.business);
|
||||
const projects = Object.entries(business.projects ?? {}).filter(([, entry]) => entry.kind === 'library');
|
||||
if (!projects.length || typeof this.#library?.lookup !== 'function') fail('library-not-configured');
|
||||
const spec = LIBRARY_LOOKUPS[verb];
|
||||
keys(args, ['project', ...spec.fields], spec.required);
|
||||
if (!text(args.universe, 160)) fail('invalid-request');
|
||||
if (args.project === undefined && projects.length !== 1) fail('invalid-request');
|
||||
const project = args.project ?? projects[0][0];
|
||||
if (!text(project, 160) || !projects.some(([name]) => name === project)) fail('library-not-configured');
|
||||
const revision = JSON.stringify(business.projects[project]);
|
||||
const authorize = () => {
|
||||
this.#agent(s);
|
||||
this.#processingGate(s, verb);
|
||||
if (JSON.stringify(this.#business(s.business).projects?.[project]) !== revision) fail('library-not-configured');
|
||||
};
|
||||
const { project: ignored, ...query } = args;
|
||||
authorize();
|
||||
const result = await this.#library.lookup({ business, project }, { method: spec.method, args: query, authorize });
|
||||
authorize();
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const Failure = this.#library?.LibraryError;
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, { verb, args });
|
||||
}
|
||||
}
|
||||
// C2-04 M4: only a verified human reads library text or status. Host Git
|
||||
// functions stay injected, preserving this module's sandbox import closure.
|
||||
async #requestLibrary(s, request) {
|
||||
const { verb, args } = request;
|
||||
if (Object.hasOwn(LIBRARY_LOOKUPS, verb)) return this.#requestLibraryLookup(s, request);
|
||||
try {
|
||||
this.#human(s);
|
||||
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept'] : ['project'];
|
||||
keys(args, fields, verb === 'library.sync' ? ['project'] : fields);
|
||||
if (!this.#library?.readFile || !this.#library?.status) fail('project-not-configured');
|
||||
if (verb === 'library.sync' && !this.#library.sync) fail('project-not-configured');
|
||||
const context = { business: this.#business(s.business), project: args.project };
|
||||
const agent = verb === 'library.doc.write' || verb === 'library.doc.delete';
|
||||
const b = this.#business(s.business);
|
||||
let project = args.project, writer;
|
||||
if (agent) {
|
||||
// The caller gate precedes argument checks, including human and tester refusals.
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (s.human || s.reader || !CREATIVE_DEFINITIONS.includes(b.roles?.[s.role]?.definition)
|
||||
|| !b.roles[s.role].authority?.withinRole?.includes('library.write')) fail('library-write-refused');
|
||||
this.#agent(s);
|
||||
const fields = ['universe', 'path', 'base', ...(verb === 'library.doc.write' ? ['content'] : [])];
|
||||
keys(args, fields, fields);
|
||||
if (typeof args.universe !== 'string') fail('invalid-request');
|
||||
const matches = Object.entries(b.projects ?? {}).filter(([, e]) => e.kind === 'library'
|
||||
&& Object.hasOwn(e.universes ?? {}, args.universe));
|
||||
if (matches.length !== 1) fail('project-not-configured');
|
||||
project = matches[0][0];
|
||||
writer = this.#libraryWriter(s, project);
|
||||
} else {
|
||||
this.#human(s);
|
||||
const fields = verb === 'library.file' ? ['project', 'path'] : verb === 'library.sync' ? ['project', 'accept']
|
||||
: verb === 'library.file.save' ? ['project', 'path', 'base', 'content']
|
||||
: verb === 'library.revert' ? ['project', 'commit'] : ['project'];
|
||||
keys(args, fields, verb === 'library.sync' ? ['project'] : fields);
|
||||
}
|
||||
if (typeof project !== 'string' || b.projects?.[project]?.kind !== 'library') fail('project-not-configured');
|
||||
if (verb === 'library.revert' && (typeof args.commit !== 'string' || !/^[a-f0-9]{40}$/.test(args.commit))) fail('invalid-request');
|
||||
if (agent || verb === 'library.file.save') {
|
||||
if (args.base !== null && (typeof args.base !== 'string' || !/^[a-f0-9]{40}$/.test(args.base))) fail('invalid-request');
|
||||
if (verb !== 'library.doc.delete' && (typeof args.content !== 'string' || args.content.includes('\0')
|
||||
|| Buffer.byteLength(args.content, 'utf8') > 1024 * 1024
|
||||
|| Buffer.from(args.content, 'utf8').toString('utf8') !== args.content)) fail('invalid-request');
|
||||
}
|
||||
const context = { business: b, project };
|
||||
let result;
|
||||
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
|
||||
const method = agent ? 'writeDoc' : verb === 'library.file.save' ? 'saveFile'
|
||||
: verb === 'library.revert' ? 'revert' : verb === 'library.file' ? 'readFile' : verb === 'library.status' ? 'status' : 'sync';
|
||||
if (typeof this.#library?.[method] !== 'function') fail('project-not-configured');
|
||||
if (agent) {
|
||||
const op = verb === 'library.doc.delete' ? 'delete' : 'write';
|
||||
result = await this.#library.writeDoc({ ...context, ...args }, { ...writer, op,
|
||||
beforePush: () => { this.#processingWork(); this.#libraryWriter(s, project); },
|
||||
onPushed: saved => {
|
||||
try {
|
||||
this.#store.transaction(() => {
|
||||
this.#libraryWriteNotice(s, op, saved);
|
||||
this.#event(s, 'action.allowed', { action: 'library.write', class: 'within-role', project, path: saved.path, commit: saved.commit });
|
||||
});
|
||||
} catch {
|
||||
// A separate metadata-only sink survives a Console store failure. Never log args,
|
||||
// file text, credentials or raw SQL/exception output.
|
||||
try { this.#library.log?.({ event: 'library-notice-store-failed', code: 'outcome-unknown',
|
||||
business: s.business, instance: s.role, op, commit: saved.commit }); } catch {}
|
||||
fail('outcome-unknown');
|
||||
}
|
||||
} });
|
||||
} else if (verb === 'library.file.save') {
|
||||
this.#processingWork();
|
||||
result = await this.#library.saveFile({ ...context, path: args.path, base: args.base, content: args.content });
|
||||
} else if (verb === 'library.revert') {
|
||||
this.#processingWork();
|
||||
result = await this.#library.revert({ ...context, commit: args.commit });
|
||||
} else 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 {
|
||||
// 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 });
|
||||
if (code !== null) fail(code);
|
||||
result = { project: args.project, ...state };
|
||||
result = { project, ...state };
|
||||
}
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const Failure = this.#library?.LibraryError;
|
||||
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
|
||||
const mapped = Failure && error instanceof Failure ? new BusError(error.code) : error;
|
||||
const refused = this.#refused(s, mapped, request);
|
||||
// Once a push succeeded the remote effect is uncertain to the caller even if the store
|
||||
// cannot record refusal evidence. Do not replace that with an ordinary storage refusal.
|
||||
throw mapped instanceof BusError && mapped.code === 'outcome-unknown' ? mapped : refused;
|
||||
}
|
||||
}
|
||||
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
|
||||
@@ -5081,6 +5319,7 @@ export class Broker {
|
||||
const s = this.#session(cap);
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -5119,6 +5358,7 @@ export class Broker {
|
||||
let args;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
this.#processingWork();
|
||||
if (!verb) fail('unknown-verb');
|
||||
@@ -5178,6 +5418,7 @@ export class Broker {
|
||||
let a, entry, timeout, bound;
|
||||
try {
|
||||
if (s.user) fail('access-refused');
|
||||
if (s.console) fail('human-required');
|
||||
this.#processingWork();
|
||||
keys(request, ['verb', 'args'], ['verb']);
|
||||
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
|
||||
@@ -5501,15 +5742,38 @@ export class Broker {
|
||||
}
|
||||
return this.#decisionView(this.#decision(s, did));
|
||||
}
|
||||
// Row 252 (#1753, design section 3): the Console's read of one grant request before its
|
||||
// confirmation. It returns the two answers, a safe grant summary and the digest the answer must
|
||||
// carry. Anything not eligible refuses, so the browser never decides eligibility (J1).
|
||||
case 'decision.preview.console': {
|
||||
keys(a, ['id'], ['id']);
|
||||
identifier(a.id);
|
||||
if (!s.console || s.human !== b.human) fail('human-required');
|
||||
const d = this.#decision(s, a.id);
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
const c = this.#consoleResolution(d);
|
||||
if (!c) fail('decision-not-eligible');
|
||||
return { id: d.id, business: d.business, question: d.question, ...c };
|
||||
}
|
||||
case 'decision.resolve.console':
|
||||
case 'decision.resolve':
|
||||
case 'decision.seen':
|
||||
case 'decision.withdraw': {
|
||||
keys(a, ['id', 'choice', 'note'], ['id']);
|
||||
const viaConsole = verb === 'decision.resolve.console';
|
||||
if (viaConsole) keys(a, ['id', 'choice', 'digest'], ['id', 'choice', 'digest']);
|
||||
else keys(a, ['id', 'choice', 'note'], ['id']);
|
||||
identifier(a.id);
|
||||
if (a.note !== undefined) string(a.note);
|
||||
const d = this.#decision(s, a.id);
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
if (verb === 'decision.withdraw') {
|
||||
// Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice
|
||||
// in this transaction. It answers one of the two keys, never a key the browser names (J2).
|
||||
if (viaConsole) {
|
||||
if (!s.console || s.human !== b.human) fail('human-required');
|
||||
const c = this.#consoleResolution(d);
|
||||
if (!c) fail('decision-not-eligible');
|
||||
if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale');
|
||||
} else if (verb === 'decision.withdraw') {
|
||||
if (s.human) {
|
||||
this.#human(s);
|
||||
} else {
|
||||
@@ -5521,32 +5785,39 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
}
|
||||
const op = verb === 'decision.resolve' ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
fail('invalid-choice');
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id,
|
||||
this.#now(),
|
||||
op,
|
||||
by,
|
||||
op === 'resolved' ? a.choice : null,
|
||||
a.note ?? null,
|
||||
s.human ? 'cli' : 'broker',
|
||||
const seq = Number(
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id,
|
||||
this.#now(),
|
||||
op,
|
||||
by,
|
||||
op === 'resolved' ? a.choice : null,
|
||||
a.note ?? null,
|
||||
s.console ? 'console' : s.human ? 'cli' : 'broker',
|
||||
).lastInsertRowid,
|
||||
);
|
||||
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
|
||||
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
|
||||
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
|
||||
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
|
||||
// Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's
|
||||
// record to start the executor. Its body names the owner subject and the close's seq.
|
||||
if (s.human)
|
||||
this.#event(
|
||||
s,
|
||||
'human.input',
|
||||
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||
s.console
|
||||
? { kind: 'answer', decision: d.id, op, choice: a.choice, via: 'console', subject: s.console, decisionSeq: seq }
|
||||
: { kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
|
||||
d.id,
|
||||
);
|
||||
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
|
||||
// transaction commits. It rechecks all of this itself, so a rollback starts nothing. Row 252: a
|
||||
// Console approve does too. A deny closes the decision with the other key and starts nothing.
|
||||
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
|
||||
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
|
||||
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
|
||||
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
|
||||
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
|
||||
// Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the
|
||||
// raiser, definitions change only with a pinned copy and arbiters only through the business
|
||||
@@ -5573,7 +5844,7 @@ export class Broker {
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.human ? 'human' : s.role,
|
||||
s.human ? 'human-cli' : s.run,
|
||||
s.console ? 'human-console' : s.human ? 'human-cli' : s.run,
|
||||
d.raised_by_role,
|
||||
'DECISION',
|
||||
null,
|
||||
@@ -5583,7 +5854,8 @@ export class Broker {
|
||||
);
|
||||
this.#route(s.business, mid, d.raised_by_role, session);
|
||||
}
|
||||
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
|
||||
const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op };
|
||||
return grant ? { ...answer, grant: 'started' } : answer;
|
||||
}
|
||||
case 'message.send': {
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
|
||||
@@ -5939,7 +6211,12 @@ export class Broker {
|
||||
s.business,
|
||||
s.human || s.reader ? 'human' : s.role,
|
||||
)
|
||||
.map((d) => this.#decisionView(d));
|
||||
.map((d) => {
|
||||
// Row 252: a grant request the Console can answer carries its two answers. The Console
|
||||
// shows buttons from this, and the answer path rechecks it at the bus.
|
||||
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
|
||||
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
|
||||
});
|
||||
if (verb === 'agents') {
|
||||
const claims = this.#store.all(
|
||||
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { connect } from 'node:net';
|
||||
import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
export class Client {
|
||||
constructor({ path, cap, human, tester, user, timeout = 5000 }) {
|
||||
constructor({ path, cap, human, tester, user, console: owner, timeout = 5000 }) {
|
||||
this.path = path;
|
||||
this.cap = cap;
|
||||
this.human = human;
|
||||
this.tester = tester;
|
||||
this.user = user;
|
||||
this.console = owner;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
call(verb, args = {}) {
|
||||
const request =
|
||||
JSON.stringify({
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }),
|
||||
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : this.console ? { console: this.console } : { cap: this.cap }),
|
||||
verb,
|
||||
args,
|
||||
}) + '\n';
|
||||
if (Buffer.byteLength(request) > (verb === PROJECT_SAVE_VERB ? PROJECT_SAVE_REQUEST_MAX : 65536))
|
||||
if (Buffer.byteLength(request) > ([PROJECT_SAVE_VERB, 'library.file.save'].includes(verb) ? PROJECT_SAVE_REQUEST_MAX : 65536))
|
||||
return Promise.reject(new BusError('request-too-large'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = connect(this.path);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Row 252 (#1753, design section 2): the Console owner's transport shim, spawned only by the webui
|
||||
// `cli.mjs serve` process for one preview or one answer. Not a second product CLI. The Console sets
|
||||
// the nonce, the business and the signed-in (provider, iss, sub) in the spawn environment, and the
|
||||
// bus reads them back from this process's initial environment. Nothing respawns here, so the
|
||||
// Console stays the direct parent. Input is a verb and its args only: a body that names a business,
|
||||
// a subject or any other key is refused, never merged.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Client } from './client.mjs';
|
||||
import { readProcess } from './human.mjs';
|
||||
import { CONSOLE_VERBS } from './broker.mjs';
|
||||
const env = process.env;
|
||||
try {
|
||||
if (!/^[a-f0-9]{64}$/.test(env.MOSAIC_BUS_CONSOLE_NONCE ?? '')) throw Object.assign(Error(), { code: 'human-required' });
|
||||
const request = JSON.parse(readFileSync(0, 'utf8'));
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).some((k) => !['verb', 'args'].includes(k)) ||
|
||||
!CONSOLE_VERBS.includes(request.verb)
|
||||
)
|
||||
throw Error();
|
||||
const me = readProcess(process.pid);
|
||||
const client = new Client({
|
||||
path: process.argv[2],
|
||||
console: {
|
||||
business: env.MOSAIC_BUS_CONSOLE_BUSINESS,
|
||||
provider: env.MOSAIC_BUS_CONSOLE_PROVIDER,
|
||||
iss: env.MOSAIC_BUS_CONSOLE_ISS,
|
||||
sub: env.MOSAIC_BUS_CONSOLE_SUB,
|
||||
pid: me.pid,
|
||||
startTime: me.startTime,
|
||||
nonce: env.MOSAIC_BUS_CONSOLE_NONCE,
|
||||
},
|
||||
});
|
||||
const result = await client.call(request.verb, request.args ?? {});
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
} catch (e) {
|
||||
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -10,6 +10,10 @@ const MARKERS = [
|
||||
'CODEX_THREAD_ID',
|
||||
'PI_AGENT_DIR',
|
||||
];
|
||||
// Row 252 (#1753, Webby N5): the Console wrapper's nonce and the subject its spawn bound. The
|
||||
// verifier reads each from the wrapper's initial environment, never from its proof alone.
|
||||
export const CONSOLE_ENV = Object.freeze(['MOSAIC_BUS_CONSOLE_NONCE', 'MOSAIC_BUS_CONSOLE_BUSINESS', 'MOSAIC_BUS_CONSOLE_PROVIDER', 'MOSAIC_BUS_CONSOLE_ISS', 'MOSAIC_BUS_CONSOLE_SUB']);
|
||||
const NONCE_KEYS = ['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE', ...CONSOLE_ENV];
|
||||
const refuse = () => {
|
||||
throw new BusError('human-required');
|
||||
};
|
||||
@@ -31,7 +35,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
|
||||
for (const entry of environment.split('\0')) {
|
||||
const i = entry.indexOf('=');
|
||||
const key = entry.slice(0, i);
|
||||
if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
||||
if (NONCE_KEYS.includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
|
||||
}
|
||||
return {
|
||||
pid,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { verifyTester, testerNonce } from './tester.mjs';
|
||||
|
||||
// Trusted transport API. Identity is held only by this connection's transient session.
|
||||
// User mapping and live grant authorization belong to R3, not to this verifier.
|
||||
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) {
|
||||
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
|
||||
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce, consoleOwners = [] }) {
|
||||
const kinds = ['human', 'tester', 'user', 'console'].filter(k => request?.[k]);
|
||||
if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope');
|
||||
const kind = kinds[0], proof = request[kind];
|
||||
const error = kind === 'tester' ? 'tester-required' : 'human-required';
|
||||
@@ -16,6 +16,7 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
|
||||
const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce });
|
||||
return { kind: 'tester', business, id: 'tester' };
|
||||
}
|
||||
if (kind === 'console') return verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners });
|
||||
let checked = proof, parent;
|
||||
if (kind === 'user') {
|
||||
if (!proof || typeof proof.user !== 'string' || !proof.user ||
|
||||
@@ -42,3 +43,38 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
|
||||
if (!id) throw new BusError('unknown-business');
|
||||
return { kind: 'owner', business, id };
|
||||
}
|
||||
|
||||
// Row 252 (#1753, design section 2): the Console owner. The same-tree console-cli.mjs wrapper, its own
|
||||
// nonce namespace and the human walk, with the exact same-tree webui `cli.mjs serve` as its direct
|
||||
// parent, read again after the walk as the user kind does. Business and (provider, iss, sub) must be
|
||||
// the wrapper's initial spawn environment and one of the checked console.sso subjects the runtime
|
||||
// was started with (Webby N1: every configured subject is an owner). The Console's mapping is trusted
|
||||
// as row 196 permits; the bus does not exchange an ID token itself.
|
||||
const SUBJECT_KEYS = ['provider', 'iss', 'sub'];
|
||||
const SUBJECT_ENV = { business: 'MOSAIC_BUS_CONSOLE_BUSINESS', provider: 'MOSAIC_BUS_CONSOLE_PROVIDER', iss: 'MOSAIC_BUS_CONSOLE_ISS', sub: 'MOSAIC_BUS_CONSOLE_SUB' };
|
||||
function verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners }) {
|
||||
const refuse = () => { throw new BusError('human-required'); };
|
||||
if (!proof || typeof proof !== 'object' ||
|
||||
Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', ...SUBJECT_KEYS].includes(k)) ||
|
||||
SUBJECT_KEYS.some(k => typeof proof[k] !== 'string' || !proof[k] || proof[k].length > 512)) refuse();
|
||||
if (typeof paths.consoleCli !== 'string' || typeof paths.console !== 'string') refuse();
|
||||
const { provider, iss, sub, ...checked } = proof;
|
||||
let child, parent;
|
||||
try {
|
||||
child = readProcess(proof.pid);
|
||||
parent = readProcess(child.ppid);
|
||||
if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse();
|
||||
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (child.env?.[name] !== proof[key]) refuse();
|
||||
} catch { refuse(); }
|
||||
const { business } = verifyHuman(checked, { cliPath: paths.consoleCli, launches, readProcess, nonceKey: 'MOSAIC_BUS_CONSOLE_NONCE' });
|
||||
try {
|
||||
const again = readProcess(proof.pid), up = readProcess(parent.pid);
|
||||
if (again.ppid !== parent.pid || again.startTime !== child.startTime || up.startTime !== parent.startTime ||
|
||||
up.ppid !== parent.ppid || up.uid !== parent.uid || up.argv[1] !== parent.argv[1] || up.argv[2] !== 'serve') refuse();
|
||||
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (again.env?.[name] !== proof[key]) refuse();
|
||||
} catch { refuse(); }
|
||||
if (!Array.isArray(consoleOwners) || !consoleOwners.some(o => o && o.provider === provider && o.iss === iss && o.sub === sub)) refuse();
|
||||
const id = businesses[business]?.human;
|
||||
if (!id) throw new BusError('unknown-business');
|
||||
return { kind: 'console', business, id, via: 'console', subject: { provider, iss, sub } };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { Store } from './store.mjs';
|
||||
import * as projectModule from '../../project/src/index.mjs';
|
||||
import * as libraryModule from '../../library/src/index.mjs';
|
||||
import { Broker, BusError } from './broker.mjs';
|
||||
import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs';
|
||||
import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
||||
@@ -24,7 +24,7 @@ import { busBusiness } from './business.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { hostExecutor, sweepRunDir } from './host-run.mjs';
|
||||
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart, giteaReference } from './gitea-grant.mjs';
|
||||
import { verifyPrincipal } from './principal.mjs';
|
||||
import { loadAccess } from '../../business/src/access.mjs';
|
||||
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
|
||||
@@ -87,6 +87,38 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
|
||||
}
|
||||
return (name) => levels.get(name) ?? null;
|
||||
}
|
||||
// Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old
|
||||
// objects, as C2-03a requires; no caller may select a repo, commit or sync --accept.
|
||||
export function createLibraryLookup({ dataRoot, sync, now = () => Date.now() }) {
|
||||
const methods = { find: libraryModule.find, get: libraryModule.get, search: libraryModule.search,
|
||||
manuscript: libraryModule.manuscript, universeInfo: libraryModule.universeInfo };
|
||||
return async (context, { method, args, authorize }) => {
|
||||
if (!Object.hasOwn(methods, method)) throw new libraryModule.LibraryError('query-argument-invalid', 'unknown lookup');
|
||||
authorize();
|
||||
const source = { ...context, dataRoot };
|
||||
let state = await libraryModule.libraryStatus(source);
|
||||
authorize();
|
||||
// sage-2819 / dewey-0852: failed attempts also pace inline retries. Otherwise an
|
||||
// outage makes every lookup refetch while lastFetch remains old or null.
|
||||
const recentAttempt = Math.max(state.lastFetch === null ? -Infinity : Date.parse(state.lastFetch),
|
||||
state.lastError === null ? -Infinity : Date.parse(state.lastError.at));
|
||||
const age = now() - recentAttempt;
|
||||
if (age > libraryModule.SYNC_INTERVAL_MS || age < 0) {
|
||||
try { await sync(source); }
|
||||
catch (error) {
|
||||
// A timer may already be fetching this mirror. Its current served state is still safe.
|
||||
if (!(error instanceof libraryModule.LibraryError) || error.code !== 'library-sync-busy') throw error;
|
||||
}
|
||||
authorize();
|
||||
state = await libraryModule.libraryStatus(source);
|
||||
}
|
||||
if (!state.served) throw new libraryModule.LibraryError('library-commit', 'library has never been synced');
|
||||
authorize();
|
||||
return methods[method]({ repo: libraryModule.mirrorPath(source), commit: state.served,
|
||||
mapping: libraryModule.checkMapping(context.business.projects[context.project]) }, args);
|
||||
};
|
||||
}
|
||||
|
||||
export async function startBroker({
|
||||
dataRoot,
|
||||
businesses,
|
||||
@@ -106,7 +138,15 @@ export async function startBroker({
|
||||
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet. FED R4 (#1801):
|
||||
// intervalMs, the revocation timer's period (design 3.7, 30 s), in-process only like librarySync's.
|
||||
access = null,
|
||||
// Row 252 (#1753, design section 2): the checked console.sso subjects, [{provider, iss, sub}], each an
|
||||
// owner of the business (Webby N1). The boot config carries them; no socket verb sets them. Empty, no
|
||||
// Console owner binds and the terminal owner is unchanged.
|
||||
consoleOwners = [],
|
||||
}) {
|
||||
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
|
||||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
|
||||
throw new BusError('invalid-host-request');
|
||||
const owners = Object.freeze(consoleOwners.map((o) => Object.freeze({ provider: o.provider, iss: o.iss, sub: o.sub })));
|
||||
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'
|
||||
|| (Object.hasOwn(access, 'intervalMs') && !(Number.isSafeInteger(access.intervalMs) && access.intervalMs > 0))))
|
||||
throw new BusError('invalid-host-request');
|
||||
@@ -215,11 +255,26 @@ export async function startBroker({
|
||||
}
|
||||
// C2-03a: the slot is <configDir>/secrets/<business>/library-gitea (design 10 step 4). Sync only
|
||||
// checks that it is there; the askpass helper reads it.
|
||||
// C2-03b: the broker chooses. The chief's grant store file once a chief has Gitea grant evidence,
|
||||
// the slot before that, or a refusal sync records. The answer carries no file to sync but this one.
|
||||
const slotDir = librarySync?.configDir ?? organization?.dir ?? null;
|
||||
const fetchCredential = business => {
|
||||
const choice = broker.libraryFetchCredential(business.id);
|
||||
if (choice.kind === 'chief')
|
||||
return { kind: 'chief', user: choice.user, file: storeRoot ? giteaReference(storeRoot, business.id, choice.agent).file : null };
|
||||
if (choice.kind === 'slot') return { kind: 'slot', file: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null };
|
||||
return { refused: choice.refused };
|
||||
};
|
||||
const syncOptions = business => ({
|
||||
slot: slotDir ? join(slotDir, 'secrets', business.id, libraryModule.SLOT) : null,
|
||||
credential: fetchCredential(business),
|
||||
...(librarySync ? { command: librarySync.command } : {}),
|
||||
});
|
||||
const humanWriteOptions = context => {
|
||||
const mapping = libraryModule.checkMapping(context.business.projects[context.project]);
|
||||
if (!mapping.editor) throw new libraryModule.LibraryError('library-mapping-invalid', 'editor is required');
|
||||
return { credential: libraryModule.humanCredential(context, { user: mapping.editor.user, configDir: slotDir }),
|
||||
...(librarySync ? { command: librarySync.command } : {}) };
|
||||
};
|
||||
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
|
||||
// would load it. The credential references, the project roots the Credentials store protects
|
||||
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
|
||||
@@ -293,9 +348,20 @@ export async function startBroker({
|
||||
store,
|
||||
businesses,
|
||||
library: {
|
||||
log: entry => process.stderr.write(`library: ${JSON.stringify(entry)}\n`),
|
||||
lookup: createLibraryLookup({ dataRoot, sync: context =>
|
||||
processing.work(() => libraryModule.sync(context, syncOptions(context.business))) }),
|
||||
readFile: context => libraryModule.readLibraryFile({ ...context, dataRoot }),
|
||||
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
|
||||
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
|
||||
// C2-02: trusted references only. The askpass helper alone opens a credential file.
|
||||
writeDoc: (context, options) => processing.work(() => libraryModule.writeLibraryDoc({ ...context, dataRoot }, {
|
||||
...options, credential: { ...options.credential,
|
||||
file: storeRoot ? giteaReference(storeRoot, context.business.id, options.credential.agent).file : null },
|
||||
...(librarySync ? { command: librarySync.command } : {}),
|
||||
})),
|
||||
saveFile: context => processing.work(() => libraryModule.saveLibraryFile({ ...context, dataRoot }, humanWriteOptions(context))),
|
||||
revert: context => processing.work(() => libraryModule.revertLibraryCommit({ ...context, dataRoot }, humanWriteOptions(context))),
|
||||
LibraryError: libraryModule.LibraryError,
|
||||
},
|
||||
project: {
|
||||
@@ -408,6 +474,7 @@ export async function startBroker({
|
||||
tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
|
||||
user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)),
|
||||
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
|
||||
consoleCli: fileURLToPath(new URL('./console-cli.mjs', import.meta.url)),
|
||||
};
|
||||
const path = join(store.directory, 'broker.sock');
|
||||
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
|
||||
@@ -439,6 +506,11 @@ export async function startBroker({
|
||||
const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
|
||||
return broker.bindUser({ business: p.business, user: p.id });
|
||||
},
|
||||
// Row 252 (#1753): a fresh proof per connection, a cap for the Console verbs only.
|
||||
authenticateConsole: (proof, peer) => {
|
||||
const p = verifyPrincipal({ console: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords, consoleOwners: owners });
|
||||
return broker.bindConsole({ business: p.business, human: p.id, subject: p.subject });
|
||||
},
|
||||
});
|
||||
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
|
||||
// wait on the network.
|
||||
@@ -551,7 +623,8 @@ export async function startBroker({
|
||||
for (const { business, project } of timed) {
|
||||
try {
|
||||
const { code } = await libraryModule.sync({ business, project, dataRoot }, syncOptions(business));
|
||||
if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code });
|
||||
if (code !== null) syncLog({ event: 'library-sync-held', business: business.id, project, code,
|
||||
...(code === 'library-fetch-no-grant' ? { fix: LIBRARY_FETCH_FIX } : {}) });
|
||||
} catch (e) {
|
||||
if (e?.code === 'library-sync-busy') continue;
|
||||
syncLog({ event: 'library-sync-refused', business: business.id, project,
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX, CONSOLE_VERBS } from './broker.mjs';
|
||||
import { inspectInterpreter } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
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('.', '\\.')}"`);
|
||||
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
|
||||
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
|
||||
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
|
||||
// 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
|
||||
// the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted).
|
||||
// `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses
|
||||
// host-not-configured.
|
||||
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
// 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);
|
||||
throw new BusError('socket-exists');
|
||||
@@ -59,20 +62,22 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
try {
|
||||
if (end !== input.length - 1) throw new BusError('invalid-envelope');
|
||||
const r = JSON.parse(input.subarray(0, end).toString('utf8'));
|
||||
if (input.length > LIMIT && r?.verb !== PROJECT_SAVE_VERB) throw new BusError('request-too-large');
|
||||
if (input.length > LIMIT && ![PROJECT_SAVE_VERB, 'library.file.save'].includes(r?.verb)) throw new BusError('request-too-large');
|
||||
if (
|
||||
!r ||
|
||||
Array.isArray(r) ||
|
||||
typeof r !== 'object' ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) ||
|
||||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'console', 'verb', 'args'].includes(k)) ||
|
||||
typeof r.verb !== 'string' ||
|
||||
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
|
||||
[r.cap, r.human, r.tester, r.user, r.console].filter(Boolean).length !== 1
|
||||
)
|
||||
throw new BusError('invalid-envelope');
|
||||
let cap = r.cap;
|
||||
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
|
||||
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : r.console ? 'console' : null;
|
||||
// Row 252 (#1753): a Console owner proof reaches its two verbs only, refused before any proof check.
|
||||
if (kind === 'console' && !CONSOLE_VERBS.includes(r.verb)) throw new BusError('human-required');
|
||||
if (kind) {
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
|
||||
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : kind === 'user' ? authenticateUser : authenticateConsole;
|
||||
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
|
||||
socket.pause();
|
||||
let peer;
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
// bus.mjs for six library-* STATUS entries; no proof or transport change.
|
||||
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
|
||||
// transport change.
|
||||
// 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.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -15,7 +18,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
|
||||
import { Client } from "../src/client.mjs";
|
||||
|
||||
const baselines = {
|
||||
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
|
||||
"packages/bus/src/human.mjs": "bb13c146717cdc46d75d157177e400edac4d3e10f14fe511027a25fd44cec885",
|
||||
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
|
||||
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
||||
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
|
||||
@@ -26,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": "39fcb27c82e917ef05e1a7a650d522c9f3c656bf8dbade87b8ddb3661b67ad6c",
|
||||
"packages/webui/src/bus.mjs": "604092cb706b152d458f299ff1071f6cd488774629012f96b672f7e9a16c0b2c",
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5, N1-N5): the broker side of the
|
||||
// Console's Approve and Deny for a grant request. A console cap, made by bindConsole after the
|
||||
// transport checked the wrapper and the signed-in owner, reaches only decision.preview.console and
|
||||
// decision.resolve.console, and no other cap reaches those. The answer records via 'console' and a
|
||||
// human.input that names the owner, written before the grant check (N2), and only that record lets
|
||||
// an approve start the executor (J13, J14). Q188: no agent caller answers. The grant uses row 234's
|
||||
// host-enroll raise with a fake keyscan and fake enroll and check steps, so nothing connects anywhere.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant, fingerprint } from '../src/host-grant.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
const options = [{ key: 'yes', text: 'Yes, enroll it' }, { key: 'no', text: 'No' }];
|
||||
const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const keyLine = (dir, name) => {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
|
||||
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
|
||||
};
|
||||
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'c252-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
|
||||
const configDir = join(root, 'config');
|
||||
const real = hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run'), scan: async () => hostKeys });
|
||||
const enrolls = [];
|
||||
const hg = {
|
||||
...real,
|
||||
enroll: async (...args) => (enrolls.push(args), real.enroll(...args)),
|
||||
check: async (...args) => ({ installerFingerprint: args[2].installer_fingerprint, exit: 0 }),
|
||||
};
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const make = () => new Broker({ store, businesses: { demo: business }, hostGrant: hg });
|
||||
const broker = make();
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = broker.bindTester({ business: 'demo' });
|
||||
caps.reader = broker.bindReader({ business: 'demo' });
|
||||
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: hostKeys.map(fingerprint) };
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const raise = (extra = {}) =>
|
||||
call('chief', 'decision.raise', { action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false, grant, ...extra });
|
||||
const events = () =>
|
||||
store
|
||||
.all("SELECT seq, kind, actor_role, actor_run, subject, body FROM events WHERE business='demo' ORDER BY seq")
|
||||
// The row's subject is `about`, since a console answer's body has its own `subject`. The row's kind
|
||||
// is `event`, set after the body, since an answer's body has its own `kind`.
|
||||
.map((e) => ({ seq: e.seq, kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, about: e.subject, ...JSON.parse(e.body), event: e.kind }));
|
||||
const grantEvents = (decision) => events().filter((e) => e.actor_run === 'grant' && (e.decision === decision || e.grantDecision === decision));
|
||||
const settle = async (decision) => {
|
||||
for (let i = 0; i < 250; i++) {
|
||||
if (grantEvents(decision).some((e) => ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('enrollment did not end');
|
||||
};
|
||||
const closes = (decision) => store.all('SELECT seq, op, by, choice, via FROM decision_events WHERE decision=? ORDER BY seq', decision);
|
||||
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
|
||||
}
|
||||
|
||||
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
assert.ok(Object.isFrozen(CONSOLE_VERBS));
|
||||
});
|
||||
|
||||
test('bindConsole takes only the business human and a three-string owner subject', (t) => {
|
||||
const f = setup(t);
|
||||
const bind = (record) => () => f.broker.bindConsole(record);
|
||||
refusesSync(bind({ business: 'demo', human: 'someone', subject: SUBJECT }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, name: 'Jason' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'i' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: '' } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 'x'.repeat(513) } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 7 } }), 'human-required');
|
||||
refusesSync(bind({ business: 'demo', human: 'jason', subject: 'owner-1' }), 'human-required');
|
||||
assert.throws(bind({ business: 'demo', human: 'jason', subject: SUBJECT, via: 'cli' }));
|
||||
assert.throws(bind({ business: 'demo', human: 'jason' }));
|
||||
assert.throws(bind({ business: 'other', human: 'jason', subject: SUBJECT }));
|
||||
assert.deepEqual(f.broker.identity(f.caps.console), { business: 'demo', role: null, run: null, human: 'jason' });
|
||||
});
|
||||
|
||||
test('J1, Q188: only a console cap reaches the console verbs, and a console cap reaches nothing else', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const preview = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const args = { id: d.id, choice: 'yes', digest: preview.digest };
|
||||
// Agents, the cli human, the tester and the reader are refused before the verb runs.
|
||||
for (const who of ['chief', 'pm', 'human', 'tester', 'reader']) {
|
||||
refusesSync(() => f.call(who, 'decision.preview.console', { id: d.id }), 'human-required');
|
||||
refusesSync(() => f.call(who, 'decision.resolve.console', args), 'human-required');
|
||||
}
|
||||
// N4: the console cap answers nothing through decision.resolve, and reaches no other verb.
|
||||
for (const [verb, a] of [
|
||||
['decision.resolve', { id: d.id, choice: 'yes' }],
|
||||
['decision.seen', { id: d.id }],
|
||||
['decision.withdraw', { id: d.id }],
|
||||
['decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant }],
|
||||
['inbox', {}],
|
||||
['message.send', { to: 'chief', body: 'hi' }],
|
||||
['role.claim', {}],
|
||||
])
|
||||
refusesSync(() => f.call('console', verb, a), 'human-required');
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
});
|
||||
|
||||
test('preview: the two real answers, a grant summary and the digest, for an open grant request only', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const p = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
assert.equal(p.id, d.id);
|
||||
assert.equal(p.business, 'demo');
|
||||
assert.match(p.question, /^Enroll web1\?/);
|
||||
assert.deepEqual(p.approve, { key: 'yes', text: 'Yes, enroll it' });
|
||||
assert.deepEqual(p.deny, { key: 'no', text: 'No' });
|
||||
assert.deepEqual(p.grant, { method: 'host-enroll', host: 'web1' });
|
||||
assert.match(p.digest, /^[a-f0-9]{64}$/);
|
||||
// Read twice, the digest is the same; a preview writes nothing.
|
||||
assert.equal(f.call('console', 'decision.preview.console', { id: d.id }).digest, p.digest);
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id, choice: 'yes' }), 'invalid-request');
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: 'no-such-decision' }), 'decision-not-found');
|
||||
// The inbox row says the same, for the human and the reader. The agent-facing views do not.
|
||||
for (const who of ['human', 'reader']) {
|
||||
const row = f.call(who, 'inbox').find((r) => r.id === d.id);
|
||||
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
|
||||
}
|
||||
});
|
||||
|
||||
test('J1, J2: a decision that is not an eligible grant request refuses both console verbs', async (t) => {
|
||||
const f = setup(t);
|
||||
const grant = { method: 'host', agent: 'pm', host: 'web1', commands: ['uptime'], rotateBy: '2099-01-01' };
|
||||
const ineligible = [
|
||||
grantDecision(f.store, { action: 'deploy', grant, close: null }),
|
||||
grantDecision(f.store, { route: 'pm', grant, close: null }),
|
||||
grantDecision(f.store, { grant: undefined, close: null }),
|
||||
];
|
||||
for (const id of ineligible) {
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
assert.equal(f.call('human', 'inbox').find((r) => r.id === id)?.console, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest });
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'yes', grant: 'started' });
|
||||
const [close] = f.closes(d.id);
|
||||
assert.deepEqual({ ...close, seq: undefined }, { seq: undefined, op: 'resolved', by: 'jason', choice: 'yes', via: 'console' });
|
||||
const input = f.events().filter((e) => e.event === 'human.input' && e.about === d.id);
|
||||
assert.equal(input.length, 1);
|
||||
assert.deepEqual(
|
||||
{ ...input[0], seq: undefined },
|
||||
{ seq: undefined, kind: 'answer', actor_role: null, actor_run: null, about: d.id, decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: close.seq, event: 'human.input' },
|
||||
);
|
||||
await f.settle(d.id);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
const spend = f.grantEvents(d.id).find((e) => e.operation === 'grant.host-enroll');
|
||||
assert.ok(spend && input[0].seq < spend.seq, 'the human.input precedes the executor spend');
|
||||
assert.ok(f.grantEvents(d.id).some((e) => e.action === 'grant.host.enrolled'));
|
||||
// N3: the close notice to the raiser comes from the human through the Console.
|
||||
const notices = f.store.all("SELECT from_run, body FROM messages WHERE business='demo' AND decision=? AND class='DECISION' AND from_role='human'", d.id);
|
||||
assert.equal(notices.length, 1);
|
||||
const [notice] = notices;
|
||||
assert.equal(notice.from_run, 'human-console');
|
||||
assert.match(notice.body, /resolved by jason: yes \(Yes, enroll it\)/);
|
||||
// A second answer of either kind finds it closed, and nothing starts again.
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id }), 'decision-closed');
|
||||
refusesSync(() => f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }), 'decision-closed');
|
||||
await sleep(50);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
assert.equal(f.closes(d.id).length, 1);
|
||||
});
|
||||
|
||||
test('J15: a Console deny closes with the other key and starts nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'no', digest });
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'no' });
|
||||
assert.deepEqual(f.closes(d.id).map((c) => [c.op, c.choice, c.via]), [['resolved', 'no', 'console']]);
|
||||
await sleep(100);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
assert.deepEqual(f.grantEvents(d.id), []);
|
||||
assert.deepEqual(f.make().reconcileGrants(), []);
|
||||
});
|
||||
|
||||
test('J2: approve and deny come from approvalChoice and the options, not a fixed yes and no', async (t) => {
|
||||
const f = setup(t);
|
||||
// Deny listed first, so a build that takes options[0] as approve fails too.
|
||||
const keyed = { options: [{ key: 'stop', text: 'Stop' }, { key: 'go', text: 'Go ahead' }], approvalChoice: 'go', recommendation: 'go' };
|
||||
const denied = await f.raise({ ...keyed, question: 'Enroll web1? (deny)' });
|
||||
const p = f.call('console', 'decision.preview.console', { id: denied.id });
|
||||
assert.deepEqual([p.approve, p.deny], [{ key: 'go', text: 'Go ahead' }, { key: 'stop', text: 'Stop' }]);
|
||||
for (const choice of ['yes', 'no']) refusesSync(() => f.call('console', 'decision.resolve.console', { id: denied.id, choice, digest: p.digest }), 'decision-stale');
|
||||
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: denied.id, choice: 'stop', digest: p.digest }), { id: denied.id, op: 'resolved', choice: 'stop' });
|
||||
await sleep(100);
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
const approved = await f.raise({ ...keyed, question: 'Enroll web1? (approve)' });
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: approved.id });
|
||||
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: approved.id, choice: 'go', digest }), { id: approved.id, op: 'resolved', choice: 'go', grant: 'started' });
|
||||
await f.settle(approved.id);
|
||||
assert.equal(f.enrolls.length, 1);
|
||||
});
|
||||
|
||||
test('J11: a stale digest or a key outside the two refuses decision-stale and writes nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: 'f'.repeat(64) }), 'decision-stale');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'maybe', digest }), 'decision-stale');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes' }), 'invalid-request');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest, note: 'x' }), 'invalid-request');
|
||||
// Another decision's digest does not answer this one.
|
||||
const other = await f.raise({ question: 'Enroll web1 again?' });
|
||||
const od = f.call('console', 'decision.preview.console', { id: other.id }).digest;
|
||||
assert.notEqual(od, digest);
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: od }), 'decision-stale');
|
||||
assert.deepEqual(f.closes(d.id), []);
|
||||
assert.equal(f.events().filter((e) => e.event === 'human.input').length, 0);
|
||||
// A refusal is recorded, as for every verb.
|
||||
assert.ok(f.events().some((e) => e.event === 'action.refused' && e.code === 'decision-stale'));
|
||||
// The decision closed by the cli meanwhile: the console answer finds it closed.
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
|
||||
});
|
||||
|
||||
test('J14: a via console close with no matching human.input approves nothing', (t) => {
|
||||
const f = setup(t);
|
||||
const grant = { method: 'host-enroll', host: 'web2', address: '192.0.2.11', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
|
||||
const cli = grantDecision(f.store, { grant });
|
||||
const bare = grantDecision(f.store, { grant: { ...grant, host: 'web3' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const wrong = grantDecision(f.store, { grant: { ...grant, host: 'web4' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const good = grantDecision(f.store, { grant: { ...grant, host: 'web5' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
|
||||
const seqOf = (id) => f.store.get('SELECT seq FROM decision_events WHERE decision=?', id).seq;
|
||||
const input = (id, body, role = null) =>
|
||||
f.store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',?,?,?,?)",
|
||||
'e-' + id + '-' + Math.random(), '2026-10-22T00:00:00.000Z', 'demo', role, role && role + '-run', id, JSON.stringify(body),
|
||||
);
|
||||
const answer = (id) => ({ kind: 'answer', decision: id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: seqOf(id) });
|
||||
// `bare` has no human.input at all. `wrong` has records that each break one part: another seq, choice, empty subject, an agent actor.
|
||||
input(wrong, { ...answer(wrong), decisionSeq: seqOf(wrong) + 1000 });
|
||||
input(wrong, { ...answer(wrong), choice: 'no' });
|
||||
input(wrong, { ...answer(wrong), subject: { ...SUBJECT, sub: '' } });
|
||||
input(wrong, answer(wrong), 'chief');
|
||||
input(good, answer(good));
|
||||
// reconcileGrants ends every approved, unspent host-enroll: exactly the cli one and the good console one.
|
||||
assert.deepEqual(f.make().reconcileGrants().sort(), [cli, good].sort());
|
||||
assert.equal(f.enrolls.length, 0);
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE business='demo' AND actor_run='grant' AND json_extract(body,'$.decision') IN (?,?)", bare, wrong).n, 0);
|
||||
});
|
||||
|
||||
test('recordEvent keeps human.input reserved, so no agent forges a console answer', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const forged = { kind: 'human.input', subject: d.id, body: { kind: 'answer', decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: 1 } };
|
||||
for (const who of ['chief', 'pm']) refusesSync(() => f.broker.recordEvent(f.caps[who], forged), 'reserved-event');
|
||||
refusesSync(() => f.broker.recordEvent(f.caps.console, forged), 'agent-required');
|
||||
assert.equal(f.events().filter((e) => e.event === 'human.input').length, 0);
|
||||
});
|
||||
|
||||
// J7: the socket transport binds a console proof's cap for its one call only. The reply and a refusal
|
||||
// both release it, so the owner cap can't outlive the request that proved it.
|
||||
test('J7: a console proof over the socket binds a cap for its one call and the reply releases it', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
const dir = mkdtempSync(join(tmpdir(), 'c252s-'));
|
||||
const bound = [];
|
||||
const server = await serve({
|
||||
broker: f.broker,
|
||||
path: join(dir, 'bus.sock'),
|
||||
authenticateConsole: () => (bound.push(f.broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT })), bound.at(-1)),
|
||||
});
|
||||
t.after(async () => {
|
||||
await server.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
const client = new Client({ path: join(dir, 'bus.sock'), console: { fixture: true } });
|
||||
const preview = await client.call('decision.preview.console', { id: d.id });
|
||||
assert.equal(preview.id, d.id);
|
||||
await assert.rejects(client.call('decision.preview.console', { id: 'nope' }), { code: 'decision-not-found' });
|
||||
assert.equal(bound.length, 2);
|
||||
for (const cap of bound) refusesSync(() => f.broker.identity(cap), 'unauthenticated');
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { businessDoc, FAR_DATE, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
|
||||
// no decision channel (M6). The messaging lists are read from the shipped role files.
|
||||
@@ -98,7 +98,7 @@ async function runtime(t) {
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||||
doc.roles.bean = { definition: 'companion' };
|
||||
doc.roles.bean = { definition: 'companion', credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars };
|
||||
// Only bean names the sonnet key, so the chief may still change every other key.
|
||||
for (const [name, r] of Object.entries(doc.roles)) if (name !== 'bean') r.vars.model = 'claude-opus-5-5';
|
||||
|
||||
@@ -256,6 +256,22 @@ test('X-G1: a creative role, a role without identity and a missing or due slot r
|
||||
for (const f of [creative, bare, noSlot, due, elsewhere]) assert.equal(f.gitea.requests.length, 0);
|
||||
});
|
||||
|
||||
// Row 285 (#1790, sage-2921): the chief's definition declares gitea for a read grant only, and this path
|
||||
// mints SEAT_SCOPES, so a chief with identity and publish still refuses at raise and stores nothing.
|
||||
test('X-G1: the chief of staff refuses at raise, whatever its definition, identity and publish declare', async (t) => {
|
||||
const f = await setup(t, {
|
||||
business: (b) => {
|
||||
b.definitions['chief-of-staff'].credentials = [{ service: 'gitea' }];
|
||||
b.roles.chief.identity = { name: 'Chief', email: '[email protected]', gitea: { url: b.roles.coder.identity.gitea.url, user: 'demo-chief' } };
|
||||
b.roles.chief.publish = ['app'];
|
||||
},
|
||||
});
|
||||
const n = f.store.get('SELECT count(*) AS n FROM decisions').n;
|
||||
await rejects(f.raise({ agent: 'chief' }), 'gitea-not-declared');
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, n);
|
||||
assert.equal(f.gitea.requests.length, 0);
|
||||
});
|
||||
|
||||
test('X-G2: admin set refuses each failed check and writes nothing, then writes the slot once', async (t) => {
|
||||
const f = await setup(t, { slot: false });
|
||||
const set = (token, over = {}) =>
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
// C2-03b (#1791, design 7.2): the credential the library fetch runs with. The chief's grant store file
|
||||
// once a chief has Gitea grant evidence, slot library-gitea before that, and a refusal otherwise.
|
||||
// T7's revoked and fired cases and T9's revoked case run the broker's real paths (dewey-0864): the
|
||||
// chief's grant through decision.raise and the human's yes against the fake Gitea in fake-gitea-api.mjs
|
||||
// on 127.0.0.1, then grants.revoke or instance.fire. roles/chief-of-staff.json names no gitea credential,
|
||||
// so these tests give chief-of-staff one in their own definitions, as gitea-grant.test.mjs gives coder
|
||||
// one. The choice function's other checks (listed, a fire under way, held, two chiefs, another project)
|
||||
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
|
||||
// decisions. Tokens are made up or the fake's, and only ever compared.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, GRANT_EVIDENCE, LIBRARY_FETCH_FIX, writeGrantEvent } from '../src/broker.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart } from '../src/gitea-grant.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { gitEnv } from '../../seat/src/checkout.mjs';
|
||||
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
|
||||
import * as library from '../../library/src/index.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
import { rolesCopy, systemFor } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const AT = '2026-10-22T00:00:00.000Z';
|
||||
const REV = 'b'.repeat(64);
|
||||
const SLOT_TOKEN = 'fixture-token-2c90-made-up-0048';
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
||||
const SWITCH = { version: 1, halted: false, generation: '0b6e7f6e-4c1d-4a8e-9a51-6d2f3c4b5a69' };
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
async function until(fn, ms = 10000) {
|
||||
const end = Date.now() + ms;
|
||||
while (!fn()) {
|
||||
if (Date.now() > end) throw new Error('timed out');
|
||||
await sleep(10);
|
||||
}
|
||||
}
|
||||
|
||||
const makeBusiness = ({ chief = true, instances = null } = {}) => ({
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { ...(chief ? { chief: { definition: 'chief-of-staff', ...role } } : {}), coder: { definition: 'coder', ...role } },
|
||||
projects: { books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
...(instances ? { launch: { instances } } : {}),
|
||||
});
|
||||
|
||||
// A Gitea grant for `agent` as row 236 leaves it: the decision, the user and grant rows, and
|
||||
// grant.applied. `project` is the grant's target; the evidence check names no project.
|
||||
function grant(store, agent, { user = agent + '-bot', project = 'books', row = true } = {}) {
|
||||
const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent, target: { projects: [project] }, rotateBy: '2099-01-01' } });
|
||||
writeGrantEvent(store, AT, 'acme', { operation: 'grant.gitea', decision }, decision);
|
||||
writeGrantEvent(store, AT, 'acme', { action: 'grant.applied', method: 'gitea', agent, grantDecision: decision, user, projects: [project] }, decision);
|
||||
if (!row) return decision;
|
||||
store.run('INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)', 'acme', agent, user, 7, randomBytes(8).toString('hex'), decision);
|
||||
store.run('INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
|
||||
'acme', agent, user, 11, 'mosaic-' + agent, JSON.stringify([project]), '2099-01-01', decision);
|
||||
return decision;
|
||||
}
|
||||
|
||||
function world(t, options) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-credential-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = makeBusiness(options);
|
||||
const held = new Set();
|
||||
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
|
||||
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
|
||||
}
|
||||
|
||||
// The business for the real paths. `boss` is launch.by: instance.fire is the launch.by role's verb and
|
||||
// refuses its own name (organization-instance-referenced), so the fired chief is the one listed
|
||||
// chief-of-staff instance, fired by boss. The grant targets project app; books is the library.
|
||||
function verbDoc(url) {
|
||||
return {
|
||||
id: 'acme', human: 'jason', arbiters: { technical: 'boss', delivery: 'boss' },
|
||||
roles: {
|
||||
boss: { definition: 'pm', authority: chiefAuthority },
|
||||
chief: { definition: 'chief-of-staff', ...none, identity: { name: 'Chief', email: '[email protected]', gitea: { url, user: 'acme-chief' } }, publish: ['app'] },
|
||||
coder: { definition: 'coder', ...none },
|
||||
},
|
||||
definitions: {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
coder: { roleVersion: 2, credentials: [] },
|
||||
},
|
||||
projects: { app: { remote: `${url}/acme/app` }, books: { ...mappingEntry(), user: 'books-bot' } },
|
||||
launch: { by: 'boss', instances: ['chief', 'coder'] },
|
||||
};
|
||||
}
|
||||
|
||||
// A bare broker on the real grant paths, as gitea-grant.test.mjs and fire-revokes-grants.test.mjs run
|
||||
// it. A second one on the same data root and store picks the grant up as runtime.mjs does: giteaStart,
|
||||
// then Credentials with its references and held list. The admin slot sits outside storeRoot's config
|
||||
// dir, so a runtime started on that dir finds no admin slot.
|
||||
async function verbWorld(t, opts = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bus-library-verbs-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
let gitea = opts.gitea;
|
||||
if (!gitea) {
|
||||
gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
||||
gitea.addUser('acme');
|
||||
gitea.addRepo('acme', 'app');
|
||||
}
|
||||
const adminDir = join(dir, 'admin');
|
||||
mkdirSync(adminDir, { mode: 0o700 });
|
||||
const dataRoot = opts.dataRoot ?? join(dir, 'data');
|
||||
mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||
const storeRoot = opts.storeRoot ?? join(dir, 'secrets', 'agents');
|
||||
const doc = opts.doc ?? verbDoc(gitea.url);
|
||||
const store = new Store(dataRoot);
|
||||
const references = {};
|
||||
const { held } = giteaStart(store, storeRoot, { acme: doc }, references);
|
||||
const credentials = new Credentials({ references, storeRoot, dataRoot, held });
|
||||
let open = true;
|
||||
const close = () => {
|
||||
if (!open) return;
|
||||
open = false;
|
||||
credentials.close();
|
||||
store.close();
|
||||
};
|
||||
t.after(close);
|
||||
const grantApi = giteaGrant({ configDir: adminDir, ca: gitea.ca, timeoutMs: 5000 });
|
||||
grantApi.writeAdmin('acme', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: AT });
|
||||
let broker;
|
||||
const organization = (business, op, args) => {
|
||||
if (op === 'instance.fire') {
|
||||
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.name);
|
||||
broker.reloadBusiness('acme', doc);
|
||||
return { revision: REV, transaction: 't-fire' };
|
||||
}
|
||||
return { revision: REV };
|
||||
};
|
||||
broker = new Broker({
|
||||
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
|
||||
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
|
||||
});
|
||||
const boss = broker.bindLaunch({ business: 'acme', role: 'boss', run: 'boss-run', harness: 'pi', address: 'boss' });
|
||||
broker.request(boss, { verb: 'role.claim' });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const events = () => store.all(`SELECT body FROM events WHERE business='acme' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||
const ended = (id) => events().findLast((e) => e.grantDecision === id && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||
const row = () => store.get("SELECT 1 FROM gitea_grants WHERE business='acme' AND agent='chief'");
|
||||
const approveChief = async () => {
|
||||
const d = await broker.request(boss, {
|
||||
verb: 'decision.raise',
|
||||
args: {
|
||||
action: 'credential.mint', question: 'Grant chief Gitea?', options, recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'gitea', agent: 'chief', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
||||
},
|
||||
});
|
||||
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
await until(() => ended(d.id));
|
||||
await broker.giteaIdle();
|
||||
assert.equal(ended(d.id).action, 'grant.applied', 'the chief\'s grant landed');
|
||||
assert.ok(row(), 'with its gitea_grants row');
|
||||
};
|
||||
const revoke = async () => {
|
||||
await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'chief', reason: 'test' } });
|
||||
await broker.giteaIdle();
|
||||
assert.equal(row(), undefined, 'grants.revoke deleted the row');
|
||||
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
|
||||
};
|
||||
const fire = async () => {
|
||||
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
|
||||
await until(() => !doc.launch.instances.includes('chief'));
|
||||
await broker.grantsIdle();
|
||||
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
|
||||
};
|
||||
return { gitea, doc, store, broker, storeRoot, row, approveChief, revoke, fire, close, choose: () => broker.libraryFetchCredential('acme') };
|
||||
}
|
||||
|
||||
const REFUSED = { refused: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX };
|
||||
|
||||
test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s live grant', t => {
|
||||
const w = world(t);
|
||||
assert.deepEqual(w.choose(), { kind: 'slot' });
|
||||
grant(w.store, 'coder');
|
||||
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
|
||||
grant(w.store, 'chief', { user: 'chief-bot' });
|
||||
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
||||
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
|
||||
'a bus with no grant store refuses rather than guess');
|
||||
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
|
||||
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
|
||||
});
|
||||
|
||||
test('C2-03b T7: a chief revoked through grants.revoke or fired through instance.fire refuses, and the fallback stays ended', { timeout: 60000 }, async t => {
|
||||
const revoked = await verbWorld(t);
|
||||
assert.deepEqual(revoked.choose(), { kind: 'slot' });
|
||||
await revoked.approveChief();
|
||||
assert.deepEqual(revoked.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
||||
await revoked.revoke();
|
||||
assert.deepEqual(revoked.choose(), REFUSED, 'revoked');
|
||||
|
||||
const fired = await verbWorld(t);
|
||||
await fired.approveChief();
|
||||
assert.deepEqual(fired.choose(), { kind: 'chief', agent: 'chief', user: 'acme-chief' });
|
||||
await fired.fire();
|
||||
assert.equal(fired.doc.launch.instances.includes('chief'), false, 'delisted');
|
||||
assert.ok(fired.doc.roles.chief, 'roles[chief] remains after a fire, so its evidence still ends the fallback');
|
||||
assert.deepEqual(fired.choose(), REFUSED, 'fired');
|
||||
});
|
||||
|
||||
test('C2-03b T7 choice checks: delisted, a fire under way and held each refuse; a rehire lists the chief again', t => {
|
||||
const delisted = world(t, { instances: ['coder'] });
|
||||
grant(delisted.store, 'chief');
|
||||
assert.deepEqual(delisted.choose(), REFUSED, 'delisted with the row still there');
|
||||
delisted.business.launch.instances.push('chief');
|
||||
assert.deepEqual(delisted.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' }, 'a rehire lists it again');
|
||||
delisted.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('f1',?,?,'action.allowed','chief','run','chief',?)",
|
||||
AT, 'acme', JSON.stringify({ action: 'fire.stopped' }));
|
||||
assert.deepEqual(delisted.choose(), REFUSED, 'a fire under way');
|
||||
|
||||
const held = world(t);
|
||||
grant(held.store, 'chief');
|
||||
held.held.add('acme/chief');
|
||||
assert.deepEqual(held.choose(), REFUSED, 'held');
|
||||
});
|
||||
|
||||
test('C2-03b T8: a chief grant on another project ends the fallback, and two listed chiefs refuse library-fetch-no-grant', t => {
|
||||
const w = world(t);
|
||||
grant(w.store, 'chief', { project: 'other-project', row: false });
|
||||
assert.deepEqual(w.choose(), REFUSED);
|
||||
const two = world(t);
|
||||
grant(two.store, 'chief');
|
||||
assert.deepEqual(two.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
||||
two.business.roles.second = { definition: 'chief-of-staff', ...role };
|
||||
assert.deepEqual(two.choose(), REFUSED, 'two chief-of-staff entries, both listed: one chief only');
|
||||
two.business.launch = { instances: ['coder'] };
|
||||
assert.deepEqual(two.choose(), REFUSED, 'neither listed');
|
||||
});
|
||||
|
||||
test('C2-03b departure (sage-2928): with no chief entry, any Gitea grant evidence refuses', t => {
|
||||
const w = world(t, { chief: false });
|
||||
assert.deepEqual(w.choose(), { kind: 'slot' });
|
||||
grant(w.store, 'coder');
|
||||
assert.deepEqual(w.choose(), REFUSED);
|
||||
});
|
||||
|
||||
// T9 and the runtime wiring. The timer and library.sync both take the broker's choice: the chief's
|
||||
// store file, the slot, or a refusal that runs no git even with the slot at 0000. The grant and its
|
||||
// revoke run through the real paths on the runtime's own data root and store.
|
||||
test('C2-03b T9 runtime: the chief\'s store file and user, then a real revoke that runs no git with the slot at 0000', { timeout: 60000 }, async t => {
|
||||
const f = repo(t), root = f.root, dataRoot = join(root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
// A scratch roles/ whose chief-of-staff carries a gitea credential, as row 285 ships it
|
||||
// (case2-rows.md:1823-1824), so the runtime's definitions match the ones the grant ran under
|
||||
// (dewey-0869). Rebased onto 285, the copy needs no edit.
|
||||
const rolesDir = rolesCopy(root);
|
||||
const chiefRole = join(rolesDir, 'chief-of-staff.json');
|
||||
const chiefDefinition = JSON.parse(readFileSync(chiefRole, 'utf8'));
|
||||
if (!chiefDefinition.credentials.some(c => c.service === 'gitea')) chiefDefinition.credentials.push({ service: 'gitea' });
|
||||
writeFileSync(chiefRole, JSON.stringify(chiefDefinition, null, 2) + '\n');
|
||||
const slot = join(root, 'secrets', 'acme', library.SLOT);
|
||||
mkdirSync(join(root, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(slot, SLOT_TOKEN + '\n', { mode: 0o600 });
|
||||
const granted = await verbWorld(t, { dataRoot, storeRoot });
|
||||
await granted.approveChief();
|
||||
granted.close();
|
||||
const gitea = granted.gitea;
|
||||
const chiefFile = join(storeRoot, 'acme', 'chief', 'gitea');
|
||||
assert.ok(existsSync(chiefFile), 'the grant wrote the chief\'s store file');
|
||||
const tokens = () => [SLOT_TOKEN, ...gitea.issued];
|
||||
const calls = [], lines = [], write = process.stderr.write;
|
||||
const command = (kind, url, opts) => { calls.push({ kind, opts }); return { argv: ['--version'], env: gitEnv() }; };
|
||||
process.stderr.write = function (chunk, ...rest) {
|
||||
if (String(chunk).startsWith('library: ')) { lines.push(String(chunk)); return true; }
|
||||
return write.call(this, chunk, ...rest);
|
||||
};
|
||||
t.after(() => { process.stderr.write = write; });
|
||||
const start = () => startBroker({ dataRoot, businesses: { acme: verbDoc(gitea.url) }, organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
librarySync: { command, intervalMs: 50 } });
|
||||
const untilAsync = async (fn) => { const end = Date.now() + 10000; while (!fn() && Date.now() < end) await sleep(10); };
|
||||
|
||||
let runtime = await start();
|
||||
await untilAsync(() => calls.length >= 1);
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
await runtime.broker.request(human, { verb: 'library.sync', args: { project: 'books' } }).catch(e => e);
|
||||
// webby-0151 R3: library.status names the kind and user of the last fetch, never the file or value.
|
||||
const shown = await runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } });
|
||||
assert.deepEqual(shown.credential, { kind: 'chief', user: 'acme-chief' });
|
||||
assert.equal(JSON.stringify(shown).includes(chiefFile) || tokens().some(tok => JSON.stringify(shown).includes(tok)), false);
|
||||
await runtime.close();
|
||||
assert.ok(calls.length >= 2, 'the timer and the verb');
|
||||
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === chiefFile && c.opts.user === 'acme-chief'), 'the chief\'s file and user, never the slot');
|
||||
|
||||
// Revoked through grants.revoke: the next start refuses before git, and the log names the fix.
|
||||
const revoking = await verbWorld(t, { dataRoot, storeRoot, gitea });
|
||||
await revoking.revoke();
|
||||
revoking.close();
|
||||
chmodSync(slot, 0o000);
|
||||
calls.length = 0; lines.length = 0;
|
||||
runtime = await start();
|
||||
await untilAsync(() => lines.length >= 1);
|
||||
const human2 = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
await assert.rejects(runtime.broker.request(human2, { verb: 'library.sync', args: { project: 'books' } }), { code: 'library-fetch-no-grant' });
|
||||
const after = await runtime.broker.request(human2, { verb: 'library.status', args: { project: 'books' } });
|
||||
assert.equal(after.lastError.code, 'library-fetch-no-grant');
|
||||
assert.equal(after.credential, null);
|
||||
await runtime.close();
|
||||
assert.equal(calls.length, 0, 'no git with a refusal');
|
||||
assert.equal(statSync(slot).mode & 0o777, 0, 'the slot was never fixed up');
|
||||
assert.deepEqual(JSON.parse(lines[0].slice('library: '.length)),
|
||||
{ event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-no-grant', fix: LIBRARY_FETCH_FIX });
|
||||
for (const line of lines) assert.equal(tokens().some(tok => line.includes(tok)), false);
|
||||
const mirror = library.mirrorPath({ business: verbDoc(gitea.url), project: 'books', dataRoot });
|
||||
const state = await library.readSyncState(mirror, { branch: 'main' });
|
||||
assert.equal(state.lastError.code, 'library-fetch-no-grant');
|
||||
assert.equal(state.credential, null);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// Row 224: exercise broker authorization, real C2-05a queries and host served snapshots.
|
||||
// All bodies and remotes are made up. External sync is replaced only at its trusted host seam.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { createLibraryLookup, startBroker } from '../src/runtime.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import * as library from '../../library/src/index.mjs';
|
||||
import { scratchRepo, note } from '../../library/tests/query-fixture.mjs';
|
||||
import { mappingEntry, git } from '../../library/tests/helpers.mjs';
|
||||
|
||||
const NOW = Date.parse('2026-10-22T16:00:00.000Z');
|
||||
const VERBS = ['library.find', 'library.get', 'library.search', 'library.manuscript', 'library.universe'];
|
||||
const GET = { universe: 'harbor', path: 'Book One/Characters/Tomas.md' };
|
||||
const ARGS = [
|
||||
{ universe: 'harbor', query: 'Tomas' }, GET,
|
||||
{ universe: 'harbor', text: 'ferry' }, { universe: 'harbor', book: 'Book One' }, { universe: 'harbor' },
|
||||
];
|
||||
const TEXTS = {
|
||||
'Book One/Characters/Tomas.md': note('character', 'Tomas', 'He runs the ferry. [[Elsewhere]]'),
|
||||
'Book One/Chapters/01.md': '# A ferry\nOne line.\n',
|
||||
'Book One/Playground/What If.md': note('character', 'Maybe Tomas', 'Unwritten ferry.'),
|
||||
'Second World/Characters/Elsewhere.md': note('character', 'Elsewhere', 'Other ferry.'),
|
||||
};
|
||||
|
||||
async function world(t, { projects, adapter, claimed = true, now = () => NOW, files = TEXTS } = {}) {
|
||||
const f = scratchRepo(t, files), dataRoot = join(f.root, 'data');
|
||||
mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
|
||||
projects: projects ?? { books: mappingEntry() }, arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const context = { business, project: 'books', dataRoot };
|
||||
let mirror = null, state = null, fetches = 0;
|
||||
if (business.projects.books?.kind === 'library') {
|
||||
mirror = await library.ensureMirror(context);
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
state = { version: 1, branch: 'main', served: f.commit, lastFetch: new Date(NOW).toISOString(),
|
||||
lastError: null, diverged: null, problems: [] };
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
|
||||
}
|
||||
const setState = patch => {
|
||||
state = { ...state, ...patch };
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify(state), { mode: 0o600 });
|
||||
};
|
||||
const lookup = createLibraryLookup({ dataRoot, now, sync: async () => {
|
||||
fetches++;
|
||||
setState({ lastFetch: new Date(now()).toISOString() });
|
||||
return { state, code: null };
|
||||
} });
|
||||
const store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const broker = new Broker({ store, businesses: { acme: business },
|
||||
library: adapter ?? { lookup, LibraryError: library.LibraryError } });
|
||||
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224', harness: 'pi' });
|
||||
if (claimed) broker.request(agent, { verb: 'role.claim' });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const reader = broker.bindReader({ business: 'acme' }), tester = broker.bindTester({ business: 'acme' });
|
||||
const call = (verb, args = {}, cap = agent) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
|
||||
return { ...f, dataRoot, business, context, mirror, store, broker, agent, human, reader, tester,
|
||||
call, lookup, setState, fetches: () => fetches };
|
||||
}
|
||||
|
||||
test('all five agent verbs read the configured served commit through real queries', async t => {
|
||||
const f = await world(t);
|
||||
const [find, get, search, manuscript, universe] = await Promise.all(VERBS.map((verb, i) => f.call(verb, ARGS[i])));
|
||||
for (const result of [find, get, search, manuscript, universe]) assert.equal(result.commit, f.commit);
|
||||
assert.deepEqual(find.results.map(x => x.path), ['Book One/Characters/Tomas.md']);
|
||||
assert.match(JSON.stringify(get.lines), /He runs the ferry/);
|
||||
assert.deepEqual(get.links_out, []); // The only target is in another universe.
|
||||
assert.ok(search.results.length > 0);
|
||||
assert.ok(search.results.every(x => !x.path.startsWith('Second World/') && x.scope === 'canon'));
|
||||
assert.equal(manuscript.path, 'Book One/Chapters/01.md');
|
||||
assert.deepEqual(universe.books, ['Book One']);
|
||||
assert.equal(f.fetches(), 0);
|
||||
const playground = await f.call('library.find', { universe: 'harbor', query: '', scope: 'playground' });
|
||||
assert.deepEqual(playground.results.map(x => x.path), ['Book One/Playground/What If.md']);
|
||||
assert.equal(await f.call('library.get', { universe: 'harbor', path: 'Second World/Characters/Elsewhere.md' }), null);
|
||||
});
|
||||
|
||||
test('each verb refuses missing universe and unknown keys before host work', async t => {
|
||||
let calls = 0;
|
||||
const f = await world(t, { adapter: { lookup: () => { calls++; } } });
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
const { universe, ...missing } = ARGS[i];
|
||||
await assert.rejects(f.call(verb, missing), { code: 'invalid-request' });
|
||||
for (const extra of [{ repo: '/elsewhere' }, { commit: 'b'.repeat(40) }, { business: 'other' }, { accept: 'b'.repeat(40) }])
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], ...extra }), { code: 'invalid-request' });
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], universe: '' }), { code: 'invalid-request' });
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('no library, missing host adapter and undeclared project refuse library-not-configured', async t => {
|
||||
const noLibrary = await world(t, { projects: {} });
|
||||
const noAdapter = await world(t, { adapter: {} });
|
||||
const f = await world(t);
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
await assert.rejects(noLibrary.call(verb, ARGS[i]), { code: 'library-not-configured' });
|
||||
await assert.rejects(noAdapter.call(verb, ARGS[i]), { code: 'library-not-configured' });
|
||||
await assert.rejects(f.call(verb, { ...ARGS[i], project: 'other' }), { code: 'library-not-configured' });
|
||||
}
|
||||
f.business.projects.second = mappingEntry();
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'invalid-request' });
|
||||
assert.equal((await f.call('library.get', { ...GET, project: 'books' })).commit, f.commit);
|
||||
});
|
||||
|
||||
test('reads need the current holder on each call; human/tester/reader are not agents', async t => {
|
||||
const f = await world(t, { claimed: false });
|
||||
for (const [i, verb] of VERBS.entries()) {
|
||||
await assert.rejects(f.call(verb, ARGS[i]), { code: 'not-holder' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.human), { code: 'agent-required' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.tester), { code: 'tester-refused' });
|
||||
await assert.rejects(f.call(verb, ARGS[i], f.reader), { code: 'read-only' });
|
||||
}
|
||||
f.broker.request(f.agent, { verb: 'role.claim' });
|
||||
assert.equal((await f.call('library.get', GET)).commit, f.commit);
|
||||
f.broker.request(f.agent, { verb: 'role.release' });
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'not-holder' });
|
||||
});
|
||||
|
||||
test('releasing a claim during an async lookup fences the response', { timeout: 30_000 }, async t => {
|
||||
let release, entered;
|
||||
const started = new Promise(resolve => { entered = resolve; });
|
||||
const f = await world(t, { adapter: { lookup: async () => {
|
||||
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
|
||||
} } });
|
||||
const pending = f.call('library.get', GET);
|
||||
await started;
|
||||
f.broker.request(f.agent, { verb: 'role.release' });
|
||||
release();
|
||||
await assert.rejects(pending, { code: 'not-holder' });
|
||||
});
|
||||
|
||||
test('a business library removed during an async read cannot leak its result', { timeout: 30_000 }, async t => {
|
||||
let release, entered;
|
||||
const started = new Promise(resolve => { entered = resolve; });
|
||||
const f = await world(t, { adapter: { lookup: async () => {
|
||||
entered(); await new Promise(resolve => { release = resolve; }); return { lines: [[1, 'not returned']] };
|
||||
} } });
|
||||
const pending = f.call('library.get', GET);
|
||||
await started;
|
||||
delete f.business.projects.books;
|
||||
release();
|
||||
await assert.rejects(pending, { code: 'library-not-configured' });
|
||||
});
|
||||
|
||||
test('freshness boundary, unknown last fetch and failed fetch still use immutable served state', async t => {
|
||||
const f = await world(t);
|
||||
f.setState({ lastFetch: new Date(NOW - 60_000).toISOString() });
|
||||
await f.call('library.get', GET);
|
||||
assert.equal(f.fetches(), 0);
|
||||
f.setState({ lastFetch: new Date(NOW - 60_001).toISOString() });
|
||||
await f.call('library.get', GET);
|
||||
assert.equal(f.fetches(), 1);
|
||||
f.setState({ lastFetch: null });
|
||||
await f.call('library.universe', { universe: 'harbor' });
|
||||
assert.equal(f.fetches(), 2);
|
||||
let attempted = 0, clock = NOW;
|
||||
const failing = createLibraryLookup({ dataRoot: f.dataRoot, now: () => clock, sync: async () => {
|
||||
attempted++;
|
||||
f.setState({ lastError: { code: 'library-fetch-failed', at: new Date(clock).toISOString() } });
|
||||
return { code: 'library-fetch-failed' };
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
const result = await failing(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(attempted, 1);
|
||||
assert.equal(result.commit, f.commit);
|
||||
for (const elapsed of [1, 59_999, 60_000]) {
|
||||
clock = NOW + elapsed;
|
||||
const retry = await failing(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(retry.commit, f.commit, 'serve the old pin during the failure cooldown');
|
||||
assert.equal(attempted, 1, 'recent lastError prevents another sync even though lastFetch is null');
|
||||
}
|
||||
clock = NOW + 60_001;
|
||||
assert.equal((await failing(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
|
||||
assert.equal(attempted, 2, 'retry only after the failed-attempt interval expires');
|
||||
});
|
||||
|
||||
test('an unreadable sync state fails closed; a busy sync keeps serving the existing pin', async t => {
|
||||
const f = await world(t);
|
||||
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
|
||||
throw new library.LibraryError('library-sync-busy', 'another fetch');
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
assert.equal((await lookup(f.context, { method: 'get', args: GET, authorize() {} })).commit, f.commit);
|
||||
writeFileSync(join(f.mirror, library.SYNC_STATE_FILE), '{bad json', { mode: 0o600 });
|
||||
await assert.rejects(f.call('library.get', GET), { code: 'library-state-invalid' });
|
||||
});
|
||||
|
||||
test('a stale lookup sees the commit published by its sync, not the prior served commit', async t => {
|
||||
const f = await world(t);
|
||||
writeFileSync(join(f.repo, GET.path), note('character', 'Tomas', 'Changed ferry.'));
|
||||
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'second fixture');
|
||||
const next = git(f.repo, 'rev-parse', 'HEAD');
|
||||
const lookup = createLibraryLookup({ dataRoot: f.dataRoot, now: () => NOW, sync: async () => {
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', f.mirror, 'HEAD:refs/heads/served');
|
||||
f.setState({ served: next, lastFetch: new Date(NOW).toISOString() });
|
||||
return { code: null };
|
||||
} });
|
||||
f.setState({ lastFetch: null });
|
||||
const result = await lookup(f.context, { method: 'get', args: GET, authorize() {} });
|
||||
assert.equal(result.commit, next);
|
||||
assert.match(JSON.stringify(result.lines), /Changed ferry/);
|
||||
});
|
||||
|
||||
test('real socket awaits lookup results and rechecks rejected query arguments', { timeout: 30_000 }, async t => {
|
||||
const f = await world(t);
|
||||
const path = join(f.root, 'lookup.sock');
|
||||
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
|
||||
const client = new Client({ path, cap: f.agent });
|
||||
const result = await client.call('library.get', GET);
|
||||
assert.equal(result.commit, f.commit);
|
||||
await assert.rejects(client.call('library.find', { universe: 'harbor', query: '', scope: 'anything' }), /query-argument-invalid/);
|
||||
});
|
||||
|
||||
test('runtime actually injects the lookup adapter, without exposing human library.file', { timeout: 30_000 }, async t => {
|
||||
const f = scratchRepo(t, TEXTS), dataRoot = join(f.root, 'runtime'); mkdirSync(dataRoot);
|
||||
const business = { id: 'acme', human: 'jason', projects: { books: mappingEntry() },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } } };
|
||||
const runtime = await startBroker({ dataRoot, businesses: { acme: business } }); t.after(() => runtime.close());
|
||||
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
|
||||
git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served');
|
||||
writeFileSync(join(mirror, library.SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit,
|
||||
lastFetch: new Date().toISOString(), lastError: null, diverged: null, problems: [] }), { mode: 0o600 });
|
||||
const cap = runtime.broker.bindLaunch({ business: 'acme', role: 'coder', run: 'r224-runtime', harness: 'pi' });
|
||||
runtime.broker.request(cap, { verb: 'role.claim' });
|
||||
assert.equal((await runtime.broker.request(cap, { verb: 'library.get', args: GET })).commit, f.commit);
|
||||
await assert.rejects(runtime.broker.request(cap, { verb: 'library.file', args: { project: 'books', path: GET.path } }),
|
||||
{ code: 'human-required' });
|
||||
});
|
||||
|
||||
test('C2-05c broker forwards supporting scope and returns the served blob without adding write verbs', async t => {
|
||||
const projects = { books: mappingEntry() };
|
||||
projects.books.universes.harbor.supporting = ['Book One/Support/'];
|
||||
const path = 'Book One/Support/Helper.md';
|
||||
const f = await world(t, { projects, files: { ...TEXTS, [path]: note('summary', 'Helper', 'Supporting fixture.') } });
|
||||
assert.deepEqual((await f.call('library.find', { universe: 'harbor', query: '', scope: 'supporting' })).results.map(x => x.path), [path]);
|
||||
assert.deepEqual((await f.call('library.search', { universe: 'harbor', text: 'Supporting fixture', scope: 'supporting' })).results.map(x => x.path), [path]);
|
||||
assert.equal(await f.call('library.get', { universe: 'harbor', path }), null);
|
||||
const result = await f.call('library.get', { universe: 'harbor', path, scope: 'supporting' });
|
||||
assert.equal(result.scope, 'supporting');
|
||||
assert.equal(result.commit, f.commit);
|
||||
assert.equal(result.blob, git(f.mirror, 'rev-parse', `${f.commit}:${path}`));
|
||||
assert.equal(await f.call('library.get', { universe: 'second', path, scope: 'supporting' }), null);
|
||||
await assert.rejects(f.call('library.manuscript', { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { connect } from 'node:net';
|
||||
import { startBroker, createLibraryLookup } from '../src/runtime.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { mappingEntry } from '../../library/tests/helpers.mjs';
|
||||
import { LibraryError } from '../../library/src/problems.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
import { world as bareWorld } from '../../library/tests/write-fixture.mjs';
|
||||
import { sync } from '../../library/src/sync.mjs';
|
||||
import { SYNC_STATE_FILE, servedCommit } from '../../library/src/mirror.mjs';
|
||||
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../../library/src/write.mjs';
|
||||
import { git } from '../../library/tests/helpers.mjs';
|
||||
|
||||
const SHA = 'a'.repeat(40), NEXT = 'b'.repeat(40), PATH = 'Book One/Notes/Agent.md';
|
||||
function world(t, options = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-library-write-')), store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const projects = options.projects ?? { books: mappingEntry() }; projects.books.universes.harbor.supporting = ['Book One/Notes/'];
|
||||
const authority = { withinRole: ['message.send', 'library.write'], crossRole: [] };
|
||||
const roles = { bean: { definition: 'companion', authority, identity: { name: 'Bean', email: '[email protected]', gitea: { user: 'bean', url: 'https://git.example.invalid' } }, publish: ['books'] },
|
||||
chief: { definition: 'chief-of-staff', authority }, quill: { definition: 'proofreader', authority: { withinRole: ['message.send'], crossRole: [] } } };
|
||||
options.changeRole?.(roles.bean);
|
||||
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true }, projects, roles,
|
||||
arbiters: { technical: 'chief', delivery: 'chief' }, definitions: { companion: {}, 'chief-of-staff': {}, proofreader: {} } };
|
||||
const seen = [], held = new Set(), logs = [];
|
||||
const result = { project: 'books', path: PATH, commit: NEXT };
|
||||
const library = { LibraryError, log: entry => logs.push(entry), readFile: async () => ({}), status: async () => ({}),
|
||||
writeDoc: async (context, options) => { seen.push({ context, options }); options.beforePush(); options.onPushed(result); return result; },
|
||||
saveFile: async context => { seen.push({ context }); return result; },
|
||||
revert: async context => { seen.push({ context }); return { ...result, reverted: context.commit }; } };
|
||||
const broker = new Broker({ store, businesses: { acme: business }, library, giteaKeys: { held: (b, a) => held.has(a) } });
|
||||
const caps = { human: broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: broker.bindTester({ business: 'acme' }) };
|
||||
const p = broker.request(caps.human, { verb: 'project.create', args: { name: 'Library chat', repo: 'books' } }).id;
|
||||
for (const role of ['bean', 'quill', 'chief']) {
|
||||
const thread = role === 'chief' ? null : broker.request(caps.human,
|
||||
{ verb: 'thread.create', args: { project: p, name: role, instance: role } }).id;
|
||||
caps[role] = broker.bindLaunch({ business: 'acme', role, run: role + '-run', harness: 'pi', ...(thread ? { thread } : {}) });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
const grant = (project = 'books') => {
|
||||
const decision = grantDecision(store, { business: 'acme', grant: { method: 'gitea', agent: 'bean', target: { projects: [project] }, rotateBy: '2099-01-01' } });
|
||||
store.run('INSERT OR REPLACE INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?)',
|
||||
'acme', 'bean', 'bean', 7, 'fixture', JSON.stringify([project]), '2099-01-01', decision);
|
||||
};
|
||||
grant();
|
||||
const call = (who, verb, args = {}) => Promise.resolve().then(() => broker.request(caps[who], { verb, args }));
|
||||
return { root, store, broker, caps, call, seen, held, business, grant, library, logs };
|
||||
}
|
||||
const args = () => ({ universe: 'harbor', path: PATH, base: SHA, content: 'fixture\n' });
|
||||
|
||||
test('C2-02: agent verbs require creative holder, action and live unheld project grant', async t => {
|
||||
const f = world(t);
|
||||
for (const verb of ['library.doc.write', 'library.doc.delete']) {
|
||||
const a = args(); if (verb.endsWith('delete')) delete a.content;
|
||||
for (const [who, code] of [['human', 'library-write-refused'], ['tester', 'tester-refused'], ['chief', 'library-write-refused'], ['quill', 'library-write-refused']])
|
||||
await assert.rejects(f.call(who, verb, a), { code });
|
||||
f.held.add('bean'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' }); f.held.clear();
|
||||
f.store.run('DELETE FROM gitea_grants');
|
||||
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' });
|
||||
f.grant('other'); await assert.rejects(f.call('bean', verb, a), { code: 'library-write-no-grant' });
|
||||
f.store.run('DELETE FROM gitea_grants'); f.grant();
|
||||
}
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
test('C2-02: writing instance supplies neither ident nor notice text; broker inserts exact notice', async t => {
|
||||
const f = world(t);
|
||||
const result = await f.call('bean', 'library.doc.write', args());
|
||||
assert.equal(result.commit, NEXT);
|
||||
const m = f.store.get("SELECT * FROM messages WHERE from_role='bean' AND to_role='human'");
|
||||
assert.equal(m.body, JSON.stringify({ notice: 'library-write', op: 'write', path: PATH, commit: NEXT }));
|
||||
assert.equal(m.from_run, 'bean-run');
|
||||
assert.ok(f.store.get('SELECT 1 FROM message_threads WHERE message=?', m.id));
|
||||
assert.equal(f.seen[0].options.identity.name, 'Bean');
|
||||
assert.equal(f.seen[0].options.credential.user, 'bean');
|
||||
assert.equal(f.seen[0].options.credential.agent, 'bean');
|
||||
for (const extra of [{ author: 'other' }, { agent: 'chief' }, { credential: { agent: 'chief' } }])
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), ...extra }), { code: 'invalid-request' });
|
||||
});
|
||||
|
||||
test('C2-02 A2: strict human revert args/result and agent human-required refusal', async t => {
|
||||
const f = world(t);
|
||||
for (const who of ['bean', 'quill', 'chief', 'tester'])
|
||||
for (const verb of ['library.revert', 'library.file.save'])
|
||||
await assert.rejects(f.call(who, verb, {}), { code: 'human-required' });
|
||||
for (const a of [{ commit: SHA }, { project: 'books' }, { project: 'books', commit: 'A'.repeat(40) },
|
||||
{ project: 'books', commit: SHA, editor: {} }, { project: 'books', commit: SHA + 'a' }])
|
||||
await assert.rejects(f.call('human', 'library.revert', a), { code: 'invalid-request' });
|
||||
assert.deepEqual(await f.call('human', 'library.revert', { project: 'books', commit: SHA }),
|
||||
{ project: 'books', path: PATH, reverted: SHA, commit: NEXT });
|
||||
await assert.rejects(f.call('human', 'library.revert', { project: 'missing', commit: SHA }), { code: 'project-not-configured' });
|
||||
});
|
||||
|
||||
test('C2-02: human revert independent of revoked seat and LibraryError codes survive', async t => {
|
||||
const f = world(t); f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
|
||||
assert.equal((await f.call('human', 'library.revert', { project: 'books', commit: SHA })).reverted, SHA);
|
||||
for (const code of ['library-revert-not-agent', 'stale-base', 'library-mapping-invalid', 'library-human-missing']) {
|
||||
f.library.revert = async () => { throw new LibraryError(code, 'fixture'); };
|
||||
await assert.rejects(f.call('human', 'library.revert', { project: 'books', commit: SHA }), { code });
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02 transport: human library.file.save exceeds 64 KiB; agent write remains bounded', async t => {
|
||||
const f = world(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }) });
|
||||
t.after(() => server.close());
|
||||
const human = new Client({ path, human: { fixture: true } }), agent = new Client({ path, cap: f.caps.bean });
|
||||
const content = 'x'.repeat(70000);
|
||||
assert.equal((await human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content })).commit, NEXT);
|
||||
assert.equal(f.seen.at(-1).context.content, content);
|
||||
await assert.rejects(agent.call('library.doc.write', { ...args(), content }), { code: 'request-too-large' });
|
||||
await assert.rejects(human.call('library.file.save', { project: 'books', path: PATH, base: SHA, content: 'x'.repeat(2 * 1024 * 1024) }), { code: 'request-too-large' });
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 M12 through broker: every refusal preserves remote, served and all mirror refs', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
for (const path of ['Book One/Chapters/Chapter 01.md', 'Inbox Note.md', 'Book One/Playground/What If.md',
|
||||
'Book One/Notes/X.canvas', 'Book One/Notes/X.base', 'Book One/Notes/X.png', 'Second World/Chapters/01.md',
|
||||
'/Book One/Notes/X.md', 'Book One/Notes/../X.md', 'Book One/./Notes/X.md', 'Book One//Notes/X.md',
|
||||
'Book One/Notes/X\\Y.md', 'Book One/Notes/X\nY.md', 'Book One/Notes/Link.md', 'Book One/Notes/Submodule.md',
|
||||
'Book One/Notes/agent.md', `Book One/Notes/${'é'.repeat(512)}.md`]) {
|
||||
for (const verb of ['library.doc.write', 'library.doc.delete']) {
|
||||
const before = bare.refs(), head = bare.remoteHead(), a = { ...args(), path, base: null };
|
||||
if (verb.endsWith('delete')) delete a.content;
|
||||
await assert.rejects(f.call('bean', verb, a), { code: 'library-write-story-path' }, path);
|
||||
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
|
||||
}
|
||||
}
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
|
||||
});
|
||||
|
||||
test('C2-02 broker + bare: exact write/delete notices, then human revert with revoked seat grant', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
f.library.revert = context => revertLibraryCommit({ ...context, dataRoot: bare.dataRoot },
|
||||
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
|
||||
f.library.saveFile = context => saveLibraryFile({ ...context, dataRoot: bare.dataRoot },
|
||||
{ credential: { ...bare.credential, kind: 'human' }, command: bare.command });
|
||||
const base = git(bare.mirror, 'rev-parse', `served:${PATH}`);
|
||||
const saved = await f.call('bean', 'library.doc.write', { ...args(), base });
|
||||
assert.equal(saved.commit, bare.remoteHead());
|
||||
f.store.run('DELETE FROM gitea_grants'); f.held.add('bean');
|
||||
const reverted = await f.call('human', 'library.revert', { project: 'books', commit: saved.commit });
|
||||
assert.equal(reverted.reverted, saved.commit);
|
||||
assert.equal(git(bare.remote, 'show', `main:${PATH}`), 'before');
|
||||
assert.equal(git(bare.remote, 'show', '-s', '--format=%an <%ae>', reverted.commit), 'Human Fixture <[email protected]>');
|
||||
f.held.clear(); f.grant();
|
||||
const deleted = await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base });
|
||||
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human' ORDER BY seq");
|
||||
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
|
||||
{ notice: 'library-write', op: 'write', path: PATH, commit: saved.commit },
|
||||
{ notice: 'library-write', op: 'delete', path: PATH, commit: deleted.commit }]);
|
||||
assert.equal(git(bare.remote, 'ls-tree', '-r', deleted.commit, '--', PATH), '');
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope', async t => {
|
||||
const f = world(t), path = join(f.root, 'raw-bus.sock');
|
||||
const server = await serve({ broker: f.broker, path, authenticateHuman: () => f.caps.human });
|
||||
t.after(() => server.close());
|
||||
const request = JSON.stringify({ human: { verb: 'library.file.save' }, verb: 'library.doc.write',
|
||||
args: { ...args(), content: 'x'.repeat(70000) } }) + '\n';
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const socket = connect(path); let result = '';
|
||||
socket.on('error', reject); socket.on('connect', () => socket.write(request));
|
||||
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
|
||||
});
|
||||
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
|
||||
assert.equal(f.seen.length, 0);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 E2: held grant during fetch refuses at beforePush and preserves every ref', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
const before = bare.refs(), head = bare.remoteHead();
|
||||
const command = (kind, url, options) => {
|
||||
if (kind === 'fetch') f.held.add('bean');
|
||||
return bare.command(kind, url, options);
|
||||
};
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command });
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
|
||||
{ code: 'library-write-no-grant' });
|
||||
assert.equal(bare.refs(), before); assert.equal(bare.remoteHead(), head);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
|
||||
});
|
||||
|
||||
test('C2-02 E3: expired, firing, missing publish, wrong grant user and missing ident refuse', async t => {
|
||||
const cases = [
|
||||
{ name: 'expired', prepare: f => f.store.run("UPDATE gitea_grants SET rotate_by='2000-01-01'"), code: 'library-write-no-grant' },
|
||||
{ name: 'firing', prepare: f => f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('fire-bean',?,'acme','action.allowed','chief','run','bean',?)",
|
||||
new Date().toISOString(), JSON.stringify({ action: 'fire.stopped' })), code: 'library-write-no-grant' },
|
||||
{ name: 'publish', changeRole: r => { r.publish = []; }, code: 'library-write-no-grant' },
|
||||
{ name: 'grant user', prepare: f => f.store.run("UPDATE gitea_grants SET user='other-seat'"), code: 'library-write-no-grant' },
|
||||
{ name: 'identity name', changeRole: r => { delete r.identity.name; }, code: 'library-write-refused' },
|
||||
{ name: 'identity email', changeRole: r => { delete r.identity.email; }, code: 'library-write-refused' },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const f = world(t, { changeRole: c.changeRole }); c.prepare?.(f);
|
||||
for (const verb of ['library.doc.write', 'library.doc.delete']) {
|
||||
const a = args(); if (verb.endsWith('delete')) delete a.content;
|
||||
await assert.rejects(f.call('bean', verb, a), { code: c.code }, c.name);
|
||||
}
|
||||
assert.equal(f.seen.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 F2/E6 runtime: human saves/reverts use editor.user; no login never uses the fetch bot', { timeout: 30000 }, async t => {
|
||||
for (const user of [undefined, 'her-gitea-login']) {
|
||||
const bare = await bareWorld(t);
|
||||
const agent = await writeLibraryDoc(bare.args(), bare.opts);
|
||||
bare.entry.user = 'fetch-bot';
|
||||
if (user) bare.entry.editor.user = user;
|
||||
const configDir = join(bare.root, 'config'), slots = join(configDir, 'secrets', 'acme');
|
||||
mkdirSync(slots, { recursive: true, mode: 0o700 });
|
||||
for (const slot of ['library-human', 'library-gitea'])
|
||||
writeFileSync(join(slots, slot), 'fixture-runtime-token-288\n', { mode: 0o600 });
|
||||
const pushes = [];
|
||||
const command = (kind, url, options) => { if (kind === 'push') pushes.push(options.user); return bare.command(kind, url, options); };
|
||||
const initial = (await bare.state()).lastFetch;
|
||||
const business = { ...bare.context.business, human: 'jason', roles: { coder: { authority: { withinRole: [], crossRole: [] } } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' } };
|
||||
const runtime = await startBroker({ dataRoot: bare.dataRoot, businesses: { acme: business },
|
||||
librarySync: { configDir, command, intervalMs: 60000 } });
|
||||
try {
|
||||
const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (verb, args) => Promise.resolve().then(() => runtime.broker.request(human, { verb, args }));
|
||||
// Let the boot fetch finish before inspecting only the human operation's command count.
|
||||
const deadline = Date.now() + 10000;
|
||||
while (true) {
|
||||
const state = await call('library.status', { project: 'books' });
|
||||
if (state.lastFetch !== initial) break;
|
||||
if (Date.now() > deadline) throw Error('boot fetch did not complete');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
const path = 'Book One/Chapters/Chapter 01.md';
|
||||
const saves = [
|
||||
['library.file.save', { project: 'books', path, base: git(bare.mirror, 'rev-parse', `served:${path}`), content: 'her edit\n' }],
|
||||
['library.revert', { project: 'books', commit: agent.commit }],
|
||||
];
|
||||
const before = bare.commands.length;
|
||||
for (const [verb, args] of saves) {
|
||||
if (user) assert.match((await call(verb, args)).commit, /^[a-f0-9]{40}$/);
|
||||
else await assert.rejects(call(verb, args), { code: 'library-human-missing' });
|
||||
}
|
||||
assert.deepEqual(pushes, user ? [user, user] : []);
|
||||
if (!user) assert.equal(bare.commands.length, before);
|
||||
} finally { await runtime.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 E4: post-push publication failure stores the exact notice and returns outcome-unknown', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
const state = join(bare.mirror, SYNC_STATE_FILE), original = readFileSync(state);
|
||||
const previous = await servedCommit(bare.mirror), head = bare.remoteHead();
|
||||
const command = (kind, url, options) => {
|
||||
// Break only state decoding after push admission; the real remote push still executes.
|
||||
if (kind === 'push') writeFileSync(state, 'invalid state\n');
|
||||
return bare.command(kind, url, options);
|
||||
};
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command });
|
||||
try {
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
|
||||
{ code: 'outcome-unknown' });
|
||||
assert.notEqual(bare.remoteHead(), head);
|
||||
assert.equal(git(bare.mirror, 'rev-parse', 'served'), previous);
|
||||
const messages = f.store.all("SELECT body FROM messages WHERE from_role='bean' AND to_role='human'");
|
||||
assert.deepEqual(messages.map(m => JSON.parse(m.body)), [
|
||||
{ notice: 'library-write', op: 'write', path: PATH, commit: bare.remoteHead() }]);
|
||||
} finally { writeFileSync(state, original); }
|
||||
const commit = bare.remoteHead();
|
||||
const human = { credential: { ...bare.credential, kind: 'human' }, command: bare.command };
|
||||
await assert.rejects(revertLibraryCommit({ ...bare.context, commit }, human), { code: 'library-revert-not-agent' });
|
||||
await sync(bare.context, { credential: bare.credential, command: bare.command });
|
||||
assert.equal((await revertLibraryCommit({ ...bare.context, commit }, human)).reverted, commit);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 mos-1520: failed notice insert logs metadata and returns outcome-unknown even if refusal evidence fails', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
const previous = await servedCommit(bare.mirror), head = bare.remoteHead();
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
f.store.run(`CREATE TEMP TRIGGER fail_library_notice BEFORE INSERT ON messages
|
||||
WHEN NEW.from_role='bean' BEGIN SELECT RAISE(ABORT, 'fixture store failure'); END;`);
|
||||
f.store.run(`CREATE TEMP TRIGGER fail_refusal_log BEFORE INSERT ON events
|
||||
WHEN NEW.kind='action.refused' BEGIN SELECT RAISE(ABORT, 'fixture evidence failure'); END;`);
|
||||
await assert.rejects(f.call('bean', 'library.doc.write', { ...args(), base: git(bare.mirror, 'rev-parse', `served:${PATH}`) }),
|
||||
{ code: 'outcome-unknown' });
|
||||
assert.notEqual(bare.remoteHead(), head);
|
||||
assert.equal(await servedCommit(bare.mirror), previous);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='bean'").n, 0);
|
||||
assert.deepEqual(f.logs, [{ event: 'library-notice-store-failed', code: 'outcome-unknown', business: 'acme',
|
||||
instance: 'bean', op: 'write', commit: bare.remoteHead() }]);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 + C2-05c: broker get blob feeds write/delete and new served lookups immediately', async t => {
|
||||
const bare = await bareWorld(t), f = world(t, { projects: bare.context.business.projects });
|
||||
f.library.lookup = createLibraryLookup({ dataRoot: bare.dataRoot,
|
||||
sync: context => sync({ ...context, dataRoot: bare.dataRoot }, { credential: bare.credential, command: bare.command }) });
|
||||
f.library.writeDoc = (context, options) => writeLibraryDoc({ ...context, dataRoot: bare.dataRoot },
|
||||
{ ...options, credential: { ...options.credential, file: bare.file }, command: bare.command });
|
||||
const query = { universe: 'harbor', path: PATH, scope: 'supporting' };
|
||||
const before = await f.call('bean', 'library.get', query);
|
||||
assert.equal(before.blob, git(bare.mirror, 'rev-parse', `served:${PATH}`));
|
||||
const saved = await f.call('bean', 'library.doc.write', { ...args(), base: before.blob, content: 'new served text\n' });
|
||||
const after = await f.call('bean', 'library.get', query);
|
||||
assert.equal(after.commit, saved.commit);
|
||||
assert.equal(after.blob, git(bare.mirror, 'rev-parse', `${saved.commit}:${PATH}`));
|
||||
assert.notEqual(after.blob, before.blob);
|
||||
assert.ok(after.lines.includes('new served text'));
|
||||
assert.equal(await f.call('bean', 'library.get', { universe: 'harbor', path: PATH }), null);
|
||||
await f.call('bean', 'library.doc.delete', { universe: 'harbor', path: PATH, base: after.blob });
|
||||
assert.equal(await f.call('bean', 'library.get', query), null);
|
||||
});
|
||||
@@ -46,3 +46,52 @@ test('human leaf identity is reread after walking the parents',()=>{
|
||||
c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;};
|
||||
assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/);
|
||||
});
|
||||
|
||||
// Row 252 (#1753, design section 2, J3-J5): the Console owner. The same-tree console-cli wrapper with
|
||||
// its own nonce, a direct same-tree `serve` parent, the subject from the wrapper's spawn environment,
|
||||
// and that subject one of the configured owners (N1).
|
||||
const owner={provider:'fake-oidc',iss:'https://idp.test',sub:'owner-1'};
|
||||
function consoleContext() {
|
||||
const c=context('user');c.paths={...paths,consoleCli:'/tree/console-cli.mjs'};c.consoleOwners=[{provider:'other',iss:'https://idp.test',sub:'owner-2'},owner];
|
||||
Object.assign(c.table.get(50),{argv:['node','/tree/console-cli.mjs'],env:{MOSAIC_BUS_CONSOLE_NONCE:nonce,MOSAIC_BUS_CONSOLE_BUSINESS:'demo',MOSAIC_BUS_CONSOLE_PROVIDER:owner.provider,MOSAIC_BUS_CONSOLE_ISS:owner.iss,MOSAIC_BUS_CONSOLE_SUB:owner.sub}});
|
||||
return c;
|
||||
}
|
||||
const consoleProof={...proof,...owner};
|
||||
test('console requires its own wrapper, nonce and serve parent, and a configured owner from the spawn env',()=>{
|
||||
assert.deepEqual(verifyPrincipal({console:consoleProof},consoleContext()),{kind:'console',business:'demo',id:'owner',via:'console',subject:owner});
|
||||
// J3: the subject is the spawn's, never the body's alone, and must be a configured owner.
|
||||
for(const change of [{sub:'owner-2'},{iss:'https://evil.test'},{provider:'other'}])
|
||||
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...change}},consoleContext()),/human-required/);
|
||||
const unlisted=consoleContext();unlisted.consoleOwners=[];
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},unlisted),/human-required/);
|
||||
for(const extra of [{name:'Jason'},{human:'owner'},{user:'owner'},{sub:''},{sub:'x'.repeat(513)}])
|
||||
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...extra}},consoleContext()),/human-required/);
|
||||
// J4: the parent is the same-tree serve, direct, and not an agent.
|
||||
for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}},{uid:uid+1}]) {
|
||||
const c=consoleContext();Object.assign(c.table.get(40),change);
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
const launched=consoleContext();launched.launches=[{pid:40,startTime:'90'}];
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},launched),/human-required/);
|
||||
// J5: another nonce namespace, or the wrapper path of another kind, refuses.
|
||||
for(const env of [{MOSAIC_BUS_CLI_NONCE:nonce},{MOSAIC_BUS_USER_NONCE:nonce}]) {
|
||||
const c=consoleContext();const t=c.table.get(50);t.env={...t.env,...env};delete t.env.MOSAIC_BUS_CONSOLE_NONCE;
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
for(const argv of [['node',paths.human],['node',paths.user],['node','/other/console-cli.mjs']]) {
|
||||
const c=consoleContext();c.table.get(50).argv=argv;
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
// The Console's own nonce does not answer as the cli human or the user.
|
||||
assert.throws(()=>verifyPrincipal({human:proof},consoleContext()),/human-required/);
|
||||
assert.throws(()=>verifyPrincipal({user:{...proof,user:'owner'}},consoleContext()),/human-required/);
|
||||
// Each piece is read again after the walk: a parent or a spawn subject that changed refuses.
|
||||
for(const [pid,mutate] of [[40,p=>{p.startTime='91';}],[50,p=>{p.env={...p.env,MOSAIC_BUS_CONSOLE_SUB:'owner-2'};}],[50,p=>{p.ppid=41;}]]) {
|
||||
const c=consoleContext(),read=c.readProcess;let n=0;
|
||||
// The last read of each pid is verifyConsole's own reread: 40 is read 3 times, 50 is read 4 times.
|
||||
c.readProcess=x=>{const p=read(x);if(x===pid&&n++===(pid===40?2:3))mutate(p);return p;};
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
|
||||
}
|
||||
for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({console:consoleProof},{...consoleContext(),peer}),/human-required/);
|
||||
assert.throws(()=>verifyPrincipal({console:consoleProof,human:proof},consoleContext()),/invalid-envelope/);
|
||||
});
|
||||
@@ -45,7 +45,7 @@ test('requirements returns the committed list to a holding role session, and no
|
||||
const pm = agent('pm');
|
||||
const list = call(b, pm, 'requirements');
|
||||
assert.deepEqual(list, loadRequirements());
|
||||
assert.equal(list.requirements.length, 85);
|
||||
assert.equal(list.requirements.length, 87);
|
||||
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
|
||||
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
|
||||
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
|
||||
|
||||
@@ -160,7 +160,7 @@ thread run, and it configures the assistant through `assistants` (Assistant
|
||||
settings, below) and names instances through `set_name`. PRD 1.7 REQ-COS-3
|
||||
(row 194) grants it `role.hire` and `role.fire` within role: it hires and
|
||||
fires with a reason and no decision, through the broker's `instance.hire` and
|
||||
`instance.fire`, which the harness's `hire` and `fire` tools call. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
`instance.fire`, which the harness's `hire` and `fire` tools call. It has the same read-only tools, and its one credential reference is gitea, for the read grant the library mirror fetches with (row 285). The broker holds that token, never the agent. It is the only shipped role that holds `role.launch`, so
|
||||
it is the only role `launch.by` can name. The PM holds no `role.launch`
|
||||
(REQ-LAUNCH-1); it asks the chief of staff for a session.
|
||||
|
||||
@@ -171,7 +171,7 @@ use the shared, broker-backed organization writer. The instance names are the ow
|
||||
example file's:
|
||||
|
||||
```json
|
||||
"chief": { "definition": "chief-of-staff", "vars": { "model": "opus" } },
|
||||
"chief": { "definition": "chief-of-staff", "credentials": { "gitea": { "file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/chief-of-staff-gitea.token", "rotateBy": "YYYY-MM-DD" } }, "vars": { "model": "opus" } },
|
||||
"assistant": { "definition": "assistant", "holder": "mos", "vars": { "model": "opus" } }
|
||||
...
|
||||
"launch": { "by": "chief", "instances": ["coder", "reviewer"], "models": {
|
||||
@@ -180,8 +180,10 @@ example file's:
|
||||
}, "limits": { "models": { "anthropic": { "claude-opus-5-5": 4, "claude-sonnet-5-5": 4 } } } }
|
||||
```
|
||||
|
||||
Neither entry has a `tracker` or `credentials`, because neither role holds
|
||||
any. A session that launches needs a harness and a model, so give each
|
||||
Neither entry has a `tracker`. The chief's `credentials` must name exactly
|
||||
its definition's services, gitea alone, and the assistant has none. A
|
||||
`{ "store": true, "rotateBy": "YYYY-MM-DD" }` reference works as well as a
|
||||
file. Neither form creates an account, mints a token or grants anything. A session that launches needs a harness and a model, so give each
|
||||
instance `vars` (`harness`, `model`) as the PM has, unless the system's
|
||||
execution settings cover it. A `launch.by` that names a PM, an assistant or any other instance
|
||||
whose role lacks `role.launch` is refused. A file that still names the PM
|
||||
|
||||
@@ -113,6 +113,12 @@
|
||||
},
|
||||
"chief": {
|
||||
"definition": "chief-of-staff",
|
||||
"credentials": {
|
||||
"gitea": {
|
||||
"file": "/home/you/.config/mosaic-dev/secrets/mosaic-stack/chief-of-staff-gitea.token",
|
||||
"rotateBy": "YYYY-MM-DD"
|
||||
}
|
||||
},
|
||||
"vars": {
|
||||
"model": "opus"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, unhireableAction,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, CREATIVE_ACTIONS, CREATIVE_SERVICE, unhireableAction,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -159,15 +159,17 @@ function checkContract(contract, file) {
|
||||
}
|
||||
|
||||
// Row 213 (C2-08, Case 2 design 4, M1): a creative role only reads and answers the human. Its file
|
||||
// may name no tool outside CREATIVE_TOOLS, no authority but message.send, and must carry a messaging
|
||||
// key that names only the human, so it can't relay through another role.
|
||||
// may name no tool outside CREATIVE_TOOLS, and must carry a messaging key that names only the human,
|
||||
// so it can't relay through another role. Row 285 (C2-08b, mos-1309 item 5) lets it write supporting
|
||||
// docs: its authority is withinRole CREATIVE_ACTIONS at most, and its one credential is gitea.
|
||||
function checkCreative(role) {
|
||||
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
|
||||
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
|
||||
const actions = [...role.authority.withinRole, ...role.authority.crossRole];
|
||||
if (role.authority.crossRole.length > 0 || actions.some((a) => a !== "message.send")) {
|
||||
refuse(`creative role ${role.name} may hold only withinRole message.send`);
|
||||
if (role.authority.crossRole.length > 0 || role.authority.withinRole.some((a) => !CREATIVE_ACTIONS.includes(a))) {
|
||||
refuse(`creative role ${role.name} may hold only withinRole ${CREATIVE_ACTIONS.join(" and ")}`);
|
||||
}
|
||||
const services = role.credentials.map((c) => c.service).filter((service) => service !== CREATIVE_SERVICE);
|
||||
if (services.length > 0) refuse(`creative role ${role.name} may hold only the ${CREATIVE_SERVICE} credential (got ${services.join(", ")})`);
|
||||
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
|
||||
for (const key of ["sendTo", "receiveFrom"]) {
|
||||
if (role.messaging[key].some((entry) => entry !== "human")) {
|
||||
|
||||
@@ -57,6 +57,10 @@ export const ACTIONS = Object.freeze([
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
// Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library.
|
||||
// Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role
|
||||
// to the creative roles only.
|
||||
"library.write",
|
||||
]);
|
||||
|
||||
// The actions that are not hard waits. PRD 1.5 REQ-WAIT-3: an action added
|
||||
@@ -91,6 +95,9 @@ export const ROUTED = Object.freeze([
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write, within the
|
||||
// bounds that paragraph names.
|
||||
"library.write",
|
||||
]);
|
||||
|
||||
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
|
||||
@@ -202,18 +209,13 @@ export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// Row 155 (#1649) moves it here from the broker, so the organization writer reads the same name.
|
||||
export const THREAD_DEFINITION = "assistant";
|
||||
|
||||
// Row 213 (C2-08, #1714, Case 2 design 4): the creative roles. Each only reads and answers the human
|
||||
// in cited items; its instances are the human's alone (M10).
|
||||
// Row 213 (C2-08, #1714, Case 2 design 4): the creative roles. Each reads and answers the human, and
|
||||
// row 285 (C2-08b, #1790) lets it write supporting docs; its instances are the human's alone (M10).
|
||||
export const CREATIVE_DEFINITIONS = Object.freeze(["companion", "ideas", "research", "proofreader", "fact-checker", "continuity"]);
|
||||
// The reply item kinds each creative definition may send (design 4, 5.2's reply-kind check).
|
||||
export const CREATIVE_KINDS = Object.freeze({
|
||||
companion: Object.freeze(["idea", "question", "finding", "fact"]),
|
||||
ideas: Object.freeze(["idea", "question"]),
|
||||
research: Object.freeze(["fact", "question"]),
|
||||
proofreader: Object.freeze(["correction", "question"]),
|
||||
"fact-checker": Object.freeze(["finding", "question"]),
|
||||
continuity: Object.freeze(["finding", "question"]),
|
||||
});
|
||||
// Row 285 (mos-1309 item 5): the only authority a creative role file may name, all withinRole, and
|
||||
// the one credential service it may name, the definition credential the Gitea grant path reads.
|
||||
export const CREATIVE_ACTIONS = Object.freeze(["message.send", "library.write"]);
|
||||
export const CREATIVE_SERVICE = "gitea";
|
||||
// The only tools a creative role file may name (M1): reading, and the broker's web grant.
|
||||
export const CREATIVE_TOOLS = Object.freeze(["read", "grep", "find", "ls", "web"]);
|
||||
// The definitions that may hold a thread: the assistant and the creative roles. The definition the
|
||||
|
||||
@@ -46,8 +46,21 @@ test("a granted agent loads with its identity, grant and the project's upstream"
|
||||
|
||||
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
|
||||
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
|
||||
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
|
||||
/roles\.chief\.publish needs identity and a gitea definition/);
|
||||
// Row 285 (mos-1333 item 3): the chief's definition now declares gitea, so this case names an
|
||||
// instance whose definition has none.
|
||||
refuses(() => check((d) => { d.roles.helper = { definition: "assistant", holder: "mos", identity: structuredClone(IDENTITY), publish: ["stack"] }; }),
|
||||
/roles\.helper\.publish needs identity and a gitea definition/);
|
||||
});
|
||||
|
||||
// Row 285 (mos-1333 item 4, brief :1829): the chief's grant needs identity and publish on the
|
||||
// instance, so a chief with its gitea reference loads with both. X-G1 (gitea-grant.test) shows the
|
||||
// same chief refuses at raise.
|
||||
test("row 285: a chief with its gitea reference, identity and publish loads", () => {
|
||||
const identity = { name: "Chief", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-chief" } };
|
||||
const business = check((d) => { d.roles.chief.identity = structuredClone(identity); d.roles.chief.publish = ["stack"]; });
|
||||
assert.deepEqual({ ...business.roles.chief.identity, gitea: { ...business.roles.chief.identity.gitea } }, identity);
|
||||
assert.deepEqual([...business.roles.chief.publish], ["stack"]);
|
||||
assert.deepEqual(Object.keys(business.roles.chief.credentials), ["gitea"]);
|
||||
});
|
||||
|
||||
test("publish names declared projects that have a remote, each once", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs";
|
||||
import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
|
||||
import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
test('C2-04: library mappings load without roots, remain frozen, and retain mapping refusals', async t => {
|
||||
@@ -27,6 +28,10 @@ test('C2-04: library mappings load without roots, remain frozen, and retain mapp
|
||||
assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']);
|
||||
doc.projects.books.universes.harbor.playground = ['Second World/Playground/'];
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/);
|
||||
// C2-01d: a supporting prefix inside another universe's book refuses with its own code.
|
||||
doc.projects.books = mappingEntry();
|
||||
doc.projects.books.universes.harbor.supporting = ['Second World/Notes/'];
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/);
|
||||
doc.projects.books = { ...mappingEntry(), root: '/forbidden' };
|
||||
refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/);
|
||||
});
|
||||
@@ -269,28 +274,56 @@ test("the shipped example refuses as written and validates once filled in", () =
|
||||
assert.equal(id, 15);
|
||||
});
|
||||
|
||||
// Row 285 (C2-08b, #1790, mos-1330): a creative definition declares gitea, so its instance names
|
||||
// the reference. The store form needs no token file, and the bus reads a missing one as no token.
|
||||
const CREATIVE_GITEA = Object.freeze({ gitea: Object.freeze({ store: true, rotateBy: "2099-01-01" }) });
|
||||
|
||||
// Row 213 (C2-08, #1714), deviation D1: the persona vars sit on a creative instance only, and
|
||||
// both or neither. The var checks themselves are vars.mjs's.
|
||||
test("persona vars: agent layer, creative definition only, both or neither", () => {
|
||||
const sha = "a".repeat(64);
|
||||
const persona = { "persona.name": "Bean", "persona.sha256": sha };
|
||||
const business = check((d) => { d.roles.bean = { definition: "companion", vars: { ...persona } }; });
|
||||
const business = check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona } }; });
|
||||
assert.deepEqual({ ...business.roles.bean.vars }, persona);
|
||||
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition: "ideas" }; }));
|
||||
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition: "ideas", credentials: CREATIVE_GITEA }; }));
|
||||
refuses(() => check((d) => { d.vars["persona.name"] = "Bean"; }), /persona\.name can't be set at the business layer/);
|
||||
refuses(() => check((d) => { d.roles.pm.vars = { ...d.roles.pm.vars, ...persona }; }),
|
||||
/roles\.pm\.vars: persona vars need a creative definition \(pm isn't one\)/);
|
||||
refuses(() => check((d) => { d.roles.chief.vars = { ...persona }; }), /chief-of-staff isn't one/);
|
||||
for (const key of Object.keys(persona)) {
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "research", vars: { [key]: persona[key] } }; }),
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "research", credentials: CREATIVE_GITEA, vars: { [key]: persona[key] } }; }),
|
||||
/roles\.bean\.vars: persona\.name and persona\.sha256 are set together or not at all/);
|
||||
}
|
||||
for (const [key, value] of [["persona.name", "x".repeat(41)], ["persona.name", "Be\nan"], ["persona.name", "Be\u0007an"], ["persona.name", "Be\u2028an"], ["persona.name", "Be\u2029an"], ["persona.name", 7],
|
||||
["persona.sha256", "A".repeat(64)], ["persona.sha256", sha.slice(1)], ["persona.sha256", `${sha}0`]]) {
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "companion", vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2);
|
||||
refuses(() => check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 285 (C2-08b, #1790, mos-1330): the chief and each creative definition declare gitea, so an
|
||||
// instance without the reference refuses at load with the credential map's message.
|
||||
test("row 285: a chief or creative instance without credentials.gitea refuses at load", () => {
|
||||
refuses(() => check((d) => { delete d.roles.chief.credentials; }),
|
||||
/roles\.chief\.credentials must reference exactly the services its role definition needs \(gitea; got none\)/);
|
||||
for (const definition of CREATIVE_DEFINITIONS) {
|
||||
refuses(() => check((d) => { d.roles.bean = { definition }; }),
|
||||
/roles\.bean\.credentials must reference exactly the services its role definition needs \(gitea; got none\)/);
|
||||
assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition, credentials: CREATIVE_GITEA }; }), definition);
|
||||
}
|
||||
});
|
||||
|
||||
// Row 285 (C2-08b, brief): a creative instance may carry identity and publish, under the rules at
|
||||
// business.mjs:155-166, which the grant path reads. The grant itself still refuses it (#giteaRole).
|
||||
test("row 285: a creative instance with identity and publish loads", () => {
|
||||
const identity = { name: "Bean", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-bean" } };
|
||||
const business = check((d) => {
|
||||
d.projects.stack.remote = "https://git.example/acme/stack";
|
||||
d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, identity, publish: ["stack"] };
|
||||
});
|
||||
assert.deepEqual({ ...business.roles.bean.identity, gitea: { ...business.roles.bean.identity.gitea } }, identity);
|
||||
assert.deepEqual([...business.roles.bean.publish], ["stack"]);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
|
||||
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
|
||||
const plain = check();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ACTIONS, BusinessError, classify, loadBusiness, loadRole, resolveInstance } from "../src/index.mjs";
|
||||
import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
|
||||
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
import { businessDoc, FAR_DATE, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
// Decision 104's list plus role.launch (decision 139) plus role.end and
|
||||
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
|
||||
@@ -20,7 +20,8 @@ const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]),
|
||||
// Row 285 (#1790, mos-1309 item 5, sage-2915): gitea for the library read grant, read scopes only.
|
||||
credentials: Object.freeze([Object.freeze({ service: "gitea", scopes: Object.freeze(["read:repository", "read:user"]) })]),
|
||||
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
|
||||
hireable: Object.freeze([]),
|
||||
});
|
||||
@@ -79,13 +80,17 @@ test("the shipped file is exactly the approved list", () => {
|
||||
});
|
||||
|
||||
test("the resolved record matches the approved list, so a widened file fails", () => {
|
||||
const record = resolved(loadFixture());
|
||||
const fixture = loadFixture();
|
||||
const record = resolved(fixture);
|
||||
assert.deepEqual(record.limits, {
|
||||
tools: APPROVED.tools,
|
||||
network: APPROVED.network,
|
||||
authority: APPROVED.authority,
|
||||
});
|
||||
assert.deepEqual(record.credentials, {});
|
||||
// Row 285 (mos-1330): the fixture's gitea reference, the chief's only one.
|
||||
assert.deepEqual(record.credentials, {
|
||||
gitea: { service: "gitea", file: join(fixture.root, "secrets", "chief-of-staff-gitea.token"), rotateBy: FAR_DATE },
|
||||
});
|
||||
});
|
||||
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => {
|
||||
@@ -149,12 +154,13 @@ test("the contract names no duty to talk to the human and the pm contract no lau
|
||||
assert.match(pm, /You don't launch sessions/);
|
||||
});
|
||||
|
||||
test("the instance takes no credential references and no tracker", () => {
|
||||
// Row 285 (mos-1330): the instance references exactly gitea, its definition's one service, and no tracker.
|
||||
test("the instance takes only the gitea credential reference and no tracker", () => {
|
||||
refuses(
|
||||
() => loadFixture((doc) => {
|
||||
doc.roles[INSTANCE].credentials = { gitea: { file: "/x/token", rotateBy: "2099-01-01" } };
|
||||
doc.roles[INSTANCE].credentials.vikunja = { file: "/x/token", expires: "2099-01-01" };
|
||||
}),
|
||||
/must reference exactly the services its role definition needs \(none; got gitea\)/,
|
||||
/must reference exactly the services its role definition needs \(gitea; got gitea, vikunja\)/,
|
||||
);
|
||||
refuses(
|
||||
() => loadFixture((doc) => {
|
||||
|
||||
@@ -38,6 +38,13 @@ test("REQ-COS-3: role.hire and role.fire are routed, not hard waits", () => {
|
||||
for (const action of ["role.hire", "role.fire"]) assert.ok(ROUTED.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): library.write is in the vocabulary and routed.
|
||||
test("REQ-WAIT-3: library.write is routed under REQ-LIB-6 (1.16), not a hard wait", () => {
|
||||
assert.ok(ACTIONS.includes("library.write"));
|
||||
assert.ok(ROUTED.includes("library.write"));
|
||||
assert.ok(!GATED_ONLY.includes("library.write"));
|
||||
});
|
||||
|
||||
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
|
||||
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
|
||||
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
|
||||
|
||||
@@ -68,9 +68,10 @@ export function businessDoc(root, id = "acme") {
|
||||
cto: role("cto", 4, { holder: "darkwing", vars: { harness: "claude-code" } }),
|
||||
coder: role("coder", 5, { vars: { harness: "pi", "limits.network": "none" } }),
|
||||
reviewer: role("reviewer", 6),
|
||||
// The chief of staff launches (REQ-LAUNCH-1, decision 139); it holds no
|
||||
// credential and no tracker bot.
|
||||
chief: { definition: "chief-of-staff" },
|
||||
// The chief of staff launches (REQ-LAUNCH-1, decision 139); it has no
|
||||
// tracker bot. Its one credential reference is gitea, for the library
|
||||
// read grant (row 285, mos-1330).
|
||||
chief: { definition: "chief-of-staff", credentials: { gitea: ref("chief-of-staff", "gitea") } },
|
||||
},
|
||||
launch: launchDoc(),
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync }
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { projectFilePath } from "../src/project.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
|
||||
const roots = [];
|
||||
@@ -286,7 +286,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
writeFileSync(join(f.root, "config", "personas", "acme", "bean.md"), text, { mode: 0o600 });
|
||||
const persona = { "persona.name": "Bean", "persona.sha256": createHash("sha256").update(text).digest("hex") };
|
||||
const view = f.settings.read("acme");
|
||||
assert.deepEqual(view.definitions.companion, { services: [], accepted: true });
|
||||
// Row 285 (mos-1332 item 4): the companion definition declares gitea.
|
||||
assert.deepEqual(view.definitions.companion, { services: ["gitea"], accepted: true });
|
||||
const add = { instance: "bean", op: "add", definition: "companion", vars: persona };
|
||||
for (const actor of ["chief-of-staff.chief", "tester"]) {
|
||||
assert.throws(() => f.settings.set("acme", { revision: view.revision, ...add }, actor), e => e.code === "organization-creative-human-only", actor);
|
||||
@@ -331,8 +332,8 @@ test("row 213 a creative instance is the human's: add, vars and definition chang
|
||||
// every actor, the human included. The listing's endProtected is that same check for its reader.
|
||||
test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor does, and the assistant stays", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.roles.muse = { definition: "ideas" };
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.roles.muse = { definition: "ideas", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
});
|
||||
const del = (instance, actor) => () => f.settings.set("acme", { revision: f.settings.read("acme").revision, instance, op: "delete" }, actor);
|
||||
@@ -377,7 +378,7 @@ test("row 256 T1-T3, T5 the human deletes a creative instance, no other actor do
|
||||
// instance can't be kept, launch.by or an arbiter, so the check it reaches is the last-listed one.
|
||||
test("row 256 T4 the human's delete of the only listed instance, a creative one, is the last-listed refusal", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.launch.instances = ["bean"];
|
||||
});
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
|
||||
@@ -181,7 +181,7 @@ test('B7 kept instance: its route is the human\'s',async()=>{
|
||||
// can't reach its model through the models table, the instances key or a route; the human can,
|
||||
// except through the instances key, which refuses a creative instance for everyone.
|
||||
test('M10 creative instance: models keys it names, the instances key and its route are the human\'s',async()=>{
|
||||
const f=fixture();const doc=structuredClone(f.doc);doc.roles.bean={definition:'companion',vars:{model:'sonnet'}};writeJson(f.file,doc);
|
||||
const f=fixture();const doc=structuredClone(f.doc);doc.roles.bean={definition:'companion',credentials:{gitea:{store:true,rotateBy:FAR_DATE}},vars:{model:'sonnet'}};writeJson(f.file,doc);
|
||||
const g={...f,doc};const {settings}=await routeSettings(g);const file=()=>readFileSync(f.file,'utf8');
|
||||
const chief='chief-of-staff.chief';let {revision}=settings.read('acme');
|
||||
const models=structuredClone(settings.read('acme').models);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
|
||||
const roots = [];
|
||||
@@ -234,7 +234,7 @@ test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and
|
||||
// every actor's fire, the human's included.
|
||||
test("row 256 checkDelete passes the human's creative delete and refuses another actor's; fire still refuses", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.bean = { definition: "companion" };
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
doc.launch.instances.push("bean");
|
||||
});
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import {
|
||||
BusinessError, classify, projectFilePath, resolveInstance, systemVars, validateBusinessDocument, validateProjectDocument,
|
||||
} from "../src/index.mjs";
|
||||
import { businessDoc, REPO_ROLES, systemFor, tmp } from "./helpers.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO_ROLES, systemFor, tmp } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
@@ -114,7 +114,7 @@ test("role.launch stays within-role only for the instance the launch block names
|
||||
|
||||
// A second chief-of-staff instance is the launcher, so the first one may not launch.
|
||||
const two = setup((d) => {
|
||||
d.roles.chief2 = { definition: "chief-of-staff" };
|
||||
d.roles.chief2 = { definition: "chief-of-staff", credentials: { gitea: { store: true, rotateBy: FAR_DATE } } };
|
||||
d.launch.by = "chief2";
|
||||
});
|
||||
assert.equal(classify(resolve(two, "chief"), "role.launch"), "gated");
|
||||
|
||||
@@ -226,14 +226,16 @@ test("contract: a non-empty regular Markdown file beside the role file", () => {
|
||||
// Row 213 (C2-08, #1714, M1): a creative role reads and answers the human, and nothing else.
|
||||
const CREATIVE = { companion: ["read", "web"], ideas: ["read"], research: ["read", "web"], proofreader: ["read"], "fact-checker": ["read"], continuity: ["read"] };
|
||||
|
||||
test("the six creative roles load: read tools, message.send only, messaging with the human only", () => {
|
||||
// Row 285 (C2-08b, #1790, mos-1309 item 5): each also holds library.write and the gitea credential, whose
|
||||
// scopes equal what the Gitea grant path mints (gitea-grant.mjs SEAT_SCOPES, sage-2915).
|
||||
test("the six creative roles load: read tools, message.send and library.write, gitea, messaging with the human only", () => {
|
||||
for (const [name, tools] of Object.entries(CREATIVE)) {
|
||||
const role = loadRole(REPO_ROLES, name);
|
||||
assert.equal(role.roleVersion, 2, name);
|
||||
assert.deepEqual(role.tools, tools, name);
|
||||
assert.equal(role.network, tools.includes("web") ? "api-only" : "none", name);
|
||||
assert.deepEqual(role.authority, { withinRole: ["message.send"], crossRole: [] }, name);
|
||||
assert.deepEqual(role.credentials, [], name);
|
||||
assert.deepEqual(role.authority, { withinRole: ["message.send", "library.write"], crossRole: [] }, name);
|
||||
assert.deepEqual(role.credentials, [{ service: "gitea", scopes: ["read:user", "write:repository"] }], name);
|
||||
assert.deepEqual(role.messaging, { sendTo: ["human"], receiveFrom: ["human"] }, name);
|
||||
}
|
||||
});
|
||||
@@ -255,8 +257,20 @@ test("M1: a creative role file wider than read and answer is refused", () => {
|
||||
for (const tool of ["write", "edit", "bash"]) {
|
||||
refuses(creative("ideas", { tools: ["read", tool] }), new RegExp(`^creative role ideas may not name tools ${tool} `));
|
||||
}
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send", "record.write"], crossRole: [] } }), /^creative role proofreader may hold only withinRole message.send$/);
|
||||
refuses(creative("proofreader", { authority: { withinRole: [], crossRole: ["message.send"] } }), /^creative role proofreader may hold only withinRole message.send$/);
|
||||
const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }];
|
||||
assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea }));
|
||||
// Row 285: any other action, at either class, refuses, each routed action looped from the list.
|
||||
const only = /^creative role proofreader may hold only withinRole message\.send and library\.write$/;
|
||||
for (const action of ACTIONS.filter((a) => !GATED_ONLY.includes(a) && !["message.send", "library.write"].includes(a))) {
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send", "library.write", action], crossRole: [] } }), only);
|
||||
}
|
||||
refuses(creative("proofreader", { authority: { withinRole: [], crossRole: ["message.send"] } }), only);
|
||||
refuses(creative("proofreader", { authority: { withinRole: ["message.send"], crossRole: ["library.write"] } }), only);
|
||||
// Row 285: library.write with any credential but gitea refuses.
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [...gitea, { service: "vikunja", scopes: { tasks: ["read_one"] } }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got vikunja\)$/);
|
||||
refuses(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: [{ service: "host" }] }),
|
||||
/^creative role ideas may hold only the gitea credential \(got host\)$/);
|
||||
refuses(creative("continuity", { messaging: undefined }), /^creative role continuity requires "messaging"$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human", "pm"], receiveFrom: ["human"] } }), /^creative role research messaging.sendTo may name only human$/);
|
||||
refuses(creative("research", { messaging: { sendTo: ["human"], receiveFrom: ["assistant"] } }), /^creative role research messaging.receiveFrom may name only human$/);
|
||||
|
||||
@@ -115,6 +115,9 @@ function accessFor(system, env, warn) {
|
||||
return { file, context };
|
||||
}
|
||||
|
||||
export const consoleOwners = (sso) =>
|
||||
sso.providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, iss: p.issuer, sub: s.sub })));
|
||||
|
||||
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
|
||||
// business. The host holds a reader capability for the notifier and binds
|
||||
// launches later through bindLaunch, so `launches` starts empty.
|
||||
@@ -135,6 +138,9 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
|
||||
if (tracker) config.trackers = { [b.id]: tracker };
|
||||
const access = accessFor(system, env, warn);
|
||||
if (access) config.access = access;
|
||||
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
|
||||
// The loader checked console.sso; no new config field names owners.
|
||||
if (system.console?.sso) config.consoleOwners = consoleOwners(system.console.sso);
|
||||
return config;
|
||||
});
|
||||
}
|
||||
@@ -528,6 +528,7 @@ export function createLauncher({
|
||||
contract: readFileSync(resolved.contract, "utf8"),
|
||||
arbiter: Object.values(b.arbiters).includes(instance),
|
||||
tracker,
|
||||
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
|
||||
credentials,
|
||||
session: { run, launchedBy, workspace: inSandbox, toolSocket, turnMarker, checkouts: inSandbox === workspace ? checkouts : checkouts.map((c) => ({ ...c, path: join(inSandbox, relative(workspace, c.path)) })) },
|
||||
harness,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { accessContextFor } from "../src/access.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { fixture, tmp } from "./helpers.mjs";
|
||||
|
||||
const SSO = { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } };
|
||||
const SSO = { sso: { providers: [{ id: "authentik", issuer: "https://idp.test", subjects: [{ sub: "jason-sub" }] }] } };
|
||||
const FEDERATION = { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] };
|
||||
const DOC = {
|
||||
accessVersion: 1,
|
||||
|
||||
@@ -69,3 +69,19 @@ test("an unknown business and a broken system config refuse with exit 3", (t) =>
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "nope", env: f.env }), (e) => e.exitCode === 3);
|
||||
assert.throws(() => loadSystem({ env: { ...f.env, MOSAIC_CONFIG: join(f.dataRoot, "missing.json") } }), (e) => e.exitCode === 3);
|
||||
});
|
||||
|
||||
test("Row 252 (Webby N1): every console.sso subject reaches the boot as a console owner, and no sso means no consoleOwners key", (t) => {
|
||||
const f = fixture(tmp(t));
|
||||
const system = loadSystem({ env: f.env });
|
||||
assert.equal("consoleOwners" in bootConfig({ system, businessId: "acme", env: f.env }), false);
|
||||
const sso = { providers: [
|
||||
{ id: "authentik", issuer: "https://auth.example/app/", subjects: [{ sub: "jason-sub" }, { sub: "second-sub" }] },
|
||||
{ id: "other", issuer: "https://idp.example", subjects: [{ sub: "jason-sub" }] },
|
||||
] };
|
||||
const boot = bootConfig({ system: { ...system, console: { sso } }, businessId: "acme", env: f.env });
|
||||
assert.deepEqual(boot.consoleOwners, [
|
||||
{ provider: "authentik", iss: "https://auth.example/app/", sub: "jason-sub" },
|
||||
{ provider: "authentik", iss: "https://auth.example/app/", sub: "second-sub" },
|
||||
{ provider: "other", iss: "https://idp.example", sub: "jason-sub" },
|
||||
]);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { FAR_DATE, launchDoc } from "../../business/tests/helpers.mjs";
|
||||
// The S6 launcher inside a real bus host: real broker child, real runners
|
||||
// in the bwrap sandbox, the fake adapter from packages/harness standing in for the
|
||||
// harness. The chief of staff launches a coder through its `launch` tool (row 119: the
|
||||
@@ -766,7 +766,7 @@ test("row 64 a role skill that isn't enabled refuses skill-not-enabled, and one
|
||||
test("row 213 a creative instance's persona is read from <configDir>/personas: off its digest the launch fails with the mismatch as its reason", { timeout: 30000 }, async (t) => {
|
||||
const text = "Warm. PERSONA-MARKER-d2\n";
|
||||
const given = prepare(t, (doc) => {
|
||||
doc.roles.bean = { definition: "companion", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high", "persona.name": "Bean", "persona.sha256": createHash("sha256").update(text).digest("hex") } };
|
||||
doc.roles.bean = { definition: "companion", credentials: { gitea: { store: true, rotateBy: FAR_DATE } }, vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high", "persona.name": "Bean", "persona.sha256": createHash("sha256").update(text).digest("hex") } };
|
||||
doc.launch.instances.push("bean");
|
||||
return doc;
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
import { mappingEntry } from "../../library/tests/helpers.mjs";
|
||||
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
||||
// legacy thread, with the thread's id.
|
||||
const DEFAULT_SESSION = [
|
||||
@@ -529,3 +530,24 @@ test('C2-09a: terminating one creative thread does not signal an unselected peer
|
||||
await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0]));
|
||||
assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1]));
|
||||
});
|
||||
|
||||
// Row224: the launcher must derive library presence from business projects, not
|
||||
// from session checkouts (a library never has an agent clone).
|
||||
test("C2-05b a real launched bundle gets lookups for a library project without a library checkout", { skip, timeout: 60000 }, async t => {
|
||||
const prepared = prepare(t, { idle: 10, edit: doc => {
|
||||
doc.projects.books = mappingEntry();
|
||||
return doc;
|
||||
} });
|
||||
const { f } = prepared;
|
||||
const host = await start(t, prepared);
|
||||
const project = host.call("project.create", { name: "library work", repo: "stack" }).id;
|
||||
const thread = host.call("thread.create", { project, name: "lookup" }).id;
|
||||
await ask(host.call, thread, "inspect bundle");
|
||||
const run = launchLog(f).find(e => e.event === "launch" && e.thread === thread).run;
|
||||
const tools = JSON.parse(readFileSync(join(f.dataRoot, "launches", "acme", run, "bundle", "tools.json"), "utf8"));
|
||||
assert.deepEqual(tools.filter(tool => tool.name.startsWith("library_")).map(tool => tool.name),
|
||||
["library_find", "library_get", "library_search", "library_manuscript", "library_universe"]);
|
||||
const [session] = threadRuns(f, thread);
|
||||
assert.ok(session);
|
||||
assert.ok(!session.checkouts?.some(checkout => checkout.project === "books"));
|
||||
});
|
||||
@@ -292,6 +292,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
// resolved resolveInstance(...) output
|
||||
// contract the role contract's text
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// library true when the trusted business configuration has a library project
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// credentials the broker's credentialStatus(...) for the instance (metadata only)
|
||||
// session { run, launchedBy, workspace, toolSocket, turnMarker, checkouts }
|
||||
@@ -309,11 +310,11 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
|
||||
// (row 213); an instance with persona.sha256 and no personaDir refuses
|
||||
// Returns { manifest, files, policy, tools, env } with absolute paths; env is the module's
|
||||
// adapter variables, which the host writes into session.json.
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
|
||||
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, library = false, credentials = [], session, harness, route = "primary", routeReason = null, harnessVersion = null, models = null, land = false, skillsDir = null, personaDir = null }) {
|
||||
if (route !== "primary" && route !== "fallback") throw new BundleError(`route ${JSON.stringify(route)} isn't primary or fallback`);
|
||||
const model = sessionModel(resolved, models, route);
|
||||
const web = webPlan(resolved, model.harness, harness.builtinWeb());
|
||||
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const tools = typedTools({ resolved, arbiter, tracker, library, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
|
||||
const untyped = untypedActions(resolved, tools);
|
||||
const persona = readPersona(resolved, personaDir);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -78,9 +78,10 @@ const TASK_TOOLS = {
|
||||
|
||||
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION", "BLOCK"];
|
||||
|
||||
// typedTools({ resolved, arbiter, tracker, projects, land }) -> [{ name, verb, action, description, parameters }]
|
||||
// typedTools({ resolved, arbiter, tracker, projects, land, library }) -> [{ name, verb, action, description, parameters }]
|
||||
// resolved resolveInstance(...) output for the session's instance
|
||||
// arbiter true when the business names this instance as an arbiter
|
||||
// library true when the trusted business configuration has a library project
|
||||
// tracker true when the broker has task verbs for the business
|
||||
// projects the project ids the session has a clone of
|
||||
// land true when the role and thread hold a merge decision a landing could use (landHeld)
|
||||
@@ -88,7 +89,7 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
|
||||
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
|
||||
// to the host instead, and "working.push", "working.fetch" and
|
||||
// "working.land" go to the host's git socket.
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false }) {
|
||||
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
|
||||
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
|
||||
const tools = [];
|
||||
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
||||
@@ -222,6 +223,48 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
url: str("The https url to fetch", { maxLength: 2048 }),
|
||||
}, ["url"]));
|
||||
}
|
||||
// Row224: no library clone or token reaches a session. The broker chooses the served
|
||||
// commit and requires a universe on every call; cursors page without cutting replies.
|
||||
if (library) {
|
||||
const universe = str("Universe ID in the configured library", { minLength: 1 });
|
||||
const project = str("Library project ID; omit only when the business has one library");
|
||||
const scope = str("canon by default; playground and supporting are opt-in", { enum: ["canon", "playground", "supporting"] });
|
||||
const manuscriptScope = str("canon by default; playground is opt-in", { enum: ["canon", "playground"] });
|
||||
const book = str("Book folder from library_universe");
|
||||
const offset = { type: "integer", minimum: 0, description: "next from the previous page" };
|
||||
const line = { type: "integer", minimum: 1, description: "One-based line number" };
|
||||
const base = { universe, project };
|
||||
add("library_find", "library.find", null, "Find names in one universe, at the served commit. At most 50 results; continue with next as offset. Playground and supporting are opt-in.", obj({
|
||||
...base, query: str("Case-insensitive name or filename substring; empty matches all"),
|
||||
type: str("Mapped type", { enum: ["character", "place", "research", "tracker", "summary"] }),
|
||||
book, scope, offset,
|
||||
}, ["universe", "query"]));
|
||||
add("library_get", "library.get", null, "Read a note and its blob at the served commit, canon by default. Playground/supporting paths need matching scope; mismatch returns null. next pages lines; links_next pages visible links in supporting/playground/canon order.", obj({
|
||||
...base, path: str("Mapped note path relative to the library"), scope, from: line, links_from: offset,
|
||||
}, ["universe", "path"]));
|
||||
add("library_search", "library.search", null, "Search text in one universe at the served commit. At most 50 hits; use next as offset. Playground and supporting are opt-in.", obj({
|
||||
...base, text: str("Nonempty case-insensitive substring", { minLength: 1 }), book, scope, offset,
|
||||
}, ["universe", "text"]));
|
||||
add("library_manuscript", "library.manuscript", null, "Read one chapter of a book at the served commit, at most 400 lines. Omit chapter for the first; continue with next or next_chapter.", obj({
|
||||
...base, book, scope: manuscriptScope, chapter: str("Chapter filename without extension"), from: line, to: line,
|
||||
}, ["universe", "book"]));
|
||||
add("library_universe", "library.universe", null, "Read a universe's mapping at the served commit: books, types, playground prefixes, and hybrid sources/constraints/notes or draft metadata.", obj(base, ["universe"]));
|
||||
// C2-02: write tools carry only the note/base/content. The broker supplies the writer,
|
||||
// credential and fixed commit message; a creative within-role grant is required.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition) && resolved.limits.authority.withinRole.includes("library.write")) {
|
||||
const writeBase = {
|
||||
universe,
|
||||
path: str("A .md path under this universe's human-marked supporting folder"),
|
||||
base: { type: ["string", "null"], pattern: "^[a-f0-9]{40}$", description: "The blob from library_get, or null to create; never the commit id" },
|
||||
};
|
||||
add("library_doc_write", "library.doc.write", "library.write", "Create or edit one supporting .md note using the blob from library_get. One commit by your identity, with a fixed human notice. A stale base refuses; agent requests retain the 64 KiB envelope limit.", obj({
|
||||
...writeBase, content: str("UTF-8 note text without NUL; at most 1 MiB and bounded by the agent socket envelope"),
|
||||
}, ["universe", "path", "base", "content"]));
|
||||
add("library_doc_delete", "library.doc.delete", "library.write", "Delete one existing regular supporting .md note using the blob from library_get. One commit and a fixed human notice. A stale base or nonregular/missing target refuses.", obj({ ...writeBase,
|
||||
base: { ...writeBase.base, description: "The existing file's blob from library_get; a missing file cannot be deleted" },
|
||||
}, ["universe", "path", "base"]));
|
||||
}
|
||||
}
|
||||
add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({
|
||||
project: str("Project ID from the business file"),
|
||||
commit: str("Optional full historical commit ID", { pattern: "^[0-9a-f]{40}$" }),
|
||||
@@ -277,6 +320,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
if (held.has("task.create"))
|
||||
add("list_requirements", "requirements", null, "List the approved PRD's requirement ids, each with a one-line title. task_create takes only these. When none fits a request, raise a prd.approve decision instead of creating the task.", obj({}));
|
||||
}
|
||||
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
|
||||
// C2-08 web grant still supplies broker web reads to companion and research only.
|
||||
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
|
||||
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
|
||||
tool.name === "web_search" || tool.name === "web_fetch");
|
||||
return tools;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Row224 M3: exact creative tool sets preserve the existing C2-08 web grants.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { typedTools } from '../src/tools.mjs';
|
||||
import { buildBundle } from '../src/bundle.mjs';
|
||||
import { TYPES } from '../../library/src/mapping.mjs';
|
||||
import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from './helpers.mjs';
|
||||
|
||||
const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe'];
|
||||
const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity'];
|
||||
const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
|
||||
d.roles.bean = { definition, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
|
||||
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
|
||||
|
||||
test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
|
||||
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
|
||||
assert.ok(tools.every(t => t.parameters.additionalProperties === false));
|
||||
}
|
||||
});
|
||||
|
||||
test('every noncreative role gets lookups when a library is configured, not otherwise', t => {
|
||||
for (const instance of ['coder', 'reviewer', 'pm', 'chief']) {
|
||||
const resolved = resolvedFor(t, instance);
|
||||
const ordinary = typedTools({ resolved }).map(t => t.name);
|
||||
assert.ok(!ordinary.some(name => name.startsWith('library_')));
|
||||
const tools = typedTools({ resolved, library: true });
|
||||
assert.deepEqual(tools.filter(t => t.name.startsWith('library_')).map(t => t.name), LOOKUPS);
|
||||
assert.deepEqual(tools.filter(t => !t.name.startsWith('library_')).map(t => t.name), ordinary);
|
||||
}
|
||||
});
|
||||
|
||||
test('lookup schemas require a universe even for get, include paging, and cannot select repo or commit', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
const required = [ ['universe', 'query'], ['universe', 'path'], ['universe', 'text'], ['universe', 'book'], ['universe'] ];
|
||||
for (const [i, tool] of tools.filter(t => LOOKUPS.includes(t.name)).entries()) {
|
||||
assert.deepEqual(tool.parameters.required, required[i]);
|
||||
assert.equal(tool.verb, LOOKUPS[i].replace('_', '.'));
|
||||
assert.equal(tool.action, null);
|
||||
assert.equal(tool.parameters.additionalProperties, false);
|
||||
for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false);
|
||||
}
|
||||
const byName = Object.fromEntries(tools.map(t => [t.name, t]));
|
||||
assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
assert.deepEqual(byName.library_find.parameters.properties.type.enum, [...TYPES]);
|
||||
assert.equal(byName.library_find.parameters.properties.offset.minimum, 0);
|
||||
assert.equal(byName.library_get.parameters.properties.from.minimum, 1);
|
||||
assert.equal(byName.library_get.parameters.properties.links_from.minimum, 0);
|
||||
assert.equal(byName.library_manuscript.parameters.properties.chapter.type, 'string');
|
||||
});
|
||||
|
||||
test('bundle flag reaches both harness tool manifests and creative prompt without generic tools', t => {
|
||||
for (const definition of CREATIVE) for (const harness of ['pi', 'claude-code']) {
|
||||
const dir = scratch(t), workspace = join(dir, 'ws'); mkdirSync(workspace);
|
||||
const b = buildBundle({ dir: join(dir, 'bundle'), resolved: role(t, definition, harness), library: true,
|
||||
contract: '# Creative\n', tracker: true, arbiter: true, skillsDir: REPO_SKILLS,
|
||||
session: { run: 'r224', launchedBy: 'chief', workspace, toolSocket: join(dir, 't.sock'), turnMarker: join(dir, 'turn.done') },
|
||||
harness: HARNESS[harness] });
|
||||
assert.deepEqual(b.tools.map(t => t.name), expected(definition), `${definition} ${harness}`);
|
||||
const serialized = JSON.parse(readFileSync(b.files.tools, 'utf8'));
|
||||
assert.deepEqual(serialized.map(t => t.name), expected(definition));
|
||||
const prompt = readFileSync(b.files.prompt, 'utf8');
|
||||
assert.match(prompt, /library_find/);
|
||||
assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
|
||||
for (const name of ['library_find', 'library_search', 'library_get']) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
|
||||
assert.ok(!tool.parameters.required.includes('scope'));
|
||||
}
|
||||
assert.deepEqual(tools.find(t => t.name === 'library_manuscript').parameters.properties.scope.enum, ['canon', 'playground']);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Row 288 M3: creative write tools and independent closed-schema coverage.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { typedTools, untypedActions } from '../src/tools.mjs';
|
||||
import { resolvedFor } from './helpers.mjs';
|
||||
|
||||
const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe'];
|
||||
const WRITES = ['library_doc_write', 'library_doc_delete'];
|
||||
const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity'];
|
||||
const role = (t, definition) => resolvedFor(t, 'bean', doc => {
|
||||
doc.roles.bean = { definition, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
|
||||
});
|
||||
|
||||
test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => {
|
||||
for (const definition of CREATIVE) {
|
||||
const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true });
|
||||
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES];
|
||||
assert.deepEqual(tools.map(tool => tool.name), expected, definition);
|
||||
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8);
|
||||
assert.equal(untypedActions(resolved, tools).includes('library.write'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02 schemas: both write tools require universe/path/base, close keys, map verb and action', t => {
|
||||
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
|
||||
for (const [name, verb, required] of [
|
||||
['library_doc_write', 'library.doc.write', ['universe', 'path', 'base', 'content']],
|
||||
['library_doc_delete', 'library.doc.delete', ['universe', 'path', 'base']],
|
||||
]) {
|
||||
const tool = tools.find(tool => tool.name === name);
|
||||
assert.ok(tool, name); assert.equal(tool.verb, verb); assert.equal(tool.action, 'library.write');
|
||||
assert.equal(tool.parameters.type, 'object'); assert.equal(tool.parameters.additionalProperties, false);
|
||||
assert.deepEqual(tool.parameters.required, required);
|
||||
assert.deepEqual(Object.keys(tool.parameters.properties).sort(), [...required].sort());
|
||||
assert.equal(tool.parameters.properties.universe.type, 'string');
|
||||
assert.equal(tool.parameters.properties.path.type, 'string');
|
||||
assert.deepEqual(tool.parameters.properties.base.type, ['string', 'null']);
|
||||
assert.equal(tool.parameters.properties.base.pattern, '^[a-f0-9]{40}$');
|
||||
if (name === 'library_doc_write') {
|
||||
assert.equal(tool.parameters.properties.content.type, 'string');
|
||||
assert.match(tool.parameters.properties.base.description, /null to create/);
|
||||
} else {
|
||||
assert.match(tool.parameters.properties.base.description, /existing file/);
|
||||
assert.doesNotMatch(tool.parameters.properties.base.description, /create/);
|
||||
}
|
||||
for (const field of ['repo', 'commit', 'business', 'accept', 'project', 'identity', 'author', 'credential', 'agent', 'message'])
|
||||
assert.equal(Object.hasOwn(tool.parameters.properties, field), false, `${name} ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02 tools: no library, noncreative or no within-role write grant exposes neither write tool', t => {
|
||||
const creative = role(t, 'ideas');
|
||||
assert.equal(typedTools({ resolved: creative }).some(tool => WRITES.includes(tool.name)), false);
|
||||
for (const instance of ['coder', 'reviewer', 'pm', 'chief'])
|
||||
assert.equal(typedTools({ resolved: resolvedFor(t, instance), library: true }).some(tool => WRITES.includes(tool.name)), false);
|
||||
const narrowed = structuredClone(creative);
|
||||
narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write');
|
||||
narrowed.limits.authority.crossRole.push('library.write');
|
||||
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]);
|
||||
});
|
||||
+141
-6
@@ -31,33 +31,72 @@ deep-frozen copy. `where` names the entry in messages. `fail(code, message)` def
|
||||
- `user` is the bot account name from design step 3, required for sync. It is optional in the
|
||||
mapping and checked with row 199's account name pattern, `[A-Za-z0-9_.-]`, 1 to 100 characters.
|
||||
- `vaultPrefix` is optional, one folder name.
|
||||
- `editor` is optional, `{ "name", "email" }` plus an optional `"user"`: the ident her Console saves commit under
|
||||
(C2-01d). Each value is text that is non-empty after trim, with no control character
|
||||
(`U+0000`-`U+001F`, `U+007F`-`U+009F`), `<` or `>`, and no whitespace or git-stripped character at
|
||||
either end. `user` is her Gitea login for the `library-human` slot (sage-2925), checked as the
|
||||
entry's `user` is. `editor` and its `user` come back only when given; a save without them refuses
|
||||
in C2-02.
|
||||
- `types` maps a folder name to `character`, `place`, `research`, `tracker` or `summary`.
|
||||
`manuscript` lists folder names and shares none with `types`.
|
||||
- A universe has `name`, `books` and `playground`. A hybrid adds `kind: "hybrid"`, two or more
|
||||
plain-universe `sources`, `constraints` (each one `{ "exclude": ... }` or
|
||||
`{ "pin": { "entity", "field", "value" } }`) and `notes`, and may have `books: []`. A draft
|
||||
universe has only `kind: "draft"`, `name` and one or more `playground` prefixes, and comes back as
|
||||
`{ kind: "draft", name, books: [], playground }`. `kind` is absent, `"hybrid"` or `"draft"`.
|
||||
`{ kind: "draft", name, books: [], playground, supporting }`. `kind` is absent, `"hybrid"` or `"draft"`.
|
||||
- A playground prefix ends in `/` and has one of three levels (C2-01c). Book level lies inside one of
|
||||
its own entry's book folders. Universe level, in a plain universe or a hybrid, lies outside every
|
||||
book folder. A draft's prefixes all lie outside every book folder. No prefix holds another or is
|
||||
listed twice, across every entry.
|
||||
- Every universe kind may also have `supporting`, a list of prefixes where agents may write (C2-01d,
|
||||
M12). It comes back on every universe, `[]` when absent. A supporting prefix follows the playground's
|
||||
rules: it ends in `/`, has the same three levels, holds no other supporting prefix and is listed
|
||||
once, across every entry.
|
||||
- Between the two lists: a supporting prefix may equal a playground prefix of its own entry, and a
|
||||
playground prefix may hold one of its own entry's supporting prefixes. A supporting prefix may not
|
||||
hold a playground prefix, which scope would then never reach, and may not equal, hold or lie inside
|
||||
another entry's playground prefix.
|
||||
|
||||
Refusals:
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `library-playground-other-universe` | a playground prefix lies inside, or holds, a book folder of another entry |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground prefix that holds another or is listed twice, a prefix equal to a book folder or holding its own entry's book, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
|
||||
| `library-supporting-other-universe` | a supporting prefix lies inside, or holds, a book folder of another entry |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, an unknown `kind`, a book folder in two universes, a book folder that holds another, a playground or supporting prefix that holds another of its list or is listed twice, a prefix equal to a book folder or holding its own entry's book, a supporting prefix that holds a playground prefix or overlaps another entry's, a bad `editor`, a draft with `books` or with no prefix, a hybrid source that is unknown, a hybrid or a draft |
|
||||
|
||||
## Scope
|
||||
|
||||
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
|
||||
mapped. It checks prefixes first. A path under any entry's playground prefix is `playground`, with that
|
||||
entry as `universe` and the book folder holding the prefix as `book`, or `null` at universe level and
|
||||
mapped. It checks prefixes first, supporting before playground. A path under any entry's supporting
|
||||
prefix is `supporting`, and otherwise one under a playground prefix is `playground`. Either way that
|
||||
entry is `universe` and the book folder holding the prefix is `book`, or `null` at universe level and
|
||||
in a draft. Any other path under a book folder is `canon`. Everything else is `unmapped`. The type is
|
||||
the first folder after the playground prefix, or after the book: a `types` name, `manuscript`, or
|
||||
`null`.
|
||||
the first folder after the prefix, or after the book: a `types` name, `manuscript`, or `null`.
|
||||
|
||||
## Agent write paths
|
||||
|
||||
`supportingPath(mapping, universe, path, tree)` returns `path` when an agent may write it in that
|
||||
universe, else it refuses `library-write-story-path` (C2-01d, M12). `tree` is the array `listTree`
|
||||
returns for the commit the write lands on; anything else is a TypeError. It reads no git. The checks
|
||||
run in this order, so a path with two faults always names the same one:
|
||||
|
||||
1. a string of at most 1,024 UTF-8 bytes;
|
||||
2. no control character (the `editor` class), and the message leaves the path out;
|
||||
3. no backslash;
|
||||
4. not absolute;
|
||||
5. no empty, `.` or `..` segment, which also refuses `//` and a trailing `/`;
|
||||
6. `classify` gives `supporting` in that same universe, which refuses unmapped, `.canvas`, `.base`
|
||||
and attachment paths, canon, a playground-only folder and another universe's prefix;
|
||||
7. no folder on the way, and not the path itself, is a symlink (`120000`) or gitlink (`160000`) in
|
||||
`tree`;
|
||||
8. no folder on the way, and not the path itself, matches a tree path or tree folder after
|
||||
`normalize('NFC').toLowerCase()` unless it is that exact name. APFS folds case and Unicode form,
|
||||
so her checkout would put the file somewhere else. A tree that already holds case twins refuses a
|
||||
write to either (R7).
|
||||
|
||||
A delete's further rule, that the path exists at the tip as a `100644` blob, belongs to the C2-02
|
||||
broker.
|
||||
|
||||
## Reader
|
||||
|
||||
@@ -180,6 +219,45 @@ and `library.status` shows the result once it ends. A sync that outlasts the 120
|
||||
`request-timeout` from the server and still finishes the same way. The Console has no library route yet: `cli.mjs`
|
||||
passes `librarySync` to `startServer`, and `serve.mjs` doesn't read it.
|
||||
|
||||
### Push and credentials (C2-03b)
|
||||
|
||||
`push(context, { commit, credential, retried })` sends a full commit id, already in the mirror, to the
|
||||
entry's `branch` as `push --porcelain --no-verify --no-follow-tags --end-of-options <url>
|
||||
<commit>:refs/heads/<branch>`, through the same prefix, env and helper as the fetch. The refspec has no
|
||||
`+`, so the remote takes a fast-forward only.
|
||||
|
||||
| git says | Answer |
|
||||
|---|---|
|
||||
| the push landed | `{ pushed: true }` |
|
||||
| `[rejected] (fetch first)` or `(non-fast-forward)` | one fetch with the same credential, then `{ pushed: false, raced: true, tip }` |
|
||||
| the same, with `retried: true` | `stale-base` |
|
||||
| the same, while a sync of the mirror runs | `library-sync-busy`, with no fetch |
|
||||
| anything else, `[remote rejected]` (a hook) included | `library-push-failed` |
|
||||
|
||||
Push moves no local ref; C2-02 re-applies and moves `served`. git's output is never kept.
|
||||
|
||||
A credential is `{ kind, user, file }`, with `kind` one of `slot`, `chief`, `seat` or `human`, or the
|
||||
bus's `{ refused: code }`. The helper reads only `file`. It opens the file on fd 3 and refuses unless
|
||||
the open file and the path have the same device and inode, and the file is regular, the bus's uid's,
|
||||
mode 0600 and at most 16384 bytes, as `readSecretFile` in `packages/bus/src/gitea-grant.mjs` checks a
|
||||
grant store file. A FIFO swapped in after `test -f` blocks the open, the same residual
|
||||
`readSecretFile` has.
|
||||
|
||||
- A seat writes with its own Gitea user and grant store file, `<storeRoot>/<business>/<agent>/gitea`.
|
||||
- `humanCredential(context, { user, configDir })` gives her save its credential: her Gitea login and
|
||||
`<configDir>/secrets/<business>/library-human`. A missing login, or a slot that is missing, a link,
|
||||
another uid's or not 0600, refuses `library-human-missing` before any git call.
|
||||
- The fetch takes the broker's choice, `libraryFetchCredential(business)`. The chief is a `roles`
|
||||
entry whose definition is `chief-of-staff`; a fire keeps it there. With no Gitea grant evidence for
|
||||
a chief, the fetch uses slot `library-gitea` and the entry's `user`. Once there is some, only the one
|
||||
listed chief's live, unheld grant answers, with that grant's user and store file. Anything else
|
||||
refuses `library-fetch-no-grant`, and so does any Gitea grant evidence in a business with no chief
|
||||
entry. That refusal opens no file and runs no git. The bus answers its code; the timer's log line
|
||||
adds `fix: "add the chief of staff, or grant the library through C2-21"`.
|
||||
|
||||
The sync state adds `credential`, `{ kind, user }` of the last fetch, or `null` when none ran. It is
|
||||
optional, so a state written before C2-03b still reads.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
@@ -188,3 +266,60 @@ node --test packages/library/tests/
|
||||
|
||||
Fixtures are made-up repos built per test in a temp dir. A marker string stands in for her text, and
|
||||
the survey tests check that it never appears in output.
|
||||
|
||||
### C2-02 supporting writes and human saves
|
||||
|
||||
The host exposes `library.doc.write {universe,path,base,content}` and
|
||||
`library.doc.delete {universe,path,base}` only to a creative session holding
|
||||
`library.write` and a live, unheld Gitea grant for the library project. The
|
||||
broker chooses the project from the universe mapping, the author/committer
|
||||
from the instance identity, and the credential from its grant store. `base`
|
||||
is the served-tree blob id, or `null` to create. Supporting-path checks run
|
||||
before remote effects and against each fetched tip; deletes require an
|
||||
existing `100644` blob. Content is UTF-8 without NUL and at most 1 MiB;
|
||||
agent requests still have the ordinary 64 KiB socket envelope limit.
|
||||
|
||||
Each operation is one plumbing commit with one parent, a fixed
|
||||
`Mosaic: write <path>` or `Mosaic: delete <path>` message, and the trailer
|
||||
`Mosaic-Agent: <business>/<instance>`. Pushes are fast-forward only. A moved
|
||||
remote tip retries once when the target blob still equals `base`; otherwise
|
||||
it refuses `stale-base`. A failed push leaves no queued work or local ref
|
||||
change. A successful push updates served/tracking, rebuilds the index and
|
||||
records `lastPush: {commit,at}`. The broker inserts the exact fixed notice
|
||||
`{notice:"library-write",op,path,commit}` from the writer to the human in
|
||||
that thread, independently of the agent-message citation guard.
|
||||
|
||||
`library.file.save {project,path,base,content}` and
|
||||
`library.revert {project,commit}` are human-only. Project is the business
|
||||
project key. A save reaches any mapped `.md` file under the structural path
|
||||
rules, uses the mapping's `editor` as author and committer, and pushes with
|
||||
slot `library-human`. Revert accepts only a reachable, one-parent,
|
||||
one-path commit with this business's agent trailer, and restores its
|
||||
parent's blob with the agent commit's blob as `base`. The result is
|
||||
`{project,path,reverted,commit}`. Revoking/firing the original writer does
|
||||
not prevent a human revert. Missing editor refuses
|
||||
`library-mapping-invalid`; missing/unsafe human slot refuses
|
||||
`library-human-missing`, before Git. An agent calling either human verb
|
||||
gets `human-required`. Only `library.file.save` joins the larger Console
|
||||
save envelope exception; agent writes retain 64 KiB.
|
||||
|
||||
The write guard excludes concurrent sync and admits a race fetch only for
|
||||
its opaque owner token. The library flock spans fetch/build/push as the
|
||||
brief requires, so readers can receive `library-sync-busy` after the
|
||||
existing five-second lock wait while network Git runs (MVP tradeoff,
|
||||
sage-3628). Publication errors return `outcome-unknown`: served is restored
|
||||
to match old state unless the new state was already published. The remote
|
||||
commit remains; callers must inspect status rather than retry blindly.
|
||||
|
||||
The human push login is `editor.user` (optional in the mapping, required
|
||||
for save/revert), distinct from `user`, which names the fetch bot.
|
||||
An absent editor login refuses `library-human-missing`; no fallback uses
|
||||
the bot's login. Once an agent push succeeds, the broker stores the exact
|
||||
write notice with that pushed commit id before local publication. If
|
||||
publication then fails, the agent receives `outcome-unknown` and the
|
||||
notice remains. Revert can refuse `library-revert-not-agent` until a sync
|
||||
makes the commit reachable from served. No notice outbox or retry is
|
||||
needed. If the Console store itself refuses the notice insert, the caller
|
||||
still receives `outcome-unknown` and a separate metadata-only host log
|
||||
records `library-notice-store-failed` with the business, instance, operation
|
||||
and pushed commit id; it contains no file text or credential.
|
||||
@@ -2,6 +2,11 @@
|
||||
# C2-03a: git's GIT_ASKPASS for library sync (packages/library/src/sync.mjs). It answers only the
|
||||
# Username and Password prompts for the configured host and user, and exits 1 on anything else.
|
||||
# The env names the slot path, never the token. printf is a shell builtin, so the token is in no argv.
|
||||
# C2-03b: the slot is whichever file the bus chose (a grant store file, library-gitea or library-human),
|
||||
# read with readSecretFile's checks (gitea-grant.mjs): a regular file the bus's uid owns, mode 0600, at
|
||||
# most 16384 bytes. The checks run on the open fd 3, and its dev:inode must match the path's own
|
||||
# (an lstat), so a link or a swap before the open refuses and the token comes from the checked file
|
||||
# (dewey-0860). Residual, as in readSecretFile: a FIFO swapped in after test -f blocks the open.
|
||||
host=$MOSAIC_LIBRARY_HOST user=$MOSAIC_LIBRARY_USER slot=$MOSAIC_LIBRARY_SLOT
|
||||
test -n "$host" && test -n "$user" && test -n "$slot" || exit 1
|
||||
case $host in *[!a-z0-9.:-]*) exit 1 ;; esac
|
||||
@@ -10,7 +15,14 @@ case $1 in
|
||||
"Username for 'https://$host': ") printf '%s\n' "$user" ;;
|
||||
"Password for 'https://$user@$host': ")
|
||||
test -f "$slot" && test ! -L "$slot" || exit 1
|
||||
IFS= read -r token < "$slot" || test -n "$token" || exit 1
|
||||
exec 3<"$slot" || exit 1
|
||||
open=$(stat -L -c '%d:%i %u %a %s %F' /dev/fd/3 2>/dev/null) || exit 1
|
||||
path=$(stat -c '%d:%i' -- "$slot" 2>/dev/null) || exit 1
|
||||
set -- $open
|
||||
test "$1" = "$path" && test "$2" = "$(id -u)" && test "$3" = 600 && test "$4" -le 16384 \
|
||||
&& test "$5 $6" = "regular file" && test $# -eq 6 || exit 1
|
||||
IFS= read -r token <&3 || test -n "$token" || exit 1
|
||||
exec 3<&-
|
||||
test -n "$token" || exit 1
|
||||
printf '%s\n' "$token" ;;
|
||||
*) exit 1 ;;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// The lookup index of design 3 (C2-05a): types, names, links and backlinks for one commit of her repo.
|
||||
// It is built from the reader's output, so it holds only mapped, readable files: R1, R2 and R7 leave a
|
||||
// file out, and an unresolved link is not in it. It is cached in memory by commit id. The key also
|
||||
// carries the repo path and a digest of the mapping, because one commit reads differently under another
|
||||
// mapping. The index is not universe-aware; query.mjs decides what a universe may see.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readLibrary } from './reader.mjs';
|
||||
import { raise } from './problems.mjs';
|
||||
|
||||
const CACHE_LIMIT = 8;
|
||||
const cache = new Map();
|
||||
|
||||
const push = (map, key, value) => {
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key).push(value);
|
||||
};
|
||||
// A file's name for lookups: its front matter `name` when it is text, else its file name.
|
||||
const displayName = file => {
|
||||
const name = file.frontmatter?.name;
|
||||
return typeof name === 'string' && name.trim() ? name.trim() : file.name;
|
||||
};
|
||||
// A link as the index keeps it: the file at the other end, its line, and the field when front matter holds it.
|
||||
const linkAt = (path, link) => ({ path, line: link.line, ...(link.field === undefined ? {} : { field: link.field }) });
|
||||
|
||||
// Builds the index from readLibrary's output. Pure: no git, no clock.
|
||||
export function buildIndex(library, mapping) {
|
||||
const files = new Map(), types = new Map(), names = new Map(), linksOut = new Map(), linksIn = new Map();
|
||||
for (const file of library.files) {
|
||||
const name = displayName(file);
|
||||
files.set(file.path, {
|
||||
path: file.path, scope: file.scope, universe: file.universe, book: file.book, type: file.type,
|
||||
folder: file.folder, stem: file.name, name, frontmatter: file.frontmatter, lines: file.lines,
|
||||
});
|
||||
if (file.type !== null) push(types, file.type, file.path);
|
||||
push(names, name.toLowerCase(), file.path);
|
||||
linksOut.set(file.path, file.links.map(link => linkAt(link.path, link)));
|
||||
for (const link of file.links) push(linksIn, link.path, linkAt(file.path, link));
|
||||
}
|
||||
return Object.freeze({
|
||||
commit: library.commit, mapping, files, paths: [...files.keys()], types, names, linksOut, linksIn,
|
||||
problems: library.problems,
|
||||
});
|
||||
}
|
||||
|
||||
const digest = mapping => createHash('sha256').update(JSON.stringify(mapping)).digest('hex');
|
||||
|
||||
// The index for one commit under one mapping, read once and kept in memory. A repeat call returns the same
|
||||
// object. Past CACHE_LIMIT entries the oldest goes. A failed read is not kept.
|
||||
export async function indexFor({ repo, commit, mapping }) {
|
||||
if (!mapping || mapping.kind !== 'library') raise('library-mapping-invalid', 'expected a checked library mapping');
|
||||
const key = JSON.stringify([repo, commit, digest(mapping)]);
|
||||
if (cache.has(key)) {
|
||||
const hit = cache.get(key);
|
||||
cache.delete(key);
|
||||
cache.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
const pending = readLibrary({ repo, commit, mapping }).then(library => buildIndex(library, mapping));
|
||||
cache.set(key, pending);
|
||||
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
if (cache.get(key) === pending) cache.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearIndexCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export function indexCacheSize() {
|
||||
return cache.size;
|
||||
}
|
||||
@@ -2,9 +2,14 @@
|
||||
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
||||
export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } from './scope.mjs';
|
||||
export { supportingPath } from './supporting.mjs';
|
||||
export { extractLinks, parseWikilink, resolver, formOf, isAttachment } from './links.mjs';
|
||||
export { readLibrary, listTree, readTexts, parseFile, parseFrontMatter, headCommit, MAX_BLOB, LINK_FIELDS } from './reader.mjs';
|
||||
export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
|
||||
export { 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 { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, CREDENTIAL_KINDS, SYNC_INTERVAL_MS } from './sync.mjs';
|
||||
|
||||
export { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from './write.mjs';
|
||||
export { buildIndex, indexFor, clearIndexCache, indexCacheSize } from './index-build.mjs';
|
||||
export { sees, find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from './query.mjs';
|
||||
@@ -1,8 +1,9 @@
|
||||
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
|
||||
// books, type folders, manuscript folders and playground prefixes at three levels (ruling after r11,
|
||||
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. A pure validator. Business file
|
||||
// loading wires it in C2-04, which passes its own `fail` so the refusal reads as a business file
|
||||
// problem. Agents can't write this entry (M9, C2-04).
|
||||
// C2-01c): inside one of the entry's books, outside every book, or in a draft universe. Supporting
|
||||
// prefixes (C2-01d) follow the same rules and are where agents may write (M12). A pure validator.
|
||||
// Business file loading wires it in C2-04, which passes its own `fail` so the refusal reads as a
|
||||
// business file problem. Agents can't write this entry (M9, C2-04).
|
||||
import { raise } from './problems.mjs';
|
||||
|
||||
export const TYPES = Object.freeze(['character', 'place', 'research', 'tracker', 'summary']);
|
||||
@@ -12,6 +13,12 @@ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
||||
const USER = /^[A-Za-z0-9_.-]{1,100}$/;
|
||||
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
||||
const KINDS = ['hybrid', 'draft'];
|
||||
// C2-01d (F41): the editor ident her Console saves commit under. Git drops `<` and `>` from an ident
|
||||
// and commits under a different name, so they refuse here (webby-0169 R1).
|
||||
const IDENT_BAD = /[\u0000-\u001f\u007f-\u009f<>]/;
|
||||
// Git's ident.c crud() strips any byte <= 0x20 and , : ; < > " \ ' from both ends of an ident, so a
|
||||
// value that starts or ends with one would save one name and commit another (sage-3601, sage-3606).
|
||||
const IDENT_CRUD_END = /^[\u0000-\u0020,:;<>"\\']|[\u0000-\u0020,:;<>"\\']$/;
|
||||
|
||||
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
||||
// and dot names are not.
|
||||
@@ -40,7 +47,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
};
|
||||
|
||||
object(entry, '');
|
||||
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'universes', 'types', 'manuscript'], 'entry');
|
||||
only(entry, ['kind', 'remote', 'branch', 'user', 'vaultPrefix', 'editor', 'universes', 'types', 'manuscript'], 'entry');
|
||||
if (entry.kind !== 'library') bad('kind must be "library"');
|
||||
let url;
|
||||
try { url = new URL(entry.remote); } catch { bad('remote must be an https:// URL'); }
|
||||
@@ -52,6 +59,20 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (Object.hasOwn(entry, 'user') && (typeof entry.user !== 'string' || !USER.test(entry.user)))
|
||||
bad('user must be a plain account name');
|
||||
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
|
||||
if (Object.hasOwn(entry, 'editor')) {
|
||||
object(entry.editor, 'editor');
|
||||
only(entry.editor, ['name', 'email', 'user'], 'editor');
|
||||
// Her Gitea login for the library-human slot, which C2-02 passes in (sage-2925). Optional here.
|
||||
if (Object.hasOwn(entry.editor, 'user') && (typeof entry.editor.user !== 'string' || !USER.test(entry.editor.user)))
|
||||
bad('editor.user must be a plain account name');
|
||||
for (const key of ['name', 'email']) {
|
||||
const value = entry.editor[key];
|
||||
if (typeof value !== 'string' || !value.trim() || IDENT_BAD.test(value))
|
||||
bad(`editor.${key} must be non-empty text with no control character, < or >`);
|
||||
if (value.trim() !== value || IDENT_CRUD_END.test(value))
|
||||
bad(`editor.${key} must not start or end with whitespace or any of , : ; " ' \\`);
|
||||
}
|
||||
}
|
||||
|
||||
object(entry.types, 'types');
|
||||
for (const [name, type] of Object.entries(entry.types)) {
|
||||
@@ -64,6 +85,7 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
object(entry.universes, 'universes');
|
||||
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
||||
const universes = {}, bookOwner = new Map(), prefixes = [], prefixOwner = new Map();
|
||||
const supports = [], supportOwner = new Map();
|
||||
for (const [id, universe] of Object.entries(entry.universes)) {
|
||||
const name = `universes.${id}`;
|
||||
if (!ID.test(id)) bad(`universe id ${JSON.stringify(id)} is not a plain id`);
|
||||
@@ -71,8 +93,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (Object.hasOwn(universe, 'kind') && !KINDS.includes(universe.kind)) bad(`${name}.kind must be "hybrid" or "draft"`);
|
||||
const hybrid = universe.kind === 'hybrid', draft = universe.kind === 'draft';
|
||||
if (draft && Object.hasOwn(universe, 'books')) bad(`${name} is a draft and takes no books`);
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes']
|
||||
: draft ? ['kind', 'name', 'playground'] : ['name', 'books', 'playground'], name);
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'supporting', 'constraints', 'notes']
|
||||
: draft ? ['kind', 'name', 'playground', 'supporting'] : ['name', 'books', 'playground', 'supporting'], name);
|
||||
if (typeof universe.name !== 'string' || !universe.name.trim() || universe.name.length > 128 || /[\u0000-\u001f]/.test(universe.name))
|
||||
bad(`${name}.name must be a short display name`);
|
||||
const books = draft ? [] : list(universe.books, `${name}.books`, folder, 'a folder path');
|
||||
@@ -81,12 +103,14 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
if (bookOwner.has(book)) bad(`book folder ${JSON.stringify(book)} is in two universes`);
|
||||
bookOwner.set(book, id);
|
||||
}
|
||||
const playground = list(universe.playground ?? [], `${name}.playground`,
|
||||
value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1)), 'a folder path ending in /');
|
||||
const prefix = value => typeof value === 'string' && value.endsWith('/') && folder(value.slice(0, -1));
|
||||
const playground = list(universe.playground ?? [], `${name}.playground`, prefix, 'a folder path ending in /');
|
||||
if (draft && !playground.length) bad(`${name}.playground must name at least one prefix`);
|
||||
for (const prefix of playground) { prefixes.push(prefix); prefixOwner.set(prefix, id); }
|
||||
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground }; continue; }
|
||||
const out = { name: universe.name, books, playground };
|
||||
for (const p of playground) { prefixes.push(p); prefixOwner.set(p, id); }
|
||||
const supporting = list(universe.supporting ?? [], `${name}.supporting`, prefix, 'a folder path ending in /');
|
||||
for (const p of supporting) { supports.push(p); supportOwner.set(p, id); }
|
||||
if (draft) { universes[id] = { kind: 'draft', name: universe.name, books, playground, supporting }; continue; }
|
||||
const out = { name: universe.name, books, playground, supporting };
|
||||
if (hybrid) {
|
||||
out.kind = 'hybrid';
|
||||
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
||||
@@ -124,18 +148,36 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
for (const a of books) for (const b of books) if (a !== b && holds(a, b)) bad(`book folder ${JSON.stringify(a)} holds book folder ${JSON.stringify(b)}`);
|
||||
for (const a of prefixes) for (const b of prefixes) if (a !== b && holds(a, b)) bad(`playground ${JSON.stringify(a)} holds playground ${JSON.stringify(b)}`);
|
||||
if (new Set(prefixes).size !== prefixes.length) bad('a playground prefix is listed twice');
|
||||
for (const a of supports) for (const b of supports) if (a !== b && holds(a, b)) bad(`supporting ${JSON.stringify(a)} holds supporting ${JSON.stringify(b)}`);
|
||||
if (new Set(supports).size !== supports.length) bad('a supporting prefix is listed twice');
|
||||
// A prefix's level comes from where it sits (ruling after r11). Inside one of its own entry's books
|
||||
// is book level, outside every book is universe level, and a draft's prefixes are all outside. Scope
|
||||
// checks prefixes first (scope.mjs), so a prefix equal to or holding a book would turn that book
|
||||
// into playground.
|
||||
for (const [prefix, id] of prefixOwner) {
|
||||
for (const [book, owner] of bookOwner) {
|
||||
const at = `universes.${id}.playground ${JSON.stringify(prefix)}`;
|
||||
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
||||
const inside = holds(book, prefix), holding = holds(prefix, book);
|
||||
if ((inside || holding) && owner !== id)
|
||||
fail('library-playground-other-universe', `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
||||
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
||||
// into playground or supporting.
|
||||
const againstBooks = (owners, list, code) => {
|
||||
for (const [prefix, id] of owners) {
|
||||
for (const [book, owner] of bookOwner) {
|
||||
const at = `universes.${id}.${list} ${JSON.stringify(prefix)}`;
|
||||
if (prefix === `${book}/`) bad(`${at} is the whole book folder ${JSON.stringify(book)}`);
|
||||
const inside = holds(book, prefix), holding = holds(prefix, book);
|
||||
if ((inside || holding) && owner !== id)
|
||||
fail(code, `${where} ${at} ${inside ? 'lies inside' : 'holds'} book folder ${JSON.stringify(book)} of universes.${owner}`);
|
||||
if (holding) bad(`${at} holds its own book folder ${JSON.stringify(book)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
againstBooks(prefixOwner, 'playground', 'library-playground-other-universe');
|
||||
againstBooks(supportOwner, 'supporting', 'library-supporting-other-universe');
|
||||
// C2-01d, between the lists. A supporting prefix may equal a playground prefix of its own entry, and
|
||||
// a playground may hold one of its own entry's supporting prefixes. A supporting prefix that holds a
|
||||
// playground would hide it, since scope checks supporting first. Any overlap across entries would
|
||||
// give one path two universes.
|
||||
for (const [s, sid] of supportOwner) {
|
||||
for (const [p, pid] of prefixOwner) {
|
||||
const at = `universes.${sid}.supporting ${JSON.stringify(s)}`;
|
||||
if (sid !== pid && (s === p || holds(s, p) || holds(p, s)))
|
||||
bad(`${at} overlaps playground ${JSON.stringify(p)} of universes.${pid}`);
|
||||
if (sid === pid && s !== p && holds(s, p)) bad(`${at} holds playground ${JSON.stringify(p)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +185,8 @@ export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
kind: 'library', remote: entry.remote, branch: entry.branch,
|
||||
...(Object.hasOwn(entry, 'user') ? { user: entry.user } : {}),
|
||||
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
||||
...(Object.hasOwn(entry, 'editor') ? { editor: { name: entry.editor.name, email: entry.editor.email,
|
||||
...(Object.hasOwn(entry.editor, 'user') ? { user: entry.editor.user } : {}) } } : {}),
|
||||
universes, types: { ...entry.types }, manuscript,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,7 +219,13 @@ async function readStateHeld(mirror, { branch } = {}) {
|
||||
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, used)));
|
||||
} catch { fail('library-state-invalid', 'invalid sync state'); }
|
||||
finally { if (fd !== undefined) closeSync(fd); }
|
||||
if (!keys(value, ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems'])
|
||||
// C2-03b adds `credential`, { kind, user } or null. It is optional, so a state from before it reads.
|
||||
const listed = ['version', 'branch', 'served', 'lastFetch', 'lastError', 'diverged', 'problems'];
|
||||
const expected = [...listed, ...['credential', 'lastPush'].filter(key => Object.hasOwn(value ?? {}, key))];
|
||||
if (!keys(value, expected)
|
||||
|| !optional(value.lastPush ?? null, v => keys(v, ['commit', 'at']) && SHA.test(v.commit) && utc(v.at))
|
||||
|| !optional(value.credential ?? null, v => keys(v, ['kind', 'user']) && ['slot', 'chief', 'seat', 'human'].includes(v.kind)
|
||||
&& typeof v.user === 'string' && /^[A-Za-z0-9_.-]{1,100}$/.test(v.user))
|
||||
|| value.version !== 1 || value.branch !== branch || value.served !== served
|
||||
|| !optional(value.served, v => typeof v === 'string' && SHA.test(v)) || !optional(value.lastFetch, utc)
|
||||
|| !optional(value.lastError, v => keys(v, ['code', 'at']) && typeof v.code === 'string' && /^[a-z-]{1,64}$/.test(v.code) && utc(v.at))
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// The lookup queries of design 3 (C2-05a): find, get, search, manuscript and universeInfo. Each takes a
|
||||
// source {repo, commit, mapping} and an argument object, and returns a reply that fits QUERY_REPLY_CAP.
|
||||
// Universe isolation is here, in sees(): a file reaches a reply only when sees() allows it. Scope comes
|
||||
// from the path (D2). Unmapped files are not in the index, so they never appear. Lists page by position
|
||||
// and never cut an item. A search line too long to fit alone comes back as a marked hit with no excerpt;
|
||||
// a find item, a file's front matter or one of its lines that cannot fit alone is refused.
|
||||
import { raise } from './problems.mjs';
|
||||
import { TYPES } from './mapping.mjs';
|
||||
import { indexFor } from './index-build.mjs';
|
||||
import { listTree } from './reader.mjs';
|
||||
|
||||
// The same cap as packages/harness/src/tools.mjs REPLY_CAP. The query tests assert the two stay equal.
|
||||
export const QUERY_REPLY_CAP = 40_000;
|
||||
export const LOOKUP_LIMIT = 50;
|
||||
export const MANUSCRIPT_LINES = 400;
|
||||
const SCOPES = Object.freeze(['canon', 'playground', 'supporting']);
|
||||
const MANUSCRIPT_SCOPES = Object.freeze(['canon', 'playground']);
|
||||
const SCOPE_RANK = Object.freeze({ supporting: 0, playground: 1, canon: 2 });
|
||||
|
||||
const bytes = value => JSON.stringify(value).length;
|
||||
const invalid = message => raise('query-argument-invalid', message);
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
function textArg(value, name) {
|
||||
if (typeof value !== 'string') invalid(`${name} must be text`);
|
||||
return value;
|
||||
}
|
||||
function scopeArg(value, allowed = SCOPES) {
|
||||
if (value === undefined) return 'canon';
|
||||
if (!allowed.includes(value)) invalid(`scope must be one of ${allowed.join(", ")}`);
|
||||
return value;
|
||||
}
|
||||
function positionArg(value, name, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
if (!Number.isSafeInteger(value) || value < 1) invalid(`${name} must be a line number of 1 or more`);
|
||||
return value;
|
||||
}
|
||||
function offsetArg(value) {
|
||||
if (value === undefined) return 0;
|
||||
if (!Number.isSafeInteger(value) || value < 0) invalid('offset must be a whole number of 0 or more');
|
||||
return value;
|
||||
}
|
||||
function universeArg(mapping, value) {
|
||||
if (typeof value !== 'string' || !value) invalid('universe is required');
|
||||
if (!Object.hasOwn(mapping.universes, value)) raise('query-universe-unknown', `this library has no universe ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Whether universe U may see a file. Its own files, in either scope. A hybrid also sees the canon of
|
||||
// its declared sources, minus the folders its exclude constraints name. A hybrid never sees a source's
|
||||
// playground. Pin constraints do not hide anything; they are reported by universeInfo only.
|
||||
export function sees(mapping, universe, file) {
|
||||
if (file.universe === universe) return true;
|
||||
const entry = mapping.universes[universe];
|
||||
if (entry.kind !== 'hybrid' || !entry.sources.includes(file.universe) || file.scope !== 'canon') return false;
|
||||
return !entry.constraints.some(c => c.exclude !== undefined && inside(c.exclude, file.path));
|
||||
}
|
||||
|
||||
// The files one universe may see in one scope, with optional book and type filters, in path order.
|
||||
async function visible(source, universe, { scope, book, type }) {
|
||||
const index = await indexFor(source);
|
||||
const files = index.paths
|
||||
.map(path => index.files.get(path))
|
||||
.filter(file => file.scope === scope && sees(index.mapping, universe, file)
|
||||
&& (book === undefined || file.book === book) && (type === undefined || file.type === type));
|
||||
return { index, files };
|
||||
}
|
||||
|
||||
// A reply whose size is over the cap is refused, never cut.
|
||||
function checked(reply) {
|
||||
if (bytes(reply) > QUERY_REPLY_CAP) raise('query-item-too-large', `the reply is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
return reply;
|
||||
}
|
||||
|
||||
// Picks items from `offset`, at most `limit`, while the reply still fits. build(picked, next) returns
|
||||
// the reply; `next` is the position of the first item not picked, or null when none is left. An item
|
||||
// that cannot fit even alone is refused: skipping it would hide it, and cutting it would break it.
|
||||
function fitPage(items, { offset = 0, limit = LOOKUP_LIMIT, build }) {
|
||||
const picked = [];
|
||||
let at = offset;
|
||||
while (at < items.length && picked.length < limit) {
|
||||
// The size check uses items.length for `next`, the widest number a cursor can take, so a page never runs over.
|
||||
if (bytes(build([...picked, items[at]], items.length)) > QUERY_REPLY_CAP) {
|
||||
if (!picked.length) raise('query-item-too-large', `the item at position ${at} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
break;
|
||||
}
|
||||
picked.push(items[at]);
|
||||
at += 1;
|
||||
}
|
||||
return checked(build(picked, at < items.length ? at : null));
|
||||
}
|
||||
|
||||
// find: the files whose name or file name holds `query`, case-insensitive. Up to 50 per page.
|
||||
export async function find(source, { universe: name, query, type, book, scope, offset } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const needle = textArg(query, 'query').toLowerCase();
|
||||
if (type !== undefined && !TYPES.includes(type)) invalid(`type must be one of ${TYPES.join(', ')}`);
|
||||
const { index, files } = await visible(source, universe, {
|
||||
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'), type,
|
||||
});
|
||||
const hits = files.filter(file => file.name.toLowerCase().includes(needle) || file.stem.toLowerCase().includes(needle));
|
||||
return fitPage(hits, {
|
||||
offset: offsetArg(offset),
|
||||
build: (picked, next) => ({
|
||||
commit: index.commit,
|
||||
results: picked.map(file => ({
|
||||
path: file.path, type: file.type, name: file.name, appears_in: file.frontmatter?.appears_in ?? null,
|
||||
scope: file.scope, universe: file.universe, book: file.book,
|
||||
})),
|
||||
next,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// get: one file in the requested scope (canon default), its served-tree blob, front matter,
|
||||
// lines and visible links both ways. Links keep all visible scopes, ordered supporting/playground/canon.
|
||||
// Returns null when the file is missing or not visible, so a caller cannot tell the two apart. Lines page
|
||||
// by `from` (reply `next`), links page together by `links_from` (reply `links_next`); `links_out` come
|
||||
// before `links_in`. Every page carries the front matter. Links take at most half the cap, so a hub note
|
||||
// with many backlinks stays readable and still leaves room for lines. A page with lines left carries at
|
||||
// least one line, so links shrink when a line needs the room; read on with `from: next` and
|
||||
// `links_from: links_next`. A `from` past the last line returns links alone. Only front matter, or a
|
||||
// line that cannot fit with no links beside it, is refused.
|
||||
export async function get(source, { universe: name, path, scope, from, links_from: linksFrom } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const requestedScope = scopeArg(scope);
|
||||
const startLine = positionArg(from, 'from', 1);
|
||||
const linkStart = offsetArg(linksFrom);
|
||||
if (typeof path !== 'string') invalid('path must be text');
|
||||
const index = await indexFor(source);
|
||||
const file = index.files.get(path);
|
||||
if (!file || file.scope !== requestedScope || !sees(index.mapping, universe, file)) return null;
|
||||
const reachable = links => links.filter(link => {
|
||||
const other = index.files.get(link.path);
|
||||
return other !== undefined && sees(index.mapping, universe, other);
|
||||
}).sort((a, b) => SCOPE_RANK[index.files.get(a.path).scope] - SCOPE_RANK[index.files.get(b.path).scope]);
|
||||
const outs = reachable(index.linksOut.get(file.path) ?? []);
|
||||
const ins = reachable(index.linksIn.get(file.path) ?? []);
|
||||
const links = [...outs.map(link => ({ out: true, link })), ...ins.map(link => ({ out: false, link }))];
|
||||
// The reader/index does not keep tree oids. Resolve only in this immutable served
|
||||
// snapshot, never HEAD; listTree already checks/bounds the tree and its object IDs.
|
||||
const entry = (await listTree(source.repo, index.commit)).find(entry => entry.path === file.path);
|
||||
if (!entry || entry.mode !== '100644' || entry.type !== 'blob') raise('library-commit', 'indexed file is absent from the served tree');
|
||||
const blob = entry.oid;
|
||||
const lines = file.lines.slice(startLine - 1);
|
||||
const linkRest = Math.max(0, links.length - linkStart);
|
||||
const page = (nLines, nLinks) => {
|
||||
const taken = links.slice(linkStart, linkStart + nLinks);
|
||||
return {
|
||||
path: file.path, commit: index.commit, blob, scope: file.scope, frontmatter: file.frontmatter,
|
||||
lines: lines.slice(0, nLines),
|
||||
links_out: taken.filter(item => item.out).map(item => item.link),
|
||||
links_in: taken.filter(item => !item.out).map(item => item.link),
|
||||
next: nLines < lines.length ? startLine + nLines : null,
|
||||
links_next: nLinks < linkRest ? linkStart + nLinks : null,
|
||||
};
|
||||
};
|
||||
// How many leading items fit in `room`, by each item's own size.
|
||||
const count = (items, room) => {
|
||||
let used = 0, n = 0;
|
||||
while (n < items.length && used + bytes(items[n]) + 1 <= room) used += bytes(items[n++]) + 1;
|
||||
return n;
|
||||
};
|
||||
const base = bytes(page(0, 0));
|
||||
if (base > QUERY_REPLY_CAP) raise('query-item-too-large', `the front matter of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
if (lines.length && bytes(page(1, 0)) > QUERY_REPLY_CAP) {
|
||||
raise('query-item-too-large', `line ${lines[0][0]} of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
}
|
||||
const rest = links.slice(linkStart);
|
||||
let nLinks = count(rest, (lines.length ? Math.floor(QUERY_REPLY_CAP / 2) : QUERY_REPLY_CAP) - base);
|
||||
if (!nLinks && rest.length) nLinks = count(rest, QUERY_REPLY_CAP - base);
|
||||
if (!nLinks && rest.length) raise('query-item-too-large', `a link of ${file.path} alone is over the ${QUERY_REPLY_CAP}-character cap`);
|
||||
while (lines.length && nLinks > 0 && bytes(page(1, nLinks)) > QUERY_REPLY_CAP) nLinks = Math.floor(nLinks / 2);
|
||||
let nLines = Math.max(lines.length ? 1 : 0, count(lines, QUERY_REPLY_CAP - bytes(page(0, nLinks))));
|
||||
while (nLines > 1 && bytes(page(nLines, nLinks)) > QUERY_REPLY_CAP) nLines -= 1;
|
||||
return checked(page(nLines, nLinks));
|
||||
}
|
||||
|
||||
// search: lines whose text holds `text`, case-insensitive. The excerpt is the whole line. Up to 50 per page.
|
||||
export async function search(source, { universe: name, text, book, scope, offset } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const needle = textArg(text, 'text').toLowerCase();
|
||||
if (!needle) invalid('text must not be empty');
|
||||
const { index, files } = await visible(source, universe, {
|
||||
scope: scopeArg(scope), book: book === undefined ? undefined : textArg(book, 'book'),
|
||||
});
|
||||
const hits = [];
|
||||
for (const file of files) {
|
||||
for (const [line, body] of file.lines) {
|
||||
if (!body.toLowerCase().includes(needle)) continue;
|
||||
const hit = { path: file.path, line, excerpt: body, scope: file.scope };
|
||||
// The excerpt is the whole line. A line too long to fit alone is marked, not cut and not refused.
|
||||
hits.push(bytes({ commit: index.commit, results: [hit], next: Number.MAX_SAFE_INTEGER }) > QUERY_REPLY_CAP
|
||||
? { path: file.path, line, excerpt: null, excerpt_too_long: true, scope: file.scope } : hit);
|
||||
}
|
||||
}
|
||||
return fitPage(hits, {
|
||||
offset: offsetArg(offset),
|
||||
build: (picked, next) => ({ commit: index.commit, results: picked, next }),
|
||||
});
|
||||
}
|
||||
|
||||
// manuscript: one chapter file of a book, by its file name, up to 400 lines per call from `from` to `to`.
|
||||
// Without a chapter it returns the book's first chapter file in path order. Every reply carries
|
||||
// `next_chapter`, the next chapter file's name in path order or null. A name that matches two visible
|
||||
// files is refused. Files outside every book (book null) are never chapters of a book.
|
||||
export async function manuscript(source, { universe: name, book, scope, chapter, from, to } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const bookName = textArg(book, 'book');
|
||||
const chapterName = chapter === undefined ? undefined : textArg(chapter, 'chapter');
|
||||
const start = positionArg(from, 'from', 1);
|
||||
const end = positionArg(to, 'to', Infinity);
|
||||
if (end < start) invalid('to must not be before from');
|
||||
const { index, files } = await visible(source, universe, { scope: scopeArg(scope, MANUSCRIPT_SCOPES), book: bookName, type: 'manuscript' });
|
||||
let at = 0;
|
||||
if (chapterName !== undefined) {
|
||||
const matches = files.filter(file => file.stem === chapterName);
|
||||
if (matches.length > 1) raise('query-chapter-ambiguous', `${matches.length} visible files are named ${JSON.stringify(chapterName)}`);
|
||||
if (!matches.length) return null;
|
||||
at = files.indexOf(matches[0]);
|
||||
}
|
||||
const file = files[at];
|
||||
if (!file) return null;
|
||||
const nextChapter = files[at + 1]?.stem ?? null;
|
||||
return fitPage(file.lines.slice(start - 1, end), {
|
||||
limit: MANUSCRIPT_LINES,
|
||||
build: (picked, next) => ({
|
||||
path: file.path, commit: index.commit, scope: file.scope, lines: picked,
|
||||
next: next === null ? null : start + next, next_chapter: nextChapter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// universeInfo: a universe's kind, name, books, playground prefixes, type folders and manuscript folders.
|
||||
// A hybrid adds its sources, constraints and notes. A draft has no books and no canon: its kind, name
|
||||
// and prefixes are the whole answer beside the mapping's types. It reads only the checked mapping and the
|
||||
// served commit.
|
||||
export async function universeInfo(source, { universe: name } = {}) {
|
||||
const universe = universeArg(source.mapping, name);
|
||||
const entry = source.mapping.universes[universe];
|
||||
const hybrid = entry.kind === 'hybrid';
|
||||
return checked({
|
||||
commit: source.commit, universe, name: entry.name, kind: entry.kind ?? 'universe',
|
||||
books: entry.books, playground: entry.playground, types: source.mapping.types, manuscript: source.mapping.manuscript,
|
||||
...(hybrid ? {
|
||||
sources: entry.sources, constraints: entry.constraints,
|
||||
...(entry.notes === undefined ? {} : { notes: entry.notes }),
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
@@ -2,16 +2,22 @@
|
||||
// Paths are repo-relative and compared exactly, case included (R7 handles case-only twins).
|
||||
const inside = (folder, path) => path.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
||||
|
||||
// { scope: 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||
// Prefixes come first (ruling after r11, C2-01c): a `.md` file under any entry's playground prefix is
|
||||
// playground, with that entry as `universe` and the book folder that holds the prefix as `book`, or
|
||||
// null at universe level and in a draft. Otherwise a `.md` file in a book folder is canon. `type` is
|
||||
// the mapped type of the first folder under the prefix (playground) or under the book (canon),
|
||||
// { scope: 'supporting' | 'playground' | 'canon', universe, book, type, folder } or { scope: 'unmapped' }.
|
||||
// Prefixes come first (ruling after r11, C2-01c), supporting before playground (C2-01d, design 1.2):
|
||||
// a `.md` file under any entry's supporting prefix is supporting, and otherwise one under a playground
|
||||
// prefix is playground. Either way `universe` is that entry and `book` is the book folder that holds
|
||||
// the prefix, or null at universe level and in a draft. Otherwise a `.md` file in a book folder is
|
||||
// canon. `type` is the mapped type of the first folder under the prefix or under the book (canon),
|
||||
// 'manuscript' for a manuscript folder, else null. `folder` is that folder name. checkMapping keeps
|
||||
// prefixes from overlapping, so at most one matches.
|
||||
// prefixes of one list from overlapping, and keeps a supporting prefix from holding a playground
|
||||
// prefix, so the first match is the only one that counts.
|
||||
export function classify(mapping, path) {
|
||||
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
|
||||
const entries = Object.entries(mapping.universes);
|
||||
for (const [universe, entry] of entries) {
|
||||
const prefix = entry.supporting.find(p => inside(p, path));
|
||||
if (prefix) return place(mapping, path, prefix, { scope: 'supporting', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||
}
|
||||
for (const [universe, entry] of entries) {
|
||||
const prefix = entry.playground.find(p => inside(p, path));
|
||||
if (prefix) return place(mapping, path, prefix, { scope: 'playground', universe, book: entry.books.find(b => inside(b, prefix)) ?? null });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// C2-01d (M12): the paths an agent may write. A path passes only under a supporting prefix of the
|
||||
// named universe, and only if nothing in the tree could turn the write into something else: a
|
||||
// symlink or gitlink on the way, or a name that differs from a tree name only in case or Unicode
|
||||
// form (APFS folds both, so her checkout would put the file somewhere else). Pure; reads no git.
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
|
||||
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
||||
const MAX_BYTES = 1024;
|
||||
const LINKS = new Set(['120000', '160000']);
|
||||
const fold = name => name.normalize('NFC').toLowerCase();
|
||||
|
||||
// `tree` is the array listTree returns ({ mode, type, oid, size, path }). Returns `path` or throws
|
||||
// library-write-story-path naming the class. Checks run in a fixed order, so a path with two faults
|
||||
// always names the same one.
|
||||
export function supportingPath(mapping, universe, path, tree) {
|
||||
if (!Array.isArray(tree)) throw new TypeError('supportingPath needs the listTree array');
|
||||
const refuse = message => { throw new LibraryError('library-write-story-path', message); };
|
||||
if (typeof path !== 'string') refuse('the path must be a string');
|
||||
const at = JSON.stringify(path);
|
||||
if (Buffer.byteLength(path, 'utf8') > MAX_BYTES) refuse(`${at} is longer than ${MAX_BYTES} bytes`);
|
||||
if (CONTROL.test(path)) refuse('the path holds a control character');
|
||||
if (path.includes('\\')) refuse(`${at} holds a backslash`);
|
||||
if (path.startsWith('/')) refuse(`${at} is absolute`);
|
||||
const parts = path.split('/');
|
||||
if (parts.some(part => part === '' || part === '.' || part === '..')) refuse(`${at} has an empty, . or .. segment`);
|
||||
const place = classify(mapping, path);
|
||||
if (place.scope !== 'supporting' || place.universe !== universe)
|
||||
refuse(`${at} is not under a supporting folder of universe ${JSON.stringify(universe)}`);
|
||||
const own = parts.map((_, i) => parts.slice(0, i + 1).join('/'));
|
||||
const modes = new Map(tree.map(entry => [entry.path, entry.mode]));
|
||||
for (const p of own) if (LINKS.has(modes.get(p))) refuse(`${at} goes through the symlink or gitlink ${JSON.stringify(p)}`);
|
||||
const names = new Map();
|
||||
for (const entry of tree) {
|
||||
const segs = entry.path.split('/');
|
||||
for (let i = 1; i <= segs.length; i++) {
|
||||
const name = segs.slice(0, i).join('/');
|
||||
const key = fold(name);
|
||||
if (!names.has(key)) names.set(key, new Set());
|
||||
names.get(key).add(name);
|
||||
}
|
||||
}
|
||||
for (const p of own) {
|
||||
const seen = names.get(fold(p));
|
||||
if (seen && [...seen].some(name => name !== p))
|
||||
refuse(`${at} differs only in case or Unicode form from ${JSON.stringify([...seen].find(name => name !== p))}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export async function survey({ repo, commit, mapping }) {
|
||||
forms, attachments, fields,
|
||||
};
|
||||
if (mapping) {
|
||||
const scopes = { canon: 0, playground: 0, unmapped: 0 };
|
||||
const scopes = { canon: 0, playground: 0, supporting: 0, unmapped: 0 };
|
||||
for (const entry of entries) scopes[classify(mapping, entry.path).scope]++;
|
||||
const library = await readLibrary({ repo, commit: head, mapping });
|
||||
const steps = Object.fromEntries(STEPS.map(s => [s, 0]));
|
||||
@@ -89,7 +89,7 @@ export function formatSurvey(s) {
|
||||
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}; attachment ${s.attachments}`);
|
||||
for (const [field, c] of Object.entries(s.fields)) out.push(`field ${field} wikilink ${c.wikilink}, name ${c.name}`);
|
||||
if (s.scopes) {
|
||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||
out.push(`scopes canon ${s.scopes.canon}, playground ${s.scopes.playground}, supporting ${s.scopes.supporting}, unmapped ${s.scopes.unmapped}; indexed ${s.indexed}`);
|
||||
out.push(`resolved ${STEPS.map(st => `${st} ${s.steps[st]}`).join(', ')}, unresolved ${s.steps.unresolved}`);
|
||||
out.push(`problems ${Object.entries(s.problems).map(([code, n]) => `${code} ${n}`).join(', ')}`);
|
||||
}
|
||||
|
||||
+147
-12
@@ -2,6 +2,9 @@
|
||||
// configured branch into refs/remotes/library/<branch>. A fast-forward moves refs/heads/served,
|
||||
// a non-fast-forward holds it until a human accepts, and a failed fetch keeps serving. The token
|
||||
// never reaches argv, env, config, a URL or a log: git's GIT_ASKPASS helper reads it from the slot.
|
||||
// C2-03b: the same command pushes, fast-forward only, and the file the helper reads is the one
|
||||
// credential the caller chose: a seat's grant store file, the chief's for the fetch, slot
|
||||
// library-gitea before any chief grant, or slot library-human for her save (ruling after r11).
|
||||
import { constants, closeSync, fsyncSync, lstatSync, openSync, renameSync, rmSync, writeSync } from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { dirname, join } from 'node:path';
|
||||
@@ -11,10 +14,13 @@ import { SAFE, git } from '../../project/src/git.mjs';
|
||||
import { checkMapping } from './mapping.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { readLibrary } from './reader.mjs';
|
||||
import { indexFor } from './index-build.mjs';
|
||||
import { SYNC_STATE_FILE, ensureMirror, withLibraryLock, servedCommit } from './mirror.mjs';
|
||||
|
||||
export const ASKPASS = fileURLToPath(new URL('../bin/askpass', import.meta.url));
|
||||
export const SLOT = 'library-gitea';
|
||||
export const HUMAN_SLOT = 'library-human';
|
||||
export const CREDENTIAL_KINDS = Object.freeze(['slot', 'chief', 'seat', 'human']);
|
||||
export const SYNC_INTERVAL_MS = 60_000;
|
||||
// Row 199's patterns (packages/seat/src/working.mjs).
|
||||
const HOST = /^[a-z0-9.-]{1,253}(:[0-9]{1,5})?$/;
|
||||
@@ -27,10 +33,21 @@ const fail = (code, message = code) => { throw new LibraryError(code, message);
|
||||
// One sync per mirror in this process. The library lock covers publication only, so a second
|
||||
// fetch into the same mirror is refused here rather than racing the first.
|
||||
const running = new Set();
|
||||
// C2-02: opaque identity lets only the owning write re-enter push's race fetch.
|
||||
const writers = new Map();
|
||||
export async function withLibraryWriteGuard(mirror, operation) {
|
||||
if (running.has(mirror)) fail('library-sync-busy');
|
||||
const token = Object.freeze({});
|
||||
running.add(mirror); writers.set(mirror, token);
|
||||
try { return await operation(token); }
|
||||
finally { writers.delete(mirror); running.delete(mirror); }
|
||||
}
|
||||
|
||||
// The one command sync runs against her remote. `kind` is 'fetch'; there is no other.
|
||||
export function syncCommand(kind, url, { mirror, branch, user, slot }) {
|
||||
if (kind !== 'fetch') fail('library-remote-invalid', 'sync only fetches');
|
||||
// The one command builder for her remote. `kind` is 'fetch' or 'push'. A push sends `commit` to the
|
||||
// branch with no '+', so the remote takes a fast-forward only (D6). `slot` is the file the helper reads.
|
||||
export function syncCommand(kind, url, { mirror, branch, user, slot, commit = null }) {
|
||||
if (kind !== 'fetch' && kind !== 'push') fail('library-remote-invalid', 'sync fetches or pushes');
|
||||
if (kind === 'push' && (typeof commit !== 'string' || !SHA.test(commit))) fail('library-remote-invalid', 'a push takes a full commit id');
|
||||
let parsed;
|
||||
try { parsed = new URL(url); } catch { fail('library-remote-invalid', 'remote must be an https:// URL'); }
|
||||
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash
|
||||
@@ -38,10 +55,13 @@ export function syncCommand(kind, url, { mirror, branch, user, slot }) {
|
||||
fail('library-remote-invalid', 'remote must be https:// with a plain host and no credentials');
|
||||
// core.createObject=rename: git's default files each object by link then unlink, and a reader's
|
||||
// inspectMirror (C2-04) refuses a file with two links while that runs.
|
||||
const command = kind === 'fetch'
|
||||
? ['fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
|
||||
parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`]
|
||||
: ['push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options', parsed.href, `${commit}:refs/heads/${branch}`];
|
||||
return {
|
||||
argv: [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
|
||||
'-c', 'core.createObject=rename', '-C', mirror, 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--end-of-options',
|
||||
parsed.href, `+refs/heads/${branch}:refs/remotes/library/${branch}`],
|
||||
'-c', 'core.createObject=rename', '-C', mirror, ...command],
|
||||
env: { ...gitEnv(), LC_ALL: 'C', GIT_CEILING_DIRECTORIES: dirname(mirror), GIT_ASKPASS: ASKPASS,
|
||||
MOSAIC_LIBRARY_HOST: parsed.host, MOSAIC_LIBRARY_USER: user, MOSAIC_LIBRARY_SLOT: slot },
|
||||
};
|
||||
@@ -53,6 +73,32 @@ function slotPresent(slot) {
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
// A chosen credential, { kind, user, file }, or { refused: code } when the bus refused one.
|
||||
function checkCredential(credential) {
|
||||
if (credential === null || typeof credential !== 'object') fail('library-credential-missing', 'no credential chosen');
|
||||
if (Object.hasOwn(credential, 'refused')) return credential;
|
||||
const { kind, user, file } = credential;
|
||||
if (!CREDENTIAL_KINDS.includes(kind) || (user !== undefined && (typeof user !== 'string' || !NAME.test(user)))
|
||||
|| (file !== null && typeof file !== 'string'))
|
||||
fail('library-credential-missing', 'credential must name a kind, a plain user and a file');
|
||||
return credential;
|
||||
}
|
||||
|
||||
// C2-03b: her save and revert push with slot library-human and her Gitea login. A missing login, or a
|
||||
// slot that is missing, a link, another uid's or not 0600, refuses before any git call. Like sync, this
|
||||
// never opens the slot; the helper reads it with readSecretFile's checks.
|
||||
export function humanCredential(context, { user, configDir }) {
|
||||
const missing = message => fail('library-human-missing', message);
|
||||
if (typeof user !== 'string' || !NAME.test(user)) missing('her Gitea login is not configured');
|
||||
if (typeof configDir !== 'string' || typeof context?.business?.id !== 'string') missing('slot library-human is not configured');
|
||||
const file = join(configDir, 'secrets', context.business.id, HUMAN_SLOT);
|
||||
let st;
|
||||
try { st = lstatSync(file); } catch { missing('slot library-human is missing'); }
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384)
|
||||
missing('slot library-human must be a regular 0600 file the bus owns');
|
||||
return { kind: 'human', user, file };
|
||||
}
|
||||
|
||||
async function isAncestor(mirror, older, newer) {
|
||||
const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer],
|
||||
{ ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(mirror) });
|
||||
@@ -89,19 +135,27 @@ const errorCode = error => typeof error?.code === 'string' && CODE.test(error.co
|
||||
// 'library-credential-missing', 'library-diverged' or a reader refusal). It throws for a bad
|
||||
// entry, a busy mirror, a stale --accept or an unreadable state. Sync never runs gc or prune:
|
||||
// a reader holds `served` after the lock is released and reads its objects later (Filbert N2).
|
||||
export async function sync(context, { slot, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) {
|
||||
// C2-03b: `credential` is the bus's choice for the fetch, { kind, user, file } or { refused: code }.
|
||||
// A refusal (library-fetch-no-grant) is recorded like any held fetch, and no file is opened. Without
|
||||
// it, `slot` is slot library-gitea with the entry's user, as in C2-03a. The state names the kind and
|
||||
// the user the last fetch ran with, never the file or the value.
|
||||
export async function sync(context, { slot, credential = null, accept = null, command = syncCommand, now = () => new Date(), limits = GIT_LIMITS } = {}) {
|
||||
const mirror = await ensureMirror(context);
|
||||
const mapping = checkMapping(context.business.projects[context.project]);
|
||||
const { branch, user } = mapping;
|
||||
if (typeof user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync');
|
||||
const { branch } = mapping;
|
||||
if (typeof mapping.user !== 'string') fail('library-remote-invalid', 'library entry has no user for sync');
|
||||
const chosen = credential === null ? { kind: 'slot', file: typeof slot === 'string' ? slot : null } : checkCredential(credential);
|
||||
const user = chosen.user ?? mapping.user;
|
||||
if (accept !== null && (typeof accept !== 'string' || !SHA.test(accept))) fail('library-accept-invalid', 'accept takes a full commit id');
|
||||
if (running.has(mirror)) fail('library-sync-busy', 'a sync of this library is running');
|
||||
running.add(mirror);
|
||||
try {
|
||||
let code = null, tip = null, read = null;
|
||||
if (typeof slot !== 'string' || !slotPresent(slot)) code = 'library-credential-missing';
|
||||
let code = null, tip = null, read = null, used = null;
|
||||
if (Object.hasOwn(chosen, 'refused')) code = typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-fetch-no-grant';
|
||||
else if (typeof chosen.file !== 'string' || !slotPresent(chosen.file)) code = 'library-credential-missing';
|
||||
else {
|
||||
const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot });
|
||||
used = { kind: chosen.kind, user };
|
||||
const { argv, env } = command('fetch', mapping.remote, { mirror, branch, user, slot: chosen.file });
|
||||
const result = await runGit(argv, env, limits);
|
||||
// git's stderr is never kept or logged: the code is the whole record.
|
||||
if (!result.ok) code = 'library-fetch-failed';
|
||||
@@ -127,7 +181,7 @@ export async function sync(context, { slot, accept = null, command = syncCommand
|
||||
const prev = accept !== null
|
||||
? { version: 1, branch, served, lastFetch: null, lastError: null, diverged: null, problems: [] }
|
||||
: await readSyncState({ branch });
|
||||
let next = { ...prev }, target = served;
|
||||
let next = { ...prev, credential: used }, target = served;
|
||||
if (code !== null) next.lastError = { code, at };
|
||||
else if (accept !== null) {
|
||||
const problems = await problemsOf();
|
||||
@@ -158,3 +212,84 @@ export async function sync(context, { slot, accept = null, command = syncCommand
|
||||
});
|
||||
} finally { running.delete(mirror); }
|
||||
}
|
||||
|
||||
// C2-03b: push `commit`, already in the mirror, to her branch with the writer's own credential. It
|
||||
// resolves { pushed: true } or, when the remote tip moved, { pushed: false, raced: true, tip } after
|
||||
// one fetch with the same credential; the caller re-applies once and calls again with retried: true,
|
||||
// and a second race refuses stale-base. The fetch refuses library-sync-busy while a sync of the same
|
||||
// mirror runs. A hook refusal ([remote rejected]) or any other failure
|
||||
// refuses library-push-failed, and git's output is never kept. It moves no local ref: C2-02 moves the
|
||||
// remote-tracking and served refs after a push.
|
||||
export async function push(context, { commit, credential, retried = false, writeToken = null, command = syncCommand, limits = GIT_LIMITS } = {}) {
|
||||
const mirror = await ensureMirror(context);
|
||||
const mapping = checkMapping(context.business.projects[context.project]);
|
||||
const { branch } = mapping;
|
||||
const chosen = checkCredential(credential);
|
||||
if (Object.hasOwn(chosen, 'refused'))
|
||||
fail(typeof chosen.refused === 'string' && CODE.test(chosen.refused) ? chosen.refused : 'library-credential-missing', 'the bus refused a credential');
|
||||
if (typeof chosen.user !== 'string') fail('library-credential-missing', 'a push names its user');
|
||||
if (typeof chosen.file !== 'string' || !slotPresent(chosen.file))
|
||||
fail(chosen.kind === 'human' ? 'library-human-missing' : 'library-credential-missing', 'credential file missing or unsafe');
|
||||
if (typeof commit !== 'string' || !SHA.test(commit)) fail('library-remote-invalid', 'a push takes a full commit id');
|
||||
const opts = { mirror, branch, user: chosen.user, slot: chosen.file };
|
||||
const { argv, env } = command('push', mapping.remote, { ...opts, commit });
|
||||
const result = await runGit(argv, env, limits);
|
||||
if (result.ok) return { pushed: true };
|
||||
// --porcelain: one tab-separated line per ref on stdout, '!' and the summary for a refusal.
|
||||
const line = String(result.out ?? '').split('\n').find(l => l.split('\t')[1] === `${commit}:refs/heads/${branch}`);
|
||||
const summary = line?.split('\t')[2] ?? '';
|
||||
if (!line?.startsWith('!\t') || !/^\[rejected\] \((fetch first|non-fast-forward)\)$/.test(summary))
|
||||
fail('library-push-failed', 'push refused');
|
||||
if (retried) fail('stale-base', 'her branch moved again during the write');
|
||||
// The race fetch writes the mirror, so it takes sync's guard (dewey-0864 R1). A held guard refuses
|
||||
// library-sync-busy and the caller tries the write again.
|
||||
const owned = writeToken !== null && writers.get(mirror) === writeToken;
|
||||
if (running.has(mirror) && !owned) fail('library-sync-busy', 'a sync of this library is running');
|
||||
if (!owned) running.add(mirror);
|
||||
try {
|
||||
const fetch = command('fetch', mapping.remote, opts);
|
||||
if (!(await runGit(fetch.argv, fetch.env, limits)).ok) fail('library-push-failed', 'fetch after a moved tip failed');
|
||||
let tip;
|
||||
try { tip = await git(mirror, ['rev-parse', '--verify', '--end-of-options', `refs/remotes/library/${branch}^{commit}`]); }
|
||||
catch { fail('library-push-failed', 'fetch after a moved tip found no branch'); }
|
||||
if (!SHA.test(tip)) fail('library-push-failed', 'fetch after a moved tip found no branch');
|
||||
return { pushed: false, raced: true, tip };
|
||||
} finally { if (!owned) running.delete(mirror); }
|
||||
}
|
||||
|
||||
// Called under the publication lock by C2-02; the raw state writer stays private.
|
||||
export async function publishLibraryWrite(mirror, { mapping, commit, previous, readSyncState, now = () => new Date() }) {
|
||||
if (!SHA.test(commit) || (previous !== null && !SHA.test(previous))) fail('library-state-invalid');
|
||||
const state = await readSyncState({ branch: mapping.branch });
|
||||
if (state.diverged !== null) fail('library-diverged');
|
||||
if (state.served !== previous) fail('library-state-invalid');
|
||||
const read = await indexFor({ repo: mirror, commit, mapping });
|
||||
const at = now().toISOString();
|
||||
const next = { ...state, served: commit, lastPush: { commit, at }, lastError: null,
|
||||
problems: read.problems.map(p => ({ ...p })) };
|
||||
const tracking = `refs/remotes/library/${mapping.branch}`;
|
||||
// The remote push already succeeded. A local publication failure is outcome-unknown to the
|
||||
// caller; it must never encourage an automatic second push.
|
||||
let moved = false;
|
||||
try {
|
||||
await git(mirror, ['update-ref', 'refs/heads/served', commit, previous ?? ZERO]);
|
||||
moved = true;
|
||||
await git(mirror, ['update-ref', tracking, commit]);
|
||||
writeState(mirror, next);
|
||||
} catch {
|
||||
if (moved) {
|
||||
// A rename may have succeeded before directory fsync failed. Keep the new ref when the
|
||||
// validated state already matches it; otherwise restore served by compare-and-swap.
|
||||
let published = false;
|
||||
try { published = (await readSyncState({ branch: mapping.branch })).served === commit; } catch {}
|
||||
if (!published) {
|
||||
try {
|
||||
await git(mirror, ['update-ref', ...(previous === null
|
||||
? ['-d', 'refs/heads/served', commit] : ['refs/heads/served', previous, commit])]);
|
||||
} catch { fail('outcome-unknown'); }
|
||||
}
|
||||
}
|
||||
fail('outcome-unknown');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// C2-02: one-path plumbing commits on the private bare mirror. No checkout and no pending push.
|
||||
import { lstatSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SAFE, git, gitBytes } from '../../project/src/git.mjs';
|
||||
import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
import { checkMapping } from './mapping.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { supportingPath } from './supporting.mjs';
|
||||
import { LibraryError } from './problems.mjs';
|
||||
import { listTree, MAX_BLOB } from './reader.mjs';
|
||||
import { mirrorPath, ensureMirror, withLibraryLock } from './mirror.mjs';
|
||||
import { syncCommand, push, withLibraryWriteGuard, publishLibraryWrite } from './sync.mjs';
|
||||
|
||||
const SHA = /^[a-f0-9]{40}$/;
|
||||
const fail = code => { throw new LibraryError(code, code); };
|
||||
const ident = identity => ({ GIT_AUTHOR_NAME: identity.name, GIT_AUTHOR_EMAIL: identity.email,
|
||||
GIT_COMMITTER_NAME: identity.name, GIT_COMMITTER_EMAIL: identity.email });
|
||||
|
||||
function mappingOf(context) {
|
||||
if (!Object.hasOwn(context.business?.projects ?? {}, context.project)) fail('project-not-configured');
|
||||
return checkMapping(context.business.projects[context.project]);
|
||||
}
|
||||
function contentOf(value) {
|
||||
if (typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value, 'utf8') > MAX_BLOB
|
||||
|| Buffer.from(value, 'utf8').toString('utf8') !== value) fail('invalid-request');
|
||||
return value;
|
||||
}
|
||||
function credentialOf(credential, human) {
|
||||
const code = human ? 'library-human-missing' : 'library-write-no-grant';
|
||||
if (!credential || credential.kind !== (human ? 'human' : 'seat')
|
||||
|| typeof credential.user !== 'string' || !/^[A-Za-z0-9_.-]{1,100}$/.test(credential.user)
|
||||
|| typeof credential.file !== 'string') fail(code);
|
||||
try {
|
||||
const st = lstatSync(credential.file);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || (st.mode & 0o777) !== 0o600 || st.size > 16384) fail(code);
|
||||
} catch { fail(code); }
|
||||
return credential;
|
||||
}
|
||||
// Her save uses the same structural and folded-tree checks; only the supporting rule changes.
|
||||
function pathOf(mapping, universe, path, tree, human) {
|
||||
if (!human) return supportingPath(mapping, universe, path, tree);
|
||||
if (typeof path !== 'string' || classify(mapping, path).scope === 'unmapped') fail('library-write-story-path');
|
||||
const place = classify(mapping, path);
|
||||
const universes = Object.fromEntries(Object.keys(mapping.universes).map(id => [id,
|
||||
{ ...mapping.universes[id], supporting: id === place.universe ? [path.slice(0, path.lastIndexOf('/') + 1)] : [] }]));
|
||||
return supportingPath({ ...mapping, universes }, place.universe, path, tree);
|
||||
}
|
||||
async function ref(mirror, name) {
|
||||
const rows = await git(mirror, ['for-each-ref', '--format=%(refname) %(objectname)', name]);
|
||||
const line = rows.split('\n').find(row => row.startsWith(name + ' '));
|
||||
return line ? line.slice(name.length + 1) : null;
|
||||
}
|
||||
async function ancestor(mirror, older, newer) {
|
||||
const result = await runGit([...SAFE, '-C', mirror, 'merge-base', '--is-ancestor', older, newer], gitEnv());
|
||||
if (result.ok) return true;
|
||||
if (result.why === 'exit 1') return false;
|
||||
fail('library-git');
|
||||
}
|
||||
async function fetchTip(mirror, mapping, credential, command) {
|
||||
const c = command('fetch', mapping.remote, { mirror, branch: mapping.branch, user: credential.user, slot: credential.file });
|
||||
if (!(await runGit(c.argv, c.env)).ok) fail('library-push-failed');
|
||||
const tip = await ref(mirror, `refs/remotes/library/${mapping.branch}`);
|
||||
if (!tip || !SHA.test(tip)) fail('library-push-failed');
|
||||
return tip;
|
||||
}
|
||||
|
||||
async function plumbing(mirror, tip, path, content, identity, message, stage) {
|
||||
const env = { ...gitEnv(), ...ident(identity), GIT_INDEX_FILE: join(stage, 'index'), GIT_CEILING_DIRECTORIES: dirname(mirror) };
|
||||
const run = async (args, input = null) => {
|
||||
const result = await runGit([...SAFE, '-c', 'core.createObject=rename', '-C', mirror, ...args], env, GIT_LIMITS, { stdin: input });
|
||||
if (!result.ok) fail('library-git');
|
||||
return result.out.trimEnd();
|
||||
};
|
||||
rmSync(env.GIT_INDEX_FILE, { force: true });
|
||||
await run(['read-tree', tip]);
|
||||
if (content === null) await run(['update-index', '--force-remove', '--', path]);
|
||||
else {
|
||||
const file = join(stage, 'content'); writeFileSync(file, content, { mode: 0o600 });
|
||||
const blob = await run(['hash-object', '-w', '--no-filters', file]);
|
||||
// --cacheinfo keeps the path a separate argv item; no pathspec expansion or filters.
|
||||
await run(['update-index', '--add', '--cacheinfo', '100644', blob, path]);
|
||||
}
|
||||
const tree = await run(['write-tree']);
|
||||
return run(['commit-tree', tree, '-p', tip, '-m', message]);
|
||||
}
|
||||
|
||||
async function revertChange(mirror, served, context, mapping) {
|
||||
const commit = context.commit;
|
||||
const refuse = () => fail('library-revert-not-agent');
|
||||
if (!served || !SHA.test(commit)) refuse();
|
||||
let text, parents;
|
||||
try {
|
||||
if (!await ancestor(mirror, commit, served)) refuse();
|
||||
text = await git(mirror, ['show', '-s', '--format=%(trailers:key=Mosaic-Agent,valueonly)', commit]);
|
||||
parents = (await git(mirror, ['show', '-s', '--format=%P', commit])).split(' ');
|
||||
} catch { refuse(); }
|
||||
if (parents.length !== 1 || !SHA.test(parents[0])
|
||||
|| !text.startsWith(context.business.id + '/')
|
||||
|| !ID_PATTERN.test(text.slice(context.business.id.length + 1))) refuse();
|
||||
const before = await listTree(mirror, parents[0]), after = await listTree(mirror, commit);
|
||||
const old = new Map(before.map(e => [e.path, e])), current = new Map(after.map(e => [e.path, e]));
|
||||
const changed = [...new Set([...old.keys(), ...current.keys()])].filter(path =>
|
||||
old.get(path)?.oid !== current.get(path)?.oid || old.get(path)?.mode !== current.get(path)?.mode);
|
||||
if (changed.length !== 1) refuse();
|
||||
const path = changed[0], was = old.get(path), made = current.get(path);
|
||||
if ((was && (was.mode !== '100644' || was.type !== 'blob' || was.size > MAX_BLOB))
|
||||
|| (made && (made.mode !== '100644' || made.type !== 'blob'))) refuse();
|
||||
pathOf(mapping, null, path, after, true);
|
||||
const bytes = was ? await gitBytes(mirror, ['cat-file', 'blob', was.oid], MAX_BLOB) : null;
|
||||
let content = null;
|
||||
if (bytes !== null) {
|
||||
try { content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); } catch { refuse(); }
|
||||
contentOf(content);
|
||||
}
|
||||
return { path, base: made?.oid ?? null, content, reverted: commit };
|
||||
}
|
||||
|
||||
async function write(context, options, human, revert = false) {
|
||||
const mapping = mappingOf(context);
|
||||
if (human && !mapping.editor) fail('library-mapping-invalid');
|
||||
const credential = credentialOf(options.credential, human);
|
||||
const identity = human ? mapping.editor : options.identity;
|
||||
if (!identity || typeof identity.name !== 'string' || typeof identity.email !== 'string') fail('library-write-refused');
|
||||
if (!human && (typeof options.agent !== 'string' || !options.agent.startsWith(context.business.id + '/'))) fail('library-write-refused');
|
||||
const op = options.op ?? 'write';
|
||||
if (!['write', 'delete'].includes(op)) fail('invalid-request');
|
||||
let change = revert ? null : { path: context.path, base: context.base, content: op === 'delete' ? null : contentOf(context.content) };
|
||||
if (change) {
|
||||
if (change.base !== null && (typeof change.base !== 'string' || !SHA.test(change.base))) fail('invalid-request');
|
||||
pathOf(mapping, context.universe, change.path, [], human);
|
||||
}
|
||||
const mirror = mirrorPath(context);
|
||||
return withLibraryWriteGuard(mirror, writeToken => withLibraryLock(mirror, async lock => {
|
||||
await ensureMirror(context);
|
||||
const state = await lock.readSyncState({ branch: mapping.branch });
|
||||
if (state.diverged !== null) fail('library-diverged');
|
||||
const served = state.served;
|
||||
if (!served) fail('library-commit');
|
||||
if (revert) change = await revertChange(mirror, served, context, mapping);
|
||||
// Check the served tree before any remote effect, then recheck against every fetched tip.
|
||||
pathOf(mapping, context.universe, change.path, await listTree(mirror, served), human);
|
||||
const tracking = `refs/remotes/library/${mapping.branch}`, previous = await ref(mirror, tracking);
|
||||
let pushed = false;
|
||||
const stage = mkdtempSync(join(dirname(mirror), '.library-write-'));
|
||||
try {
|
||||
let tip = await fetchTip(mirror, mapping, credential, options.command ?? syncCommand);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (!await ancestor(mirror, served, tip)) fail('library-diverged');
|
||||
const tree = await listTree(mirror, tip);
|
||||
pathOf(mapping, context.universe, change.path, tree, human);
|
||||
const entry = tree.find(e => e.path === change.path);
|
||||
if ((!human && op === 'delete' && (!entry || entry.mode !== '100644' || entry.type !== 'blob'))
|
||||
|| (entry && (entry.mode !== '100644' || entry.type !== 'blob'))
|
||||
|| tree.some(e => e.path.startsWith(change.path + '/') || change.path.startsWith(e.path + '/')))
|
||||
fail('library-write-story-path');
|
||||
if ((entry?.oid ?? null) !== change.base) fail('stale-base');
|
||||
const action = change.content === null ? 'delete' : 'write';
|
||||
const message = `Mosaic: ${action} ${change.path}` + (human ? '' : `\n\nMosaic-Agent: ${options.agent}`);
|
||||
const commit = await plumbing(mirror, tip, change.path, change.content, identity, message, stage);
|
||||
// Broker rechecks live grants/holder immediately before each push.
|
||||
options.beforePush?.();
|
||||
const result = await push(context, { commit, credential, retried: attempt === 1, writeToken, command: options.command ?? syncCommand });
|
||||
if (!result.pushed) { tip = result.tip; continue; }
|
||||
pushed = true;
|
||||
try {
|
||||
// The broker records the pushed commit even if publication fails next (mos-1520).
|
||||
options.onPushed?.({ project: context.project, path: change.path, commit });
|
||||
await publishLibraryWrite(mirror, { mapping, commit, previous: served, readSyncState: lock.readSyncState, now: options.now });
|
||||
} catch { fail('outcome-unknown'); }
|
||||
return { project: context.project, path: change.path, ...(revert ? { reverted: change.reverted } : {}), commit };
|
||||
}
|
||||
fail('stale-base');
|
||||
} finally {
|
||||
rmSync(stage, { recursive: true, force: true });
|
||||
if (!pushed) {
|
||||
if (previous === null) await git(mirror, ['update-ref', '-d', tracking]);
|
||||
else await git(mirror, ['update-ref', tracking, previous]);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
export const writeLibraryDoc = (context, options = {}) => write(context, options, false);
|
||||
export const saveLibraryFile = (context, options = {}) => write(context, options, true);
|
||||
export const revertLibraryCommit = (context, options = {}) => write(context, options, true, true);
|
||||
@@ -0,0 +1,91 @@
|
||||
// Index tests (C2-05a): the cache key and its bound, failed reads, mapped-only contents, and the rebuild
|
||||
// time on a 3,000-file fixture. Fixtures are made up and built under the scratch area.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { clearIndexCache, indexCacheSize, indexFor } from '../src/index-build.mjs';
|
||||
import { git, mapping, MARKER } from './helpers.mjs';
|
||||
import { note, scratchRepo } from './query-fixture.mjs';
|
||||
|
||||
const FILES = {
|
||||
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', MARKER),
|
||||
'Book One/Chapters/Chapter 01.md': '# One\n[[Mara Vell]]\n',
|
||||
'Inbox Note.md': `${MARKER}\n`,
|
||||
};
|
||||
|
||||
// A mapping whose harbor universe has the given display name. Each name gives a new cache key.
|
||||
const named = name => mapping(e => ({ ...e, universes: { ...e.universes, harbor: { ...e.universes.harbor, name } } }));
|
||||
|
||||
test('the same commit, repo path and mapping return the same index, and only mapped files are in it', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
const a = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const b = await indexFor({ repo, commit, mapping: mapping() });
|
||||
assert.equal(a, b);
|
||||
assert.equal(indexCacheSize(), 1);
|
||||
assert.equal(a.commit, commit);
|
||||
assert.ok(a.files.has('Book One/Characters/Mara Vell.md'));
|
||||
assert.ok(!a.files.has('Inbox Note.md'));
|
||||
assert.deepEqual(a.names.get('mara vell'), ['Book One/Characters/Mara Vell.md']);
|
||||
assert.deepEqual(a.linksIn.get('Book One/Characters/Mara Vell.md').map(l => l.path), ['Book One/Chapters/Chapter 01.md']);
|
||||
});
|
||||
|
||||
test('a different repo path, mapping or commit gets its own index', async t => {
|
||||
clearIndexCache();
|
||||
const { root, repo, commit } = scratchRepo(t, FILES);
|
||||
const base = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const alias = join(root, 'alias');
|
||||
symlinkSync(repo, alias);
|
||||
assert.notEqual(await indexFor({ repo: alias, commit, mapping: mapping() }), base);
|
||||
assert.notEqual(await indexFor({ repo, commit, mapping: named('Renamed Harbor') }), base);
|
||||
writeFileSync(join(repo, 'Book One/Places/New.md'), note('place', 'New'));
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'second');
|
||||
const next = git(repo, 'rev-parse', 'HEAD');
|
||||
const later = await indexFor({ repo, commit: next, mapping: mapping() });
|
||||
assert.notEqual(later, base);
|
||||
assert.equal(later.commit, next);
|
||||
assert.ok(later.files.has('Book One/Places/New.md'));
|
||||
});
|
||||
|
||||
test('the cache holds at most eight indexes and evicts the least recently used', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
const maps = Array.from({ length: 9 }, (_, i) => named(`Harbor ${i}`));
|
||||
const first = await indexFor({ repo, commit, mapping: maps[0] });
|
||||
for (const m of maps.slice(1)) await indexFor({ repo, commit, mapping: m });
|
||||
assert.equal(indexCacheSize(), 8);
|
||||
assert.notEqual(await indexFor({ repo, commit, mapping: maps[0] }), first);
|
||||
assert.equal(indexCacheSize(), 8);
|
||||
});
|
||||
|
||||
test('a failed read is not kept in the cache', async t => {
|
||||
clearIndexCache();
|
||||
const { repo } = scratchRepo(t, FILES);
|
||||
await assert.rejects(indexFor({ repo, commit: '0'.repeat(40), mapping: mapping() }));
|
||||
assert.equal(indexCacheSize(), 0);
|
||||
});
|
||||
|
||||
test('a missing or unchecked mapping is refused before any read', async t => {
|
||||
clearIndexCache();
|
||||
const { repo, commit } = scratchRepo(t, FILES);
|
||||
await assert.rejects(indexFor({ repo, commit, mapping: undefined }), { code: 'library-mapping-invalid' });
|
||||
await assert.rejects(indexFor({ repo, commit, mapping: { kind: 'hybrid' } }), { code: 'library-mapping-invalid' });
|
||||
});
|
||||
|
||||
test('a 3,000-file rebuild completes and its time is reported', { timeout: 300_000 }, async t => {
|
||||
clearIndexCache();
|
||||
const folders = ['Characters', 'Places', 'Research'];
|
||||
const files = {};
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
files[`Book One/${folders[i % 3]}/Note ${i}.md`] = note('character', `Note ${i}`, `Body ${i} [[Note ${(i + 1) % 3000}]]`);
|
||||
}
|
||||
const { repo, commit } = scratchRepo(t, files);
|
||||
const started = performance.now();
|
||||
const index = await indexFor({ repo, commit, mapping: mapping() });
|
||||
const ms = performance.now() - started;
|
||||
t.diagnostic(`3,000-file index rebuild: ${ms.toFixed(0)} ms`);
|
||||
assert.equal(index.paths.length, 3000);
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
|
||||
import { mapping, mappingEntry } from './helpers.mjs';
|
||||
|
||||
@@ -45,7 +49,7 @@ test('C2-01c: universe-level prefixes lie outside every book, in a plain univers
|
||||
|
||||
test('C2-01c: a draft takes kind, name and playground only, and comes back frozen with books []', () => {
|
||||
const m = mapping(e => { e.universes.idea = draft(); return e; });
|
||||
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'] });
|
||||
assert.deepEqual(m.universes.idea, { kind: 'draft', name: 'New World', books: [], playground: ['Drafts/New World/'], supporting: [] });
|
||||
assert.ok(Object.isFrozen(m.universes.idea) && Object.isFrozen(m.universes.idea.books) && Object.isFrozen(m.universes.idea.playground));
|
||||
refuses(e => { e.universes.idea = { ...draft(), books: ['New World'] }; return e; }, 'library-mapping-invalid', /universes.idea is a draft and takes no books/);
|
||||
refuses(e => { e.universes.idea = { ...draft(), books: [] }; return e; }, 'library-mapping-invalid', /takes no books/);
|
||||
@@ -56,6 +60,107 @@ test('C2-01c: a draft takes kind, name and playground only, and comes back froze
|
||||
refuses(e => { e.universes.harbor.kind = kind; return e; }, 'library-mapping-invalid', /universes.harbor.kind must be "hybrid" or "draft"/);
|
||||
});
|
||||
|
||||
test('C2-01d: supporting prefixes at book level, universe level, in a hybrid and in a draft, [] when absent', () => {
|
||||
const m = mapping(e => {
|
||||
e.universes.harbor.supporting = ['Book One/Notes/', 'Harbor Notes/'];
|
||||
e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: [], supporting: ['Crossover Notes/'] };
|
||||
e.universes.idea = { ...draft(), supporting: ['Drafts/New World Notes/'] };
|
||||
return e;
|
||||
});
|
||||
assert.deepEqual(m.universes.harbor.supporting, ['Book One/Notes/', 'Harbor Notes/']);
|
||||
assert.deepEqual(m.universes.cross.supporting, ['Crossover Notes/']);
|
||||
assert.deepEqual(m.universes.idea.supporting, ['Drafts/New World Notes/']);
|
||||
assert.deepEqual(m.universes.second.supporting, []);
|
||||
assert.ok(Object.isFrozen(m.universes.harbor.supporting) && Object.isFrozen(m.universes.second.supporting));
|
||||
assert.deepEqual(mapping().universes.harbor.supporting, []);
|
||||
});
|
||||
|
||||
test('C2-01d: each supporting prefix rule refuses with its code', () => {
|
||||
const invalid = (change, pattern) => refuses(change, 'library-mapping-invalid', pattern);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes']; return e; }, /universes.harbor.supporting holds "Book One\/Notes", which is not a folder path ending in \//);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/../Notes/']; return e; }, /which is not a folder path ending in \//);
|
||||
invalid(e => { e.universes.harbor.supporting = 'Book One/Notes/'; return e; }, /universes.harbor.supporting must be a list/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Notes/', 'Book One/Notes/Deep/']; return e; },
|
||||
/supporting "Book One\/Notes\/" holds supporting "Book One\/Notes\/Deep\/"/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Ideas/']; e.universes.idea = { ...draft(), supporting: ['Ideas/'] }; return e; },
|
||||
/a supporting prefix is listed twice/);
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/']; return e; }, /universes.harbor.supporting "Book One\/" is the whole book folder "Book One"/);
|
||||
invalid(e => { e.universes.harbor.books = ['Shelf/Book One']; e.universes.harbor.playground = []; e.universes.harbor.supporting = ['Shelf/']; return e; },
|
||||
/universes.harbor.supporting "Shelf\/" holds its own book folder "Shelf\/Book One"/);
|
||||
// Supporting is checked first in scope, so a supporting prefix holding a playground would hide it.
|
||||
invalid(e => { e.universes.harbor.supporting = ['Book One/Workshop/']; e.universes.harbor.playground = ['Book One/Workshop/Playground/']; return e; },
|
||||
/universes.harbor.supporting "Book One\/Workshop\/" holds playground "Book One\/Workshop\/Playground\/"/);
|
||||
// Across entries any overlap would give one path two universes.
|
||||
for (const [supporting, playground] of [['Ideas/', 'Ideas/'], ['Ideas/Deep/', 'Ideas/'], ['Ideas/', 'Ideas/Deep/']])
|
||||
invalid(e => { e.universes.harbor.supporting = [supporting]; e.universes.idea = draft([playground]); return e; },
|
||||
new RegExp(`universes.harbor.supporting "${supporting}" overlaps playground "${playground}" of universes.idea`));
|
||||
refuses(e => { e.universes.harbor.supporting = ['Second World/Notes/']; return e; },
|
||||
'library-supporting-other-universe', /universes.harbor.supporting "Second World\/Notes\/" lies inside book folder "Second World" of universes.second/);
|
||||
refuses(e => { e.universes.second.books = ['Worlds/Second']; e.universes.harbor.supporting = ['Worlds/']; return e; },
|
||||
'library-supporting-other-universe', /universes.harbor.supporting "Worlds\/" holds book folder "Worlds\/Second" of universes.second/);
|
||||
refuses(e => { e.universes.idea = { ...draft(), supporting: ['Book One/Idea Notes/'] }; return e; },
|
||||
'library-supporting-other-universe', /universes.idea.supporting "Book One\/Idea Notes\/" lies inside book folder "Book One" of universes.harbor/);
|
||||
});
|
||||
|
||||
test('C2-01d: between the lists, a supporting prefix may equal its own playground, and a playground may hold its own supporting', () => {
|
||||
const same = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/']; return e; });
|
||||
assert.deepEqual([same.universes.harbor.playground, same.universes.harbor.supporting], [['Book One/Playground/'], ['Book One/Playground/']]);
|
||||
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
|
||||
assert.deepEqual(nested.universes.harbor.supporting, ['Book One/Playground/Notes/']);
|
||||
});
|
||||
|
||||
test('C2-01d: editor is optional, kept when given, and refuses anything but a plain name and email', () => {
|
||||
assert.equal(Object.hasOwn(mapping(), 'editor'), false);
|
||||
const editor = { name: 'Her Name', email: '[email protected]' };
|
||||
const m = mapping(e => ({ ...e, editor }));
|
||||
assert.deepEqual(m.editor, editor);
|
||||
assert.notEqual(m.editor, editor);
|
||||
assert.ok(Object.isFrozen(m.editor));
|
||||
const invalid = (value, pattern) => refuses(e => ({ ...e, editor: value }), 'library-mapping-invalid', pattern);
|
||||
for (const value of [null, 'Her Name', 7, ['Her Name', '[email protected]']]) invalid(value, /editor must be an object/);
|
||||
invalid({ ...editor, signingKey: 'x' }, /editor has unknown key "signingKey"/);
|
||||
// sage-2925: an optional Gitea login for her save, checked as the entry's user is.
|
||||
assert.deepEqual(mapping(e => ({ ...e, editor: { ...editor, user: 'her.login-1' } })).editor, { ...editor, user: 'her.login-1' });
|
||||
for (const user of ['', 'her login', 'her@host', 'a'.repeat(101), 7, null])
|
||||
invalid({ ...editor, user }, /editor.user must be a plain account name/);
|
||||
invalid({ name: 'Her Name' }, /editor.email must be non-empty text with no control character, < or >/);
|
||||
invalid({ email: '[email protected]' }, /editor.name must be non-empty text/);
|
||||
for (const bad of ['', ' ', 7, null, 'a\u0000b', 'a\nb', 'a\u001fb', 'a\u007fb', 'a\u0085b', 'a\u009fb', '<her>', 'a<b', 'a>b']) {
|
||||
invalid({ ...editor, name: bad }, /editor.name must be non-empty text with no control character, < or >/);
|
||||
invalid({ ...editor, email: bad }, /editor.email must be non-empty text with no control character, < or >/);
|
||||
}
|
||||
// sage-3601, sage-3606: git's crud() strips whitespace and , : ; < > " \ ' from both ends of an
|
||||
// ident, so a value starting or ending with one refuses. Inner < > and controls refuse above.
|
||||
const ends = [' ', '\u00a0', '\u3000', ',', ':', ';', '"', "'", '\\'];
|
||||
for (const c of ends) for (const padded of [`${c}Her Name`, `Her Name${c}`]) {
|
||||
invalid({ ...editor, name: padded }, /editor.name must not start or end with whitespace or any of , : ; " ' \\/);
|
||||
invalid({ ...editor, email: padded }, /editor.email must not start or end with whitespace or any of , : ; " ' \\/);
|
||||
}
|
||||
for (const c of ['<', '>', '\n', '\t']) for (const padded of [`${c}Her Name`, `Her Name${c}`, `Her${c}Name`])
|
||||
invalid({ ...editor, name: padded }, /editor.name must be non-empty text with no control character, < or >/);
|
||||
assert.equal(mapping(e => ({ ...e, editor: { name: 'Her Middle Name', email: '[email protected]' } })).editor.name, 'Her Middle Name');
|
||||
for (const name of ['Doe, Jane Q.', 'Jane: "Q" Doe', "O'Neil", 'Back\\slash Name'])
|
||||
assert.equal(mapping(e => ({ ...e, editor: { name, email: '[email protected]' } })).editor.name, name);
|
||||
});
|
||||
|
||||
// sage-3606: an accepted editor commits under exactly the saved ident, as author and committer. A
|
||||
// future git that strips more from an ident fails here.
|
||||
test('C2-01d: an accepted editor round-trips through a real git commit unchanged', t => {
|
||||
const editor = mapping(e => ({ ...e, editor: { name: 'Doe, Jane Q.', email: '[email protected].' } })).editor;
|
||||
const root = mkdtempSync(join(tmpdir(), 'library-ident-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// An ident in the environment would win over -c, so the commit runs without one. GIT_DIR and its
|
||||
// family would beat -C (inside a hook, say), and git reads no global or system config (webby-0236 E5).
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_(AUTHOR|COMMITTER)_/.test(k)));
|
||||
for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE']) delete env[k];
|
||||
Object.assign(env, { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' });
|
||||
const git = (...args) => execFileSync('git', ['-c', `user.name=${editor.name}`, '-c', `user.email=${editor.email}`,
|
||||
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { encoding: 'utf8', env });
|
||||
git('init', '-q');
|
||||
git('commit', '-q', '--allow-empty', '-m', 'ident');
|
||||
assert.equal(git('log', '-1', '--format=%an%n%ae%n%cn%n%ce'), `${editor.name}\n${editor.email}\n${editor.name}\n${editor.email}\n`);
|
||||
});
|
||||
|
||||
test('no playground prefix holds another, and no book folder holds another', () => {
|
||||
refuses(e => { e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Playground/Deep/']; return e; },
|
||||
'library-mapping-invalid', /holds playground/);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Scratch repos for the query and index tests (C2-05a). They are built under tmpdir(), as helpers.mjs
|
||||
// repo() does; the gate sets TMPDIR under /mnt/storage/scratch/tmp, never /tmp (RULINGS "tmpdir flag").
|
||||
// Bodies are made up.
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { git } from './helpers.mjs';
|
||||
|
||||
// A repo with one commit holding `files` (path -> text). Removed when the test ends.
|
||||
export function scratchRepo(t, files) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r216-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const repo = join(root, 'books');
|
||||
mkdirSync(repo);
|
||||
git(repo, 'init', '-q', '-b', 'main');
|
||||
for (const [path, text] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(repo, path)), { recursive: true });
|
||||
writeFileSync(join(repo, path), text);
|
||||
}
|
||||
git(repo, 'add', '-A');
|
||||
git(repo, 'commit', '-qm', 'fixture');
|
||||
return { root, repo, commit: git(repo, 'rev-parse', 'HEAD') };
|
||||
}
|
||||
|
||||
// A note: front matter with a type and a name, optional extra front matter lines, then a body.
|
||||
export const note = (type, name, body = '', extra = []) => ['---', `type: ${type}`, `name: ${name}`, ...extra, '---', body, ''].join('\n');
|
||||
|
||||
// The one exclusion the hybrid tests use, and the paths it hides. C2-15 (row 267) replaces the reading of
|
||||
// `exclude` (a path prefix today); swapping these two constants is the whole change to the tests.
|
||||
export const EXCLUSION = { exclude: 'Book One/Research' };
|
||||
export const EXCLUDED_PATHS = ['Book One/Research/Ferry Logs.md'];
|
||||
|
||||
// C2-05c: synthetic scope fixture, including same-prefix precedence and a draft.
|
||||
export const SUPPORT_FILES = {
|
||||
'Book One/Characters/Canon.md': note('character', 'Canon', 'Scope fixture canon.'),
|
||||
'Book One/Playground/Plan.md': note('summary', 'Plan', 'Scope fixture playground.'),
|
||||
'Book One/Support/Notes/Helper.md': note('summary', 'Helper', 'Scope fixture supporting.'),
|
||||
'Notes/HarborSupport/Loose.md': note('summary', 'Loose', 'Scope fixture supporting loose.'),
|
||||
'Drafts/Idea/Support/Seed.md': note('summary', 'Seed', 'Scope fixture supporting draft.'),
|
||||
};
|
||||
export const withSupporting = entry => ({
|
||||
...entry,
|
||||
universes: {
|
||||
...entry.universes,
|
||||
harbor: { ...entry.universes.harbor,
|
||||
playground: [...entry.universes.harbor.playground, 'Book One/Support/'],
|
||||
supporting: ['Book One/Support/', 'Notes/HarborSupport/'] },
|
||||
idea: { kind: 'draft', name: 'Idea', playground: ['Drafts/Idea/'], supporting: ['Drafts/Idea/Support/'] },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
// Query tests for design 3 (C2-05a): universe isolation, scope labels, unmapped files, hybrids, paging
|
||||
// under the reply cap, refusals and manuscript paging. Fixtures are made up and built under the scratch area.
|
||||
import { test } from 'node:test';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import assert from 'node:assert/strict';
|
||||
import { REPLY_CAP } from '../../harness/src/tools.mjs';
|
||||
import { find, get, search, manuscript, universeInfo, QUERY_REPLY_CAP, LOOKUP_LIMIT, MANUSCRIPT_LINES } from '../src/query.mjs';
|
||||
import { mapping, MARKER, git } from './helpers.mjs';
|
||||
import { scratchRepo, note, EXCLUSION, EXCLUDED_PATHS, SUPPORT_FILES, withSupporting } from './query-fixture.mjs';
|
||||
|
||||
// A hybrid over harbor and second. Its one constraint is EXCLUSION (query-fixture.mjs) and it declares no
|
||||
// books or playground.
|
||||
const WITH_BLEND = e => ({
|
||||
...e,
|
||||
universes: {
|
||||
...e.universes,
|
||||
blend: {
|
||||
kind: 'hybrid', name: 'Blend', sources: ['harbor', 'second'], books: [], playground: [],
|
||||
constraints: [EXCLUSION], notes: 'made-up hybrid',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// PRD 1.14 levels (C2-01c): harbor gets a universe-level playground prefix outside every book, and a draft
|
||||
// universe has only playground prefixes. Files there have book null.
|
||||
const WITH_LEVELS = e => ({
|
||||
...WITH_BLEND(e),
|
||||
universes: {
|
||||
...WITH_BLEND(e).universes,
|
||||
harbor: { ...e.universes.harbor, playground: ['Book One/Playground/', 'Notes/Harbor/'] },
|
||||
idea: { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'] },
|
||||
},
|
||||
});
|
||||
const LEVEL_FILES = {
|
||||
'Notes/Harbor/Scraps.md': note('summary', 'Scraps', `${MARKER} loose`),
|
||||
'Notes/Harbor/Chapters/Idea Chapter.md': `# Idea\n${MARKER} unbooked chapter\n`,
|
||||
'Drafts/New World/Characters/Rin.md': note('character', 'Rin', `${MARKER} draft`),
|
||||
'Drafts/New World/Summary.md': note('summary', 'Summary', `${MARKER} draft`),
|
||||
};
|
||||
|
||||
const FILES = {
|
||||
'Book One/Characters/Mara Vell.md': note('character', 'Mara Vell', `Grey eyes. ${MARKER}`,
|
||||
['appears_in: "[[Book One/Chapters/Chapter 01]]"', 'relationships:', ' - "[[Tomas]]"']),
|
||||
'Book One/Characters/Tomas.md': note('character', 'Tomas', MARKER),
|
||||
'Book One/Chapters/Chapter 01.md': `# One\n${MARKER} [[Mara Vell]] met [[Book One/Places/Harbor Town|the harbor]].\n`,
|
||||
'Book One/Chapters/Chapter 02.md': 'Quiet. [[Ferry Logs]] [[Ilse]]\n',
|
||||
'Book One/Places/Harbor Town.md': note('place', 'Harbor Town', `Salt. ${MARKER}`),
|
||||
'Book One/Research/Ferry Logs.md': note('research', 'Ferry Logs', `Timetable. ${MARKER}`),
|
||||
'Book One/Playground/What If.md': note('summary', 'What If', `${MARKER} [[Mara Vell]]`),
|
||||
'Second World/Characters/Ilse.md': note('character', 'Ilse', `${MARKER} Ilse.`),
|
||||
'Second World/Chapters/01.md': '# Elsewhere\n[[Ilse]] waits.\n',
|
||||
'Inbox Note.md': `${MARKER}\n`,
|
||||
'Book One/Board.canvas': '{"nodes":[]}\n',
|
||||
};
|
||||
|
||||
const src = (ref, map = mapping()) => ({ repo: ref.repo, commit: ref.commit, mapping: map });
|
||||
const paths = reply => reply.results.map(r => r.path);
|
||||
|
||||
test('the query reply cap equals the harness reply cap', () => {
|
||||
assert.equal(QUERY_REPLY_CAP, REPLY_CAP);
|
||||
});
|
||||
|
||||
test('find is bound to its universe and refuses a call without one', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const names = paths(await find(s, { universe: 'harbor', query: '' }));
|
||||
assert.ok(names.length > 0);
|
||||
assert.ok(names.every(p => p.startsWith('Book One/')));
|
||||
assert.ok(!names.includes('Second World/Characters/Ilse.md'));
|
||||
await assert.rejects(find(s, { query: '' }), { code: 'query-argument-invalid' });
|
||||
await assert.rejects(find(s, { universe: 'nowhere', query: '' }), { code: 'query-universe-unknown' });
|
||||
});
|
||||
|
||||
test('every result carries its scope, and playground appears only when asked for', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const canon = (await find(s, { universe: 'harbor', query: '' })).results;
|
||||
assert.ok(canon.every(r => r.scope === 'canon'));
|
||||
assert.ok(!canon.some(r => r.path.includes('/Playground/')));
|
||||
const play = (await find(s, { universe: 'harbor', query: '', scope: 'playground' })).results;
|
||||
assert.deepEqual(play.map(r => r.path), ['Book One/Playground/What If.md']);
|
||||
assert.ok(play.every(r => r.scope === 'playground'));
|
||||
});
|
||||
|
||||
test('unmapped files never appear in any reply', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Inbox' })).results, []);
|
||||
assert.deepEqual((await find(s, { universe: 'harbor', query: 'Board' })).results, []);
|
||||
const hits = (await search(s, { universe: 'harbor', text: MARKER })).results;
|
||||
assert.ok(hits.length > 0);
|
||||
assert.ok(hits.every(r => r.path !== 'Inbox Note.md' && r.path !== 'Book One/Board.canvas'));
|
||||
});
|
||||
|
||||
test('a hybrid sees its sources canon, minus exclusions, and no playground', async t => {
|
||||
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
|
||||
const reply = await find(s, { universe: 'blend', query: '' });
|
||||
assert.deepEqual(paths(reply).sort(), [
|
||||
'Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Chapters/Chapter 01.md',
|
||||
'Book One/Chapters/Chapter 02.md', 'Book One/Places/Harbor Town.md',
|
||||
'Second World/Characters/Ilse.md', 'Second World/Chapters/01.md',
|
||||
]);
|
||||
assert.ok(reply.results.every(r => r.scope === 'canon' && ['harbor', 'second'].includes(r.universe)));
|
||||
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'playground' })).results, []);
|
||||
assert.deepEqual((await find(s, { universe: 'blend', query: 'Ferry' })).results, []);
|
||||
});
|
||||
|
||||
test('a hybrid gets a source file it can see, and not an excluded or foreign one', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const hybrid = src(ref, mapping(WITH_BLEND));
|
||||
const ilse = await get(hybrid, { universe: 'blend', path: 'Second World/Characters/Ilse.md' });
|
||||
assert.equal(ilse.scope, 'canon');
|
||||
assert.ok(ilse.links_in.some(l => l.path === 'Second World/Chapters/01.md'));
|
||||
assert.equal(await get(hybrid, { universe: 'blend', path: 'Book One/Research/Ferry Logs.md' }), null);
|
||||
assert.equal(await get(src(ref), { universe: 'harbor', path: 'Second World/Characters/Ilse.md' }), null);
|
||||
});
|
||||
|
||||
test('get lists visible links both ways and drops a target another universe cannot see', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const harbor = src(ref);
|
||||
const ch1 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 01.md' });
|
||||
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Characters/Mara Vell.md' && l.line === 2));
|
||||
assert.ok(ch1.links_out.some(l => l.path === 'Book One/Places/Harbor Town.md'));
|
||||
const ch2 = await get(harbor, { universe: 'harbor', path: 'Book One/Chapters/Chapter 02.md' });
|
||||
assert.ok(ch2.links_out.some(l => l.path === 'Book One/Research/Ferry Logs.md'));
|
||||
assert.ok(!ch2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
|
||||
const mara = await get(harbor, { universe: 'harbor', path: 'Book One/Characters/Mara Vell.md' });
|
||||
assert.ok(mara.links_in.some(l => l.path === 'Book One/Chapters/Chapter 01.md'));
|
||||
assert.ok(mara.links_in.some(l => l.path === 'Book One/Playground/What If.md'));
|
||||
assert.ok(mara.links_out.some(l => l.path === 'Book One/Characters/Tomas.md' && l.field === 'relationships'));
|
||||
assert.ok(mara.links_out.some(l => l.path === 'Book One/Chapters/Chapter 01.md' && l.field === 'appears_in'));
|
||||
const blendCh2 = await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path: 'Book One/Chapters/Chapter 02.md' });
|
||||
assert.ok(blendCh2.links_out.some(l => l.path === 'Second World/Characters/Ilse.md'));
|
||||
});
|
||||
|
||||
test('a long list pages by position, never cuts an item, and returns 50 per page', async t => {
|
||||
const files = { ...FILES };
|
||||
for (let i = 1; i <= 120; i++) files[`Book One/Places/Note ${i}.md`] = note('place', `Note ${i}`);
|
||||
const s = src(scratchRepo(t, files));
|
||||
const seen = [];
|
||||
let offset = 0;
|
||||
for (let pages = 0; pages < 10; pages++) {
|
||||
const reply = await find(s, { universe: 'harbor', query: 'Note', offset });
|
||||
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
|
||||
assert.ok(reply.results.length <= LOOKUP_LIMIT);
|
||||
if (pages === 0) assert.equal(reply.results.length, LOOKUP_LIMIT);
|
||||
seen.push(...paths(reply));
|
||||
if (reply.next === null) break;
|
||||
offset = reply.next;
|
||||
}
|
||||
assert.equal(seen.length, 120);
|
||||
assert.equal(new Set(seen).size, 120);
|
||||
});
|
||||
|
||||
test('a page stays under the reply cap when items are large', async t => {
|
||||
const big = 'x'.repeat(2000);
|
||||
const files = { ...FILES };
|
||||
for (let i = 1; i <= 40; i++) files[`Book One/Places/Big ${i}.md`] = note('place', `Big ${i}`, '', [`appears_in: "${big}"`]);
|
||||
const s = src(scratchRepo(t, files));
|
||||
const first = await find(s, { universe: 'harbor', query: 'Big' });
|
||||
assert.ok(JSON.stringify(first).length <= QUERY_REPLY_CAP);
|
||||
assert.ok(first.results.length > 0 && first.results.length < 40);
|
||||
assert.notEqual(first.next, null);
|
||||
const seen = [];
|
||||
let offset = 0;
|
||||
for (let pages = 0; pages < 10; pages++) {
|
||||
const reply = await find(s, { universe: 'harbor', query: 'Big', offset });
|
||||
assert.ok(JSON.stringify(reply).length <= QUERY_REPLY_CAP);
|
||||
seen.push(...paths(reply));
|
||||
if (reply.next === null) break;
|
||||
offset = reply.next;
|
||||
}
|
||||
assert.equal(new Set(seen).size, 40);
|
||||
});
|
||||
|
||||
test('a search line too long to fit alone comes back marked, with no excerpt, and the other hits still return', async t => {
|
||||
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', `${huge} ${MARKER}`) }));
|
||||
const reply = await search(s, { universe: 'harbor', text: 'yyyyyyyy' });
|
||||
assert.deepEqual(reply.results, [{ path: 'Book One/Places/Wall.md', line: 5, excerpt: null, excerpt_too_long: true, scope: 'canon' }]);
|
||||
const mixed = await search(s, { universe: 'harbor', text: MARKER.toLowerCase() });
|
||||
assert.ok(mixed.results.some(r => r.excerpt_too_long === true));
|
||||
assert.ok(mixed.results.some(r => typeof r.excerpt === 'string' && !('excerpt_too_long' in r)));
|
||||
assert.ok(JSON.stringify(mixed).length <= QUERY_REPLY_CAP);
|
||||
});
|
||||
|
||||
test('a find item that cannot fit alone is refused, not cut', async t => {
|
||||
const huge = 'y'.repeat(QUERY_REPLY_CAP + 1000);
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Places/Wall.md': note('place', 'Wall', '', [`appears_in: "${huge}"`]) }));
|
||||
await assert.rejects(find(s, { universe: 'harbor', query: 'Wall' }), { code: 'query-item-too-large' });
|
||||
});
|
||||
|
||||
test('get pages its lines, and the next line picks up where the page stopped', async t => {
|
||||
const body = `${Array.from({ length: 500 }, (_, i) => `Line ${i + 1} ${'z'.repeat(120)}`).join('\n')}\n`;
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long.md': body }));
|
||||
const first = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md' });
|
||||
assert.equal(first.lines[0][0], 1);
|
||||
assert.ok(first.lines.length < 501);
|
||||
assert.notEqual(first.next, null);
|
||||
let from = 1, count = 0;
|
||||
for (let pages = 0; pages < 10; pages++) {
|
||||
const page = await get(s, { universe: 'harbor', path: 'Book One/Chapters/Long.md', from });
|
||||
count += page.lines.length;
|
||||
if (page.next === null) break;
|
||||
from = page.next;
|
||||
}
|
||||
assert.equal(count, 501);
|
||||
});
|
||||
|
||||
test('get refuses a file whose front matter alone is over the cap, and a line alone is over the cap', async t => {
|
||||
const huge = 'q'.repeat(QUERY_REPLY_CAP + 1000);
|
||||
const s = src(scratchRepo(t, {
|
||||
...FILES,
|
||||
'Book One/Places/Heavy.md': note('place', 'Heavy', '', [`appears_in: "${huge}"`]),
|
||||
'Book One/Chapters/Wide.md': `${huge}\n`,
|
||||
}));
|
||||
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Places/Heavy.md' }), { code: 'query-item-too-large' });
|
||||
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Chapters/Wide.md' }), { code: 'query-item-too-large' });
|
||||
});
|
||||
|
||||
test('get pages the links of a hub note, every page carries the front matter, and the pages add up', async t => {
|
||||
const files = { ...FILES };
|
||||
for (let i = 1; i <= 900; i++) files[`Book One/Chapters/Mention ${i}.md`] = `[[Tomas]] ${'w'.repeat(60)}\n`;
|
||||
const s = src(scratchRepo(t, files));
|
||||
const seen = [];
|
||||
const PAST_END = 1000;
|
||||
let linksFrom = 0, pages = 0, lines = 0, from = 1;
|
||||
for (; pages < 40; pages++) {
|
||||
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from, links_from: linksFrom });
|
||||
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
|
||||
assert.equal(page.frontmatter.name, 'Tomas');
|
||||
seen.push(...page.links_in.map(l => l.path));
|
||||
if (from !== PAST_END) lines += page.lines.length;
|
||||
// Once the lines are read, ask past the last one: the page then carries links alone.
|
||||
from = page.next ?? PAST_END;
|
||||
if (page.links_next === null && page.next === null) break;
|
||||
if (page.links_next !== null) linksFrom = page.links_next;
|
||||
}
|
||||
assert.ok(pages > 1, 'the hub needed more than one page');
|
||||
assert.ok(pages < 40);
|
||||
assert.equal(seen.filter(p => p.startsWith('Book One/Chapters/Mention ')).length, 900);
|
||||
assert.equal(new Set(seen).size, seen.length, 'no link comes back twice');
|
||||
assert.equal(lines, 6, 'the five lines of the note and the empty one after its last newline');
|
||||
const tail = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', from: PAST_END });
|
||||
assert.deepEqual(tail.lines, []);
|
||||
assert.equal(tail.next, null);
|
||||
});
|
||||
|
||||
test('get rejects a bad links_from', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: -1 }), { code: 'query-argument-invalid' });
|
||||
const past = await get(s, { universe: 'harbor', path: 'Book One/Characters/Tomas.md', links_from: 99 });
|
||||
assert.deepEqual([past.links_out, past.links_in, past.links_next], [[], [], null]);
|
||||
});
|
||||
|
||||
test('search returns scoped line hits, case-insensitively, and refuses empty text', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const reply = await search(s, { universe: 'harbor', text: 'marker' });
|
||||
assert.ok(reply.results.length > 0);
|
||||
assert.ok(reply.results.every(r => r.scope === 'canon' && r.path.startsWith('Book One/') && Number.isInteger(r.line)));
|
||||
assert.ok(reply.results.some(r => r.path === 'Book One/Chapters/Chapter 01.md' && r.line === 2));
|
||||
await assert.rejects(search(s, { universe: 'harbor', text: '' }), { code: 'query-argument-invalid' });
|
||||
});
|
||||
|
||||
test('a manuscript call returns one chapter, at most 400 lines, and names its next line', async t => {
|
||||
const long = `${Array.from({ length: 900 }, (_, i) => `Scene ${i + 1}`).join('\n')}\n`;
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Long Chapter.md': long }));
|
||||
const first = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter' });
|
||||
assert.equal(first.lines.length, MANUSCRIPT_LINES);
|
||||
assert.equal(first.next, MANUSCRIPT_LINES + 1);
|
||||
const second = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Long Chapter', from: first.next });
|
||||
assert.equal(second.lines[0][0], MANUSCRIPT_LINES + 1);
|
||||
});
|
||||
|
||||
test('a manuscript call without a chapter returns the first chapter in path order and names the next', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
const first = await manuscript(s, { universe: 'harbor', book: 'Book One' });
|
||||
assert.equal(first.path, 'Book One/Chapters/Chapter 01.md');
|
||||
assert.equal(first.next_chapter, 'Chapter 02');
|
||||
const last = await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 02' });
|
||||
assert.equal(last.path, 'Book One/Chapters/Chapter 02.md');
|
||||
assert.equal(last.next_chapter, null);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Nope' }), null);
|
||||
});
|
||||
|
||||
test('a chapter name that matches two visible files is refused', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, 'Book One/Chapters/Part/Chapter 01.md': 'Again.\n' }));
|
||||
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', chapter: 'Chapter 01' }), { code: 'query-chapter-ambiguous' });
|
||||
});
|
||||
|
||||
test('a hybrid reads a source chapter through its declared sources only', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const hybrid = await manuscript(src(ref, mapping(WITH_BLEND)), { universe: 'blend', book: 'Second World', chapter: '01' });
|
||||
assert.equal(hybrid.path, 'Second World/Chapters/01.md');
|
||||
assert.equal(hybrid.scope, 'canon');
|
||||
assert.equal(await manuscript(src(ref), { universe: 'harbor', book: 'Second World', chapter: '01' }), null);
|
||||
});
|
||||
|
||||
test('universe info reports a hybrid sources, constraints and notes, and refuses an unknown universe', async t => {
|
||||
const s = src(scratchRepo(t, FILES), mapping(WITH_BLEND));
|
||||
const blend = await universeInfo(s, { universe: 'blend' });
|
||||
assert.equal(blend.kind, 'hybrid');
|
||||
assert.deepEqual(blend.sources, ['harbor', 'second']);
|
||||
assert.deepEqual(blend.constraints, [EXCLUSION]);
|
||||
assert.equal(blend.notes, 'made-up hybrid');
|
||||
const harbor = await universeInfo(s, { universe: 'harbor' });
|
||||
assert.equal(harbor.kind, 'universe');
|
||||
assert.equal(harbor.sources, undefined);
|
||||
await assert.rejects(universeInfo(s, { universe: 'ghost' }), { code: 'query-universe-unknown' });
|
||||
});
|
||||
|
||||
test('an exclusion hides what it names from a hybrid and from nothing else', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const hybrid = src(ref, mapping(WITH_BLEND));
|
||||
const seen = paths(await find(hybrid, { universe: 'blend', query: '' }));
|
||||
for (const path of EXCLUDED_PATHS) assert.ok(!seen.includes(path), `${path} stays hidden`);
|
||||
const own = paths(await find(src(ref), { universe: 'harbor', query: '' }));
|
||||
for (const path of EXCLUDED_PATHS) assert.ok(own.includes(path), `${path} stays visible to its own universe`);
|
||||
});
|
||||
|
||||
test('PRD 1.14: a lookup that names a book leaves book-null files out', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
const all = paths(await find(s, { universe: 'harbor', query: '', scope: 'playground' }));
|
||||
assert.deepEqual(all.sort(), ['Book One/Playground/What If.md', 'Notes/Harbor/Chapters/Idea Chapter.md', 'Notes/Harbor/Scraps.md']);
|
||||
const booked = await find(s, { universe: 'harbor', query: '', scope: 'playground', book: 'Book One' });
|
||||
assert.deepEqual(paths(booked), ['Book One/Playground/What If.md']);
|
||||
assert.ok(booked.results.every(r => r.book === 'Book One'));
|
||||
const hits = (await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground' })).results;
|
||||
assert.deepEqual(hits.map(r => r.path), ['Notes/Harbor/Chapters/Idea Chapter.md']);
|
||||
assert.deepEqual((await search(s, { universe: 'harbor', text: 'unbooked', scope: 'playground', book: 'Book One' })).results, []);
|
||||
});
|
||||
|
||||
test('PRD 1.14: library_manuscript never reads a book-null file', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
// The unbooked file sits under a manuscript folder, so it has type manuscript and book null.
|
||||
const listed = await find(s, { universe: 'harbor', query: 'Idea', scope: 'playground' });
|
||||
assert.deepEqual(listed.results.map(r => [r.path, r.type, r.book]), [['Notes/Harbor/Chapters/Idea Chapter.md', 'manuscript', null]]);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground', chapter: 'Idea Chapter' }), null);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'playground' }), null);
|
||||
assert.equal(await manuscript(s, { universe: 'harbor', book: 'Notes', scope: 'playground', chapter: 'Idea Chapter' }), null);
|
||||
});
|
||||
|
||||
test('PRD 1.14: a draft universe has no canon, so its lookups return files only with scope playground', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
assert.deepEqual((await find(s, { universe: 'idea', query: '' })).results, []);
|
||||
assert.deepEqual((await search(s, { universe: 'idea', text: MARKER })).results, []);
|
||||
const found = await find(s, { universe: 'idea', query: '', scope: 'playground' });
|
||||
assert.deepEqual(paths(found).sort(), ['Drafts/New World/Characters/Rin.md', 'Drafts/New World/Summary.md']);
|
||||
assert.ok(found.results.every(r => r.scope === 'playground' && r.universe === 'idea' && r.book === null));
|
||||
const hits = (await search(s, { universe: 'idea', text: 'draft', scope: 'playground' })).results;
|
||||
assert.equal(hits.length, 2);
|
||||
const rin = await get(s, { universe: 'idea', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' });
|
||||
assert.equal(rin.scope, 'playground');
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Drafts/New World/Characters/Rin.md', scope: 'playground' }), null);
|
||||
});
|
||||
|
||||
test('PRD 1.14: library_universe on a draft returns its kind, name and prefixes', async t => {
|
||||
const s = src(scratchRepo(t, { ...FILES, ...LEVEL_FILES }), mapping(WITH_LEVELS));
|
||||
const draft = await universeInfo(s, { universe: 'idea' });
|
||||
assert.equal(draft.kind, 'draft');
|
||||
assert.equal(draft.name, 'New World');
|
||||
assert.deepEqual(draft.playground, ['Drafts/New World/']);
|
||||
assert.deepEqual(draft.books, []);
|
||||
assert.equal(draft.sources, undefined);
|
||||
});
|
||||
|
||||
test('a hybrid gets a source playground file as null, while the source gets it with scope playground', async t => {
|
||||
const ref = scratchRepo(t, FILES);
|
||||
const path = 'Book One/Playground/What If.md';
|
||||
assert.equal(await get(src(ref, mapping(WITH_BLEND)), { universe: 'blend', path, scope: 'playground' }), null);
|
||||
const own = await get(src(ref), { universe: 'harbor', path, scope: 'playground' });
|
||||
assert.equal(own.scope, 'playground');
|
||||
});
|
||||
|
||||
// mos-1330 item3: the new get contract defaults to canon even for an explicit playground path.
|
||||
test('C2-05c get without scope returns null for a playground path', async t => {
|
||||
const s = src(scratchRepo(t, FILES));
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Playground/What If.md' }), null);
|
||||
});
|
||||
|
||||
// Row284: scope is explicit for get as well as lists; blob and lines share a served snapshot.
|
||||
test('C2-05c supporting find/search/get are opt-in at book, universe and draft level', async t => {
|
||||
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
|
||||
const expected = ['Book One/Support/Notes/Helper.md', 'Notes/HarborSupport/Loose.md'];
|
||||
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '', scope: 'supporting' })), expected);
|
||||
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results.map(x => x.path), expected);
|
||||
assert.deepEqual(paths(await find(s, { universe: 'harbor', query: '' })), ['Book One/Characters/Canon.md']);
|
||||
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture' })).results.map(x => x.path), ['Book One/Characters/Canon.md']);
|
||||
for (const path of expected) {
|
||||
assert.equal(await get(s, { universe: 'harbor', path }), null);
|
||||
assert.equal(await get(s, { universe: 'harbor', path, scope: 'playground' }), null);
|
||||
assert.equal((await get(s, { universe: 'harbor', path, scope: 'supporting' })).scope, 'supporting');
|
||||
assert.equal(await get(s, { universe: 'second', path, scope: 'supporting' }), null);
|
||||
}
|
||||
const draftPath = 'Drafts/Idea/Support/Seed.md';
|
||||
assert.deepEqual(paths(await find(s, { universe: 'idea', query: '', scope: 'supporting' })), [draftPath]);
|
||||
assert.equal((await get(s, { universe: 'idea', path: draftPath, scope: 'supporting' })).scope, 'supporting');
|
||||
assert.equal(await get(s, { universe: 'idea', path: draftPath }), null);
|
||||
assert.equal((await find(s, { universe: 'harbor', query: '', book: 'Book One', scope: 'supporting' })).results.length, 1);
|
||||
});
|
||||
|
||||
test('C2-05c an absent supporting prefix returns nothing without refusal', async t => {
|
||||
const s = src(scratchRepo(t, SUPPORT_FILES));
|
||||
assert.deepEqual((await find(s, { universe: 'harbor', query: '', scope: 'supporting' })).results, []);
|
||||
assert.deepEqual((await search(s, { universe: 'harbor', text: 'Scope fixture', scope: 'supporting' })).results, []);
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope: 'supporting' }), null);
|
||||
assert.equal(await get(s, { universe: 'harbor', path: 'Book One/Support/Notes/Helper.md', scope: 'supporting' }), null);
|
||||
});
|
||||
|
||||
test('C2-05c each get blob equals its served tree object, even after source HEAD advances', async t => {
|
||||
const ref = scratchRepo(t, SUPPORT_FILES), s = src(ref, mapping(withSupporting));
|
||||
const canon = 'Book One/Characters/Canon.md';
|
||||
const priorBlob = git(ref.repo, 'rev-parse', `${ref.commit}:${canon}`);
|
||||
writeFileSync(join(ref.repo, canon), note('character', 'Canon changed', 'New HEAD must not be read.'));
|
||||
git(ref.repo, 'add', '-A'); git(ref.repo, 'commit', '-qm', 'advance head fixture');
|
||||
assert.notEqual(git(ref.repo, 'rev-parse', `HEAD:${canon}`), priorBlob);
|
||||
for (const [scope, path] of [['canon', canon], ['playground', 'Book One/Playground/Plan.md'], ['supporting', 'Book One/Support/Notes/Helper.md']]) {
|
||||
const result = await get(s, { universe: 'harbor', path, scope });
|
||||
assert.equal(result.commit, ref.commit);
|
||||
assert.equal(result.blob, git(ref.repo, 'rev-parse', `${ref.commit}:${path}`));
|
||||
assert.ok(!JSON.stringify(result.lines).includes('New HEAD must not be read'));
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-05c full outgoing and incoming links stay in scope order across pages without losing cross-scope links', async t => {
|
||||
const files = {}, groups = { supporting: [], playground: [], canon: [] };
|
||||
for (const [scope, folder] of [['canon', 'Book One/Characters'], ['playground', 'Book One/Playground'], ['supporting', 'Book One/Support']]) {
|
||||
for (let n = 0; n < 100; n++) {
|
||||
const path = `${folder}/${scope}-${String(n).padStart(3, '0')}-${'x'.repeat(80)}.md`;
|
||||
groups[scope].push(path);
|
||||
files[path] = `[[Book One/Characters/Hub]]\n`;
|
||||
}
|
||||
}
|
||||
const all = [...groups.canon, ...groups.playground, ...groups.supporting];
|
||||
files['Book One/Characters/Hub.md'] = all.map(path => `[[${path.slice(0, -3)}]]`).join('\n');
|
||||
// Other-universe links are never returned, regardless of rank.
|
||||
files['Second World/Other.md'] = '[[Book One/Characters/Hub]]\n';
|
||||
const s = src(scratchRepo(t, files), mapping(withSupporting));
|
||||
const outs = [], ins = [];
|
||||
let offset = 0, pages = 0;
|
||||
for (; pages < 20; pages++) {
|
||||
const page = await get(s, { universe: 'harbor', path: 'Book One/Characters/Hub.md', from: 999_999, links_from: offset });
|
||||
assert.ok(JSON.stringify(page).length <= QUERY_REPLY_CAP);
|
||||
outs.push(...page.links_out.map(x => x.path)); ins.push(...page.links_in.map(x => x.path));
|
||||
if (page.links_next === null) break;
|
||||
offset = page.links_next;
|
||||
}
|
||||
const expected = [...groups.supporting, ...groups.playground, ...groups.canon];
|
||||
assert.ok(pages > 0 && pages < 20, 'links occupy multiple bounded pages');
|
||||
assert.deepEqual(outs, expected);
|
||||
assert.deepEqual(ins, expected);
|
||||
assert.equal(new Set(outs).size, 300);
|
||||
assert.equal(new Set(ins).size, 300);
|
||||
});
|
||||
|
||||
test('C2-05c manuscript still refuses supporting rather than silently widening its scope', async t => {
|
||||
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(withSupporting));
|
||||
await assert.rejects(manuscript(s, { universe: 'harbor', book: 'Book One', scope: 'supporting' }), { code: 'query-argument-invalid' });
|
||||
});
|
||||
|
||||
test('C2-05c scope expansion keeps hybrid sources canon-only and refuses all/array scopes', async t => {
|
||||
const s = src(scratchRepo(t, SUPPORT_FILES), mapping(entry => withSupporting(WITH_BLEND(entry))));
|
||||
assert.deepEqual((await find(s, { universe: 'blend', query: '', scope: 'supporting' })).results, []);
|
||||
assert.equal(await get(s, { universe: 'blend', path: 'Book One/Support/Notes/Helper.md', scope: 'supporting' }), null);
|
||||
assert.equal((await get(s, { universe: 'blend', path: 'Book One/Characters/Canon.md' })).scope, 'canon');
|
||||
for (const scope of ['all', ['canon', 'supporting']])
|
||||
await assert.rejects(get(s, { universe: 'harbor', path: 'Book One/Characters/Canon.md', scope }), { code: 'query-argument-invalid' });
|
||||
});
|
||||
@@ -53,3 +53,44 @@ test('C2-01c: a canon file linking into a draft gives R6, and into its own unive
|
||||
assert.deepEqual(codes(path), [], path);
|
||||
}
|
||||
});
|
||||
|
||||
// Supporting prefixes at three levels (C2-01d). Harbor's book-level prefix sits in both lists, and a
|
||||
// second book-level prefix is supporting only.
|
||||
const supporting = () => mapping(e => {
|
||||
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
|
||||
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
|
||||
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
|
||||
return e;
|
||||
});
|
||||
|
||||
test('C2-01d: each supporting level gives its universe, and its book or null', () => {
|
||||
const m = supporting();
|
||||
assert.deepEqual(classify(m, 'Book One/Notes/Characters/Mara.md'),
|
||||
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
|
||||
assert.deepEqual(classify(m, 'Harbor Notes/Places/Island.md'),
|
||||
{ scope: 'supporting', universe: 'harbor', book: null, type: 'place', folder: 'Places' });
|
||||
assert.deepEqual(classify(m, 'Harbor Notes/Loose.md'), { scope: 'supporting', universe: 'harbor', book: null, type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Drafts/New World Notes/Chapters/Open.md'),
|
||||
{ scope: 'supporting', universe: 'idea', book: null, type: 'manuscript', folder: 'Chapters' });
|
||||
assert.deepEqual(classify(m, 'Drafts/New World/Ana.md'), { scope: 'playground', universe: 'idea', book: null, type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Book One/Places/Dock.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: 'place', folder: 'Places' });
|
||||
});
|
||||
|
||||
test('C2-01d: supporting comes before playground; a folder in both lists is supporting, one in the playground only stays playground', () => {
|
||||
const m = supporting();
|
||||
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
|
||||
{ scope: 'supporting', universe: 'harbor', book: 'Book One', type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Book One/Sketches/Research/Tides.md'),
|
||||
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'research', folder: 'Research' });
|
||||
// A playground may hold a supporting prefix of its own entry: the inner folder is supporting.
|
||||
const nested = mapping(e => { e.universes.harbor.supporting = ['Book One/Playground/Notes/']; return e; });
|
||||
assert.equal(classify(nested, 'Book One/Playground/Notes/A.md').scope, 'supporting');
|
||||
assert.equal(classify(nested, 'Book One/Playground/A.md').scope, 'playground');
|
||||
});
|
||||
|
||||
test('C2-01d: only .md files are supporting; a .canvas or .base under a supporting prefix is unmapped', () => {
|
||||
const m = supporting();
|
||||
for (const path of ['Book One/Notes/Board.canvas', 'Harbor Notes/Table.base', 'Drafts/New World Notes/Map.canvas',
|
||||
'Harbor Notes/cover.png', 'Harbor Notesx/a.md'])
|
||||
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { inspect } from 'node:util';
|
||||
import { LibraryError, supportingPath } from '../src/index.mjs';
|
||||
import { mapping } from './helpers.mjs';
|
||||
|
||||
// C2-01d (M12): the path checks an agent write passes. The tree is a listTree array built by hand,
|
||||
// with a symlink, a gitlink, case twins and an NFC name, so no git is needed.
|
||||
const m = mapping(e => {
|
||||
e.universes.harbor.playground = ['Book One/Playground/', 'Book One/Sketches/'];
|
||||
e.universes.harbor.supporting = ['Book One/Playground/', 'Book One/Notes/', 'Harbor Notes/'];
|
||||
e.universes.second.supporting = ['Second Notes/'];
|
||||
e.universes.idea = { kind: 'draft', name: 'New World', playground: ['Drafts/New World/'], supporting: ['Drafts/New World Notes/'] };
|
||||
return e;
|
||||
});
|
||||
const blob = (path, mode = '100644') => ({ mode, type: mode === '160000' ? 'commit' : 'blob', oid: '0'.repeat(40), size: 1, path });
|
||||
const tree = [
|
||||
blob('Book One/Characters/Mara Vell.md'),
|
||||
blob('Book One/Notes/Existing.md'),
|
||||
blob('Book One/Notes/Linked', '120000'),
|
||||
blob('Book One/Notes/Vendor', '160000'),
|
||||
blob('Book One/Notes/Pointer.md', '120000'),
|
||||
blob('Book One/Notes/Case/Twin.md'),
|
||||
blob('Book One/Notes/Café.md'),
|
||||
blob('Harbor Notes/Places/Island.md'),
|
||||
];
|
||||
const refuses = (universe, path, pattern) => assert.throws(() => supportingPath(m, universe, path, tree),
|
||||
e => e instanceof LibraryError && e.code === 'library-write-story-path' && pattern.test(e.message), inspect(path));
|
||||
|
||||
test('C2-01d: a path passes at book level, universe level, in a draft and in a folder in both lists', () => {
|
||||
for (const [universe, path] of [['harbor', 'Book One/Notes/Characters/New.md'], ['harbor', 'Book One/Notes/Existing.md'],
|
||||
['harbor', 'Harbor Notes/Places/Island.md'], ['harbor', 'Harbor Notes/Loose.md'], ['idea', 'Drafts/New World Notes/Ana.md'],
|
||||
['harbor', 'Book One/Playground/What If.md'], ['second', 'Second Notes/A.md'], ['harbor', 'Book One/Notes/Café.md']])
|
||||
assert.equal(supportingPath(m, universe, path, tree), path);
|
||||
assert.equal(supportingPath(m, 'harbor', 'Book One/Notes/x.md', []), 'Book One/Notes/x.md');
|
||||
});
|
||||
|
||||
test('C2-01d: a path of exactly 1024 bytes passes, and 1025 bytes made of multi-byte characters refuses', () => {
|
||||
const prefix = 'Harbor Notes/';
|
||||
const exact = `${prefix}${'a'.repeat(1024 - prefix.length - 3)}.md`;
|
||||
assert.equal(Buffer.byteLength(exact), 1024);
|
||||
assert.equal(supportingPath(m, 'harbor', exact, tree), exact);
|
||||
// é is two bytes: 1025 bytes but far fewer characters.
|
||||
const name = 'é'.repeat((1025 - prefix.length - 3 - 1) / 2);
|
||||
const long = `${prefix}${name}a.md`;
|
||||
assert.equal(Buffer.byteLength(long), 1025);
|
||||
assert.ok(long.length < 1024);
|
||||
refuses('harbor', long, /is longer than 1024 bytes/);
|
||||
});
|
||||
|
||||
test('C2-01d: a path outside the universe\'s supporting folders refuses', () => {
|
||||
const outside = /is not under a supporting folder of universe/;
|
||||
for (const path of ['Inbox Note.md', 'Book One/Notes/Board.canvas', 'Book One/Notes/Table.base', 'Book One/Notes/cover.png',
|
||||
'Book One/Characters/Mara Vell.md', 'Book One/Sketches/Idea.md', 'Drafts/New World/Ana.md', 'Second Notes/A.md', 'Book One/Notes.md'])
|
||||
refuses('harbor', path, outside);
|
||||
refuses('idea', 'Book One/Notes/A.md', /universe "idea"/);
|
||||
refuses('nowhere', 'Book One/Notes/A.md', /universe "nowhere"/);
|
||||
});
|
||||
|
||||
test('C2-01d: malformed paths refuse by class, in order', () => {
|
||||
refuses('harbor', '/Book One/Notes/A.md', /"\/Book One\/Notes\/A.md" is absolute/);
|
||||
for (const path of ['Book One/Notes/../Notes/A.md', 'Book One/Notes/./A.md', 'Book One//Notes/A.md', 'Book One/Notes/A/', 'Book One/Notes/..'])
|
||||
refuses('harbor', path, /has an empty, \. or \.\. segment/);
|
||||
refuses('harbor', 'Book One/Notes/A\\B.md', /holds a backslash/);
|
||||
for (const c of ['\u0000', '\u0001', '\n', '\u001f', '\u007f', '\u0085', '\u009f'])
|
||||
assert.throws(() => supportingPath(m, 'harbor', `Book One/Notes/A${c}.md`, tree),
|
||||
e => e.code === 'library-write-story-path' && e.message === 'library-write-story-path: the path holds a control character');
|
||||
for (const path of [7, null, undefined, 1n, ['Book One/Notes/A.md']]) refuses('harbor', path, /the path must be a string/);
|
||||
// A backslash in a path that is also absolute names the backslash: the order is fixed.
|
||||
refuses('harbor', '/a\\b.md', /holds a backslash/);
|
||||
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md', { 'Book One/Notes/A.md': '100644' }), TypeError);
|
||||
assert.throws(() => supportingPath(m, 'harbor', 'Book One/Notes/A.md'), TypeError);
|
||||
});
|
||||
|
||||
test('C2-01d: a symlink or gitlink on the way, or at the path itself, refuses', () => {
|
||||
refuses('harbor', 'Book One/Notes/Linked/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Linked"/);
|
||||
refuses('harbor', 'Book One/Notes/Vendor/Deep/A.md', /goes through the symlink or gitlink "Book One\/Notes\/Vendor"/);
|
||||
refuses('harbor', 'Book One/Notes/Pointer.md', /goes through the symlink or gitlink "Book One\/Notes\/Pointer.md"/);
|
||||
});
|
||||
|
||||
test('C2-01d: a name that differs from a tree name only in case or Unicode form refuses, at the file or any folder', () => {
|
||||
refuses('harbor', 'Book One/Notes/existing.md', /differs only in case or Unicode form from "Book One\/Notes\/Existing.md"/);
|
||||
refuses('harbor', 'Book One/Notes/case/New.md', /differs only in case or Unicode form from "Book One\/Notes\/Case"/);
|
||||
refuses('harbor', 'Book One/Notes/CASE/Twin.md', /from "Book One\/Notes\/Case"/);
|
||||
refuses('harbor', 'Harbor notes/A.md', /is not under a supporting folder/);
|
||||
// NFD e + combining acute against the NFC name in the tree: one file on APFS.
|
||||
refuses('harbor', 'Book One/Notes/Café.md', /differs only in case or Unicode form from "Book One\/Notes\/Café.md"/);
|
||||
// A tree that already holds case twins refuses a write to either (R7).
|
||||
const twins = [...tree, blob('Book One/Notes/existing.md')];
|
||||
for (const path of ['Book One/Notes/Existing.md', 'Book One/Notes/existing.md'])
|
||||
assert.throws(() => supportingPath(m, 'harbor', path, twins), e => e.code === 'library-write-story-path' && /differs only in case/.test(e.message));
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -70,12 +70,23 @@ test('with --mapping the survey adds scope, resolver step and problem counts', a
|
||||
assert.equal(out.code, 0, out.stderr);
|
||||
const tail = out.stdout.trimEnd().split('\n').slice(-3);
|
||||
assert.deepEqual(tail, [
|
||||
'scopes canon 5, playground 1, unmapped 3; indexed 6',
|
||||
'scopes canon 5, playground 1, supporting 0, unmapped 3; indexed 6',
|
||||
'resolved root 2, vault-prefix 1, relative 2, shortest 3, unresolved 0',
|
||||
'problems R1 0, R2 3, R3 0, R4 0, R5 0, R6 0, R7 0']);
|
||||
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
||||
});
|
||||
|
||||
test('C2-01d: with --mapping the survey counts supporting files', async t => {
|
||||
const r = repo(t);
|
||||
const entry = JSON.parse(readFileSync(join(FIXTURES, 'mapping.json'), 'utf8'));
|
||||
entry.universes.harbor.supporting = ['Book One/Places/'];
|
||||
const file = join(r.root, 'supporting.json');
|
||||
writeFileSync(file, JSON.stringify(entry));
|
||||
const out = await run(['survey', r.repo, '--mapping', file]);
|
||||
assert.equal(out.code, 0, out.stderr);
|
||||
assert.match(out.stdout, /^scopes canon 4, playground 1, supporting 1, unmapped 3; indexed 6$/m);
|
||||
});
|
||||
|
||||
test('survey exit codes: usage 4, bad mapping 3, unreadable repo 2', async t => {
|
||||
const r = repo(t);
|
||||
for (const args of [[], ['scan', r.repo], ['survey'], ['survey', r.repo, '--mapping'], ['survey', r.repo, '--other'], ['survey', r.repo, r.repo]])
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
// argv and adds the CA to its env. The token is made up and must appear nowhere but the slot.
|
||||
import test, { before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { accessSync, constants, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { scratchCa, fakeGitea } from '../../seat/tests/fake-gitea.mjs';
|
||||
import { SAFE } from '../../project/src/git.mjs';
|
||||
import { mappingEntry, repo, git, BASE } from './helpers.mjs';
|
||||
import { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from '../src/sync.mjs';
|
||||
import { syncCommand, sync, push, humanCredential, ASKPASS, SLOT, HUMAN_SLOT, SYNC_INTERVAL_MS } from '../src/sync.mjs';
|
||||
import { mirrorPath, readSyncState, readLibraryFile, SYNC_STATE_FILE } from '../src/mirror.mjs';
|
||||
import { readLibrary } from '../src/reader.mjs';
|
||||
import { checkMapping } from '../src/mapping.mjs';
|
||||
@@ -25,9 +25,9 @@ after(() => rmSync(scratch, { recursive: true, force: true }));
|
||||
|
||||
const clock = () => { let n = 0; return () => new Date(Date.UTC(2026, 9, 21, 12, 0, n++)); };
|
||||
|
||||
async function world(t, { token = TOKEN, redirect = null, user = USER } = {}) {
|
||||
async function world(t, { token = TOKEN, redirect = null, user = USER, users = { [USER]: token } } = {}) {
|
||||
const f = repo(t);
|
||||
const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, redirect });
|
||||
const gitea = await fakeGitea({ dir: join(f.root, 'gitea'), tls, user: USER, token, users, redirect });
|
||||
t.after(() => gitea.close());
|
||||
const remote = gitea.repo('writer', 'books');
|
||||
mkdirSync(dirname(remote), { recursive: true });
|
||||
@@ -304,3 +304,226 @@ test('C2-03a one sync per mirror: an overlapping call refuses library-sync-busy'
|
||||
assert.equal(results.find(r => r.status === 'rejected').reason.code, 'library-sync-busy');
|
||||
assert.equal((await w.run()).code, null, 'the mirror is free again afterwards');
|
||||
});
|
||||
|
||||
// C2-03b (#1791): push and per-seat credentials. A seat's grant store file is the token plus "\n", as
|
||||
// Credentials writes it. SEAT_TOKEN is made up too and must also appear nowhere but its file.
|
||||
const SEAT = 'writer-seat';
|
||||
const SEAT_TOKEN = 'fixture-token-71ae-made-up-0044';
|
||||
|
||||
async function writeWorld(t) {
|
||||
const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } });
|
||||
const seatFile = join(w.root, 'store', 'acme', SEAT, 'gitea');
|
||||
mkdirSync(dirname(seatFile), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(seatFile, SEAT_TOKEN + '\n', { mode: 0o600 });
|
||||
const seat = { kind: 'seat', user: SEAT, file: seatFile };
|
||||
assert.equal((await w.run()).code, null);
|
||||
// A commit on top of `parent` in the mirror, as C2-02's apply would leave it.
|
||||
const child = (parent, text = 'next') => git(w.mirror(), 'commit-tree', `${parent}^{tree}`, '-p', parent, '-m', text);
|
||||
const remoteTip = () => git(w.remote, 'rev-parse', 'refs/heads/main');
|
||||
const write = (options) => push(w.context, { command: w.command, credential: seat, ...options });
|
||||
return { ...w, seatFile, seat, child, remoteTip, write };
|
||||
}
|
||||
|
||||
test('C2-03b argv: a push sends one full commit to the branch, fast-forward only, with the fetch prefix', () => {
|
||||
const opts = { mirror: '/srv/data/projects/acme/books', branch: 'main', user: SEAT, slot: '/srv/store/acme/writer-seat/gitea' };
|
||||
const commit = 'c'.repeat(40);
|
||||
const { argv, env } = syncCommand('push', 'https://git.example.invalid/writer/books.git', { ...opts, commit });
|
||||
assert.deepEqual(argv, [...SAFE, '-c', 'protocol.https.allow=always', '-c', 'http.followRedirects=false', '-c', 'credential.helper=',
|
||||
'-c', 'core.createObject=rename', '-C', opts.mirror, 'push', '--porcelain', '--no-verify', '--no-follow-tags', '--end-of-options',
|
||||
'https://git.example.invalid/writer/books.git', `${commit}:refs/heads/main`]);
|
||||
assert.equal(argv.some(a => a.startsWith('+') || a === '--force' || a === '-f' || a.startsWith('--force-')), false, 'no forced update (D6)');
|
||||
assert.equal(env.MOSAIC_LIBRARY_SLOT, opts.slot);
|
||||
assert.equal(env.MOSAIC_LIBRARY_USER, SEAT);
|
||||
for (const bad of [null, undefined, 'main', 'c'.repeat(39), 'C'.repeat(40), `${'c'.repeat(40)}:refs/heads/x`])
|
||||
assert.throws(() => syncCommand('push', 'https://git.example.invalid/b.git', { ...opts, commit: bad }), { code: 'library-remote-invalid' }, String(bad));
|
||||
assert.throws(() => syncCommand('gc', 'https://git.example.invalid/b.git', opts), { code: 'library-remote-invalid' });
|
||||
});
|
||||
|
||||
test('C2-03b T1 push: a fast-forward lands with the seat\'s own user and file, and moves no local ref', async t => {
|
||||
const w = await writeWorld(t);
|
||||
const before = git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)');
|
||||
const next = w.child(w.commit);
|
||||
w.gitea.requests.length = 0;
|
||||
assert.deepEqual(await w.write({ commit: next }), { pushed: true });
|
||||
assert.equal(w.remoteTip(), next);
|
||||
assert.ok(w.gitea.requests.length > 0);
|
||||
assert.ok(w.gitea.requests.every(r => r.user === null || r.user === SEAT), 'only the seat\'s user reaches the remote');
|
||||
assert.ok(w.gitea.requests.some(r => r.user === SEAT));
|
||||
assert.equal(git(w.mirror(), 'for-each-ref', '--format=%(refname) %(objectname)'), before, 'C2-02 moves the local refs, not push');
|
||||
assert.equal(holdsToken(w.dataRoot), null, 'the bot token sits only in the slot');
|
||||
for (const name of readdirSync(w.root, { recursive: true })) {
|
||||
const file = join(w.root, name);
|
||||
if (file !== w.seatFile && statSync(file).isFile()) assert.equal(readFileSync(file).includes(SEAT_TOKEN), false, file);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-03b T2 and T3: a moved tip reports the race after one fetch, and a second race refuses stale-base', async t => {
|
||||
const w = await writeWorld(t);
|
||||
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
|
||||
const mine = w.child(w.commit);
|
||||
const raced = await w.write({ commit: mine });
|
||||
assert.deepEqual(raced, { pushed: false, raced: true, tip: moved });
|
||||
assert.equal(w.remoteTip(), moved, 'her commit stands: nothing forced it out');
|
||||
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), moved, 'the one fetch ran with the same credential');
|
||||
const again = w.push({ 'Inbox Note.md': 'and more\n' });
|
||||
await assert.rejects(w.write({ commit: w.child(moved), retried: true }), { code: 'stale-base' });
|
||||
assert.equal(w.remoteTip(), again);
|
||||
});
|
||||
|
||||
// dewey-0864 R1: the race fetch takes sync's guard. A sync holds it: its fetch is a git alias that
|
||||
// touches a marker, then waits for a release file. The push starts only once the marker exists and
|
||||
// the release comes only after the refusal, so no step depends on timing (dewey-0869).
|
||||
test('C2-03b race fetch: while a sync of the mirror runs, the race refuses library-sync-busy', async t => {
|
||||
const w = await writeWorld(t);
|
||||
const moved = w.push({ 'Inbox Note.md': 'she wrote more\n' });
|
||||
const before = git(w.mirror(), 'rev-parse', 'refs/remotes/library/main');
|
||||
const mark = join(w.root, 'hold.mark'), release = join(w.root, 'hold.release');
|
||||
const hold = '!f() { touch "$LIB_HOLD_MARK"; i=0; while [ ! -e "$LIB_HOLD_RELEASE" ] && [ $i -lt 1200 ]; do sleep 0.05; i=$((i+1)); done; }; f';
|
||||
const slow = (kind, url, opts) => {
|
||||
const real = w.command(kind, url, opts);
|
||||
return { argv: ['-c', 'alias.hold=' + hold, 'hold'], env: { ...real.env, LIB_HOLD_MARK: mark, LIB_HOLD_RELEASE: release } };
|
||||
};
|
||||
const syncing = sync(w.context, { slot: w.slot, command: slow });
|
||||
t.after(() => { try { writeFileSync(release, ''); } catch {} });
|
||||
const end = Date.now() + 30000;
|
||||
while (!existsSync(mark) && Date.now() < end) await new Promise(r => setTimeout(r, 10));
|
||||
assert.ok(existsSync(mark), 'the sync is inside its fetch, holding the guard');
|
||||
await assert.rejects(w.write({ commit: w.child(w.commit) }), { code: 'library-sync-busy' });
|
||||
assert.equal(w.remoteTip(), moved, 'her commit stands');
|
||||
writeFileSync(release, '');
|
||||
await syncing.catch(() => {});
|
||||
assert.equal(git(w.mirror(), 'rev-parse', 'refs/remotes/library/main'), before, 'the refused race fetched nothing');
|
||||
assert.deepEqual(await w.write({ commit: w.child(w.commit) }), { pushed: false, raced: true, tip: moved }, 'free again afterwards');
|
||||
});
|
||||
|
||||
test('C2-03b T4: a dead remote, a hook refusal and a wrong token each refuse library-push-failed', async t => {
|
||||
const w = await writeWorld(t);
|
||||
// N2: [remote rejected] is a hook, not a race.
|
||||
const hook = join(w.remote, 'hooks', 'pre-receive');
|
||||
writeFileSync(hook, '#!/bin/sh\necho no >&2\nexit 1\n', { mode: 0o755 });
|
||||
const next = w.child(w.commit);
|
||||
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
|
||||
await assert.rejects(w.write({ commit: next, retried: true }), { code: 'library-push-failed' }, 'never stale-base for a hook');
|
||||
rmSync(hook);
|
||||
writeFileSync(w.seatFile, 'fixture-token-wrong-0045\n', { mode: 0o600 });
|
||||
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
|
||||
writeFileSync(w.seatFile, SEAT_TOKEN + '\n', { mode: 0o600 });
|
||||
await w.gitea.close();
|
||||
await assert.rejects(w.write({ commit: next }), { code: 'library-push-failed' });
|
||||
assert.equal(w.remoteTip(), w.commit);
|
||||
});
|
||||
|
||||
test('C2-03b push: a refused, missing or unsafe credential runs no git', async t => {
|
||||
const w = await writeWorld(t);
|
||||
let called = 0;
|
||||
const command = (...a) => { called++; return w.command(...a); };
|
||||
const next = w.child(w.commit);
|
||||
w.gitea.requests.length = 0;
|
||||
const cases = [
|
||||
[{ refused: 'library-fetch-no-grant' }, 'library-fetch-no-grant'],
|
||||
[null, 'library-credential-missing'],
|
||||
[{ kind: 'seat', file: w.seatFile }, 'library-credential-missing'],
|
||||
[{ kind: 'seat', user: 'a b', file: w.seatFile }, 'library-credential-missing'],
|
||||
[{ kind: 'nobody', user: SEAT, file: w.seatFile }, 'library-credential-missing'],
|
||||
[{ ...w.seat, file: join(w.root, 'absent') }, 'library-credential-missing'],
|
||||
[{ kind: 'human', user: 'her-login', file: join(w.root, 'absent') }, 'library-human-missing'],
|
||||
];
|
||||
for (const [credential, code] of cases)
|
||||
await assert.rejects(push(w.context, { commit: next, credential, command }), { code }, JSON.stringify(credential));
|
||||
chmodSync(w.seatFile, 0o644);
|
||||
await assert.rejects(push(w.context, { commit: next, credential: w.seat, command }), { code: 'library-credential-missing' });
|
||||
chmodSync(w.seatFile, 0o600);
|
||||
await assert.rejects(push(w.context, { commit: 'main', credential: w.seat, command }), { code: 'library-remote-invalid' });
|
||||
assert.equal(called, 0);
|
||||
assert.equal(w.gitea.requests.length, 0);
|
||||
});
|
||||
|
||||
test('C2-03b T10 humanCredential: her login and a 0600 slot library-human, or library-human-missing', t => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'library-human-'));
|
||||
t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
||||
const context = { business: { id: 'acme' }, project: 'books' };
|
||||
const file = join(configDir, 'secrets', 'acme', HUMAN_SLOT);
|
||||
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'missing');
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(file, 'fixture-token-human-0046\n', { mode: 0o600 });
|
||||
assert.deepEqual(humanCredential(context, { user: 'her-login', configDir }), { kind: 'human', user: 'her-login', file });
|
||||
for (const user of [undefined, null, '', 'a b', 'her@x'])
|
||||
assert.throws(() => humanCredential(context, { user, configDir }), { code: 'library-human-missing' }, String(user));
|
||||
assert.throws(() => humanCredential(context, { user: 'her-login' }), { code: 'library-human-missing' });
|
||||
chmodSync(file, 0o644);
|
||||
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, '0644');
|
||||
chmodSync(file, 0o600);
|
||||
const real = join(configDir, 'real'); writeFileSync(real, 'x\n', { mode: 0o600 });
|
||||
rmSync(file); symlinkSync(real, file);
|
||||
assert.throws(() => humanCredential(context, { user: 'her-login', configDir }), { code: 'library-human-missing' }, 'a link');
|
||||
});
|
||||
|
||||
test('C2-03b fetch credential: the state names the kind and user, a refusal runs no git, and a pre-286 state still reads', async t => {
|
||||
const w = await world(t, { users: { [USER]: TOKEN, [SEAT]: SEAT_TOKEN } });
|
||||
const first = await w.run();
|
||||
assert.equal(first.code, null);
|
||||
assert.deepEqual(first.state.credential, { kind: 'slot', user: USER });
|
||||
assert.deepEqual((await w.state()).credential, { kind: 'slot', user: USER });
|
||||
// The chief's grant store file, with the chief's Gitea user.
|
||||
const chiefFile = join(w.root, 'store', 'acme', 'chief', 'gitea');
|
||||
mkdirSync(dirname(chiefFile), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(chiefFile, SEAT_TOKEN + '\n', { mode: 0o600 });
|
||||
w.gitea.requests.length = 0;
|
||||
const chief = await w.run({ slot: undefined, credential: { kind: 'chief', user: SEAT, file: chiefFile } });
|
||||
assert.equal(chief.code, null);
|
||||
assert.deepEqual(chief.state.credential, { kind: 'chief', user: SEAT });
|
||||
assert.ok(w.gitea.requests.some(r => r.user === SEAT) && w.gitea.requests.every(r => r.user !== USER));
|
||||
w.gitea.requests.length = 0;
|
||||
let called = 0;
|
||||
const refused = await sync(w.context, { credential: { refused: 'library-fetch-no-grant' }, slot: w.slot,
|
||||
command: (...a) => { called++; return w.command(...a); }, now: clock() });
|
||||
assert.equal(refused.code, 'library-fetch-no-grant');
|
||||
assert.equal(refused.state.lastError.code, 'library-fetch-no-grant');
|
||||
assert.equal(refused.state.credential, null);
|
||||
assert.equal(called, 0);
|
||||
assert.equal(w.gitea.requests.length, 0, 'a refusal opens no file and reaches no remote, even with a valid slot');
|
||||
// N1: a state file written before C2-03b has no credential key and still reads.
|
||||
const file = join(w.mirror(), SYNC_STATE_FILE);
|
||||
const { credential, ...old } = JSON.parse(readFileSync(file, 'utf8'));
|
||||
assert.equal(credential, null);
|
||||
writeFileSync(file, JSON.stringify(old) + '\n');
|
||||
assert.deepEqual(await w.state(), old);
|
||||
writeFileSync(file, JSON.stringify({ ...old, credential: { kind: 'slot', user: USER, file: w.slot } }) + '\n');
|
||||
await assert.rejects(w.state(), { code: 'library-state-invalid' }, 'the state never names a file');
|
||||
});
|
||||
|
||||
// T5 and T5b: the helper on a seat's file. `stat` is the one external command between the open and the
|
||||
// read, so a shim on the helper's PATH stands in for another uid and stages a swap.
|
||||
test('C2-03b T5 helper: the named seat\'s user and file, never a link, 0644, another uid, or a swapped file', t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'library-askpass-seat-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const file = join(dir, 'gitea'), other = join(dir, 'other');
|
||||
writeFileSync(file, SEAT_TOKEN + '\n', { mode: 0o600 });
|
||||
writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 });
|
||||
const bin = join(dir, 'bin'); mkdirSync(bin);
|
||||
const realStat = spawnSync('sh', ['-c', 'command -v stat'], { encoding: 'utf8' }).stdout.trim();
|
||||
const env = { PATH: '/usr/bin:/bin', MOSAIC_LIBRARY_HOST: '127.0.0.1:4430', MOSAIC_LIBRARY_USER: SEAT, MOSAIC_LIBRARY_SLOT: file };
|
||||
const prompt = `Password for 'https://${SEAT}@127.0.0.1:4430': `;
|
||||
const ask = (change = {}, p = prompt) => spawnSync(ASKPASS, [p], { env: { ...env, ...change }, encoding: 'utf8' });
|
||||
assert.equal(ask().stdout, SEAT_TOKEN + '\n');
|
||||
assert.equal(ask({}, `Username for 'https://127.0.0.1:4430': `).stdout, SEAT + '\n');
|
||||
const refused = (r, why) => { assert.equal(r.status, 1, why); assert.equal(r.stdout, '', why); };
|
||||
refused(ask({}, `Password for 'https://${USER}@127.0.0.1:4430': `), 'another seat\'s user');
|
||||
const link = join(dir, 'link'); symlinkSync(file, link);
|
||||
refused(ask({ MOSAIC_LIBRARY_SLOT: link }), 'a link');
|
||||
chmodSync(file, 0o644); refused(ask(), '0644');
|
||||
chmodSync(file, 0o640); refused(ask(), '0640');
|
||||
chmodSync(file, 0o600);
|
||||
writeFileSync(other, 'x'.repeat(16385), { mode: 0o600 });
|
||||
refused(ask({ MOSAIC_LIBRARY_SLOT: other }), 'over 16384 bytes');
|
||||
writeFileSync(other, 'fixture-token-other-0047\n', { mode: 0o600 });
|
||||
// Another uid: this suite can't chown, so the shim reports uid+1 for the open file.
|
||||
writeFileSync(join(bin, 'stat'), `#!/bin/sh\ncase $1 in -L) out=$(${realStat} "$@") || exit 1; set -- $out; printf '%s %s %s %s %s %s\\n' "$1" "$(($2 + 1))" "$3" "$4" "$5" "$6" ;; *) exec ${realStat} "$@" ;; esac\n`, { mode: 0o755 });
|
||||
refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'another uid');
|
||||
// T5b, the swap: on its first call the shim renames another file over the slot, then stats.
|
||||
const marker = join(dir, 'swapped');
|
||||
writeFileSync(join(bin, 'stat'), `#!/bin/sh\nif [ ! -e '${marker}' ]; then : > '${marker}'; mv -f '${other}' '${file}'; fi\nexec ${realStat} "$@"\n`, { mode: 0o755 });
|
||||
refused(ask({ PATH: `${bin}:/usr/bin:/bin` }), 'a file swapped between the check and the read');
|
||||
assert.ok(statSync(marker).isFile(), 'the swap ran');
|
||||
assert.equal(readFileSync(file, 'utf8'), 'fixture-token-other-0047\n', 'the slot path names the other file now');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Shared scratch bare-repository fixture for row 288 library and broker tests.
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repo, git, mappingEntry, BASE } from './helpers.mjs';
|
||||
import { sync, syncCommand } from '../src/sync.mjs';
|
||||
import { mirrorPath, readSyncState } from '../src/mirror.mjs';
|
||||
|
||||
export const PATH = 'Book One/Notes/Agent.md';
|
||||
export const IDENTITY = { name: 'Bean Fixture', email: '[email protected]' };
|
||||
export const EDITOR = { name: 'Human Fixture', email: '[email protected]' };
|
||||
|
||||
export async function world(t, { business = 'acme' } = {}) {
|
||||
const f = repo(t, { ...BASE, [PATH]: 'before\n',
|
||||
'Book One/Notes/Executable.md': { text: 'exec\n', mode: 0o755 },
|
||||
'Book One/Notes/Link.md': { link: 'Agent.md' } });
|
||||
git(f.repo, 'update-index', '--add', '--cacheinfo', '160000', f.commit, 'Book One/Notes/Submodule.md');
|
||||
git(f.repo, 'commit', '-qm', 'gitlink fixture');
|
||||
const remote = join(f.root, 'remote.git');
|
||||
git(f.root, 'clone', '-q', '--bare', '--no-local', f.repo, remote);
|
||||
const dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const entry = { ...mappingEntry(), user: 'human-fixture', editor: { ...EDITOR } };
|
||||
entry.universes.harbor.supporting = ['Book One/Notes/'];
|
||||
const context = { business: { id: business, projects: { books: entry } }, project: 'books', dataRoot };
|
||||
const file = join(f.root, 'fixture-slot'); writeFileSync(file, 'fixture-write-token-288\n', { mode: 0o600 });
|
||||
const credential = { kind: 'seat', user: 'bean-fixture', file };
|
||||
// Keep the production refspec exactly: a '+' mutant on push would overwrite the racing remote.
|
||||
let beforePush = null, reject = false;
|
||||
const commands = [];
|
||||
const command = (kind, url, opts) => {
|
||||
commands.push(kind);
|
||||
if (kind === 'push' && beforePush) { const fn = beforePush; beforePush = null; fn(); }
|
||||
const c = syncCommand(kind, url, opts);
|
||||
const argv = [...c.argv];
|
||||
argv.splice(0, 0, '-c', 'protocol.file.allow=always');
|
||||
const at = argv.indexOf(url);
|
||||
assert.ok(at >= 0);
|
||||
argv[at] = kind === 'push' && reject ? join(f.root, 'missing.git') : remote;
|
||||
return { argv, env: c.env };
|
||||
};
|
||||
await sync(context, { credential, command });
|
||||
const mirror = mirrorPath(context);
|
||||
const refs = () => git(mirror, 'for-each-ref', '--format=%(refname) %(objectname)');
|
||||
const remoteHead = () => git(remote, 'rev-parse', 'main');
|
||||
const state = () => readSyncState(mirror, { branch: 'main' });
|
||||
const external = (path, content) => {
|
||||
writeFileSync(join(f.repo, path), content); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'external');
|
||||
git(f.repo, 'push', '-q', remote, 'HEAD:main');
|
||||
};
|
||||
const args = (extra = {}) => ({ ...context, universe: 'harbor', path: PATH,
|
||||
base: git(mirror, 'rev-parse', `served:${PATH}`), content: 'after\n', ...extra });
|
||||
const opts = { credential, identity: IDENTITY, agent: `${business}/bean`, command };
|
||||
return { ...f, remote, context, entry, file, credential, command, mirror, refs, remoteHead, state, external, args, opts,
|
||||
race: fn => { beforePush = fn; }, reject: () => { reject = true; }, commands };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Row 288: real bare-repository plumbing and remote effects. All text and credentials are fixtures.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync, existsSync, renameSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { git } from './helpers.mjs';
|
||||
import { sync, push, withLibraryWriteGuard } from '../src/sync.mjs';
|
||||
import { servedCommit, withLibraryLock, readSyncState, SYNC_STATE_FILE } from '../src/mirror.mjs';
|
||||
import { publishLibraryWrite } from '../src/sync.mjs';
|
||||
import { checkMapping } from '../src/mapping.mjs';
|
||||
import { writeLibraryDoc, saveLibraryFile, revertLibraryCommit } from '../src/write.mjs';
|
||||
import { world, PATH, EDITOR } from './write-fixture.mjs';
|
||||
|
||||
test('C2-02: write/delete each publish one parent, seat ident, fixed trailer, index and lastPush', async t => {
|
||||
const f = await world(t), parent = f.remoteHead();
|
||||
const saved = await writeLibraryDoc(f.args(), f.opts);
|
||||
assert.deepEqual(saved, { project: 'books', path: PATH, commit: f.remoteHead() });
|
||||
assert.equal(git(f.remote, 'show', `${saved.commit}:${PATH}`), 'after');
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%P', saved.commit), parent);
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%an <%ae>|%cn <%ce>', saved.commit),
|
||||
'Bean Fixture <[email protected]>|Bean Fixture <[email protected]>');
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%B', saved.commit), `Mosaic: write ${PATH}\n\nMosaic-Agent: acme/bean`);
|
||||
assert.equal(await servedCommit(f.mirror), saved.commit);
|
||||
assert.equal((await f.state()).lastPush.commit, saved.commit);
|
||||
assert.equal(existsSync(join(f.mirror, 'index')), false);
|
||||
const gone = await writeLibraryDoc(f.args({ content: undefined }), { ...f.opts, op: 'delete' });
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%P', gone.commit), saved.commit);
|
||||
assert.equal(git(f.remote, 'ls-tree', '-r', gone.commit, '--', PATH), '');
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%B', gone.commit), `Mosaic: delete ${PATH}\n\nMosaic-Agent: acme/bean`);
|
||||
});
|
||||
|
||||
test('C2-02 M12: each path refusal leaves every ref and remote unchanged', async t => {
|
||||
const f = await world(t);
|
||||
const paths = ['Book One/Chapters/Chapter 01.md', 'Book One/Playground/What If.md', 'Inbox Note.md',
|
||||
'Book One/Notes/X.canvas', 'Book One/Notes/X.base', 'Book One/Notes/X.png', 'Second World/Chapters/01.md',
|
||||
'/Book One/Notes/X.md', 'Book One/Notes/../X.md', 'Book One/./Notes/X.md', 'Book One//Notes/X.md',
|
||||
'Book One/Notes/X\\Y.md', 'Book One/Notes/X\nY.md', 'Book One/Notes/Link.md',
|
||||
'Book One/Notes/agent.md', `Book One/Notes/${'é'.repeat(512)}.md`];
|
||||
for (const path of paths) {
|
||||
const before = f.refs(), head = f.remoteHead();
|
||||
await assert.rejects(writeLibraryDoc(f.args({ path, base: null }), f.opts), { code: 'library-write-story-path' }, path);
|
||||
assert.equal(f.refs(), before); assert.equal(f.remoteHead(), head);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02 Q4: delete requires an existing 100644 blob; null base never deletes a missing path', async t => {
|
||||
const f = await world(t);
|
||||
for (const path of ['Book One/Notes/Missing.md', 'Book One/Notes/Executable.md', 'Book One/Notes/Link.md', 'Book One/Notes/Submodule.md']) {
|
||||
const before = f.refs();
|
||||
await assert.rejects(writeLibraryDoc(f.args({ path, base: null, content: undefined }), { ...f.opts, op: 'delete' }),
|
||||
{ code: 'library-write-story-path' });
|
||||
assert.equal(f.refs(), before);
|
||||
}
|
||||
});
|
||||
|
||||
test('C2-02: stale base and failed push keep remote/served/tracking refs unchanged', async t => {
|
||||
const f = await world(t), refs = f.refs(), head = f.remoteHead();
|
||||
await assert.rejects(writeLibraryDoc(f.args({ base: 'a'.repeat(40) }), f.opts), { code: 'stale-base' });
|
||||
assert.equal(f.refs(), refs); assert.equal(f.remoteHead(), head);
|
||||
f.reject();
|
||||
await assert.rejects(writeLibraryDoc(f.args(), f.opts), { code: 'library-push-failed' });
|
||||
assert.equal(f.refs(), refs); assert.equal(f.remoteHead(), head);
|
||||
});
|
||||
|
||||
test('C2-02: a moved unrelated blob retries once on the new parent, changed target refuses', async t => {
|
||||
const f = await world(t);
|
||||
const args = f.args(); let raced;
|
||||
f.race(() => { f.external('Inbox Note.md', 'another\n'); raced = f.remoteHead(); });
|
||||
const saved = await writeLibraryDoc(args, f.opts);
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%P', saved.commit), raced);
|
||||
assert.equal(git(f.remote, 'show', `${saved.commit}:Inbox Note.md`), 'another');
|
||||
const g = await world(t), before = g.refs();
|
||||
g.race(() => g.external(PATH, 'raced target\n'));
|
||||
await assert.rejects(writeLibraryDoc(g.args(), g.opts), { code: 'stale-base' });
|
||||
assert.equal(g.refs(), before); assert.equal(git(g.remote, 'show', `main:${PATH}`), 'raced target');
|
||||
});
|
||||
|
||||
test('C2-02: create with null base; human revert restores parent and does not require seat credentials', async t => {
|
||||
const f = await world(t);
|
||||
const made = await writeLibraryDoc(f.args({ path: 'Book One/Notes/New.md', base: null, content: 'new\n' }), f.opts);
|
||||
const options = { credential: { ...f.credential, kind: 'human' }, command: f.command };
|
||||
const reverted = await revertLibraryCommit({ ...f.context, commit: made.commit }, options);
|
||||
assert.deepEqual(reverted, { project: 'books', path: 'Book One/Notes/New.md', reverted: made.commit, commit: f.remoteHead() });
|
||||
assert.equal(git(f.remote, 'ls-tree', '-r', reverted.commit, '--', reverted.path), '');
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%an <%ae>|%cn <%ce>', reverted.commit),
|
||||
'Human Fixture <[email protected]>|Human Fixture <[email protected]>');
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%(trailers:key=Mosaic-Agent,valueonly)', reverted.commit), '');
|
||||
});
|
||||
|
||||
test('C2-02: revert requires reachable business trailer and refuses a later path change', async t => {
|
||||
const f = await world(t), options = { credential: { ...f.credential, kind: 'human' }, command: f.command };
|
||||
await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, options), { code: 'library-revert-not-agent' });
|
||||
const saved = await writeLibraryDoc(f.args(), f.opts);
|
||||
await saveLibraryFile({ ...f.context, path: PATH, base: git(f.mirror, 'rev-parse', `served:${PATH}`), content: 'human later\n' }, options);
|
||||
await assert.rejects(revertLibraryCommit({ ...f.context, commit: saved.commit }, options), { code: 'stale-base' });
|
||||
});
|
||||
|
||||
test('C2-02: human save reaches story, preserves UTF-8 bytes and uses editor without a trailer', async t => {
|
||||
const f = await world(t), path = 'Book One/Chapters/Chapter 01.md';
|
||||
const content = '# Her text\n' + 'é'.repeat(40000) + '\n\n';
|
||||
const saved = await saveLibraryFile({ ...f.context, path, base: git(f.mirror, 'rev-parse', `served:${path}`), content },
|
||||
{ credential: { ...f.credential, kind: 'human' }, command: f.command });
|
||||
assert.equal(git(f.remote, 'show', '-s', '--format=%an <%ae>', saved.commit), 'Human Fixture <[email protected]>');
|
||||
// git helper trims; compare the stored blob byte count separately.
|
||||
assert.equal(Number(git(f.remote, 'cat-file', '-s', `${saved.commit}:${path}`)), Buffer.byteLength(content));
|
||||
});
|
||||
|
||||
test('C2-02: invalid content/editor/human credential refuse before any git command', async t => {
|
||||
const f = await world(t), before = f.refs();
|
||||
for (const content of ['nul\0', '\ud800', 'x'.repeat(1024 * 1024 + 1)])
|
||||
await assert.rejects(writeLibraryDoc(f.args({ content }), f.opts), { code: 'invalid-request' });
|
||||
const args = { ...f.context, path: PATH, base: null, content: 'x' };
|
||||
writeFileSync(join(f.mirror, 'unexpected'), 'any local git inspection must refuse\n');
|
||||
delete f.entry.editor;
|
||||
await assert.rejects(saveLibraryFile(args, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' });
|
||||
await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, { credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-mapping-invalid' });
|
||||
f.entry.editor = EDITOR;
|
||||
const n = f.commands.length;
|
||||
await assert.rejects(saveLibraryFile(args, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' });
|
||||
await assert.rejects(revertLibraryCommit({ ...f.context, commit: f.commit }, { credential: { kind: 'human', user: 'human', file: '/absent' }, command: f.command }), { code: 'library-human-missing' });
|
||||
assert.equal(f.commands.length, n); assert.equal(f.refs(), before);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 guard: only the exact owning token permits a push race fetch; sync refuses busy', async t => {
|
||||
const f = await world(t), commit = f.remoteHead();
|
||||
f.external('Inbox Note.md', 'remote moved\n');
|
||||
await withLibraryWriteGuard(f.mirror, async token => {
|
||||
await assert.rejects(sync(f.context, { credential: f.credential, command: f.command }), { code: 'library-sync-busy' });
|
||||
for (const writeToken of [null, {}, Object.freeze({})])
|
||||
await assert.rejects(push(f.context, { commit, credential: f.credential, command: f.command, writeToken }), { code: 'library-sync-busy' });
|
||||
const raced = await push(f.context, { commit, credential: f.credential, command: f.command, writeToken: token });
|
||||
assert.equal(raced.raced, true); assert.equal(raced.tip, f.remoteHead());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 publication F1: failed state rename restores served to match prior state', async t => {
|
||||
const f = await world(t), previous = await servedCommit(f.mirror);
|
||||
const commit = git(f.mirror, 'commit-tree', git(f.mirror, 'rev-parse', `${previous}^{tree}`), '-p', previous, '-m', 'candidate');
|
||||
const statePath = join(f.mirror, SYNC_STATE_FILE), backup = join(f.root, 'state-backup');
|
||||
await withLibraryLock(f.mirror, async lock => {
|
||||
// A filesystem failure after the locked state read, before state publication. The original
|
||||
// state is saved outside the mirror and restored after the failed rename.
|
||||
const read = async options => {
|
||||
const state = await lock.readSyncState(options);
|
||||
renameSync(statePath, backup); mkdirSync(statePath, { mode: 0o700 });
|
||||
return state;
|
||||
};
|
||||
try {
|
||||
await assert.rejects(publishLibraryWrite(f.mirror, { mapping: checkMapping(f.entry), commit, previous, readSyncState: read }),
|
||||
{ code: 'outcome-unknown' });
|
||||
assert.equal(git(f.mirror, 'rev-parse', 'served'), previous);
|
||||
} finally { rmSync(statePath, { recursive: true, force: true }); renameSync(backup, statePath); }
|
||||
});
|
||||
assert.equal((await readSyncState(f.mirror, { branch: 'main' })).served, previous);
|
||||
});
|
||||
|
||||
|
||||
test('C2-02 E1: a dotted business id never matches another business trailer', async t => {
|
||||
const f = await world(t, { business: 'acme.co' });
|
||||
writeFileSync(join(f.repo, PATH), 'foreign seat change\n');
|
||||
git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'Foreign\n\nMosaic-Agent: acme-co/bean');
|
||||
const foreign = git(f.repo, 'rev-parse', 'HEAD');
|
||||
git(f.repo, 'push', '-q', f.remote, 'HEAD:main');
|
||||
await sync(f.context, { credential: f.credential, command: f.command });
|
||||
const before = f.refs();
|
||||
await assert.rejects(revertLibraryCommit({ ...f.context, commit: foreign },
|
||||
{ credential: { ...f.credential, kind: 'human' }, command: f.command }), { code: 'library-revert-not-agent' });
|
||||
assert.equal(f.refs(), before); assert.equal(f.remoteHead(), foreign);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"prd": "docs/prd/mosaic-stack.md",
|
||||
"version": "1.15",
|
||||
"version": "1.17",
|
||||
"requirements": [
|
||||
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
|
||||
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
|
||||
@@ -77,13 +77,15 @@
|
||||
{ "id": "REQ-SESS-1", "title": "Each session shows its lifetime count of completed compactions on pi and Claude Code; an unproven count shows unknown" },
|
||||
{ "id": "REQ-SESS-2", "title": "Clear context retires a session after a safe stop and starts a fresh successor with none of the old context" },
|
||||
{ "id": "REQ-SESS-3", "title": "Every session start passes the launch checks: authority, route, revocation, kill switch, limits and admission" },
|
||||
{ "id": "REQ-LIB-1", "title": "A library maps the user's repository onto universes, books, types, playgrounds and supporting folders, set by a human" },
|
||||
{ "id": "REQ-LIB-2", "title": "Scope comes from the path, lookups default to canon, and a playground or supporting file is never cited as canon" },
|
||||
{ "id": "REQ-LIB-1", "title": "A human-set library maps the user's repository onto universes, books, types, playgrounds, supporting and archive folders" },
|
||||
{ "id": "REQ-LIB-2", "title": "Scope comes from the path or a DOCX's source, lookups default to canon, and only canon files are ever cited as canon" },
|
||||
{ "id": "REQ-LIB-3", "title": "Universes stay separate; a crossover is a hybrid universe only a human creates, reading its sources' canon" },
|
||||
{ "id": "REQ-LIB-4", "title": "Git holds every change; the bus fast-forwards a read-only mirror every 60 seconds and agents read only that" },
|
||||
{ "id": "REQ-LIB-4", "title": "Git holds every change. The bus fast-forwards a read-only mirror every 60 seconds, the one place agents read Markdown" },
|
||||
{ "id": "REQ-LIB-5", "title": "Agents check the library through typed lookup tools that name the commit and show links out and in" },
|
||||
{ "id": "REQ-LIB-6", "title": "Agents never write story, only docs in supporting folders a human marks. Chat is free text, and any citation is checked" },
|
||||
{ "id": "REQ-LIB-7", "title": "One chat room with several creative agents, addressed by leading @tags; an unknown tag sends nothing" },
|
||||
{ "id": "REQ-LIB-8", "title": "Case 2 story files become DOCX by a human-only one-way import or in the editor; Markdown stays for notes and docs" },
|
||||
{ "id": "REQ-LIB-9", "title": "The Console's DOCX editor: only a human saves, each save is one commit, and the editor code is vendored and pinned" },
|
||||
{ "id": "REQ-INST-1", "title": "The installer offers optional components: pinned, health-checked, removable, switched on in config; first is SearXNG" },
|
||||
{ "id": "REQ-INST-2", "title": "With no OIDC provider to configure, the installer deploys a bundled Pocket ID, Q203 (post-v1)" },
|
||||
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" }
|
||||
|
||||
@@ -18,10 +18,10 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
|
||||
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
|
||||
// Any other mention of a bold id would be a bullet this pattern missed.
|
||||
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
|
||||
assert.equal(ids.length, 85);
|
||||
assert.equal(ids.length, 87);
|
||||
assert.deepEqual(list.requirements.map((r) => r.id), ids);
|
||||
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.15']);
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.17']);
|
||||
});
|
||||
|
||||
test('the committed list loads frozen', () => {
|
||||
@@ -74,7 +74,7 @@ test('the schema refuses any other shape', () => {
|
||||
const twice = raw();
|
||||
twice.requirements.push(structuredClone(twice.requirements[0]));
|
||||
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
|
||||
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[85].id: REQ-ROLE-1 twice'));
|
||||
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[87].id: REQ-ROLE-1 twice'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -335,13 +335,28 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
same functions, so both show the same broker data.
|
||||
- Nothing writes but the "Message a role" box, the Settings update, the Assistant
|
||||
section's three buttons and the spec and docs editor's Save, all under `--send` (above,
|
||||
"Assistant settings" and "Spec and docs editor"). A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below.
|
||||
A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
with Copy, which only puts the command on the clipboard. It is answered in a
|
||||
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
|
||||
command has no `--business`, so `mosaic decide` uses the running bus host's
|
||||
business, as the Console does by default. From a Console started with
|
||||
`--business` for another business the copied command refuses (no bus host,
|
||||
or no such decision) rather than answering elsewhere.
|
||||
- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed
|
||||
to the human, raised with a grant and two options) can also be approved or
|
||||
denied on its decision page, only when the Console runs with both `--sso` and
|
||||
`--send` and the page is signed in as a `console.sso` subject. The first click
|
||||
POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only
|
||||
Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to
|
||||
the session, decision and choice). Each step spawns
|
||||
`packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's
|
||||
child holding the signed-in subject, and the bus records the answer `via
|
||||
console` with a `human.input` naming that subject. Nothing retries; an unknown
|
||||
outcome says to refresh. Without `--sso` the page has no buttons and says why;
|
||||
under `--sso` without `--send` it says the Console is read-only for decisions.
|
||||
The tester listener has no decision routes. Every other decision stays
|
||||
terminal-only.
|
||||
- What no Q1 verb returns is labelled where it would show, for example
|
||||
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
|
||||
- A task's list row can only say its current state came from a Vikunja read,
|
||||
|
||||
+33
-11
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
||||
import { readHostState } from '../../cli/src/host.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
|
||||
|
||||
export class BusReadError extends Error {
|
||||
constructor(code, message = code) { super(message); this.code = code; }
|
||||
@@ -27,10 +28,13 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
|
||||
// request on stdin, JSON on stdout, or a bus error code as the last
|
||||
// `^[a-z-]{1,64}$` line of stderr.
|
||||
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
|
||||
return (verb, args = {}) => new Promise((resolve, reject) => {
|
||||
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
|
||||
}
|
||||
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
|
||||
const out = [], err = [];
|
||||
let size = 0, settled = false;
|
||||
@@ -53,24 +57,42 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI
|
||||
catch { done(reject, new BusReadError('invalid-response')); }
|
||||
});
|
||||
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
|
||||
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
|
||||
child.stdin.end(`${JSON.stringify(input)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// Row 252 (#1753, design section 2): the Console owner's answer to a grant request. Each call spawns
|
||||
// a fresh console-cli with a fresh nonce, and the business and the signed-in (provider, iss, sub) go
|
||||
// in its spawn environment, where the bus reads them back. Inherited console keys are dropped first,
|
||||
// so only this call's values reach the wrapper. Only the two console verbs pass (N4). A resolve pins
|
||||
// the business its confirmation was issued for.
|
||||
export const CONSOLE_CLI = fileURLToPath(new URL('../../bus/src/console-cli.mjs', import.meta.url));
|
||||
export function consoleCall({ dataRoot, socket, business = null, env = process.env, cli = CONSOLE_CLI, timeoutMs = 30000 }) {
|
||||
return async (verb, args, { subject, business: pinned = null }) => {
|
||||
if (!CONSOLE_VERBS.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
const id = pinned ?? (business || hostBusiness(dataRoot));
|
||||
const own = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('MOSAIC_BUS_CONSOLE_')));
|
||||
return transport({
|
||||
cli, socket, timeoutMs, maxBytes: 1024 * 1024, input: { verb, args },
|
||||
env: { ...own, MOSAIC_BUS_CONSOLE_NONCE: randomBytes(32).toString('hex'), MOSAIC_BUS_CONSOLE_BUSINESS: id,
|
||||
MOSAIC_BUS_CONSOLE_PROVIDER: subject.provider, MOSAIC_BUS_CONSOLE_ISS: subject.iss, MOSAIC_BUS_CONSOLE_SUB: subject.sub },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// The business is the --business flag, or else the live bus host's, read
|
||||
// on every request so a host started after the Console is picked up.
|
||||
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
|
||||
return async (verb, args) => {
|
||||
let id = business;
|
||||
if (!id) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
id = state.business;
|
||||
}
|
||||
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
|
||||
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
|
||||
};
|
||||
}
|
||||
function hostBusiness(dataRoot) {
|
||||
let state;
|
||||
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
|
||||
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
|
||||
return state.business;
|
||||
}
|
||||
export const busReader = options => views({ call: busCaller(options) });
|
||||
|
||||
// FED R6 (#1800): a granted user's bus transport, R3's user-cli.mjs (#414, design 3.6). Same
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
|
||||
import { createChat } from './chat.mjs';
|
||||
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
||||
import { busCaller, busBusiness, userCaller } from './bus.mjs';
|
||||
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
@@ -68,14 +68,17 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
||||
let processing = null, ownerCall = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite } = consoleBus(call, { send: sendOn }));
|
||||
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, librarySave, 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 });
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -104,7 +107,7 @@ try {
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, 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, librarySave, 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) {
|
||||
@@ -128,7 +131,7 @@ try {
|
||||
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
||||
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
|
||||
@@ -30,10 +30,11 @@ export function consoleBus(call, { send: sendOn = false, owner = null } = {}) {
|
||||
projectFiles: args => call('project.files', args),
|
||||
projectFile: args => call('project.file', args),
|
||||
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
|
||||
send: null, librarySync: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
send: null, librarySync: null, librarySave: null, settingsWrite: null, projectSave: null, nameWrite: null, assistantStop: null, instancesWrite: null, routesWrite: null, agentWrite: null,
|
||||
};
|
||||
if (sendOn) {
|
||||
o.send = args => call('message.send', args); o.librarySync = args => call('library.sync', args); o.settingsWrite = args => call('limits.set', args);
|
||||
o.librarySave = args => call('library.file.save', 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant
|
||||
// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's
|
||||
// two answers and digest and returns a one-use confirmation bound to this session, business,
|
||||
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
|
||||
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
|
||||
// rechecks everything in the resolve transaction. Nothing here retries.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { BusReadError, subjectOk } from './bus.mjs';
|
||||
|
||||
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve)$/;
|
||||
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
|
||||
export const DECISION_REFUSALS = Object.freeze({
|
||||
'sign-in-required': [401, 'Sign in again before answering.'],
|
||||
'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'],
|
||||
'cross-origin': [403, 'Answer from the signed-in Console page.'],
|
||||
'not-found': [404, 'Not found.'],
|
||||
'human-required': [403, 'The bus refused this human identity.'],
|
||||
'peercred-required': [403, 'The bus could not verify this connection.'],
|
||||
'method-not-allowed': [405, 'Use POST.'],
|
||||
'invalid-request': [400, 'Invalid decision answer.'],
|
||||
'request-too-large': [413, 'Decision answer is too large.'],
|
||||
'decision-not-found': [404, 'This decision is unavailable.'],
|
||||
'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'],
|
||||
'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'],
|
||||
'decision-stale': [409, 'The choices changed. Review the decision again.'],
|
||||
'confirmation-required': [409, 'Review and confirm this answer again.'],
|
||||
'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'],
|
||||
'outcome-unknown': [503, 'The answer may have reached the bus. Refresh before trying again.'],
|
||||
});
|
||||
// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any
|
||||
// other code, or no answer, is confirmation-unavailable at the preview and outcome-unknown at the
|
||||
// resolve, which the page reads as "refresh first".
|
||||
const BUS_CODES = {
|
||||
'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required',
|
||||
'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required',
|
||||
'peercred-required': 'peercred-required',
|
||||
'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible',
|
||||
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
|
||||
'invalid-request': 'invalid-request',
|
||||
};
|
||||
const BODY_MAX = 4096;
|
||||
const TOKEN = /^[a-f0-9]{64}$/;
|
||||
const DIGEST = /^[a-f0-9]{64}$/;
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
||||
export function decisionRefuse(res, code) {
|
||||
const [status, message] = DECISION_REFUSALS[code];
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify({ error: code, message }));
|
||||
}
|
||||
|
||||
// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024
|
||||
// live. A full store refuses a new one rather than drop a live one. An expired one is swept.
|
||||
export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) {
|
||||
const live = new Map();
|
||||
const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); };
|
||||
return {
|
||||
issue(binding) {
|
||||
sweep();
|
||||
if (live.size >= max) return null;
|
||||
const token = randomBytes(32).toString('hex');
|
||||
live.set(hash(token), { ...binding, expires: now() + ttlMs });
|
||||
return token;
|
||||
},
|
||||
// One use: a token found is gone, whether or not it matches.
|
||||
consume(token, { session, decision, choice }) {
|
||||
sweep();
|
||||
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
|
||||
const k = hash(token), v = live.get(k);
|
||||
if (!v) return null;
|
||||
live.delete(k);
|
||||
return v.session === session && v.decision === decision && v.choice === choice ? v : null;
|
||||
},
|
||||
get size() { sweep(); return live.size; },
|
||||
};
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' };
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > BODY_MAX) return { code: 'request-too-large' };
|
||||
chunks.push(chunk);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
|
||||
} catch { return { code: 'invalid-request' }; }
|
||||
}
|
||||
const preview = (v, id) =>
|
||||
v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') &&
|
||||
['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') &&
|
||||
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string';
|
||||
|
||||
// `session` is the live sign-in session object this request passed, and `live()` reads it again
|
||||
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
|
||||
export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) {
|
||||
const [, raw, step] = DECISION_ROUTE.exec(path);
|
||||
let id = null;
|
||||
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
|
||||
if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed');
|
||||
if (!decide) return decisionRefuse(res, 'read-only');
|
||||
if (!session) return decisionRefuse(res, 'sign-in-required');
|
||||
if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin');
|
||||
const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`);
|
||||
const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); };
|
||||
const { value, code } = await readBody(req);
|
||||
if (code) return refuse(code);
|
||||
if (!subjectOk(id)) return refuse('decision-not-found');
|
||||
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
|
||||
const busCode = (err, open) => {
|
||||
const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined;
|
||||
return c ?? open;
|
||||
};
|
||||
if (step === 'confirmation') {
|
||||
if (Object.keys(value).length !== 1 || typeof value.choice !== 'string') return refuse('invalid-request');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
let v;
|
||||
try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
|
||||
if (!preview(v, id)) return refuse('confirmation-unavailable');
|
||||
const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null;
|
||||
if (!action) return refuse('decision-stale');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest });
|
||||
if (!confirmation) return refuse('confirmation-unavailable');
|
||||
line(`action=${action}`);
|
||||
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation });
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
|
||||
// Consumed before any await on the bus, so a second click finds nothing (J12).
|
||||
const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice });
|
||||
if (!held) return refuse('confirmation-required');
|
||||
if (live() !== session) return refuse('sign-in-required');
|
||||
let r;
|
||||
try {
|
||||
r = await decide.call('decision.resolve.console', { id, choice: held.choice, digest: held.digest }, { subject, business: held.business });
|
||||
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
|
||||
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
|
||||
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
|
||||
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}) });
|
||||
}
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
@@ -190,8 +190,9 @@
|
||||
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
|
||||
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
|
||||
} else if (r.table === 'decision_events') {
|
||||
// The human answers from the CLI (via cli); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
|
||||
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
|
||||
// signed-in Console (via console); every other row is the broker's.
|
||||
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : r.via === 'console' ? 'human-console' : 'broker';
|
||||
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
|
||||
} else if (r.table === 'task_snapshots') {
|
||||
out.kind = `snapshot.${r.source}`;
|
||||
@@ -227,8 +228,9 @@
|
||||
// An empty state says what would appear and where it comes from.
|
||||
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
|
||||
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
|
||||
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
|
||||
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
|
||||
// or from the broker itself.
|
||||
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : by === 'human-console' ? 'human, signed-in Console' : 'broker'}</span>`;
|
||||
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
|
||||
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
|
||||
let readAt = null;
|
||||
@@ -256,7 +258,7 @@
|
||||
// Inbox
|
||||
function decisionCard(d) {
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
@@ -314,12 +316,101 @@
|
||||
el.outerHTML = composerHtml();
|
||||
restore(k);
|
||||
}
|
||||
// Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when
|
||||
// the server says this Console answers decisions (`decide` in /api/config: signed in through SSO
|
||||
// and started with --send) and the bus marked the inbox row eligible (its `console` field). The
|
||||
// first click asks the Console for a one-use confirmation that names what the answer does. Only
|
||||
// the second click answers. Nothing here retries: a refusal drops the confirmation, and an
|
||||
// unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s
|
||||
// refresh redraws it. The server and the bus check everything again; this is presentation only.
|
||||
let canDecide = false, ssoOn = false, answer = null;
|
||||
const ACTIONS = { approve: 'Approve', deny: 'Deny' };
|
||||
const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · ');
|
||||
function decideHtml() {
|
||||
const a = answer;
|
||||
if (!a) return '';
|
||||
const off = a.pending ? ' disabled' : '';
|
||||
let body;
|
||||
if (a.result) {
|
||||
const r = a.result;
|
||||
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.</p>`;
|
||||
} else if (a.confirm) {
|
||||
const c = a.confirm, label = `Confirm ${c.action}`;
|
||||
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
|
||||
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd><dt>Grant</dt><dd>${grantText(c.grant)}</dd></dl>
|
||||
<p class="small muted">${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
|
||||
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
|
||||
} else {
|
||||
body = `<ul class="s1-plain">${Object.entries(ACTIONS).map(([k, l]) => `<li><b>${l}</b> chooses <b class="s1-key">${txt(a.console[k].key)}</b> ${txt(a.console[k].text)}</li>`).join('')}</ul>
|
||||
<p class="small muted">Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.</p>
|
||||
<div class="s1-cmd">${Object.entries(ACTIONS).map(([k, l]) => `<button type="button" class="btn${k === 'approve' ? ' primary' : ''}" data-decide="${k}"${off}>${a.pending === k ? 'Checking…' : l}</button>`).join('')}</div>`;
|
||||
}
|
||||
const e = a.error;
|
||||
const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : '';
|
||||
return `<section class="panel s1-decide" id="decide-section" aria-labelledby="b-decide"><h2 id="b-decide">Answer in Console</h2>${body}${said}</section>`;
|
||||
}
|
||||
// Redraw only the answer panel. `focus` names what takes focus after it.
|
||||
function paintDecide(focus) {
|
||||
const el = $('decide-section');
|
||||
if (!el) return;
|
||||
el.outerHTML = decideHtml();
|
||||
(focus ? $('decide-section')?.querySelector(focus) : null)?.focus();
|
||||
}
|
||||
async function decidePost(url, body) {
|
||||
let res = null, json = null;
|
||||
try {
|
||||
res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
|
||||
try { json = await res.json(); } catch { json = null; }
|
||||
} catch { res = null; }
|
||||
return { res, json };
|
||||
}
|
||||
// A refusal the Console wrote carries its code and message (design section 4). Anything else, a
|
||||
// 408 or no answer included, gets the fallback: at the resolve step that is outcome-unknown.
|
||||
const DECIDE_FALLBACK = {
|
||||
'confirmation-unavailable': 'Confirmation is unavailable. Try again later.',
|
||||
'outcome-unknown': 'The answer may have reached the bus. Refresh before trying again.',
|
||||
};
|
||||
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
|
||||
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
|
||||
async function decide(step) {
|
||||
const a = answer;
|
||||
if (!a || a.pending || a.result) return;
|
||||
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; }
|
||||
if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; }
|
||||
if (step === 'approve' || step === 'deny') {
|
||||
const choice = a.console[step].key;
|
||||
a.pending = step; a.error = null; a.confirm = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice });
|
||||
a.pending = null;
|
||||
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') {
|
||||
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`);
|
||||
} else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
return;
|
||||
}
|
||||
if (step !== 'confirm' || !a.confirm) return;
|
||||
const c = a.confirm;
|
||||
a.pending = 'confirm'; a.error = null; paintDecide();
|
||||
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
|
||||
// One use, whatever came back.
|
||||
a.pending = null; a.confirm = null;
|
||||
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
|
||||
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
|
||||
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
|
||||
a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
|
||||
paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
|
||||
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
|
||||
}
|
||||
async function inboxView(get) {
|
||||
// The broker orders gated first, then oldest; lift blocking ones to the top.
|
||||
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
|
||||
countInbox(rows);
|
||||
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
|
||||
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
|
||||
const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with <code>mosaic decide</code>.'
|
||||
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
|
||||
return `${head('Inbox', `${readNote()} ${answered}`)}
|
||||
${await composer(get)}
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
@@ -343,6 +434,13 @@
|
||||
// A command is offered only for an open decision routed to the human, and only when
|
||||
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
|
||||
const answerable = !c && d.routeTo === 'human';
|
||||
// Row 252: the answer panel follows this decision. A refresh keeps a confirmation open; a
|
||||
// decision no longer eligible (closed elsewhere, say) drops it, unless an answer is in flight
|
||||
// or this page just answered it.
|
||||
const eligible = canDecide && answerable && open?.console ? open.console : null;
|
||||
if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
|
||||
else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible;
|
||||
else if (!eligible && !answer.pending && !answer.result) answer = null;
|
||||
const options = `<ol class="s1-opts">${d.options.map(o => {
|
||||
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
|
||||
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
|
||||
@@ -352,11 +450,18 @@
|
||||
}).join('')}</ol>`;
|
||||
const earlier = await chain(get, d);
|
||||
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
|
||||
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
|
||||
// Without SSO, or under SSO without --send, the Console says why it has no buttons and never
|
||||
// draws a disabled one (design section 5).
|
||||
const why = !answerable || isTester || canDecide ? ''
|
||||
: ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.'
|
||||
: ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).';
|
||||
const help = eligible ? '<p class="small muted">Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
|
||||
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
|
||||
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
|
||||
return `${head(`Decision ${txt(short(d.id))}`, status)}
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
|
||||
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
${answer?.id === d.id ? decideHtml() : ''}
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
|
||||
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
|
||||
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
|
||||
@@ -1098,6 +1203,7 @@
|
||||
return {
|
||||
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
|
||||
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
|
||||
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
|
||||
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
|
||||
};
|
||||
}
|
||||
@@ -1105,7 +1211,10 @@
|
||||
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
|
||||
if ($('copy-status')) $('copy-status').textContent = k.status;
|
||||
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry'));
|
||||
if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
// Row 252: the answer panel's buttons and its status line.
|
||||
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
|
||||
if (dec) dec.focus({ preventScroll: true });
|
||||
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
|
||||
el.focus({ preventScroll: true });
|
||||
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
|
||||
}
|
||||
@@ -1197,6 +1306,9 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Row 252: Approve, Deny, Confirm and Cancel. One click is one step; none repeats itself.
|
||||
const dec = e.target.closest('#decide-section [data-decide]');
|
||||
if (dec) { if (!dec.disabled) decide(dec.dataset.decide); return; }
|
||||
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
|
||||
const item = e.target.closest('#cmdk-list [data-href]');
|
||||
if (item) { pkGo(item.dataset.href); return; }
|
||||
@@ -1318,12 +1430,14 @@
|
||||
isTester = c?.tester === true;
|
||||
canEdit = !isTester && c?.edit === true;
|
||||
canSend = !isTester && c?.send === true;
|
||||
ssoOn = c?.sso === true;
|
||||
canDecide = !isTester && c?.decide === true;
|
||||
if (isTester) {
|
||||
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
|
||||
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
|
||||
$('main').prepend(banner);
|
||||
}
|
||||
if (!canSend && !isTester) return;
|
||||
if (!canSend && !isTester && !ssoOn) return;
|
||||
const path = routeOf().split('?')[0];
|
||||
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ReadError, readStatus, fieldTable } from './reads.mjs';
|
||||
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
|
||||
import { chatHTTP } from './chat-http.mjs';
|
||||
import { processingHTTP } from './processing-http.mjs';
|
||||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||||
|
||||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||||
export function isLoopback(host) {
|
||||
@@ -600,7 +601,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, 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');
|
||||
@@ -611,6 +612,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
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.
|
||||
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
|
||||
const confirmations = createConfirmations();
|
||||
const upstream = boardURL(board);
|
||||
// `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.
|
||||
@@ -669,18 +673,23 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview;
|
||||
const decisionPath = DECISION_ROUTE.test(path);
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||||
return decisionPath ? decisionRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview, ssoSession = null;
|
||||
if (sso !== null && named) {
|
||||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||||
if (!session && path !== '/healthz') {
|
||||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
// Row 252: only the owner's session answers a decision. A granted user stops at userRoute below.
|
||||
if (session && session.user === undefined) ssoSession = session;
|
||||
// FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport
|
||||
// object is left, so a route past the gate reaches the broker only as this user.
|
||||
if (session?.user !== undefined) {
|
||||
@@ -696,6 +705,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
||||
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
|
||||
// session on the public name under --sso and --send, they are read-only.
|
||||
if (decisionPath) {
|
||||
if (tester) return decisionRefuse(res, 'not-found');
|
||||
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null;
|
||||
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
|
||||
}
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
@@ -820,7 +836,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(granted ? { user: true } : {}), ...(hub ? { federation: hub } : {}), send: !tester && Boolean(send) && named, decide: !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// Row 252 (#1753, design section 5, J16 and J17): Approve and Deny on a decision page, in a browser.
|
||||
// A loopback page has no sign-in, so a script added before the page loads sets `sso` and `decide`
|
||||
// in /api/config from the page URL, and answers each POST /api/decisions/... from a queue, recording
|
||||
// what the page sent. Server-side checks are decision-http.test.mjs's and the bus side is
|
||||
// packages/bus/tests/console-decide.test.mjs's. The decisions come from an in-process broker
|
||||
// through its reader session. Refresh timers fire after 150 ms.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { grantDecision } from '../../bus/tests/host-fixtures.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2">';
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
|
||||
// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect`
|
||||
// what fetch gives for the sign-in proxy's redirect under redirect: 'manual'.
|
||||
const STUB = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__posts = []; window.__answers = [];
|
||||
const timeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
|
||||
const q = new URLSearchParams(location.search);
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const u = String(url);
|
||||
if (u === '/api/config') {
|
||||
const c = await (await real(url, init)).json();
|
||||
return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) });
|
||||
}
|
||||
if (!u.startsWith('/api/decisions/')) return real(url, init);
|
||||
window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect });
|
||||
const a = window.__answers.shift();
|
||||
if (!a) throw new TypeError('no answer queued');
|
||||
if (a.hold) await new Promise(r => { window.__release = r; });
|
||||
if (a.reject) throw new TypeError('Failed to fetch');
|
||||
if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } };
|
||||
return answer(a.status, a.body);
|
||||
};
|
||||
})();`;
|
||||
|
||||
// The open grant request, as host-fixtures' grantDecision writes it but with the hostile question.
|
||||
// decisions is append-only (migration 3b), so the question goes in with the row, not by an UPDATE.
|
||||
function hostileGrant(store, grant) {
|
||||
const id = randomUUID(), at = '2026-10-21T00:00:00.000Z';
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, at, 'demo', 'chief', 'chief-run', 'gated', 'credential.mint', 'human', `Enroll web1? ${HOSTILE}`,
|
||||
JSON.stringify([{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]), 'yes', 0,
|
||||
);
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), at, 'demo', 'chief', 'chief-run', id,
|
||||
JSON.stringify({ decision: id, action: 'credential.mint', operation: 'decision.raise', approvalChoice: 'yes', grant }),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-'));
|
||||
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
|
||||
const open = hostileGrant(store, grant);
|
||||
const deploy = grantDecision(store, { action: 'deploy', grant, close: null });
|
||||
const bare = grantDecision(store, { close: null });
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser();
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
|
||||
const posts = () => b.evaluate('window.__posts');
|
||||
const queue = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
|
||||
const click = step => b.evaluate(`document.querySelector('#decide-section [data-decide="${step}"]').click(), true`);
|
||||
const page = async (query, id) => {
|
||||
// The same URL again is a same-document hash change, not a load, so load about:blank first (as editor-browser does).
|
||||
await b.navigate('about:blank');
|
||||
await b.navigate(`${base}?${query}#/inbox/${id}`);
|
||||
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && !document.querySelector("#bus-view [aria-busy]")');
|
||||
// The config read redraws once it lands; a refresh tick after it settles the page.
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
};
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done };
|
||||
}
|
||||
|
||||
const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } });
|
||||
|
||||
test('J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
try {
|
||||
// Without SSO: no panel, no button, and the reason names both flags.
|
||||
await s.page('x', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
assert.match(await s.text('#bus-view'), /needs sign-in \(--sso\) and writes enabled \(--send\)/);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
|
||||
// SSO without --send: read-only, still the terminal lines.
|
||||
await s.page('sso', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.match(await s.text('#bus-view'), /read-only for decisions: it was started without writes enabled \(--send\)/);
|
||||
// send alone, without decide, still draws nothing.
|
||||
await s.page('sso&send', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
// A tester Console never answers, whatever the config says.
|
||||
await s.page('sso&decide&tester', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
// Eligible: the panel, both buttons, the escaped question and the terminal lines too.
|
||||
await s.page('sso&decide', s.open);
|
||||
assert.equal(await s.text('#decide-section h2'), 'Answer in Console');
|
||||
assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => [e.dataset.decide, e.textContent, e.disabled])'), [['approve', 'Approve', false], ['deny', 'Deny', false]]);
|
||||
assert.match(await s.text('#decide-section'), /Grant: method host-enroll · host web1\./);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
|
||||
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
|
||||
assert.ok((await s.text('#bus-view .s1-q')).includes(HOSTILE));
|
||||
// A grant raise with another action, and a credential.mint with no grant, keep the terminal only.
|
||||
for (const id of [s.deploy, s.bare]) {
|
||||
await s.page('sso&decide', id);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
}
|
||||
// The Inbox says so, and marks only the eligible row.
|
||||
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
|
||||
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3');
|
||||
await s.settle();
|
||||
assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./);
|
||||
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1);
|
||||
assert.deepEqual(await s.posts(), []);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
|
||||
test('J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
const enc = encodeURIComponent;
|
||||
try {
|
||||
await s.page('sso&decide', s.open);
|
||||
|
||||
// Approve asks for a confirmation and answers nothing. The button holds while the request is out.
|
||||
await s.queue({ ...CONFIRM(s, 'approve', 'yes', 'Yes'), hold: true });
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=approve]")?.textContent === "Checking…"');
|
||||
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].every(e => e.disabled)'), true);
|
||||
await s.click('approve');
|
||||
await s.b.evaluate('window.__release(), true');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
let sent = await s.posts();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.deepEqual(sent[0], { url: `/api/decisions/${enc(s.open)}/confirmation`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }), cache: 'no-store', redirect: 'manual' });
|
||||
assert.equal(await s.text('#decide-section [data-decide=confirm]'), 'Confirm approve');
|
||||
assert.match(await s.text('#decide-section .s1-confirm'), /Approving starts this grant\./);
|
||||
assert.equal(await s.b.evaluate('document.activeElement?.dataset.decide ?? null'), 'confirm');
|
||||
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
|
||||
|
||||
// A refresh keeps the confirmation open.
|
||||
await s.settle();
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), true);
|
||||
|
||||
// Cancel drops it without a request.
|
||||
await s.click('cancel');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=approve]")');
|
||||
assert.equal((await s.posts()).length, 1);
|
||||
|
||||
// A refusal the Console wrote shows its code and message; a 408 or a lost answer is unavailable.
|
||||
await s.queue({ status: 409, body: { error: 'decision-stale', message: 'The choices changed. Review the decision again.' } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status")');
|
||||
assert.equal(await s.text('#decide-status'), 'Not answered here: The choices changed. Review the decision again. (decision-stale).');
|
||||
await s.queue({ status: 408, body: { error: 'x', message: 'y' } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable"');
|
||||
await s.queue({ reject: true });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
|
||||
assert.equal(await s.b.evaluate('document.querySelector("#decide-status code")?.textContent'), 'confirmation-unavailable');
|
||||
assert.equal((await s.posts()).length, 4);
|
||||
|
||||
// A reply that doesn't match what was asked is not a confirmation.
|
||||
await s.queue({ status: 200, body: { ...CONFIRM(s, 'approve', 'yes', 'Yes').body } });
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable" && document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
|
||||
|
||||
// An unknown outcome at the answer says to refresh, drops the confirmation and sends nothing again.
|
||||
await s.queue(CONFIRM(s, 'deny', 'no', 'No'));
|
||||
await s.click('deny');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
assert.match(await s.text('#decide-section .s1-confirm'), /Denying starts nothing\./);
|
||||
await s.queue({ status: 503, body: { error: 'outcome-unknown', message: 'The answer may have reached the bus. Refresh before trying again.' } });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
|
||||
assert.match(await s.text('#decide-status'), /Nothing was sent again\.$/);
|
||||
sent = await s.posts();
|
||||
assert.equal(sent.length, 7);
|
||||
assert.deepEqual(sent[6], { url: `/api/decisions/${enc(s.open)}/resolve`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'no', confirmation: 'c'.repeat(64) }), cache: 'no-store', redirect: 'manual' });
|
||||
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
|
||||
await s.settle();
|
||||
await s.settle();
|
||||
assert.equal((await s.posts()).length, 7, 'the page sent again by itself');
|
||||
|
||||
// A confirmed approve: one answer, a receipt, and no buttons after it, across refreshes.
|
||||
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
await s.queue({ status: 200, body: { id: s.open, op: 'resolved', choice: 'yes', grant: 'started' } });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Approved")');
|
||||
assert.equal(await s.text('#decide-status'), 'Approved: you chose “yes”, Yes. The grant executor started. History below shows it once the bus is read again.');
|
||||
await s.settle();
|
||||
assert.equal(await s.has('[data-decide]'), false);
|
||||
assert.equal((await s.posts()).length, 9);
|
||||
|
||||
// Closed elsewhere: the next read drops the panel and shows the recorded answer with its provenance.
|
||||
s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.open, '2026-10-22T00:00:00.000Z', 'resolved', 'jason', 'yes', 'console');
|
||||
await s.page('sso&decide', s.open);
|
||||
assert.equal(await s.has('#decide-section'), false);
|
||||
assert.match(await s.text('#bus-view'), /Resolved: jason chose “yes” via console/);
|
||||
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
|
||||
test('a sign-in redirect at either step signs the page out and answers nothing', { timeout: 120000 }, async () => {
|
||||
const s = await setup();
|
||||
try {
|
||||
await s.page('sso&decide', s.open);
|
||||
await s.queue({ redirect: true });
|
||||
await s.click('approve');
|
||||
await s.wait('document.body.classList.contains("signed-out")');
|
||||
assert.equal((await s.posts()).length, 1);
|
||||
await s.page('sso&decide', s.open);
|
||||
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
|
||||
await s.click('approve');
|
||||
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
|
||||
await s.queue({ redirect: true });
|
||||
await s.click('confirm');
|
||||
await s.wait('document.body.classList.contains("signed-out")');
|
||||
assert.equal((await s.posts()).length, 2);
|
||||
} finally { await s.done(); }
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed
|
||||
// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub,
|
||||
// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the
|
||||
// endpoint's own table and console-decide.test.mjs the bus. The public name is console.example.test,
|
||||
// sent only as a Host header to the test's own listener. sso.test.mjs pins its own log lines, so the
|
||||
// decide lines are checked here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { DECISION_REFUSALS } from '../src/decision-http.mjs';
|
||||
import { consoleCall } from '../src/bus.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { userOptionsFor } from '../src/console-bus.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
const DIGEST = 'b'.repeat(64);
|
||||
const PREVIEW = { id: 'd1', business: 'demo', question: 'Enroll web1?', digest: DIGEST, approve: { key: 'yes', text: 'Yes' }, deny: { key: 'no', text: 'No' }, grant: { method: 'host-enroll', host: 'web1' } };
|
||||
|
||||
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
|
||||
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
||||
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
||||
let text = '';
|
||||
res.on('data', c => (text += c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
|
||||
// The fake OP, createSso over it, and the human listener. `server` overrides startServer options.
|
||||
// The decision transport records each call and answers a preview or a resolve. `access`, when given,
|
||||
// is the access document: createSso gets the users hook and the listener a user transport, as
|
||||
// granted-user.test.mjs sets them up.
|
||||
async function setup(t, server = {}, access = null) {
|
||||
const op = await fakeOidc();
|
||||
t.after(() => op.close());
|
||||
const iss = op.issuer('a');
|
||||
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
|
||||
const lines = [];
|
||||
const users = access && { users: consoleAccess({ load: () => access, business: () => 'demo' }) };
|
||||
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l), ...users });
|
||||
t.after(() => sso.close());
|
||||
const calls = [];
|
||||
const decisionWrite = async (verb, args, ctx) => {
|
||||
calls.push({ verb, args, ctx });
|
||||
return verb === 'decision.preview.console' ? { ...PREVIEW } : { id: args.id, op: 'resolved', choice: args.choice };
|
||||
};
|
||||
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
|
||||
const userOptions = access && { userOptions: userOptionsFor({ owner: async () => ({}), send: true, transport: () => async () => [] }) };
|
||||
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...userOptions, ...server });
|
||||
t.after(() => close(web));
|
||||
const port = web.address().port;
|
||||
const signIn = async (sub = undefined) => {
|
||||
const start = await call(port, { path: '/auth/login' });
|
||||
assert.equal(start.status, 303);
|
||||
const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0];
|
||||
const g = op.grant(iss, start.headers.location, { sub });
|
||||
const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') });
|
||||
const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } });
|
||||
return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0];
|
||||
};
|
||||
const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) });
|
||||
return { port, iss, calls, lines, signIn, post };
|
||||
}
|
||||
const refused = (r, code) => {
|
||||
assert.equal(r.status, DECISION_REFUSALS[code][0], `${code}: ${r.status} ${JSON.stringify(r.body)}`);
|
||||
assert.deepEqual(r.body, { error: code, message: DECISION_REFUSALS[code][1] });
|
||||
};
|
||||
|
||||
test('a signed-in owner confirms and answers, with the session\'s provider, issuer and sub as the subject', async t => {
|
||||
const env = await setup(t);
|
||||
const session = await env.signIn();
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.decide, true);
|
||||
assert.equal(config.body.sso, true);
|
||||
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
|
||||
assert.equal(c.status, 200, JSON.stringify(c.body));
|
||||
const r = await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation });
|
||||
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||
assert.deepEqual(r.body, { id: 'd1', op: 'resolved', choice: 'yes' });
|
||||
const subject = { provider: 'a', iss: env.iss.issuer, sub: 'user-a' };
|
||||
assert.deepEqual(env.calls, [
|
||||
{ verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject } },
|
||||
{ verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject, business: 'demo' } },
|
||||
]);
|
||||
assert.match(env.lines.at(-1), / decide user="person-a" decision="d1" step=resolve choice="yes"$/);
|
||||
assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false);
|
||||
});
|
||||
|
||||
test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => {
|
||||
const env = await setup(t);
|
||||
refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required');
|
||||
const session = await env.signIn();
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { origin: 'https://evil.test' }), 'cross-origin');
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { 'sec-fetch-site': 'cross-site' }), 'cross-origin');
|
||||
const loop = await call(env.port, { method: 'POST', path: '/api/decisions/d1/confirmation', host: `127.0.0.1:${env.port}`, headers: { cookie: session, 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
|
||||
assert.equal(loop.status, 403);
|
||||
refused(await call(env.port, { path: '/api/decisions/d1/confirmation', headers: { cookie: session } }), 'method-not-allowed');
|
||||
assert.equal(env.calls.length, 0);
|
||||
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
|
||||
assert.equal(c.status, 200);
|
||||
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
|
||||
assert.equal(out.status, 303);
|
||||
refused(await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'sign-in-required');
|
||||
assert.equal(env.calls.length, 1);
|
||||
// A new session can't use the old session's confirmation.
|
||||
const again = await env.signIn();
|
||||
refused(await env.post(again, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'confirmation-required');
|
||||
assert.equal(env.calls.length, 1);
|
||||
});
|
||||
|
||||
test('J16: without a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send', async t => {
|
||||
// A decision transport without --send never starts (serve.mjs refuses it). The configs that start
|
||||
// without send have no transport either, so they are checked with the missing transport.
|
||||
await assert.rejects(setup(t, { send: null }), /need --sso and --send/);
|
||||
for (const server of [{ decisionWrite: null }, { decisionWrite: null, send: null }]) {
|
||||
const env = await setup(t, server);
|
||||
const session = await env.signIn();
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
||||
assert.equal(config.body.decide, false);
|
||||
assert.equal(config.body.sso, true);
|
||||
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'read-only');
|
||||
assert.equal(env.calls.length, 0);
|
||||
}
|
||||
const write = async () => ({});
|
||||
await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/);
|
||||
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/);
|
||||
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
||||
t.after(() => close(plain));
|
||||
const port = plain.address().port, host = `127.0.0.1:${port}`;
|
||||
const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
|
||||
refused(r, 'read-only');
|
||||
const config = await call(port, { path: '/api/config', host });
|
||||
assert.equal(config.body.decide, false);
|
||||
assert.equal('sso' in config.body, false);
|
||||
});
|
||||
|
||||
test('J19: a granted user\'s session gets decide false and the user page config, and a decision POST stops at owner-only', async t => {
|
||||
const access = {
|
||||
accessVersion: 1, revision: 1,
|
||||
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'a', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }],
|
||||
};
|
||||
const env = await setup(t, {}, access);
|
||||
// The listener has a decision transport and --send, and the owner's session decides.
|
||||
const owner = await env.signIn();
|
||||
assert.equal((await call(env.port, { path: '/api/config', headers: { cookie: owner } })).body.decide, true);
|
||||
// The granted user's config says send (its own transport) but never decide.
|
||||
const alice = await env.signIn('alice-sub');
|
||||
const config = await call(env.port, { path: '/api/config', headers: { cookie: alice } });
|
||||
assert.equal(config.status, 200);
|
||||
assert.equal(config.body.user, true);
|
||||
assert.equal(config.body.send, true);
|
||||
assert.equal(config.body.decide, false);
|
||||
// userRoute refuses the decision routes before decisionHTTP, so no session reaches the transport.
|
||||
for (const step of ['confirmation', 'resolve']) {
|
||||
const r = await env.post(alice, `/api/decisions/d1/${step}`, { choice: 'yes', confirmation: 'c'.repeat(64) });
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error, 'owner-only');
|
||||
}
|
||||
assert.equal(env.calls.length, 0);
|
||||
});
|
||||
|
||||
test('N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs', async t => {
|
||||
const env = await setup(t);
|
||||
const session = await env.signIn();
|
||||
for (const verb of ['decision.resolve', 'decision.resolve.console', 'decision.preview.console']) {
|
||||
for (const method of ['GET', 'POST']) {
|
||||
const r = await call(env.port, { method, path: `/api/bus/${verb}`, headers: writeHeaders(session), ...(method === 'POST' ? { body: '{}' } : {}) });
|
||||
// A GET finds no such bus read. A POST stops earlier, at the preview's read-only refusal for
|
||||
// every write that has no route of its own (row 74).
|
||||
if (method === 'GET') assert.equal(r.status, 404, `${method} ${verb}`);
|
||||
else assert.deepEqual([r.status, r.body.error], [403, 'read-only'], `${method} ${verb}`);
|
||||
}
|
||||
}
|
||||
assert.equal(env.calls.length, 0);
|
||||
const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(cli, /['"]decision\./);
|
||||
const c = consoleCall({ dataRoot: '/nonexistent', socket: '/nonexistent', business: 'demo', cli: '/nonexistent/console-cli.mjs', timeoutMs: 1000 });
|
||||
for (const verb of ['decision.resolve', 'decision.seen', 'message.send', 'inbox']) {
|
||||
await assert.rejects(c(verb, { id: 'd1', choice: 'yes' }, { subject: { provider: 'a', iss: 'i', sub: 's' } }), /the Console does not call/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
|
||||
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
|
||||
// in the browser tests.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
|
||||
|
||||
const ORIGIN = 'https://console.test';
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
const PREVIEW = {
|
||||
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
|
||||
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
|
||||
grant: { method: 'host-enroll', host: 'h1' },
|
||||
};
|
||||
|
||||
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
|
||||
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
|
||||
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
||||
const data = Buffer.from(raw ?? JSON.stringify(body));
|
||||
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
|
||||
}
|
||||
function response() {
|
||||
const r = { status: null, headers: null, text: '' };
|
||||
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
|
||||
r.end = (t = '') => { r.text += t; };
|
||||
r.json = () => JSON.parse(r.text);
|
||||
return r;
|
||||
}
|
||||
function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
|
||||
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
|
||||
const calls = [], lines = [];
|
||||
const s = {
|
||||
session, calls, lines, current: session,
|
||||
confirmations: createConfirmations(now ? { now } : {}),
|
||||
decide: {
|
||||
origin: ORIGIN,
|
||||
call: async (verb, args, ctx) => {
|
||||
calls.push({ verb, args, ctx });
|
||||
return verb === 'decision.preview.console' ? answer(args) : resolve(args);
|
||||
},
|
||||
},
|
||||
};
|
||||
s.run = async (path, req) => {
|
||||
const res = response();
|
||||
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
|
||||
return res;
|
||||
};
|
||||
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
|
||||
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
|
||||
return s;
|
||||
}
|
||||
const refused = (res, code) => {
|
||||
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
|
||||
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
|
||||
assert.equal(res.headers['cache-control'], 'no-store');
|
||||
};
|
||||
|
||||
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
|
||||
const s = setup();
|
||||
const c = await s.confirm('yes');
|
||||
assert.equal(c.status, 200);
|
||||
const body = c.json();
|
||||
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
|
||||
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
|
||||
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
|
||||
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
|
||||
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
|
||||
// J12: the confirmation is spent, so a repeat never reaches the bus.
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
|
||||
assert.equal(s.calls.length, 2);
|
||||
assert.equal(s.lines.length, 3);
|
||||
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
|
||||
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
|
||||
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
|
||||
});
|
||||
|
||||
test('deny resolves without a grant field', async () => {
|
||||
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
|
||||
const c = (await s.confirm('no')).json();
|
||||
assert.equal(c.action, 'deny');
|
||||
assert.equal(c.text, 'No');
|
||||
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
|
||||
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
|
||||
});
|
||||
|
||||
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
|
||||
const s = setup();
|
||||
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
|
||||
const res = response();
|
||||
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
|
||||
refused(res, 'read-only');
|
||||
const out = response();
|
||||
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
|
||||
refused(out, 'sign-in-required');
|
||||
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
|
||||
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a bad body, id or content type refuses before the bus', async () => {
|
||||
const s = setup();
|
||||
const path = '/api/decisions/d1/confirmation';
|
||||
refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
|
||||
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
|
||||
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
||||
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
|
||||
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
|
||||
refused(await setup().confirm('maybe'), 'decision-stale');
|
||||
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
|
||||
const s = setup({ answer: () => bad });
|
||||
refused(await s.confirm('yes'), 'confirmation-unavailable');
|
||||
assert.equal(s.confirmations.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
|
||||
const cases = [
|
||||
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
|
||||
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
|
||||
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
|
||||
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
|
||||
];
|
||||
for (const [bus, code] of cases) {
|
||||
const s = setup({ answer: () => { throw new BusReadError(bus); } });
|
||||
refused(await s.confirm('yes'), code);
|
||||
}
|
||||
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
|
||||
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
|
||||
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
|
||||
const c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
|
||||
// Nothing retries, and the confirmation stays spent.
|
||||
assert.equal(s.calls.length, 2);
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
}
|
||||
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
|
||||
const s = setup({ resolve: () => reply });
|
||||
const c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
|
||||
}
|
||||
});
|
||||
|
||||
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
|
||||
const s = setup();
|
||||
let c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
c = (await s.confirm('yes')).json();
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
|
||||
c = (await s.confirm('yes')).json();
|
||||
const other = setup();
|
||||
other.confirmations = s.confirmations;
|
||||
other.session = { ...s.session };
|
||||
other.current = other.session;
|
||||
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
|
||||
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
|
||||
assert.equal(other.calls.length, 0);
|
||||
});
|
||||
|
||||
test('a session that ends between the steps refuses before the bus', async () => {
|
||||
const s = setup();
|
||||
s.current = null;
|
||||
refused(await s.confirm('yes'), 'sign-in-required');
|
||||
assert.equal(s.calls.length, 0);
|
||||
s.current = s.session;
|
||||
const c = (await s.confirm('yes')).json();
|
||||
s.current = { ...s.session };
|
||||
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
|
||||
assert.equal(s.calls.length, 1);
|
||||
// A sign-out during the preview also refuses, and no confirmation is issued.
|
||||
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
|
||||
refused(await t.confirm('yes'), 'sign-in-required');
|
||||
assert.equal(t.confirmations.size, 0);
|
||||
});
|
||||
|
||||
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
|
||||
let t = 0;
|
||||
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
|
||||
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
|
||||
const a = store.issue(bind), b = store.issue(bind);
|
||||
assert.equal(store.issue(bind), null);
|
||||
assert.equal(store.size, 2);
|
||||
assert.equal(store.consume(a, bind).digest, DIGEST);
|
||||
assert.equal(store.consume(a, bind), null);
|
||||
t = 1000;
|
||||
assert.equal(store.consume(b, bind), null);
|
||||
assert.equal(store.size, 0);
|
||||
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
|
||||
assert.equal(store.consume(undefined, bind), null);
|
||||
assert.equal(store.consume('A'.repeat(64), bind), null);
|
||||
});
|
||||
@@ -14,7 +14,7 @@ test('C2-04 CLI: both listener callback pairs dispatch library verbs with their
|
||||
// The loader supplies a fixture bus and captures callbacks instead of
|
||||
// opening an HTTP listener. Production imports and routes are unchanged.
|
||||
const serveSource = "export const DEFAULT_BOARD='http://127.0.0.1:9';export const previewOptions=()=>null;export const proofOptions=()=>null;export async function startServer(o){if(typeof o.libraryFile!=='function'||typeof o.libraryStatus!=='function')throw Error('missing library callbacks');await o.libraryFile({project:'books',path:'Book One/A.md'});await o.libraryStatus({project:'books'});return {address:()=>({port:0}),close:()=>{}};}";
|
||||
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};`;
|
||||
const busSource = `import {appendFileSync} from 'node:fs';export class BusReadError extends Error{};export const busCaller=o=>async(verb,args)=>{appendFileSync(${JSON.stringify(log)},JSON.stringify({verb,args,cli:o.cli??${JSON.stringify(HUMAN_CLI)}})+'\\n');return {};};export const busBusiness=()=>{throw Error('busBusiness outside --sso')};export const userCaller=()=>{throw Error('userCaller outside --sso')};export const consoleCall=()=>{throw Error('consoleCall outside --sso')};`;
|
||||
writeFileSync(hook, `import {registerHooks} from 'node:module';
|
||||
registerHooks({load(url,context,next){
|
||||
if(url.endsWith('/webui/src/serve.mjs'))return {format:'module',shortCircuit:true,source:${JSON.stringify(serveSource)}};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Row 288: the Console's save option carries only the caller's args over its own transport.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { consoleBus, userOptionsFor } from '../src/console-bus.mjs';
|
||||
|
||||
test('C2-02: library save option is absent without --send and forwards no editor or identity', async () => {
|
||||
const calls = [], call = async (verb, args) => { calls.push({ verb, args }); return { commit: 'a'.repeat(40) }; };
|
||||
assert.equal(consoleBus(call).librarySave, null);
|
||||
const args = { project: 'books', path: 'Book One/Chapters/01.md', base: 'b'.repeat(40), content: 'her text\n' };
|
||||
assert.equal((await consoleBus(call, { send: true }).librarySave(args)).commit, 'a'.repeat(40));
|
||||
assert.deepEqual(calls, [{ verb: 'library.file.save', args }]);
|
||||
assert.deepEqual(Object.keys(calls[0].args).sort(), ['base', 'content', 'path', 'project']);
|
||||
});
|
||||
|
||||
test('C2-02: user save option uses that user transport; owner transport is never called', async () => {
|
||||
const calls = [];
|
||||
const options = userOptionsFor({ owner: () => { throw Error('owner transport touched'); }, send: true,
|
||||
transport: user => async (verb, args) => { calls.push({ user, verb, args }); } });
|
||||
const args = { project: 'books', path: 'Book One/Chapters/01.md', base: null, content: 'fixture' };
|
||||
await options({ user: 'reader' }).librarySave(args);
|
||||
assert.deepEqual(calls, [{ user: 'reader', verb: 'library.file.save', args }]);
|
||||
});
|
||||
@@ -50,14 +50,18 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
|
||||
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
|
||||
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
|
||||
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes:
|
||||
// Row 252 (#1753, mos-1321): the one decision control is the Console's grant Approve and Deny, one
|
||||
// fetch(url, ...) in decidePost, called only for a decision's confirmation and its resolve. Its labels
|
||||
// come from ACTIONS, and the doesNotMatch below keeps any literal Resolve, Decide or Approve label out
|
||||
// of the markup. The board's Seen and reply are app.js's POSTs; bus.js's other explicit writes are
|
||||
// row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path,
|
||||
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
|
||||
// instance add or change to /api/instances, and row 65 the routing table's save to /api/routes.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7);
|
||||
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 8);
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ['url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
|
||||
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
|
||||
// Row 176 (#1674): the tester name form adds a second /api/name caller.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);
|
||||
|
||||
@@ -183,3 +183,14 @@ test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and wr
|
||||
assert.equal((await http(web, '/api/chat/threads', { headers: signed })).status, 200);
|
||||
assert.deepEqual(calls, ['threads']);
|
||||
});
|
||||
|
||||
test('Row 252: the tester listener has no decision answer routes, and its config never offers them', async t => {
|
||||
const s = await testerFixture(t);
|
||||
for (const step of ['confirmation', 'resolve']) {
|
||||
const r = await post(s.web, `/api/decisions/d1/${step}`, step === 'confirmation' ? { choice: 'yes' } : { choice: 'yes', confirmation: 'a'.repeat(64) });
|
||||
assert.equal(r.status, 404, r.text);
|
||||
assert.equal(r.body.error, 'not-found');
|
||||
}
|
||||
assert.equal((await http(s.web, '/api/config')).body.decide, false);
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user