Compare commits
50
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 | ||
|
|
573fa0be06 | ||
|
|
4d6c8234fa | ||
|
|
a1876928f4 | ||
|
|
ae48662afa | ||
|
|
44edead6d8 | ||
|
|
b0d21c41a9 | ||
|
|
61ef385e4a | ||
|
|
d46b6d6ef6 | ||
|
|
edec640d29 | ||
|
|
dd3e3c0963 | ||
|
|
b3cf04b371 | ||
|
|
4faa3f6e9a | ||
|
|
4f66dccdec | ||
|
|
398314cf4d | ||
|
|
23816cf285 | ||
|
|
38e322ea01 | ||
|
|
4f3fbc25a0 |
No files matched your search
@@ -5411,3 +5411,7 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r
|
||||
2026-10-23T17:29:46Z | Gyro Vikunja571 sage-3542 Connections amendment | Branch gyro/editor-connections-design cut exactly from landed PRD1.17 30e6bb680; read REQ-LIB-5 in that PRD. Replaced stale J-b waits in parent and W4. Proposed W8 owns current authorized Markdown/DOCX links/backlinks list, labels, versioned refresh, leakage refusals and fixture tests. Build order under mos-1470/comment961 is W7 closed routing candidate, W8 landing, then W7 human first-real-import opening. No REQ-LIB-8 condition or acceptance change. Q234/mos-1363/S0 M1-M14 and post-10-27/MVP priority fixed. Static diff, twelve brief sections and append-only checks pass. No repo Node, tests, build, browser, queue mutation or live action. Fenton reviews after authorized docs branch push; Sage lands on approval.
|
||||
|
||||
2026-10-23T17:32:37Z | Gyro sage-3544 fenton-0016 N1/N2 delta | Before: W7 Gate/final real-conversion list omit the W8 build-order cross-reference and W8 human-view filtering cites agent settings. After: both locations explicitly put W8 landing before human opening as mos-1470 build order, not acceptance; comment/tracked-deletion links are not current story links and never appear in the human Connections view regardless of agent settings. New commit atop approved 5af060fb5, no amend. Static diff and append-only checks pass. Docs only, no repo Node/tests/live action. Fenton checks only this delta.
|
||||
|
||||
## 2026-10-23 — 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.
|
||||
@@ -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.
|
||||
@@ -1593,3 +1593,4 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-23T17:48:43Z | Scrooge (629b6cd3) | Row 252 Console inbox Approve and Deny (#1753) | Rebased onto 306 landing as 580037105 (range-diff all =), slot run bus 907/0 webui 410/0 cli 943/0, in-review revs 3608/3609, Webby r1 APPROVE, J1-J19 specs to Sage; Sage landed 45ea3fa0d (done rev 3616). Part 1 mutant results were never in BUILD.md; they went to Sage by T3 (scrooge-0252-mutant-specs).
|
||||
2026-10-23 | Sage (1ef1e4f8) | Rows 308 and 252 landings, 571 Connections design amendment, Vikunja 597 | 308 (PRD 1.17, #1815) landed 30e6bb680 and 252 (#1753) landed 45ea3fa0d (replay of 580037105 onto 30e6bb680, range-diff all =), each after a full gate on the landed tree (252 also J1-J19, J11c and J18 equivalent); Gyro's 571 amendment (Fenton APPROVE) landed e8bb13df8; Vikunja 570, 435 and 597 done, canonical webui esbuild 0.28.2; queue revs 3589-3616 committed 59f013247
|
||||
2026-10-23 | Sage (1ef1e4f8) | Vikunja 571 W build rows; rows 13, 42, 257 and 289 check | Filed the design's ten W rows as queue rows 309-318 (revs 3617-3626, owners set to unassigned 3627-3636, queue commit 293b42fee pushed); Vikunja 571 done (comment 994). Row 13's candidate fd72d2689 is on refactor, gate is Monday's row 7 ledger run; 42 paused under mos-1184; 257 post-v1; 289 waits on 214 and 288. Slot released 17:56Z.
|
||||
2026-10-23 | 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.
|
||||
+166
-11
@@ -100,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']);
|
||||
@@ -263,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');
|
||||
@@ -1955,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)) {
|
||||
@@ -5131,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
|
||||
|
||||
@@ -17,7 +17,7 @@ export class Client {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
@@ -223,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
|
||||
@@ -301,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: {
|
||||
@@ -565,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,
|
||||
|
||||
@@ -6,7 +6,9 @@ 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
|
||||
@@ -60,7 +62,7 @@ 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) ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -28,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/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -68,14 +68,14 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, 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 });
|
||||
@@ -107,7 +107,7 @@ try {
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite, userOptions, federation: system?.federation ?? null });
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, 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) {
|
||||
|
||||
@@ -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,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 }]);
|
||||
});
|
||||
Reference in new issue
Block a user