Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fdfe3790c | ||
|
|
1f00d4f91e | ||
|
|
44104adb19 | ||
|
|
1f6db15899 | ||
|
|
cb048b93ce | ||
|
|
67daf64033 |
No files matched your search
@@ -0,0 +1,180 @@
|
||||
# Row 307 (#1814, Vikunja 598): move row 270's pi compaction reading behind the harness kind
|
||||
|
||||
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
|
||||
docs/plans/2026-10-23_harness-kind-compactions.md. Base 1e629c892. Design: DESIGN.md beside this file,
|
||||
approved (static) by Duckworth on 67daf6403 (duckworth-0280), with C1, C2, N1 and N2 folded in at
|
||||
cb048b93c. Mos ruled go on the four old-test edits in mos-1459, with the bounds below.
|
||||
|
||||
## Commits
|
||||
|
||||
1. 1f6db1589, red first. The two new test files and capture.mjs. Its product files are base's:
|
||||
`git diff --stat 1e629c892 1f6db1589` lists only this packet, capture.mjs and the two tests.
|
||||
2. 44104adb1, the build. The product files and the four old-test edits.
|
||||
|
||||
## What the build does
|
||||
|
||||
- `packages/plugins/src/kinds.mjs:52-57`: the harness kind's exports end in `"compactions"`, and the
|
||||
comment gains its contract line. `interfaceVersion` stays 1 (DESIGN.md "interfaceVersion stays 1").
|
||||
- New `packages/plugins/harness/pi/compactions.mjs`: base's `scanPi`, `readRange`, `digest`, `STREAM`,
|
||||
`TAIL`, the chunk size, `PI_VERSION` and `claudeHistory`, unchanged in behaviour, plus the export.
|
||||
`scanPi` is no longer exported and takes no defaults: its one caller passes all four arguments.
|
||||
- `pi/index.mjs` re-exports it. `claude-code/index.mjs` answers harness-unsupported with version null.
|
||||
- `packages/seat/src/compactions.mjs`: keeps the ledger. `readLedger` sets `unsupported` on
|
||||
`r.reason === "harness-unsupported"` only (:113). `checkAnswer` (:147-164) refuses an answer out of
|
||||
contract. `collectCompactions(store, { name, compactions }, opts)` (:174-205) calls the export, checks
|
||||
it, and writes the same lines in the same order as base.
|
||||
- `packages/cli/src/launcher.mjs`: the `compactions` closure takes the loaded module and calls the
|
||||
export directly, with the N1 comment on why it skips `call()`. Both call sites pass `harness`.
|
||||
- `packages/plugins/README.md`: the Harness section gains the `compactions` bullet.
|
||||
|
||||
## Old-test edits (mos-1459 bounds)
|
||||
|
||||
| File | Edit | Bound |
|
||||
| --- | --- | --- |
|
||||
| plugins/tests/kinds.test.mjs | `"compactions"` appended to the pinned list | :17-21, append only |
|
||||
| seat/tests/compactions.test.mjs | two module imports; `collect` passes `{ name, compactions }` | :11/:21, helper only |
|
||||
| seat/tests/compactions-budget.test.mjs | pi import; `collect` passes `{ name, compactions }` | :13/:24-25, helper only |
|
||||
| cli/tests/compactions-launch.test.mjs | pi import; base :344's call (now :345) passes `{ name: "pi", compactions: pi }` | call only, `.count === 1` kept |
|
||||
|
||||
No call or expected value in those files changed. harness.test.mjs, cli helpers.mjs:133-151,
|
||||
launch-routes.test.mjs:17-23 and launcher.test.mjs:694-697 are unedited.
|
||||
|
||||
## New tests
|
||||
|
||||
- `packages/seat/tests/compactions-ledger.test.mjs`:
|
||||
- FIXTURES: seven ledgers row 270's code writes (known, store-incomplete, store-unreadable,
|
||||
not-collected, harness-unsupported from claude-code, harness-unsupported from Claude history, and
|
||||
torn), each with its streams, projects and steps. Per fixture, test A reads `compactionCount` and
|
||||
test B runs one collection through `{ name: "pi", compactions: pi.compactions }`.
|
||||
- Test B runs over the fixture's own streams, not an empty session as DESIGN.md:189 says. Over an
|
||||
empty session every fixture with a cursor would go store-incomplete (the stream went away), so
|
||||
"the others go known" could not hold. The expected answers: known stays known (1), store-unreadable
|
||||
goes known with 0 (the cursor is at the end), not-collected goes known with 1, and
|
||||
store-incomplete, torn and both harness-unsupported fixtures stay unknown.
|
||||
- The malformed-answer test (17 answers, plus a module that throws, over a fresh store and over the
|
||||
known fixture) and the argument test (exact keys; core passes a copy of the cursors).
|
||||
- `packages/plugins/tests/harness-compactions.test.mjs`: CASES plain, budget (100 bytes), cap (1
|
||||
stream of 2), shrank and header against pi; pi's `first` with and without Claude history;
|
||||
claude-code; and a module re-exporting every pi export but `compactions`, refused `missing-export`.
|
||||
- capture.mjs reads both capture blocks from the test files. It replays each fixture with base's
|
||||
`collectCompactions(store, "<name>", ...)` and compares the ledger byte for byte, and it runs base's
|
||||
`scanPi` per case and compares the answer under base's reason order (:301 at base) and
|
||||
`PI_VERSION`. It prints base's `compactionCount` per fixture: that is the before half.
|
||||
|
||||
## Expected red on 1f6db1589
|
||||
|
||||
- seat compactions-ledger: the seven test A pass (the ledger rules are base's). Test B fails for known,
|
||||
store-unreadable and not-collected: base takes the object as a harness that isn't pi and writes
|
||||
harness-unsupported. Test B passes on base for the other four, whose expected answer is unknown with
|
||||
the same reason. The malformed-answer and argument tests fail: base never calls the module.
|
||||
Correction after the run: test B fails on base for claude-history too, so the reds are known,
|
||||
store-unreadable, not-collected and claude-history. On base the object harness takes :304, which
|
||||
writes `collected` without a scan, so the count stays null. Pi on the tip scans on a later
|
||||
collection, finds c1 and answers 1, still unknown with harness-unsupported. The test is right and
|
||||
this list was wrong (Duckworth agreed, duckworth-0287).
|
||||
- plugins harness-compactions: the five CASES, the `first` test and the claude-code test fail with a
|
||||
TypeError (`compactions` isn't exported on base). The missing-export test fails: base's loader loads
|
||||
the module.
|
||||
- harness.test.mjs:192 is already red on base. That's the brief's starting point.
|
||||
|
||||
## Run plan (mos-1275, `timeout -k 10`)
|
||||
|
||||
All on a CLEAR from Sage. TMPDIR comes from `mktemp -d /mnt/storage/scratch/tmp/gXXX`. Gate worktrees
|
||||
under /mnt/storage/scratch, node_modules linked right after `git worktree add`, pipefail and tee.
|
||||
|
||||
| Run | Longest recorded | LIMIT |
|
||||
| --- | --- | --- |
|
||||
| capture.mjs on a 1f6db1589 worktree (base and candidate both it) | none | 120 s |
|
||||
| red first: the two new files on 1f6db1589, each whole | none (seat suite 7 s) | 300 s |
|
||||
| the touched test files whole on 44104adb1, one at a time | compactions-launch alone: none; other files 15 s | 600 s |
|
||||
| bus suite | 25 s (270 r5) | 900 s |
|
||||
| cli suite | 682 s (270 r5) | 2100 s |
|
||||
| plugins suite | none recorded | 900 s |
|
||||
| webui suite | 58.2 s (294) | 600 s |
|
||||
| business suite | none recorded | 900 s |
|
||||
| seat suite whole, `node --test tests/` (Duckworth C1) | 7 s (270 r5) | 600 s |
|
||||
| each scripts/test-*.sh | 42 s (test-foundation) | 900 s |
|
||||
| each mutant, its kill files whole | 1 s (264a probes) | 300 s |
|
||||
|
||||
- In the cli run, compactions-launch's T3 tests (base :206, and :305 with the :344 edit; :207, :306
|
||||
and :345 after the import line) must show as run, not skipped on `sandboxProblem()` (base :36, now
|
||||
:37). The report names every skip (Duckworth C2).
|
||||
- Plugins must have zero failures.
|
||||
- A timeout with no failed test is a hang and goes to Sage. A timeout in the shell phase goes to Mos
|
||||
unplaced. A first-run red in a new 307 test is placed and reported after (mos-1272). A red in an old
|
||||
test goes to Sage, unfixed.
|
||||
|
||||
## Mutants
|
||||
|
||||
Each on an isolated copy of the file, restore checked by sha256, only on a green base.
|
||||
|
||||
| # | Change | Kill files |
|
||||
| --- | --- | --- |
|
||||
| M1 | `readLedger`: `if (r.harness !== "pi" \|\| r.reason === "harness-unsupported")` restored | plugins harness.test.mjs (:192) |
|
||||
| M2 | pi `compactions` drops the `first && claudeHistory(store)` branch | seat compactions-budget.test.mjs (base :86, now :87), plugins harness-compactions.test.mjs |
|
||||
| M3 | `readLedger` never sets `unsupported` | seat compactions-ledger.test.mjs, seat compactions.test.mjs (base :197, now :199) |
|
||||
| M4 | `collectCompactions` skips `checkAnswer` | seat compactions-ledger.test.mjs |
|
||||
|
||||
## Runs
|
||||
|
||||
Sage's CLEARs: sage-3455 for steps 1-3, sage-3465 for steps 4-6. Gate worktrees at
|
||||
/mnt/storage/scratch/g307-base (1f6db1589), g307-tip and g307-mut (1f00d4f91), with node_modules
|
||||
linked. TMPDIR was /mnt/storage/scratch/tmp/gTIK. Outputs went to /mnt/storage/scratch/gizmo-307-out/s1 and s2.
|
||||
|
||||
### Steps 1-3 (13:31:21Z-13:31:36Z, then the capture rerun at 13:32Z)
|
||||
|
||||
- capture.mjs crashed with ENOENT on its first run. Base `sessionStore` (session.mjs:78
|
||||
`privateDirs`, called from :117) makes its directories one level at a time, and :44 passed a
|
||||
per-fixture root that didn't exist yet. The fix is one line before :44,
|
||||
`` mkdirSync(join(root, `f-${name}`)) ``. A copy with that line, run on g307-base as both base and
|
||||
candidate, exited rc 0 and printed `all match`: 7 ledgers byte for byte, 7 base counts and 5 scans.
|
||||
Base counts: known 1 known; store-incomplete, store-unreadable and not-collected null, unknown
|
||||
with their own reason; claude-code and claude-history null, harness-unsupported; torn 1,
|
||||
store-incomplete.
|
||||
- Red first on 1f6db1589, both rc 1. Seat compactions-ledger: 9 pass, 7 fail (the corrected list
|
||||
above, plus the malformed-answer and argument tests). Plugins harness-compactions: 0 pass, 8 fail.
|
||||
Seven fail with `TypeError: pi.compactions is not a function` or `claude.compactions is not a
|
||||
function`. The missing-export test fails because base loads the module.
|
||||
- Touched files whole on 1f00d4f91, all rc 0: compactions-ledger 16/16, harness-compactions 8/8,
|
||||
kinds 5/5, harness 9/9 (:192 green), compactions 20/20, compactions-budget 10/10,
|
||||
compactions-launch 5/5 with 0 skipped.
|
||||
|
||||
### Steps 4-6 (14:00:10Z-14:14:00Z, on 1f00d4f91)
|
||||
|
||||
| Run | rc | Time | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| bus | 0 | 24 s | 890 pass, 0 fail, 0 skipped |
|
||||
| cli | 0 | 631 s | 927 pass, 0 fail, 0 skipped; T3 :207, :306, :345 ran |
|
||||
| plugins | 0 | 0 s | 119 pass, 0 fail, 0 skipped |
|
||||
| webui | 0 | 57 s | 374 pass, 0 fail, 1 skipped (below) |
|
||||
| business | 0 | 2 s | 231 pass, 0 fail, 0 skipped |
|
||||
| seat | 0 | 7 s | 218 pass, 0 fail, 0 skipped |
|
||||
| test-auth | 0 | 0 s | 15 passed, 0 failed |
|
||||
| test-conductor | 0 | 7 s | |
|
||||
| test-config | 0 | 3 s | |
|
||||
| test-discord | 0 | 7 s | |
|
||||
| test-extension-package | 0 | 2 s | |
|
||||
| test-foundation | 0 | 39 s | |
|
||||
| test-land-work | 0 | 12 s | |
|
||||
| test-queue | 0 | 20 s | one skip (below) |
|
||||
| test-release | 0 | 3 s | 15 passed, 0 failed |
|
||||
| test-task | 0 | 14 s | 92 passed, 0 failed, two skips (below) |
|
||||
|
||||
Skips:
|
||||
- webui: "rebuilding from the source gives the same bytes" needs the pinned esbuild. 307 doesn't
|
||||
touch the editor.
|
||||
- test-queue: "queue verify and render --check", because the gate worktree isn't the queue's
|
||||
canonical root.
|
||||
- test-task: "live provider cases" twice (row 244, no provider call in the gate).
|
||||
|
||||
Mutants ran in g307-mut. Each restore matched its sha256, and the tree was clean after M4.
|
||||
|
||||
| # | Killed by |
|
||||
| --- | --- |
|
||||
| M1 | plugins harness.test.mjs:192 |
|
||||
| M2 | seat compactions-budget.test.mjs:87 and :104; plugins harness-compactions.test.mjs:47 |
|
||||
| M3 | seat compactions-ledger.test.mjs:123 (test B, claude-code and claude-history); seat compactions.test.mjs:199 |
|
||||
| M4 | seat compactions-ledger.test.mjs:151 (the malformed-answer test) |
|
||||
|
||||
There were no reds, so nothing was placed under mos-1272. The sweep found no node or chromium left in
|
||||
my cwds.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Row 307 (#1814, Vikunja 598): row 270's pi compaction reading moves behind the harness kind
|
||||
|
||||
Owner Gizmoduck. Reviewer Duckworth. Gate owner Sage. Brief:
|
||||
`docs/plans/2026-10-23_harness-kind-compactions.md` at 1e629c892, which is the base. Mos ruled the
|
||||
move in mos-1455 item 3 and declined an EXCEPT entry.
|
||||
|
||||
## What is red, as the code has it at 1e629c892
|
||||
|
||||
`packages/plugins/tests/harness.test.mjs:192` greps core sources for a quoted harness name. It finds
|
||||
three lines in `packages/seat/src/compactions.mjs`:
|
||||
|
||||
- `:232` in `readLedger`: `if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;`
|
||||
- `:283` and `:287` in `collectCompactions`: the `harness === "pi"` branches.
|
||||
|
||||
Everything else row 270 put in that file is either harness-free (the ledger, the budget and stream
|
||||
settings, `storeDir`, `compactionCount`) or pi-only (`scanPi`, `PI_VERSION`, `claudeHistory`, and
|
||||
the helpers only `scanPi` uses: `readRange`, `digest`, `STREAM`, `TAIL`).
|
||||
|
||||
## The new export: `compactions`
|
||||
|
||||
`packages/plugins/src/kinds.mjs:54` appends `"compactions"` to the harness kind's exports, so the
|
||||
loader refuses a module without it (`loader.mjs:61-66`, code `missing-export`).
|
||||
|
||||
```
|
||||
compactions({ store, cursors, budget, streams, first })
|
||||
-> { found, cursors, known, reason, version }
|
||||
```
|
||||
|
||||
Arguments, all from core:
|
||||
|
||||
- `store` is `{ session, projects }`, the two native store directories `sessionStore` makes. The
|
||||
module never gets `store.dir`, the directory that holds the ledger.
|
||||
- `cursors` is a copy of the ledger's cursor map, `{ [stream]: { bytes, tail } }`.
|
||||
- `budget` and `streams` are `readBudget(...)` and `readStreams(...)`, as the launcher passes them
|
||||
now (`launcher.mjs:299`, `:305`). Core keeps both readers and their defaults.
|
||||
- `first` is true when the store has no ledger yet (today's `!prior`).
|
||||
|
||||
The answer:
|
||||
|
||||
- `found`: `[{ entry, completedAt, stream }]`, the compactions read past the cursors. `entry` is a
|
||||
non-empty string, `completedAt` a string or null, `stream` a non-empty string.
|
||||
- `cursors`: `{ [stream]: { bytes, tail } }` for each stream the module read. `bytes` is a safe
|
||||
integer of 0 or more and `tail` 64 lowercase hex.
|
||||
- `reason`: null, `store-incomplete`, `store-unreadable`, `not-collected` or `harness-unsupported`.
|
||||
- `known`: a boolean, true exactly when `reason` is null.
|
||||
- `version`: the store format the module read against as a string, or null when it read nothing.
|
||||
|
||||
Core checks the answer before it writes. Anything else throws `harness <name> compactions: bad
|
||||
answer: <what>` and nothing is appended. A throw from the module itself is the same case. Both reach
|
||||
the launcher's existing catch (`launcher.mjs:313-316`), which logs the message and records the run's
|
||||
`compactions` event as unknown with `store-unreadable`, as a scan that throws today does.
|
||||
|
||||
### interfaceVersion stays 1
|
||||
|
||||
- Every harness module lives in this tree, and the loader loads nothing else (decision 143,
|
||||
`loader.mjs:1-5`). Both shipped modules gain the export in the same commit.
|
||||
- A module without the export already refuses at load, as `missing-export` naming `compactions`.
|
||||
That is the brief's third test. A bump would add a second refusal for the same fact.
|
||||
- No existing export changes its arguments or answer. Row 125 moved limit-check to 2 because
|
||||
`mayLaunch`, `mustStop` and `report` changed shape. Nothing like that happens here.
|
||||
- `harness.test.mjs:36` pins `interfaceVersion` 1, and the brief keeps that file unchanged.
|
||||
|
||||
The tradeoff: a module someone wrote against version 1 without `compactions` reports
|
||||
`missing-export`, not `interface-version`. The message names the export, which is the more useful of
|
||||
the two.
|
||||
|
||||
## What moves where
|
||||
|
||||
New file `packages/plugins/harness/pi/compactions.mjs`, imported by `pi/index.mjs`. The loader
|
||||
checks only `index.mjs` itself (`loader.mjs:42-54`), and claude-code's module already imports files
|
||||
outside its directory (`claude-code/index.mjs:11-12`). It takes, unchanged in behaviour:
|
||||
|
||||
- `scanPi`, with `readRange`, `digest`, `STREAM`, `TAIL` and the chunk size it reads by;
|
||||
- `PI_VERSION` ("0.85.1");
|
||||
- `claudeHistory(store)`;
|
||||
- the reason order `["store-incomplete", "store-unreadable", "not-collected"]` from `:301`;
|
||||
- the header comment's Pi paragraphs (`compactions.mjs:12-19`, `:21-24`).
|
||||
|
||||
pi's `compactions`:
|
||||
|
||||
- `first` and `claudeHistory(store)`: answers `{ found: [], cursors: {}, known: false, reason:
|
||||
"harness-unsupported", version: "0.85.1" }`. That is today's `:283-286`.
|
||||
- otherwise: `scanPi(store.session, cursors, budget, streams)`, and answers its `found` and
|
||||
`cursors` with the first reason in the order above, `known` when that's null, and version
|
||||
"0.85.1". That is today's `:287-302` before the dedupe.
|
||||
|
||||
claude-code's `compactions` reads nothing and answers `{ found: [], cursors: {}, known: false,
|
||||
reason: "harness-unsupported", version: null }`. That is today's `:303-304`. Vikunja 466 still has
|
||||
no pinned Claude Code format.
|
||||
|
||||
## Core keeps the ledger
|
||||
|
||||
`packages/seat/src/compactions.mjs` keeps `LEDGER`, `READ_BYTES`, `STREAMS`, `readBudget`,
|
||||
`readStreams`, `storeDir`, `readLedger`, `compactionCount`, the key and the single O_APPEND write.
|
||||
|
||||
`collectCompactions(store, harness, { now, budget, streams })` takes `harness = { name,
|
||||
compactions }`, the name the route used and the loaded module's export. It:
|
||||
|
||||
1. reads the ledger (`prior`), as now;
|
||||
2. calls `harness.compactions({ store: { session: store.session, projects: store.projects },
|
||||
cursors: { ...ledger.cursors }, budget, streams, first: prior === null })` and checks the answer;
|
||||
3. appends, in today's order: one `compaction` line per key the ledger doesn't hold yet, one
|
||||
`cursor` line per stream whose cursor moved, then `{ v: 1, kind: "collected", at, known, reason,
|
||||
harness: name, version }`. These are the same lines and field order as today's `:288-302` and
|
||||
`:304`. `name` is a variable, never a literal;
|
||||
4. returns `compactionCount(store.dir)`.
|
||||
|
||||
`readLedger`'s unsupported test becomes `r.reason === "harness-unsupported"`, a field every answer
|
||||
carries. No core line names a harness. The core comments drop the quoted names too, because the
|
||||
grep reads comments.
|
||||
|
||||
`packages/cli/src/launcher.mjs:309-318`: the `compactions` closure takes the loaded module too and
|
||||
calls `collectCompactions(store, { name: model.harness, compactions: harness.compactions }, {
|
||||
budget, streams })`. Both call sites, `:509` and `:650`, have `harness` in scope (it's
|
||||
`startLocked`'s `let harness` from `:503`; the `exited.then` callback at `:632` closes over it).
|
||||
The closure calls the export directly, not through `call()` (`launcher.mjs:196`), because `call()`
|
||||
turns a throw into a run refusal. The closure gets a comment saying so (Duckworth N1).
|
||||
`bus/src/runtime.mjs:282` and `broker.mjs:885-889` keep calling `compactionCount` and don't change.
|
||||
|
||||
`packages/plugins/README.md`, Harness section, and the comment at `kinds.mjs:35-51` each gain the
|
||||
`compactions` line with the contract above.
|
||||
|
||||
## A ledger row 270 wrote reads the same count
|
||||
|
||||
Row 270's code writes a `collected` line in exactly three places:
|
||||
|
||||
| Line | harness | reason | version | old rule (`:232`) | new rule |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `:286`, Claude history on the first pi collection | `"pi"` | `harness-unsupported` | "0.85.1" | unsupported | unsupported |
|
||||
| `:302`, a pi scan | `"pi"` | null, `store-incomplete`, `store-unreadable` or `not-collected` | "0.85.1" | supported | supported |
|
||||
| `:304`, any other harness name (`claude-code`, or `claude` in the tests) | that name | `harness-unsupported` | null | unsupported | unsupported |
|
||||
|
||||
The two rules agree on every line row 270 can write. The rest of `readLedger` (the `bad`, `torn`,
|
||||
`incomplete` and `events` handling) and all of `compactionCount` don't change. So every ledger row
|
||||
270 wrote reads the same `{ count, known, reason, at }` after the move. Nothing rewrites a ledger;
|
||||
the next collection only appends.
|
||||
|
||||
The rules would differ only on a `collected` line naming a harness other than `"pi"` with a reason
|
||||
other than `harness-unsupported`. Row 270 never writes one, and the launcher is the ledger's only
|
||||
writer (`compactions.mjs:7-10`).
|
||||
|
||||
New collections after the move append the same bytes as before for the same store state. pi's
|
||||
answer is today's scan result, core keeps the dedupe and the line format, and `harness` in the line
|
||||
is still the route's name.
|
||||
|
||||
## Existing tests that change (to Mos through Sage, mos-1272)
|
||||
|
||||
Grepped for `interfaceVersion`, the harness exports, `write("harness"`, `fakeHarnesses` and
|
||||
`collectCompactions(` in `packages/*/tests/*` and `scripts/`.
|
||||
|
||||
Four existing files change:
|
||||
|
||||
1. `packages/plugins/tests/kinds.test.mjs:17-21`: the pinned harness export list gains
|
||||
`"compactions"` at the end.
|
||||
2. `packages/seat/tests/compactions.test.mjs:11` and `:21`: import the two shipped modules. `setup`'s
|
||||
`collect(harness = "pi")` passes `{ name: harness, compactions: MODULES[harness].compactions }`,
|
||||
with `pi` mapped to the pi module and `claude` to claude-code's. The 27 `f.collect()` calls and
|
||||
the two named ones at `:199` and `:201` stay as written.
|
||||
3. `packages/seat/tests/compactions-budget.test.mjs:13` and `:24-25`: the same change in its
|
||||
`collect`. Its calls at `:67-69` and `:129-134` stay as written.
|
||||
4. `packages/cli/tests/compactions-launch.test.mjs:344`: the one direct
|
||||
`collectCompactions(legacy, "pi")` becomes `collectCompactions(legacy, { name: "pi",
|
||||
compactions: pi.compactions })`, with an import of the pi module.
|
||||
|
||||
Fixtures that build a harness module and need no edit, because they re-export a real module and
|
||||
inherit `compactions`:
|
||||
|
||||
- `packages/cli/tests/helpers.mjs:133-151` `fakeHarnesses`, used by `compactions-launch`,
|
||||
`idle-stop-race`, `launcher`, `lifecycle`, `session-clear`, `session-launch`, `thread-launch` and
|
||||
`thread-revoke`;
|
||||
- `packages/cli/tests/launch-routes.test.mjs:17-23`: `down`, `sparse`, `metered` and `throws`
|
||||
re-export pi. `broken` lacks every export but `adapter` on purpose. The loader still refuses it at
|
||||
`levels` first, so `:114` and `:138` (`missing-export`) hold;
|
||||
- `packages/cli/tests/launcher.test.mjs:694-697`: `down` and `fixture` re-export pi.
|
||||
|
||||
`packages/plugins/tests/harness.test.mjs` doesn't change. `:33-37` loops over the kind's exports,
|
||||
which both modules then have, and `:192` goes green.
|
||||
|
||||
No `scripts/test-*.sh` builds a harness module. `test-config.sh:266-280` checks only the two
|
||||
config keys.
|
||||
|
||||
## New tests, red first (mos-1272)
|
||||
|
||||
1. `packages/seat/tests/compactions-ledger.test.mjs`: one fixture ledger per reason row 270 writes:
|
||||
known, `store-incomplete`, `store-unreadable`, `not-collected`, `harness-unsupported` from
|
||||
claude-code, `harness-unsupported` from Claude history, and a torn ledger.
|
||||
- Each is literal JSONL in the test. Before the build, in the slot, a capture script runs base
|
||||
1e629c892's `collectCompactions` with a fixed `now` over the same stores. The literals must
|
||||
equal its output byte for byte. That proves row 270's code wrote them.
|
||||
- `compactionCount` reads each one. I run this half on the base and on the candidate, and record
|
||||
both outputs in BUILD.md as the before and after.
|
||||
- Then one more collection through `{ name, compactions }` over an empty session gives each
|
||||
fixture's expected next answer: unsupported and `store-incomplete` stay, the others go known.
|
||||
This half is red on the base, where core treats the object as a harness that isn't pi.
|
||||
2. `packages/plugins/tests/harness-compactions.test.mjs`:
|
||||
- pi's `compactions` over fixture streams answers what base `scanPi` answers, captured by the same
|
||||
script: a plain count, a budget that stops short (cursor at the last whole line,
|
||||
`not-collected`), more streams than the cap (no cursor for unopened streams, `not-collected`),
|
||||
a stream that shrank under its cursor (`store-incomplete`), a non-session header
|
||||
(`store-unreadable`). Each answer has version "0.85.1".
|
||||
- pi with `first` and a non-empty `projects` answers `harness-unsupported` without scanning.
|
||||
Without `first`, the same store scans.
|
||||
- claude-code answers `harness-unsupported` with version null, over directories that don't exist.
|
||||
- A harness module that re-exports every other pi export by name, without `compactions`, refuses
|
||||
at load: `missing-export`, "doesn't export compactions".
|
||||
3. In the same seat file: core refuses each malformed answer and appends nothing. The cases are
|
||||
`found` not an array, an empty `entry`, a cursor whose `bytes` is negative or `tail` isn't 64
|
||||
hex, `known` true with a reason, an unknown reason, a numeric `version`, and a module that
|
||||
throws. The module gets exactly `{ store: { session, projects }, cursors, budget, streams,
|
||||
first }`.
|
||||
|
||||
## Mutants
|
||||
|
||||
- M1, the brief's first: core branches on a harness name again (`readLedger`'s `r.harness !== "pi"
|
||||
||` restored). Killed by `harness.test.mjs:192`.
|
||||
- M2, the brief's second: pi's module drops the Claude-history check. Killed by
|
||||
`compactions-budget.test.mjs:86` and new test 2's `first` case.
|
||||
- M3, the brief's third: `readLedger` never sets `unsupported`. Killed by new test 1's two
|
||||
`harness-unsupported` fixtures and `compactions.test.mjs:197`.
|
||||
- M4, mine: core skips the answer check. Killed by new test 3.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves this design, then the manifest. On Sage's CLEAR:
|
||||
|
||||
- the capture script on base 1e629c892 first, then the new tests red on the base;
|
||||
- the bus, cli, plugins (zero failures), webui and business suites, and every `scripts/test-*.sh`;
|
||||
- the seat suite whole (`packages/seat`, `node --test tests/`), where two old-test edits, new
|
||||
tests 1 and 3, and the M2 and M3 kills live (Duckworth C1);
|
||||
- in the cli run, `compactions-launch`'s T3 tests (`:206`, and `:305` with the `:344` edit) must
|
||||
run, not skip on `sandboxProblem()` (`:36`). BUILD.md names every skip (Duckworth C2);
|
||||
- the mutants, each on an isolated copy with its restore checked by sha256.
|
||||
|
||||
Each suite runs under `timeout -k 10` at 3x or more of its longest recorded wall (mos-1275). I'll
|
||||
put the numbers in BUILD.md before the first run. TMPDIR comes from
|
||||
`mktemp -d /mnt/storage/scratch/tmp/gXXX`.
|
||||
|
||||
## Round 1
|
||||
|
||||
Duckworth approved round 1 on 67daf6403 (duckworth-0280). This revision takes C1 and C2 into
|
||||
the gate, adds the N1 comment to the launcher plan, and corrects the N2 line refs (`:302`, `:301`).
|
||||
@@ -0,0 +1,79 @@
|
||||
// Row 307 (#1814): checks the new tests' literals against row 270's code at base 1e629c892.
|
||||
//
|
||||
// node capture.mjs <base checkout at 1e629c892> <candidate checkout>
|
||||
//
|
||||
// Reads the FIXTURES and CASES blocks (between `// capture:begin` and `// capture:end`) from the
|
||||
// candidate's two new test files. Replays each fixture's steps with the base's collectCompactions,
|
||||
// harness named by string as row 270 takes it, with the clock at 12:00:00 and one second a step,
|
||||
// and compares the ledger byte for byte. Prints the base's compactionCount for each. Runs the
|
||||
// base's scanPi over each case and compares the answer the base's reason order gives (:301) with
|
||||
// version PI_VERSION. Exit 0 only when every one matches. Writes only under TMPDIR.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const [base, cand] = process.argv.slice(2).map((p) => p && resolve(p));
|
||||
if (!base || !cand) {
|
||||
console.error("usage: node capture.mjs <base checkout> <candidate checkout>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function block(file) {
|
||||
const text = readFileSync(join(cand, file), "utf8");
|
||||
const m = /\n\/\/ capture:begin\nconst [A-Z]+ = (\{[\s\S]*?\});\n\/\/ capture:end\n/.exec(text);
|
||||
if (!m) throw new Error(`${file}: no capture block`);
|
||||
return JSON.parse(m[1]);
|
||||
}
|
||||
|
||||
const FIXTURES = block("packages/seat/tests/compactions-ledger.test.mjs");
|
||||
const CASES = block("packages/plugins/tests/harness-compactions.test.mjs");
|
||||
const seat = await import(pathToFileURL(join(base, "packages/seat/src/compactions.mjs")).href);
|
||||
const { sessionStore } = await import(pathToFileURL(join(base, "packages/seat/src/session.mjs")).href);
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "g307-capture-"));
|
||||
let failed = 0;
|
||||
const report = (what, same, detail) => {
|
||||
console.log(`${same ? "MATCH" : "DIFF "} ${what}${detail ? ` ${detail}` : ""}`);
|
||||
if (!same) failed++;
|
||||
};
|
||||
try {
|
||||
for (const [name, fx] of Object.entries(FIXTURES)) {
|
||||
mkdirSync(join(root, `f-${name}`));
|
||||
const store = sessionStore(join(root, `f-${name}`), "acme", "th1", "s1");
|
||||
for (const [file, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, file), text);
|
||||
for (const [file, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, file), text);
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
for (const step of fx.steps) {
|
||||
if (step.tear !== undefined) appendFileSync(join(store.dir, seat.LEDGER), step.tear);
|
||||
else seat.collectCompactions(store, step.collect, { now, ...(step.budget === undefined ? {} : { budget: step.budget }) });
|
||||
}
|
||||
const got = readFileSync(join(store.dir, seat.LEDGER), "utf8");
|
||||
const want = fx.ledger.join("\n") + "\n";
|
||||
report(`ledger ${name}`, got === want, got === want ? `(${Buffer.byteLength(got)} bytes)` : `\n--- base wrote\n${got}--- test has\n${want}`);
|
||||
const count = seat.compactionCount(store.dir);
|
||||
report(`base count ${name}`, JSON.stringify(count) === JSON.stringify(fx.count), JSON.stringify(count));
|
||||
}
|
||||
for (const [name, c] of Object.entries(CASES)) {
|
||||
const dir = join(root, `c-${name}`);
|
||||
mkdirSync(dir);
|
||||
for (const [file, text] of Object.entries(c.streams)) writeFileSync(join(dir, file), text);
|
||||
const scan = seat.scanPi(dir, c.cursors, c.budget, c.cap);
|
||||
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
|
||||
const answer = { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: seat.PI_VERSION };
|
||||
let same = true;
|
||||
try {
|
||||
assert.deepStrictEqual(answer, c.answer);
|
||||
} catch {
|
||||
same = false;
|
||||
}
|
||||
report(`scan ${name}`, same, JSON.stringify(scan));
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
console.log(failed ? `${failed} differ` : "all match");
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -306,10 +306,13 @@ export function createLauncher({
|
||||
if (system?.compactionStreams !== undefined && streams !== system.compactionStreams) {
|
||||
log(`system config: compactionStreams ${JSON.stringify(system.compactionStreams)} isn't a whole number from 32 to 65536; compaction collections open up to the default ${STREAMS} streams`);
|
||||
}
|
||||
const compactions = (store, harness, { instance, thread, session, run }) => {
|
||||
// Row 307 (#1814): the route's harness module reads its own store through the kind's
|
||||
// `compactions`. The call skips call(): call() turns a throw into a launch refusal, and a
|
||||
// collection that fails must never stop or fail the run, so this catch records it instead.
|
||||
const compactions = (store, name, harness, { instance, thread, session, run }) => {
|
||||
let c;
|
||||
try {
|
||||
c = collectCompactions(store, harness, { budget, streams });
|
||||
c = collectCompactions(store, { name, compactions: harness.compactions }, { budget, streams });
|
||||
} catch (e) {
|
||||
log(`run ${run}: compactions: ${e.message}`);
|
||||
c = { count: null, known: false, reason: "store-unreadable" };
|
||||
@@ -506,7 +509,7 @@ export function createLauncher({
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
if (store) compactions(store, model.harness, { instance, thread, session, run });
|
||||
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
|
||||
const toolSocket = join(runDir, "tools.sock");
|
||||
const turnMarker = join(runDir, "turn.done");
|
||||
const version = harnessVersion !== undefined ? harnessVersion : call(model.harness, "version", () => harness.version({ env }));
|
||||
@@ -647,7 +650,7 @@ export function createLauncher({
|
||||
} finally {
|
||||
try { unregisterRuntime(dataRoot, managed.id); }
|
||||
catch { log(`run ${run}: processing receipt cleanup refused`); }
|
||||
if (store) compactions(store, model.harness, { instance, thread, session, run });
|
||||
if (store) compactions(store, model.harness, harness, { instance, thread, session, run });
|
||||
try { lock?.release(); }
|
||||
catch (e) { log(`run ${run}: workspace lock release: ${e.message}`); }
|
||||
children.delete(run);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { collectCompactions, LEDGER } from "../../seat/src/compactions.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, SESSION_CREDENTIALS, sessionStore } from "../../seat/src/session.mjs";
|
||||
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
@@ -341,7 +342,7 @@ test("REQ-THREAD-2 T3 T4 a clear leaves the retired session's count as it was, i
|
||||
seeded.close();
|
||||
const legacy = sessionStore(f.dataRoot, "acme", L, L, { legacy: true });
|
||||
writeFileSync(join(legacy.session, "pi.jsonl"), line({ type: "session", version: 3, id: "pi", timestamp: "2026-10-22T11:00:00.000Z", cwd: "/w" }) + line({ type: "compaction", id: "c7", parentId: null, timestamp: "2026-10-22T11:07:00.000Z", summary: "s", firstKeptEntryId: "k", tokensBefore: 100 }), { mode: 0o600 });
|
||||
assert.equal(collectCompactions(legacy, "pi").count, 1);
|
||||
assert.equal(collectCompactions(legacy, { name: "pi", compactions: pi }).count, 1);
|
||||
host = await start(t, prepared);
|
||||
const [row] = host.call("session.list", { thread: L });
|
||||
assert.equal(row.id, L);
|
||||
|
||||
@@ -153,6 +153,21 @@ export returns:
|
||||
`gateTimeout`, `bundle`, `env`, `turnEnd` and `transcript` give its
|
||||
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
|
||||
how a turn ends and where its transcripts land.
|
||||
- `compactions({store, cursors, budget, streams, first})` reads a thread
|
||||
session's native store for row 270's compaction count (row 307). Core keeps
|
||||
the ledger (`packages/seat/src/compactions.mjs`) and passes `store` as
|
||||
`{session, projects}`, the ledger's `cursors` by stream, the read `budget`
|
||||
and stream cap `streams` from the system config, and `first` when the
|
||||
store has no ledger yet. The answer is `{found, cursors, known, reason,
|
||||
version}`: `found` lists `{entry, completedAt, stream}` past the cursors,
|
||||
`cursors` maps each stream read to `{bytes, tail}` (`tail` is the sha256 of
|
||||
up to 4096 bytes ending there), `reason` is null, `store-incomplete`,
|
||||
`store-unreadable`, `not-collected` or `harness-unsupported`, `known` is
|
||||
true exactly when `reason` is null, and `version` names the store format
|
||||
read, or null. Core refuses any other answer and writes nothing. pi reads
|
||||
its streams within the budget and the cap, and answers
|
||||
`harness-unsupported` on a first collection that finds Claude history.
|
||||
Claude Code answers `harness-unsupported` with version null (Vikunja 466).
|
||||
- Row 66: both modules wire the unslop check from `tools/unslop-hook`.
|
||||
`mounts` adds its three files read-only and `bundle` names them in the
|
||||
manifest's sources. Pi's `env` loads `extension.ts` as the second entry of
|
||||
|
||||
@@ -128,3 +128,6 @@ export function env({ files, policy, session }) {
|
||||
// A turn ends when claude exits; there is no marker.
|
||||
export const turnEnd = () => "exit";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.projects : join(runDir, "home", ".claude", "projects"));
|
||||
// Row 307 (#1814): Claude Code has no pinned compaction format yet (Vikunja 466), so a collection
|
||||
// reads nothing and the count is unknown with harness-unsupported, never 0.
|
||||
export const compactions = () => ({ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
|
||||
@@ -0,0 +1,166 @@
|
||||
// Row 307 (#1814, Vikunja 598): pi's half of row 270's compaction count (264c, #1775, T1 item 7
|
||||
// D6), moved here from packages/seat/src/compactions.mjs. Core keeps the ledger and calls
|
||||
// `compactions` through the harness kind (packages/plugins/README.md has the contract). This file
|
||||
// reads only the session's native pi store and the Claude history beside it.
|
||||
//
|
||||
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
|
||||
// written by SessionManager.appendCompaction after the summary exists. A start
|
||||
// or an aborted compaction writes none. A `compactionSummary` message, a
|
||||
// message inside a compaction's retainedTail and a branch_summary are not
|
||||
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
|
||||
// file of the same directory with their id and timestamp unchanged, so a
|
||||
// compaction's key is (entry id, entry timestamp) over the whole store, and the
|
||||
// stream it was first seen in is recorded beside it.
|
||||
//
|
||||
// A session whose first collection finds Claude history in claude-projects/
|
||||
// from before the ledger existed is unknown with harness-unsupported (row 270
|
||||
// r2, B3): those runs' compactions can't be counted (Vikunja 466).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const PI_VERSION = "0.85.1";
|
||||
const TAIL = 4096;
|
||||
const CHUNK = 1 << 20;
|
||||
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
|
||||
// store-incomplete outranks store-unreadable: lost entries don't come back.
|
||||
// not-collected is last: the next collection reads on.
|
||||
const REASONS = ["store-incomplete", "store-unreadable", "not-collected"];
|
||||
|
||||
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
|
||||
|
||||
// Only the short tail reads (at most TAIL bytes) use this.
|
||||
function readRange(fd, from, to) {
|
||||
const out = Buffer.alloc(to - from);
|
||||
let off = 0;
|
||||
while (off < out.length) {
|
||||
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
|
||||
if (n === 0) break;
|
||||
off += n;
|
||||
}
|
||||
return out.subarray(0, off);
|
||||
}
|
||||
|
||||
// One collection over a Pi session directory. `cursors` are the ledger's,
|
||||
// by stream. Opens at most `streams` streams, in name order, and reads at
|
||||
// most `budget` bytes past their cursors, over all of them.
|
||||
// Returns the compactions found past each cursor, the new cursors, and the
|
||||
// problems: store-unreadable (a file that can't be read or isn't a Pi
|
||||
// session), store-incomplete (a stream that shrank, changed or went away, or
|
||||
// a line that isn't JSON) and not-collected (the budget or the stream cap
|
||||
// ran out first). A
|
||||
// missing directory is empty.
|
||||
function scanPi(dir, cursors, budget, streams) {
|
||||
const found = [], next = {}, problems = new Set();
|
||||
let left = budget;
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") problems.add("store-unreadable");
|
||||
names = [];
|
||||
}
|
||||
const present = new Set(names);
|
||||
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
|
||||
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
|
||||
if (visit.length > streams) problems.add("not-collected");
|
||||
for (const name of visit.slice(0, streams)) {
|
||||
const path = join(dir, name);
|
||||
let fd;
|
||||
try {
|
||||
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
|
||||
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const size = fstatSync(fd).size;
|
||||
const c = cursors[name];
|
||||
let from = 0;
|
||||
if (c) {
|
||||
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
|
||||
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
|
||||
else problems.add("store-incomplete");
|
||||
}
|
||||
let header = from > 0;
|
||||
const take = (raw) => {
|
||||
const line = raw.toString("utf8");
|
||||
if (!line.trim()) return;
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
e = null;
|
||||
}
|
||||
if (!e || typeof e !== "object" || Array.isArray(e)) {
|
||||
problems.add("store-incomplete");
|
||||
return;
|
||||
}
|
||||
if (!header) {
|
||||
header = true;
|
||||
if (e.type !== "session") problems.add("store-unreadable");
|
||||
return;
|
||||
}
|
||||
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
|
||||
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
|
||||
}
|
||||
};
|
||||
// A CHUNK at a time, up to the budget left. Complete lines only: a line
|
||||
// split across chunks waits in `carry`, and a partial last line waits
|
||||
// for its newline. `end` is past the last whole line.
|
||||
const stop = Math.min(size, from + left);
|
||||
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
|
||||
let pos = from, end = from, carry = [];
|
||||
while (pos < stop) {
|
||||
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
|
||||
if (n === 0) break;
|
||||
const view = chunk.subarray(0, n);
|
||||
let i = 0;
|
||||
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
|
||||
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
|
||||
carry = [];
|
||||
i = nl + 1;
|
||||
end = pos + i;
|
||||
}
|
||||
if (i < n) carry.push(Buffer.from(view.subarray(i)));
|
||||
pos += n;
|
||||
}
|
||||
left -= pos - from;
|
||||
if (stop < size) problems.add("not-collected");
|
||||
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
return { found, cursors: next, problems: [...problems].sort() };
|
||||
}
|
||||
|
||||
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
|
||||
// directory that can't be read throws, so the collection is not recorded and
|
||||
// the next one checks again.
|
||||
function claudeHistory(store) {
|
||||
if (!store.projects) return false;
|
||||
try {
|
||||
return readdirSync(store.projects).length > 0;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// The harness kind's `compactions` for pi: the compactions past the ledger's
|
||||
// cursors, the moved cursors, and the reason the count isn't whole, if any.
|
||||
export function compactions({ store, cursors, budget, streams, first }) {
|
||||
if (first && claudeHistory(store)) {
|
||||
// Claude runs before this ledger: their compactions can't be counted
|
||||
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
|
||||
return { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: PI_VERSION };
|
||||
}
|
||||
const scan = scanPi(store.session, cursors, budget, streams);
|
||||
const reason = REASONS.find((p) => scan.problems.includes(p)) ?? null;
|
||||
return { found: scan.found, cursors: scan.cursors, known: reason === null, reason, version: PI_VERSION };
|
||||
}
|
||||
@@ -73,3 +73,5 @@ export function env({ policy, tools, session }) {
|
||||
// The extension writes the turn marker at agent_end; an exit without it is a failed turn.
|
||||
export const turnEnd = () => "marker";
|
||||
export const transcript = ({ runDir, store = null }) => (store ? store.session : join(runDir, "session"));
|
||||
// Row 307 (#1814): the session store's compactions, read from its pi streams (compactions.mjs).
|
||||
export { compactions } from "./compactions.mjs";
|
||||
@@ -49,10 +49,12 @@ export const KINDS = Object.freeze({
|
||||
// env({ files, policy, tools, session }) -> its adapter variables, written into session.json
|
||||
// turnEnd() -> "marker" (a turn ends only with the turn marker) | "exit"
|
||||
// transcript({ runDir, store }) -> the directory its transcripts land in, for usage
|
||||
// compactions({ store, cursors, budget, streams, first }) -> { found, cursors, known, reason, version },
|
||||
// the session store's compactions past the ledger's cursors (row 307)
|
||||
harness: Object.freeze({
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: Object.freeze(["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"]),
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"]),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Row 307 (#1814, Vikunja 598): the harness kind's `compactions`. pi's answers what row 270's
|
||||
// scanPi read at 1e629c892, with its reason order and version; claude-code reads nothing and
|
||||
// answers harness-unsupported; a module without the export doesn't load.
|
||||
//
|
||||
// CASES: each case's streams and cursors, scanned at 1e629c892 with its budget and cap, give its
|
||||
// answer (agents/gizmoduck/work/queue-307/capture.mjs checks that).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { KINDS } from "../src/kinds.mjs";
|
||||
import * as pi from "../harness/pi/index.mjs";
|
||||
import * as claude from "../harness/claude-code/index.mjs";
|
||||
import { tree } from "./helpers.mjs";
|
||||
|
||||
// capture:begin
|
||||
const CASES = {
|
||||
"plain": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": true, "reason": null, "version": "0.85.1"}},
|
||||
"budget": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"}, "cursors": {}, "budget": 100, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
|
||||
"cap": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n", "b.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1, "answer": {"found": [{"entry": "c1", "completedAt": "2026-10-22T11:01:00.000Z", "stream": "a.jsonl"}], "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "known": false, "reason": "not-collected", "version": "0.85.1"}},
|
||||
"shrank": {"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n"}, "cursors": {"a.jsonl": {"bytes": 233, "tail": "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a"}}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 90, "tail": "2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5"}}, "known": false, "reason": "store-incomplete", "version": "0.85.1"}},
|
||||
"header": {"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"}, "cursors": {}, "budget": 67108864, "cap": 1024, "answer": {"found": [], "cursors": {"a.jsonl": {"bytes": 29, "tail": "91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df"}}, "known": false, "reason": "store-unreadable", "version": "0.85.1"}}
|
||||
};
|
||||
// capture:end
|
||||
|
||||
function stores(t, streams = {}, projects = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "mosaic-harness-compactions-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = { session: join(root, "session"), projects: join(root, "claude-projects") };
|
||||
for (const [dir, files] of [[store.session, streams], [store.projects, projects]]) {
|
||||
mkdirSync(dir);
|
||||
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
for (const [name, c] of Object.entries(CASES)) {
|
||||
test(`row 307 pi answers what row 270 read: ${name}`, (t) => {
|
||||
const store = stores(t, c.streams);
|
||||
assert.deepEqual(pi.compactions({ store, cursors: c.cursors, budget: c.budget, streams: c.cap, first: false }), c.answer);
|
||||
});
|
||||
}
|
||||
|
||||
test("row 307 pi's first collection over Claude history answers harness-unsupported without a scan, and a later one scans", (t) => {
|
||||
const { streams, budget, cap, answer } = CASES.plain;
|
||||
const store = stores(t, streams, { "p.jsonl": "{}\n" });
|
||||
const args = { store, cursors: {}, budget, streams: cap };
|
||||
assert.deepEqual(pi.compactions({ ...args, first: true }), { found: [], cursors: {}, known: false, reason: "harness-unsupported", version: "0.85.1" });
|
||||
assert.deepEqual(pi.compactions({ ...args, first: false }), answer);
|
||||
// With no Claude history, the first collection scans too.
|
||||
assert.deepEqual(pi.compactions({ ...args, store: stores(t, streams), first: true }), answer);
|
||||
});
|
||||
|
||||
test("row 307 claude-code reads nothing and answers harness-unsupported with no version", () => {
|
||||
const store = { session: "/nonexistent/session", projects: "/nonexistent/claude-projects" };
|
||||
for (const first of [true, false]) {
|
||||
assert.deepEqual(claude.compactions({ store, cursors: {}, budget: 1 << 20, streams: 32, first }),
|
||||
{ found: [], cursors: {}, known: false, reason: "harness-unsupported", version: null });
|
||||
}
|
||||
});
|
||||
|
||||
test("row 307 a harness module without compactions doesn't load: missing-export", async (t) => {
|
||||
const s = tree(t);
|
||||
const url = pathToFileURL(resolve(fileURLToPath(new URL("../harness/pi/index.mjs", import.meta.url)))).href;
|
||||
const names = ["interfaceVersion", ...KINDS.harness.exports.filter((k) => k !== "compactions")];
|
||||
s.write("harness", "no-compactions", `export { ${names.join(", ")} } from ${JSON.stringify(url)};\n`);
|
||||
const l = await s.loader();
|
||||
const result = await l.load({ harness: ["no-compactions"] });
|
||||
assert.deepEqual(result.refused.map((e) => [e.kind, e.name, e.code]), [["harness", "no-compactions", "missing-export"]]);
|
||||
assert.match(result.refused[0].message, /^harness no-compactions doesn't export compactions$/);
|
||||
assert.deepEqual(l.loaded().harness, []);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ test("REQ-PLUG-1 REQ-LIMIT-4 the registry: usage-source at interface version 1,
|
||||
assert.deepEqual({ ...KINDS.harness, exports: [...KINDS.harness.exports] }, {
|
||||
kind: "harness", interfaceVersion: 1, switch: "list",
|
||||
exports: ["adapter", "levels", "preflight", "version", "countsAccount", "mounts", "builtinWeb", "builtins",
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript"],
|
||||
"typedName", "reliesOn", "gateTimeout", "bundle", "env", "turnEnd", "transcript", "compactions"],
|
||||
});
|
||||
assert.ok(Object.isFrozen(KINDS.harness) && Object.isFrozen(KINDS.harness.exports));
|
||||
assert.ok(Object.isFrozen(KINDS) && Object.isFrozen(KINDS["usage-source"]) && Object.isFrozen(KINDS["limit-check"].exports));
|
||||
|
||||
@@ -9,19 +9,14 @@
|
||||
// workspace lock (session.mjs lockSessionWorkspace); each collection is one
|
||||
// O_APPEND write. The count is rebuilt from the ledger, never from a page.
|
||||
//
|
||||
// Pi (pinned 0.85.1): a completed compaction is one `type: "compaction"` entry,
|
||||
// written by SessionManager.appendCompaction after the summary exists. A start
|
||||
// or an aborted compaction writes none. A `compactionSummary` message, a
|
||||
// message inside a compaction's retainedTail and a branch_summary are not
|
||||
// compactions. Pi's createBranchedSession and forkFrom copy entries into a new
|
||||
// file of the same directory with their id and timestamp unchanged, so a
|
||||
// compaction's key is (entry id, entry timestamp) over the whole store, and the
|
||||
// stream it was first seen in is recorded beside it.
|
||||
//
|
||||
// Claude Code has no pinned compaction format yet (Vikunja 466): a session
|
||||
// any Claude run collected for is unknown with harness-unsupported, never 0.
|
||||
// So is one whose first collection finds Claude history in claude-projects/
|
||||
// from before this ledger existed (row 270 r2, B3).
|
||||
// What a compaction is depends on the harness, so core never reads a native
|
||||
// stream itself (row 307, #1814, Vikunja 598). Each collection calls the route's
|
||||
// harness module's `compactions` (packages/plugins/README.md has the contract),
|
||||
// checks its answer, and writes the ledger lines from it. A compaction's key is
|
||||
// (entry, completedAt) over the whole store, and the stream it was first seen in
|
||||
// is recorded beside it. A harness with no pinned compaction format answers
|
||||
// harness-unsupported, and so does one that can't count the store's history: a
|
||||
// session any such collection ran for is unknown for good, never 0.
|
||||
//
|
||||
// One collection reads at most a budget of stream bytes (row 270 r2, B1).
|
||||
// The budget is the system config's compactionReadBytes, which only a human
|
||||
@@ -46,14 +41,11 @@
|
||||
// that shrank, changed under its cursor or went away is store-incomplete from
|
||||
// then on: entries may be lost, so the count stays a lower bound for good.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync, writeSync } from "node:fs";
|
||||
import { closeSync, constants as fsc, fstatSync, lstatSync, openSync, readFileSync, writeSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
export const PI_VERSION = "0.85.1";
|
||||
export const LEDGER = "compactions.jsonl";
|
||||
const TAIL = 4096;
|
||||
const CHUNK = 1 << 20;
|
||||
// BUILD.md (queue-270, round 2) has the measured stream sizes behind the default.
|
||||
export const READ_BYTES = 64 * CHUNK;
|
||||
@@ -61,9 +53,7 @@ const READ_MIN = CHUNK, READ_MAX = 1024 * CHUNK;
|
||||
// BUILD.md (queue-270, round 3) has the measured stream counts behind the default.
|
||||
export const STREAMS = 1024;
|
||||
const STREAMS_MIN = 32, STREAMS_MAX = 65536;
|
||||
const STREAM = /^[A-Za-z0-9._-]{1,200}\.jsonl$/;
|
||||
|
||||
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
|
||||
const keyOf = (entry, completedAt) => JSON.stringify([entry, completedAt]);
|
||||
const segment = (x) => typeof x === "string" && ID_PATTERN.test(x) && !x.includes("..");
|
||||
|
||||
@@ -87,115 +77,6 @@ export function readStreams(value) {
|
||||
return Number.isSafeInteger(value) && value >= STREAMS_MIN && value <= STREAMS_MAX ? value : STREAMS;
|
||||
}
|
||||
|
||||
// Only the short tail reads (at most TAIL bytes) use this.
|
||||
function readRange(fd, from, to) {
|
||||
const out = Buffer.alloc(to - from);
|
||||
let off = 0;
|
||||
while (off < out.length) {
|
||||
const n = readSync(fd, out, off, Math.min(CHUNK, out.length - off), from + off);
|
||||
if (n === 0) break;
|
||||
off += n;
|
||||
}
|
||||
return out.subarray(0, off);
|
||||
}
|
||||
|
||||
// One collection over a Pi session directory. `cursors` are the ledger's,
|
||||
// by stream. Opens at most `streams` streams, in name order, and reads at
|
||||
// most `budget` bytes past their cursors, over all of them.
|
||||
// Returns the compactions found past each cursor, the new cursors, and the
|
||||
// problems: store-unreadable (a file that can't be read or isn't a Pi
|
||||
// session), store-incomplete (a stream that shrank, changed or went away, or
|
||||
// a line that isn't JSON) and not-collected (the budget or the stream cap
|
||||
// ran out first). A
|
||||
// missing directory is empty.
|
||||
export function scanPi(dir, cursors = {}, budget = READ_BYTES, streams = STREAMS) {
|
||||
const found = [], next = {}, problems = new Set();
|
||||
let left = budget;
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(dir);
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") problems.add("store-unreadable");
|
||||
names = [];
|
||||
}
|
||||
const present = new Set(names);
|
||||
for (const stream of Object.keys(cursors)) if (!present.has(stream)) problems.add("store-incomplete");
|
||||
const visit = names.filter((n) => n.endsWith(".jsonl")).sort();
|
||||
if (visit.length > streams) problems.add("not-collected");
|
||||
for (const name of visit.slice(0, streams)) {
|
||||
const path = join(dir, name);
|
||||
let fd;
|
||||
try {
|
||||
if (!STREAM.test(name) || !lstatSync(path).isFile()) throw new Error("not a regular stream file");
|
||||
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const size = fstatSync(fd).size;
|
||||
const c = cursors[name];
|
||||
let from = 0;
|
||||
if (c) {
|
||||
const tail = c.bytes > size ? null : readRange(fd, Math.max(0, c.bytes - TAIL), c.bytes);
|
||||
if (tail && tail.length === Math.min(TAIL, c.bytes) && digest(tail) === c.tail) from = c.bytes;
|
||||
else problems.add("store-incomplete");
|
||||
}
|
||||
let header = from > 0;
|
||||
const take = (raw) => {
|
||||
const line = raw.toString("utf8");
|
||||
if (!line.trim()) return;
|
||||
let e;
|
||||
try {
|
||||
e = JSON.parse(line);
|
||||
} catch {
|
||||
e = null;
|
||||
}
|
||||
if (!e || typeof e !== "object" || Array.isArray(e)) {
|
||||
problems.add("store-incomplete");
|
||||
return;
|
||||
}
|
||||
if (!header) {
|
||||
header = true;
|
||||
if (e.type !== "session") problems.add("store-unreadable");
|
||||
return;
|
||||
}
|
||||
if (e.type === "compaction" && typeof e.id === "string" && e.id) {
|
||||
found.push({ entry: e.id, completedAt: typeof e.timestamp === "string" ? e.timestamp : null, stream: name });
|
||||
}
|
||||
};
|
||||
// A CHUNK at a time, up to the budget left. Complete lines only: a line
|
||||
// split across chunks waits in `carry`, and a partial last line waits
|
||||
// for its newline. `end` is past the last whole line.
|
||||
const stop = Math.min(size, from + left);
|
||||
const chunk = Buffer.alloc(Math.min(CHUNK, stop - from));
|
||||
let pos = from, end = from, carry = [];
|
||||
while (pos < stop) {
|
||||
const n = readSync(fd, chunk, 0, Math.min(chunk.length, stop - pos), pos);
|
||||
if (n === 0) break;
|
||||
const view = chunk.subarray(0, n);
|
||||
let i = 0;
|
||||
for (let nl = view.indexOf(0x0a); nl !== -1; nl = view.indexOf(0x0a, i)) {
|
||||
take(carry.length ? Buffer.concat([...carry, view.subarray(i, nl)]) : view.subarray(i, nl));
|
||||
carry = [];
|
||||
i = nl + 1;
|
||||
end = pos + i;
|
||||
}
|
||||
if (i < n) carry.push(Buffer.from(view.subarray(i)));
|
||||
pos += n;
|
||||
}
|
||||
left -= pos - from;
|
||||
if (stop < size) problems.add("not-collected");
|
||||
next[name] = { bytes: end, tail: digest(readRange(fd, Math.max(0, end - TAIL), end)) };
|
||||
} catch {
|
||||
problems.add("store-unreadable");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
return { found, cursors: next, problems: [...problems].sort() };
|
||||
}
|
||||
|
||||
// The ledger as recorded. Throws on a ledger that can't be read. `bad` is set
|
||||
// by any line that isn't a v 1 record, a torn last line included.
|
||||
export function readLedger(dir) {
|
||||
@@ -229,28 +110,16 @@ export function readLedger(dir) {
|
||||
else if (r.kind === "collected") {
|
||||
last = r;
|
||||
if (r.reason === "store-incomplete") incomplete = true;
|
||||
if (r.harness !== "pi" || r.reason === "harness-unsupported") unsupported = true;
|
||||
if (r.reason === "harness-unsupported") unsupported = true;
|
||||
} else bad = true;
|
||||
}
|
||||
return { events, cursors, last, incomplete, unsupported, bad, torn };
|
||||
}
|
||||
|
||||
// Row 270 r2 (B3): whether the store's claude-projects/ holds anything. A
|
||||
// directory that can't be read throws, so the collection is not recorded and
|
||||
// the next one checks again.
|
||||
function claudeHistory(store) {
|
||||
if (!store.projects) return false;
|
||||
try {
|
||||
return readdirSync(store.projects).length > 0;
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return false;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// What session.list shows: { count, known, reason, at }. Known only when the
|
||||
// ledger is whole, every collection ran on Pi and none lost entries. Any other
|
||||
// answer is unknown with a reason and a lower bound, or null for none: never 0.
|
||||
// ledger is whole, no collection was harness-unsupported and none lost
|
||||
// entries. Any other answer is unknown with a reason and a lower bound, or null
|
||||
// for none: never 0.
|
||||
export function compactionCount(dir) {
|
||||
const unknown = (reason, count = null, at = null) => ({ count: count || null, known: false, reason, at });
|
||||
let ledger;
|
||||
@@ -270,39 +139,57 @@ export function compactionCount(dir) {
|
||||
return { count, known: true, reason: null, at };
|
||||
}
|
||||
|
||||
// One collection for a session's store ({ dir, session } from sessionStore),
|
||||
// at run start (resume) and run end, under the session's workspace lock.
|
||||
// Appends the new compactions, the moved cursors and one collected line in a
|
||||
// single O_APPEND write, then answers the count. A key already in the ledger
|
||||
// is never written again, and nothing rewrites the file.
|
||||
const REASONS = ["store-incomplete", "store-unreadable", "not-collected", "harness-unsupported"];
|
||||
const HEX = /^[0-9a-f]{64}$/;
|
||||
|
||||
// The harness module's answer, checked before anything is written: what the
|
||||
// export's contract allows and nothing else. Returns what's wrong, or null.
|
||||
function checkAnswer(a) {
|
||||
const plain = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
||||
if (!plain(a)) return "not an object";
|
||||
if (!Array.isArray(a.found)) return "found isn't a list";
|
||||
for (const f of a.found) {
|
||||
if (!plain(f) || typeof f.entry !== "string" || !f.entry) return "a found entry has no entry id";
|
||||
if (f.completedAt !== null && typeof f.completedAt !== "string") return `found ${f.entry} has a completedAt that isn't a string or null`;
|
||||
if (typeof f.stream !== "string" || !f.stream) return `found ${f.entry} has no stream`;
|
||||
}
|
||||
if (!plain(a.cursors)) return "cursors isn't an object";
|
||||
for (const [stream, c] of Object.entries(a.cursors)) {
|
||||
if (!plain(c) || !Number.isSafeInteger(c.bytes) || c.bytes < 0 || typeof c.tail !== "string" || !HEX.test(c.tail)) return `cursor ${JSON.stringify(stream)} isn't { bytes, tail }`;
|
||||
}
|
||||
if (a.reason !== null && !REASONS.includes(a.reason)) return `reason ${JSON.stringify(a.reason)} isn't one of ${REASONS.join(", ")}`;
|
||||
if (a.known !== (a.reason === null)) return "known must be true exactly when reason is null";
|
||||
if (a.version !== null && typeof a.version !== "string") return "version isn't a string or null";
|
||||
return null;
|
||||
}
|
||||
|
||||
// One collection for a session's store ({ dir, session, projects } from
|
||||
// sessionStore), at run start (resume) and run end, under the session's
|
||||
// workspace lock. `harness` is { name, compactions }: the route's harness name
|
||||
// and its loaded module's export. Appends the new compactions, the moved
|
||||
// cursors and one collected line in a single O_APPEND write, then answers the
|
||||
// count. A key already in the ledger is never written again, and nothing
|
||||
// rewrites the file. A module that throws or answers out of contract throws
|
||||
// here, before anything is written.
|
||||
export function collectCompactions(store, harness, { now = () => new Date().toISOString(), budget = READ_BYTES, streams = STREAMS } = {}) {
|
||||
const prior = readLedger(store.dir);
|
||||
const ledger = prior ?? { events: new Map(), cursors: {}, torn: false };
|
||||
const at = now();
|
||||
const answer = harness.compactions({ store: { session: store.session, projects: store.projects }, cursors: { ...ledger.cursors }, budget, streams, first: prior === null });
|
||||
const wrong = checkAnswer(answer);
|
||||
if (wrong) throw new Error(`harness ${harness.name} compactions: bad answer: ${wrong}`);
|
||||
const out = [];
|
||||
if (harness === "pi" && !prior && claudeHistory(store)) {
|
||||
// Claude runs before this ledger: their compactions can't be counted
|
||||
// (Vikunja 466), so the count is unknown for good, as after a Claude run.
|
||||
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: PI_VERSION });
|
||||
} else if (harness === "pi") {
|
||||
const scan = scanPi(store.session, ledger.cursors, budget, streams);
|
||||
for (const f of scan.found) {
|
||||
const key = keyOf(f.entry, f.completedAt);
|
||||
if (ledger.events.has(key)) continue;
|
||||
ledger.events.set(key, f);
|
||||
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
|
||||
}
|
||||
for (const [stream, c] of Object.entries(scan.cursors)) {
|
||||
const old = ledger.cursors[stream];
|
||||
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
|
||||
}
|
||||
// store-incomplete outranks store-unreadable: lost entries don't come back.
|
||||
// not-collected is last: the next collection reads on.
|
||||
const reason = ["store-incomplete", "store-unreadable", "not-collected"].find((p) => scan.problems.includes(p)) ?? null;
|
||||
out.push({ v: 1, kind: "collected", at, known: reason === null, reason, harness, version: PI_VERSION });
|
||||
} else {
|
||||
out.push({ v: 1, kind: "collected", at, known: false, reason: "harness-unsupported", harness, version: null });
|
||||
for (const f of answer.found) {
|
||||
const key = keyOf(f.entry, f.completedAt);
|
||||
if (ledger.events.has(key)) continue;
|
||||
ledger.events.set(key, f);
|
||||
out.push({ v: 1, kind: "compaction", entry: f.entry, completedAt: f.completedAt, stream: f.stream, at });
|
||||
}
|
||||
for (const [stream, c] of Object.entries(answer.cursors)) {
|
||||
const old = ledger.cursors[stream];
|
||||
if (!old || old.bytes !== c.bytes || old.tail !== c.tail) out.push({ v: 1, kind: "cursor", stream, bytes: c.bytes, tail: c.tail, at });
|
||||
}
|
||||
out.push({ v: 1, kind: "collected", at, known: answer.known, reason: answer.reason, harness: harness.name, version: answer.version });
|
||||
const fd = openSync(join(store.dir, LEDGER), fsc.O_WRONLY | fsc.O_APPEND | fsc.O_CREAT | fsc.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
if (!fstatSync(fd).isFile()) throw Object.assign(new Error(`${join(store.dir, LEDGER)} isn't a regular file`), { code: "ledger-unsafe" });
|
||||
|
||||
@@ -12,6 +12,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { collectCompactions, LEDGER, READ_BYTES, readBudget, readStreams, STREAMS } from "../src/compactions.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
||||
|
||||
const MiB = 1 << 20;
|
||||
|
||||
@@ -22,7 +23,7 @@ function setup(t) {
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
const collect = (harness = "pi", budget = undefined, streams = undefined) =>
|
||||
collectCompactions(store, harness, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
|
||||
collectCompactions(store, { name: harness, compactions: { pi }[harness] }, { now, ...(budget === undefined ? {} : { budget }), ...(streams === undefined ? {} : { streams }) });
|
||||
const records = () => readFileSync(join(store.dir, LEDGER), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
return { root, store, collect, records };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Row 307 (#1814, Vikunja 598): core keeps the ledger and asks the harness module for the
|
||||
// compactions. A ledger row 270 wrote reads the same count after the move, the next collection
|
||||
// goes through the module's `compactions`, and core refuses an answer outside the contract before
|
||||
// it writes anything.
|
||||
//
|
||||
// FIXTURES are what row 270's collectCompactions wrote at 1e629c892, one ledger per reason it
|
||||
// writes plus a torn one: each fixture's streams, Claude projects and steps, replayed there with
|
||||
// the clock at 12:00:00 and one second a step, give its ledger byte for byte
|
||||
// (agents/gizmoduck/work/queue-307/capture.mjs checks that). `count` is compactionCount over it,
|
||||
// and `next` is the count after one more pi collection at the next second.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { collectCompactions, compactionCount, LEDGER } from "../src/compactions.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
import * as pi from "../../plugins/harness/pi/index.mjs";
|
||||
|
||||
// capture:begin
|
||||
const FIXTURES = {
|
||||
"known": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"store-incomplete": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\nnot json\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":99,\"tail\":\"14607eb2c2f70f460e1a420da1b4ead7f00f2a81cc4105e9e55e6c5c828d6626\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-incomplete\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": null, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"store-unreadable": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"message\",\"id\":\"m1\"}\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":29,\"tail\":\"91f8cd26da181eddfdbefaef87896493bf488791937a099f5899467b04fc68df\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"store-unreadable\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "store-unreadable", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 0, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"not-collected": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"steps": [{"collect": "pi", "budget": 100}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":90,\"tail\":\"2dff60ffd3bc96efa6dd8d740044255d75a1f71d82e7e99a39158d18cf211fc5\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"not-collected\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "not-collected", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 1, "known": true, "reason": null, "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"claude-code": {
|
||||
"streams": {},
|
||||
"steps": [{"collect": "claude-code"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"claude-code\",\"version\":null}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"claude-history": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"projects": {"p.jsonl": "{}\n"},
|
||||
"steps": [{"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":false,\"reason\":\"harness-unsupported\",\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": null, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:00.000Z"},
|
||||
"next": {"count": 1, "known": false, "reason": "harness-unsupported", "at": "2026-10-22T12:00:01.000Z"}
|
||||
},
|
||||
"torn": {
|
||||
"streams": {"a.jsonl": "{\"type\":\"session\",\"version\":3,\"id\":\"a\",\"timestamp\":\"2026-10-22T11:00:00.000Z\",\"cwd\":\"/w\"}\n{\"type\":\"compaction\",\"id\":\"c1\",\"parentId\":null,\"timestamp\":\"2026-10-22T11:01:00.000Z\",\"summary\":\"s\",\"firstKeptEntryId\":\"k\",\"tokensBefore\":100}\n"},
|
||||
"steps": [{"collect": "pi"}, {"tear": "{\"v\":1,\"kind\":\"coll"}, {"collect": "pi"}],
|
||||
"ledger": [
|
||||
"{\"v\":1,\"kind\":\"compaction\",\"entry\":\"c1\",\"completedAt\":\"2026-10-22T11:01:00.000Z\",\"stream\":\"a.jsonl\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"cursor\",\"stream\":\"a.jsonl\",\"bytes\":233,\"tail\":\"39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a\",\"at\":\"2026-10-22T12:00:00.000Z\"}",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:00.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}",
|
||||
"{\"v\":1,\"kind\":\"coll",
|
||||
"{\"v\":1,\"kind\":\"collected\",\"at\":\"2026-10-22T12:00:01.000Z\",\"known\":true,\"reason\":null,\"harness\":\"pi\",\"version\":\"0.85.1\"}"
|
||||
],
|
||||
"count": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:01.000Z"},
|
||||
"next": {"count": 1, "known": false, "reason": "store-incomplete", "at": "2026-10-22T12:00:02.000Z"}
|
||||
}
|
||||
};
|
||||
// capture:end
|
||||
|
||||
function setup(t, fx = { streams: {}, ledger: null }) {
|
||||
const root = mkdtempSync(join(tmpdir(), "seat-compactions-ledger-"));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const store = sessionStore(root, "acme", "th1", "s1");
|
||||
for (const [name, text] of Object.entries(fx.streams)) writeFileSync(join(store.session, name), text);
|
||||
for (const [name, text] of Object.entries(fx.projects ?? {})) writeFileSync(join(store.projects, name), text);
|
||||
if (fx.ledger) writeFileSync(join(store.dir, LEDGER), fx.ledger.join("\n") + "\n", { mode: 0o600 });
|
||||
const ledger = () => {
|
||||
try {
|
||||
return readFileSync(join(store.dir, LEDGER), "utf8");
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return { root, store, ledger };
|
||||
}
|
||||
|
||||
for (const [name, fx] of Object.entries(FIXTURES)) {
|
||||
test(`row 307 the ${name} ledger row 270 wrote reads the same count`, (t) => {
|
||||
const f = setup(t, fx);
|
||||
assert.deepEqual(compactionCount(f.store.dir), fx.count);
|
||||
});
|
||||
|
||||
test(`row 307 after the ${name} ledger, one collection through pi's module answers the next count`, (t) => {
|
||||
const f = setup(t, fx);
|
||||
assert.deepEqual(collectCompactions(f.store, { name: "pi", compactions: pi.compactions }, { now: () => fx.next.at }), fx.next);
|
||||
});
|
||||
}
|
||||
|
||||
const ok = { found: [], cursors: {}, known: true, reason: null, version: null };
|
||||
const HEX = "0".repeat(64);
|
||||
const BAD = [
|
||||
[null, "not an object"],
|
||||
[[], "not an object"],
|
||||
[{ ...ok, found: {} }, "found isn't a list"],
|
||||
[{ ...ok, found: [{ entry: "", completedAt: null, stream: "a.jsonl" }] }, "a found entry has no entry id"],
|
||||
[{ ...ok, found: ["c1"] }, "a found entry has no entry id"],
|
||||
[{ ...ok, found: [{ entry: "c1", completedAt: 5, stream: "a.jsonl" }] }, "found c1 has a completedAt that isn't a string or null"],
|
||||
[{ ...ok, found: [{ entry: "c1", completedAt: null, stream: "" }] }, "found c1 has no stream"],
|
||||
[{ ...ok, cursors: [] }, "cursors isn't an object"],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: -1, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: 1.5, tail: HEX } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: HEX.toUpperCase().replace(/0/g, "A") } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, cursors: { "a.jsonl": { bytes: 1, tail: "0".repeat(63) } } }, 'cursor "a.jsonl" isn\'t { bytes, tail }'],
|
||||
[{ ...ok, reason: "lost", known: false }, 'reason "lost" isn\'t one of store-incomplete, store-unreadable, not-collected, harness-unsupported'],
|
||||
[{ ...ok, reason: "not-collected" }, "known must be true exactly when reason is null"],
|
||||
[{ ...ok, known: false }, "known must be true exactly when reason is null"],
|
||||
[{ ...ok, known: 1 }, "known must be true exactly when reason is null"],
|
||||
[{ ...ok, version: 1 }, "version isn't a string or null"],
|
||||
];
|
||||
|
||||
test("row 307 core refuses an answer outside the contract and appends nothing", (t) => {
|
||||
for (const fx of [undefined, FIXTURES.known]) {
|
||||
const f = setup(t, fx);
|
||||
const before = f.ledger();
|
||||
for (const [answer, what] of BAD) {
|
||||
const message = `harness odd compactions: bad answer: ${what}`;
|
||||
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => answer }), { message }, JSON.stringify(answer));
|
||||
assert.equal(f.ledger(), before, JSON.stringify(answer));
|
||||
}
|
||||
// The module's own throw reaches the caller as it is, and nothing is written either.
|
||||
const boom = new Error("boom");
|
||||
assert.throws(() => collectCompactions(f.store, { name: "odd", compactions: () => { throw boom; } }), (e) => e === boom);
|
||||
assert.equal(f.ledger(), before);
|
||||
// A good answer is written: the check refuses only what's out of contract.
|
||||
assert.equal(collectCompactions(f.store, { name: "odd", compactions: () => ok }, { now: () => "2026-10-22T12:00:09.000Z" }).at, "2026-10-22T12:00:09.000Z");
|
||||
}
|
||||
});
|
||||
|
||||
test("row 307 the module gets the store's two native directories, a copy of the cursors, the budget, the cap and first", (t) => {
|
||||
const seen = [];
|
||||
// The module writes into the cursors it got and answers the same cursor: core's ledger copy
|
||||
// doesn't have it, so core writes it.
|
||||
const harness = { name: "odd", compactions: (args) => (seen.push(structuredClone(args)), args.cursors.x = { bytes: 0, tail: HEX }, { ...ok, cursors: { x: { bytes: 0, tail: HEX } } }) };
|
||||
const fresh = setup(t);
|
||||
collectCompactions(fresh.store, harness, { budget: 123, streams: 45 });
|
||||
const known = setup(t, FIXTURES.known);
|
||||
collectCompactions(known.store, harness);
|
||||
const store = (f) => ({ session: f.store.session, projects: f.store.projects });
|
||||
assert.deepEqual(seen, [
|
||||
{ store: store(fresh), cursors: {}, budget: 123, streams: 45, first: true },
|
||||
{ store: store(known), cursors: { "a.jsonl": { bytes: 233, tail: "39650b0c01a938998403d9dec3f8c224a01509ccde607711e962bf68bcbff54a" } }, budget: 64 << 20, streams: 1024, first: false },
|
||||
]);
|
||||
assert.equal(readFileSync(join(known.store.dir, LEDGER), "utf8").split("\n").filter((l) => l.includes('"stream":"x"')).length, 1);
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { join, relative, isAbsolute } from "node:path";
|
||||
import { collectCompactions, compactionCount, LEDGER, readLedger, storeDir } from "../src/compactions.mjs";
|
||||
import { binds } from "../src/sandbox.mjs";
|
||||
import { sessionStore } from "../src/session.mjs";
|
||||
import { compactions as pi } from "../../plugins/harness/pi/index.mjs";
|
||||
import { compactions as claude } from "../../plugins/harness/claude-code/index.mjs";
|
||||
|
||||
function setup(t, { legacy = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "seat-compactions-"));
|
||||
@@ -18,7 +20,7 @@ function setup(t, { legacy = false } = {}) {
|
||||
const store = sessionStore(root, "acme", "th1", legacy ? "th1" : "s1", { legacy });
|
||||
let tick = 0;
|
||||
const now = () => new Date(Date.UTC(2026, 9, 22, 12, 0, tick++)).toISOString();
|
||||
const collect = (harness = "pi") => collectCompactions(store, harness, { now });
|
||||
const collect = (harness = "pi") => collectCompactions(store, { name: harness, compactions: { pi, claude }[harness] }, { now });
|
||||
const ledger = () => readFileSync(join(store.dir, LEDGER), "utf8");
|
||||
const records = () => ledger().trim().split("\n").map((l) => JSON.parse(l));
|
||||
return { root, store, collect, ledger, records };
|
||||
|
||||
Reference in new issue
Block a user