Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8461266cd | ||
|
|
8ae9d8acd9 | ||
|
|
9aa6d52d94 |
@@ -1,53 +0,0 @@
|
||||
# Row 212 build notes, C2-01 packages/library (#1713, Vikunja #416)
|
||||
|
||||
## Rounds
|
||||
|
||||
- Round 1: bdf18272a on gosalyn/c2-01, base 32b54ac3b. Webby approved it in Gitea comment 29704.
|
||||
- Round 2: d3c7bbf839d9c3bf50f154b18e7e4472fbd005ed on gosalyn/c2-01-r2, rebased onto 9b5362018 after row 232 drifted three paths (sage-1714). Webby approved it at merge scope, rev 2666 against comment 29921 (webby-0052).
|
||||
- Round 3: this commit on gosalyn/c2-01-r3, one commit on the row 234 landing c771a36f4 (sage-1736, sage-1779, mos-1160). Row 234 changed scripts/mosaic, so round 3 review covers that one file.
|
||||
|
||||
## Round 2 merge
|
||||
|
||||
range-diff 32b54ac3b..bdf18272a against 9b5362018..d3c7bbf83 shows one change, the context line where upstream added `credentials` to the scripts/mosaic case line. Patch-id moved from 8f80dfa3 to 175f45c6 for that reason only. All three drifted hunks keep both sides:
|
||||
|
||||
- scripts/stack-copy.sh COPY_PATHS reads `packages/queue/src packages/library/src`. Row 232's bundle, --create, --units and instance code is unchanged.
|
||||
- scripts/mosaic runs the `library survey` exec block before the case line, and the case line keeps `credentials`.
|
||||
- packages/cli/tests/stack-copy.test.mjs adds `packages/library/src/index.mjs` to the copy file list.
|
||||
|
||||
## Round 2 singles, evidence for the 19 paths other than scripts/mosaic
|
||||
|
||||
Run on d3c7bbf83 in Sage's slot (sage-1723) from 2026-10-21T05:00:09Z to 05:11:29Z. The HEAD and a clean tree were checked first. The counts are an awk sum over the 23 logs: 481 passed, 0 failed, 0 skipped, 0 cancelled, and every file exited rc=0.
|
||||
|
||||
| Package | File | Pass |
|
||||
|---|---|---|
|
||||
| library | links | 6 |
|
||||
| library | mapping | 7 |
|
||||
| library | reader | 11 |
|
||||
| library | render | 11 |
|
||||
| library | survey | 4 |
|
||||
| cli | app | 30 |
|
||||
| cli | case2-unit | 30 |
|
||||
| cli | credentials | 5 |
|
||||
| cli | host | 42 |
|
||||
| cli | instances | 5 |
|
||||
| cli | project | 4 |
|
||||
| cli | stack-copy | 16 |
|
||||
| cli | stack-instances | 23 |
|
||||
| cli | stack-release-external | 25 |
|
||||
| cli | stack-release | 192 |
|
||||
| cli | tester-reads | 6 |
|
||||
| cli | tester-threads | 5 |
|
||||
| cli | usage | 2 |
|
||||
| queue | dispatch | 4 |
|
||||
| runs | task-cli | 9 |
|
||||
| webui | pinned-copy | 10 |
|
||||
| webui | service | 31 |
|
||||
| webui | views | 3 |
|
||||
|
||||
The file list comes from the library tests plus every test that names scripts/mosaic or stack-copy.sh, by basename and by join() path. No 64-hex pin in the tests names a merged path.
|
||||
|
||||
## Round 3 scripts/mosaic
|
||||
|
||||
The only path both row 234 and this row change is scripts/mosaic. Row 234 added `hosts` to the cli case line and changed nothing else in the file. Round 3 takes the landed blob cce1fdee4 and adds this row's six-line `library survey` block before the case line by hand, so `hosts` stays. The mode stays 100755. The other 19 paths are byte-identical to d3c7bbf83. Against d3c7bbf83 the only difference is the `hosts` context line.
|
||||
|
||||
Round 3 runs only the tests that read scripts/mosaic, in Sage's slot. Their counts go to Sage in T3 and onto #1713, since this file can't hold them and stay one commit. Those tests are library survey, cli app, case2-unit, credentials, hosts, host, instances, project, stack-copy, stack-release, stack-release-external, stack-upgrade, tester-reads, tester-threads and usage, queue dispatch, runs task-cli, webui pinned-copy, service and views, plus scripts/test-queue.sh. hosts.test.mjs is new with row 234.
|
||||
@@ -0,0 +1,294 @@
|
||||
# Row 197 execution ledger
|
||||
|
||||
Plan: MANIFEST.md r2, approved by Webby ca6b32d66.
|
||||
Build grant: sage-1390. Suites, image builds and paired proof are not granted.
|
||||
Rebased five documentation commits onto origin/refactor aa1bd209e before code.
|
||||
Existing worktree herb/row197 and plain node_modules symlink retained.
|
||||
|
||||
- T1 preflight/steps: in progress.
|
||||
- T2 locks/status/recovery: pending.
|
||||
- T3 image/Docker: pending.
|
||||
- T4 fixtures/timing: pending.
|
||||
- T5 evidence/acceptance: historical timing done; implementation pending.
|
||||
|
||||
Preflight interfaces: T1 RunPaths supplies T2 ownership and T3 image checks.
|
||||
T2 run receipt supplies T4 lane/port state and T5 attribution. T3 image identity
|
||||
is checked before Docker-bearing steps. T4 timings supply T5 pair summaries.
|
||||
No interface conflict found. No product API or assertion changes are authorized.
|
||||
|
||||
Ruling: use this approved BUILD.md as the execution ledger, rather than adding
|
||||
skill workspace paths outside the manifest. This preserves the reviewed path
|
||||
boundary. Missing ledger detail would cost recovery time, not product behavior.
|
||||
|
||||
## Implementation checkpoint 2026-10-20T20:11:58Z
|
||||
|
||||
T1-T5 source is present within the approved manifest. Full acceptance is pending.
|
||||
Fresh single-file checks: preflight/supervisor 6/6; state/recovery 11/11; image and
|
||||
Docker guard 4/4; port leases/attribution/proof summary 3/3. No skips or failures.
|
||||
Existing files checked singly: board serve; WebUI serve, preview, proof-cli,
|
||||
tester-cli and service; harness pi and Claude. Stack-release R1 selected single
|
||||
passed 1/1. WebUI service also passed 31/31 with lane-a leases and a 30-byte root.
|
||||
All existing assert lines are unchanged. Shell syntax and diff checks pass.
|
||||
|
||||
Observed defects fixed during the single-file cycles: cancellation incorrectly
|
||||
returned zero when a TERM handler exited zero; recovery did not supersede an
|
||||
unclean terminal; status omitted the lock-before-receipt window. Evidence logs
|
||||
retain the failing and passing observations. Source still has no commit because
|
||||
applicable suites have not run. No image build, proof, push or live operation.
|
||||
|
||||
Ruling: use an instrumented single-lane author gate on a frozen dirty-source
|
||||
clone before committing source. The new wrapper requires a clean candidate and
|
||||
proven image receipt, while repository rules require green suites before source
|
||||
commits. The author gate retains every suite and assertion, caps concurrency at
|
||||
two, uses own port/state/temp/project roots, and guards Docker against builds.
|
||||
Cost if wrong: the author gate fails; clean-candidate wrapper baselines and the
|
||||
paired proof remain mandatory after source is verified and committed.
|
||||
|
||||
Ruling: publish the initial run directory atomically after the start receipt is
|
||||
complete. This avoids a second lane observing a partially written start. A
|
||||
crash before publication has started no test or mutable Docker operation.
|
||||
Cost if wrong: a lane refuses or quarantines; no protected state is bypassed.
|
||||
|
||||
## Implementation review corrections, 2026-10-20T20:33Z
|
||||
|
||||
Webby-0029 reviewed the first implementation packet at f942bd7d4.
|
||||
B1 identified missing manifest tests. B2 identified a test that leased both real
|
||||
bands through a private lock namespace. The old author-gate clear was withdrawn
|
||||
in herb-0019. SOURCE.json, BUILD.patch and gate.sh still describe the old frozen
|
||||
clone. They are not a clear for the edited working tree. No author gate ran.
|
||||
|
||||
B2 correction: the helper test uses only the active lane and MOSAIC_GATE_STATE.
|
||||
It compares the other band arithmetically and has no unasserted listener. The
|
||||
occupied-port probe first obtains its own real lease, binds that port, and
|
||||
asserts that its sentinel listener survives refusal. No test reserves another
|
||||
lane's band. Exhaustion narrows only the test process's band to one owned port.
|
||||
|
||||
B1 coverage added within the existing approved paths. No case is dropped:
|
||||
|
||||
| Manifest case | Added check |
|
||||
| --- | --- |
|
||||
| T1 canonical and builder checkout, existing output | gate-lane.test: direct validateRun refusals |
|
||||
| T1 92-byte socket and over-107-byte socket | exact design example with a distinct gt- test root; 108-byte refusal |
|
||||
| T1 both candidates carry tooling | validateTooling compares each launcher file, including missing and changed cases |
|
||||
| T1 duplicates and NODE_TEST_CONTEXT | repeated package/script fixture is retained; sanitized environment is exercised by both supervised children |
|
||||
| T2 one-winner race and inherited FDs | two actual shell wrappers race; a controlled supervisor verifies held FDs, and its child checks that no FD points to either lock file |
|
||||
| T2 same checkout and legacy claim refusal | activeConflicts uses synthetic process observations with a live lane and a sibling landing gate |
|
||||
| T2 surviving child and interrupted recovery | locked recovery kills a child proven by the private root, preserves a foreign child, is killed during fake Docker cleanup, leaves quarantine, and resumes under a new fixture grant |
|
||||
| T3 missing receipt and missing image | verifyImage refuses absent provenance and failing inspection |
|
||||
| T3 tag changed mid-gate and at gate end | runGate now uses the same executePinnedSteps boundary exercised by the tests; the next step never runs after a changed ID |
|
||||
| T3 mutable-tag rebuild | fake preparation returns a new image ID with identical input digest; the old receipt refuses it |
|
||||
| T3 cold and warm counters | preparation records one build and its time; warm execution records zero builds; repeated verification never builds |
|
||||
| T4 parallel test-file leases | two generated Node test files hold ports in the same lane/state; locks are checked during child execution and shutdown |
|
||||
| T4 exhausted band, occupied port and foreign listener | a one-port process-local band refuses both a held lease and an existing sentinel listener; the listener survives |
|
||||
| T4 owner death | a killed allocator releases only its own holder; a foreign lease remains held |
|
||||
| T4 lease through child shutdown | parent probes the lock while the fixture test runs and after its child has stopped |
|
||||
| T4 fixtureCacheEnv | replacement child environment writes only under separate tmp, jiti and cache directories |
|
||||
| T5 unrelated session append | a separate process waits for a running-step barrier, appends under a fixture HOME, and remains unrelated in the resulting metadata attribution |
|
||||
|
||||
N1 removes the unused closed binding. All leases reserve an unbound endpoint;
|
||||
existing closed options need no call-site change. N2 makes child TMPDIR a tmp
|
||||
subdirectory. N3 normalizes ss interface scopes before checking port 9. N4 uses
|
||||
gt- for unit run roots. N5 documents the canonical node_modules dependency.
|
||||
N6 is recorded: row 200 also edits stack-release.test.mjs; the second landing
|
||||
must rebase and rerun that file singly. Sage was told in herb-0019.
|
||||
|
||||
Before Sage's hold, expanded preflight tests passed 11/11, state tests 14/14,
|
||||
image tests 7/7, and the pi/Claude singles passed 8/8 each with separate tmp
|
||||
subdirectories. Later source/test edits still need verification. In particular,
|
||||
the new parallel test-file probe had a cleanup-order defect and is unverified.
|
||||
Its root cleanup ran before child shutdown. Cleanup now stops children before
|
||||
removing their root. Output is retained for a useful failure report. The initial
|
||||
FD probe also assumed Node did not reopen FD numbers; it now checks the actual
|
||||
lock targets. The early passing fixture file covered child allocators, before
|
||||
they were changed to actual generated test files. It is not acceptance of the
|
||||
latest file.
|
||||
|
||||
Sage-1448 stopped all node --test runs at 20:30Z for Rocko's browser slot.
|
||||
The remaining owned process tree was stopped at 20:30:32Z. Inspection found no
|
||||
remaining owned test processes afterward. Edits and static inspection continue.
|
||||
20 module syntax checks, shell syntax and git diff checks pass. Existing-file
|
||||
assertion lines remain identical to HEAD. No test is run during the hold.
|
||||
|
||||
Next: after Sage passes Rocko's clear, verify the corrected files singly,
|
||||
including a lane-env helper-file check. Then refresh the frozen clone and all
|
||||
three packet artifacts, send their hashes, and request a new author-gate clear.
|
||||
Suites, image builds and paired proofs still need their own grants. No source
|
||||
commit, push, suite, real Docker build or paired proof has run in this phase.
|
||||
|
||||
## Refrozen review packet, 2026-10-20T20:41Z
|
||||
|
||||
Sage-1454 lifted the single-file hold. Fresh runs passed without failures or
|
||||
skips: review-t1-resume.log 11/11, review-t2-resume.log 14/14,
|
||||
review-t3-resume.log 8/8, review-t4-resume.log 8/8. The helper file also passed
|
||||
8/8 with lane-a environment in review-t4-lane-a.log. Its retained state had only
|
||||
an a port-lock namespace, no b directory, and every lock was released. Generated
|
||||
fixture Node test entries explicitly use --test-concurrency=2. The child-test
|
||||
probe now finishes and cleans up before removing its root. All 41 new tests pass.
|
||||
|
||||
B1 and B2 fixes are ready for Webby's review. No manifest case was dropped.
|
||||
All 25 source paths were copied to a new frozen clone and compared by mode and
|
||||
SHA-256. This supersedes the earlier withdrawn source packet:
|
||||
|
||||
- SOURCE.json: 91ba149f2fc039a8bed0e6d1ad5da44b80fb8cbec84d0fa4e38616040138596b
|
||||
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
|
||||
- BUILD.patch: 0b3871bd9848ca223c7cda50266debc04643e5d986eb7f0fece096aaf25ff43e
|
||||
- Frozen checkout: /mnt/storage/scratch/mg197-a-d22dacd92735/wt
|
||||
- Output: /mnt/storage/scratch/mg197-a-d22dacd92735/out
|
||||
- Temporary root: /mnt/storage/scratch/ga-VPXhG7 (30 bytes)
|
||||
- Compose project: mg197-a-d22dacd92735
|
||||
|
||||
After an explicit author-gate grant, run this command with Sage's actual ID:
|
||||
|
||||
```sh
|
||||
env MOSAIC_GATE_GRANT=ACTUAL_SAGE_GRANT_ID bash /home/jwoltje/.t3/worktrees/mosaic-stack/herb-row197/agents/herb/work/queue-197/gate.sh > /mnt/storage/scratch/mg197-a-d22dacd92735/out/gate.log 2>&1
|
||||
```
|
||||
|
||||
No gate or suite has started. Queue order from sage-1454 is 192, 201, 204,
|
||||
200, Gander, then 197. No branch deletion is authorized or attempted. Any later
|
||||
rebase uses a new branch name, and pr-merge.sh -d stays off under #426(a).
|
||||
Whichever of rows 197 and 200 lands second must rerun stack-release singly.
|
||||
Clean-candidate wrapper baselines, image preparation and paired proofs remain
|
||||
separately gated acceptance work. There is still no source commit or push.
|
||||
|
||||
## Implementation approval and final notes, 2026-10-20T20:47Z
|
||||
|
||||
Webby-0031 approved r2 in REVIEW-impl-r2.md at 1f404c973. Their static review
|
||||
verified every source hash/mode and patch result, and all B1/B2 cases. No queue
|
||||
review request comment exists yet; Sage owns creating it if needed.
|
||||
|
||||
Folded all three optional notes into the same approved paths. The helper file's
|
||||
standalone fallback now shares /mnt/storage/scratch/mosaic-gate-lanes with real
|
||||
lane-a leases and leaves its stable lock files in place. Band assertions use the
|
||||
imported BANDS. Both T5 polling children fail after five seconds if their barrier
|
||||
is missed. README records the shared standalone namespace and deadlines.
|
||||
|
||||
Fresh single-file logs review-r3-fixtures.log and review-r3-lane-a.log both pass
|
||||
8/8 with zero skips. The lane-a run has no b lock directory, and every lock was
|
||||
released. Syntax and diff checks pass. No existing test assertions or timeouts
|
||||
changed. Remaining source matches the 11/14/8-file checks from r2.
|
||||
|
||||
Refreshed all 25 source rows in the existing frozen checkout. New digests replace
|
||||
the preceding r2 packet. gate.sh and its command are unchanged:
|
||||
|
||||
- SOURCE.json: 3584763642506e81a532c839bdd07b111d25daa87e35dbe441d8e8c01a2c2aab
|
||||
- gate.sh: 48e06e23e45d3484755f8cd79ad5b6dfde5ef4f960081e7c455a2540e707fed1
|
||||
- BUILD.patch: 55fe390da17ab926a0593d39e79561abe52756a1b1a2c756ec57f45d90f10fde
|
||||
|
||||
Author gate remains unstarted and queued for Sage's explicit grant. No source
|
||||
commit, suite, image build, paired proof, push or branch deletion ran.
|
||||
|
||||
## R3 approval receipt
|
||||
|
||||
Webby-0033 approved r3 in REVIEW-impl-r3.md at a5643f9bd. Static review verified
|
||||
the three packet hashes, all 25 source rows, and the patch result. Their review
|
||||
accepts N1-N3; no additional hang-guard test is requested. No queue review request
|
||||
comment exists yet, so Webby has not recorded a queue review.
|
||||
|
||||
Read-only verification after receipt confirms that the approved packet hashes
|
||||
and all 25 worktree/frozen source hashes and modes still match. Source and gate
|
||||
script stay frozen. The author gate has not started and still needs Sage's grant.
|
||||
|
||||
## Queue review round 1
|
||||
|
||||
Sage-1476 authorized moving row 197 to in-review before the author gate.
|
||||
Created CANDIDATE.sha256 from the same 25 reviewed SOURCE.json rows. Its digest
|
||||
is 6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
|
||||
Ran the queue move from the canonical checkout with op herb-197-review-r3.
|
||||
The move succeeded at revision 2395, opening round 1 on #1695. The request post
|
||||
failed before send because MOSAIC_GITEA_CREDENTIAL_FILE is not set; revision 2396
|
||||
records the failed outcome. Exit code 1 describes the failed post, not a failed
|
||||
row transition. Read-back confirms state in-review and the correct candidate.
|
||||
Sage will post as jarvis and give Webby the request comment number. No credential
|
||||
lookup, alternate post or retry was attempted here.
|
||||
|
||||
SOURCE.json, BUILD.patch, gate.sh and all 25 frozen source rows remain unchanged.
|
||||
Latest slot order is row 190 r2, 201, 204, 200, Gander, then Herb. No gate starts
|
||||
before Sage sends an explicit grant ID.
|
||||
|
||||
## Follow-on assignment
|
||||
|
||||
Sage-1503 assigns row 215, Case 2 C2-04, to Herb after row 197 (queue rev 2455,
|
||||
Gosalyn design r11). Read docs/plans/2026-10-20_case2-rows.md, C2-04 section,
|
||||
at 5b8def2f1 when starting that row. Its required base is origin/refactor
|
||||
305857c8f. This assignment does not replace row 197 or authorize its gate.
|
||||
Keep row 197's approved source frozen while awaiting Sage's grant. Start row
|
||||
215 only after row 197, using a new branch name under the existing fleet rule.
|
||||
|
||||
## Round 2 merge preparation on row 233
|
||||
|
||||
Sage-1757 replaces history replay with one candidate commit on 57f59e0c3.
|
||||
The attempted history replay on herb/row197-r2-233 had already finished when
|
||||
that instruction arrived. Its only conflict was the append-only BUILD-LOG tail.
|
||||
That branch remains for history; none of its replayed commits is in this round.
|
||||
The current branch is herb/row197-r3-233, directly from
|
||||
57f59e0c399ce29b1bb85ca6af686fdc10eb195d.
|
||||
|
||||
Stash correction: shared refs/stash was used incorrectly. The exact entry
|
||||
2767b88549dfd8c7e810908b5416876fa50cb571 is now kept on
|
||||
herb/row197-r3-wip. After verifying stash@{0} still named it, only that entry was
|
||||
dropped. The two other entries remain. No further stash creation, force push,
|
||||
branch deletion or refs/stash mutation is planned. A separate working-copy
|
||||
backup is /mnt/storage/scratch/herb197-merge-W4oiLg.
|
||||
|
||||
Sage-1760 requires preserving every landed hunk on every changed manifest path.
|
||||
Checked each of the 25 r1 paths with git diff aa1bd209e 57f59e0c3 -- PATH.
|
||||
Exactly three changed upstream. Their r2 diff against 57f59e0c3 is captured in
|
||||
r2-merge-only.patch:
|
||||
|
||||
| Merged path | Only row 197 changes against landed base | Landed content retained |
|
||||
| --- | --- | --- |
|
||||
| packages/cli/tests/stack-release.test.mjs | leasePort and laneAfter imports; freePort now calls the lease helper | All landed tests, including row 233's tests after R15 and BACKUP_STORE checks |
|
||||
| packages/webui/tests/service.test.mjs | leasePort and laneAfter imports; closedPort now calls the lease helper | SEND_REFUSAL permits --sso; default unit rendering removes the @CONFIG_ENV@ line |
|
||||
| scripts/test-discord.sh | fixture PI_CODING_AGENT_DIR setup; optional two-worker nested Node cap | Row 190's absent scratch MOSAIC_CONFIG selection and caller-config restoration |
|
||||
|
||||
The other 22 paths equal the r1 reviewed source bytes and modes. r2-MERGE.json
|
||||
lists every path, its r1/r2 digest, mode, upstream-change flag and equality result.
|
||||
Source copies came from the hash-verified r1 frozen checkout. Original r1 digest:
|
||||
6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3.
|
||||
No existing assertion or timeout line differs from the landed base. Static
|
||||
checks only: 20 module syntax checks, four shell syntax checks and git diff check
|
||||
pass. BUILD-LOG.md equals 57f59e0c3 byte for byte and is outside this commit.
|
||||
|
||||
Sage explicitly requested the local candidate SHA before granting tests. This
|
||||
commit is an untested preparation checkpoint, not gate evidence. No tests or
|
||||
push are authorized yet. r2-TEST-FILES.txt lists the requested grant scope:
|
||||
16 Node test files singly and scripts/test-discord.sh, test-foundation.sh and
|
||||
test-queue.sh. The four stack-release consumers are stack-release.test.mjs,
|
||||
stack-release-external.test.mjs, stack-upgrade.test.mjs and stack-instances.test.mjs.
|
||||
The shared stack-release-fixture.mjs is exercised by its importing test files,
|
||||
not run as a standalone test. This covers all 13 touched Node test files plus
|
||||
three additional stack-release consumers and all three touched shell suites.
|
||||
|
||||
Current slot order from sage-1760: Gizmoduck, Launchpad, Darkwing, then Herb,
|
||||
around 06:30Z. Wait for an explicit grant. After granted checks and authorized
|
||||
push, build the new candidate manifest from that pushed tip and open the
|
||||
merge-scoped round 2. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain r1
|
||||
historical artifacts and are not a clear to run the merged candidate.
|
||||
|
||||
## Round 2 rebuilt on row 234
|
||||
|
||||
Sage-1776 names landing c771a36f4bd0b6c359282425c668996a90072f93.
|
||||
Built a new branch, herb/row197-r4-234, directly from that landing. No history
|
||||
replay, stash, force push, branch deletion, test run or push is involved.
|
||||
The prior candidate a3b7cbde8 remains untested and is not the queued candidate.
|
||||
|
||||
Checked git diff --name-only 57f59e0c3 c771a36f4 against all 25 source paths.
|
||||
Only packages/cli/tests/stack-release.test.mjs overlaps; the other 24 do not.
|
||||
The full comparison is in r2-on234-OVERLAP.txt. The stack-release file starts
|
||||
from the landing and keeps both T46 untimed entries, host_grants and hosts.
|
||||
Its only changes against the landing are the leasePort and laneAfter imports
|
||||
and freePort's call to the lease helper. r2-on234-merge-only.patch records those
|
||||
hunks. Existing assertions and timeouts match the landing exactly.
|
||||
|
||||
Every other source path equals a3b7cbde8 byte for byte and retains its mode.
|
||||
This includes the previously merged service and test-discord files, preserving
|
||||
all landed --sso, CONFIG_ENV and scratch-config handling. The earlier evidence
|
||||
r2-MERGE.json still records those three pre-234 merge cases. The new
|
||||
r2-on234-MERGE.json records each current digest, prior digest, mode and equality
|
||||
result. BUILD-LOG.md equals the new landing byte for byte and is excluded.
|
||||
|
||||
Static check of the changed Node file and the candidate diff pass. No tests
|
||||
were run. The scope stays r2-TEST-FILES.txt: 16 Node files singly plus the three
|
||||
shell suites. No manifest or queue round is rebuilt until granted checks and
|
||||
authorized push. Old SOURCE.json, CANDIDATE.sha256 and gate.sh remain historical
|
||||
r1 evidence. The test slot follows Darkwing 240 and needs Sage's explicit grant.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Two-lane proof command and schedule
|
||||
|
||||
This evidence stays outside r5's 32 manifest paths. No proof or image build has
|
||||
run. The driver and example config are committed on herb/row197-proof, rooted
|
||||
at 9aa6d52d9. Sage runs the committed copy from their own checkout.
|
||||
|
||||
Driver: `agents/herb/work/queue-197/proof/run-proof.mjs`.
|
||||
The commit message to Sage carries its exact SHA-256. Check it with
|
||||
`git show <proof-commit>:agents/herb/work/queue-197/proof/run-proof.mjs | sha256sum`.
|
||||
|
||||
After Sage fills config.json from config.example.json and grants the whole block:
|
||||
|
||||
```sh
|
||||
MOSAIC_PROOF_GRANT=ACTUAL_SAGE_GRANT_ID node /SAGE_PROOF_CHECKOUT/agents/herb/work/queue-197/proof/run-proof.mjs /SAGE_OWNED_CONFIG/config.json
|
||||
```
|
||||
|
||||
The example config pins the launcher and candidate A to 197 r5:
|
||||
9aa6d52d9431efd215aa31d748d7e273eb321474. The launcher is a separate clean
|
||||
checkout owned by Sage, so later row 215 work cannot change it. Fill candidateB with
|
||||
Sage's approved combined 212+197 commit, imageReceipt with the granted pinned
|
||||
image-provenance receipt, and runRoot with a fresh private scratch path. Both
|
||||
candidates must carry identical lane tooling and image inputs and use the same
|
||||
pinned image ID. The driver refuses missing parameters, equal candidate SHAs,
|
||||
dirty launcher, changed tooling, mismatched image inputs or image ID.
|
||||
|
||||
D5 order is exactly two solo gates, then three pairs:
|
||||
|
||||
| Wave | Candidate A | Candidate B |
|
||||
| --- | --- | --- |
|
||||
| 1 | solo, lane a | idle |
|
||||
| 2 | idle | solo, lane b |
|
||||
| 3 | paired, lane a | paired, lane b |
|
||||
| 4 | paired, lane b | paired, lane a |
|
||||
| 5 | paired, lane a | paired, lane b |
|
||||
|
||||
Every invocation gets its own clean clone, output, short temp root and random
|
||||
Compose project. The wrapper discovers all package and shell steps, with two
|
||||
Node test-file workers per lane. The summary requires three overlaps, the lane
|
||||
swap, successful clean receipts and zero warm image builds. Raw logs and per-step
|
||||
receipts remain available. No failed gate or series is retried automatically.
|
||||
|
||||
A live monitor stops both gates on the first failure line, records lane, step,
|
||||
source file when the runner emits it, test name and preserved log, then enriches
|
||||
that report after shutdown. If the runner gives no source path, the report says
|
||||
unresolved and points to the log rather than guessing. It stops earlier than the
|
||||
two-failure limit. TERM/INT/HUP stop owned supervisors; cleanup waits for them.
|
||||
|
||||
Container and network names come from Compose's random recorded project. The
|
||||
project and service configuration have no fixed global names. Exact-label Docker
|
||||
event streams record create/destroy IDs and names, including --rm containers.
|
||||
An exit trap inventories names/labels, removes only exact-project objects with
|
||||
the approved cleanup helper, verifies emptiness and stops its own event streams.
|
||||
The driver's Docker inspection never reads Config.Env or credential contents, and never builds, pulls,
|
||||
retags or prunes. Missing resource-name evidence, an ended event stream, unknown
|
||||
ownership, leftover objects or quarantined lanes fail the proof. A SIGKILL cannot
|
||||
run an exit trap; durable wrapper receipts then require granted recovery.
|
||||
|
||||
Run this as its own whole-slot phase. A still-running legacy landing.mjs gate or
|
||||
gate.sh is LEGACY BUSY, including a parent process. The driver does not suppress
|
||||
that refusal. The ordinary landing gate must exit before this proof phase starts.
|
||||
|
||||
Planning runtime from sage-1837 is 12-15 minutes per wave, so reserve 60-75 minutes
|
||||
for all five waves, plus any separately granted image preparation. This is an
|
||||
estimate, not a measurement. Measured proof runtime is unavailable until this
|
||||
first run. Historical row 189 full gate was 659 seconds; the r4 focused 19-file
|
||||
run was 761.33 seconds and is not a full solo baseline. The resulting summary
|
||||
records actual per-gate times, pair makespans, slowdowns and total wall time.
|
||||
|
||||
No image provenance receipt or combined integration SHA is available to Herb yet.
|
||||
The driver remains unexecuted. Sage will read the exact hashed driver and request
|
||||
Webby's static review before the landing/proof grant.
|
||||
|
||||
Static node --check passed after sage-1839 single cleared. This is syntax-only;
|
||||
the driver has not run a proof or Docker command. Its runtime behavior awaits
|
||||
Sage and Webby review and the separately granted proof block.
|
||||
|
||||
## Driver review fixes, sage-1857
|
||||
|
||||
Webby's first review was CHANGES, B1, at bba870ee9. The revised driver takes
|
||||
tempRoot only from immutable start records for the plan's checkouts. After
|
||||
every owned gate exits, Docker cleanup and the final status/process checks
|
||||
run. The driver requires the exact short ga-/gb- scratch spelling, a matching
|
||||
lane, an owned real directory and no live owner, child group or process with
|
||||
the run paths in its argv before removal. It also requires a clean terminal
|
||||
receipt. A successful gate's root is removed recursively. A failed gate's
|
||||
root stays for diagnosis; an unverified cleanup also stays and fails the
|
||||
proof. docker-summary.json records removed, kept and error entries. Any
|
||||
cleanup error makes the driver fail. No unrelated scratch directory is read.
|
||||
|
||||
Signal handlers are installed before configuration and preparation. Clone
|
||||
preparation is inside the try/finally, and yields between synchronous clones
|
||||
so a pending signal stops it before another invocation. A preparation signal
|
||||
writes failure evidence and reaches the cleanup path. Partial clones remain
|
||||
under runRoot as permitted by Sage; they are never confused with lane roots.
|
||||
SIGKILL cannot be trapped and still needs granted recovery.
|
||||
|
||||
At 2026-10-21T08:26:41Z, Herb removed four earlier roots recorded in their
|
||||
own packet: ga-VPXhG7, gb-XeTAUS, ga-35Zo9Y and ga-R5IOT3. Each was an owned
|
||||
real directory with no process argv/cwd reference before removal. Names
|
||||
ga-AMtfI3 and ga-Vd9Ef2 were visible in the scratch directory listing but had
|
||||
no ownership evidence in Herb's packet; neither was opened or changed.
|
||||
|
||||
## N4 read-only trace, pre-existing and out of scope
|
||||
|
||||
Sage ruled in sage-1859 that N4 does not block 197. Legacy suites already
|
||||
reach Compose's read-only host auth mount, and the lane adds no exposure.
|
||||
Sage owns a DEFERRED entry for fixture PI_AUTH_FILE coverage. No fix is made
|
||||
in 197, and this procedure does not claim that the full gate is secret-free.
|
||||
|
||||
At 9aa6d52d9:
|
||||
|
||||
- gate-lane.mjs:109-111 keeps HOME real but neither keeps nor sets
|
||||
PI_AUTH_FILE. compose.yaml:43 therefore selects its host auth-file default.
|
||||
- script-task is discovered by gate-lane.mjs:36. test-task.sh has no
|
||||
PI_AUTH_FILE assignment. Calls at :149-150 (mock), :444, :460, :480, :483
|
||||
and :520 (live) reach run-task. mosaic-task.mjs:321 copies the environment
|
||||
and :395-400 invokes compose run mosaic-agent without changing that path.
|
||||
- script-release calls release activate at test-release.sh:69,85,93.
|
||||
release.sh:41-54 health_check calls run-task with no PI_AUTH_FILE override,
|
||||
then reaches the same Compose call. These tests use scratch MOSAIC_CONFIG;
|
||||
that does not override the separate auth-file bind mount.
|
||||
- test-auth.sh:48-92 sets explicit fixtures, but calls auth.sh instead of
|
||||
Compose. Package runs/task-cli.test.mjs exercises list/show, not run.
|
||||
|
||||
This was a static source trace only. No auth file was opened, no credential
|
||||
value was read, and no Docker command, image build or proof was executed.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"repo": "/mnt/storage/src/mosaic-stack",
|
||||
"launcherRoot": "SAGE_OWNED_CLEAN_LAUNCHER_CHECKOUT",
|
||||
"launcherCandidate": "9aa6d52d9431efd215aa31d748d7e273eb321474",
|
||||
"candidateA": "9aa6d52d9431efd215aa31d748d7e273eb321474",
|
||||
"candidateB": "SAGE_APPROVED_212_PLUS_197_COMMIT",
|
||||
"candidateDifference": "A is approved 197 r5; B must be the approved combined 212+197 integration commit",
|
||||
"imageReceipt": "SAGE_PINNED_IMAGE_PROVENANCE_RECEIPT",
|
||||
"runRoot": "/mnt/storage/scratch/mg197-proof-REPLACE_WITH_UNIQUE_NONCE"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Evidence driver only. Runs no test without Sage's whole-slot grant.
|
||||
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, realpathSync, openSync, closeSync, symlinkSync, readdirSync, statSync, lstatSync, readSync, rmSync } from 'node:fs';
|
||||
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
const proofStart=performance.now();
|
||||
let interrupted=false,abortOnSignal=()=>{};
|
||||
const signal=()=>{interrupted=true;abortOnSignal();};
|
||||
for(const name of ['SIGINT','SIGTERM','SIGHUP'])process.on(name,signal);
|
||||
const fail=m=>{throw Error(m);};
|
||||
const within=(root,path)=>{const p=relative(root,path);return p===''||!p.startsWith('..')&&!isAbsolute(p);};
|
||||
const grant=process.env.MOSAIC_PROOF_GRANT;
|
||||
if(!/^sage-[0-9]+$/.test(grant??''))fail('explicit whole-slot MOSAIC_PROOF_GRANT required');
|
||||
const config=JSON.parse(readFileSync(process.argv[2],'utf8'));
|
||||
for(const key of ['candidateA','candidateB'])if(!/^[a-f0-9]{40}$/.test(config[key]??''))fail('full approved candidate SHA required: '+key);
|
||||
if(config.candidateA===config.candidateB)fail('two distinct approved candidates required');
|
||||
if(!config.candidateDifference?.trim())fail('record why the approved candidates differ');
|
||||
const launcher=realpathSync(config.launcherRoot),repo=realpathSync(config.repo),receipt=realpathSync(config.imageReceipt);
|
||||
const git=(root,...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
||||
if(git(launcher,'rev-parse','HEAD')!==config.launcherCandidate)fail('launcher pin differs');
|
||||
if(git(launcher,'status','--porcelain').split('\n').some(l=>l&&l!=='?? node_modules'))fail('launcher checkout dirty');
|
||||
const root=resolve(config.runRoot);
|
||||
if(existsSync(root)||!within('/mnt/storage/scratch',realpathSync(dirname(root))))fail('fresh scratch runRoot required');
|
||||
const {validateTooling}=await import(pathToFileURL(join(launcher,'scripts/testing/gate-lane.mjs')));
|
||||
const {verifyImage}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-image.mjs')));
|
||||
const {runRecords,status,processInventory,sameProcess}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-state.mjs')));
|
||||
const {summarizePairs}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-evidence.mjs')));
|
||||
const {cleanupProject}=await import(pathToFileURL(join(launcher,'scripts/testing/lane-docker.mjs')));
|
||||
const docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
|
||||
const image=JSON.parse(readFileSync(receipt,'utf8'));
|
||||
mkdirSync(root,{mode:0o700});const state=join(root,'state');mkdirSync(state,{mode:0o700});
|
||||
const put=(name,value)=>writeFileSync(join(root,name),JSON.stringify(value,null,2)+'\n',{mode:0o600,flag:'wx'});
|
||||
const waves=[
|
||||
[['solo-a','a',config.candidateA]],
|
||||
[['solo-b','b',config.candidateB]],
|
||||
[['pair1-a','a',config.candidateA],['pair1-b','b',config.candidateB]],
|
||||
[['pair2-b','b',config.candidateA],['pair2-a','a',config.candidateB]],
|
||||
[['pair3-a','a',config.candidateA],['pair3-b','b',config.candidateB]],
|
||||
];
|
||||
const plan=waves.map(w=>w.map(([label,lane,candidate])=>({label,lane,candidate,dir:join(root,label),checkout:join(root,label,'wt'),out:join(root,label,'out')})));
|
||||
put('plan.json',{grant,...config,plan,image,started:new Date().toISOString()});
|
||||
const active=new Set(),watchers=new Map(),objects=new Map();let firstFailure=null;
|
||||
const eventError=[];
|
||||
const knownRuns=new Map();let abortTimer;
|
||||
const ownedRuns=()=>runRecords(state).flatMap(r=>r.records.filter(e=>e.kind==='start'&&plan.flat().some(p=>p.checkout===e.checkout)));
|
||||
function logObject(project,kind,id,name,action){
|
||||
if(!/^mg197-[ab]-[a-f0-9]{12}$/.test(project)||!['container','network'].includes(kind)||!/^[a-f0-9]{12,64}$/.test(id))fail('invalid Docker ownership metadata');
|
||||
if(name&&!String(name).replace(/^\//,'').startsWith(project))fail('Docker name outside its recorded project');
|
||||
const key=kind+':'+id,old=objects.get(key);const value={project,kind,id,name:name??old?.name??null,created:old?.created||action==='create',destroyed:old?.destroyed||action==='destroy'};
|
||||
objects.set(key,value);appendFileSync(join(root,'docker-objects.jsonl'),JSON.stringify({...value,action,at:new Date().toISOString()})+'\n',{mode:0o600});
|
||||
}
|
||||
function ensureWatchers(final=false){
|
||||
if(final||[...active].some(j=>!knownRuns.has(j.run.checkout)))for(const run of ownedRuns())knownRuns.set(run.checkout,run);
|
||||
for(const run of knownRuns.values())if(!watchers.has(run.project)){
|
||||
// Replay the few seconds before the immutable start was noticed; the random
|
||||
// exact project label excludes all foreign resources. No raw events are saved.
|
||||
const child=spawn(docker,['events','--since',String(Math.floor(Date.now()/1000)-5),'--filter','label=com.docker.compose.project='+run.project,'--filter','type=container','--filter','type=network','--filter','event=create','--filter','event=destroy','--format','{{json .}}'],{stdio:['ignore','pipe','pipe']});
|
||||
const watcher={child,stopping:false,buffer:''};watchers.set(run.project,watcher);
|
||||
child.stdout.on('data',bytes=>{watcher.buffer+=bytes;let end;while((end=watcher.buffer.indexOf('\n'))>=0){const line=watcher.buffer.slice(0,end);watcher.buffer=watcher.buffer.slice(end+1);if(!line)continue;try{const event=JSON.parse(line),kind=event.Type,action=event.Action;const attrs=event.Actor?.Attributes??{};if(attrs['com.docker.compose.project']&&attrs['com.docker.compose.project']!==run.project)throw Error('foreign project event');if(['container','network'].includes(kind)&&['create','destroy'].includes(action))logObject(run.project,kind,event.Actor.ID,attrs.name,action);}catch(error){eventError.push(error.message);}}});
|
||||
child.stderr.resume();watcher.done=new Promise(resolve=>{child.once('error',error=>{eventError.push(error.message);resolve();});child.once('close',code=>{if(!watcher.stopping)eventError.push('Docker event stream ended early: '+code);resolve();});});
|
||||
}
|
||||
}
|
||||
function inventory(project){
|
||||
const found=[];
|
||||
for(const kind of ['container','network']){
|
||||
const query=kind==='container'?['ps','-aq']:['network','ls','-q'];
|
||||
const ids=execFileSync(docker,[...query,'--filter','label=com.docker.compose.project='+project],{encoding:'utf8'}).trim().split(/\s+/).filter(Boolean);
|
||||
for(const id of ids){
|
||||
if(!/^[a-f0-9]{12,64}$/.test(id))fail('invalid inventory ID');
|
||||
// Read only name and ownership label, never Config.Env or credentials.
|
||||
const labels=kind==='container'?'Config.Labels':'Labels';
|
||||
const format='{"name":{{json .Name}},"project":{{json (index .'+labels+' "com.docker.compose.project")}}}';
|
||||
const item=JSON.parse(execFileSync(docker,[kind,'inspect',id,'--format',format],{encoding:'utf8'}));
|
||||
if(item.project!==project)fail('foreign Docker object');
|
||||
logObject(project,kind,id,item.name,'inventory');found.push({kind,id,name:item.name});
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function failureDetails(job){
|
||||
const events=runRecords(state).find(r=>r.records.some(e=>e.kind==='start'&&e.checkout===job.run.checkout))?.records??[];
|
||||
const steps=events.filter(e=>e.kind==='step');
|
||||
const paths=firstFailure?.gate===job.run.label&&firstFailure.log?[firstFailure.log]:existsSync(job.run.out)?readdirSync(job.run.out).filter(n=>n.endsWith('.log')).map(n=>join(job.run.out,n)):[];
|
||||
for(const log of paths){
|
||||
const text=readFileSync(log,'utf8');const bad=text.match(/^(?:\s*✖ (?!failing tests:)|not ok \d+ - |FAIL\s+)(.+)$/m);if(!bad)continue;
|
||||
const detail=text.match(/✖ failing tests:[\s\S]*$/)?.[0]??text;
|
||||
const file=detail.match(/^test at (.+?\.test\.(?:mjs|js|cjs))(?::\d+)+/m)?.[1]??detail.match(/file:\/\/([^\s)]+\.test\.(?:mjs|js|cjs))/)?.[1];
|
||||
return {lane:job.run.lane,gate:job.run.label,step:log.split('/').at(-1).slice(0,-4),file:file??'unresolved; inspect preserved log',test:bad[1],log};
|
||||
}
|
||||
const failed=steps.find(e=>e.rc||e.requiredSkips?.length);
|
||||
return {lane:job.run.lane,gate:job.run.label,step:failed?.name??'preflight or cleanup',file:'see gate.log',test:failed?.requiredSkips?.join('; ')??'wrapper failed',log:join(job.run.dir,'gate.log')};
|
||||
}
|
||||
function abortActive(){
|
||||
for(const job of active)job.child.kill('SIGTERM');
|
||||
if(!abortTimer){abortTimer=setTimeout(()=>{for(const job of active)job.child.kill('SIGKILL');},30000);abortTimer.unref();}
|
||||
}
|
||||
abortOnSignal=abortActive;
|
||||
function newFailure(job){
|
||||
if(!existsSync(job.run.out))return null;job.logs??=new Map();
|
||||
for(const name of readdirSync(job.run.out).filter(n=>n.endsWith('.log'))){
|
||||
const log=join(job.run.out,name),st=statSync(log),cursor=job.logs.get(log)??{size:0,tail:''};if(st.size===cursor.size)continue;
|
||||
const fd=openSync(log,'r'),bytes=Buffer.alloc(st.size-cursor.size);try{readSync(fd,bytes,0,bytes.length,cursor.size);}finally{closeSync(fd);}
|
||||
const text=cursor.tail+bytes.toString('utf8'),match=text.match(/^(?:\s*✖ (?!failing tests:)|not ok \d+ - |FAIL\s+)(.+)$/m);
|
||||
job.logs.set(log,{size:st.size,tail:text.slice(-8192)});
|
||||
if(match)return {lane:job.run.lane,gate:job.run.label,step:name.slice(0,-4),file:'awaiting runner failure details',test:match[1],log};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function monitor(){
|
||||
try{
|
||||
ensureWatchers();
|
||||
for(const job of active){const detail=newFailure(job);if(detail&&!firstFailure){firstFailure=detail;process.stderr.write('FIRST_FAILURE '+JSON.stringify(detail)+'\n');abortActive();}}
|
||||
if(eventError.length&&!firstFailure){firstFailure={kind:'Docker evidence',errors:[...eventError]};abortActive();}
|
||||
}catch(error){eventError.push(error.message);abortActive();}
|
||||
}
|
||||
function launch(run){
|
||||
const fd=openSync(join(run.dir,'gate.log'),'wx',0o600);
|
||||
const child=spawn('bash',[join(launcher,'scripts/testing/gate-lane.sh'),'run','--lane',run.lane,'--candidate',run.candidate,'--checkout',run.checkout,'--out',run.out,'--state-root',state,'--image-receipt',receipt,'--grant',grant],{stdio:['ignore',fd,fd]});closeSync(fd);
|
||||
const job={child,run};active.add(job);
|
||||
job.done=new Promise(resolve=>{child.once('error',error=>{active.delete(job);resolve({rc:127,error:error.message});});child.once('exit',(code,signal)=>{active.delete(job);resolve({rc:code??128,signal});});});
|
||||
return job;
|
||||
}
|
||||
function gateReceipt(run){
|
||||
const item=runRecords(state).find(r=>r.records.some(e=>e.kind==='start'&&e.checkout===run.checkout));
|
||||
if(!item)fail('missing start receipt: '+run.label);
|
||||
const terminal=item.records.filter(e=>e.kind==='terminal').at(-1);
|
||||
if(!terminal||terminal.rc!==0||!terminal.clean||terminal.imageBuilds!==0)fail('gate failed or unclean: '+run.label);
|
||||
if(item.records.some(e=>e.requiredSkips?.length))fail('required prerequisite skipped: '+run.label);
|
||||
return {...terminal,label:run.label,lane:run.lane,candidate:run.candidate,receiptDir:item.dir};
|
||||
}
|
||||
async function stop(){
|
||||
const jobs=[...active];for(const j of jobs)j.child.kill('SIGTERM');
|
||||
const timer=setTimeout(()=>{for(const j of jobs)j.child.kill('SIGKILL');},30000);timer.unref();
|
||||
await Promise.all(jobs.map(j=>j.done));clearTimeout(timer);
|
||||
}
|
||||
const measured=[],start=proofStart;
|
||||
const monitorTimer=setInterval(monitor,100);
|
||||
let summary;
|
||||
try{
|
||||
// Preparation is inside the cleanup path. Yield between synchronous clones
|
||||
// so a pending signal stops preparation before another invocation is made.
|
||||
for(const run of plan.flat()){
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
if(interrupted)fail('proof interrupted during preparation');
|
||||
mkdirSync(run.dir,{mode:0o700});
|
||||
execFileSync('git',['clone','--quiet','--no-hardlinks','--local',repo,run.checkout],{stdio:'pipe'});
|
||||
execFileSync('git',['-C',run.checkout,'checkout','--quiet','--detach',run.candidate],{stdio:'pipe'});
|
||||
symlinkSync('/mnt/storage/src/mosaic-stack/node_modules',join(run.checkout,'node_modules'));
|
||||
validateTooling(run.checkout);verifyImage(image,run.checkout);
|
||||
}
|
||||
for(const wave of plan){
|
||||
if(interrupted)fail('proof interrupted');
|
||||
const observed=status(state);
|
||||
if(observed.legacy.length||observed.a.state!=='CLEAR'||observed.b.state!=='CLEAR')fail('proof requires both lanes clear and no legacy gate');
|
||||
const jobs=wave.map(launch);
|
||||
for(const j of jobs)j.done.then(result=>{if(result.rc){firstFailure??=failureDetails(j);abortActive();}}).catch(error=>{eventError.push(error.message);abortActive();});
|
||||
const results=await Promise.all(jobs.map(j=>j.done));
|
||||
if(firstFailure?.gate){const failedJob=jobs.find(j=>j.run.label===firstFailure.gate);if(failedJob)firstFailure=failureDetails(failedJob);}
|
||||
if(interrupted||firstFailure||eventError.length||results.some(r=>r.rc))fail('failed wave: '+wave.map(r=>r.label).join(',')+' '+JSON.stringify(firstFailure));
|
||||
measured.push(wave.map(gateReceipt));
|
||||
const end=status(state);
|
||||
if(end.legacy.length||end.a.state!=='CLEAR'||end.b.state!=='CLEAR')fail('unclean wave status');
|
||||
}
|
||||
const solos=measured.slice(0,2).flat(),pairs=measured.slice(2);summary={grant,solos,pairs,summary:summarizePairs(solos,pairs),wallMs:performance.now()-start,finished:new Date().toISOString()};
|
||||
}catch(error){
|
||||
await stop();put('failed.json',{grant,message:error.message,firstFailure,measured,at:new Date().toISOString()});process.stderr.write(error.message+'\n');process.exitCode=1;
|
||||
}finally{
|
||||
clearInterval(monitorTimer);clearTimeout(abortTimer);await stop();const cleanupErrors=[];
|
||||
try{ensureWatchers(true);}catch(error){cleanupErrors.push({message:error.message});}
|
||||
for(const run of knownRuns.values())try{inventory(run.project);cleanupProject({project:run.project},docker);if(inventory(run.project).length)fail('Docker resources remain');}catch(error){cleanupErrors.push({project:run.project,message:error.message});}
|
||||
for(const watcher of watchers.values()){watcher.stopping=true;watcher.child.kill('SIGTERM');}
|
||||
await Promise.all([...watchers.values()].map(w=>w.done));
|
||||
let finalStatus;try{finalStatus=status(state);if(finalStatus.legacy.length||finalStatus.a.state!=='CLEAR'||finalStatus.b.state!=='CLEAR')cleanupErrors.push({message:'lane status not clear',status:finalStatus});}catch(error){cleanupErrors.push({message:error.message});}
|
||||
// Start receipts establish which short roots belong to this proof. Never
|
||||
// discover roots by scanning scratch or follow a substituted root symlink.
|
||||
const tempRoots=[];let procs;
|
||||
try{procs=processInventory();}catch(error){cleanupErrors.push({message:'process inventory failed: '+error.message});}
|
||||
for(const run of knownRuns.values()){
|
||||
const record={gate:plan.flat().find(p=>p.checkout===run.checkout)?.label,lane:run.lane,tempRoot:run.tempRoot};
|
||||
try{
|
||||
if(!/^\/mnt\/storage\/scratch\/g[ab]-[A-Za-z0-9]{6}$/.test(run.tempRoot??'')||!run.tempRoot.startsWith('/mnt/storage/scratch/g'+run.lane+'-'))fail('invalid recorded temp root');
|
||||
const st=lstatSync(run.tempRoot);
|
||||
if(!st.isDirectory()||st.isSymbolicLink()||st.uid!==process.getuid()||realpathSync(run.tempRoot)!==run.tempRoot)fail('temp root is not an owned real directory');
|
||||
const receipt=runRecords(state).find(r=>r.records.some(e=>e.kind==='start'&&e.checkout===run.checkout));
|
||||
const groups=new Set(receipt?.records.filter(e=>e.kind==='child').map(e=>e.group));
|
||||
if(!procs||sameProcess(run.owner)||procs.some(p=>p.state!=='Z'&&p.pid!==process.pid&&(groups.has(p.group)||p.argv.some(a=>a.includes(run.tempRoot)||a.includes(run.checkout)))))fail('run process still present or inventory unavailable; temp root retained');
|
||||
const terminal=receipt?.records.filter(e=>e.kind==='terminal').at(-1);
|
||||
if(!terminal?.clean){record.action='kept';record.reason='run cleanup unverified';cleanupErrors.push({...record});tempRoots.push(record);continue;}
|
||||
if(terminal.rc!==0||firstFailure?.gate===record.gate){record.action='kept';record.reason='failed gate diagnosis';}
|
||||
else{rmSync(run.tempRoot,{recursive:true});record.action='removed';}
|
||||
}catch(error){record.action='error';record.message=error.message;cleanupErrors.push({...record});}
|
||||
tempRoots.push(record);
|
||||
}
|
||||
put('docker-summary.json',{objects:[...objects.values()],eventError,cleanupErrors,finalStatus,tempRoots});
|
||||
if(cleanupErrors.length||eventError.length||[...objects.values()].some(o=>!o.name))process.exitCode=1;
|
||||
if(summary&&!process.exitCode){
|
||||
for(const run of knownRuns.values())for(const kind of ['container','network'])if(![...objects.values()].some(o=>o.project===run.project&&o.kind===kind&&o.created)){process.exitCode=1;process.stderr.write('missing Docker creation evidence: '+run.project+' '+kind+'\n');}
|
||||
if(!process.exitCode){put('summary.json',summary);process.stdout.write('PROOF_PASSED '+join(root,'summary.json')+'\n');}
|
||||
}
|
||||
for(const name of ['SIGINT','SIGTERM','SIGHUP'])process.off(name,signal);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"r1Base": "aa1bd209e",
|
||||
"r1Candidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
|
||||
"r2Base": "57f59e0c399ce29b1bb85ca6af686fdc10eb195d",
|
||||
"rows": [
|
||||
{
|
||||
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
|
||||
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"r2Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
|
||||
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"r2Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-image.test.mjs",
|
||||
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"r2Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-state.test.mjs",
|
||||
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"r2Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane.test.mjs",
|
||||
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"r2Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/stack-release.test.mjs",
|
||||
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
|
||||
"r2Sha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": true,
|
||||
"r1BlobEqual": false
|
||||
},
|
||||
{
|
||||
"path": "packages/control-board/tests/serve.test.mjs",
|
||||
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"r2Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/claude-session.test.mjs",
|
||||
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"r2Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/pi-session.test.mjs",
|
||||
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"r2Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/preview.test.mjs",
|
||||
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"r2Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/proof-cli.test.mjs",
|
||||
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"r2Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/serve.test.mjs",
|
||||
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"r2Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/service.test.mjs",
|
||||
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
|
||||
"r2Sha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": true,
|
||||
"r1BlobEqual": false
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/tester-cli.test.mjs",
|
||||
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"r2Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-discord.sh",
|
||||
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
|
||||
"r2Sha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": true,
|
||||
"r1BlobEqual": false
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-foundation.sh",
|
||||
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"r2Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-queue.sh",
|
||||
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"r2Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/README.md",
|
||||
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"r2Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.mjs",
|
||||
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"r2Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.sh",
|
||||
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"r2Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"mode": "0o755",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-docker.mjs",
|
||||
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"r2Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-evidence.mjs",
|
||||
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"r2Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-fixtures.mjs",
|
||||
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"r2Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-image.mjs",
|
||||
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"r2Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-state.mjs",
|
||||
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"r2Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"mode": "0o644",
|
||||
"landedPathChanged": false,
|
||||
"r1BlobEqual": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
packages/cli/tests/gate-lane-fixtures.test.mjs
|
||||
packages/cli/tests/gate-lane-image.test.mjs
|
||||
packages/cli/tests/gate-lane-state.test.mjs
|
||||
packages/cli/tests/gate-lane.test.mjs
|
||||
packages/cli/tests/stack-instances.test.mjs
|
||||
packages/cli/tests/stack-release-external.test.mjs
|
||||
packages/cli/tests/stack-release.test.mjs
|
||||
packages/cli/tests/stack-upgrade.test.mjs
|
||||
packages/control-board/tests/serve.test.mjs
|
||||
packages/harness/tests/claude-session.test.mjs
|
||||
packages/harness/tests/pi-session.test.mjs
|
||||
packages/webui/tests/preview.test.mjs
|
||||
packages/webui/tests/proof-cli.test.mjs
|
||||
packages/webui/tests/serve.test.mjs
|
||||
packages/webui/tests/service.test.mjs
|
||||
packages/webui/tests/tester-cli.test.mjs
|
||||
scripts/test-discord.sh
|
||||
scripts/test-foundation.sh
|
||||
scripts/test-queue.sh
|
||||
@@ -0,0 +1,45 @@
|
||||
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
|
||||
index 8788158c3..a78f52062 100644
|
||||
--- a/packages/cli/tests/stack-release.test.mjs
|
||||
+++ b/packages/cli/tests/stack-release.test.mjs
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
+import { after as laneAfter } from 'node:test';
|
||||
@@ -1777 +1779 @@ const TESTER='7499';
|
||||
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
||||
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
|
||||
index a6e66608a..adb460d07 100644
|
||||
--- a/packages/webui/tests/service.test.mjs
|
||||
+++ b/packages/webui/tests/service.test.mjs
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
+import { after as laneAfter } from 'node:test';
|
||||
@@ -48,9 +50 @@ function filled(flags, root = REPO) {
|
||||
-async function closedPort() {
|
||||
- const server = createServer();
|
||||
- server.listen(0, '127.0.0.1');
|
||||
- await once(server, 'listening');
|
||||
- const { port } = server.address();
|
||||
- server.close();
|
||||
- await once(server, 'close');
|
||||
- return port;
|
||||
-}
|
||||
+async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh
|
||||
index 114994936..4daf0d143 100755
|
||||
--- a/scripts/test-discord.sh
|
||||
+++ b/scripts/test-discord.sh
|
||||
@@ -73,0 +74,2 @@ TOOLS_JSON="{\"roots\":[{\"name\":\"docs\",\"path\":\"$SANDBOX/toolroot\"}],\"ma
|
||||
+export PI_CODING_AGENT_DIR="$SANDBOX/pi-agent"
|
||||
+mkdir -p "$PI_CODING_AGENT_DIR"
|
||||
@@ -145 +147,8 @@ fi
|
||||
-node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
|
||||
+node_tests() {
|
||||
+ local lane_concurrency=()
|
||||
+ if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
+ [ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
|
||||
+ lane_concurrency=(--test-concurrency=2)
|
||||
+ fi
|
||||
+ env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
|
||||
+}
|
||||
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"base": "c771a36f4bd0b6c359282425c668996a90072f93",
|
||||
"priorCandidate": "a3b7cbde8ef51c206a83769a7c272e4f9a1de5bc",
|
||||
"originalCandidate": "6318f0d0bba9ee90ccaa3503cac8c2e04c6397f2ef6c3564f687fbc2092989e3",
|
||||
"overlap": [
|
||||
"packages/cli/tests/stack-release.test.mjs"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"path": "packages/cli/tests/fixtures/gate-lane-child.mjs",
|
||||
"r1Sha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"priorCandidateSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"newSha256": "209682b968e929da4b46dc97c64fcd7aa68c490f3c6be44595f5d85f8d2e1483",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-fixtures.test.mjs",
|
||||
"r1Sha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"priorCandidateSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"newSha256": "29afa181804a724c695678eb9459c156e00f1084647c704e814c5bee156c0c25",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-image.test.mjs",
|
||||
"r1Sha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"priorCandidateSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"newSha256": "0253621a49c2e77ffcdbf32beee7e1f773ec2c52c8487dcc5aa254e61807af7c",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane-state.test.mjs",
|
||||
"r1Sha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"priorCandidateSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"newSha256": "948d62c76447d8269f03518562a261564c202ec9207461fb21d2b1c11c9bb9fc",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/gate-lane.test.mjs",
|
||||
"r1Sha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"priorCandidateSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"newSha256": "240bf0c15025ad6f88b251917a3c8d72bd21c2e5df6771e1ceb83a1d90b24a51",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/cli/tests/stack-release.test.mjs",
|
||||
"r1Sha256": "8ca733a600d5f5534d7e14d8533e1b16d710ebd43080ef8421238b5dd145fade",
|
||||
"priorCandidateSha256": "b95c0c125b6a283961e24adc07d43150bcbd05d2f11005180b836043015c52a6",
|
||||
"newSha256": "071ea2803b8c7e0450bb4c86ac9b49ae7c180af1599dca52bfce6d0274ca205b",
|
||||
"unchangedFromPrior": false,
|
||||
"overlaps234": true,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/control-board/tests/serve.test.mjs",
|
||||
"r1Sha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"priorCandidateSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"newSha256": "4838cb4609da28fc921370c5542a82402fbc9ceb469692a36f0eafc713bb56c8",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/claude-session.test.mjs",
|
||||
"r1Sha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"priorCandidateSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"newSha256": "0a09b0255e9144a039428eb8e9f56181f94ccb2d187e474ea13392b485fdd008",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/harness/tests/pi-session.test.mjs",
|
||||
"r1Sha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"priorCandidateSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"newSha256": "6db5c3b7063baf933061e91e8bdf66740717dbb3066be3b70ac779fb17022094",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/preview.test.mjs",
|
||||
"r1Sha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"priorCandidateSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"newSha256": "ada49ec100d75b3e739263593164b2798c378f3df0f38d73d80cf011ef16e234",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/proof-cli.test.mjs",
|
||||
"r1Sha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"priorCandidateSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"newSha256": "d3cec62f6eb8e2c1e0747f2a1bf1fc3f1a2122e45870d5a416d1830d3b6182cb",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/serve.test.mjs",
|
||||
"r1Sha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"priorCandidateSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"newSha256": "36ecdfcb3d0d9a2411eec751421f770b5bb41ec2fbf9236c5794fb241fd3196b",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/service.test.mjs",
|
||||
"r1Sha256": "c915239f0c223209baf84c96ec9f5ae2845fce7dd98ad46b073a96033aa7d6df",
|
||||
"priorCandidateSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
|
||||
"newSha256": "845313239547ddbc478090b29f96385f0b8853e5087c572393be328e7075f578",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "packages/webui/tests/tester-cli.test.mjs",
|
||||
"r1Sha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"priorCandidateSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"newSha256": "8bf4491f7982399b4162cfbe36d0b68888e99ceb2cbaefefc2361732dd95e0c8",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-discord.sh",
|
||||
"r1Sha256": "c10af53b0717344026cb22462964f253c7401c5082ac6ef3722a89d9dfa5c661",
|
||||
"priorCandidateSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
|
||||
"newSha256": "a5dcf783c51a5d5d184a357f91ebf9fa1c4e054f0cb84a3d1d7668f6156e025e",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-foundation.sh",
|
||||
"r1Sha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"priorCandidateSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"newSha256": "2e11038e7f784ec4f539eb768f63bab0fab9c9940334388a1fe2d47ab9e2b008",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/test-queue.sh",
|
||||
"r1Sha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"priorCandidateSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"newSha256": "c270f181d1fa8b6d42ff4131c339beba786a8fd620bb66f8afdcec05e8b10868",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/README.md",
|
||||
"r1Sha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"priorCandidateSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"newSha256": "845db994e648f29f10efb5e6a4ddcf4be4e28798fc27f1eddf9d150f0e8b5319",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.mjs",
|
||||
"r1Sha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"priorCandidateSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"newSha256": "2e5decf42a546b1441c199337c9a3162693051f2299878f617dd039d6231f226",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/gate-lane.sh",
|
||||
"r1Sha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"priorCandidateSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"newSha256": "41804d964c55dd07b6224855aae9dc44037f4e80c515c10f98fe04d6d3fa0084",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o755"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-docker.mjs",
|
||||
"r1Sha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"priorCandidateSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"newSha256": "fbe54165439923a852f64d69a6188ef29d237755289e9e21ddbfdab00e9ef933",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-evidence.mjs",
|
||||
"r1Sha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"priorCandidateSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"newSha256": "51d4dcff4ab01730b923e0a59c218f9a3d33e6ba729be1def03703bc457fb37e",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-fixtures.mjs",
|
||||
"r1Sha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"priorCandidateSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"newSha256": "8634070d68bca694c8a58911eda9576991ed0eca1eb055a2c1bbf10261305939",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-image.mjs",
|
||||
"r1Sha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"priorCandidateSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"newSha256": "2259045913b4e3042b7afd3ff6a0fb9c2cc9b4cf359f943e4dce9b8658b91982",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
},
|
||||
{
|
||||
"path": "scripts/testing/lane-state.mjs",
|
||||
"r1Sha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"priorCandidateSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"newSha256": "c37b3d5ec3025948dd8150d18d4ccc4eeb3539739ca37e4d86edede5bcafd1a8",
|
||||
"unchangedFromPrior": true,
|
||||
"overlaps234": false,
|
||||
"mode": "0o644"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
Compared 57f59e0c3 to c771a36f4bd0b6c359282425c668996a90072f93
|
||||
agents/gizmoduck/work/queue-234/BUILD.md
|
||||
docs/SESSIONS.md
|
||||
packages/bus/README.md
|
||||
packages/bus/schema.sql
|
||||
packages/bus/src/broker.mjs
|
||||
packages/bus/src/credentials.mjs
|
||||
packages/bus/src/host-grant.mjs
|
||||
packages/bus/src/host-run.mjs
|
||||
packages/bus/src/migrations/3i-3j.sql
|
||||
packages/bus/src/runtime.mjs
|
||||
packages/bus/src/server.mjs
|
||||
packages/bus/src/store.mjs
|
||||
packages/bus/tests/account-migration.test.mjs
|
||||
packages/bus/tests/chief-of-staff.test.mjs
|
||||
packages/bus/tests/comment-migration.test.mjs
|
||||
packages/bus/tests/fixtures/schema-3i.sql
|
||||
packages/bus/tests/grant-enroll.test.mjs
|
||||
packages/bus/tests/grant-migration.test.mjs
|
||||
packages/bus/tests/hard-waits.test.mjs
|
||||
packages/bus/tests/host-fixtures.mjs
|
||||
packages/bus/tests/host-run.test.mjs
|
||||
packages/bus/tests/host-tools.test.mjs
|
||||
packages/bus/tests/name-migration.test.mjs
|
||||
packages/bus/tests/project-runtime.test.mjs
|
||||
packages/bus/tests/schema.test.mjs
|
||||
packages/bus/tests/spec-fixtures.mjs
|
||||
packages/bus/tests/spec-migration.test.mjs
|
||||
packages/bus/tests/thread-migration.test.mjs
|
||||
packages/bus/tests/usage-migration.test.mjs
|
||||
packages/business/src/business.mjs
|
||||
packages/business/src/hosts.mjs
|
||||
packages/business/tests/hosts.test.mjs
|
||||
packages/cli/README.md
|
||||
packages/cli/src/cli.mjs
|
||||
packages/cli/src/hosts.mjs
|
||||
packages/cli/tests/hosts.test.mjs
|
||||
packages/cli/tests/stack-release.test.mjs
|
||||
scripts/mosaic
|
||||
tools/host/mosaic-keys
|
||||
tools/host/mosaic-keys-write
|
||||
tools/host/mosaic-keys.sshd.conf
|
||||
tools/host/mosaic-keys.sudoers
|
||||
|
||||
Overlap with row 197 source manifest:
|
||||
packages/cli/tests/stack-release.test.mjs
|
||||
Other 24 source paths: no overlap.
|
||||
@@ -0,0 +1,10 @@
|
||||
diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs
|
||||
index daae6efb7..910785bc4 100644
|
||||
--- a/packages/cli/tests/stack-release.test.mjs
|
||||
+++ b/packages/cli/tests/stack-release.test.mjs
|
||||
@@ -0,0 +1,2 @@
|
||||
+import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
+import { after as laneAfter } from 'node:test';
|
||||
@@ -1779 +1781 @@ const TESTER='7499';
|
||||
-const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
+const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
||||
@@ -1403,8 +1403,3 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-21T06:37:41Z | Darkwing (Opus 5.5, 7525c8e4) | row 240 I4 build, slot run and review request (sage-1759, sage-1773, sage-1788, sage-1795) | r1 on 47ec9fe2f stopped at singles (stack-upgrade 20/1: the N1 test's own after hook threw ENOENT once the fixture removed the root), b45852daa drops it; r2 green: singles 37/0, M1-M8 8/8 killed, cli 639/0, scripts 585/0; darkwing/row240-r3 pushed at ad1b23ab2 on c771a36f4, manifest 573ece95 (9 paths), in-review revs 2694 and 2695, request comment 29943 on #1746, Filbert woken | in review
|
||||
2026-10-21T06:41:13Z | Filbert (Opus 5.5, 048f3c63) | row 240 (I4, #1746) round 1 review on manifest 573ece95 (ad1b23ab2) | approve, Gitea 29945, rev 2696; 233 carries N1/N4/N5 present; notes N1 boot unit Before= names one business, N2 doc-only, N3 info
|
||||
2026-10-21T07:15:48Z | Darkwing (Opus 5.5, 7525c8e4) | row 195 (#1693) DESIGN r7, Case 1 install on .53 after the installer split | darkwing/row195-r7 5425ea2f3 pushed (r6 carried as 7398b7dd7 on c515273db), DESIGN.md blob 17dfbad95ba5; business id mosaic-stack named in §7, so DEFERRED 240 N1 doesn't apply (sage-1813); root steps left to ops-01, nothing run on .53 (sage-1809); Duckworth review requested 07:14Z, Gitea 29960; row 226 items (dev.args writer, adopt) sent to Gosalyn and copied to Sage per sage-1814; r7 :21 and :326 still say 226 is unclaimed, to fix next revision
|
||||
2026-10-21T07:29:11Z | Sage (Opus 5.5, 1ef1e4f8) | row 240 landing gate (cli 639/0, scripts 585/0) and landing c515273db with DEFERRED da3051ccc; 238 r3/r4 and 228 r3 requests posted as jarvis; combined 228+238 gate (seat 165/0, cli 640/0, scripts 585/0, 238 mutant killed), landings 86249ac55 and d49b1f2bd, DEFERRED e7078e6a8; #1746, #1729, #1744 closed; 197 r2 request posted (29961), r5 rebuild on 238; 195 after 226, Duckworth r7 note posted (29962); slots Herb 197, Gosalyn 212 (sage-1788..1826) | landed
|
||||
2026-10-21T08:29:39Z | Filbert (Opus 5.5, 048f3c63) | row 215 (C2-04) manifest review, herb/row215 6a508804f | CHANGES REQUESTED, static: B1 cli config.mjs:61 trackerFor projectFilePath skip, B2 refuse publish naming a library entry, B3 sync-to-read window in the shared state contract; N1-N3; sent to Herb and Sage 08:29Z
|
||||
2026-10-21T08:37:47Z | Filbert (Opus 5.5, 048f3c63) | row 215 (C2-04) manifest r2 review, herb/row215 2522025af | APPROVE, static: B1-B3 and N1-N2 resolved per sage-1860/1862; build notes on lock file creation, mirror metadata allowlist and flock dependency; sent to Herb and Sage 08:37Z
|
||||
2026-10-21T08:52:06Z | Darkwing (Opus 5.5, 7525c8e4) | row 195 (#1693) build and manifest round 1 in review (rev 2756, request comment 29985, results 29987) | darkwing/row195-b 37171f65b on 4ced5e63d (ddf7dce6c build: scripts/case1-accept.sh, case1-accept.test.mjs, TOOLS.md entry, DESIGN r7 doc fixes per sage-1817; 37171f65b BUILD.md results); slot: single 22/22 (sage-1829), 12/12 mutants killed and gate cli 662/0 plus scripts 429/0 selftests (sage-1865), green per sage-1870; Duckworth reviews (sage-1872); Gosalyn 0087 Q4/Q5 match W10/W12; flock goes to DEFERRED with 215 (sage-1871); nothing touched .53
|
||||
2026-10-21T09:20:46Z | Sage (Opus 5.5, 1ef1e4f8) | 212+197 combined landing gate (node 981/0, selftests 429/0, 08:51-09:03Z); 197 lane proof stopped after two failures (try 1 lane-image base inspect, N7; try 3 solo-a process.test.mjs:82 under umask 077), recovered cf48efccf083; mos-1162: row 212 landed alone as ee7b2f137 (paths match a2c72ac95, patch-id ea84d3e35470), #1713 closed 29996, revs 2759-2760; DEFERRED N7 plus brief docs/plans/2026-10-21_gate-env-rows.md at fefd54e13, rows 243 (chmod) and 244 (fixture PI_AUTH_FILE) revs 2761-2762; row 66 rebuilt as sage/row66-r4b 49ad61963 on fefd54e13 (manifest 99cab8275af4, not yet requested); slots: Herb 215 focused 26/0, Gosalyn 231 singles 60/0, Herb 197 r6 focused granted; kept root ga-8I3iZc removed (sage-1827..1893) | in progress
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Gate environment rows (2026-10-21)
|
||||
|
||||
Sage, from the row 197 lane proof that stopped on 2026-10-21 (sage-1881, mos-1162). Two small rows.
|
||||
Each lands after row 197.
|
||||
|
||||
## The startup token refusal test sets its file mode
|
||||
|
||||
`packages/bus/tests/process.test.mjs:76` writes the "too open" token file with
|
||||
`writeFileSync(file, secret, { mode: 0o644 })`, and `:82` expects the broker to refuse it.
|
||||
`writeFileSync` applies the process umask to that mode. Under `umask 077` the file is 0600,
|
||||
`packages/bus/src/credentials.mjs:205` accepts it, and the test fails. That happened in the row 197 lane
|
||||
proof, where `scripts/testing/gate-lane.sh:3` set 077 for the test steps.
|
||||
|
||||
- Change: the test calls `chmodSync(file, 0o644)` after the write, so its result no longer depends on the
|
||||
umask.
|
||||
- Sweep: look in `packages/*/tests` for other tests that expect a refusal of a too-open mode set
|
||||
through `writeFileSync` or `mkdirSync`, and fix those the same way. List what the sweep found in
|
||||
BUILD.md, including when it found none.
|
||||
- Test: run the file whole twice, once under `umask 022` and once under `umask 077`. Both runs pass.
|
||||
- Order: Mos ruled (mos-1162) that this lands after row 197 r6. Then r6's proof runs on the unchanged
|
||||
test, so it shows the umask fix itself works.
|
||||
|
||||
## Gate runs use a fixture pi auth file
|
||||
|
||||
The `docs/plans/DEFERRED.md` entry "Gate runs mount the host's pi auth file" (059577886, Webby N4 on the
|
||||
row 197 proof driver, traced by Herb in herb-0051) gets its own row here.
|
||||
|
||||
- Change: every gate step that reaches Compose gets a fixture `PI_AUTH_FILE`. That covers the
|
||||
`test-task.sh` cases that call `scripts/run-task.sh`, `test-release.sh` through `release.sh`
|
||||
`health_check`, and row 197's lane `cleanEnv` in `scripts/testing/gate-lane.mjs`. Either the suites
|
||||
or the lane's `cleanEnv` can set it.
|
||||
- Test: no gate step mounts the host default `/home/jwoltje/.pi/agent/auth.json`. The test reads
|
||||
the Compose arguments or the environment that each step passes. It does not read the host file.
|
||||
- Not in scope: `compose.yaml`'s default for real runs stays as it is.
|
||||
- At landing, the DEFERRED entry moves to Done with the landing commit.
|
||||
@@ -270,36 +270,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
(`:1538`, `:1689`, `:1691`). For a later row that edits
|
||||
`stack-release.sh`. Webby N1 on row 238 r1 (REVIEW-r1.md, 433a95292,
|
||||
#1744). (2026-10-21)
|
||||
- **Gate runs mount the host's pi auth file.** `compose.yaml:43` mounts
|
||||
`${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only into
|
||||
the agent container. No suite sets `PI_AUTH_FILE`, so the
|
||||
`test-task.sh` cases that call `scripts/run-task.sh`, and
|
||||
`test-release.sh` through `release.sh` `health_check` (`:41-54`), run
|
||||
Compose with the host file. Row 197's lane `cleanEnv`
|
||||
(`scripts/testing/gate-lane.mjs:109`) keeps the default too: it neither
|
||||
sets nor passes `PI_AUTH_FILE`. The mount is read-only, and the legacy
|
||||
gate already worked this way before row 197. `test-auth.sh` uses its
|
||||
own fixtures but calls `auth.sh`, not Compose. The fix: a fixture
|
||||
`PI_AUTH_FILE` for the Compose-reaching cases, set by the suites or by
|
||||
the lane's `cleanEnv`, and a test that no gate step mounts the host
|
||||
default. It needs its own row. Webby N4 on the row 197 proof driver
|
||||
(bba870ee9), traced by Herb (herb-0051). (2026-10-21)
|
||||
- **Lane image prep inspects a base that BuildKit never tagged.**
|
||||
(N7 of the row 197 proof.) After the Compose build,
|
||||
`scripts/testing/lane-image.mjs:41` (`prepareImage`) runs
|
||||
`docker image inspect node:24-bookworm-slim` to put the base digest
|
||||
in the receipt. BuildKit resolves the base from the registry
|
||||
without tagging it in the local store, so on a host without the tag
|
||||
the inspect throws "No such image" after a good build. That
|
||||
happened in Sage's proof try 1 (2026-10-21 09:03Z, run cf48efccf083).
|
||||
The throw fails closed: the run's terminal record is unclean, both
|
||||
lanes quarantine, and `gate-lane.sh recover` clears them. Pulling the
|
||||
base first fixes it, and the receipt digest then matches what the
|
||||
build used (91acba91 both ways). The fix: before the build, inspect
|
||||
the base and refuse with a named reason if it's missing, or pull it.
|
||||
After the build, refuse if the base digest changed. Add a test with a
|
||||
fake Docker that has no base tag. Kept out of row 197 r6 by Sage
|
||||
(sage-1883). Mos asked for this record in mos-1162. (2026-10-21)
|
||||
|
||||
## Queue
|
||||
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// Controlled child for lane crash tests; no live service, network or model.
|
||||
import { writeFileSync } from 'node:fs';
|
||||
const [mode,marker]=process.argv.slice(2);
|
||||
if(marker&&mode!=='lock-probe')writeFileSync(marker,String(process.pid));
|
||||
if(mode!=='lock-probe')process.stdout.write('READY\n');
|
||||
if(mode==='ignore-term')process.on('SIGTERM',()=>{});
|
||||
else process.on('SIGTERM',()=>process.exit(0));
|
||||
setInterval(()=>{},1000);
|
||||
if(mode==='lock-probe'){
|
||||
const stateRoot=marker;
|
||||
const {verifyLocks,executeSteps}=await import('../../../../scripts/testing/gate-lane.mjs');
|
||||
verifyLocks({lane:'a',stateRoot,exclusive:false});
|
||||
const {appendRecord,identity}=await import('../../../../scripts/testing/lane-state.mjs');
|
||||
const {mkdirSync}=await import('node:fs');const {join}=await import('node:path');
|
||||
const out=join(stateRoot,'out');mkdirSync(out);
|
||||
appendRecord(join(stateRoot,'runs','abcdef123456'),{kind:'start',lane:'a',owner:identity()});
|
||||
const result=await executeSteps([{name:'fds',cwd:stateRoot,argv:[process.execPath,'-e',"const fs=require('fs'); for(const fd of fs.readdirSync('/proc/self/fd')){let p;try{p=fs.readlinkSync('/proc/self/fd/'+fd)}catch{continue}if(p.endsWith('/a.lock')||p.endsWith('/image.lock'))process.exit(7)}"]}],{out,env:process.env,record:()=>{}});
|
||||
if(result.rc)process.exit(result.rc);
|
||||
writeFileSync(join(stateRoot,'ready'),'verified');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import { mkdtempSync,rmSync,mkdirSync,writeFileSync } from 'node:fs';import { join } from 'node:path';import { createServer } from 'node:net';import { once } from 'node:events';
|
||||
const api=await import('../../../scripts/testing/lane-fixtures.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
const evidence=await import('../../../scripts/testing/lane-evidence.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
test('parallel lease holders use only the active lane and its shared state',async t=>{
|
||||
const ctx=leaseContext(t),lane=ctx.lane;
|
||||
const [a,b]=await Promise.all([api.leasePort(ctx.fixture,ctx),api.leasePort(ctx.fixture,ctx)]);
|
||||
const bands=BANDS,[lo,hi]=bands[lane];
|
||||
assert.ok(a>=lo&&a<=hi);assert.ok(b>=lo&&b<=hi);assert.notEqual(a,b);
|
||||
assert.ok(bands.a[1]<bands.b[0]);assert.equal(bands.a[1]-bands.a[0],127);assert.equal(bands.b[1]-bands.b[0],127);
|
||||
});
|
||||
test('unrelated home changes do not become lane writes, and own-root references do',()=>{
|
||||
assert.equal(typeof evidence.attributeEntries,'function','attribution is required');
|
||||
const result=evidence.attributeEntries([{path:'/home/person/.claude/projects/other-session',link:null},{path:'/home/person/.cache/lane-abcdef123456',link:null},{path:'/home/person/.pi/item',link:'/mnt/storage/scratch/gt-ABCDEF/work'}],{nonce:'abcdef123456',tempRoot:'/mnt/storage/scratch/gt-ABCDEF',fixturePaths:[]});
|
||||
assert.equal(result.owned.length,2);assert.equal(result.unrelated.length,1);
|
||||
});
|
||||
test('proof summaries refuse missing timings, non-overlap and a pair without a lane swap',()=>{
|
||||
const solos=[{candidate:'one',wallMs:100},{candidate:'two',wallMs:100}];
|
||||
const make=(lane,candidate,startMs=0,endMs=120)=>({lane,candidate,startMs,endMs,rc:0,clean:true});
|
||||
const pairs=[[make('a','one'),make('b','two',5,125)],[make('b','one'),make('a','two',5,125)],[make('a','one'),make('b','two',5,125)]];
|
||||
const result=evidence.summarizePairs(solos,pairs);assert.equal(result.length,3);assert.equal(result[0].makespanMs,125);assert.equal(result[0].runs[0].slowdown,1.2);assert.equal(result[0].throughput,1.6);
|
||||
const bad=structuredClone(pairs);delete bad[0][0].endMs;assert.throws(()=>evidence.summarizePairs(solos,bad),/timing/);
|
||||
const noSwap=structuredClone(pairs);noSwap[1]=structuredClone(pairs[0]);assert.throws(()=>evidence.summarizePairs(solos,noSwap),/swap/);
|
||||
const serial=structuredClone(pairs);serial[0][1].startMs=130;serial[0][1].endMs=250;assert.throws(()=>evidence.summarizePairs(solos,serial),/overlap/);
|
||||
});
|
||||
import { BANDS } from '../../../scripts/testing/lane-state.mjs';
|
||||
import { spawn,spawnSync } from 'node:child_process';
|
||||
import { readFileSync,existsSync } from 'node:fs';
|
||||
function leaseContext(t){
|
||||
const lane=process.env.MOSAIC_GATE_LANE??'a',stateRoot=process.env.MOSAIC_GATE_STATE??'/mnt/storage/scratch/mosaic-gate-lanes',callbacks=[];
|
||||
mkdirSync(stateRoot,{recursive:true,mode:0o700});
|
||||
t.after(async()=>{for(const f of callbacks.reverse())await f();});
|
||||
return {lane,stateRoot,fixture:{after:f=>callbacks.push(f)},cleanup:f=>callbacks.push(f)};
|
||||
}
|
||||
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function waitFor(check){for(let i=0;i<100;i++){if(check())return;await delay(20);}throw Error('fixture not ready');}
|
||||
test('an exhausted single-port band refuses, and an occupied foreign listener survives the probe',async t=>{
|
||||
const ctx=leaseContext(t),callbacks=[];
|
||||
const port=await api.leasePort({after:f=>callbacks.push(f)},ctx),original=BANDS[ctx.lane];
|
||||
const server=createServer();
|
||||
try{
|
||||
BANDS[ctx.lane]=[port,port];
|
||||
await assert.rejects(api.leasePort(t,ctx),/exhausted/);
|
||||
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',resolve);});
|
||||
// Release only our lock. The sentinel listener remains foreign to the next lease.
|
||||
await callbacks.pop()();
|
||||
await assert.rejects(api.leasePort(t,ctx),/exhausted/);assert.equal(server.listening,true);
|
||||
}finally{BANDS[ctx.lane]=original;if(server.listening)await new Promise(r=>server.close(r));for(const f of callbacks)await f();}
|
||||
});
|
||||
test('parallel child allocators share real lane locks; a lease remains held through child shutdown',async t=>{
|
||||
const ctx=leaseContext(t),root=mkdtempSync('/mnt/storage/scratch/gf-');
|
||||
const modulePath=new URL('../../../scripts/testing/lane-fixtures.mjs',import.meta.url).href;
|
||||
const code=`import test from 'node:test';import fs from 'node:fs';const {leasePort}=await import(${JSON.stringify(modulePath)});test('lease held until fixture child finishes',async t=>{const port=await leasePort(t,JSON.parse(process.env.LEASE_CONTEXT));fs.writeFileSync(process.env.LEASE_PORT_FILE,String(port));await new Promise(r=>{const timer=setInterval(()=>{if(fs.existsSync(process.env.LEASE_STOP_FILE)){clearInterval(timer);r()}},5)});});`;
|
||||
const start=number=>{const file=join(root,`lease-${number}.test.mjs`),portFile=join(root,`port-${number}`),stopFile=join(root,`stop-${number}`);writeFileSync(file,code);const env={...process.env,LEASE_CONTEXT:JSON.stringify(ctx),LEASE_PORT_FILE:portFile,LEASE_STOP_FILE:stopFile};delete env.NODE_TEST_CONTEXT;const child=spawn(process.execPath,['--test','--test-concurrency=2','--test-reporter=tap',file],{env,stdio:['ignore','pipe','pipe']});child.output='';child.stderr.on('data',b=>child.output+=b);child.stdout.on('data',b=>child.output+=b);child.done=new Promise(r=>child.once('exit',code=>{child.exitedCode=code;r(code);}));child.portFile=portFile;child.stopFile=stopFile;return child;};
|
||||
const children=[start(1),start(2)];ctx.cleanup(async()=>{try{for(const c of children)writeFileSync(c.stopFile,'stop');await Promise.all(children.map(c=>c.done));}finally{rmSync(root,{recursive:true,force:true});}});
|
||||
await waitFor(()=>{for(const c of children)if(c.exitedCode!==undefined)throw Error(`allocator exited ${c.exitedCode}: ${c.output}`);return children.every(c=>existsSync(c.portFile));});
|
||||
const ports=children.map(c=>Number(readFileSync(c.portFile,'utf8')));assert.notEqual(...ports);
|
||||
const lock=join(ctx.stateRoot,'ports',ctx.lane,`${ports[0]}.lock`);
|
||||
assert.equal(spawnSync('flock',['-n',lock,'true']).status,1);
|
||||
writeFileSync(children[0].stopFile,'stop');assert.equal(await children[0].done,0,children[0].output);
|
||||
assert.equal(spawnSync('flock',['-n',lock,'true']).status,0);
|
||||
assert.equal(spawnSync('flock',['-n',join(ctx.stateRoot,'ports',ctx.lane,`${ports[1]}.lock`),'true']).status,1);
|
||||
});
|
||||
test('abrupt allocator death releases its holder without touching a foreign lease',async t=>{
|
||||
const ctx=leaseContext(t),foreign=await api.leasePort(ctx.fixture,ctx),modulePath=new URL('../../../scripts/testing/lane-fixtures.mjs',import.meta.url).href;
|
||||
const code=`const {leasePort}=await import(${JSON.stringify(modulePath)});const port=await leasePort({after:()=>{}},JSON.parse(process.argv[1]));console.log(port);setInterval(()=>{},1000);`;
|
||||
const child=spawn(process.execPath,['--input-type=module','-e',code,JSON.stringify(ctx)],{stdio:['ignore','pipe','pipe']});const done=new Promise(r=>child.once('exit',r));ctx.cleanup(async()=>{child.kill('SIGKILL');await done;});
|
||||
const port=await new Promise(r=>child.stdout.once('data',b=>r(Number(String(b).trim()))));assert.notEqual(port,foreign);
|
||||
child.kill('SIGKILL');await done;
|
||||
const lock=join(ctx.stateRoot,'ports',ctx.lane,`${port}.lock`);
|
||||
await waitFor(()=>spawnSync('flock',['-n',lock,'true']).status===0);
|
||||
assert.equal(spawnSync('flock',['-n',join(ctx.stateRoot,'ports',ctx.lane,`${foreign}.lock`),'true']).status,1);
|
||||
});
|
||||
test('fixture caches survive a replacement environment with a separate tmp subdirectory',async t=>{
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gf-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const env=api.fixtureCacheEnv(root);assert.equal(env.TMPDIR,join(root,'tmp'));assert.equal(env.JITI_FS_CACHE,join(root,'jiti'));assert.equal(env.XDG_CACHE_HOME,join(root,'cache'));
|
||||
const code="const fs=require('fs'),p=require('path');for(const k of ['TMPDIR','JITI_FS_CACHE','XDG_CACHE_HOME'])fs.writeFileSync(p.join(process.env[k],k),'fixture');if(process.env.HOME)process.exit(7)";
|
||||
const result=spawnSync(process.execPath,['-e',code],{env,encoding:'utf8'});assert.equal(result.status,0,result.stderr);
|
||||
for(const key of Object.keys(env))assert.equal(readFileSync(join(env[key],key),'utf8'),'fixture');
|
||||
});
|
||||
test('an unrelated session can append while a supervised step runs without becoming an attributed write',async t=>{
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gf-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const home=join(root,'home'),out=join(root,'out'),file=join(home,'.claude','other-session');mkdirSync(join(home,'.claude'),{recursive:true});mkdirSync(out);writeFileSync(file,'before');
|
||||
const before=evidence.homeMetadata(home),prior=new Map(before.map(e=>[e.path,JSON.stringify(e)]));
|
||||
const {executeSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
const barrier=join(root,'step-running');
|
||||
const append=spawn(process.execPath,['-e',"const fs=require('fs');const deadline=setTimeout(()=>{console.error('append barrier timed out');process.exit(8)},5000);const timer=setInterval(()=>{if(fs.existsSync(process.argv[2])){clearInterval(timer);clearTimeout(deadline);fs.appendFileSync(process.argv[1],'unrelated append')}},5)",file,barrier],{stdio:'ignore'});const appended=new Promise(r=>append.once('exit',r));t.after(()=>append.kill('SIGKILL'));
|
||||
let appendedDuringStep=false;
|
||||
const stepCode="const fs=require('fs');fs.writeFileSync(process.argv[1],'running');const deadline=setTimeout(()=>{console.error('session append timed out');process.exit(8)},5000);const timer=setInterval(()=>{if(fs.readFileSync(process.argv[2],'utf8').includes('unrelated append')){clearInterval(timer);clearTimeout(deadline)}},5)";
|
||||
const result=await executeSteps([{name:'wait',cwd:root,argv:[process.execPath,'-e',stepCode,barrier,file]}],{out,env:process.env,record:r=>{if(r.kind==='step')appendedDuringStep=readFileSync(file,'utf8').includes('unrelated append');}});
|
||||
assert.equal(await appended,0);assert.equal(result.rc,0);assert.equal(appendedDuringStep,true);
|
||||
const changed=evidence.homeMetadata(home).filter(e=>prior.get(e.path)!==JSON.stringify(e));assert.ok(changed.some(e=>e.path===file));
|
||||
const attributed=evidence.attributeEntries(changed,{nonce:'abcdef123456',tempRoot:join(root,'temp'),fixturePaths:[]});assert.equal(attributed.owned.length,0);assert.ok(attributed.unrelated.length>0);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';
|
||||
import { mkdtempSync,rmSync,writeFileSync,mkdirSync,cpSync } from 'node:fs';import { join } from 'node:path';
|
||||
const image=await import('../../../scripts/testing/lane-image.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
const docker=await import('../../../scripts/testing/lane-docker.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND')throw e;return {};});
|
||||
test('a changed COPY input or actual image ID refuses compatible image sharing',t=>{
|
||||
assert.equal(typeof image.imageFingerprint,'function','image fingerprint required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');
|
||||
for(const d of ['contracts','src','adapters']){mkdirSync(join(root,d));writeFileSync(join(root,d,'one'),'same');}
|
||||
const digest=image.imageFingerprint(root),id='sha256:'+'a'.repeat(64);const receipt={inputDigest:digest,imageId:id,tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
const inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
|
||||
assert.equal(image.verifyImage(receipt,root,inspect).imageId,id);
|
||||
assert.throws(()=>image.verifyImage(receipt,root,()=>({...inspect(),Id:'sha256:'+'c'.repeat(64)})),/image ID/);
|
||||
writeFileSync(join(root,'adapters','one'),'different');assert.throws(()=>image.verifyImage(receipt,root,inspect),/inputs/);
|
||||
});
|
||||
test('warm Docker guard disables implicit build and pull and refuses image mutation',()=>{
|
||||
assert.equal(typeof docker.guardDocker,'function','Docker guard required');
|
||||
const run={project:'mg197-a-abc',tag:'mosaic-poc-agent:fixture'};
|
||||
assert.deepEqual(docker.guardDocker(['compose','run','--rm','-T','mosaic-agent','prompt'],run),['compose','run','--no-build','--pull','never','--rm','-T','mosaic-agent','prompt']);
|
||||
for(const args of [['build','.'],['image','prune'],['compose','build'],['tag','a','b'],['network','rm','foreign']])assert.throws(()=>docker.guardDocker(args,run),/refused/);
|
||||
});
|
||||
test('exclusive image preparation records actual image ID and build wall time and requires a grant',t=>{
|
||||
assert.equal(typeof image.prepareImage,'function','prepareImage is required');
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const calls=[];const invoke=(args)=>{calls.push(args);if(args[0]==='compose')return '';return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:'sha256:'+'a'.repeat(64),Os:'linux',Architecture:'amd64'});};
|
||||
assert.throws(()=>image.prepareImage(root,{grant:'',tempRoot:root},invoke),/grant/);
|
||||
const result=image.prepareImage(root,{grant:'fixture',tempRoot:root},invoke);assert.equal(result.imageId,'sha256:'+'a'.repeat(64));assert.equal(result.builds,1);assert.ok(result.buildWallMs>=0);assert.ok(calls.some(x=>x.join(' ')==='compose build'));
|
||||
});
|
||||
test('cleanup refuses a foreign Docker label and removes only an exact owned project',()=>{
|
||||
const project='mg197-a-abcdef123456',id='123456789abc';let removed=false,label='foreign';
|
||||
const fake=(args)=>{if(args[0]==='ps')return removed?'':id;if(args[0]==='network')return '';if(args[0]==='container'&&args[1]==='inspect')return label;if(args[0]==='container'&&args[1]==='rm'){removed=true;return '';}throw Error('unexpected command');};
|
||||
// The injectable executor exercises ownership checks without touching Docker.
|
||||
assert.throws(()=>docker.cleanupProject({project},fake),/foreign/);assert.equal(removed,false);
|
||||
label=project;assert.deepEqual(docker.cleanupProject({project},fake).removed,[{kind:'container',id}]);assert.equal(removed,true);
|
||||
});
|
||||
function imageFixture(t){
|
||||
const root=mkdtempSync('/mnt/storage/scratch/gi-');t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
for(const f of ['Containerfile','package.json','package-lock.json'])cpSync(new URL('../../../'+f,import.meta.url),join(root,f));
|
||||
writeFileSync(join(root,'RELEASE'),'0.0.1\n');for(const d of ['contracts','src','adapters'])mkdirSync(join(root,d));
|
||||
const receipt={inputDigest:image.imageFingerprint(root),imageId:'sha256:'+'a'.repeat(64),tag:'mosaic-poc-agent:0.85.1-r0.0.1',baseDigest:'node@sha256:'+'b'.repeat(64),platform:'linux/amd64'};
|
||||
return {root,receipt};
|
||||
}
|
||||
test('missing receipt and missing image refuse without permitting a gate',t=>{
|
||||
const {root,receipt}=imageFixture(t);assert.throws(()=>image.verifyImage(null,root),/provenance receipt/);
|
||||
assert.throws(()=>image.verifyImage(receipt,root,()=>{throw Error('No such image');}),/No such image/);
|
||||
assert.throws(()=>image.verifyImage({...receipt,tag:'mosaic-poc-agent:wrong'},root,()=>({Id:receipt.imageId,Os:'linux',Architecture:'amd64'})),/release identity/);
|
||||
});
|
||||
test('a tag changed between supervised steps fails the next boundary and suppresses gate success',async t=>{
|
||||
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);
|
||||
let actual=receipt.imageId;const inspect=()=>({Id:actual,Os:'linux',Architecture:'amd64'}),events=[];
|
||||
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
await assert.rejects(executePinnedSteps([
|
||||
{name:'first',cwd:root,argv:[process.execPath,'-e','process.exit(0)']},
|
||||
{name:'second',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}
|
||||
],{out,env:process.env,record:r=>events.push(r)}, {receipt,checkout:root,inspect,afterStep:()=>actual='sha256:'+'c'.repeat(64)}),/image ID/);
|
||||
assert.equal(events.filter(e=>e.kind==='step').length,1);
|
||||
assert.equal(events.some(e=>e.kind==='terminal'&&e.rc===0),false);
|
||||
});
|
||||
test('mutable-base rebuilds with identical files have distinct image IDs; cold and warm counters stay separate',t=>{
|
||||
const {root}=imageFixture(t);let builds=0,id='sha256:'+'a'.repeat(64);
|
||||
const fake=args=>{if(args[0]==='compose'){builds++;return 'fixture build';}return JSON.stringify(args[2]==='node:24-bookworm-slim'?{RepoDigests:['node@sha256:'+'b'.repeat(64)]}:{Id:id,Os:'linux',Architecture:'amd64'});};
|
||||
const cold=image.prepareImage(root,{grant:'fixture-cold',tempRoot:root},fake);
|
||||
assert.equal(cold.builds,1);assert.ok(cold.buildWallMs>=0);assert.equal(builds,1);
|
||||
for(let i=0;i<3;i++)image.verifyImage(cold,root,()=>({Id:id,Os:'linux',Architecture:'amd64'}));
|
||||
assert.equal(builds,1,'warm verification builds zero images');
|
||||
id='sha256:'+'c'.repeat(64);const rebuilt=image.prepareImage(root,{grant:'fixture-rebuild',tempRoot:root},fake);
|
||||
assert.equal(rebuilt.inputDigest,cold.inputDigest);assert.equal(rebuilt.builds,1);assert.equal(builds,2);
|
||||
assert.throws(()=>image.verifyImage(cold,root,()=>({Id:rebuilt.imageId,Os:'linux',Architecture:'amd64'})),/image ID/);
|
||||
});
|
||||
test('warm pinned execution records zero builds and checks the tag again after its final step',async t=>{
|
||||
const {root,receipt}=imageFixture(t),out=join(root,'out');mkdirSync(out);let id=receipt.imageId;
|
||||
const {executePinnedSteps}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
const options={out,env:process.env,record:()=>{}},inspect=()=>({Id:id,Os:'linux',Architecture:'amd64'});
|
||||
const result=await executePinnedSteps([{name:'warm',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect});
|
||||
assert.equal(result.rc,0);assert.equal(result.imageBuilds,0);
|
||||
await assert.rejects(executePinnedSteps([{name:'last',cwd:root,argv:[process.execPath,'-e','process.exit(0)']}],options,{receipt,checkout:root,inspect,afterStep:()=>id='sha256:'+'c'.repeat(64)}),/image ID/);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import * as fsExtra from 'node:fs';
|
||||
import * as childModule from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const api=await import('../../../scripts/testing/lane-state.mjs');
|
||||
function fixture(t){const root=mkdtempSync('/mnt/storage/scratch/gs-');t.after(()=>rmSync(root,{recursive:true,force:true}));return root;}
|
||||
test('unfinished dead-owner runs quarantine their lane and a recovery record clears only that run',t=>{
|
||||
assert.equal(typeof api.appendRecord,'function','appendRecord is required');
|
||||
const root=fixture(t),dir=join(root,'runs','one');
|
||||
api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:123456789,start:'1',boot:'fixture'}});
|
||||
assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
|
||||
api.appendRecord(dir,{kind:'recovered',clean:true});
|
||||
assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');
|
||||
});
|
||||
test('a legacy landing gate beside a lane stays legacy even with the same parent',t=>{
|
||||
assert.equal(typeof api.appendRecord,'function');
|
||||
const root=fixture(t);const owner={pid:123,start:'5',boot:'b',state:'S',ppid:1,argv:['node','gate-lane.mjs']};
|
||||
api.appendRecord(join(root,'runs','one'),{kind:'start',lane:'a',owner});
|
||||
const procs=[owner,{pid:124,start:'6',boot:'b',state:'S',ppid:1,argv:['node','/fixture/landing.mjs','gate','/fixture/wt','/fixture/logs']}];
|
||||
const result=api.status(root,{processes:procs});assert.equal(result.a.state,'BUSY');assert.equal(result.b.state,'CLEAR');assert.deepEqual(result.legacy,[{pid:124,start:'6'}]);
|
||||
});
|
||||
test('the shell status entry reads scratch receipts and reports legacy processes without mutating old slots',t=>{
|
||||
const root=fixture(t);const {spawnSync}=requireChild();
|
||||
const r=spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'status','--state-root',root],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);const state=JSON.parse(r.stdout);assert.equal(state.a.state,'CLEAR');assert.equal(state.b.state,'CLEAR');assert.ok(Array.isArray(state.legacy));
|
||||
});
|
||||
function requireChild(){return childModule;}
|
||||
test('recovery supersedes an unclean terminal without rewriting either receipt',t=>{
|
||||
const root=fixture(t),dir=join(root,'runs','one');api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:99999999,start:'1',boot:'fixture'}});api.appendRecord(dir,{kind:'terminal',clean:false});api.appendRecord(dir,{kind:'recovered',clean:true});assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.records(dir).length,3);
|
||||
});
|
||||
test('the wrapper refuses a held lane lock and preserves its inode',async t=>{
|
||||
const root=fixture(t),file=join(root,'a.lock');const {spawn,spawnSync}=childModule;
|
||||
const holder=spawn('flock',['-x',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));
|
||||
const {statSync}=await import('node:fs');const ino=statSync(file).ino;
|
||||
t.after(async()=>{holder.stdin.end('\n');await exited;});
|
||||
const r=spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(r.status,2);assert.match(r.stderr,/lock busy/);assert.equal(statSync(file).ino,ino);
|
||||
});
|
||||
test('a held kernel lock without a start receipt is BUSY rather than CLEAR',async t=>{
|
||||
const root=fixture(t),file=join(root,'a.lock');const holder=childModule.spawn('flock',['-x',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));t.after(async()=>{holder.stdin.end('\n');await exited;});
|
||||
assert.equal(api.status(root,{processes:[]}).a.state,'BUSY');
|
||||
});
|
||||
test('SIGKILL leaves a quarantined run and locked recovery publishes a new clean receipt',async t=>{
|
||||
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
|
||||
const child=childModule.spawn(process.execPath,[new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname,'wait',join(temp,'started')],{stdio:['ignore','pipe','pipe']});
|
||||
const exited=new Promise(r=>child.once('exit',r));await new Promise(r=>child.stdout.once('data',r));
|
||||
const id='abcdef123456',dir=join(root,'runs',id),docker=join(root,'docker-fixture');writeFileSync(docker,'#!/bin/sh\nexit 0\n',{mode:0o700});
|
||||
api.appendRecord(dir,{kind:'start',lane:'a',owner:api.identity(child.pid),tempRoot:temp,project:`mg197-a-${id}`,docker});
|
||||
child.kill('SIGKILL');await exited;assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
|
||||
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'recover','--state-root',root,'--run-id',id,'--grant','fixture-recover'],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.records(dir).filter(e=>e.kind==='start').length,1);assert.equal(api.records(dir).filter(e=>e.kind==='recovered').length,1);
|
||||
});
|
||||
test('a malformed receipt refuses status and an identity mismatch cannot look live',t=>{
|
||||
const root=fixture(t),dir=join(root,'runs','one');api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:123,start:'old',boot:'b'}});
|
||||
assert.equal(api.status(root,{processes:[{pid:123,start:'new',boot:'b',state:'S',ppid:1,argv:['node','owned'] }]}).a.state,'QUARANTINED');
|
||||
writeFileSync(join(dir,'bad.json'),'{');assert.throws(()=>api.status(root,{processes:[]}),/invalid run receipt/);
|
||||
});
|
||||
test('a symlink lock is refused before acquiring or replacing its target',t=>{
|
||||
const root=fixture(t),target=join(root,'foreign');writeFileSync(target,'untouched');const {symlinkSync}=fsExtra;symlinkSync(target,join(root,'a.lock'));
|
||||
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(r.status,2);assert.match(r.stderr,/ordinary file/);assert.equal(readFileSync(target,'utf8'),'untouched');
|
||||
});
|
||||
test('failed recovery keeps quarantine and never publishes a clean recovery receipt',async t=>{
|
||||
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
|
||||
const id='abcdef123456',dir=join(root,'runs',id),docker=join(root,'docker-fixture');writeFileSync(docker,'#!/bin/sh\nexit 1\n',{mode:0o700});
|
||||
api.appendRecord(dir,{kind:'start',lane:'a',owner:{pid:99999999,start:'1',boot:'fixture'},tempRoot:temp,project:`mg197-a-${id}`,docker});
|
||||
const r=childModule.spawnSync('bash',[new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname,'recover','--state-root',root,'--run-id',id,'--grant','fixture-recover'],{encoding:'utf8'});
|
||||
assert.equal(r.status,2);assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');assert.equal(api.records(dir).some(e=>e.kind==='recovered'),false);
|
||||
});
|
||||
test('two shared image holders coexist, but exclusive preparation refuses their lock',async t=>{
|
||||
const root=fixture(t),file=join(root,'image.lock');const holder=childModule.spawn('flock',['-s',file,'bash','-c','echo ready; read -r done'],{stdio:['pipe','pipe','pipe']});const exited=new Promise(r=>holder.once('exit',r));await new Promise(r=>holder.stdout.once('data',r));t.after(async()=>{holder.stdin.end('\n');await exited;});
|
||||
const entry=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
|
||||
const shared=childModule.spawnSync('bash',[entry,'run','--state-root',root,'--lane','a','--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(shared.status,2);assert.doesNotMatch(shared.stderr,/lock busy|descriptor|not locked/);
|
||||
const exclusive=childModule.spawnSync('bash',[entry,'prepare-image','--state-root',root,'--grant','fixture'],{encoding:'utf8'});
|
||||
assert.equal(exclusive.status,2);assert.match(exclusive.stderr,/lock busy/);
|
||||
});
|
||||
const entry=new URL('../../../scripts/testing/gate-lane.sh',import.meta.url).pathname;
|
||||
const childFixture=new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname;
|
||||
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
||||
async function until(check){for(let n=0;n<100;n++){if(check())return;await sleep(20);}throw Error('fixture barrier did not become ready');}
|
||||
test('two simultaneous shell claims have one winner; supervisor retains lock FDs and ordinary children do not',async t=>{
|
||||
const root=fixture(t),bin=join(root,'bin');fsExtra.mkdirSync(bin);const shim=join(bin,'node');
|
||||
writeFileSync(shim,`#!/bin/sh\nif [ "$2" = --locked ]; then exec '${process.execPath}' '${childFixture}' lock-probe '${root}'; fi\nexec '${process.execPath}' "$@"\n`,{mode:0o700});
|
||||
const env={...process.env,PATH:bin+':'+process.env.PATH};
|
||||
const start=()=>{const p=childModule.spawn('bash',[entry,'run','--state-root',root,'--lane','a','--grant','fixture'],{env,stdio:['ignore','pipe','pipe']});p.done=new Promise(r=>p.once('exit',code=>r(code)));return p;};
|
||||
const first=start(),second=start();t.after(()=>{first.kill('SIGKILL');second.kill('SIGKILL');});
|
||||
await until(()=>fsExtra.existsSync(join(root,'ready')));
|
||||
const all=api.runRecords(root);assert.equal(all.length,1);assert.equal(readFileSync(join(root,'out/fds.log'),'utf8'),'');
|
||||
const winner=all[0].records.find(r=>r.kind==='start').owner.pid;assert.ok([first.pid,second.pid].includes(winner));
|
||||
const loser=first.pid===winner?second:first;assert.equal(await loser.done,2);
|
||||
const ino=fsExtra.statSync(join(root,'a.lock')).ino;
|
||||
assert.equal(childModule.spawnSync('flock',['-n',join(root,'a.lock'),'true']).status,1);
|
||||
const won=first.pid===winner?first:second;won.kill('SIGTERM');await won.done;
|
||||
assert.equal(fsExtra.statSync(join(root,'a.lock')).ino,ino);
|
||||
});
|
||||
test('same checkout and a legacy land gate each refuse a claim on an otherwise free lane',async t=>{
|
||||
const {activeConflicts}=await import('../../../scripts/testing/gate-lane.mjs');
|
||||
const root=fixture(t),owner={pid:321,start:'1',boot:'test',state:'S',ppid:1,argv:['node','owned']};
|
||||
api.appendRecord(join(root,'runs','one'),{kind:'start',lane:'a',checkout:'/fixture/candidate',owner});
|
||||
const o={stateRoot:root,lane:'b',checkout:'/fixture/candidate'};
|
||||
assert.throws(()=>activeConflicts(o,{processes:[owner]}),/checkout already owned/);
|
||||
assert.throws(()=>activeConflicts({...o,checkout:'/fixture/other'},{processes:[owner,{pid:322,start:'2',boot:'test',state:'S',ppid:1,argv:['node','/fixture/landing.mjs','gate']}]}),/LEGACY BUSY/);
|
||||
});
|
||||
test('recovery kills a proven surviving child, preserves a foreign child and keeps quarantine if interrupted',async t=>{
|
||||
const root=fixture(t),temp=mkdtempSync('/mnt/storage/scratch/gt-');t.after(()=>rmSync(temp,{recursive:true,force:true}));
|
||||
const owned=childModule.spawn(process.execPath,[childFixture,'wait',join(temp,'child')],{cwd:temp,stdio:'ignore'});
|
||||
const foreign=childModule.spawn(process.execPath,[childFixture,'wait',join(root,'foreign')],{stdio:'ignore'});
|
||||
const ownedDone=new Promise(r=>owned.once('exit',r)),foreignDone=new Promise(r=>foreign.once('exit',r));t.after(async()=>{owned.kill('SIGKILL');foreign.kill('SIGKILL');await Promise.all([ownedDone,foreignDone]);});
|
||||
await until(()=>fsExtra.existsSync(join(temp,'child'))&&fsExtra.existsSync(join(root,'foreign')));
|
||||
const docker=join(root,'docker'),pidFile=join(root,'docker-pid'),id='abcdef123456',dir=join(root,'runs',id);
|
||||
writeFileSync(docker,`#!/bin/sh\necho $$ > '${pidFile}'\nexec sleep 60\n`,{mode:0o700});
|
||||
api.appendRecord(dir,{kind:'start',id,stateRoot:root,lane:'a',owner:{pid:99999999,start:'1',boot:'test'},tempRoot:temp,project:`mg197-a-${id}`,docker});
|
||||
const recovery=childModule.spawn('bash',[entry,'recover','--state-root',root,'--run-id',id,'--grant','fixture'],{stdio:'ignore'});const done=new Promise(r=>recovery.once('exit',r));
|
||||
await until(()=>fsExtra.existsSync(pidFile));const dockerOwner=api.identity(Number(readFileSync(pidFile,'utf8')));recovery.kill('SIGKILL');await done;
|
||||
if(api.sameProcess(dockerOwner))process.kill(dockerOwner.pid,'SIGKILL');
|
||||
await ownedDone;assert.equal(api.sameProcess(api.identity(foreign.pid)),true);
|
||||
assert.equal(api.records(dir).some(r=>r.kind==='recovered'),false);assert.equal(api.status(root,{processes:[]}).a.state,'QUARANTINED');
|
||||
writeFileSync(docker,'#!/bin/sh\nexit 0\n',{mode:0o700});
|
||||
const retried=childModule.spawnSync('bash',[entry,'recover','--state-root',root,'--run-id',id,'--grant','fixture-resume'],{encoding:'utf8'});
|
||||
assert.equal(retried.status,0,retried.stderr);assert.equal(api.status(root,{processes:[]}).a.state,'CLEAR');assert.equal(api.sameProcess(api.identity(foreign.pid)),true);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const api = await import('../../../scripts/testing/gate-lane.mjs').catch(e => {
|
||||
if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e;
|
||||
return {};
|
||||
});
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync('/mnt/storage/scratch/gt-');
|
||||
// Node appends six characters, keeping this root at 30 bytes.
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const checkout = join(root, 'checkout'); mkdirSync(checkout);
|
||||
for (const [f,text] of [['package.json','{}'],['RELEASE','0.0.1\n']]) writeFileSync(join(checkout,f),text);
|
||||
execFileSync('git',['init','-q',checkout]);
|
||||
execFileSync('git',['-C',checkout,'add','.']);
|
||||
execFileSync('git',['-C',checkout,'-c','user.name=Fixture','-c','user.email=f@invalid','commit','-qm','fixture']);
|
||||
symlinkSync('/mnt/storage/src/mosaic-stack/node_modules',join(checkout,'node_modules'));
|
||||
return {lane:'a',candidate:execFileSync('git',['-C',checkout,'rev-parse','HEAD'],{encoding:'utf8'}).trim(),checkout,out:join(root,'out'),tempRoot:root,stateRoot:join(root,'state'),grant:'fixture-grant',hostFacts:{unprivilegedPortStart:1024,ephemeral:[32768,60999],listeners:[]}};
|
||||
}
|
||||
test('preflight refuses unsafe lane roots, missing grants and live port 9 before side effects', t => {
|
||||
assert.equal(typeof api.validateRun,'function','validateRun is required');
|
||||
const f=fixture(t);
|
||||
assert.equal(api.validateRun(f).lane,'a');
|
||||
for(const [patch,reason] of [[{lane:'c'},/lane/],[{grant:''},/grant/],[{tempRoot:'/tmp'},/tmp/],[{tempRoot:join(f.tempRoot,'deeper')},/30|missing/],[{candidate:'a'.repeat(40)},/candidate/],[{hostFacts:{...f.hostFacts,listeners:[{address:'0.0.0.0',port:9}]}},/port.9/],[{hostFacts:{...f.hostFacts,unprivilegedPortStart:0}},/privileged/]]) assert.throws(()=>api.validateRun({...f,...patch}),reason);
|
||||
const link=join(f.tempRoot,'link');symlinkSync('/tmp',link);assert.throws(()=>api.validateRun({...f,tempRoot:link}),/tmp/);
|
||||
writeFileSync(join(f.checkout,'RELEASE'),'changed');assert.throws(()=>api.validateRun(f),/dirty/);
|
||||
});
|
||||
test('discovery preserves all package and script steps while ignoring non-file shell names', t => {
|
||||
const f=fixture(t);mkdirSync(join(f.checkout,'packages'));
|
||||
for(const p of ['z','queue','a']){mkdirSync(join(f.checkout,'packages',p,'tests'),{recursive:true});writeFileSync(join(f.checkout,'packages',p,'tests','one.test.mjs'),'');}
|
||||
mkdirSync(join(f.checkout,'scripts'));writeFileSync(join(f.checkout,'scripts','test-z.sh'),'');writeFileSync(join(f.checkout,'scripts','test-a.sh'),'');mkdirSync(join(f.checkout,'scripts','test-directory.sh'));
|
||||
const steps=api.discoverSteps(f.checkout);
|
||||
assert.deepEqual(steps.map(s=>s.name),['pkg-a','pkg-z','script-a','script-z']);
|
||||
assert.deepEqual(steps[0].argv,['node','--test','--test-concurrency=2','tests/one.test.mjs']);
|
||||
});
|
||||
test('a failed step stops the gate and records its nonzero exit without running the next step',async t=>{
|
||||
assert.equal(typeof api.executeSteps,'function','executeSteps is required');
|
||||
const f=fixture(t),events=[],out=f.out;mkdirSync(out);const marker=join(f.tempRoot,'should-not-exist');
|
||||
const result=await api.executeSteps([{name:'first',cwd:f.checkout,argv:['bash','-c','exit 7']},{name:'second',cwd:f.checkout,argv:['touch',marker]}],{out,env:process.env,record:r=>events.push(r)});
|
||||
assert.equal(result.rc,7);assert.equal(events.filter(e=>e.kind==='step').length,1);assert.ok(events.find(e=>e.kind==='step').wallMs>=0);
|
||||
const {existsSync}=await import('node:fs');assert.equal(existsSync(marker),false);
|
||||
});
|
||||
test('a cancelled step stops its process group and records failure',async t=>{
|
||||
const f=fixture(t);mkdirSync(f.out);const controller=new AbortController(),events=[];
|
||||
const child=new URL('./fixtures/gate-lane-child.mjs',import.meta.url).pathname;
|
||||
const result=await api.executeSteps([{name:'child',cwd:f.checkout,argv:[process.execPath,child,'wait',join(f.tempRoot,'started')]}],{out:f.out,env:process.env,record:e=>{events.push(e);if(e.kind==='child')setTimeout(()=>controller.abort(),100);},signal:controller.signal});
|
||||
assert.notEqual(result.rc,0);const owned=events.find(e=>e.kind==='child').owner;const {sameProcess}=await import('../../../scripts/testing/lane-state.mjs');assert.equal(sameProcess(owned),false);
|
||||
});
|
||||
test('step receipts retain pass fail skipped counts and hashes of their actual logs',async t=>{
|
||||
const f=fixture(t);mkdirSync(f.out);const events=[];
|
||||
await api.executeSteps([{name:'counts',cwd:f.checkout,argv:[process.execPath,'-e',"console.log('ℹ pass 2\\nℹ fail 0\\nℹ skipped 1')"]}],{out:f.out,env:process.env,record:r=>events.push(r)});
|
||||
const step=events.find(e=>e.kind==='step');assert.deepEqual(step.counts,{pass:2,fail:0,skipped:1});assert.match(step.logSha256,/^[a-f0-9]{64}$/);
|
||||
});
|
||||
test('preflight rejects a real 31-byte root, missing dependency tree, occupied band and missing inventory',t=>{
|
||||
const f=fixture(t),long=f.tempRoot+'x';mkdirSync(long,{mode:0o700});t.after(()=>rmSync(long,{recursive:true,force:true}));assert.equal(Buffer.byteLength(long),31);
|
||||
assert.throws(()=>api.validateRun({...f,tempRoot:long}),/30 bytes/);
|
||||
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,ephemeral:[60000,62000]}}),/overlaps/);
|
||||
assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address:'127.0.0.1',port:61005}]}}),/live listener/);
|
||||
assert.throws(()=>api.validateRun({...f,hostFacts:{}}),/inventory/);
|
||||
rmSync(join(f.checkout,'node_modules'));assert.throws(()=>api.validateRun(f),/node_modules/);
|
||||
});
|
||||
|
||||
test('preflight refuses canonical and builder checkouts and an existing output',t=>{
|
||||
const f=fixture(t);
|
||||
for(const checkout of ['/mnt/storage/src/mosaic-stack',new URL('../../../',import.meta.url).pathname])assert.throws(()=>api.validateRun({...f,checkout}),/canonical or builder/);
|
||||
mkdirSync(f.out);assert.throws(()=>api.validateRun(f),/output already exists/);
|
||||
});
|
||||
test('both candidates must carry every approved launcher file',t=>{
|
||||
const f=fixture(t),other=join(f.tempRoot,'second');mkdirSync(other);
|
||||
for(const checkout of [f.checkout,other]){
|
||||
const target=join(checkout,'scripts','testing');fsExtra.cpSync(new URL('../../../scripts/testing',import.meta.url),target,{recursive:true});
|
||||
api.validateTooling(checkout);
|
||||
}
|
||||
fsExtra.appendFileSync(join(other,'scripts/testing/lane-state.mjs'),'\n// changed\n');
|
||||
assert.throws(()=>api.validateTooling(other),/tooling differs/);
|
||||
fsExtra.rmSync(join(f.checkout,'scripts/testing/lane-fixtures.mjs'));
|
||||
assert.throws(()=>api.validateTooling(f.checkout),/tooling differs/);
|
||||
});
|
||||
test('the 92-byte socket example passes and an over-107-byte pathname refuses',()=>{
|
||||
const root='/mnt/storage/scratch/gt-ABCDEF';assert.equal(Buffer.byteLength(root),30);
|
||||
const socket=root+'/mosaic-cli-ABCDEF/data/launches/acme/r0123456789ab/tools.sock';
|
||||
assert.equal(Buffer.byteLength(socket),92);assert.equal(stateApi.socketPath(socket),socket);
|
||||
assert.throws(()=>stateApi.socketPath(socket+'x'.repeat(16)),/107 bytes/);
|
||||
});
|
||||
test('package/script duplicates stay and every supervised step clears NODE_TEST_CONTEXT',async t=>{
|
||||
const f=fixture(t);mkdirSync(join(f.checkout,'packages/cli/tests'),{recursive:true});mkdirSync(join(f.checkout,'scripts'));
|
||||
writeFileSync(join(f.checkout,'packages/cli/tests/repeated.test.mjs'),'');writeFileSync(join(f.checkout,'scripts/test-cli.sh'),'node --test packages/cli/tests/repeated.test.mjs\n');
|
||||
const steps=api.discoverSteps(f.checkout);assert.deepEqual(steps.map(s=>s.name),['pkg-cli','script-cli']);
|
||||
const original=process.env.NODE_TEST_CONTEXT;process.env.NODE_TEST_CONTEXT='parent-context';
|
||||
let env;try{env=api.cleanEnv(f,{project:'fixture'},'/fake/docker');}finally{if(original===undefined)delete process.env.NODE_TEST_CONTEXT;else process.env.NODE_TEST_CONTEXT=original;}
|
||||
assert.equal(env.NODE_TEST_CONTEXT,undefined);assert.equal(env.HOME,process.env.HOME);
|
||||
mkdirSync(f.out);const events=[];
|
||||
for(const s of steps)s.argv=[process.execPath,'-e',"if(process.env.NODE_TEST_CONTEXT)process.exit(9);console.log('cleared')"];
|
||||
const result=await api.executeSteps(steps,{out:f.out,env,record:e=>events.push(e)});
|
||||
assert.equal(result.rc,0);assert.equal(events.filter(e=>e.kind==='step').length,2);
|
||||
});
|
||||
test('scoped ss loopback and wildcard port 9 listeners refuse',t=>{
|
||||
const f=fixture(t);
|
||||
for(const address of ['127.0.0.1%lo','0.0.0.0%lo','::%lo','::ffff:127.0.0.1%lo'])assert.throws(()=>api.validateRun({...f,hostFacts:{...f.hostFacts,listeners:[{address,port:9}]}}),/port 9/);
|
||||
});
|
||||
import * as fsExtra from 'node:fs';
|
||||
import * as stateApi from '../../../scripts/testing/lane-state.mjs';
|
||||
@@ -35,7 +35,7 @@ export function setup(t, realPin=false){
|
||||
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
|
||||
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
|
||||
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
||||
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
|
||||
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
|
||||
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
// scripts/stack-release.sh (D1, #1659). Design, the T table and the mutants:
|
||||
// agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in
|
||||
// systemctl, journalctl and systemd-analyze on PATH; nothing here reads ~/.mosaic-dev or a live unit.
|
||||
@@ -1784,7 +1786,7 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
||||
const rh=(e,...p)=>join(e.env.HOME,...p);
|
||||
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
@@ -73,16 +75,7 @@ function closeServer(server) {
|
||||
|
||||
// Grab an ephemeral free port, then hand it back immediately so a caller can
|
||||
// try to bind it themselves (used to prove startServer never opened a socket).
|
||||
function getFreePort() {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const probe = createNetServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const port = probe.address().port;
|
||||
probe.close(() => resolvePromise(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
function getFreePort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [cli, ...args], { encoding: "utf8", timeout: 15000 });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
// The host's claude CLI through adapters/claude with the bundle's hook and
|
||||
// MCP server, against the scripted Messages API. Scratch CLAUDE_CONFIG_DIR
|
||||
// and HOME, a dummy key, nonessential traffic off: nothing reaches a real
|
||||
@@ -61,7 +62,7 @@ async function session(t, script, tools = ["read", "bash"]) {
|
||||
|
||||
function turn(env, request, cwd, adapter = ADAPTER) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
||||
const child = spawn("/bin/sh", [adapter], { cwd, env: { ...env, ...fixtureCacheEnv(env.CLAUDE_CONFIG_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b) => (stdout += b));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fixtureCacheEnv } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
// Real pi (the pinned CLI) through adapters/pi with the extension, against
|
||||
// the scripted Messages API. No real model, no network beyond 127.0.0.1.
|
||||
|
||||
@@ -57,7 +58,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
|
||||
|
||||
function turn(env, request, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = spawn("/bin/sh", [ADAPTER], { cwd, env: { ...env, ...fixtureCacheEnv(env.PI_CODING_AGENT_DIR), MOSAIC_REQUEST: request }, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (b) => (stdout += b));
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# packages/library
|
||||
|
||||
Reads a writing repo by a mapping of folders to books, types, canon and playground. This is Case 2
|
||||
row C2-01 (`docs/plans/2026-10-20_case2-rows.md`), built from design sections 1.1 to 1.3 and 7.4 in
|
||||
`agents/gosalyn/work/case2/DESIGN.md` on `gosalyn/case2-design`.
|
||||
It is pure code with no state. Business file loading (C2-04), sync (C2-03a), the index (C2-05a) and the
|
||||
guard (C2-06) build on it.
|
||||
|
||||
## Mapping
|
||||
|
||||
`checkMapping(entry, where, { fail })` checks a `kind: "library"` project entry and returns a
|
||||
deep-frozen copy. `where` names the entry in messages. `fail(code, message)` defaults to throwing a
|
||||
`LibraryError`. C2-04 passes its own so a business file refusal carries the file name.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "library",
|
||||
"remote": "https://git.example.invalid/her/books.git",
|
||||
"branch": "main",
|
||||
"vaultPrefix": "books",
|
||||
"types": { "Characters": "character", "Places": "place", "Research": "research" },
|
||||
"manuscript": ["Chapters"],
|
||||
"universes": {
|
||||
"harbor": { "name": "Harbor", "books": ["Book One"], "playground": ["Book One/Playground/"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `remote` is https with no credentials, query or fragment. `branch` is a plain ref name.
|
||||
- `vaultPrefix` is optional, one folder name.
|
||||
- `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
|
||||
non-hybrid `sources`, `constraints` (each one `{ "exclude": ... }` or
|
||||
`{ "pin": { "entity", "field", "value" } }`) and `notes`.
|
||||
|
||||
Refusals:
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `library-playground-outside-book` | a playground prefix is not strictly inside a book of its own universe |
|
||||
| `library-mapping-invalid` | any other shape fault: unknown key, bad remote or branch, a book folder in two universes, a book folder that holds another, a playground prefix that holds another, an unknown hybrid source |
|
||||
|
||||
## Scope
|
||||
|
||||
`classify(mapping, path)` decides scope from the path alone. Only paths ending in lowercase `.md` are
|
||||
mapped. A path under a playground prefix is `playground`. Any other path under a book folder is
|
||||
`canon`. Everything else is `unmapped`. The type is the first folder after the book, or after the
|
||||
playground prefix: a `types` name, `manuscript`, or `null`.
|
||||
|
||||
## Reader
|
||||
|
||||
`readLibrary({ repo, commit, mapping })` reads one commit, never a working tree. `repo` may be a
|
||||
clone or a bare mirror. `commit` must be a full 40-hex id that names a commit, else `library-commit`.
|
||||
Git runs through `git()` and `gitBytes()` from `packages/project/src/git.mjs`, so the same safe config,
|
||||
environment and ceiling directory apply. A failed git call refuses `library-git`.
|
||||
|
||||
It returns `{ commit, files, problems }`, deep-frozen and sorted. Each file has its path, scope,
|
||||
universe, book, type, numbered lines, `bodyLine`, parsed `frontmatter` or `null`, `frontmatterRaw`
|
||||
and `links`. Front matter `appears_in` and `relationships` wikilinks count as links.
|
||||
|
||||
Problems carry `{ code, path, line, message }`:
|
||||
|
||||
| Rule | Meaning | File indexed |
|
||||
|---|---|---|
|
||||
| R1 | not a regular `100644` blob of at most 1 MiB, or not UTF-8, or holds a NUL | no |
|
||||
| R2 | path is unmapped | no |
|
||||
| R3 | front matter not fully parsed, `line` is where parsing stopped | yes, front matter kept raw |
|
||||
| R4 | link resolves to no file or to more than one | yes, link dropped |
|
||||
| R5 | canon file links into the playground | yes |
|
||||
| R6 | canon file links into another universe, a hybrid's sources excepted | yes |
|
||||
| R7 | two paths differ only in case | no, neither |
|
||||
|
||||
The front matter parser takes scalars, quoted strings, flow lists, block lists and wikilinks. Nested
|
||||
maps, anchors, block scalars and duplicate keys stop it with R3.
|
||||
|
||||
## Links
|
||||
|
||||
`resolver(paths, { vaultPrefix })` tries four steps in order, each with the target as written and
|
||||
then with `.md` added:
|
||||
|
||||
1. `root`: the path from the repo root.
|
||||
2. `vault-prefix`: the same path with `vaultPrefix/` stripped.
|
||||
3. `relative`: relative to the linking file, never above the repo root.
|
||||
4. `shortest`: a target with no `/` that matches the base name of exactly one indexed file.
|
||||
|
||||
It resolves only against indexed files. A link to a `.canvas` file or an attachment is R4. A link into
|
||||
another repo resolves to nothing. `extractLinks` skips fenced and inline code.
|
||||
|
||||
## renderItems
|
||||
|
||||
`renderItems(body, { scopeOf, changed })` turns a reply body into the plain text both channels show.
|
||||
It is pure. Item kinds are `fact`, `finding`, `correction` (with `from` and `to`), `question` and
|
||||
`idea`. Each cite is a path with commit, line range and quote, or a url with a quote.
|
||||
|
||||
```
|
||||
1. Correction: Chapter 1 says grey.
|
||||
From: "green eyes"
|
||||
To: "grey eyes"
|
||||
[canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes" (changed since)
|
||||
```
|
||||
|
||||
A body of `{ "items": [], "notice": ... }` gives `No cited answer`, `No answer` or `The turn failed`.
|
||||
Any other shape gives the fixed line `This reply could not be shown.` and none of the body's text.
|
||||
|
||||
## Survey
|
||||
|
||||
```sh
|
||||
mosaic library survey <repo> [--mapping <file>]
|
||||
```
|
||||
|
||||
It reads the repo's HEAD commit and prints folder names, file counts by extension, front matter key
|
||||
sets and link-form counts. It never prints file text, front matter values, file names or link targets.
|
||||
A key that is not a short identifier prints as `(other key)`, since it could be her words. With
|
||||
`--mapping` it adds scope counts, resolver step counts and problem counts by rule.
|
||||
|
||||
Exit codes follow `packages/project`: 0 done, 2 the repo can't be read (`library-git`,
|
||||
`library-commit`), 3 a bad mapping, 4 usage. `scripts/mosaic` sends `library survey` here. Other
|
||||
library verbs will be bus verbs.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
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.
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "@mosaic/library",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"exports": "./src/index.mjs",
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// packages/library: her writing repo read by a mapping (Case 2, design 1 and 7.4). See README.md.
|
||||
export { LibraryError, RULES, problem, sortProblems } from './problems.mjs';
|
||||
export { checkMapping, TYPES } from './mapping.mjs';
|
||||
export { classify } from './scope.mjs';
|
||||
export { extractLinks, parseWikilink, resolver, formOf } 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';
|
||||
@@ -1,79 +0,0 @@
|
||||
// Wikilinks and the link resolver of design 1.3. Links are [[target]], [[target|label]] and
|
||||
// [[target#heading]], with ![[...]] embeds read the same way. Links inside fenced code blocks and
|
||||
// inline code spans are not links, as in Obsidian.
|
||||
import { posix } from 'node:path';
|
||||
|
||||
const WIKILINK = /(!?)\[\[([^\[\]\n]+?)\]\]/g;
|
||||
const MARKDOWN_LINK = /(?<!!)\[[^\[\]\n]*\]\(([^()\s]+)\)/g;
|
||||
const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
||||
|
||||
// One wikilink's parts, or null for text that isn't one.
|
||||
export function parseWikilink(text) {
|
||||
const match = /^(!?)\[\[([^\[\]\n]+?)\]\]$/.exec(text);
|
||||
return match ? parts(match[2], match[1] === '!') : null;
|
||||
}
|
||||
function parts(inner, embed) {
|
||||
const bar = inner.indexOf('|');
|
||||
const target = bar < 0 ? inner : inner.slice(0, bar);
|
||||
const hash = target.indexOf('#');
|
||||
return {
|
||||
file: (hash < 0 ? target : target.slice(0, hash)).trim(),
|
||||
heading: hash < 0 ? null : target.slice(hash + 1).trim(),
|
||||
label: bar < 0 ? null : inner.slice(bar + 1),
|
||||
embed,
|
||||
};
|
||||
}
|
||||
// The form a link is counted under in the survey.
|
||||
export const formOf = link => link.markdown ? 'markdown' : link.embed ? 'embed' : !link.file ? 'self'
|
||||
: link.label !== null ? 'label' : link.heading !== null ? 'heading' : 'plain';
|
||||
|
||||
// Links in body lines. `lines` is [[number, text], ...]. Markdown links are returned only with
|
||||
// `markdown: true`, for the survey's counts. The resolver follows wikilinks only.
|
||||
export function extractLinks(lines, { markdown = false } = {}) {
|
||||
const found = [];
|
||||
let fence = null;
|
||||
for (const [line, text] of lines) {
|
||||
const open = FENCE.exec(text);
|
||||
if (fence) { if (open && open[1][0] === fence[0] && open[1].length >= fence.length) fence = null; continue; }
|
||||
if (open) { fence = open[1]; continue; }
|
||||
const plain = text.replace(/(`+)[^`]*?\1/g, match => ' '.repeat(match.length));
|
||||
for (const m of plain.matchAll(WIKILINK)) found.push({ ...parts(m[2], m[1] === '!'), line });
|
||||
if (markdown) {
|
||||
for (const m of plain.matchAll(MARKDOWN_LINK)) {
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(m[1]) && !m[1].startsWith('#'))
|
||||
found.push({ file: m[1], heading: null, label: null, embed: false, markdown: true, line });
|
||||
}
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// A resolver over the indexed paths. resolve(from, file) tries, in order: a path from the repo
|
||||
// root, the same path with `vaultPrefix/` stripped, a path relative to the linking file, and
|
||||
// Obsidian's shortest-path form, a base name that matches exactly one indexed file. Each step tries
|
||||
// the target as written and then with `.md` added. A target in another of her repos is not in the
|
||||
// index, so it resolves to nothing.
|
||||
export function resolver(paths, { vaultPrefix } = {}) {
|
||||
const known = new Set(paths), byBase = new Map();
|
||||
for (const path of known) {
|
||||
const base = posix.basename(path);
|
||||
for (const key of new Set([base, base.replace(/\.md$/, '')])) byBase.set(key, [...(byBase.get(key) ?? []), path]);
|
||||
}
|
||||
const hit = candidate => [candidate, `${candidate}.md`].find(c => known.has(c));
|
||||
return function resolve(from, file) {
|
||||
if (typeof file !== 'string' || !file) return { path: null, step: 'missing' };
|
||||
let path = hit(file);
|
||||
if (path) return { path, step: 'root' };
|
||||
if (vaultPrefix && file.startsWith(`${vaultPrefix}/`) && (path = hit(file.slice(vaultPrefix.length + 1))))
|
||||
return { path, step: 'vault-prefix' };
|
||||
const relative = posix.normalize(posix.join(posix.dirname(from), file));
|
||||
if (relative !== '..' && !relative.startsWith('../') && !relative.startsWith('/') && (path = hit(relative)))
|
||||
return { path, step: 'relative' };
|
||||
if (!file.includes('/')) {
|
||||
const matches = byBase.get(file) ?? [];
|
||||
if (matches.length === 1) return { path: matches[0], step: 'shortest' };
|
||||
if (matches.length > 1) return { path: null, step: 'ambiguous', count: matches.length };
|
||||
}
|
||||
return { path: null, step: 'missing' };
|
||||
};
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// The library project entry of a business file (design 1.1): her folders mapped onto universes,
|
||||
// books, type folders, manuscript folders and playground prefixes. 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']);
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
|
||||
const CONSTRAINT_KEYS = ['exclude', 'pin'];
|
||||
|
||||
// A folder segment from her vault: spaces and capitals are fine, separators, control characters
|
||||
// and dot names are not.
|
||||
function segment(value) {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 255 && value !== '.' && value !== '..'
|
||||
&& !/[\/\\\u0000-\u001f\u007f]/.test(value) && value.trim() === value;
|
||||
}
|
||||
function folder(value) {
|
||||
return typeof value === 'string' && value.length <= 1024 && value.split('/').every(segment);
|
||||
}
|
||||
const holds = (outer, inner) => inner.startsWith(outer.endsWith('/') ? outer : `${outer}/`);
|
||||
|
||||
export function checkMapping(entry, where = 'library', { fail = raise } = {}) {
|
||||
const bad = message => fail('library-mapping-invalid', `${where} ${message}`);
|
||||
const object = (value, name) => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) bad(`${name} must be an object`);
|
||||
};
|
||||
const only = (value, keys, name) => {
|
||||
for (const key of Object.keys(value)) if (!keys.includes(key)) bad(`${name} has unknown key ${JSON.stringify(key)}`);
|
||||
};
|
||||
const list = (value, name, check, what) => {
|
||||
if (!Array.isArray(value)) bad(`${name} must be a list`);
|
||||
for (const item of value) if (!check(item)) bad(`${name} holds ${JSON.stringify(item)}, which is not ${what}`);
|
||||
if (new Set(value).size !== value.length) bad(`${name} names an entry twice`);
|
||||
return [...value];
|
||||
};
|
||||
|
||||
object(entry, '');
|
||||
only(entry, ['kind', 'remote', 'branch', 'vaultPrefix', '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'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || entry.remote.includes('\0'))
|
||||
bad('remote must be an https:// URL with no credentials, query or fragment');
|
||||
if (typeof entry.branch !== 'string' || !BRANCH.test(entry.branch) || entry.branch.includes('..')
|
||||
|| entry.branch.includes('//') || entry.branch.endsWith('/') || entry.branch.endsWith('.lock'))
|
||||
bad('branch must be a plain branch name');
|
||||
if (Object.hasOwn(entry, 'vaultPrefix') && !segment(entry.vaultPrefix)) bad('vaultPrefix must be one folder name');
|
||||
|
||||
object(entry.types, 'types');
|
||||
for (const [name, type] of Object.entries(entry.types)) {
|
||||
if (!segment(name)) bad(`types has ${JSON.stringify(name)}, which is not one folder name`);
|
||||
if (!TYPES.includes(type)) bad(`types.${name} must be one of ${TYPES.join(', ')}`);
|
||||
}
|
||||
const manuscript = list(entry.manuscript, 'manuscript', segment, 'one folder name');
|
||||
for (const name of manuscript) if (Object.hasOwn(entry.types, name)) bad(`manuscript folder ${JSON.stringify(name)} is also a type folder`);
|
||||
|
||||
object(entry.universes, 'universes');
|
||||
if (!Object.keys(entry.universes).length) bad('universes must name at least one universe');
|
||||
const universes = {}, bookOwner = new Map(), prefixes = [];
|
||||
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`);
|
||||
object(universe, name);
|
||||
const hybrid = universe.kind === 'hybrid';
|
||||
only(universe, hybrid ? ['kind', 'name', 'sources', 'books', 'playground', 'constraints', 'notes'] : ['name', 'books', 'playground'], 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 = list(universe.books, `${name}.books`, folder, 'a folder path');
|
||||
if (!hybrid && !books.length) bad(`${name}.books must name at least one book folder`);
|
||||
for (const book of books) {
|
||||
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 /');
|
||||
for (const prefix of playground) {
|
||||
// Every playground file must have a universe and a book (1.1, B1).
|
||||
if (!books.some(book => holds(book, prefix) && prefix !== `${book}/`))
|
||||
fail('library-playground-outside-book', `${where} ${name}.playground ${JSON.stringify(prefix)} is not inside one of its books`);
|
||||
prefixes.push(prefix);
|
||||
}
|
||||
const out = { name: universe.name, books, playground };
|
||||
if (hybrid) {
|
||||
out.kind = 'hybrid';
|
||||
out.sources = list(universe.sources, `${name}.sources`, value => typeof value === 'string' && ID.test(value), 'a universe id');
|
||||
if (out.sources.length < 2) bad(`${name}.sources must name two or more universes`);
|
||||
if (!Array.isArray(universe.constraints ?? [])) bad(`${name}.constraints must be a list`);
|
||||
out.constraints = (universe.constraints ?? []).map((constraint, i) => {
|
||||
const at = `${name}.constraints[${i}]`;
|
||||
object(constraint, at);
|
||||
only(constraint, CONSTRAINT_KEYS, at);
|
||||
if (Object.keys(constraint).length !== 1) bad(`${at} must hold exactly one of ${CONSTRAINT_KEYS.join(', ')}`);
|
||||
if (Object.hasOwn(constraint, 'exclude')) {
|
||||
if (!folder(constraint.exclude)) bad(`${at}.exclude must be a source path`);
|
||||
return { exclude: constraint.exclude };
|
||||
}
|
||||
object(constraint.pin, `${at}.pin`);
|
||||
only(constraint.pin, ['entity', 'field', 'value'], `${at}.pin`);
|
||||
if (!folder(constraint.pin.entity) || typeof constraint.pin.field !== 'string' || !constraint.pin.field
|
||||
|| !['string', 'number', 'boolean'].includes(typeof constraint.pin.value))
|
||||
bad(`${at}.pin needs an entity path, a field name and a scalar value`);
|
||||
return { pin: { ...constraint.pin } };
|
||||
});
|
||||
if (Object.hasOwn(universe, 'notes') && (typeof universe.notes !== 'string' || universe.notes.length > 4000))
|
||||
bad(`${name}.notes must be text of at most 4000 characters`);
|
||||
if (Object.hasOwn(universe, 'notes')) out.notes = universe.notes;
|
||||
}
|
||||
universes[id] = out;
|
||||
}
|
||||
for (const [id, universe] of Object.entries(universes)) {
|
||||
for (const source of universe.sources ?? []) {
|
||||
if (!Object.hasOwn(universes, source) || universes[source].kind === 'hybrid')
|
||||
bad(`universes.${id}.sources names ${JSON.stringify(source)}, which is not a non-hybrid universe`);
|
||||
}
|
||||
}
|
||||
const books = [...bookOwner.keys()];
|
||||
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');
|
||||
|
||||
return deepFreeze({
|
||||
kind: 'library', remote: entry.remote, branch: entry.branch,
|
||||
...(Object.hasOwn(entry, 'vaultPrefix') ? { vaultPrefix: entry.vaultPrefix } : {}),
|
||||
universes, types: { ...entry.types }, manuscript,
|
||||
});
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === 'object') { for (const item of Object.values(value)) deepFreeze(item); Object.freeze(value); }
|
||||
return value;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Errors and the R-rule problems of design 1.3. A problem never holds back a commit: it names a
|
||||
// path and, where there is one, a line, and the index leaves the file or link out.
|
||||
export class LibraryError extends Error {
|
||||
constructor(code, message) { super(`${code}: ${message}`); this.code = code; }
|
||||
}
|
||||
export const raise = (code, message) => { throw new LibraryError(code, message); };
|
||||
|
||||
export const RULES = Object.freeze({
|
||||
R1: 'not a regular blob up to 1 MiB of UTF-8 text',
|
||||
R2: 'path is unmapped',
|
||||
R3: 'front matter not fully parsed',
|
||||
R4: 'link resolves to no file or to more than one',
|
||||
R5: 'canon file refers into the playground',
|
||||
R6: 'canon file refers into another universe',
|
||||
R7: 'paths differ only in case',
|
||||
});
|
||||
|
||||
export function problem(code, path, line, message = RULES[code]) {
|
||||
if (!Object.hasOwn(RULES, code)) raise('library-problem-code', `unknown problem code ${code}`);
|
||||
return Object.freeze({ code, path, ...(Number.isSafeInteger(line) ? { line } : {}), message });
|
||||
}
|
||||
|
||||
// Stable order for status output and tests: path, then line, then code.
|
||||
export function sortProblems(list) {
|
||||
return [...list].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1
|
||||
: (a.line ?? 0) - (b.line ?? 0) || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// The commit reader of design 1.3. It reads a commit of her repo, never a working tree: it lists the
|
||||
// tree with `git ls-tree -r -z` and reads blobs with `cat-file`, through the same bounded git()
|
||||
// and gitBytes() as packages/project, so hooks, protocols and the environment are fixed the same
|
||||
// way. Nothing in the repo is checked out or run (D7). Each R-rule is a problem, never a refusal
|
||||
// of the commit; the affected file or link is left out of the index.
|
||||
import { git, gitBytes } from '../../project/src/git.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { extractLinks, parseWikilink, resolver } from './links.mjs';
|
||||
import { LibraryError, problem, raise, sortProblems } from './problems.mjs';
|
||||
|
||||
export const MAX_BLOB = 1024 * 1024;
|
||||
const MAX_LISTING = 32 * 1024 * 1024;
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const ENTRY = /^([0-7]{6}) (blob|tree|commit) ([0-9a-f]{40}) +(-|\d+)\t(.+)$/s;
|
||||
export const LINK_FIELDS = Object.freeze(['appears_in', 'relationships']);
|
||||
const READERS = 8;
|
||||
|
||||
async function local(promise, message) {
|
||||
try { return await promise; } catch (e) { raise('library-git', `${message} (${e.code ?? 'error'})`); }
|
||||
}
|
||||
|
||||
export async function headCommit(repo) {
|
||||
return local(git(repo, ['rev-parse', '--verify', '--end-of-options', 'HEAD^{commit}']), 'repository has no readable HEAD commit');
|
||||
}
|
||||
|
||||
// Every entry of the commit's tree: { mode, type, oid, size, path }. Sizes come from `-l`, so no
|
||||
// blob is opened to list it.
|
||||
export async function listTree(repo, commit) {
|
||||
if (typeof commit !== 'string' || !SHA.test(commit)) raise('library-commit', 'expected a full commit id');
|
||||
const kind = await local(git(repo, ['cat-file', '-t', commit]), 'commit is not in the repository');
|
||||
if (kind !== 'commit') raise('library-commit', 'not a commit');
|
||||
const bytes = await local(gitBytes(repo, ['ls-tree', '-r', '-z', '-l', '--full-tree', commit], MAX_LISTING), 'tree listing refused');
|
||||
let text;
|
||||
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); }
|
||||
catch { raise('library-git', 'tree listing is not UTF-8'); }
|
||||
const entries = text.split('\0');
|
||||
if (entries.pop() !== '') raise('library-git', 'invalid tree listing');
|
||||
return entries.map(entry => {
|
||||
const m = ENTRY.exec(entry);
|
||||
if (!m) raise('library-git', 'invalid tree listing');
|
||||
return { mode: m[1], type: m[2], oid: m[3], size: m[4] === '-' ? null : Number(m[4]), path: m[5] };
|
||||
});
|
||||
}
|
||||
|
||||
// R1: only a 100644 blob up to 1 MiB of UTF-8 text without NUL is read.
|
||||
export const readable = entry => entry.mode === '100644' && entry.type === 'blob'
|
||||
&& Number.isSafeInteger(entry.size) && entry.size <= MAX_BLOB;
|
||||
|
||||
// Text of each readable entry, by path. An entry whose bytes aren't UTF-8 text is missing from the
|
||||
// map, and the caller reports it under R1.
|
||||
export async function readTexts(repo, entries) {
|
||||
const texts = new Map(), queue = [...entries];
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false });
|
||||
const worker = async () => {
|
||||
for (let entry = queue.shift(); entry; entry = queue.shift()) {
|
||||
const bytes = await local(gitBytes(repo, ['cat-file', 'blob', entry.oid], MAX_BLOB), 'blob read refused');
|
||||
let value;
|
||||
try { value = decoder.decode(bytes); } catch { continue; }
|
||||
if (!value.includes('\0')) texts.set(entry.path, value);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: READERS }, worker));
|
||||
return texts;
|
||||
}
|
||||
|
||||
const QUOTED = /^"(?:[^"\\]|\\.)*"$|^'(?:[^']|'')*'$/;
|
||||
const PLAIN_START = /^[^\s\[\]{}&*!|>%@`"'#,]/;
|
||||
function scalar(raw) {
|
||||
const value = raw.trim();
|
||||
if (parseWikilink(value)) return { ok: true, value };
|
||||
if (QUOTED.test(value)) {
|
||||
if (value[0] === "'") return { ok: true, value: value.slice(1, -1).replace(/''/g, "'") };
|
||||
try { return { ok: true, value: JSON.parse(value) }; } catch { return { ok: false }; }
|
||||
}
|
||||
if (value === '' || value === '~' || value === 'null') return { ok: true, value: null };
|
||||
if (!PLAIN_START.test(value) || /: |\s#/.test(value) || value.endsWith(':')) return { ok: false };
|
||||
if (value === 'true' || value === 'false') return { ok: true, value: value === 'true' };
|
||||
if (/^-?(0|[1-9]\d*)(\.\d+)?$/.test(value)) return { ok: true, value: Number(value) };
|
||||
return { ok: true, value };
|
||||
}
|
||||
function flow(raw) {
|
||||
const inner = raw.trim().slice(1, -1), items = [];
|
||||
let current = '', quote = null;
|
||||
for (const ch of inner) {
|
||||
if (quote) { current += ch; if (ch === quote) quote = null; continue; }
|
||||
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
|
||||
if (ch === '[' || ch === ']' || ch === '{' || ch === '}') return { ok: false };
|
||||
if (ch === ',') { items.push(current); current = ''; continue; }
|
||||
current += ch;
|
||||
}
|
||||
if (quote) return { ok: false };
|
||||
if (current.trim() || items.length) items.push(current);
|
||||
const values = [];
|
||||
for (const item of items) { const s = scalar(item); if (!s.ok || !item.trim()) return { ok: false }; values.push(s.value); }
|
||||
return { ok: true, value: values };
|
||||
}
|
||||
function value(raw) {
|
||||
const text = raw.trim();
|
||||
if (text.startsWith('[') && !parseWikilink(text)) return text.endsWith(']') ? flow(text) : { ok: false };
|
||||
return scalar(text);
|
||||
}
|
||||
|
||||
// Front matter of design 1.3 (R3): scalars, quoted strings, flow lists and block lists of scalars or
|
||||
// wikilinks. Anything else, a nested map, a block scalar or an anchor for example, leaves the whole
|
||||
// front matter as raw text with one R3 problem at the line where parsing stopped.
|
||||
// Returns { frontmatter, raw, bodyLine, stop } with 1-based line numbers.
|
||||
export function parseFrontMatter(lines) {
|
||||
if (lines[0]?.replace(/^/, '').trimEnd() !== '---') return { frontmatter: null, raw: null, bodyLine: 1, stop: null };
|
||||
const close = lines.findIndex((text, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(text));
|
||||
if (close < 0) return { frontmatter: null, raw: null, bodyLine: 1, stop: 1 };
|
||||
const raw = lines.slice(1, close).join('\n'), data = {};
|
||||
let list = null;
|
||||
for (let i = 1; i < close; i++) {
|
||||
const text = lines[i], line = i + 1;
|
||||
if (!text.trim() || /^\s*#/.test(text)) continue;
|
||||
const item = /^(\s*)- (.*)$|^(\s*)-$/.exec(text);
|
||||
if (item && list) {
|
||||
const parsed = scalar(item[2] ?? '');
|
||||
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
data[list].push(parsed.value);
|
||||
continue;
|
||||
}
|
||||
const pair = /^([^\s:#\-][^:]*?|"[^"]+"|'[^']+')\s*:(?:\s+(.*))?$/.exec(text);
|
||||
if (!pair) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
const key = pair[1].replace(/^(["'])(.*)\1$/, '$2');
|
||||
if (Object.hasOwn(data, key) || key === '__proto__') return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
if (pair[2] === undefined || !pair[2].trim()) {
|
||||
const next = lines[i + 1];
|
||||
if (i + 1 < close && /^\s*-( |$)/.test(next)) { data[key] = []; list = key; }
|
||||
else { data[key] = null; list = null; }
|
||||
continue;
|
||||
}
|
||||
list = null;
|
||||
const parsed = value(pair[2]);
|
||||
if (!parsed.ok) return { frontmatter: null, raw, bodyLine: close + 2, stop: line };
|
||||
data[key] = parsed.value;
|
||||
}
|
||||
return { frontmatter: data, raw: null, bodyLine: close + 2, stop: null };
|
||||
}
|
||||
|
||||
export const splitLines = text => text.replace(/^/, '').split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
|
||||
|
||||
// One parsed file: its lines (all of them, numbered from 1), front matter and the links it holds.
|
||||
// Front matter links come from LINK_FIELDS values that are wikilinks; other values are names.
|
||||
export function parseFile(text, { markdown = false } = {}) {
|
||||
const lines = splitLines(text);
|
||||
const fm = parseFrontMatter(lines);
|
||||
const numbered = lines.map((t, i) => [i + 1, t]);
|
||||
const links = extractLinks(numbered.slice(fm.bodyLine - 1), { markdown });
|
||||
if (fm.frontmatter) {
|
||||
for (const field of LINK_FIELDS) {
|
||||
const raw = fm.frontmatter[field];
|
||||
const values = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
|
||||
for (const v of values) {
|
||||
const link = typeof v === 'string' ? parseWikilink(v.trim()) : null;
|
||||
if (link) links.push({ ...link, field, line: frontMatterLine(lines, fm.bodyLine, field) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lines: numbered, bodyLine: fm.bodyLine, frontmatter: fm.frontmatter, frontmatterRaw: fm.raw, stop: fm.stop, links };
|
||||
}
|
||||
function frontMatterLine(lines, bodyLine, field) {
|
||||
const i = lines.slice(0, bodyLine - 1).findIndex(t => new RegExp(`^["']?${field}["']?\\s*:`).test(t));
|
||||
return i < 0 ? undefined : i + 1;
|
||||
}
|
||||
|
||||
// Read a commit under a checked mapping (mapping.mjs). Returns the indexed files and every
|
||||
// R1-R7 problem: { commit, files: [...], problems: [...] }, frozen, sorted by path.
|
||||
export async function readLibrary({ repo, commit, mapping }) {
|
||||
if (!mapping || mapping.kind !== 'library') throw new LibraryError('library-mapping-invalid', 'expected a checked library mapping');
|
||||
const entries = await listTree(repo, commit);
|
||||
const problems = [], twins = new Set(), byLower = new Map();
|
||||
for (const e of entries) byLower.set(e.path.toLowerCase(), [...(byLower.get(e.path.toLowerCase()) ?? []), e.path]);
|
||||
for (const group of byLower.values()) if (group.length > 1) for (const path of group) twins.add(path);
|
||||
|
||||
const candidates = [];
|
||||
for (const entry of entries) {
|
||||
const where = classify(mapping, entry.path);
|
||||
if (where.scope === 'unmapped') { problems.push(problem('R2', entry.path)); continue; }
|
||||
if (twins.has(entry.path)) { problems.push(problem('R7', entry.path)); continue; }
|
||||
if (!readable(entry)) { problems.push(problem('R1', entry.path)); continue; }
|
||||
candidates.push({ entry, where });
|
||||
}
|
||||
const texts = await readTexts(repo, candidates.map(c => c.entry));
|
||||
const files = [];
|
||||
for (const { entry, where } of candidates) {
|
||||
if (!texts.has(entry.path)) { problems.push(problem('R1', entry.path)); continue; }
|
||||
const parsed = parseFile(texts.get(entry.path));
|
||||
if (parsed.stop !== null) problems.push(problem('R3', entry.path, parsed.stop));
|
||||
files.push({ path: entry.path, ...where, name: entry.path.split('/').pop().replace(/\.md$/, ''), ...parsed });
|
||||
}
|
||||
|
||||
const resolve = resolver(files.map(f => f.path), { vaultPrefix: mapping.vaultPrefix });
|
||||
const byPath = new Map(files.map(f => [f.path, f]));
|
||||
for (const file of files) {
|
||||
const kept = [];
|
||||
for (const link of file.links) {
|
||||
if (!link.file) continue;
|
||||
const target = resolve(file.path, link.file);
|
||||
if (!target.path) { problems.push(problem('R4', file.path, link.line)); continue; }
|
||||
const to = byPath.get(target.path);
|
||||
if (file.scope === 'canon') {
|
||||
if (to.scope === 'playground') problems.push(problem('R5', file.path, link.line));
|
||||
const sources = mapping.universes[file.universe].sources ?? [];
|
||||
if (to.universe !== file.universe && !sources.includes(to.universe)) problems.push(problem('R6', file.path, link.line));
|
||||
}
|
||||
kept.push({ ...link, path: target.path, step: target.step });
|
||||
}
|
||||
file.links = kept;
|
||||
delete file.stop;
|
||||
}
|
||||
files.sort((a, b) => a.path < b.path ? -1 : 1);
|
||||
return deepFreeze({ commit, files, problems: sortProblems(problems) });
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.freeze(value); for (const v of Object.values(value)) deepFreeze(v); }
|
||||
return value;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// renderItems(body): how a stored cited reply looks (design 7.4), shared by the Console chat (C2-10b)
|
||||
// and the Discord relay (C2-18). Pure: no git, no clock. The caller passes what it knows about cites.
|
||||
// The guard (C2-06) checks limits before a reply is stored. This only refuses to show a body that
|
||||
// doesn't have the reply shape, and then it shows a fixed line, never the body's text.
|
||||
export const KINDS = Object.freeze({ fact: 'Fact', finding: 'Finding', correction: 'Correction', question: 'Question', idea: 'Idea' });
|
||||
export const NOTICES = Object.freeze({ 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' });
|
||||
export const UNREADABLE = 'This reply could not be shown.';
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
|
||||
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const line = n => Number.isSafeInteger(n) && n > 0;
|
||||
function cite(value, { scopeOf, changed }) {
|
||||
if (!plain(value) || typeof value.quote !== 'string') return null;
|
||||
if (Object.hasOwn(value, 'url')) {
|
||||
if (typeof value.url !== 'string' || Object.hasOwn(value, 'path')) return null;
|
||||
return { badge: 'web', url: value.url, ref: value.url, quote: value.quote, changed: false };
|
||||
}
|
||||
const { path, commit, lines } = value;
|
||||
if (typeof path !== 'string' || !SHA.test(commit ?? '') || !Array.isArray(lines) || lines.length !== 2
|
||||
|| !line(lines[0]) || !line(lines[1]) || lines[1] < lines[0]) return null;
|
||||
const badge = scopeOf?.(path);
|
||||
const ref = `${path}:${lines[0] === lines[1] ? lines[0] : `${lines[0]}-${lines[1]}`} @${commit.slice(0, 7)}`;
|
||||
return { badge: badge === 'canon' || badge === 'playground' ? badge : null, path, commit, lines: [...lines], ref,
|
||||
quote: value.quote, changed: changed?.({ path, commit }) === true };
|
||||
}
|
||||
const quoted = text => JSON.stringify(text);
|
||||
|
||||
// body: the stored message body (a JSON string or the parsed object).
|
||||
// options.scopeOf(path) -> 'canon' | 'playground' | undefined, for the scope badge.
|
||||
// options.changed({path, commit}) -> true when the file's last commit is newer than the cited one.
|
||||
// Returns { kind: 'items', items, text } | { kind: 'notice', notice, text } | { kind: 'unreadable', text }.
|
||||
export function renderItems(body, options = {}) {
|
||||
const unreadable = { kind: 'unreadable', text: UNREADABLE };
|
||||
let value = body;
|
||||
if (typeof body === 'string') { try { value = JSON.parse(body); } catch { return unreadable; } }
|
||||
if (!plain(value) || !Array.isArray(value.items)) return unreadable;
|
||||
const keys = Object.keys(value);
|
||||
if (value.items.length === 0) {
|
||||
if (keys.length !== 2 || !Object.hasOwn(NOTICES, value.notice)) return unreadable;
|
||||
return { kind: 'notice', notice: value.notice, text: NOTICES[value.notice] };
|
||||
}
|
||||
if (keys.length !== 1) return unreadable;
|
||||
const items = [];
|
||||
for (const item of value.items) {
|
||||
if (!plain(item) || !Object.hasOwn(KINDS, item.kind) || typeof item.text !== 'string' || !Array.isArray(item.cites)) return unreadable;
|
||||
const correction = item.kind === 'correction';
|
||||
if (correction && (typeof item.from !== 'string' || typeof item.to !== 'string')) return unreadable;
|
||||
const cites = item.cites.map(c => cite(c, options));
|
||||
if (cites.some(c => c === null)) return unreadable;
|
||||
items.push({ kind: item.kind, label: KINDS[item.kind], text: item.text,
|
||||
...(correction ? { from: item.from, to: item.to } : {}), cites });
|
||||
}
|
||||
const text = items.map((item, i) => [
|
||||
`${i + 1}. ${item.label}: ${item.text}`,
|
||||
...(item.kind === 'correction' ? [` From: ${quoted(item.from)}`, ` To: ${quoted(item.to)}`] : []),
|
||||
...item.cites.map(c => ` ${c.badge ? `[${c.badge}] ` : ''}${c.ref} ${quoted(c.quote)}${c.changed ? ' (changed since)' : ''}`),
|
||||
].join('\n')).join('\n');
|
||||
return { kind: 'items', items, text };
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// The scope rule of design 1.2. Scope comes from the path alone (D2): no file content can change it.
|
||||
// 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' }.
|
||||
// `type` is the mapped type of the first folder under the book (canon) or under the playground
|
||||
// prefix (playground), 'manuscript' for a manuscript folder, else null. `folder` is that folder name.
|
||||
export function classify(mapping, path) {
|
||||
if (typeof path !== 'string' || !path.endsWith('.md')) return { scope: 'unmapped' };
|
||||
for (const [universe, entry] of Object.entries(mapping.universes)) {
|
||||
const book = entry.books.find(b => inside(b, path));
|
||||
if (!book) continue;
|
||||
const prefix = entry.playground.find(p => inside(p, path));
|
||||
const rest = path.slice((prefix ?? `${book}/`).length).split('/');
|
||||
const folder = rest.length > 1 ? rest[0] : null;
|
||||
const type = folder === null ? null : Object.hasOwn(mapping.types, folder) ? mapping.types[folder]
|
||||
: mapping.manuscript.includes(folder) ? 'manuscript' : null;
|
||||
return { scope: prefix ? 'playground' : 'canon', universe, book, type, folder };
|
||||
}
|
||||
return { scope: 'unmapped' };
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// `mosaic library survey <repo> [--mapping <file>]` (design 1.1, 10 step 7). It prints what a mapping
|
||||
// needs: folder names, file counts by extension, front matter key sets and link-form counts. It
|
||||
// never prints file text, front matter values, file names or link targets. With a mapping it adds
|
||||
// scope counts, resolver step counts and R-problem counts by code. It reads the repo's HEAD commit
|
||||
// through the commit reader, never the working tree.
|
||||
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { checkMapping } from './mapping.mjs';
|
||||
import { classify } from './scope.mjs';
|
||||
import { formOf } from './links.mjs';
|
||||
import { headCommit, listTree, parseFile, readable, readLibrary, readTexts } from './reader.mjs';
|
||||
import { LibraryError, RULES } from './problems.mjs';
|
||||
|
||||
export const USAGE = 'usage: mosaic library survey <repo> [--mapping <file>]';
|
||||
// Keys are printed only in the short identifier forms property names take. A key with spaces or of
|
||||
// any other shape could be a phrase of hers, so it is counted as one "(other key)".
|
||||
const KEY = /^[A-Za-z_][A-Za-z0-9_-]{0,31}$/;
|
||||
const OTHER_KEY = '(other key)';
|
||||
const FORMS = ['plain', 'label', 'heading', 'embed', 'self', 'markdown'];
|
||||
const STEPS = ['root', 'vault-prefix', 'relative', 'shortest'];
|
||||
const extension = path => /\.md$/.test(path) ? 'md' : /\.canvas$/.test(path) ? 'canvas' : /\.base$/.test(path) ? 'base' : 'other';
|
||||
|
||||
// The survey as data. `mapping` is a checked mapping or undefined.
|
||||
export async function survey({ repo, commit, mapping }) {
|
||||
const head = commit ?? await headCommit(repo);
|
||||
const entries = await listTree(repo, head);
|
||||
const folders = new Map();
|
||||
for (const entry of entries) {
|
||||
const slash = entry.path.lastIndexOf('/');
|
||||
const name = slash < 0 ? '' : entry.path.slice(0, slash);
|
||||
const counts = folders.get(name) ?? { md: 0, canvas: 0, base: 0, other: 0 };
|
||||
counts[entry.type === 'blob' ? extension(entry.path) : 'other']++;
|
||||
folders.set(name, counts);
|
||||
}
|
||||
const markdown = entries.filter(e => e.path.endsWith('.md'));
|
||||
const texts = await readTexts(repo, markdown.filter(readable));
|
||||
const keySets = new Map(), forms = Object.fromEntries(FORMS.map(f => [f, 0]));
|
||||
const fields = { appears_in: { wikilink: 0, name: 0 }, relationships: { wikilink: 0, name: 0 } };
|
||||
let noFrontMatter = 0, unparsed = 0;
|
||||
for (const text of texts.values()) {
|
||||
const file = parseFile(text, { markdown: true });
|
||||
if (file.stop !== null) unparsed++;
|
||||
else if (!file.frontmatter) noFrontMatter++;
|
||||
else {
|
||||
const set = [...new Set(Object.keys(file.frontmatter).map(k => KEY.test(k) ? k : OTHER_KEY))].sort().join(', ');
|
||||
keySets.set(set, (keySets.get(set) ?? 0) + 1);
|
||||
for (const field of Object.keys(fields)) {
|
||||
const raw = file.frontmatter[field];
|
||||
for (const v of Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw])
|
||||
fields[field][typeof v === 'string' && /^\s*!?\[\[[^\]]+\]\]\s*$/.test(v) ? 'wikilink' : 'name']++;
|
||||
}
|
||||
}
|
||||
for (const link of file.links) if (!link.field) forms[formOf(link)]++;
|
||||
}
|
||||
const result = {
|
||||
commit: head, entries: entries.length,
|
||||
folders: [...folders].sort(([a], [b]) => a < b ? -1 : 1).map(([name, counts]) => ({ name, ...counts })),
|
||||
markdown: { files: markdown.length, read: texts.size, noFrontMatter, unparsed },
|
||||
keySets: [...keySets].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : 1)).map(([keys, count]) => ({ keys, count })),
|
||||
forms, fields,
|
||||
};
|
||||
if (mapping) {
|
||||
const scopes = { canon: 0, playground: 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]));
|
||||
for (const file of library.files) for (const link of file.links) steps[link.step]++;
|
||||
const problems = Object.fromEntries(Object.keys(RULES).map(code => [code, 0]));
|
||||
for (const p of library.problems) problems[p.code]++;
|
||||
steps.unresolved = problems.R4;
|
||||
Object.assign(result, { scopes, indexed: library.files.length, steps, problems });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Folder names are quoted as JSON strings, so no name can carry a control sequence to the terminal.
|
||||
export function formatSurvey(s) {
|
||||
const out = [`commit ${s.commit}`, `entries ${s.entries}`, 'folders (direct children: md canvas base other)'];
|
||||
for (const f of s.folders) out.push(` ${f.name === '' ? '(root)' : JSON.stringify(f.name)} ${f.md} ${f.canvas} ${f.base} ${f.other}`);
|
||||
out.push(`markdown ${s.markdown.files} files, ${s.markdown.read} read, ${s.markdown.noFrontMatter} without front matter, ${s.markdown.unparsed} with front matter not fully parsed`);
|
||||
out.push('front matter key sets');
|
||||
for (const k of s.keySets) out.push(` ${k.count} [${k.keys}]`);
|
||||
out.push(`body links ${FORMS.map(f => `${f} ${s.forms[f]}`).join(', ')}`);
|
||||
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(`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(', ')}`);
|
||||
}
|
||||
return `${out.join('\n')}\n`;
|
||||
}
|
||||
|
||||
// Exit codes as packages/project's CLI: 0 done, 2 the repo can't be read, 3 a bad mapping, 4 usage.
|
||||
export async function surveyMain(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
|
||||
const fail = (code, message) => { io.stderr.write(`library survey: ${message}\n`); return code; };
|
||||
const [verb, ...rest] = argv;
|
||||
if (verb !== 'survey') return fail(4, USAGE);
|
||||
let repo, mappingFile;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === '--mapping' && i + 1 < rest.length && mappingFile === undefined) mappingFile = rest[++i];
|
||||
else if (!rest[i].startsWith('-') && repo === undefined) repo = rest[i];
|
||||
else return fail(4, USAGE);
|
||||
}
|
||||
if (repo === undefined) return fail(4, USAGE);
|
||||
let mapping;
|
||||
if (mappingFile !== undefined) {
|
||||
let entry;
|
||||
try { entry = JSON.parse(readFileSync(mappingFile, 'utf8')); }
|
||||
catch { return fail(3, `library-mapping-invalid: ${mappingFile} is not a readable JSON file`); }
|
||||
try { mapping = checkMapping(entry, mappingFile); }
|
||||
catch (e) { if (e instanceof LibraryError) return fail(3, e.message); throw e; }
|
||||
}
|
||||
const path = resolvePath(repo);
|
||||
try { if (!statSync(path).isDirectory()) return fail(2, `library-git: ${repo} is not a directory`); }
|
||||
catch { return fail(2, `library-git: ${repo} is not a directory`); }
|
||||
try { io.stdout.write(formatSurvey(await survey({ repo: path, mapping }))); }
|
||||
catch (e) { if (e instanceof LibraryError) return fail(2, e.message); throw e; }
|
||||
return 0;
|
||||
}
|
||||
|
||||
const entry = () => { try { return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } };
|
||||
if (process.argv[1] && entry()) {
|
||||
const code = await surveyMain(process.argv.slice(2));
|
||||
if (code !== 0) process.exitCode = code;
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"kind": "library",
|
||||
"remote": "https://git.example.invalid/writer/books.git",
|
||||
"branch": "main",
|
||||
"vaultPrefix": "books",
|
||||
"universes": {
|
||||
"harbor": {"name": "The Harbor", "books": ["Book One"], "playground": ["Book One/Playground/"]},
|
||||
"second": {"name": "Second World", "books": ["Second World"]}
|
||||
},
|
||||
"types": {"Characters": "character", "Places": "place", "Research": "research", "Tracker": "tracker", "Summaries": "summary"},
|
||||
"manuscript": ["Chapters"]
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// A repo the test builds in her layout's shape: spaces and capitals in folder names, a .canvas file
|
||||
// and an unmapped top-level note. Bodies are made up; MARKER stands in for her text.
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, symlinkSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { checkMapping } from '../src/mapping.mjs';
|
||||
|
||||
export const MARKER = 'MARKER-7f3a-HER-TEXT';
|
||||
export const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url));
|
||||
export const mappingEntry = () => JSON.parse(readFileSync(join(FIXTURES, 'mapping.json'), 'utf8'));
|
||||
export const mapping = (change = e => e) => checkMapping(change(mappingEntry()));
|
||||
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'user.name=fixture', '-c', '[email protected]',
|
||||
'-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
|
||||
export const BASE = {
|
||||
'Book One/Characters/Mara Vell.md': [
|
||||
'---', 'type: character', 'name: Mara Vell', 'appears_in: "[[Book One/Chapters/Chapter 01]]"',
|
||||
'relationships:', ' - "[[Tomas]]"', ' - The Ferryman', '---', `Grey eyes. ${MARKER}`, ''].join('\n'),
|
||||
'Book One/Characters/Tomas.md': ['---', 'type: character', 'name: Tomas', 'appears_in: ["[[Chapter 01]]"]', '---', MARKER, ''].join('\n'),
|
||||
'Book One/Chapters/Chapter 01.md': [
|
||||
'# One', `${MARKER} [[Mara Vell]] met [[Book One/Places/Harbor Town|the harbor]].`,
|
||||
'See [[../Places/Harbor Town#Docks]] and [[books/Book One/Characters/Tomas]].',
|
||||
'```', '[[Not A Link]]', '```', 'Inline `[[Not Either]]` and [a note](Notes.md).', ''].join('\n'),
|
||||
'Book One/Places/Harbor Town.md': ['---', 'type: place', '---', `Salt. ${MARKER}`, ''].join('\n'),
|
||||
'Book One/Playground/What If.md': ['---', 'name: What If', '---', `${MARKER} [[Mara Vell]]`, ''].join('\n'),
|
||||
'Book One/Board.canvas': '{"nodes":[]}\n',
|
||||
'Book One/Playground/Sketch.canvas': '{"nodes":[]}\n',
|
||||
'Inbox Note.md': `${MARKER}\n`,
|
||||
'Second World/Chapters/01.md': `# Elsewhere ${MARKER}\n`,
|
||||
};
|
||||
|
||||
// repo({files, extra}) writes files (path -> text, or { link } / { mode, text }), commits, and
|
||||
// returns { repo, commit }.
|
||||
export function repo(t, files = BASE) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'library-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, 'books');
|
||||
mkdirSync(dir);
|
||||
git(dir, 'init', '-q', '-b', 'main');
|
||||
for (const [path, value] of Object.entries(files)) {
|
||||
const file = join(dir, path);
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
if (typeof value === 'object' && value.link) symlinkSync(value.link, file);
|
||||
else if (typeof value === 'object' && value.bytes) writeFileSync(file, value.bytes);
|
||||
else writeFileSync(file, typeof value === 'object' ? value.text : value);
|
||||
if (typeof value === 'object' && value.mode) chmodSync(file, value.mode);
|
||||
}
|
||||
git(dir, 'add', '-A');
|
||||
git(dir, 'commit', '-qm', 'fixture');
|
||||
return { root, repo: dir, commit: git(dir, 'rev-parse', 'HEAD') };
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractLinks, parseWikilink, resolver } from '../src/index.mjs';
|
||||
|
||||
const PATHS = ['Book One/Characters/Mara Vell.md', 'Book One/Characters/Tomas.md', 'Book One/Places/Harbor Town.md',
|
||||
'Book One/Chapters/Chapter 01.md', 'Book One/Places/Notes.md', 'Book One/Characters/Notes.md', 'Mara Vell.md'];
|
||||
const resolve = resolver(PATHS, { vaultPrefix: 'books' });
|
||||
const FROM = 'Book One/Chapters/Chapter 01.md';
|
||||
|
||||
test('resolver step 1: a path from the repo root, with or without .md', () => {
|
||||
assert.deepEqual(resolve(FROM, 'Book One/Places/Harbor Town'), { path: 'Book One/Places/Harbor Town.md', step: 'root' });
|
||||
assert.deepEqual(resolve(FROM, 'Book One/Places/Harbor Town.md'), { path: 'Book One/Places/Harbor Town.md', step: 'root' });
|
||||
// Root wins over shortest: Mara Vell.md at the root, not the character page.
|
||||
assert.deepEqual(resolve(FROM, 'Mara Vell'), { path: 'Mara Vell.md', step: 'root' });
|
||||
});
|
||||
|
||||
test('resolver step 2: the same path with vaultPrefix/ stripped', () => {
|
||||
assert.deepEqual(resolve(FROM, 'books/Book One/Characters/Tomas'), { path: 'Book One/Characters/Tomas.md', step: 'vault-prefix' });
|
||||
assert.deepEqual(resolver(PATHS)(FROM, 'books/Book One/Characters/Tomas'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('resolver step 3: a path relative to the linking file, never above the repo root', () => {
|
||||
assert.deepEqual(resolve(FROM, '../Places/Harbor Town'), { path: 'Book One/Places/Harbor Town.md', step: 'relative' });
|
||||
assert.deepEqual(resolve('Book One/Places/Harbor Town.md', './Notes'), { path: 'Book One/Places/Notes.md', step: 'relative' });
|
||||
assert.deepEqual(resolve(FROM, '../../../Book One/Places/Harbor Town'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('resolver step 4: a base name that matches exactly one indexed file', () => {
|
||||
assert.deepEqual(resolve(FROM, 'Tomas'), { path: 'Book One/Characters/Tomas.md', step: 'shortest' });
|
||||
assert.deepEqual(resolve(FROM, 'Harbor Town.md'), { path: 'Book One/Places/Harbor Town.md', step: 'shortest' });
|
||||
assert.deepEqual(resolve(FROM, 'Notes'), { path: null, step: 'ambiguous', count: 2 });
|
||||
assert.deepEqual(resolve(FROM, 'Nobody'), { path: null, step: 'missing' });
|
||||
// A partial path is not a base name.
|
||||
assert.deepEqual(resolve(FROM, 'Places/Harbor Town'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('a link into another of her repos resolves to nothing', () => {
|
||||
assert.deepEqual(resolve(FROM, 'bean-brain/Journal/Today'), { path: null, step: 'missing' });
|
||||
assert.deepEqual(resolve(FROM, '../bean-brain/Journal/Today'), { path: null, step: 'missing' });
|
||||
});
|
||||
|
||||
test('wikilink forms, embeds, and links skipped inside code', () => {
|
||||
assert.deepEqual(parseWikilink('[[A/B#Head|Label]]'), { file: 'A/B', heading: 'Head', label: 'Label', embed: false });
|
||||
assert.deepEqual(parseWikilink('![[pic.png]]'), { file: 'pic.png', heading: null, label: null, embed: true });
|
||||
assert.equal(parseWikilink('[[a]] and more'), null);
|
||||
const lines = [[1, '[[One]] [[Two|2]] [[Three#H]] ![[Four]] [[#Self]]'], [2, '```js'], [3, '[[Fenced]]'], [4, '```'],
|
||||
[5, 'Inline `[[Code]]` and [md](Five.md) and [web](https://example.invalid/x)']];
|
||||
assert.deepEqual(extractLinks(lines).map(l => [l.file, l.line]), [['One', 1], ['Two', 1], ['Three', 1], ['Four', 1], ['', 1]]);
|
||||
assert.deepEqual(extractLinks(lines, { markdown: true }).filter(l => l.markdown).map(l => l.file), ['Five.md']);
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { checkMapping, classify, LibraryError } from '../src/index.mjs';
|
||||
import { mapping, mappingEntry } from './helpers.mjs';
|
||||
|
||||
const refuses = (change, code, pattern) => assert.throws(() => checkMapping(change(mappingEntry())),
|
||||
e => e instanceof LibraryError && e.code === code && (!pattern || pattern.test(e.message)));
|
||||
|
||||
test('the fixture mapping checks and comes back frozen', () => {
|
||||
const m = mapping();
|
||||
assert.equal(m.universes.harbor.playground[0], 'Book One/Playground/');
|
||||
assert.ok(Object.isFrozen(m.universes.harbor.books));
|
||||
assert.throws(() => { m.types.Extra = 'place'; });
|
||||
});
|
||||
|
||||
test('a playground prefix outside its universe books refuses library-playground-outside-book', () => {
|
||||
refuses(e => { e.universes.harbor.playground = ['Second World/Playground/']; return e; }, 'library-playground-outside-book');
|
||||
refuses(e => { e.universes.harbor.playground = ['Elsewhere/']; return e; }, 'library-playground-outside-book');
|
||||
// The whole book as its playground would leave no canon: not inside the book.
|
||||
refuses(e => { e.universes.harbor.playground = ['Book One/']; return e; }, 'library-playground-outside-book');
|
||||
});
|
||||
|
||||
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/);
|
||||
refuses(e => { e.universes.second.books = ['Second World', 'Book One/Part Two']; return e; }, 'library-mapping-invalid', /holds book folder/);
|
||||
refuses(e => { e.universes.second.books = ['Book One']; return e; }, 'library-mapping-invalid', /in two universes/);
|
||||
});
|
||||
|
||||
test('shape refusals: kind, remote, branch, unknown keys, types, manuscript, folder names', () => {
|
||||
refuses(e => ({ ...e, kind: 'code' }), 'library-mapping-invalid', /kind/);
|
||||
refuses(e => ({ ...e, remote: 'http://git.example.invalid/x.git' }), 'library-mapping-invalid', /https/);
|
||||
refuses(e => ({ ...e, remote: 'https://user:[email protected]/x.git' }), 'library-mapping-invalid', /credentials/);
|
||||
refuses(e => ({ ...e, remote: '[email protected]:x.git' }), 'library-mapping-invalid', /https/);
|
||||
refuses(e => ({ ...e, branch: 'main..x' }), 'library-mapping-invalid', /branch/);
|
||||
refuses(e => ({ ...e, root: '/tmp' }), 'library-mapping-invalid', /unknown key "root"/);
|
||||
refuses(e => { e.universes.harbor.extra = 1; return e; }, 'library-mapping-invalid', /unknown key/);
|
||||
refuses(e => { e.types.Characters = 'villain'; return e; }, 'library-mapping-invalid', /types.Characters/);
|
||||
refuses(e => { e.types['A/B'] = 'place'; return e; }, 'library-mapping-invalid', /one folder name/);
|
||||
refuses(e => ({ ...e, manuscript: ['Characters'] }), 'library-mapping-invalid', /also a type folder/);
|
||||
refuses(e => { e.universes.harbor.books = ['../Book One']; return e; }, 'library-mapping-invalid', /folder path/);
|
||||
refuses(e => ({ ...e, vaultPrefix: 'a/b' }), 'library-mapping-invalid', /vaultPrefix/);
|
||||
refuses(e => ({ ...e, universes: {} }), 'library-mapping-invalid', /at least one universe/);
|
||||
refuses(e => { e.universes['bad id'] = e.universes.second; delete e.universes.second; return e; }, 'library-mapping-invalid', /plain id/);
|
||||
});
|
||||
|
||||
test('a hybrid universe is a shape the query code can take (1.4), with sources that must exist', () => {
|
||||
const hybrid = e => { e.universes.cross = { kind: 'hybrid', name: 'Crossover', sources: ['harbor', 'second'], books: ['Crossover'],
|
||||
constraints: [{ exclude: 'second/Characters' }, { pin: { entity: 'Book One/Characters/Mara Vell.md', field: 'eyes', value: 'grey' } }],
|
||||
notes: 'Set after book one.' }; return e; };
|
||||
const m = checkMapping(hybrid(mappingEntry()));
|
||||
assert.deepEqual(m.universes.cross.sources, ['harbor', 'second']);
|
||||
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor', 'nowhere']; return e; }, 'library-mapping-invalid', /non-hybrid universe/);
|
||||
refuses(e => { hybrid(e); e.universes.cross.sources = ['harbor']; return e; }, 'library-mapping-invalid', /two or more/);
|
||||
refuses(e => { hybrid(e); e.universes.cross.constraints = [{ exclude: 'x', pin: {} }]; return e; }, 'library-mapping-invalid', /exactly one/);
|
||||
});
|
||||
|
||||
test('a caller-supplied fail receives the code, as business file loading will pass it (C2-04)', () => {
|
||||
const seen = [];
|
||||
const fail = (code, message) => { seen.push(code); throw new Error(message); };
|
||||
const entry = mappingEntry();
|
||||
entry.universes.harbor.playground = ['Elsewhere/'];
|
||||
assert.throws(() => checkMapping(entry, 'businesses/acme.json projects.books', { fail }), /businesses\/acme.json projects.books/);
|
||||
assert.deepEqual(seen, ['library-playground-outside-book']);
|
||||
});
|
||||
|
||||
test('scope comes from the path alone (design 1.2)', () => {
|
||||
const m = mapping();
|
||||
assert.deepEqual(classify(m, 'Book One/Characters/Mara Vell.md'),
|
||||
{ scope: 'canon', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
|
||||
assert.deepEqual(classify(m, 'Book One/Chapters/Chapter 01.md'),
|
||||
{ scope: 'canon', universe: 'harbor', book: 'Book One', type: 'manuscript', folder: 'Chapters' });
|
||||
assert.deepEqual(classify(m, 'Book One/Playground/What If.md'),
|
||||
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: null, folder: null });
|
||||
assert.deepEqual(classify(m, 'Book One/Playground/Characters/New.md'),
|
||||
{ scope: 'playground', universe: 'harbor', book: 'Book One', type: 'character', folder: 'Characters' });
|
||||
assert.deepEqual(classify(m, 'Book One/Notes.md'), { scope: 'canon', universe: 'harbor', book: 'Book One', type: null, folder: null });
|
||||
assert.equal(classify(m, 'Second World/Chapters/01.md').universe, 'second');
|
||||
for (const path of ['Inbox Note.md', 'Book One/Board.canvas', 'Book One/Playground/Sketch.canvas', 'Book One/Board.base',
|
||||
'Book One/cover.png', 'Book One/Upper.MD', 'Book Onex/a.md', 'book one/a.md', 'Elsewhere/Book One/a.md'])
|
||||
assert.deepEqual(classify(m, path), { scope: 'unmapped' }, path);
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { readLibrary, parseFrontMatter, LibraryError, MAX_BLOB } from '../src/index.mjs';
|
||||
import { BASE, git, mapping, repo } from './helpers.mjs';
|
||||
|
||||
const codes = (result, path) => result.problems.filter(p => p.path === path).map(p => p.code);
|
||||
const file = (result, path) => result.files.find(f => f.path === path);
|
||||
|
||||
test('the fixture reads clean: every resolver step, appears_in and relationships wikilinks indexed', async t => {
|
||||
const r = repo(t);
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.equal(result.commit, r.commit);
|
||||
assert.deepEqual(result.files.map(f => [f.path, f.scope]), [
|
||||
['Book One/Chapters/Chapter 01.md', 'canon'], ['Book One/Characters/Mara Vell.md', 'canon'],
|
||||
['Book One/Characters/Tomas.md', 'canon'], ['Book One/Places/Harbor Town.md', 'canon'],
|
||||
['Book One/Playground/What If.md', 'playground'], ['Second World/Chapters/01.md', 'canon']]);
|
||||
const chapter = file(result, 'Book One/Chapters/Chapter 01.md');
|
||||
assert.deepEqual(chapter.links.map(l => [l.path, l.step, l.line]), [
|
||||
['Book One/Characters/Mara Vell.md', 'shortest', 2], ['Book One/Places/Harbor Town.md', 'root', 2],
|
||||
['Book One/Places/Harbor Town.md', 'relative', 3], ['Book One/Characters/Tomas.md', 'vault-prefix', 3]]);
|
||||
const mara = file(result, 'Book One/Characters/Mara Vell.md');
|
||||
assert.deepEqual(mara.frontmatter, { type: 'character', name: 'Mara Vell', appears_in: '[[Book One/Chapters/Chapter 01]]',
|
||||
relationships: ['[[Tomas]]', 'The Ferryman'] });
|
||||
assert.deepEqual(mara.links.map(l => [l.field, l.path, l.line]), [
|
||||
['appears_in', 'Book One/Chapters/Chapter 01.md', 4], ['relationships', 'Book One/Characters/Tomas.md', 5]]);
|
||||
assert.deepEqual(file(result, 'Book One/Characters/Tomas.md').links.map(l => [l.field, l.path]),
|
||||
[['appears_in', 'Book One/Chapters/Chapter 01.md']]);
|
||||
assert.equal(mara.bodyLine, 9);
|
||||
assert.equal(mara.lines[8][0], 9);
|
||||
assert.equal(mara.type, 'character');
|
||||
// Only the unmapped paths are problems: the top-level note and both .canvas files.
|
||||
assert.deepEqual(result.problems.map(p => [p.code, p.path]), [
|
||||
['R2', 'Book One/Board.canvas'], ['R2', 'Book One/Playground/Sketch.canvas'], ['R2', 'Inbox Note.md']]);
|
||||
assert.ok(Object.isFrozen(result.files[0].links));
|
||||
});
|
||||
|
||||
test('R1: executables, symlinks, gitlinks, blobs over 1 MiB and non-UTF-8 text are not indexed', async t => {
|
||||
const r = repo(t, { ...BASE,
|
||||
'Book One/Places/Run.md': { text: 'x\n', mode: 0o755 },
|
||||
'Book One/Places/Link.md': { link: 'Harbor Town.md' },
|
||||
'Book One/Places/Big.md': 'x'.repeat(MAX_BLOB + 1),
|
||||
'Book One/Places/Edge.md': 'y'.repeat(MAX_BLOB),
|
||||
'Book One/Places/Latin.md': { bytes: Buffer.from([0x63, 0x61, 0x66, 0xe9, 0x0a]) },
|
||||
'Book One/Places/Nul.md': 'a\0b\n' });
|
||||
git(r.repo, 'update-index', '--add', '--cacheinfo', `160000,${r.commit},Book One/Places/Sub.md`);
|
||||
git(r.repo, 'commit', '-qm', 'gitlink');
|
||||
const result = await readLibrary({ repo: r.repo, commit: git(r.repo, 'rev-parse', 'HEAD'), mapping: mapping() });
|
||||
for (const name of ['Run', 'Link', 'Big', 'Latin', 'Nul', 'Sub']) {
|
||||
assert.deepEqual(codes(result, `Book One/Places/${name}.md`), ['R1'], name);
|
||||
assert.equal(file(result, `Book One/Places/${name}.md`), undefined, name);
|
||||
}
|
||||
assert.ok(file(result, 'Book One/Places/Edge.md'), 'exactly 1 MiB is read');
|
||||
});
|
||||
|
||||
test('R2: unmapped paths are reported and not indexed', async t => {
|
||||
const r = repo(t);
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
for (const path of ['Inbox Note.md', 'Book One/Board.canvas', 'Book One/Playground/Sketch.canvas']) {
|
||||
assert.deepEqual(codes(result, path), ['R2']);
|
||||
assert.equal(file(result, path), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('R3: front matter not fully parsed stays raw, with the line where parsing stopped', async t => {
|
||||
const r = repo(t, { ...BASE,
|
||||
'Book One/Places/Nested.md': ['---', 'type: place', 'coords:', ' lat: 1', '---', 'Body [[Tomas]]', ''].join('\n'),
|
||||
'Book One/Places/Block.md': ['---', 'summary: |', ' text', '---', ''].join('\n'),
|
||||
'Book One/Places/Open.md': ['---', 'type: place', 'never closed', ''].join('\n') });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
const nested = file(result, 'Book One/Places/Nested.md');
|
||||
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Places/Nested.md'), [{ code: 'R3', path: 'Book One/Places/Nested.md', line: 4, message: 'front matter not fully parsed' }]);
|
||||
assert.equal(nested.frontmatter, null);
|
||||
assert.equal(nested.frontmatterRaw, 'type: place\ncoords:\n lat: 1');
|
||||
assert.deepEqual(nested.links.map(l => l.path), ['Book One/Characters/Tomas.md'], 'still indexed by body');
|
||||
assert.equal(result.problems.find(p => p.path === 'Book One/Places/Block.md').line, 2);
|
||||
assert.equal(result.problems.find(p => p.path === 'Book One/Places/Open.md').line, 1);
|
||||
assert.equal(file(result, 'Book One/Places/Open.md').bodyLine, 1);
|
||||
});
|
||||
|
||||
test('front matter forms the reader accepts and refuses', () => {
|
||||
const parse = text => parseFrontMatter(text.split('\n'));
|
||||
assert.deepEqual(parse('---\na: plain words\nb: "quoted: yes"\nc: \'it\'\'s\'\nd: [x, "y z", 3]\ne:\n- one\n- "[[Two]]"\nf: [[Wiki]]\ng: true\nh:\n---').frontmatter,
|
||||
{ a: 'plain words', b: 'quoted: yes', c: "it's", d: ['x', 'y z', 3], e: ['one', '[[Two]]'], f: '[[Wiki]]', g: true, h: null });
|
||||
for (const bad of ['a: {x: 1}', 'a: &anchor x', 'a: [x, [y]]', 'a: x: y', 'a: 1\na: 2', ' a: 1', 'a: "open', '- loose'])
|
||||
assert.notEqual(parse(`---\n${bad}\n---`).stop, null, bad);
|
||||
assert.deepEqual(parse('no front matter'), { frontmatter: null, raw: null, bodyLine: 1, stop: null });
|
||||
});
|
||||
|
||||
test('R4: a link to no file, to more than one, or into another repo is reported and not followed', async t => {
|
||||
const r = repo(t, { ...BASE,
|
||||
'Book One/Research/Notes.md': 'a\n', 'Book One/Places/Notes.md': 'b\n',
|
||||
'Book One/Chapters/Links.md': ['[[Nobody]]', '[[Notes]]', '[[bean-brain/Journal/Today]]', '[[Tomas]]', ''].join('\n') });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.deepEqual(result.problems.filter(p => p.path === 'Book One/Chapters/Links.md').map(p => [p.code, p.line]), [['R4', 1], ['R4', 2], ['R4', 3]]);
|
||||
assert.deepEqual(file(result, 'Book One/Chapters/Links.md').links.map(l => l.path), ['Book One/Characters/Tomas.md']);
|
||||
});
|
||||
|
||||
test('R5: a canon file that refers into the playground is reported, and the link still goes by path', async t => {
|
||||
const r = repo(t, { ...BASE, 'Book One/Places/Dock.md': 'See [[What If]].\n' });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.deepEqual(codes(result, 'Book One/Places/Dock.md'), ['R5']);
|
||||
assert.deepEqual(file(result, 'Book One/Places/Dock.md').links.map(l => l.path), ['Book One/Playground/What If.md']);
|
||||
// The playground file links to canon: no problem, R5 is only about canon.
|
||||
assert.deepEqual(codes(result, 'Book One/Playground/What If.md'), []);
|
||||
});
|
||||
|
||||
test('R6: a canon file that refers into another universe is reported; a hybrid may refer into its sources', async t => {
|
||||
const r = repo(t, { ...BASE, 'Book One/Places/Far.md': 'Like [[Second World/Chapters/01]].\n',
|
||||
'Crossover/Chapters/A.md': 'From [[Mara Vell]] and [[Second World/Chapters/01]].\n' });
|
||||
const hybrid = mapping(e => { e.universes.cross = { kind: 'hybrid', name: 'Cross', sources: ['harbor', 'second'], books: ['Crossover'] }; return e; });
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: hybrid });
|
||||
assert.deepEqual(codes(result, 'Book One/Places/Far.md'), ['R6']);
|
||||
assert.deepEqual(file(result, 'Book One/Places/Far.md').links.map(l => l.path), ['Second World/Chapters/01.md']);
|
||||
assert.deepEqual(codes(result, 'Crossover/Chapters/A.md'), []);
|
||||
});
|
||||
|
||||
test('R7: two paths that differ only in case are both left out', async t => {
|
||||
const r = repo(t, BASE);
|
||||
writeFileSync(join(r.root, 'twin'), 'twin\n');
|
||||
const blob = git(r.repo, 'hash-object', '-w', join(r.root, 'twin'));
|
||||
git(r.repo, 'update-index', '--add', '--cacheinfo', `100644,${blob},Book One/Characters/tomas.md`);
|
||||
git(r.repo, 'commit', '-qm', 'case twin');
|
||||
const result = await readLibrary({ repo: r.repo, commit: git(r.repo, 'rev-parse', 'HEAD'), mapping: mapping() });
|
||||
for (const path of ['Book One/Characters/Tomas.md', 'Book One/Characters/tomas.md']) {
|
||||
assert.deepEqual(codes(result, path), ['R7'], path);
|
||||
assert.equal(file(result, path), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reader reads the commit, never the working tree, and refuses a bad commit id', async t => {
|
||||
const r = repo(t);
|
||||
writeFileSync(join(r.repo, 'Book One/Places/Harbor Town.md'), 'unsaved [[Nobody]]\n');
|
||||
writeFileSync(join(r.repo, 'Book One/Places/Untracked.md'), 'x\n');
|
||||
const result = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
assert.equal(file(result, 'Book One/Places/Harbor Town.md').lines[0][1], '---');
|
||||
assert.equal(file(result, 'Book One/Places/Untracked.md'), undefined);
|
||||
for (const commit of ['HEAD', r.commit.slice(0, 12), '0'.repeat(40), git(r.repo, 'rev-parse', 'HEAD^{tree}')])
|
||||
await assert.rejects(readLibrary({ repo: r.repo, commit, mapping: mapping() }), e => e instanceof LibraryError, commit);
|
||||
await assert.rejects(readLibrary({ repo: r.repo, commit: r.commit, mapping: {} }), /library-mapping-invalid/);
|
||||
});
|
||||
|
||||
test('a bare mirror reads the same as the clone', async t => {
|
||||
const r = repo(t);
|
||||
const bare = join(r.root, 'mirror.git');
|
||||
git(r.root, 'clone', '-q', '--bare', r.repo, bare);
|
||||
const a = await readLibrary({ repo: r.repo, commit: r.commit, mapping: mapping() });
|
||||
const b = await readLibrary({ repo: bare, commit: r.commit, mapping: mapping() });
|
||||
assert.deepEqual(b, a);
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderItems, UNREADABLE } from '../src/index.mjs';
|
||||
|
||||
const A = 'a'.repeat(40), B = 'b'.repeat(40);
|
||||
const cite = (path, lines, quote, commit = A) => ({ path, commit, lines, quote });
|
||||
const options = {
|
||||
scopeOf: path => path.includes('/Playground/') ? 'playground' : path.endsWith('.md') ? 'canon' : undefined,
|
||||
changed: ({ path, commit }) => path === 'Book One/Characters/Mara Vell.md' && commit === A,
|
||||
};
|
||||
const render = items => renderItems(JSON.stringify({ items }), options);
|
||||
|
||||
// One snapshot per item kind: the plain text both channels show.
|
||||
const SNAPSHOTS = {
|
||||
fact: [{ kind: 'fact', text: 'Tomas is the ferryman.', cites: [cite('Book One/Characters/Tomas.md', [3, 3], 'the ferryman', B)] }],
|
||||
finding: [{ kind: 'finding', text: 'Eye colour differs.', cites: [cite('Book One/Chapters/Chapter 01.md', [41, 41], 'her grey eyes'),
|
||||
cite('Book One/Characters/Mara Vell.md', [9, 10], 'green eyes')] }],
|
||||
correction: [{ kind: 'correction', text: 'Chapter 1 says grey.', from: 'green eyes', to: 'grey eyes',
|
||||
cites: [cite('Book One/Characters/Mara Vell.md', [9, 9], 'green eyes', B)] }],
|
||||
question: [{ kind: 'question', text: 'Is the harbor north of the town?', cites: [cite('Book One/Places/Harbor Town.md', [2, 4], 'the harbor', B)] }],
|
||||
idea: [{ kind: 'idea', text: 'A storm could close the harbor.', cites: [cite('Book One/Playground/What If.md', [5, 5], 'a storm', B),
|
||||
{ url: 'https://example.invalid/tides', quote: 'spring tides' }] }],
|
||||
};
|
||||
const EXPECTED = {
|
||||
fact: '1. Fact: Tomas is the ferryman.\n [canon] Book One/Characters/Tomas.md:3 @bbbbbbb "the ferryman"',
|
||||
finding: '1. Finding: Eye colour differs.\n [canon] Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes" (changed since)',
|
||||
correction: '1. Correction: Chapter 1 says grey.\n From: "green eyes"\n To: "grey eyes"\n'
|
||||
+ ' [canon] Book One/Characters/Mara Vell.md:9 @bbbbbbb "green eyes"',
|
||||
question: '1. Question: Is the harbor north of the town?\n [canon] Book One/Places/Harbor Town.md:2-4 @bbbbbbb "the harbor"',
|
||||
idea: '1. Idea: A storm could close the harbor.\n [playground] Book One/Playground/What If.md:5 @bbbbbbb "a storm"\n'
|
||||
+ ' [web] https://example.invalid/tides "spring tides"',
|
||||
};
|
||||
|
||||
for (const kind of Object.keys(SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: ${kind}`, () => {
|
||||
const out = render(SNAPSHOTS[kind]);
|
||||
assert.equal(out.kind, 'items');
|
||||
assert.equal(out.text, EXPECTED[kind]);
|
||||
assert.equal(out.items[0].kind, kind);
|
||||
});
|
||||
}
|
||||
|
||||
test('renderItems numbers several items and takes a parsed body as well as a string', () => {
|
||||
const items = [...SNAPSHOTS.fact, ...SNAPSHOTS.question];
|
||||
const out = renderItems({ items }, options);
|
||||
assert.equal(out.text, `${EXPECTED.fact}\n${EXPECTED.question.replace(/^1\./, '2.')}`);
|
||||
assert.deepEqual(out.items[1].cites[0], { badge: 'canon', path: 'Book One/Places/Harbor Town.md', commit: B, lines: [2, 4],
|
||||
ref: 'Book One/Places/Harbor Town.md:2-4 @bbbbbbb', quote: 'the harbor', changed: false });
|
||||
});
|
||||
|
||||
test('without options a path cite has no badge and is never marked changed', () => {
|
||||
assert.equal(renderItems({ items: SNAPSHOTS.finding }).text,
|
||||
'1. Finding: Eye colour differs.\n Book One/Chapters/Chapter 01.md:41 @aaaaaaa "her grey eyes"\n'
|
||||
+ ' Book One/Characters/Mara Vell.md:9-10 @aaaaaaa "green eyes"');
|
||||
});
|
||||
|
||||
const NOTICE_SNAPSHOTS = { 'no-cited-answer': 'No cited answer', 'no-answer': 'No answer', 'turn-failed': 'The turn failed' };
|
||||
for (const [notice, text] of Object.entries(NOTICE_SNAPSHOTS)) {
|
||||
test(`renderItems snapshot: notice ${notice}`, () => {
|
||||
assert.deepEqual(renderItems(JSON.stringify({ items: [], notice }), options), { kind: 'notice', notice, text });
|
||||
});
|
||||
}
|
||||
|
||||
test('a body without the reply shape shows a fixed line and none of its text', () => {
|
||||
const secret = 'free prose the guard would refuse';
|
||||
const bodies = [secret, JSON.stringify(secret), '{"items": [], "notice": "other"}', JSON.stringify({ items: [], notice: 'no-answer', extra: secret }),
|
||||
JSON.stringify({ items: [] }), JSON.stringify({ items: [{ kind: 'story', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret }] }), JSON.stringify({ items: SNAPSHOTS.fact, prose: secret }),
|
||||
JSON.stringify({ items: [{ kind: 'correction', text: secret, cites: [] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [cite('a.md', [2, 1], 'q')] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ path: 'a.md', commit: 'abc', lines: [1, 1], quote: 'q' }] }] }),
|
||||
JSON.stringify({ items: [{ kind: 'fact', text: secret, cites: [{ url: 'https://x.invalid', path: 'a.md', quote: 'q' }] }] }),
|
||||
null, 42, '[]'];
|
||||
for (const body of bodies) {
|
||||
const out = renderItems(body, options);
|
||||
assert.deepEqual(out, { kind: 'unreadable', text: UNREADABLE }, String(body));
|
||||
assert.ok(!JSON.stringify(out).includes(secret));
|
||||
}
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { surveyMain } from '../src/index.mjs';
|
||||
import { BASE, FIXTURES, MARKER, repo } from './helpers.mjs';
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../../..', import.meta.url));
|
||||
async function run(args) {
|
||||
let stdout = '', stderr = '';
|
||||
const code = await surveyMain(args, { stdout: { write: s => { stdout += s; } }, stderr: { write: s => { stderr += s; } } });
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
// Strings from the fixture that are text, values, file names or link targets: none may be printed.
|
||||
const NEVER = [MARKER, 'Grey eyes', 'The Ferryman', 'Mara Vell', 'Tomas', 'Harbor Town', 'What If', 'Inbox Note', 'Chapter 01',
|
||||
'Sketch', 'Board', 'Not A Link', 'the harbor', 'Docks', 'character'];
|
||||
|
||||
test('the survey prints folder names, counts, key sets and link-form counts, and never text', async t => {
|
||||
const r = repo(t, { ...BASE, 'Book One/Places/Odd.md': ['---', `"${MARKER} key": 1`, '---', ''].join('\n') });
|
||||
const out = await run(['survey', r.repo]);
|
||||
assert.equal(out.code, 0, out.stderr);
|
||||
assert.equal(out.stdout, [
|
||||
`commit ${r.commit}`,
|
||||
'entries 10',
|
||||
'folders (direct children: md canvas base other)',
|
||||
' (root) 1 0 0 0',
|
||||
' "Book One" 0 1 0 0',
|
||||
' "Book One/Chapters" 1 0 0 0',
|
||||
' "Book One/Characters" 2 0 0 0',
|
||||
' "Book One/Places" 2 0 0 0',
|
||||
' "Book One/Playground" 1 1 0 0',
|
||||
' "Second World/Chapters" 1 0 0 0',
|
||||
'markdown 8 files, 8 read, 3 without front matter, 0 with front matter not fully parsed',
|
||||
'front matter key sets',
|
||||
' 1 [(other key)]',
|
||||
' 1 [appears_in, name, relationships, type]',
|
||||
' 1 [appears_in, name, type]',
|
||||
' 1 [name]',
|
||||
' 1 [type]',
|
||||
'body links plain 3, label 1, heading 1, embed 0, self 0, markdown 1',
|
||||
'field appears_in wikilink 2, name 0',
|
||||
'field relationships wikilink 1, name 1',
|
||||
''].join('\n'));
|
||||
for (const s of NEVER) assert.ok(!out.stdout.includes(s), s);
|
||||
});
|
||||
|
||||
test('with --mapping the survey adds scope, resolver step and problem counts', async t => {
|
||||
const r = repo(t);
|
||||
const out = await run(['survey', r.repo, '--mapping', join(FIXTURES, 'mapping.json')]);
|
||||
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',
|
||||
'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('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]])
|
||||
assert.equal((await run(args)).code, 4, args.join(' '));
|
||||
const bad = join(r.root, 'bad.json');
|
||||
writeFileSync(bad, JSON.stringify({ kind: 'library' }));
|
||||
const refused = await run(['survey', r.repo, '--mapping', bad]);
|
||||
assert.equal(refused.code, 3);
|
||||
assert.match(refused.stderr, /library-mapping-invalid/);
|
||||
writeFileSync(bad, 'not json');
|
||||
assert.equal((await run(['survey', r.repo, '--mapping', bad])).code, 3);
|
||||
assert.equal((await run(['survey', join(r.root, 'missing')])).code, 2);
|
||||
assert.equal((await run(['survey', r.root])).code, 2, 'a directory that is not a repo');
|
||||
});
|
||||
|
||||
test('scripts/mosaic library survey reaches the survey', t => {
|
||||
const r = repo(t);
|
||||
const out = spawnSync(join(ROOT, 'scripts/mosaic'), ['library', 'survey', r.repo], { encoding: 'utf8', timeout: 30000 });
|
||||
assert.equal(out.status, 0, out.stderr);
|
||||
assert.match(out.stdout, new RegExp(`^commit ${r.commit}\n`));
|
||||
assert.ok(!out.stdout.includes(MARKER));
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
// Row 74 (#1568, lead decision 93): the preview mode. A request for the public name needs the
|
||||
// proxy's header equal to the one user id, else 401 with nothing read; in preview mode nothing
|
||||
// but GET and HEAD gets through, whatever the Host; loopback requests work as they did. Round 2:
|
||||
@@ -205,8 +207,7 @@ test('in preview mode every write answers 403 read-only, from the public name an
|
||||
});
|
||||
|
||||
test('loopback requests are unchanged in preview mode, and the bus views work with the board down', async t => {
|
||||
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
|
||||
const dead = `http://127.0.0.1:${probe.address().port}`; await close(probe);
|
||||
const dead = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
const { port, busCalls } = await setup(t, { board: dead, timeout: 200 });
|
||||
const loopback = { host: `127.0.0.1:${port}` };
|
||||
const inbox = await send(port, { headers: loopback });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
@@ -62,8 +64,7 @@ test('serve argv carries only fixture proof path and header; startup explicitly
|
||||
test('CLI applies proof on the main listener and rejects that header on its tester listener', async t => {
|
||||
const f = await testerFixture(t), file = join(f.root, 'proof.fixture');
|
||||
writeFileSync(file, VALUE, { mode: 0o600 });
|
||||
const free = createServer(); free.listen(0, '127.0.0.1'); await once(free, 'listening');
|
||||
const testerPort = free.address().port; await new Promise(r => free.close(r));
|
||||
const testerPort = await leasePort({after:laneAfter}, {closed:true});
|
||||
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--tester-port', String(testerPort), '--business', 'acme', ...PREVIEW, '--proof-header', 'x-console-proof', '--proof-file', file], { env: f.f.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = ''; child.stdout.on('data', c => output += c); child.stderr.resume();
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } });
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, get } from 'node:http';
|
||||
@@ -97,8 +99,7 @@ test('proxy preserves exact request bytes, status and receipt, rejects forms and
|
||||
});
|
||||
|
||||
test('unreachable board reports URL; CLI rejects unsupported options', async () => {
|
||||
const probe = createServer(); await new Promise(r => probe.listen(0, '127.0.0.1', r));
|
||||
const url = `http://127.0.0.1:${probe.address().port}`; await close(probe);
|
||||
const url = `http://127.0.0.1:${await leasePort({after:laneAfter}, {closed:true})}`;
|
||||
const web = await startServer({ port: 0, board: url, timeout: 100 });
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${web.address().port}/api/board`);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
@@ -45,15 +47,7 @@ function filled(flags, root = REPO) {
|
||||
|
||||
// A loopback port with nothing listening, so status's /healthz request
|
||||
// reaches no real Console.
|
||||
async function closedPort() {
|
||||
const server = createServer();
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const { port } = server.address();
|
||||
server.close();
|
||||
await once(server, 'close');
|
||||
return port;
|
||||
}
|
||||
async function closedPort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
|
||||
function tmp(t) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
||||
import { after as laneAfter } from 'node:test';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:net';
|
||||
@@ -15,10 +17,7 @@ const get = (port, path) => new Promise((resolve, reject) => {
|
||||
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text }));
|
||||
}); q.on('error', reject); q.end();
|
||||
});
|
||||
async function freePort() {
|
||||
const s = createServer(); s.listen(0, '127.0.0.1'); await once(s, 'listening');
|
||||
const port = s.address().port; await new Promise(r => s.close(r)); return port;
|
||||
}
|
||||
async function freePort() { return leasePort({after:laneAfter}, {closed:true}); }
|
||||
|
||||
test('tester-port validates range tunnel main port and requires configured bus before listening', t => {
|
||||
const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root);
|
||||
|
||||
@@ -34,12 +34,6 @@ if [ "${1:-}" = business ]; then
|
||||
shift
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
# `mosaic library survey`: names and counts from a writing repo, never its text
|
||||
# (packages/library/README.md). Other library verbs are bus verbs.
|
||||
if [ "${1:-}" = library ] && [ "${2:-}" = survey ]; then
|
||||
shift
|
||||
exec node "$REPO/packages/library/src/survey.mjs" "$@"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
|
||||
@@ -33,7 +33,7 @@ COPY_PATHS=(
|
||||
packages/conversation/src packages/discord/src packages/discord/bin packages/discord/extension packages/harness/src
|
||||
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
|
||||
packages/webui/src packages/webui/README.md packages/cli/README.md
|
||||
packages/queue/src packages/library/src
|
||||
packages/queue/src
|
||||
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
|
||||
packages/plugins
|
||||
)
|
||||
|
||||
+10
-1
@@ -71,6 +71,8 @@ EOF_PROBE
|
||||
mkdir -p "$SANDBOX/toolroot/plans"
|
||||
echo "row one" >"$SANDBOX/toolroot/plans/QUEUE.md"
|
||||
TOOLS_JSON="{\"roots\":[{\"name\":\"docs\",\"path\":\"$SANDBOX/toolroot\"}],\"maxFileBytes\":4096,\"maxCallsPerTurn\":8}"
|
||||
export PI_CODING_AGENT_DIR="$SANDBOX/pi-agent"
|
||||
mkdir -p "$PI_CODING_AGENT_DIR"
|
||||
PI_COMMON="--mode rpc --no-extensions --no-context-files --no-skills --no-prompt-templates --no-themes --offline --no-session --provider zai --model glm-5.3"
|
||||
if [ -x "$PI_BIN" ]; then
|
||||
printf '{"type":"get_state","id":"a"}\n' | MOSAIC_DISCORD_TOOLS="$TOOLS_JSON" timeout 60 "$PI_BIN" $PI_COMMON --no-builtin-tools \
|
||||
@@ -142,7 +144,14 @@ fi
|
||||
# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
|
||||
# to that runner and exits 0 whatever its tests do, so the suite clears it
|
||||
# (#1508 N13). The planted failing test proves a failure still fails here.
|
||||
node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
|
||||
node_tests() {
|
||||
local lane_concurrency=()
|
||||
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
|
||||
lane_concurrency=(--test-concurrency=2)
|
||||
fi
|
||||
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
|
||||
}
|
||||
mkdir -p "$SANDBOX/planted"
|
||||
printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
|
||||
NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
|
||||
|
||||
@@ -76,7 +76,14 @@ check "checked-in demo bundles equal a fresh generation" $DEMO_OK
|
||||
# A nested `node --test` inherits a parent runner's NODE_TEST_CONTEXT, reports
|
||||
# to that runner and exits 0 whatever its tests do, so the suite clears it
|
||||
# (#1508 N13). The planted failing test proves a failure still fails here.
|
||||
node_tests() { env -u NODE_TEST_CONTEXT node --test "$@"; }
|
||||
node_tests() {
|
||||
local lane_concurrency=()
|
||||
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || return 2
|
||||
lane_concurrency=(--test-concurrency=2)
|
||||
fi
|
||||
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" "$@"
|
||||
}
|
||||
mkdir -p "$SANDBOX/planted"
|
||||
printf '%s\n' 'import { test } from "node:test";' 'import assert from "node:assert/strict";' 'test("planted failure", () => assert.equal(1, 2));' >"$SANDBOX/planted/planted.test.mjs"
|
||||
NODE_TEST_CONTEXT=child-v8 node_tests "$SANDBOX/planted/" >"$SANDBOX/planted.log" 2>&1
|
||||
|
||||
@@ -47,7 +47,12 @@ node -e 'const p=require("./packages/queue/package.json"); process.exit(p.depend
|
||||
check "packages/queue declares no dependencies" $?
|
||||
|
||||
# --- the package tests ---
|
||||
env -u NODE_TEST_CONTEXT node --test --test-reporter=spec packages/queue/tests/ > "$SANDBOX/tests.log" 2>&1
|
||||
lane_concurrency=()
|
||||
if [ -n "${MOSAIC_TEST_CONCURRENCY:-}" ]; then
|
||||
[ "$MOSAIC_TEST_CONCURRENCY" = 2 ] || exit 2
|
||||
lane_concurrency=(--test-concurrency=2)
|
||||
fi
|
||||
env -u NODE_TEST_CONTEXT node --test "${lane_concurrency[@]}" --test-reporter=spec packages/queue/tests/ > "$SANDBOX/tests.log" 2>&1
|
||||
rc=$?
|
||||
grep -E '^ℹ (tests|pass|fail) ' "$SANDBOX/tests.log"
|
||||
if [ "$rc" != 0 ]; then grep -E '^✖|Error' "$SANDBOX/tests.log" | head -40; fi
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Gate lanes
|
||||
|
||||
This tooling is for Sage's granted test gates on dragon-lin. It does not grant
|
||||
itself a slot. A grant reference is an audit field, not an authentication token.
|
||||
Until Sage adopts these lanes, the existing shared-slot procedure still applies.
|
||||
|
||||
Ask Sage with the lane, candidate SHA and exact command. Get a separate grant
|
||||
for image preparation, suites, and the whole-slot paired proof. New state is
|
||||
`/mnt/storage/scratch/mosaic-gate-lanes`. Old slot files are untouched.
|
||||
|
||||
Invoke the entry from the builder or canonical repository. The --checkout target
|
||||
must be a separate clean scratch clone containing identical tooling source:
|
||||
|
||||
```bash
|
||||
scripts/testing/gate-lane.sh run --lane a --candidate FULL_SHA \
|
||||
--checkout /mnt/storage/scratch/ROW-RUN/wt \
|
||||
--out /mnt/storage/scratch/ROW-RUN/out --grant SAGE_MESSAGE_ID \
|
||||
--image-receipt /mnt/storage/scratch/PREP/image-receipt.json
|
||||
```
|
||||
|
||||
Each run creates a private root like `/mnt/storage/scratch/ga-ABCDEF` (30 bytes).
|
||||
An explicit `--temp-root` must already be private, outside `/tmp`, and no longer
|
||||
than 30 UTF-8 bytes after resolving symlinks. The sandbox's own `/tmp` and HOME
|
||||
are unchanged. Host HOME stays real. A listener covering loopback port 9, an
|
||||
unsafe privileged-port threshold or unreadable host inventory refuses startup.
|
||||
|
||||
Lane a leases ports 61000..61127, lane b 61128..61255. Refuse any band overlapping
|
||||
the host's current ephemeral range or a live listener. Port-0 fixtures with a
|
||||
held server keep using port 0. Never replace the literal dead Board :9 URLs.
|
||||
Two Node test files run per lane, including nested shell-suite Node runners.
|
||||
|
||||
```bash
|
||||
scripts/testing/gate-lane.sh status
|
||||
scripts/testing/gate-lane.sh recover --run-id RUN_NONCE --grant SAGE_MESSAGE_ID
|
||||
```
|
||||
|
||||
Status reports each lane, the image lock, unfinished receipts and unmanaged
|
||||
test/landing/build processes as LEGACY BUSY. It prints no command lines or
|
||||
environment values. A dead owner with an unfinished receipt is quarantined even
|
||||
when flock is free. Recovery takes both lanes and the exclusive image lock.
|
||||
Unknown process-group, scope or Docker ownership refuses recovery. Do not remove
|
||||
lock files, old receipts, or another run's resources to clear a refusal.
|
||||
|
||||
An image receipt records exact COPY input hashes/modes, image ID, release tag,
|
||||
base digest and platform. Both concurrent candidates must match those files and
|
||||
that same image ID. Rebuilding identical files is not equivalent. Warm gates
|
||||
refuse implicit Docker builds/pulls and recheck the ID at each step and the end.
|
||||
External same-uid commands can ignore these locks. Sage must exclude other
|
||||
builders; this is test coordination, not a security boundary against that uid.
|
||||
|
||||
Only under an explicit image-build grant:
|
||||
|
||||
```bash
|
||||
scripts/testing/gate-lane.sh prepare-image --lane a --candidate FULL_SHA \
|
||||
--checkout /mnt/storage/scratch/ROW-PREP/wt \
|
||||
--out /mnt/storage/scratch/ROW-PREP/out --grant SAGE_MESSAGE_ID
|
||||
```
|
||||
|
||||
Preparation holds both lanes and the exclusive image lock. Its build log,
|
||||
receipt, count and wall time are separate from warm gate timings. The current
|
||||
Containerfile uses a mutable FROM tag and apt indexes. A missing base digest
|
||||
refuses provenance publication. No credential file is copied or printed.
|
||||
|
||||
Run records are immutable JSON files published by link after fsync. Every step
|
||||
has a log, UTC/monotonic times, exit code, pass/fail/skip counts and log hash.
|
||||
The first failed step stops the gate. Cleanup touches only exact project labels,
|
||||
recorded process identities and fixture paths. A process leak fails the gate
|
||||
even when cleanup succeeds. Failed logs and temp roots remain for diagnosis.
|
||||
A cleanup failure leaves the lane quarantined. GATE_PASSED requires all steps
|
||||
and cleanup. Required prerequisite skips fail the run; optional skips remain
|
||||
visible and require acceptance review.
|
||||
|
||||
Home metadata is evidence only. Assert only on entries attributable to this
|
||||
run's nonce or fixture paths. Other sessions normally change ~/.claude, ~/.pi
|
||||
and ~/.cache, so there is no global unchanged-home assertion.
|
||||
|
||||
The row 197 acceptance packet is `agents/herb/work/queue-197/`: two candidate
|
||||
solo baselines followed by three overlapping pairs, lane assignment swapped in
|
||||
pair two. Keep later paired results after adoption. Six green gates establish
|
||||
the initial acceptance bar, not a reliable estimate of rare flakes.
|
||||
|
||||
The preflight unit fixture uses the canonical checkout's existing node_modules
|
||||
at `/mnt/storage/src/mosaic-stack/node_modules` through the required plain
|
||||
symlink. It does not install dependencies. Unit run roots use the `gt-` prefix;
|
||||
real lane roots use `ga-` or `gb-`. Resource helper tests inside a lane lease only
|
||||
that lane's band through MOSAIC_GATE_STATE, including their child test files.
|
||||
The exhaustion probe temporarily narrows its process-local band to a port it
|
||||
already leased, so it never reserves the whole shared band.
|
||||
|
||||
`closed` call-site options are accepted for compatibility; every dynamic lease
|
||||
reserves an unbound endpoint. Listener fixtures decide when to bind and close.
|
||||
Fixture child caches put TMPDIR under `tmp/` next to `jiti/` and `cache/`.
|
||||
|
||||
Outside a lane, the resource helper file uses lane a with the shared default
|
||||
state `/mnt/storage/scratch/mosaic-gate-lanes`. It leaves stable lock files in
|
||||
place. A single-file run therefore respects a live lane-a gate's closed leases.
|
||||
Its unrelated-session probe bounds both polling children at five seconds.
|
||||
@@ -0,0 +1,226 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { realpathSync, existsSync, statSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { shortRoot, checkHost, hostFacts, refuse, within, SCRATCH, identity } from './lane-state.mjs';
|
||||
const BUILDER=resolve(dirname(fileURLToPath(import.meta.url)),'../..');
|
||||
const git=(root,...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
||||
export function validateRun(o){
|
||||
if(!['a','b'].includes(o.lane))refuse('invalid lane');
|
||||
if(typeof o.grant!=='string'||!o.grant.trim())refuse('missing grant reference');
|
||||
for(const key of ['checkout','out','stateRoot'])if(typeof o[key]!=='string'||!o[key])refuse(`missing ${key}`);
|
||||
const tempRoot=shortRoot(o.tempRoot);
|
||||
const checkout=realpathSync(o.checkout);
|
||||
if(checkout===BUILDER||checkout==='/mnt/storage/src/mosaic-stack'||!within(SCRATCH,checkout))refuse('canonical or builder checkout');
|
||||
if(!/^[a-f0-9]{40}$/.test(o.candidate)||git(checkout,'rev-parse','HEAD')!==o.candidate)refuse('wrong candidate SHA');
|
||||
const changed=git(checkout,'status','--porcelain','--untracked-files=normal').split('\n').filter(x=>x&&x!=='?? node_modules');
|
||||
if(changed.length)refuse('dirty checkout');
|
||||
if(!existsSync(join(checkout,'node_modules'))||!statSync(join(checkout,'node_modules')).isDirectory())refuse('missing node_modules');
|
||||
const out=resolve(o.out);if(existsSync(out))refuse('output already exists');
|
||||
if(!within(dirname(checkout),out)||!within(SCRATCH,realpathSync(dirname(out))))refuse('output escapes run scratch');
|
||||
const stateRoot=resolve(o.stateRoot);if(!within(SCRATCH,stateRoot))refuse('state outside scratch');
|
||||
checkHost(o.lane,o.hostFacts??hostFacts());
|
||||
return {...o,tempRoot,checkout,out,stateRoot};
|
||||
}
|
||||
export function validateTooling(checkout){
|
||||
for(const name of readdirSync(join(BUILDER,'scripts/testing')).filter(n=>/\.(mjs|sh)$/.test(n))){
|
||||
const candidate=join(checkout,'scripts/testing',name),launcher=join(BUILDER,'scripts/testing',name);
|
||||
if(!existsSync(candidate)||!readFileSync(candidate).equals(readFileSync(launcher)))refuse('candidate tooling differs from launcher');
|
||||
}
|
||||
}
|
||||
export function discoverSteps(checkout,concurrency=2){
|
||||
if(concurrency!==2)refuse('test concurrency must be 2');
|
||||
const steps=[];
|
||||
for(const p of readdirSync(join(checkout,'packages')).sort())if(p!=='queue'&&existsSync(join(checkout,'packages',p,'tests'))&&statSync(join(checkout,'packages',p,'tests')).isDirectory())steps.push({name:`pkg-${p}`,cwd:join(checkout,'packages',p),argv:['node','--test','--test-concurrency=2',...readdirSync(join(checkout,'packages',p,'tests')).filter(n=>n.endsWith('.test.mjs')).sort().map(n=>`tests/${n}`)]});
|
||||
for(const f of readdirSync(join(checkout,'scripts')).sort())if(/^test-.+\.sh$/.test(f)&&statSync(join(checkout,'scripts',f)).isFile())steps.push({name:`script-${f.slice(5,-3)}`,cwd:checkout,argv:['bash',`scripts/${f}`]});
|
||||
return steps;
|
||||
}
|
||||
|
||||
export async function executeSteps(steps,{out,env,record,onChild=()=>{},signal}){
|
||||
const {openSync,closeSync}=await import('node:fs');const {spawn}=await import('node:child_process');
|
||||
for(const step of steps){
|
||||
if(signal?.aborted)return {rc:130};
|
||||
const begin=performance.now(),startMs=Date.now();const fd=openSync(join(out,`${step.name}.log`),'wx',0o600);
|
||||
const child=spawn(step.argv[0],step.argv.slice(1),{cwd:step.cwd,env,detached:true,stdio:['ignore',fd,fd]});closeSync(fd);
|
||||
onChild(child);record({kind:'child',group:child.pid,owner:identity(child.pid)});
|
||||
let escalation;
|
||||
const abort=()=>{try{process.kill(-child.pid,'SIGTERM');}catch{};escalation=setTimeout(()=>{try{process.kill(-child.pid,'SIGKILL');}catch{}},3000);escalation.unref();};
|
||||
signal?.addEventListener('abort',abort,{once:true});
|
||||
const rc=await new Promise(resolve=>{child.once('error',()=>resolve(127));child.once('close',(code,sig)=>resolve(signal?.aborted?130:(code??(sig?128:1))));});
|
||||
clearTimeout(escalation);signal?.removeEventListener('abort',abort);onChild(null);
|
||||
const bytes=readFileSync(join(out,`${step.name}.log`)),text=bytes.toString('utf8');
|
||||
const counts={};for(const name of ['pass','fail','skipped']){const m=text.match(new RegExp('^(?:ℹ |# )'+name+' (\\d+)','m'));if(m)counts[name]=Number(m[1]);}
|
||||
if(counts.pass===undefined){const summaries=[...text.matchAll(/(\d+) passed, (\d+) failed/g)];const last=summaries.at(-1);if(last){counts.pass=Number(last[1]);counts.fail=Number(last[2]);}}
|
||||
const requiredSkips=text.split('\n').filter(line=>/^skip\b/i.test(line)&&/(docker|daemon|sandbox|systemd|credential)/i.test(line));
|
||||
record({kind:'step',name:step.name,startMs,endMs:Date.now(),wallMs:performance.now()-begin,rc,counts,requiredSkips,logSha256:createHash('sha256').update(bytes).digest('hex')});
|
||||
if(rc!==0||requiredSkips.length)return {rc:rc||2};
|
||||
}
|
||||
return {rc:0};
|
||||
}
|
||||
|
||||
export async function executePinnedSteps(steps,options,{receipt,checkout,inspect,afterStep=()=>{}}){
|
||||
for(const step of steps){
|
||||
verifyImage(receipt,checkout,inspect);
|
||||
const result=await executeSteps([step],options);afterStep(step);
|
||||
if(result.rc){verifyImage(receipt,checkout,inspect);return {...result,imageBuilds:0};}
|
||||
}
|
||||
verifyImage(receipt,checkout,inspect);return {rc:0,imageBuilds:0};
|
||||
}
|
||||
|
||||
import { mkdirSync, lstatSync, fstatSync, mkdtempSync, writeFileSync, readFileSync, openSync, readlinkSync, rmdirSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { status, appendRecord, runRecords, processInventory, sameProcess, nonce } from './lane-state.mjs';
|
||||
import { verifyImage, imageFingerprint, inspectImage, prepareImage } from './lane-image.mjs';
|
||||
import { cleanupProject } from './lane-docker.mjs';
|
||||
import { homeMetadata, attributeEntries, socketInventory } from './lane-evidence.mjs';
|
||||
function options(argv){
|
||||
const result={verb:'run',stateRoot:'/mnt/storage/scratch/mosaic-gate-lanes',lane:'a',exclusive:false};
|
||||
const names={'--state-root':'stateRoot','--lane':'lane','--candidate':'candidate','--checkout':'checkout','--out':'out','--temp-root':'tempRoot','--grant':'grant','--image-receipt':'imageReceipt','--run-id':'runId'};
|
||||
const args=[...argv];if(args[0]&&!args[0].startsWith('--'))result.verb=args.shift();
|
||||
if(!['run','status','recover','prepare-image','lock-dir'].includes(result.verb))refuse('unknown verb');
|
||||
while(args.length){const key=args.shift();if(key==='--exclusive'){result.exclusive=true;continue;}if(!names[key]||!args.length)refuse('invalid or missing option');result[names[key]]=args.shift();}
|
||||
return result;
|
||||
}
|
||||
function stateDirectory(path,create=false){
|
||||
const root=resolve(path);if(!within(SCRATCH,root)||root===SCRATCH)refuse('state must be private scratch');
|
||||
if(create){const parent=realpathSync(dirname(root));if(!within(SCRATCH,parent))refuse('state parent escapes scratch');mkdirSync(root,{recursive:true,mode:0o700});}
|
||||
if(existsSync(root)){const st=lstatSync(root);if(st.isSymbolicLink()||!st.isDirectory()||st.uid!==process.getuid()||(st.mode&0o077))refuse('state is not a private owned directory');
|
||||
for(const n of ['a.lock','b.lock','image.lock'])if(existsSync(join(root,n))&&!lstatSync(join(root,n)).isFile())refuse('lock is not an ordinary file');
|
||||
}
|
||||
return root;
|
||||
}
|
||||
export function verifyLocks(o){
|
||||
for(const [fd,name] of o.exclusive?[[3,'a.lock'],[4,'b.lock'],[5,'image.lock']]:[[3,`${o.lane}.lock`],[5,'image.lock']]){
|
||||
let opened,path;try{opened=fstatSync(fd);path=lstatSync(join(o.stateRoot,name));}catch{refuse('lock descriptors missing');}
|
||||
if(!opened.isFile()||!path.isFile()||opened.ino!==path.ino||opened.dev!==path.dev||path.uid!==process.getuid())refuse('lock file replaced');
|
||||
// A separate open must fail while our shared/exclusive lock is held.
|
||||
const r=spawnSync('flock',['-n','-x',join(o.stateRoot,name),'true']);if(r.status!==1)refuse('lock descriptor is not locked');
|
||||
}
|
||||
}
|
||||
export function activeConflicts(o,observations={}){
|
||||
const current=status(o.stateRoot,{...observations,ignoreLocks:o.exclusive?['a','b','image']:[o.lane,'image']});
|
||||
if(current.legacy.length)refuse('LEGACY BUSY: unmanaged gate, test or Docker process');
|
||||
for(const lane of o.exclusive?['a','b']:[o.lane])if(current[lane].state!=='CLEAR')refuse(`${lane} ${current[lane].state}`);
|
||||
for(const r of runRecords(o.stateRoot)){const start=r.records.find(e=>e.kind==='start');if(!r.records.some(e=>['terminal','recovered'].includes(e.kind)&&e.clean)&&start?.checkout===o.checkout)refuse('checkout already owned');}
|
||||
}
|
||||
export function cleanEnv(o,run,docker){
|
||||
const kept=['PATH','HOME','USER','LOGNAME','SHELL','LANG','LC_ALL','LC_CTYPE','TZ','TERM','XDG_RUNTIME_DIR','DBUS_SESSION_BUS_ADDRESS','DOCKER_HOST','CHROMIUM'];
|
||||
const env=Object.fromEntries(kept.filter(k=>typeof process.env[k]==='string').map(k=>[k,process.env[k]]));
|
||||
Object.assign(env,{TMPDIR:o.tempRoot,TMP:o.tempRoot,TEMP:o.tempRoot,MOSAIC_GATE_LANE:o.lane,MOSAIC_GATE_STATE:o.stateRoot,MOSAIC_TEST_CONCURRENCY:'2',COMPOSE_PROJECT_NAME:run.project,JITI_FS_CACHE:join(o.tempRoot,'jiti'),XDG_CACHE_HOME:join(o.tempRoot,'cache'),MOSAIC_CONFIG:join(o.tempRoot,'missing-config.json'),MOSAIC_GATE_DOCKER_RECORD:join(o.out,'docker.json')});
|
||||
env.PATH=`${join(o.out,'bin')}:${env.PATH??'/usr/bin:/bin'}`;
|
||||
return env;
|
||||
}
|
||||
function fixtureProcesses(run){
|
||||
const all=processInventory(),ancestors=new Set([process.pid]);let parent=process.pid;
|
||||
while(parent){const p=all.find(p=>p.pid===parent);if(!p||ancestors.has(p.ppid))break;ancestors.add(p.ppid);parent=p.ppid;}
|
||||
return all.filter(p=>{
|
||||
if(ancestors.has(p.pid)||p.state==='Z')return false;
|
||||
let cwd;try{cwd=readlinkSync(`/proc/${p.pid}/cwd`);}catch{}
|
||||
return p.argv.some(a=>typeof a==='string'&&(a===run.tempRoot||a.startsWith(run.tempRoot+'/')))||cwd&&(within(run.tempRoot,cwd)||run.checkout&&within(run.checkout,cwd));
|
||||
});
|
||||
}
|
||||
async function cleanProcesses(run,record){
|
||||
const before=fixtureProcesses(run);
|
||||
if(run.stateRoot&&run.id){
|
||||
const procs=processInventory(),known=new Set(before.map(p=>p.pid));
|
||||
const {records}=await import('./lane-state.mjs');
|
||||
for(const child of records(join(run.stateRoot,'runs',run.id)).filter(e=>e.kind==='child')){
|
||||
const members=procs.filter(p=>p.group===child.group&&p.state!=='Z');
|
||||
if(members.length&&sameProcess(child.owner,procs.find(p=>p.pid===child.owner?.pid))){
|
||||
try{process.kill(-child.group,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}
|
||||
}else if(members.some(p=>!known.has(p.pid)))refuse('unproven orphan process-group ownership');
|
||||
}
|
||||
}
|
||||
record({kind:'leftover-processes',objects:before.map(({pid,start,boot})=>({pid,start,boot}))});
|
||||
for(const p of before){if(!sameProcess(p))continue;let raw;try{raw=readFileSync(`/proc/${p.pid}/cgroup`,'utf8');}catch(e){if(e.code==='ENOENT')continue;throw e;}const scope=raw.trim().split('/').find(x=>/^mosaic-chat-[a-z0-9]+\.scope$/.test(x));
|
||||
if(scope){const shown=execFileSync('systemctl',['--user','show',scope,'-p','InvocationID','--value'],{encoding:'utf8'}).trim();if(!/^[a-f0-9]{32}$/.test(shown))refuse('scope identity unknown');record({kind:'scope-cleanup',unit:scope,invocation:shown});execFileSync('systemctl',['--user','kill','--signal=SIGKILL',scope],{stdio:'pipe'});}
|
||||
if(sameProcess(p))try{process.kill(p.pid,'SIGKILL');}catch(e){if(e.code!=='ESRCH')throw e;}
|
||||
}
|
||||
for(let n=0;n<30;n++){if(!fixtureProcesses(run).some(p=>p.state!=='Z'))return before.length===0;await new Promise(r=>setTimeout(r,100));}
|
||||
refuse('owned fixture process remains');
|
||||
}
|
||||
async function recoverRun(o){
|
||||
if(!o.grant)refuse('missing recovery grant');
|
||||
if(!/^[a-f0-9]{12}$/.test(o.runId??''))refuse('invalid run ID');
|
||||
const r=runRecords(o.stateRoot).find(r=>r.id===o.runId);if(!r)refuse('run not found');
|
||||
const start=r.records.find(x=>x.kind==='start');if(start?.mode==='prepare-image'&&status(o.stateRoot).legacy.length)refuse('image builder still active or unknown');if(!start||sameProcess(start.owner))refuse('run owner live or unknown');
|
||||
if(r.records.some(x=>['terminal','recovered'].includes(x.kind)&&x.clean))refuse('run already clean');
|
||||
shortRoot(start.tempRoot);const record=e=>appendRecord(r.dir,e);
|
||||
const procs=processInventory(),known=new Set(fixtureProcesses(start).map(p=>p.pid));
|
||||
for(const event of r.records.filter(e=>e.kind==='child')){
|
||||
const members=procs.filter(p=>p.group===event.group&&p.state!=='Z');
|
||||
if(members.length&&!sameProcess(event.owner,procs.find(p=>p.pid===event.owner?.pid))&&members.some(p=>!known.has(p.pid)))refuse('orphan process-group ownership unknown');
|
||||
if(members.length&&sameProcess(event.owner)){process.kill(-event.group,'SIGKILL');}
|
||||
}
|
||||
await cleanProcesses(start,record);cleanupProject(start,start.docker);
|
||||
appendRecord(r.dir,{kind:'recovered',clean:true,grant:o.grant,owner:identity()});return {recovered:r.id};
|
||||
}
|
||||
async function runGate(input){
|
||||
for(const key of ['checkout','out','candidate','grant','imageReceipt'])if(!input[key])refuse(`missing ${key}`);
|
||||
const automatic=!input.tempRoot,tempRoot=input.tempRoot??mkdtempSync(`/mnt/storage/scratch/g${input.lane}-`);
|
||||
let o,receipt,docker;
|
||||
try{
|
||||
o=validateRun({...input,tempRoot});
|
||||
validateTooling(o.checkout);
|
||||
activeConflicts(o);
|
||||
receipt=JSON.parse(readFileSync(o.imageReceipt,'utf8'));verifyImage(receipt,o.checkout);
|
||||
docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
|
||||
}catch(e){if(automatic)rmdirSync(tempRoot);throw e;}
|
||||
const id=nonce(),run={kind:'start',id,stateRoot:o.stateRoot,lane:o.lane,exclusive:o.exclusive,owner:identity(),grant:o.grant,candidate:o.candidate,checkout:o.checkout,tempRoot:o.tempRoot,out:o.out,project:`mg197-${o.lane}-${id}`,tag:receipt.tag,imageId:receipt.imageId,docker};
|
||||
const dir=join(o.stateRoot,'runs',id);appendRecord(dir,run);const record=e=>{appendRecord(dir,e);if(e.kind==='step')process.stdout.write(`${e.name} rc=${e.rc} wall_ms=${Math.round(e.wallMs)} pass=${e.counts.pass??'-'} fail=${e.counts.fail??'-'} skipped=${e.counts.skipped??'-'}\n`);};
|
||||
const begin=performance.now(),startMs=Date.now();let rc=1,clean=false,current=null;
|
||||
const aborter=new AbortController();const stop=()=>aborter.abort();for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.on(signal,stop);
|
||||
try{
|
||||
mkdirSync(o.out,{mode:0o700});mkdirSync(join(o.out,'bin'),{mode:0o700});
|
||||
writeFileSync(join(o.out,'docker.json'),JSON.stringify({...run,docker}),{mode:0o600,flag:'wx'});
|
||||
const guard=join(BUILDER,'scripts/testing/lane-docker.mjs');
|
||||
// Launch the guard directly so it validates every real Docker invocation.
|
||||
writeFileSync(join(o.out,'bin','docker'),`#!/bin/sh\nexec '${process.execPath.replaceAll("'","'\\''")}' '${guard.replaceAll("'","'\\''")}' "$@"\n`,{mode:0o700,flag:'wx'});
|
||||
const steps=discoverSteps(o.checkout);record({kind:'plan',steps});
|
||||
const before=homeMetadata(process.env.HOME);record({kind:'home-before',entries:before});
|
||||
const env=cleanEnv(o,run,docker);
|
||||
const result=await executePinnedSteps(steps,{out:o.out,env,record,onChild:c=>current=c,signal:aborter.signal},{receipt,checkout:o.checkout,afterStep:()=>record({kind:'sockets',entries:socketInventory(o.tempRoot)})});
|
||||
rc=result.rc;
|
||||
const after=homeMetadata(process.env.HOME);record({kind:'home-after',entries:after});
|
||||
const prior=new Map(before.map(e=>[e.path,JSON.stringify(e)]));const changed=after.filter(e=>prior.get(e.path)!==JSON.stringify(e));
|
||||
if(attributeEntries(changed,{...run,nonce:id,fixturePaths:[]}).owned.length)refuse('lane-attributed real HOME write');
|
||||
if(git(o.checkout,'diff','--name-only'))refuse('tracked checkout changed during gate');
|
||||
const noLeaks=await cleanProcesses(run,record);cleanupProject(run,docker);clean=true;if(!noLeaks&&rc===0)rc=1;
|
||||
}catch(e){record({kind:'failure',message:e.message});process.stderr.write(`${e.message}\n`);rc=rc||1;
|
||||
try{await cleanProcesses(run,record);cleanupProject(run,docker);clean=true;}catch(error){record({kind:'cleanup-failed',message:error.message});}
|
||||
}finally{
|
||||
if(current){try{process.kill(-current.pid,'SIGKILL');}catch{}}
|
||||
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.off(signal,stop);
|
||||
record({kind:'terminal',clean,rc,startMs,endMs:Date.now(),wallMs:performance.now()-begin,imageBuilds:0});
|
||||
}
|
||||
if(rc===0&&clean)process.stdout.write('GATE_PASSED\n');return rc;
|
||||
}
|
||||
async function main(argv){
|
||||
const locked=argv[0]==='--locked';if(locked)argv=argv.slice(1);
|
||||
if(argv[0]==='lock-dir'){
|
||||
const o=options(argv.slice(1));if(!o.grant)refuse('missing grant reference');if(!['a','b'].includes(o.lane))refuse('invalid lane');process.stdout.write(stateDirectory(o.stateRoot,true)+'\n');return 0;
|
||||
}
|
||||
const o=options(argv);o.stateRoot=stateDirectory(o.stateRoot);
|
||||
if(o.verb==='status'){process.stdout.write(JSON.stringify(status(o.stateRoot))+'\n');return 0;}
|
||||
if(!locked)refuse('use gate-lane.sh for locked operations');
|
||||
if(['recover','prepare-image'].includes(o.verb))o.exclusive=true;
|
||||
verifyLocks(o);
|
||||
if(o.verb==='recover'){process.stdout.write(JSON.stringify(await recoverRun(o))+'\n');return 0;}
|
||||
if(o.verb==='prepare-image'){
|
||||
for(const key of ['checkout','out','candidate','grant'])if(!o[key])refuse(`missing ${key}`);
|
||||
const checked=validateRun({...o,tempRoot:o.tempRoot??mkdtempSync('/mnt/storage/scratch/ga-')});activeConflicts(checked);
|
||||
const id=nonce(),dir=join(checked.stateRoot,'runs',id),docker=execFileSync('which',['docker'],{encoding:'utf8'}).trim();
|
||||
appendRecord(dir,{kind:'start',mode:'prepare-image',lane:checked.lane,exclusive:true,owner:identity(),grant:checked.grant,candidate:checked.candidate,checkout:checked.checkout,tempRoot:checked.tempRoot,out:checked.out,project:`mg197-${checked.lane}-${id}`,docker});
|
||||
mkdirSync(checked.out,{mode:0o700});
|
||||
try{
|
||||
const receipt=prepareImage(checked.checkout,checked);
|
||||
writeFileSync(join(checked.out,'image-receipt.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600,flag:'wx'});
|
||||
appendRecord(dir,{kind:'terminal',clean:true,rc:0,imageId:receipt.imageId,builds:1,wallMs:receipt.buildWallMs});
|
||||
process.stdout.write(JSON.stringify({imageReceipt:join(checked.out,'image-receipt.json'),imageId:receipt.imageId,buildWallMs:receipt.buildWallMs})+'\n');return 0;
|
||||
}catch(e){appendRecord(dir,{kind:'terminal',clean:false,rc:1,message:e.message});throw e;}
|
||||
}
|
||||
return runGate(o);
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href)main(process.argv.slice(2)).then(rc=>process.exitCode=rc).catch(e=>{process.stderr.write(`${e.message}\n`);process.exitCode=2;});
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
entry="$(cd "$(dirname "$0")" && pwd -P)/gate-lane.mjs"
|
||||
verb="${1:-run}"
|
||||
if [ "$verb" = status ]; then exec node "$entry" "$@"; fi
|
||||
state="$(node "$entry" lock-dir "$@")"
|
||||
lane=a
|
||||
exclusive=0
|
||||
args=("$@")
|
||||
for ((i=0;i<${#args[@]};i++)); do
|
||||
case "${args[i]}" in
|
||||
--lane) lane="${args[i+1]:-}" ;;
|
||||
--exclusive) exclusive=1 ;;
|
||||
esac
|
||||
done
|
||||
case "$verb" in recover|prepare-image) exclusive=1;; esac
|
||||
case "$lane" in a|b) ;; *) echo 'gate-lane: refused: invalid lane' >&2; exit 2;; esac
|
||||
if [ "$exclusive" = 1 ]; then
|
||||
exec 3>>"$state/a.lock" 4>>"$state/b.lock" 5>>"$state/image.lock"
|
||||
flock -n 3 && flock -n 4 && flock -n -x 5 || { echo 'gate-lane: refused: lane or image lock busy' >&2; exit 2; }
|
||||
else
|
||||
exec 3>>"$state/$lane.lock" 5>>"$state/image.lock"
|
||||
flock -n 3 && flock -n -s 5 || { echo 'gate-lane: refused: lane or image lock busy' >&2; exit 2; }
|
||||
fi
|
||||
exec node "$entry" --locked "$@"
|
||||
@@ -0,0 +1,34 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { refuse } from './lane-state.mjs';
|
||||
export function guardDocker(argv,run){
|
||||
if(argv[0]==='info'&&argv.length===1)return argv;
|
||||
if(argv[0]==='image'&&argv[1]==='inspect'&&argv[2]===run.tag&&argv.length===3)return argv;
|
||||
if(argv[0]==='compose'&&argv[1]==='run'){
|
||||
const service=argv.indexOf('mosaic-agent',2);
|
||||
if(service<0||argv.slice(2,service).some(a=>!['--rm','-T','--no-deps','--interactive','-i'].includes(a)))refuse('Docker Compose run options not allowed');
|
||||
return ['compose','run','--no-build','--pull','never',...argv.slice(2)];
|
||||
}
|
||||
refuse('Docker mutation or unowned command');
|
||||
}
|
||||
export function cleanupProject(run,docker='docker'){
|
||||
if(!/^mg197-[ab]-[a-f0-9]{12}$/.test(run.project))refuse('invalid Docker ownership project');
|
||||
const invoke=typeof docker==='function'?docker:(args)=>execFileSync(docker,args,{encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
||||
const removed=[];
|
||||
for(const [kind,list,filter] of [['container',['ps','-aq'],'com.docker.compose.project'],['network',['network','ls','-q'],'com.docker.compose.project']]){
|
||||
const ids=invoke([...list,'--filter',`label=${filter}=${run.project}`]).trim().split(/\s+/).filter(Boolean);
|
||||
for(const id of ids){if(!/^[a-f0-9]{12,64}$/.test(id))refuse('invalid Docker resource ID');
|
||||
const label=invoke([kind,'inspect',id,'--format',`{{index .${kind==='container'?'Config.':''}Labels "${filter}"}}`]).trim();
|
||||
if(label!==run.project)refuse('foreign Docker resource');
|
||||
invoke(kind==='container'?['container','rm','-f',id]:['network','rm',id]);removed.push({kind,id});
|
||||
}
|
||||
const left=invoke([...list,'--filter',`label=${filter}=${run.project}`]).trim();if(left)refuse('Docker cleanup incomplete');
|
||||
}
|
||||
return {clean:true,removed};
|
||||
}
|
||||
if(process.argv[1]&&import.meta.url===pathToFileURL(process.argv[1]).href){
|
||||
try{const run=JSON.parse(readFileSync(process.env.MOSAIC_GATE_DOCKER_RECORD,'utf8'));if(process.env.COMPOSE_PROJECT_NAME!==run.project)refuse('Compose project mismatch');
|
||||
const argv=guardDocker(process.argv.slice(2),run);const result=spawnSync(run.docker,argv,{stdio:'inherit'});process.exit(result.status??1);
|
||||
}catch(e){process.stderr.write(`${e.message}\n`);process.exit(2);}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readdirSync, lstatSync, readlinkSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { within, refuse } from './lane-state.mjs';
|
||||
export function attributeEntries(metadata,run){
|
||||
const result={owned:[],unrelated:[],unknown:[]};
|
||||
for(const e of metadata){const owns=e.path.includes(run.nonce)||e.link&&within(run.tempRoot,e.link)||(run.fixturePaths??[]).some(path=>within(path,e.path)||e.link&&within(path,e.link));result[owns?'owned':'unrelated'].push(e);}
|
||||
return result;
|
||||
}
|
||||
export function homeMetadata(home){
|
||||
const out=[];
|
||||
for(const name of ['.claude','.pi','.cache']){
|
||||
const root=join(home,name);if(!existsSync(root))continue;
|
||||
// Two levels are evidence only. Never open or hash file contents.
|
||||
const walk=(dir,depth)=>{for(const d of readdirSync(dir,{withFileTypes:true})){const path=join(dir,d.name);try{const st=lstatSync(path);out.push({path,size:st.size,mtime:st.mtimeMs,mode:st.mode,link:st.isSymbolicLink()?readlinkSync(path):null});if(d.isDirectory()&&depth)walk(path,depth-1);}catch{}}};
|
||||
walk(root,1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function socketInventory(root){const out=[];const walk=dir=>{for(const d of readdirSync(dir,{withFileTypes:true})){const p=join(dir,d.name);if(d.isDirectory())walk(p);else if(d.isSocket()){const bytes=Buffer.byteLength(p);out.push({path:p,bytes});if(bytes>107)refuse('socket path exceeds 107 bytes');}}};walk(root);return out;}
|
||||
export function summarizePairs(solos,pairs){
|
||||
const byCandidate=new Map(solos.map(r=>[r.candidate,r]));
|
||||
if(solos.length!==2||byCandidate.size!==2||solos.some(r=>!Number.isFinite(r.wallMs)||r.wallMs<=0))refuse('solo timing invalid');
|
||||
if(pairs.length!==3)refuse('proof needs three pairs');
|
||||
let first;
|
||||
return pairs.map((pair,index)=>{
|
||||
if(pair.length!==2||pair[0].candidate===pair[1].candidate||new Set(pair.map(r=>r.lane)).size!==2||pair.some(r=>!['a','b'].includes(r.lane)))refuse('pair needs different candidates and lanes');
|
||||
for(const run of pair){
|
||||
if(!run.clean||run.rc!==0||!byCandidate.has(run.candidate))refuse('proof gate failed');
|
||||
if(!Number.isFinite(run.startMs)||!Number.isFinite(run.endMs)||run.endMs<=run.startMs)refuse('paired timing invalid');
|
||||
}
|
||||
if(index===0)first=new Map(pair.map(r=>[r.candidate,r.lane]));
|
||||
if(index===1&&pair.some(r=>first.get(r.candidate)===r.lane))refuse('second pair must swap lanes');
|
||||
if(Math.max(...pair.map(r=>r.startMs))>=Math.min(...pair.map(r=>r.endMs)))refuse('paired gates did not overlap');
|
||||
const makespan=Math.max(...pair.map(r=>r.endMs))-Math.min(...pair.map(r=>r.startMs));
|
||||
return {makespanMs:makespan,runs:pair.map(r=>({candidate:r.candidate,wallMs:r.endMs-r.startMs,slowdown:(r.endMs-r.startMs)/byCandidate.get(r.candidate).wallMs})),throughput:pair.reduce((n,r)=>n+byCandidate.get(r.candidate).wallMs,0)/makespan};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Imported only by tests. The port holder lives until the fixture closes.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { once } from 'node:events';
|
||||
import { BANDS, within, SCRATCH, refuse } from './lane-state.mjs';
|
||||
async function hold(file){
|
||||
const child=spawn('flock',['-n','-E','73',file,'bash','-c','printf "held\\n"; IFS= read -r release'],{stdio:['pipe','pipe','pipe']});
|
||||
let err='';child.stderr.on('data',c=>err+=c);const exited=once(child,'exit');
|
||||
const ok=await new Promise((resolve,reject)=>{child.stdout.once('data',()=>resolve(true));child.once('exit',code=>code===73?resolve(false):reject(Error(`port lock holder exited ${code}: ${err}`)));child.once('error',reject);});
|
||||
if(!ok)return null;
|
||||
return async()=>{child.stdin.end('\n');await exited;};
|
||||
}
|
||||
async function probe(port){const s=createServer();try{await new Promise((resolve,reject)=>{s.once('error',reject);s.listen(port,'127.0.0.1',resolve);});const found=s.address().port;await new Promise(r=>s.close(r));return found;}catch(e){if(s.listening)await new Promise(r=>s.close(r));if(e.code==='EADDRINUSE')return null;throw e;}}
|
||||
export async function leasePort(t,{lane=process.env.MOSAIC_GATE_LANE,stateRoot=process.env.MOSAIC_GATE_STATE}={}){
|
||||
if(!lane){const port=await probe(0);return port;}
|
||||
if(!BANDS[lane]||!stateRoot)refuse('invalid port lane state');
|
||||
const state=realpathSync(stateRoot);if(!within(SCRATCH,state))refuse('port state outside scratch');
|
||||
const dir=join(state,'ports',lane);mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const [lo,hi]=BANDS[lane];
|
||||
for(let port=lo;port<=hi;port++){
|
||||
const release=await hold(join(dir,`${port}.lock`));if(!release)continue;
|
||||
let actual;try{actual=await probe(port);}catch(e){await release();throw e;}
|
||||
if(!actual){await release();continue;}
|
||||
t.after(release);return port;
|
||||
}
|
||||
refuse('lane port band exhausted');
|
||||
}
|
||||
export function fixtureCacheEnv(root){
|
||||
mkdirSync(root,{recursive:true,mode:0o700});const dir=realpathSync(root);for(const n of ['tmp','jiti','cache'])mkdirSync(join(dir,n),{recursive:true,mode:0o700});
|
||||
return {TMPDIR:join(dir,'tmp'),JITI_FS_CACHE:join(dir,'jiti'),XDG_CACHE_HOME:join(dir,'cache')};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { refuse } from './lane-state.mjs';
|
||||
export function imageFingerprint(checkout){
|
||||
const dockerfile=readFileSync(join(checkout,'Containerfile'),'utf8');
|
||||
const copies=dockerfile.split('\n').filter(l=>/^\s*(COPY|ADD)\s/i.test(l)).map(l=>l.trim());
|
||||
const expected=['COPY package.json package-lock.json ./','COPY contracts /opt/mosaic/contracts','COPY src /opt/mosaic/src','COPY adapters /opt/mosaic/adapters'];
|
||||
if(JSON.stringify(copies)!==JSON.stringify(expected))refuse('image COPY inputs changed; manifest review required');
|
||||
const hash=createHash('sha256');
|
||||
const visit=rel=>{const path=join(checkout,rel),st=lstatSync(path);if(st.isSymbolicLink())refuse('image input is a symlink');hash.update(JSON.stringify([rel,st.mode&0o777,st.isDirectory()?'d':'f'])+'\n');if(st.isDirectory())for(const n of readdirSync(path).sort())visit(`${rel}/${n}`);else if(st.isFile())hash.update(readFileSync(path));else refuse('unsupported image input');};
|
||||
for(const rel of ['Containerfile','package.json','package-lock.json','contracts','src','adapters'])visit(rel);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
export function inspectImage(tag,docker=process.env.MOSAIC_GATE_REAL_DOCKER??'docker'){
|
||||
return JSON.parse(execFileSync(docker,['image','inspect',tag,'--format','{{json .}}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}));
|
||||
}
|
||||
export function verifyImage(receipt,checkout,inspect=inspectImage){
|
||||
if(!receipt||!/^sha256:[a-f0-9]{64}$/.test(receipt.imageId??'')||!/^.+@sha256:[a-f0-9]{64}$/.test(receipt.baseDigest??'')||!/^linux\/[a-z0-9_]+$/.test(receipt.platform??'')||!/^mosaic-poc-agent:[A-Za-z0-9_.-]+$/.test(receipt.tag??''))refuse('image provenance receipt invalid');
|
||||
if(receipt.inputDigest!==imageFingerprint(checkout))refuse('image inputs differ');
|
||||
const actual=inspect(receipt.tag);
|
||||
if(actual.Id!==receipt.imageId)refuse('pinned image ID differs');
|
||||
if(`${actual.Os}/${actual.Architecture}`!==receipt.platform)refuse('image platform differs');
|
||||
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
|
||||
if(pkg.dependencies?.['@earendil-works/pi-coding-agent']&&receipt.tag!==`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`)refuse('image tag differs from release identity');
|
||||
return receipt;
|
||||
}
|
||||
// Called by the CLI only while both lanes and the image lock are exclusive.
|
||||
export function prepareImage(checkout,{grant,tempRoot,out},invoke){
|
||||
if(!grant)refuse('missing image preparation grant');
|
||||
const inputDigest=imageFingerprint(checkout);
|
||||
const pkg=JSON.parse(readFileSync(join(checkout,'package.json'),'utf8'));
|
||||
const release=readFileSync(join(checkout,'RELEASE'),'utf8').trim();
|
||||
if(!/^\d+\.\d+\.\d+$/.test(release))refuse('invalid image release');
|
||||
const tag=`mosaic-poc-agent:${pkg.dependencies['@earendil-works/pi-coding-agent']}-r${release}`;
|
||||
const run=invoke??((args)=>execFileSync('docker',args,{cwd:checkout,encoding:'utf8',stdio:['ignore','pipe','pipe'],env:{PATH:process.env.PATH,HOME:process.env.HOME,...(process.env.DOCKER_HOST?{DOCKER_HOST:process.env.DOCKER_HOST}:{}),COMPOSE_PROJECT_NAME:'mg197-image-prepare',MOSAIC_IMAGE_TAG:tag,MOSAIC_DATA_ROOT:join(tempRoot,'data'),MOSAIC_PROVIDER:'fixture',MOSAIC_MODEL:'fixture',PI_AUTH_FILE:'/dev/null'}}));
|
||||
const start=performance.now();let buildOutput;try{buildOutput=run(['compose','build']);}catch(e){if(out)writeFileSync(join(out,'image-build.log'),String(e.stdout??'')+String(e.stderr??''),{mode:0o600,flag:'wx'});throw e;}if(out)writeFileSync(join(out,'image-build.log'),buildOutput,{mode:0o600,flag:'wx'});const buildWallMs=performance.now()-start;
|
||||
if(inputDigest!==imageFingerprint(checkout))refuse('image inputs changed while building');
|
||||
const actual=JSON.parse(run(['image','inspect',tag,'--format','{{json .}}']));
|
||||
const base=JSON.parse(run(['image','inspect','node:24-bookworm-slim','--format','{{json .}}']));
|
||||
const receipt={inputDigest,imageId:actual.Id,tag,baseDigest:base.RepoDigests?.find(x=>/@sha256:[a-f0-9]{64}$/.test(x)),platform:`${actual.Os}/${actual.Architecture}`,grant,builds:1,buildWallMs,at:new Date().toISOString()};
|
||||
verifyImage(receipt,checkout,()=>actual);return receipt;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Scratch-only lane ownership. No live Mosaic state is read or written.
|
||||
import { realpathSync, statSync, lstatSync, existsSync, readdirSync, readFileSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync, linkSync, unlinkSync, renameSync } from 'node:fs';
|
||||
import { resolve, join, dirname, relative, isAbsolute } from 'node:path';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
export const SCRATCH='/mnt/storage/scratch';
|
||||
export const BANDS={a:[61000,61127],b:[61128,61255]};
|
||||
export function refuse(message){throw new Error(`gate-lane: refused: ${message}`);}
|
||||
export function within(root,path){const r=relative(root,path);return r===''||(!r.startsWith('..')&&!isAbsolute(r));}
|
||||
export function shortRoot(path){
|
||||
let real;try{real=realpathSync(path);}catch{refuse('temporary root missing');}
|
||||
if(real==='/tmp'||real.startsWith('/tmp/'))refuse('temporary root under /tmp');
|
||||
if(Buffer.byteLength(real)>30)refuse('temporary root exceeds 30 bytes');
|
||||
if(!within(SCRATCH,real)||!statSync(real).isDirectory()||statSync(real).uid!==process.getuid()||(statSync(real).mode&0o077))refuse('temporary root must be private owned scratch');
|
||||
return real;
|
||||
}
|
||||
export function socketPath(path){if(Buffer.byteLength(path)>107)refuse('socket path exceeds 107 bytes');return path;}
|
||||
export function hostFacts(){
|
||||
const range=readFileSync('/proc/sys/net/ipv4/ip_local_port_range','utf8').trim().split(/\s+/).map(Number);
|
||||
const unprivilegedPortStart=Number(readFileSync('/proc/sys/net/ipv4/ip_unprivileged_port_start','utf8').trim());
|
||||
const text=execFileSync('ss',['-Hltn'],{encoding:'utf8'});
|
||||
const listeners=text.trim().split('\n').filter(Boolean).map(line=>{const field=line.trim().split(/\s+/)[3];const m=field?.match(/^(.*):(\d+)$/);if(!m)refuse('unreadable listener inventory');return {address:m[1].replace(/^\[|\]$/g,''),port:Number(m[2])};});
|
||||
return {ephemeral:range,unprivilegedPortStart,listeners};
|
||||
}
|
||||
export function checkHost(lane,facts){
|
||||
if(!facts||!Array.isArray(facts.listeners)||facts.ephemeral?.length!==2||!facts.ephemeral.every(Number.isInteger))refuse('host inventory unavailable');
|
||||
if(!Number.isInteger(facts.unprivilegedPortStart)||facts.unprivilegedPortStart<=9)refuse('unprivileged port range includes port 9');
|
||||
if(facts.listeners.some(x=>x.port===9&&['127.0.0.1','0.0.0.0','*','::','::ffff:127.0.0.1'].includes(x.address.replace(/%[^%]+$/, ''))))refuse('live port 9 listener');
|
||||
const [lo,hi]=BANDS[lane];const [first,last]=facts.ephemeral;
|
||||
if(lo<=last&&hi>=first)refuse('lane port band overlaps ephemeral range');
|
||||
if(facts.listeners.some(x=>x.port>=lo&&x.port<=hi))refuse('lane port band has a live listener');
|
||||
}
|
||||
export function identity(pid=process.pid){try{const s=readFileSync(`/proc/${pid}/stat`,'utf8');const f=s.slice(s.lastIndexOf(')')+2).split(' ');return {pid:Number(pid),start:f[19],boot:readFileSync('/proc/sys/kernel/random/boot_id','utf8').trim(),state:f[0]};}catch{return null;}}
|
||||
export function sameProcess(a,b=identity(a?.pid)){return !!a&&!!b&&a.pid===b.pid&&a.start===b.start&&a.boot===b.boot&&b.state!=='Z';}
|
||||
export function nonce(){return randomBytes(6).toString('hex');}
|
||||
export function appendRecord(dir,record){
|
||||
const publishedDir=dir;if(record.kind==='start'){if(existsSync(dir))refuse('run already exists');dir=dir+'.starting';}
|
||||
mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const file=join(dir,`${Date.now()}-${nonce()}-${record.kind}.json`);
|
||||
const pending=file+'.pending',fd=openSync(pending,'wx',0o600);try{writeSync(fd,JSON.stringify({...record,at:new Date().toISOString()})+'\n');fsyncSync(fd);}finally{closeSync(fd);}
|
||||
linkSync(pending,file);unlinkSync(pending);if(record.kind==='start')renameSync(dir,publishedDir);return join(publishedDir,file.split('/').at(-1));
|
||||
}
|
||||
export function records(dir){if(!existsSync(dir))return [];return readdirSync(dir).filter(n=>n.endsWith('.json')).sort().map(n=>{try{return JSON.parse(readFileSync(join(dir,n),'utf8'));}catch{refuse(`invalid run receipt ${n}`);}});}
|
||||
export function runRecords(stateRoot){
|
||||
const runs=join(stateRoot,'runs');if(!existsSync(runs))return [];
|
||||
return readdirSync(runs).filter(id=>!id.endsWith('.starting')).map(id=>({id,dir:join(runs,id),records:records(join(runs,id))}));
|
||||
}
|
||||
export function processInventory(){
|
||||
const out=[];
|
||||
for(const pid of readdirSync('/proc').filter(n=>/^\d+$/.test(n))){try{const argv=readFileSync(`/proc/${pid}/cmdline`,'utf8').split('\0').filter(Boolean);const raw=readFileSync(`/proc/${pid}/stat`,'utf8');const f=raw.slice(raw.lastIndexOf(')')+2).split(' ');out.push({...identity(Number(pid)),ppid:Number(f[1]),group:Number(f[2]),argv});}catch{}}
|
||||
return out;
|
||||
}
|
||||
export function status(stateRoot,observations={}){
|
||||
const procs=observations.processes??processInventory();const runs=runRecords(stateRoot);
|
||||
const answer={a:{state:'CLEAR'},b:{state:'CLEAR'},image:'free',legacy:[],at:new Date().toISOString()};
|
||||
const owned=new Set();
|
||||
for(const run of runs){const start=run.records.find(x=>x.kind==='start');if(!start)refuse('run receipt has no start');const clean=run.records.some(x=>['terminal','recovered'].includes(x.kind)&&x.clean===true);
|
||||
if(clean)continue;
|
||||
const live=procs.find(p=>sameProcess(start.owner,p));const state=live?'BUSY':'QUARANTINED';
|
||||
for(const lane of start.exclusive?['a','b']:[start.lane]){if(!BANDS[lane])refuse('invalid lane receipt');answer[lane]={state,run:run.id};}
|
||||
answer.image=start.exclusive?'exclusive':'shared';
|
||||
if(live){owned.add(live.pid);let changed=true;while(changed){changed=false;for(const p of procs)if(owned.has(p.ppid)&&!owned.has(p.pid)){owned.add(p.pid);changed=true;}}}
|
||||
}
|
||||
for(const p of procs){if(p.state==='Z'||p.pid===process.pid||owned.has(p.pid))continue;
|
||||
const args=p.argv??[];const executable=args[0]?.split('/').at(-1);
|
||||
const test=(executable==='node'&&args.includes('--test'))||args.some(a=>/^(?:.*\/)?(?:gate\.sh|test-[a-z-]+\.sh)$/.test(a))||(executable==='docker'&&(args.includes('compose')||args.includes('build')))||args.some(a=>a.endsWith('/landing.mjs')&&args.includes('gate'))||args.some(a=>/(?:^|\/)[^/]*mutant[^/]*\.(?:mjs|py|sh)$/.test(a));
|
||||
if(test)answer.legacy.push({pid:p.pid,start:p.start});
|
||||
}
|
||||
for(const lane of ['a','b','image']){
|
||||
if(observations.ignoreLocks?.includes(lane)||!existsSync(join(stateRoot,`${lane}.lock`)))continue;
|
||||
const lock=lstatSync(join(stateRoot,`${lane}.lock`));if(!lock.isFile())refuse('lock is not an ordinary file');
|
||||
const probe=spawnSync('flock',['-n','-x',join(stateRoot,`${lane}.lock`),'true']);
|
||||
if(probe.status!==0&&probe.status!==1)refuse('cannot inspect lane lock');
|
||||
if(probe.status===1){if(lane==='image')answer.image=answer.image==='free'?'held':answer.image;else if(answer[lane].state==='CLEAR')answer[lane]={state:'BUSY',run:null};}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
Reference in New Issue
Block a user