From d45100b746427f0e79e9c82051eec782f1027c79 Mon Sep 17 00:00:00 2001 From: Daniel Strader Date: Tue, 25 Aug 2026 16:20:29 -0700 Subject: [PATCH] Add framed checkpoint v2 codec and bounded readback --- CHANGELOG.md | 1 + README.md | 4 +- benchmarks/agent-readiness-v1/expected.json | 8 +- docs/agent-discovery.md | 2 +- docs/local-checkpoints.md | 32 +- docs/shared-checkpoint-artifact-v2.md | 135 ++ docs/shared-checkpoint-schema-v1.md | 10 +- package.json | 1 + src/checkpoint-artifact-v2.ts | 1343 +++++++++++++++ src/checkpoints.ts | 1722 +++++++++++++++++-- src/cli.ts | 2 + src/discovery.ts | 24 +- src/search-sessions.ts | 1 + test/agent-skill.test.js | 12 + test/checkpoint-artifact-v2.test.js | 607 +++++++ test/checkpoint-v2.test.js | 1359 +++++++++++++++ test/inkcheck.test.js | 34 +- tool.json | 2 +- 18 files changed, 5150 insertions(+), 149 deletions(-) create mode 100644 docs/shared-checkpoint-artifact-v2.md create mode 100644 src/checkpoint-artifact-v2.ts create mode 100644 test/checkpoint-artifact-v2.test.js create mode 100644 test/checkpoint-v2.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 099f940..e3572e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add an opt-in framed checkpoint artifact-v2 codec around the unchanged logical shared-checkpoint schema v1. Ordered configuration, scheduler, frontier, witness-ancestry, dedupe, semantic-index, finding, and metadata records are independently bounded and compressed; exact frame/component checksums and a terminal index support incremental readback without a whole stored artifact, decompressed artifact, or raw artifact string. Cross-format writers share one neutral no-clobber commit point, so a stable ID reuses exactly one verified `.json.gz` or `.inkcp` layout under the existing crash-recovery and retention protocol. Observed-before-work, preflight-without-output, and successfully encoded no-clobber losers reuse the winner; a private candidate that emitted bytes and then failed preserves its error for a later retry. Stable checkpoint IDs, exact resume state, default gzip writes, default CLI/MCP search behavior, search order, and resource policy are unchanged; storage listing and search-session disk accounting now recognize, reuse, and attribute either verified layout. This is a codec foundation only: framed v2 remains an explicit library opt-in pending Intercept plus second-family promotion evidence, and it does not complete #156, activate checkpoint/epoch/pressure policy, restart InkBench, or publish a release. - Extend the partial owner ledger across checkpoint reopen and report enrichment/finalization without adding another payload read, decode, parse, or stable-ID traversal. Successful checkpoint opens and resumes expose bounded stage-by-stage receipts, while typed corrupt/unsupported/resource-limit failures retain the work reached before failure. The opt-in accounted report builder measures the source exploration graph, finding-identity strings already materialized by enrichment, and the returned report; report saves separately distinguish created envelope graphs from reuse-parsed artifact graphs. These compact-JSON serialized-view proxies and their conservative potential sums are not exclusive retained-owner bytes or observed heap peaks. Runtime receipts remain outside saved report/checkpoint payloads and IDs, and bounded JSON streaming still avoids the monolithic report graph. This adds no checkpoint v2/framed readback, allocation, eviction, compaction, stopping policy, benchmark restart, or release; #156 and #216 remain open. - Add versioned retained-owner and finalization receipts without changing search policy or the checksum-bound observability-v1/v2 shapes. Shared pass telemetry now accounts for the bounded observability ledger through a non-recursive canonical UTF-8 projection and preserves its known high-water state across resume; older checkpoints remain readable and explicitly report incomplete owner history. Checkpoint saves report logical graph bytes, streamed artifact/chunk bytes, compressed output, configured compression capacity, durable payload/manifest bytes, and whether encode/compress work was applied or an existing stable ID was reused. Report saves similarly expose canonical identity-string, artifact-serialization, conservative potential string, and durable bytes outside the content-derived report. Configured finalization headroom is labeled unallocated capacity and remains excluded from retained/process totals. Stable-ID algorithms are unchanged: deterministic owner state participates in newly emitted checkpoint content, while post-write receipts never feed back into the IDs or payloads they measure. These fields are observational/accounting inputs only: they do not emit the reserved checkpoint/epoch/pressure reasons, change frontier order, activate a budget or stopping policy, add checkpoint v2, or publish a release; #156 and #216 remain open. - Extend shared-search observability to nested schema v2 with monotonic sample sequences, canonical coalesced reason vectors, an exact 15-bit trigger mask, a fixed eight-count boundary-local trigger-yield tuple, category/frontier/cadence/termination triggers, and category-specific first/last discovery, current/longest dry-state, and identities-per-million-transition facts. The redundant vector/mask/tuple preserves exact retained-boundary triggers across compaction while aggregate interval deltas are rebuilt: adjacent complete-history samples require the tuple to equal the semantic interval delta, while genuine compaction gaps or incomplete v1 history allow only a componentwise-bounded tuple. Observability-v1 migration synthesizes masks only from explicit historical boundaries and uses a zero tuple to mean no v1 trigger was recorded, not that an interval was event-free. Sequence validation applies monotonic and feasible record/state/cadence bounds without claiming exact replay of discarded compaction history. Emitted v2 checkpoints add a domain-separated, key-order-independent canonical SHA-256 self-check binding every fixed known ledger field; it detects stale or accidental mutation but is not authentication because a writer can recompute it, and persisted payload/manifest digests remain the external artifact-corruption boundary. V1 and pre-ledger inputs gain the nested checksum on their first emitted v2 checkpoint. Live process sampling and progress output remain bounded to cadence and final termination while the compacted deterministic ledger retains event boundaries. Checkpoint construction remains side-effect-free and deterministic: the persisted ledger participates in stable checkpoint identity, while observability-v1 checkpoints migrate explicitly with incomplete event history and nullable facts rather than invented history. This remains an independent partial #216 slice: `checkpoint`, `epoch`, and generic `pressure` reasons are reserved but not emitted; retained-owner accounting remains incomplete; no resource, allocation, or stopping policy is activated; #216 remains open; and the earlier V1 overhead study is not an exact-head overhead claim for this V2 implementation. diff --git a/README.md b/README.md index cd36277..6022e05 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ For a new project containing one `.ink` file, `inkcheck init` creates this confi `--save-report` atomically stores a versioned report under `.inkcheck/reports/` and returns its stable content-and-entrypoint-derived ID. A later session can use `inkcheck artifacts list --json` and `inkcheck artifacts show --json`; reopening reports whether the saved evidence is `current`, `stale`, or `path_changed` against the present entrypoint. Reports can contain story text, variables, and exact witnesses, so the agent kit ignores them by default. See [local report artifacts](docs/local-artifacts.md) for the trust, privacy, and compatibility contract. -Long base-shared runs can also persist their exact live frontier locally. Start with `--search=shared --no-min-repro --save-checkpoint`, then continue later with `inkcheck resume --max-states N`; `N` is the larger total grant, not extra hidden work. `inkcheck checkpoints list/show` reports bounded metadata, durable compressed size, storage encoding, and source freshness. New checkpoint files are streamed gzip artifacts; older plain schema-v1 JSON remains readable. Checkpoints are private, atomic, source/config-bound, ignored by default, and retention-capped; they may contain authored text and runtime state. See [local resumable checkpoints](docs/local-checkpoints.md). Shared passes also expose a bounded [resource/yield observability ledger](docs/shared-search-observability.md) that keeps deterministic logical accounting separate from live heap/RSS observations. MCP agents can use the same exact foundation through durable [`start_search` / `inspect_search` / `continue_search` / `cancel_search` result windows](docs/mcp-search-sessions.md). They can also use `add_goal` for an explicit additive directed probe that starts from the story root and leaves that exact base frontier untouched. Portfolio, shared-variable, assertions, directed-frontier resume, and hosted jobs do not use this checkpoint contract yet. +Long base-shared runs can also persist their exact live frontier locally. Start with `--search=shared --no-min-repro --save-checkpoint`, then continue later with `inkcheck resume --max-states N`; `N` is the larger total grant, not extra hidden work. `inkcheck checkpoints list/show` reports bounded metadata, durable compressed size, storage encoding, and source freshness. CLI/default checkpoint files are streamed gzip artifacts; older plain schema-v1 JSON remains readable, and library callers can explicitly evaluate the opt-in [framed artifact-v2 codec](docs/shared-checkpoint-artifact-v2.md) without changing the logical checkpoint ID. Checkpoints are private, atomic, source/config-bound, ignored by default, and retention-capped; they may contain authored text and runtime state. See [local resumable checkpoints](docs/local-checkpoints.md). Shared passes also expose a bounded [resource/yield observability ledger](docs/shared-search-observability.md) that keeps deterministic logical accounting separate from live heap/RSS observations. MCP agents can use the same exact foundation through durable [`start_search` / `inspect_search` / `continue_search` / `cancel_search` result windows](docs/mcp-search-sessions.md). They can also use `add_goal` for an explicit additive directed probe that starts from the story root and leaves that exact base frontier untouched. Portfolio, shared-variable, assertions, directed-frontier resume, and hosted jobs do not use this checkpoint contract yet. ## Hosted checker @@ -186,7 +186,7 @@ Within a single run, inkcheck spends its state budget across complementary searc `--search=shared` opts into an experimental alternative: deep, novelty-first, and seeded views draw from one deduplicated frontier and each reachable state is expanded at most once. This can spend a tight budget more efficiently when several strategies would otherwise rediscover the same state. JSON telemetry separates pending and active checkpoint JSON/variables, retained witness ancestry, dedupe and semantic indexes, frontier references, and findings; it also reports released ancestry and stale-view compactions. These are deterministic accounted payload/structural estimates, not exact V8 heap usage. It is not the default yet: some early-choice structures may still favor the portfolio's independent random and beam passes. -Library consumers evaluating long-running shared jobs can use `exploreSharedResumable(...)`. When budget remains, it returns a source-bound, versioned JSON checkpoint containing the exact live base-shared frontier; a later call raises the total grant (for example 100k to 1m) and continues without replaying the first 100k. Split-run equivalence is regression-tested against an uninterrupted run. This is an engine foundation, not yet a CLI persistence feature: checkpoints may contain authored text, variables, runtime state, and witness paths, and schema v1 deliberately excludes assertions, goals, and variable/goal-aware shared modes. See [shared checkpoint schema v1](docs/shared-checkpoint-schema-v1.md). +Library consumers evaluating long-running shared jobs can use `exploreSharedResumable(...)`. When budget remains, it returns a source-bound, versioned JSON checkpoint containing the exact live base-shared frontier; a later call raises the total grant (for example 100k to 1m) and continues without replaying the first 100k. Split-run equivalence is regression-tested against an uninterrupted run. This logical engine foundation also underlies the local CLI persistence above; its storage envelope is separate. Checkpoints may contain authored text, variables, runtime state, and witness paths, and schema v1 deliberately excludes assertions, goals, and variable/goal-aware shared modes. See [shared checkpoint schema v1](docs/shared-checkpoint-schema-v1.md). `--search=shared-variable` is a narrower experiment that gives 12.5% of shared-frontier selections to states reached through uncommon variable snapshots or transitions. The boost is bounded and the deep, novelty, and seeded views remain active. It can help mechanically driven storylet graphs, but it is not uniformly better; the checked-in [comparison table](docs/search-experiments.md) includes both gains and regressions. diff --git a/benchmarks/agent-readiness-v1/expected.json b/benchmarks/agent-readiness-v1/expected.json index 6948f1b..ca253b5 100644 --- a/benchmarks/agent-readiness-v1/expected.json +++ b/benchmarks/agent-readiness-v1/expected.json @@ -8,7 +8,13 @@ "config": 1, "projectInspection": 4, "artifact": 1, - "checkpointArtifact": 1, + "checkpointArtifact": 2, + "checkpointArtifactSupported": [ + 1, + 2 + ], + "checkpointArtifactDefaultWrite": 1, + "checkpointListResponse": 1, "searchSession": 5, "regressionArtifact": 2, "campaignPolicy": 1 diff --git a/docs/agent-discovery.md b/docs/agent-discovery.md index 6a77dd9..ba97e3c 100644 --- a/docs/agent-discovery.md +++ b/docs/agent-discovery.md @@ -15,7 +15,7 @@ The default MCP profile is deliberately compact: `inkcheck_capabilities`, `inspe The response is deterministic for an installed Inkcheck version and reports: - `schemaVersion` and `inkcheckVersion` -- report, configuration, project-inspection, report-artifact, checkpoint-artifact, search-session, and campaign-policy schema versions +- report, configuration, project-inspection, report-artifact, checkpoint-artifact, search-session, and campaign-policy schema versions. `schemas.checkpointArtifact` is the latest supported persisted envelope (2); `checkpointArtifactSupported` lists `[1, 2]`, `checkpointArtifactDefaultWrite` remains 1, and `checkpointListResponse` remains 1. The logical shared-search checkpoint nested inside either artifact remains schema 1. - default and maximum state/depth limits - supported search modes - explicit feature flags, including `false` for features not yet available diff --git a/docs/local-checkpoints.md b/docs/local-checkpoints.md index 940b388..329f0e6 100644 --- a/docs/local-checkpoints.md +++ b/docs/local-checkpoints.md @@ -30,6 +30,8 @@ inkcheck checkpoints show checkpoint-0123456789abcdef01234567 --json These commands return metadata, not the frontier payload. New saves also write a private, at-most-64-KiB `checkpoint-.meta.json` sidecar. `list` and retention read only that canonical self-checksummed manifest plus the payload's file size; they never open, hash, inflate, or JSON-parse a manifested frontier. Earlier schema-v1 `.json` and `.json.gz` artifacts without a sidecar still work, using the bounded full reader as a compatibility fallback. +The JSON list keeps its historical top-level `checkpointArtifactSchemaVersion: 1` as the list-response schema for compatibility; it is not the schema of every persisted payload. Each list item and the `show` artifact report `artifactSchemaVersion: 1 | 2` alongside `storageEncoding`, so callers can distinguish a legacy JSON/gzip envelope from an opt-in framed-v2 envelope. + The manifest self-checksum binds every field, including the stored-payload digest, so an isolated edit to creation time, grant, state count, or another field fails before it can affect listing or retention. This detects accidental/local metadata corruption; it is not authentication against someone with write access who deliberately rewrites both the manifest and its checksum. `open` and resume are the full-integrity boundary: they validate the manifest, hash the complete stored payload and compare its digest, then decompress, parse, verify the stable logical ID/configuration, and check source freshness. `show` reports: - `current`: compiled story and knot/source map match the saved checkpoint. @@ -42,29 +44,37 @@ Library callers can distinguish three `CheckpointReadError.kind` values instead - `corrupt`: invalid gzip/JSON, checksum or stable-ID mismatch, or malformed metadata; - `unsupported`: an artifact, manifest, or shared-checkpoint schema this Inkcheck cannot interpret; -- `resource_limit`: stored or decompressed bytes exceed the bounded readback envelope. This does not label the checkpoint corrupt and never deletes or replaces it. +- `resource_limit`: a stored/decompressed/frame/record byte bound, frame/record count bound, or JSON-depth bound is exceeded. This does not label the checkpoint corrupt and never deletes or replaces it. + +Resource-limit errors add generic `observed`, `limit`, and `unit` (`bytes`, `count`, or `depth`) fields. The legacy `observedBytes` and `limitBytes` aliases remain populated only for byte limits; they are absent for frame/record counts and JSON depth. + +The low-level `readCheckpointArtifactV2` / `writeCheckpointArtifactV2` codec API reports cancellation as `CheckpointArtifactV2Error` with `kind: "cancelled"` and can promptly interrupt a stalled source or record iterator mid-I/O. High-level `saveCheckpointArtifact`, `openCheckpointArtifact`, and `loadCheckpointForResume` normalize that condition to the ordinary `AbortError` contract. Only publication after the neutral no-clobber commit becomes non-cancellable so recovery can finish one unambiguous pair. -`listCheckpointArtifacts`, `openCheckpointArtifact`, and `loadCheckpointForResume` accept optional `maxStoredBytes` and `maxDecompressedBytes` read limits. Defaults cap stored input at 512 MiB and schema-v1 decompression at the smaller of 512 MiB and the runtime's maximum string length. Listing uses those limits only for a sidecar-free compatibility fallback. +`listCheckpointArtifacts`, `openCheckpointArtifact`, and `loadCheckpointForResume` accept optional `maxStoredBytes` and `maxDecompressedBytes` read limits. Defaults cap stored input at 512 MiB and schema-v1 decompression at the smaller of 512 MiB and the runtime's maximum string length. Framed-v2 library reads also accept explicit per-header, frame, record, JSON-depth, count, total-stored, and total-decoded `framedV2Limits`; runtime Buffer/string ceilings and the enclosing stored/decompressed bounds remain authoritative. Listing uses those limits only for a sidecar-free compatibility fallback. Successful `openCheckpointArtifact` and `loadCheckpointForResume` calls also return an additive `CheckpointReadAccountingV1` receipt. Typed failures attach the partial receipt reached before failure as `CheckpointReadError.accounting`; constructing it does not retry the failed operation. The receipt records effective configured limits (which are bounds, not allocated capacity), the existing manifest buffer/string/parsed source graph and canonical checksum-validation string when present, the one stored payload buffer, the gzip output buffer or a legacy-JSON `not_applied` marker, the one raw artifact string, the parsed artifact source graph, and the exact logical checkpoint bytes, largest source chunk, and two configuration-comparison strings from the existing stable-ID/envelope validation. Plain legacy JSON and gzip therefore have distinct truthful owner shapes without changing their accepted bytes. -`conservativePotential.totalBytes` sums the reported logical owners that were reached plus the largest validation chunk. It is deliberately not an observed simultaneous peak, V8 heap measurement, or claim about unreported runtime/library internals such as fixed race probes, hashing state, gzip/JSON-parser workspace, or source-freshness compilation. The parsed artifact already owns its nested checkpoint; `currentReturnedGraph` transfers that same graph rather than charging it twice. It is zero after metadata-only `openCheckpointArtifact` and one with the exact logical checkpoint bytes after a successful `loadCheckpointForResume`. A stale or path-changed resume retains its existing plain source-binding error after the artifact read completed, so it is outside the typed read-failure receipt contract; callers that need accounting alongside stale/path-changed metadata can use `openCheckpointArtifact` first. +`conservativePotential.totalBytes` sums the reported logical owners that were reached plus the largest validation chunk. It is deliberately not an observed simultaneous peak, V8 heap measurement, or claim about unreported runtime/library internals such as fixed race probes, hashing state, gzip/JSON-parser workspace, or source-freshness compilation. The parsed artifact already owns its nested checkpoint; `currentReturnedGraph` transfers that same graph rather than charging it twice. It is zero after metadata-only `openCheckpointArtifact` and one with the exact logical checkpoint bytes after a successful `loadCheckpointForResume`. For framed v2 the monolithic stored/decompressed/raw-string/parsed-artifact owners are `not_applied`; after a successful terminal-index/EOF validation, an additive `framedV2` section reports the fixed privacy-safe component/frame/index totals while the assembled returned graph remains explicit. A typed mid-stream v2 failure retains manifest and failure-stage accounting but currently omits `framedV2`, because the codec has not returned verified aggregate component totals; it does not present partial frames as a completed receipt. A stale or path-changed resume retains its existing plain source-binding error after the artifact read completed, so it is outside the typed read-failure receipt contract; callers that need accounting alongside stale/path-changed metadata can use `openCheckpointArtifact` first. ## Atomicity and retention -New checkpoints live under `.inkcheck/checkpoints/checkpoint-.json.gz`, with the bounded metadata sidecar beside them. Their stable ID derives from the exact logical checkpoint content plus the project-relative entrypoint, not from compression or manifest bytes, so repeating the same deterministic boundary reuses one artifact. Existing schema-v1 `.json` artifacts remain readable and resumable. +Default checkpoints live under `.inkcheck/checkpoints/checkpoint-.json.gz`, with the bounded metadata sidecar beside them. Library callers may explicitly request the opt-in framed-v2 codec, which writes `checkpoint-.inkcp` plus a schema-v2 sidecar. Their shared stable ID derives from the exact logical checkpoint content plus the project-relative entrypoint, not from compression, framing, or manifest bytes, so repeating the same deterministic boundary reuses one verified artifact in either layout. The writer never creates both layouts for one ID. Existing schema-v1 `.json` and `.json.gz` artifacts remain readable and resumable. + +The default writer emits compact JSON through gzip directly into a private same-directory temporary file while computing the stored-byte digest in the same pass. It never constructs a second artifact-sized JSON string in memory, and it checks the final payload-plus-sidecar bytes before publication. Its reader bounds stored input and gzip output before parsing and does not create a second decompressed string. That schema-v1 storage layout is nevertheless memory-heavy: the compressed buffer, decompressed buffer, one JSON string, and parsed frontier graph can overlap until garbage collection. + +The opt-in v2 writer instead emits ordered bounded records into independently compressed frames and a terminal index. Its reader incrementally validates and assembles those records without a whole artifact Buffer, whole decompressed Buffer, or whole raw JSON string. It still retains the reconstructed logical resume graph, and one frame's stored/decoded/record data plus runtime/parser workspace can overlap. Both layouts pass the same logical checkpoint schema, stable-ID, source, configuration, and freshness checks. See [shared checkpoint artifact v2](shared-checkpoint-artifact-v2.md) for the wire contract and deliberate limits. -The writer emits compact JSON through gzip directly into a private same-directory temporary file while computing the stored-byte digest in the same pass. It never constructs a second artifact-sized JSON string in memory, and it checks the final payload-plus-sidecar bytes before publication. The reader bounds stored input and gzip output before parsing and does not create a second decompressed string. Schema v1 is nevertheless still memory-heavy: the compressed buffer, decompressed buffer, one JSON string, and parsed frontier graph can overlap until garbage collection. A malformed gzip stream fails closed as storage corruption; a valid stream still passes the normal schema, stable-ID, source, and configuration checks after decompression. +`saveCheckpointArtifact` returns an additive schema-v1 `accounting` receipt outside the checkpoint object and stable ID. It reports the logical checkpoint graph's canonical UTF-8 bytes and largest source chunk from the existing ID traversal; whether legacy artifact serialization/compression was applied; and the durable payload, manifest, and pair bytes. Configured compression capacity is explicitly not observed heap. A framed write marks those legacy monolithic stages `not_applied` and adds fixed component/frame/index totals under `framedV2`. A fast same-ID reuse reports all encode work as `not_applied`, while a concurrent loser that encoded a candidate before finding the winner truthfully reports applied work with outcome `reused` in the layout it attempted. Neither path opens or decodes the payload an extra time merely to construct this receipt. -`saveCheckpointArtifact` returns an additive schema-v1 `accounting` receipt outside the checkpoint object and stable ID. It reports the logical checkpoint graph's canonical UTF-8 bytes and largest source chunk from the existing ID traversal; whether artifact serialization/compression was applied; streamed artifact bytes, largest compressed output chunk, and configured zlib/stream-buffer capacity when applied; and the durable payload, manifest, and pair bytes. Configured compression capacity is explicitly not observed heap. A fast same-ID reuse reports serialization/compression as `not_applied` with zero transient counters, while a concurrent loser that encoded a candidate before finding the winner truthfully reports applied work with outcome `reused`. Neither path opens or decodes the payload an extra time merely to construct this receipt. +Cross-format requests reuse committed same-ID bytes when they observe them before candidate work, when format preflight fails without emitting candidate bytes, or after a candidate encoded successfully but loses no-clobber publication. If a private candidate emits bytes and then fails, that request preserves its own error instead of returning an untruthful reuse receipt; a later retry can reuse the durable winner. -Neither lifecycle receipt can be embedded in the checkpoint bytes it measures without becoming self-referential. The write receipt is post-write evidence, while the read receipt is returned only by the library reopen APIs or a typed read error. They do not change the stable ID, payload, manifest, retention order, source-freshness behavior, or search policy. The read receipt reuses the existing manifest/payload read, payload digest, gzip decode, JSON parse, and stable-ID traversal; it does not add another payload read, decode, hash, parse, or graph serialization. Framed incremental readback and removal of the single-value overlap remain checkpoint-v2 work. +Neither lifecycle receipt can be embedded in the checkpoint bytes it measures without becoming self-referential. The write receipt is post-write evidence, while the read receipt is returned only by the library reopen APIs or a typed read error. They do not change the stable ID, payload, manifest, retention order, source-freshness behavior, or search policy. The read receipt is derived from the existing validation path and does not add a graph serialization. Framed v2 removes the schema-v1 whole-value materializations from that path; it does not claim that the final resume graph or all runtime workspace are absent. -Before exposing payload bytes, Inkcheck reserves one of 32 fixed, hidden recovery-manifest slots for that stable ID, writes the canonical at-most-64-KiB manifest there, flushes it, and directory-syncs the slot. A same-directory hard-link create is then the exclusive, no-clobber same-ID payload commit point on supported local filesystems, including ordinary NTFS/APFS/ext filesystems: concurrent losers reopen the winning bytes instead of overwriting them. The writer hashes the visible payload with a fixed-size buffer, selects only a recovery record whose size, digest, ID, and requested checkpoint summary match, and promotes that record to the canonical sidecar. Recovery therefore does not need to inflate or parse an artifact that exceeds the schema-v1 readback ceiling. +Before exposing payload bytes, Inkcheck reserves one of 32 fixed, hidden recovery-manifest slots for that stable ID, writes the canonical at-most-64-KiB manifest there, flushes it, and directory-syncs the slot. New v1 and v2 writers then compete for the same deterministic neutral hard-link pathname, independent of public extension. The winning inode's recovery manifest selects the sole `.json.gz` or `.inkcp` destination; observed-before-work, preflight-without-output, and successfully encoded no-clobber losers reopen those winning bytes instead of overwriting or creating a second layout. A partial private write failure still surfaces its own error. The writer hashes the visible payload with a fixed-size buffer, selects only a recovery record whose size, digest, ID, requested checkpoint summary, storage encoding, and optional component summary match, and promotes that record to the canonical sidecar. Recovery therefore does not need to inflate or parse an artifact that exceeds the schema-v1 readback ceiling. The canonical sidecar is never pre-quarantined. A matching recovery record replaces an older orphan/corrupt sidecar only after this writer sees the published payload; POSIX uses atomic rename-over-existing, while the portable fallback retains bounded promotion and displaced companions so a crash in the replace gap can restore or complete the pair. Each slot has a nonce-bearing owner claim. Reservation and cleanup must first acquire the same fixed per-slot cleaning claim, which is removed last; a delayed cleaner therefore cannot delete a pathname after another writer has reused the slot. A transaction that returns before cleanup durably records that exact nonce as released, allowing a same-process or cross-process retry to reclaim it without confusing another live transaction for debris. Recovery records remain hidden from listing and retention. They are deleted only after the canonical manifest has been reread and matched against a second fixed-memory hash of the same visible payload; a crash before payload publication leaves ignored recovery-only metadata, and a crash after publication leaves enough metadata to finish without decoding. Sidecar reconstruction for legacy schema-v1 JSON also uses this fixed transaction namespace. The claim, release marker, cleaning claim, payload temporary, manifest, promotion, and displaced filenames are fixed and bounded per stable ID. A process that dies while holding a cleaning claim consumes that one slot until an operator removes it while no checkpoint save is active; it cannot expose or overwrite checkpoint evidence. -This publication guarantee serializes writers for the same stable ID. Retention still validates the complete project set again after the pair is durable, but it is not a global transaction or recovery journal across simultaneous writers of different checkpoint IDs. Hidden files left by an abruptly terminated process are transaction debris, not listed checkpoint artifacts: they are excluded from retention and the project artifact byte ceiling, and the next successful save of that same stable ID cleans dead-owner slots. Repeated crashes across many distinct IDs can therefore leave additional hidden disk use; when no checkpoint save is active, an operator may remove those hidden recovery-slot files. Project-wide crash-debris accounting/recovery belongs with the framed checkpoint-v2 journal rather than this same-ID schema-v1 precursor. Only after the new pair is durable does retention remove older payloads and their sidecars. Defaults are hard safety ceilings for final checkpoint artifacts: +This publication guarantee serializes writers and both storage layouts for the same stable ID. Retention still validates the complete project set again after the pair is durable, but it is not a global transaction or recovery journal across simultaneous writers of different checkpoint IDs. Hidden files left by an abruptly terminated process are transaction debris, not listed checkpoint artifacts: they are excluded from retention and the project artifact byte ceiling, and the next successful save of that same stable ID cleans dead-owner slots. Repeated crashes across many distinct IDs can therefore leave additional hidden disk use; when no checkpoint save is active, an operator may remove those hidden recovery-slot files. Project-wide crash-debris accounting/recovery remains follow-up work. Only after the new pair is durable does retention remove older payloads and their sidecars. Defaults are hard safety ceilings for final checkpoint artifacts: - 512 MiB for one checkpoint; - 1 GiB across checkpoint artifacts in one project; @@ -72,11 +82,11 @@ This publication guarantee serializes writers for the same stable ID. Retention An individually oversized payload-plus-sidecar pair is rejected. Once a new generation is durable, oldest generations for that entrypoint are removed first, then the oldest project checkpoints if needed to satisfy the project byte ceiling. The saved generation is protected from that cleanup. `checkpoints list/show` reports `payloadSizeBytes`, `metadataSizeBytes`, and their sum as the actual durable `sizeBytes`; this is storage cost, not an estimate of process heap or future search value. -## Schema-v1 readback boundary +## Schema-v1 readback boundary and v2 promotion gate This is the safe foundation for the observed 600,000-state boundary, not a claim that every such checkpoint can now resume. A gzip payload can be within the durable disk quota while its single logical JSON value is larger than V8 can represent. Inkcheck now returns `resource_limit` at that boundary, keeps the known-good bytes intact, and can still list/prune a manifested artifact without inflation. It does not misreport the file as corrupt or retry an unsafe allocation. A repeated same-ID save may recognize such an artifact only after its canonical manifest matches the requested checkpoint summary and its full stored-byte digest verifies; that preserves known bytes but does not claim the logical payload was decoded or resumable. -Removing that format ceiling requires a framed artifact schema v2: independently bounded metadata and frontier frames, per-frame lengths/checksums, incremental decode into the resume structures, and a compatibility reader that leaves schema-v1 IDs and exact trajectories unchanged. Promotion should require split-run equality against uninterrupted search plus truncated-frame, oversized-frame, checksum, and mixed-v1/v2 retention tests. +The opt-in framed artifact v2 foundation supplies independently bounded records/frames, checksums, a terminal index, incremental assembly, mixed-layout compatibility, and stable schema-v1 IDs. It is not yet the default and does not by itself prove that the observed 600,000-state cell now completes or that memory use improved. Default promotion requires exact split-versus-uninterrupted equality, adversarial truncation/oversize/checksum/cancellation/crash/race gates, the same Intercept boundary under identical ceilings, and a second public family such as Heresy II. No allocation, compaction, eviction, epoch, or stopping policy is activated by selecting the storage codec. ## Privacy diff --git a/docs/shared-checkpoint-artifact-v2.md b/docs/shared-checkpoint-artifact-v2.md new file mode 100644 index 0000000..0f8316a --- /dev/null +++ b/docs/shared-checkpoint-artifact-v2.md @@ -0,0 +1,135 @@ +# Shared checkpoint artifact v2 + +Artifact v2 is an opt-in, framed storage codec for the existing logical +`SharedSearchCheckpoint` schema v1. It changes neither the logical checkpoint, +the stable checkpoint-ID algorithm, deterministic frontier order, nor resume +semantics. The public CLI, MCP/search-session persistence, and +`saveCheckpointArtifact(...)` calls that omit `format` continue to write the +existing streamed-gzip `.json.gz` artifact. + +Library callers can request the codec explicitly: + +```ts +await saveCheckpointArtifact(projectRoot, entrypoint, checkpoint, { + format: "framed-v2", +}); +``` + +The resulting payload is `.inkcheck/checkpoints/checkpoint-.inkcp`, with +a schema-v2 canonical `.meta.json` sidecar. Readers dispatch from that verified +sidecar and continue to accept manifested and legacy sidecar-free schema-v1 +`.json`/`.json.gz` artifacts. A stable ID may have exactly one verified public +layout. A request for either format reuses an already verified artifact with the +same ID; it never creates a second layout. + +## Wire contract + +The artifact begins with the fixed ASCII magic `INKCHECKCP2\r\n`. Every frame +then has an eight-byte big-endian prefix containing bounded header and stored +payload lengths, an exact-key canonical JSON header, and an independently +gzip-compressed payload. Data-frame payloads contain length-prefixed compact +JSON records. A terminal index binds every frame and component summary, and no +bytes may follow it. + +The fixed component order is: + +1. `configuration` +2. `scheduler` +3. `frontier` +4. `witnessAncestry` +5. `dedupe` +6. `semanticIndexes` +7. `findings` +8. `metadata` + +Within a component, fields, record starts, and record indexes are strictly +ordered. The writer deterministically splits a field before its record-count, +decoded-byte, or compressed-byte bound would be exceeded. The reader validates +magic, prefix lengths, exact header fields, component/field order, record +lengths and indexes, frame and component summaries, the terminal index, and +exact EOF before accepting the reconstructed logical checkpoint. + +Default codec ceilings are 64 KiB per header, 16 MiB stored and 32 MiB decoded +per frame, 8 MiB and 512 JSON nesting levels per record, 100,000 records per +frame, 65,536 frames, ten million records, and 512 MiB each for total stored and +decoded frame payloads. Runtime Buffer/string ceilings remain authoritative. +`framedV2Limits` may only narrow or deliberately replace those explicit library +bounds; the enclosing checkpoint and project durable-byte ceilings still apply +to the complete payload-plus-sidecar pair. Counts and byte sums use checked safe +integer arithmetic. + +Each data frame, the terminal index, and each aggregate component summary bind +decoded and stored content with SHA-256. The canonical sidecar also binds the +complete stored payload digest and exact private component/index summaries. +These are integrity checks, not authentication: a writer that can replace the +checkpoint files can recompute them. The public list/show projection contains +only the fixed component names and bounded numeric counts/byte totals; it omits +the private digests and all checkpoint content. + +## Bounded readback and accounting + +The v2 reader holds one bounded frame's stored bytes, decoded bytes, and record +at a time and incrementally delivers records to the logical checkpoint +assembler. It does not materialize a whole stored-artifact Buffer, a whole +decompressed Buffer, or a whole raw artifact string. The reconstructed resume +graph is still retained, and bounded frame/runtime/parser workspace can overlap; +the codec is not a claim about total V8 heap. + +At the low-level codec API, `onRecord` deliveries are provisional until +`readCheckpointArtifactV2` resolves after validating the terminal index and +exact EOF. A caller must not publish callback effects early. The high-level +checkpoint reader keeps assembly private and discards it on any later frame, +index, checksum, bound, cancellation, or EOF failure. + +The existing additive checkpoint read/write receipts remain outside checkpoint +bytes and IDs. For v2, legacy monolithic serialization, gzip, stored-buffer, +decompressed-buffer, raw-string, and parsed-artifact owners are marked +`not_applied`; the optional `framedV2` section reports the codec's fixed numeric +component/frame/index totals. The canonical logical checkpoint traversal still +validates the unchanged stable ID after assembly. Typed corruption, +resource-limit, and unsupported failures retain the manifest and failure-stage +receipt reached by the enclosing checkpoint reader. A mid-stream failure +currently omits `framedV2`: aggregate component/frame totals are reported only +after the terminal index and exact EOF verify them, rather than exposing an +unverified partial aggregate. + +At the low-level codec surface, cancellation is a +`CheckpointArtifactV2Error` with `kind: "cancelled"`; it can interrupt a +stalled source or record iterator during I/O as well as stop at framed +boundaries. High-level checkpoint save/open/load operations map that condition +to `AbortError`. Publication alone becomes non-cancellable after the neutral +no-clobber commit point, so a successful writer or later retry can finish one +durable payload/sidecar pair rather than expose an ambiguous partial result. + +## Same-ID publication and recovery + +All new v1 and v2 writers for one stable ID compete for the same hidden neutral +hard-link pathname. The winning inode's already durable recovery manifest +selects the only public extension that may be linked. A concurrent loser—or a +retry after a process exit—verifies and reuses that winner regardless of which +format it requested when it observes committed bytes before candidate work, +fails format preflight without emitting candidate bytes, or successfully +encodes a candidate but loses no-clobber publication. A private candidate that +has already emitted bytes and then fails retains its own error; a later retry +reuses the winner. Only after the public payload and canonical sidecar have been +reread and matched are the bounded recovery records and neutral link removed. + +This extends the existing same-ID crash-recovery protocol; it is not a global +transaction journal. Hidden abrupt-process debris remains outside listed +artifact quotas, and cross-ID publication/retention retains the documented +limitations in [local resumable checkpoints](local-checkpoints.md). + +## Deliberate scope + +This is a codec foundation, not format promotion. It does not make framed v2 the +default, add an engine-native streaming checkpoint producer, change allocation, +compaction, eviction, stopping, or epoch policy, publish a release, or establish +an InkBench improvement claim. Promotion still requires exact split-versus- +uninterrupted resume evidence, the observed Intercept schema-v1 readback-limit +cell under identical ceilings, and a second public story family such as Heresy +II, together with the adversarial truncation, checksum, bounds, cancellation, +crash, and mixed-layout gates. + +Checkpoint records can contain authored text, variables, serialized runtime +state, findings, and witness paths. Treat `.inkcp` files as sensitive project +data just like `.json` and `.json.gz` checkpoints. diff --git a/docs/shared-checkpoint-schema-v1.md b/docs/shared-checkpoint-schema-v1.md index b0c97cf..8089d30 100644 --- a/docs/shared-checkpoint-schema-v1.md +++ b/docs/shared-checkpoint-schema-v1.md @@ -50,16 +50,16 @@ inkcheck resume checkpoint-0123456789abcdef01234567 --max-states 1000000 --json See [local resumable checkpoints](local-checkpoints.md) for freshness, privacy, atomic-write, quota, and retention behavior. -The logical checkpoint remains schema v1 JSON. New local artifacts stream that JSON through gzip as `.json.gz`; stable IDs still hash the same uncompressed logical checkpoint, and readers continue to accept earlier plain `.json` artifacts. Storage compression therefore changes neither deterministic frontier order nor split-run equivalence. +The logical checkpoint remains schema v1. The default local artifact still streams that one JSON value through gzip as `.json.gz`, while library callers may explicitly choose the framed-v2 `.inkcp` storage codec. Both layouts reconstruct the same property-ordered logical checkpoint before the unchanged stable-ID check; readers continue to accept earlier plain `.json` artifacts. Compression or framing therefore changes neither the checkpoint ID, deterministic frontier order, nor split-run equivalence. A verified artifact in either layout wins same-ID reuse, and new writers never publish both layouts for one ID. -New artifacts have a small canonical self-checksummed metadata sidecar, allowing list and retention operations to read bounded metadata plus payload file size without opening the frontier. That checksum detects isolated field corruption but is not authentication against a hostile local writer who can recompute it. Opening and resuming remain full-integrity boundaries: manifest validation and the full stored-payload digest are the external stored-file corruption boundary and precede bounded decompression; the nested observability `integritySha256` is then checked as part of logical checkpoint validation, followed by the original stable-ID, envelope, configuration, and freshness checks. Read failures explicitly distinguish corruption, unsupported schemas, and an artifact that cannot fit the schema-v1 readback envelope. +New artifacts have a small canonical self-checksummed metadata sidecar, allowing list and retention operations to read bounded metadata plus payload file size without opening the frontier. A framed-v2 sidecar additionally binds the exact private component/frame/index summary; its public projection contains only fixed component names and numeric counts/bytes. Those checksums detect isolated field corruption but are not authentication against a hostile local writer who can recompute them. Opening and resuming remain full-integrity boundaries: manifest validation and the full stored-payload digest are the external stored-file corruption boundary, framed records or the legacy one-value payload are decoded under explicit bounds, the nested observability `integritySha256` is checked as part of logical checkpoint validation, and the original stable-ID, envelope, configuration, and freshness checks follow. Read failures explicitly distinguish corruption, unsupported schemas, and an artifact that cannot fit its configured readback envelope. -The library reopen APIs return a separate schema-v1 read-accounting receipt outside this envelope; typed `CheckpointReadError` failures carry the partial receipt reached before failure. It distinguishes the manifest and its checksum-validation string, stored payload, gzip output versus legacy JSON, raw string, parsed source graph, existing stable-ID traversal and configuration-comparison strings, effective limits, conservative potential, and checkpoint graph retained on return. Metadata-only open retains no checkpoint graph; successful resume transfers one. Conservative potential sums the reported reached logical owners and the traversal's largest temporary source chunk, not an observed peak or full V8/runtime heap. These counters are derived from the existing single read/decode/parse/digest/ID-validation path, so they change neither bytes, stable identity, validation order, source-freshness behavior, nor split-run trajectory. +The library reopen APIs return a separate schema-v1 read-accounting receipt outside this envelope; typed `CheckpointReadError` failures carry the partial receipt reached before failure. For legacy artifacts it distinguishes the manifest and its checksum-validation string, stored payload, gzip output versus plain JSON, raw string, parsed source graph, existing stable-ID traversal and configuration-comparison strings, effective limits, conservative potential, and checkpoint graph retained on return. Framed v2 marks the monolithic payload/string/parsed-artifact owners `not_applied` and adds fixed component/frame/index totals. Metadata-only open retains no checkpoint graph; successful resume transfers one. Conservative potential is not an observed peak or full V8/runtime heap. These counters change neither bytes, stable identity, validation order, source-freshness behavior, nor split-run trajectory. ## Deliberate limits -Schema v1 supports only base `shared:deep-novelty-v1`; assertions, goals, variable-aware steering, goal-aware steering, and the default portfolio are rejected rather than resumed approximately. Hosted/MCP resume and frontier partitioning remain future work. The nested observability-v1-to-v2 migration does not change this storage boundary: the checkpoint is still one JSON value and cannot safely reopen a payload above the runtime's maximum string length even when gzip keeps the stored file below quota. Its compressed buffer, decompressed buffer, JSON string, and parsed graph may overlap in memory. The reader reports an unsafe boundary as a resource limit and preserves the artifact; framed incremental artifact schema v2 is required to remove both the string ceiling and this peak-memory shape. +Schema v1 supports only base `shared:deep-novelty-v1`; assertions, goals, variable-aware steering, goal-aware steering, and the default portfolio are rejected rather than resumed approximately. Hosted-job resume and frontier partitioning remain future work; MCP continuation is available through cooperative result-window sessions. The default gzip layout is still one JSON value and cannot safely reopen a payload above the runtime's maximum string length even when gzip keeps the stored file below quota. Its compressed buffer, decompressed buffer, JSON string, and parsed graph may overlap in memory. The reader reports an unsafe boundary as a resource limit and preserves the artifact. The opt-in framed-v2 codec removes those whole-value readback materializations by delivering bounded records into the same logical schema-v1 resume graph; that graph and bounded frame/runtime workspace still consume memory. -The observability reason vocabulary reserves `checkpoint`, `epoch`, and `pressure`, but this slice emits none of those boundaries. It also adds no resource policy, owner ceiling, epoch behavior, checkpoint-v2 encoding, or claim that issue #216 is complete. +The observability reason vocabulary reserves `checkpoint`, `epoch`, and `pressure`, but this slice emits none of those boundaries. The optional artifact-v2 storage encoding adds no resource policy, owner ceiling, epoch behavior, default-format promotion, or claim that issue #156 or #216 is complete. Checkpoint JSON can contain authored choice text, ending text, variable snapshots, serialized Ink runtime state, and exact witness paths. Treat it as sensitive project data and do not commit checkpoints by default. diff --git a/package.json b/package.json index eef1806..bb11fa9 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "docs/progress-ndjson.md", "docs/local-artifacts.md", "docs/local-checkpoints.md", + "docs/shared-checkpoint-artifact-v2.md", "docs/shared-checkpoint-schema-v1.md", "docs/shared-search-observability.md", "docs/mcp-search-sessions.md", diff --git a/src/checkpoint-artifact-v2.ts b/src/checkpoint-artifact-v2.ts new file mode 100644 index 0000000..4abed02 --- /dev/null +++ b/src/checkpoint-artifact-v2.ts @@ -0,0 +1,1343 @@ +import { createHash } from "crypto"; +import { constants as bufferConstants } from "buffer"; +import { Readable, Writable } from "stream"; +import { createGunzip, gzipSync } from "zlib"; + +export const CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION = 2 as const; +export const CHECKPOINT_ARTIFACT_V2_MAGIC = Buffer.from("INKCHECKCP2\r\n", "ascii"); +const CHECKPOINT_ARTIFACT_VERSIONED_MAGIC_PROBE_PATTERN = /^INKCHECKCP([0-9]+)(?:\r\n|\r)?$/; + +export const CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER = [ + "configuration", + "scheduler", + "frontier", + "witnessAncestry", + "dedupe", + "semanticIndexes", + "findings", + "metadata", +] as const; + +/** Backwards-compatible descriptive alias for callers that do not need rank order. */ +export const CHECKPOINT_ARTIFACT_V2_COMPONENTS = CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER; + +export type CheckpointArtifactV2Component = typeof CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER[number]; + +export interface CheckpointArtifactV2Limits { + maxHeaderBytes: number; + maxStoredFrameBytes: number; + maxDecodedFrameBytes: number; + maxRecordBytes: number; + maxJsonDepth: number; + maxRecordsPerFrame: number; + maxFrames: number; + maxTotalRecords: number; + maxTotalStoredBytes: number; + maxTotalDecodedBytes: number; +} + +export const DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS: Readonly = Object.freeze({ + maxHeaderBytes: 64 * 1024, + maxStoredFrameBytes: 16 * 1024 * 1024, + maxDecodedFrameBytes: 32 * 1024 * 1024, + maxRecordBytes: 8 * 1024 * 1024, + maxJsonDepth: 512, + maxRecordsPerFrame: 100_000, + maxFrames: 65_536, + maxTotalRecords: 10_000_000, + maxTotalStoredBytes: 512 * 1024 * 1024, + maxTotalDecodedBytes: 512 * 1024 * 1024, +}); + +export type CheckpointArtifactV2ErrorKind = + | "corrupt" + | "resource_limit" + | "unsupported" + | "cancelled"; + +export type CheckpointArtifactV2LimitMetric = "bytes" | "count" | "depth"; + +export type CheckpointArtifactV2ErrorStage = + | "magic" + | "prefix" + | "header" + | "payload" + | "record" + | "index" + | "eof"; + +export class CheckpointArtifactV2Error extends Error { + constructor( + public readonly kind: CheckpointArtifactV2ErrorKind, + public readonly stage: CheckpointArtifactV2ErrorStage, + message: string, + public readonly observed?: number, + public readonly limit?: number, + public readonly metric?: CheckpointArtifactV2LimitMetric + ) { + super(message); + this.name = "CheckpointArtifactV2Error"; + } +} + +export interface CheckpointArtifactV2RecordInput { + component: CheckpointArtifactV2Component; + field: string; + /** Zero-based index of the first supplied record within this component field. */ + start: number; + records: Iterable | AsyncIterable; +} + +/** One ordered logical field input; the codec splits it into bounded wire frames. */ +export type CheckpointArtifactV2FrameInput = CheckpointArtifactV2RecordInput; + +export interface CheckpointArtifactV2Record { + component: CheckpointArtifactV2Component; + field: string; + index: number; + value: unknown; +} + +export interface CheckpointArtifactV2FrameSummary { + sequence: number; + component: CheckpointArtifactV2Component; + field: string; + start: number; + recordCount: number; + headerBytes: number; + decodedBytes: number; + decodedSha256: string; + storedBytes: number; + storedSha256: string; + maxRecordBytes: number; +} + +export interface CheckpointArtifactV2ComponentSummary { + component: CheckpointArtifactV2Component; + frameCount: number; + recordCount: number; + decodedBytes: number; + decodedSha256: string; + storedBytes: number; + storedSha256: string; + maxRecordBytes: number; +} + +export interface CheckpointArtifactV2IndexSummary { + sequence: number; + headerBytes: number; + decodedBytes: number; + decodedSha256: string; + storedBytes: number; + storedSha256: string; + maxRecordBytes: number; +} + +export interface CheckpointArtifactV2Result { + schemaVersion: 2; + artifactBytes: number; + data: { + frameCount: number; + recordCount: number; + decodedBytes: number; + storedBytes: number; + maxRecordBytes: number; + }; + frames: CheckpointArtifactV2FrameSummary[]; + components: CheckpointArtifactV2ComponentSummary[]; + index: CheckpointArtifactV2IndexSummary; +} + +export type CheckpointArtifactV2WriteResult = CheckpointArtifactV2Result; +export type CheckpointArtifactV2ReadResult = CheckpointArtifactV2Result; + +export interface CheckpointArtifactV2WriteOptions { + limits?: Partial; + signal?: AbortSignal; + gzipLevel?: number; + /** The writer owns and ends its destination by default. */ + endDestination?: boolean; +} + +export interface CheckpointArtifactV2ReadOptions { + limits?: Partial; + signal?: AbortSignal; + /** Delivered records remain provisional until the read promise verifies the terminal index and exact EOF. */ + onRecord(record: CheckpointArtifactV2Record): void | Promise; +} + +interface DataFrameHeader { + kind: "data"; + version: 2; + sequence: number; + component: CheckpointArtifactV2Component; + field: string; + start: number; + recordCount: number; + decodedBytes: number; + decodedSha256: string; + encoding: "gzip"; + storedBytes: number; + storedSha256: string; +} + +interface IndexFrameHeader { + kind: "index"; + version: 2; + sequence: number; + recordCount: 1; + decodedBytes: number; + decodedSha256: string; + encoding: "gzip"; + storedBytes: number; + storedSha256: string; +} + +interface ArtifactIndexRecord { + version: 2; + frameCount: number; + recordCount: number; + decodedBytes: number; + storedBytes: number; + maxRecordBytes: number; + frames: CheckpointArtifactV2FrameSummary[]; + components: CheckpointArtifactV2ComponentSummary[]; +} + +interface EncodedRecord { + prefix: Buffer; + body: Buffer; + decodedBytes: number; + recordBytes: number; +} + +const DATA_HEADER_KEYS: ReadonlyArray = [ + "kind", "version", "sequence", "component", "field", "start", "recordCount", + "decodedBytes", "decodedSha256", "encoding", "storedBytes", "storedSha256", +]; +const INDEX_HEADER_KEYS: ReadonlyArray = [ + "kind", "version", "sequence", "recordCount", "decodedBytes", "decodedSha256", + "encoding", "storedBytes", "storedSha256", +]; +const INDEX_RECORD_KEYS: ReadonlyArray = [ + "version", "frameCount", "recordCount", "decodedBytes", "storedBytes", + "maxRecordBytes", "frames", "components", +]; +const FIELD_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const COMPONENT_RANK = new Map( + CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER.map((component, index) => [component, index]) +); + +function error( + kind: CheckpointArtifactV2ErrorKind, + stage: CheckpointArtifactV2ErrorStage, + message: string, + observed?: number, + limit?: number, + metric?: CheckpointArtifactV2LimitMetric +): CheckpointArtifactV2Error { + return new CheckpointArtifactV2Error(kind, stage, message, observed, limit, metric); +} + +function assertNotCancelled(signal: AbortSignal | undefined, stage: CheckpointArtifactV2ErrorStage): void { + if (signal?.aborted) throw cancellationError(stage); +} + +function cancellationError(stage: CheckpointArtifactV2ErrorStage): CheckpointArtifactV2Error { + return error("cancelled", stage, "checkpoint artifact operation was cancelled"); +} + +function isSafeNonnegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function checkedAdd( + left: number, + right: number, + description: string, + stage: CheckpointArtifactV2ErrorStage, + metric: CheckpointArtifactV2LimitMetric +): number { + const value = left + right; + if (!Number.isSafeInteger(value)) { + throw error( + "resource_limit", + stage, + `${description} exceeds JavaScript's safe integer range`, + value, + Number.MAX_SAFE_INTEGER, + metric + ); + } + return value; +} + +const CHECKPOINT_ARTIFACT_V2_LIMIT_METRICS: Readonly< + Record +> = Object.freeze({ + maxHeaderBytes: "bytes", + maxStoredFrameBytes: "bytes", + maxDecodedFrameBytes: "bytes", + maxRecordBytes: "bytes", + maxJsonDepth: "depth", + maxRecordsPerFrame: "count", + maxFrames: "count", + maxTotalRecords: "count", + maxTotalStoredBytes: "bytes", + maxTotalDecodedBytes: "bytes", +}); + +function effectiveLimits(input: Partial | undefined): CheckpointArtifactV2Limits { + const limits = { ...DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS, ...input }; + for (const [name, value] of Object.entries(limits)) { + const metric = CHECKPOINT_ARTIFACT_V2_LIMIT_METRICS[name as keyof CheckpointArtifactV2Limits]; + if (!metric) throw error("unsupported", "header", `unsupported checkpoint artifact limit ${name}`); + if (!Number.isSafeInteger(value) || value <= 0 || value > 0xffffffff) { + throw error( + "resource_limit", + "header", + `${name} must be a positive 32-bit safe integer`, + value, + 0xffffffff, + metric + ); + } + } + const runtimeCeilings: Array<{ + name: keyof CheckpointArtifactV2Limits; + limit: number; + stage: CheckpointArtifactV2ErrorStage; + }> = [ + { + name: "maxHeaderBytes", + limit: Math.min(bufferConstants.MAX_LENGTH, bufferConstants.MAX_STRING_LENGTH), + stage: "header", + }, + { name: "maxStoredFrameBytes", limit: bufferConstants.MAX_LENGTH, stage: "payload" }, + { name: "maxDecodedFrameBytes", limit: bufferConstants.MAX_LENGTH, stage: "payload" }, + { + name: "maxRecordBytes", + limit: Math.min(bufferConstants.MAX_LENGTH, bufferConstants.MAX_STRING_LENGTH), + stage: "record", + }, + { + name: "maxJsonDepth", + limit: DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxJsonDepth, + stage: "record", + }, + ]; + for (const ceiling of runtimeCeilings) { + if (limits[ceiling.name] > ceiling.limit) { + throw error( + "resource_limit", + ceiling.stage, + `${ceiling.name} exceeds this runtime's safe codec ceiling`, + limits[ceiling.name], + ceiling.limit, + CHECKPOINT_ARTIFACT_V2_LIMIT_METRICS[ceiling.name] + ); + } + } + if (limits.maxRecordBytes + 4 > limits.maxDecodedFrameBytes) { + throw error( + "resource_limit", + "record", + "maxRecordBytes plus its length prefix must fit maxDecodedFrameBytes", + limits.maxRecordBytes + 4, + limits.maxDecodedFrameBytes, + "bytes" + ); + } + return limits; +} + +function sha256(value: Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function validateField(field: unknown, stage: CheckpointArtifactV2ErrorStage): asserts field is string { + if (typeof field !== "string" || !FIELD_PATTERN.test(field)) { + throw error("unsupported", stage, "checkpoint frame field must match [A-Za-z][A-Za-z0-9]*"); + } +} + +function componentRank(component: unknown, stage: CheckpointArtifactV2ErrorStage): number { + const rank = COMPONENT_RANK.get(component as CheckpointArtifactV2Component); + if (rank === undefined) throw error("unsupported", stage, `unsupported checkpoint component ${String(component)}`); + return rank; +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function compactJson(value: unknown, stage: CheckpointArtifactV2ErrorStage): string { + let raw: string | undefined; + try { + raw = JSON.stringify(value); + } catch (cause) { + throw error("unsupported", stage, `checkpoint value is not JSON-serializable: ${cause instanceof Error ? cause.message : String(cause)}`); + } + if (raw === undefined) throw error("unsupported", stage, "checkpoint value is not a JSON value"); + return raw; +} + +function encodedRecord(value: unknown, limits: CheckpointArtifactV2Limits): EncodedRecord { + const raw = compactJson(value, "record"); + enforceJsonDepth(raw, limits, "record"); + const recordBytes = Buffer.byteLength(raw, "utf8"); + if (recordBytes > limits.maxRecordBytes) { + throw error("resource_limit", "record", "checkpoint record exceeds maxRecordBytes", recordBytes, limits.maxRecordBytes, "bytes"); + } + const body = Buffer.from(raw, "utf8"); + const decodedBytes = checkedAdd(4, recordBytes, "checkpoint framed record bytes", "record", "bytes"); + if (decodedBytes > limits.maxDecodedFrameBytes) { + throw error("resource_limit", "record", "checkpoint record cannot fit a decoded frame", decodedBytes, limits.maxDecodedFrameBytes, "bytes"); + } + const prefix = Buffer.allocUnsafe(4); + prefix.writeUInt32BE(recordBytes, 0); + return { prefix, body, decodedBytes, recordBytes }; +} + +function encodePayload(records: readonly EncodedRecord[]): Buffer { + const bytes = records.reduce((total, record) => total + record.decodedBytes, 0); + const payload = Buffer.allocUnsafe(bytes); + let offset = 0; + for (const record of records) { + record.prefix.copy(payload, offset); + offset += record.prefix.length; + record.body.copy(payload, offset); + offset += record.body.length; + } + return payload; +} + +async function writeChunk(destination: Writable, chunk: Buffer): Promise { + await new Promise((resolve, reject) => { + const onError = (cause: Error) => reject(cause); + destination.once("error", onError); + destination.write(chunk, (cause?: Error | null) => { + if (cause) { + // Node emits `error` after invoking a failed write callback. Leave the + // one-shot listener installed so that event cannot become unhandled. + reject(cause); + return; + } + destination.off("error", onError); + resolve(); + }); + }); +} + +async function endWritable(destination: Writable): Promise { + await new Promise((resolve, reject) => { + const onError = (cause: Error) => reject(cause); + destination.once("error", onError); + destination.end(() => { + destination.off("error", onError); + resolve(); + }); + }); +} + +async function writeWireFrame(destination: Writable, rawHeader: Buffer, stored: Buffer): Promise { + const prefix = Buffer.allocUnsafe(8); + prefix.writeUInt32BE(rawHeader.length, 0); + prefix.writeUInt32BE(stored.length, 4); + await writeChunk(destination, prefix); + await writeChunk(destination, rawHeader); + await writeChunk(destination, stored); + return prefix.length + rawHeader.length + stored.length; +} + +function emptyComponents(): CheckpointArtifactV2ComponentSummary[] { + return CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER.map((component) => ({ + component, + frameCount: 0, + recordCount: 0, + decodedBytes: 0, + decodedSha256: sha256(Buffer.alloc(0)), + storedBytes: 0, + storedSha256: sha256(Buffer.alloc(0)), + maxRecordBytes: 0, + })); +} + +function componentChecksum( + component: CheckpointArtifactV2Component, + frames: readonly CheckpointArtifactV2FrameSummary[], + representation: "decoded" | "stored" +): string { + const hash = createHash("sha256"); + for (const frame of frames) { + if (frame.component !== component) continue; + const descriptor = representation === "decoded" ? { + sequence: frame.sequence, + field: frame.field, + start: frame.start, + recordCount: frame.recordCount, + bytes: frame.decodedBytes, + sha256: frame.decodedSha256, + } : { + sequence: frame.sequence, + field: frame.field, + start: frame.start, + recordCount: frame.recordCount, + bytes: frame.storedBytes, + sha256: frame.storedSha256, + }; + const raw = Buffer.from(JSON.stringify(descriptor), "utf8"); + const prefix = Buffer.allocUnsafe(4); + prefix.writeUInt32BE(raw.length, 0); + hash.update(prefix).update(raw); + } + return hash.digest("hex"); +} + +function finalizeComponentChecksums( + components: CheckpointArtifactV2ComponentSummary[], + frames: readonly CheckpointArtifactV2FrameSummary[] +): void { + for (const component of components) { + component.decodedSha256 = componentChecksum(component.component, frames, "decoded"); + component.storedSha256 = componentChecksum(component.component, frames, "stored"); + } +} + +function dataTotals(frames: readonly CheckpointArtifactV2FrameSummary[]): CheckpointArtifactV2Result["data"] { + return frames.reduce((totals, frame) => ({ + frameCount: totals.frameCount + 1, + recordCount: totals.recordCount + frame.recordCount, + decodedBytes: totals.decodedBytes + frame.decodedBytes, + storedBytes: totals.storedBytes + frame.storedBytes, + maxRecordBytes: Math.max(totals.maxRecordBytes, frame.maxRecordBytes), + }), { frameCount: 0, recordCount: 0, decodedBytes: 0, storedBytes: 0, maxRecordBytes: 0 }); +} + +function indexRecord( + frames: CheckpointArtifactV2FrameSummary[], + components: CheckpointArtifactV2ComponentSummary[] +): ArtifactIndexRecord { + const data = dataTotals(frames); + return { + version: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + frameCount: data.frameCount, + recordCount: data.recordCount, + decodedBytes: data.decodedBytes, + storedBytes: data.storedBytes, + maxRecordBytes: data.maxRecordBytes, + frames, + components, + }; +} + +function compareTuple( + left: { rank: number; field: string }, + right: { rank: number; field: string } +): number { + return left.rank - right.rank || (left.field < right.field ? -1 : left.field > right.field ? 1 : 0); +} + +async function cancellableIteratorNext( + iterator: Iterator | AsyncIterator, + signal: AbortSignal | undefined, + stage: CheckpointArtifactV2ErrorStage +): Promise> { + assertNotCancelled(signal, stage); + const pending = Promise.resolve(iterator.next()); + if (!signal) return await pending; + + let didAbort = false; + let onAbort!: () => void; + const interrupted = new Promise((_resolve, reject) => { + onAbort = () => { + if (didAbort) return; + didAbort = true; + reject(cancellationError(stage)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + try { + return await Promise.race([pending, interrupted]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} + +function returnIteratorWithoutBlocking(iterator: Iterator | AsyncIterator): void { + try { + const returned = iterator.return?.(); + if (returned) void Promise.resolve(returned).catch(() => undefined); + } catch { + // The write/cancellation failure remains authoritative. An uncooperative + // producer must not keep the owned destination or caller promise open. + } +} + +async function* asAsync( + values: Iterable | AsyncIterable, + signal: AbortSignal | undefined, + stage: CheckpointArtifactV2ErrorStage +): AsyncIterable { + const asyncFactory = (values as AsyncIterable)[Symbol.asyncIterator]; + const iterator: Iterator | AsyncIterator = typeof asyncFactory === "function" + ? asyncFactory.call(values) + : (values as Iterable)[Symbol.iterator](); + let completed = false; + try { + while (true) { + const next = await cancellableIteratorNext(iterator, signal, stage); + if (next.done) { + completed = true; + return; + } + yield next.value; + } + } finally { + if (!completed) returnIteratorWithoutBlocking(iterator); + } +} + +export async function writeCheckpointArtifactV2( + destination: Writable, + inputs: Iterable | AsyncIterable, + options: CheckpointArtifactV2WriteOptions = {} +): Promise { + const endDestination = options.endDestination ?? true; + let limits: CheckpointArtifactV2Limits; + let gzipLevel: number; + try { + limits = effectiveLimits(options.limits); + gzipLevel = options.gzipLevel ?? 1; + if (!Number.isInteger(gzipLevel) || gzipLevel < 0 || gzipLevel > 9) { + throw error("unsupported", "payload", "gzipLevel must be an integer from 0 through 9"); + } + } catch (cause) { + if (endDestination && !destination.destroyed) destination.destroy(); + throw cause; + } + const frames: CheckpointArtifactV2FrameSummary[] = []; + const components = emptyComponents(); + const expectedStarts = new Map(); + let lastTuple: { rank: number; field: string } | undefined; + let artifactBytes = 0; + let totalRecords = 0; + let totalDecodedBytes = 0; + let totalStoredBytes = 0; + + const flush = async ( + component: CheckpointArtifactV2Component, + field: string, + start: number, + records: EncodedRecord[] + ): Promise => { + if (records.length === 0) return; + assertNotCancelled(options.signal, "prefix"); + if (frames.length >= limits.maxFrames) { + throw error("resource_limit", "header", "checkpoint artifact exceeds maxFrames", frames.length + 1, limits.maxFrames, "count"); + } + const decoded = encodePayload(records); + const stored = gzipSync(decoded, { level: gzipLevel }); + if (stored.length > limits.maxStoredFrameBytes) { + if (records.length === 1) { + throw error("resource_limit", "payload", "checkpoint record cannot fit maxStoredFrameBytes", stored.length, limits.maxStoredFrameBytes, "bytes"); + } + const splitAt = Math.ceil(records.length / 2); + await flush(component, field, start, records.slice(0, splitAt)); + await flush( + component, + field, + checkedAdd(start, splitAt, "checkpoint frame start", "header", "count"), + records.slice(splitAt) + ); + return; + } + const nextStoredTotal = checkedAdd(totalStoredBytes, stored.length, "checkpoint stored bytes", "payload", "bytes"); + if (nextStoredTotal > limits.maxTotalStoredBytes) { + throw error("resource_limit", "payload", "checkpoint artifact exceeds maxTotalStoredBytes", nextStoredTotal, limits.maxTotalStoredBytes, "bytes"); + } + const header: DataFrameHeader = { + kind: "data", + version: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + sequence: frames.length, + component, + field, + start, + recordCount: records.length, + decodedBytes: decoded.length, + decodedSha256: sha256(decoded), + encoding: "gzip", + storedBytes: stored.length, + storedSha256: sha256(stored), + }; + const headerJson = compactJson(header, "header"); + const headerBytes = Buffer.byteLength(headerJson, "utf8"); + if (headerBytes > limits.maxHeaderBytes) { + throw error("resource_limit", "header", "checkpoint frame header exceeds maxHeaderBytes", headerBytes, limits.maxHeaderBytes, "bytes"); + } + const rawHeader = Buffer.from(headerJson, "utf8"); + artifactBytes = checkedAdd(artifactBytes, await writeWireFrame(destination, rawHeader, stored), "checkpoint artifact bytes", "payload", "bytes"); + const summary: CheckpointArtifactV2FrameSummary = { + sequence: header.sequence, + component, + field, + start, + recordCount: records.length, + headerBytes: rawHeader.length, + decodedBytes: decoded.length, + decodedSha256: header.decodedSha256, + storedBytes: stored.length, + storedSha256: header.storedSha256, + maxRecordBytes: records.reduce((maximum, record) => Math.max(maximum, record.recordBytes), 0), + }; + frames.push(summary); + const componentSummary = components[componentRank(component, "header")]; + componentSummary.frameCount += 1; + componentSummary.recordCount += summary.recordCount; + componentSummary.decodedBytes += summary.decodedBytes; + componentSummary.storedBytes += summary.storedBytes; + componentSummary.maxRecordBytes = Math.max(componentSummary.maxRecordBytes, summary.maxRecordBytes); + totalStoredBytes = nextStoredTotal; + }; + + try { + assertNotCancelled(options.signal, "magic"); + await writeChunk(destination, CHECKPOINT_ARTIFACT_V2_MAGIC); + artifactBytes = CHECKPOINT_ARTIFACT_V2_MAGIC.length; + + for await (const input of asAsync(inputs, options.signal, "prefix")) { + const rank = componentRank(input.component, "header"); + validateField(input.field, "header"); + if (!isSafeNonnegativeInteger(input.start)) { + throw error("unsupported", "header", "checkpoint record input start must be a safe nonnegative integer"); + } + const tuple = { rank, field: input.field }; + if (lastTuple && compareTuple(tuple, lastTuple) < 0) { + throw error("corrupt", "header", "checkpoint record inputs are not in component/field order"); + } + const key = `${input.component}\0${input.field}`; + const expectedStart = expectedStarts.get(key) ?? 0; + if (input.start !== expectedStart) { + throw error("corrupt", "header", `checkpoint record input for ${input.component}.${input.field} starts at ${input.start}, expected ${expectedStart}`); + } + lastTuple = tuple; + let frameStart = input.start; + let pending: EncodedRecord[] = []; + let pendingBytes = 0; + let inputCount = 0; + for await (const value of asAsync(input.records, options.signal, "record")) { + const record = encodedRecord(value, limits); + if (pending.length > 0 && (pending.length >= limits.maxRecordsPerFrame + || pendingBytes + record.decodedBytes > limits.maxDecodedFrameBytes)) { + await flush(input.component, input.field, frameStart, pending); + frameStart = checkedAdd(frameStart, pending.length, "checkpoint frame start", "header", "count"); + pending = []; + pendingBytes = 0; + } + const nextRecordCount = checkedAdd(totalRecords, 1, "checkpoint record count", "record", "count"); + if (nextRecordCount > limits.maxTotalRecords) { + throw error("resource_limit", "record", "checkpoint artifact exceeds maxTotalRecords", nextRecordCount, limits.maxTotalRecords, "count"); + } + const nextDecodedTotal = checkedAdd(totalDecodedBytes, record.decodedBytes, "checkpoint decoded bytes", "record", "bytes"); + if (nextDecodedTotal > limits.maxTotalDecodedBytes) { + throw error("resource_limit", "record", "checkpoint artifact exceeds maxTotalDecodedBytes", nextDecodedTotal, limits.maxTotalDecodedBytes, "bytes"); + } + pending.push(record); + pendingBytes += record.decodedBytes; + inputCount += 1; + totalRecords = nextRecordCount; + totalDecodedBytes = nextDecodedTotal; + } + await flush(input.component, input.field, frameStart, pending); + expectedStarts.set(key, checkedAdd(input.start, inputCount, "checkpoint field record count", "header", "count")); + } + + assertNotCancelled(options.signal, "index"); + finalizeComponentChecksums(components, frames); + const recordValue = indexRecord(frames, components); + const encodedIndexRecord = encodedRecord(recordValue, limits); + const decodedIndex = encodePayload([encodedIndexRecord]); + const storedIndex = gzipSync(decodedIndex, { level: gzipLevel }); + if (storedIndex.length > limits.maxStoredFrameBytes) { + throw error("resource_limit", "index", "checkpoint index exceeds maxStoredFrameBytes", storedIndex.length, limits.maxStoredFrameBytes, "bytes"); + } + const totalWithIndexDecoded = checkedAdd(totalDecodedBytes, decodedIndex.length, "checkpoint decoded bytes including index", "index", "bytes"); + if (totalWithIndexDecoded > limits.maxTotalDecodedBytes) { + throw error("resource_limit", "index", "checkpoint index exceeds maxTotalDecodedBytes", totalWithIndexDecoded, limits.maxTotalDecodedBytes, "bytes"); + } + const totalWithIndexStored = checkedAdd(totalStoredBytes, storedIndex.length, "checkpoint stored bytes including index", "index", "bytes"); + if (totalWithIndexStored > limits.maxTotalStoredBytes) { + throw error("resource_limit", "index", "checkpoint index exceeds maxTotalStoredBytes", totalWithIndexStored, limits.maxTotalStoredBytes, "bytes"); + } + const indexHeader: IndexFrameHeader = { + kind: "index", + version: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + sequence: frames.length, + recordCount: 1, + decodedBytes: decodedIndex.length, + decodedSha256: sha256(decodedIndex), + encoding: "gzip", + storedBytes: storedIndex.length, + storedSha256: sha256(storedIndex), + }; + const indexHeaderJson = compactJson(indexHeader, "index"); + const indexHeaderBytes = Buffer.byteLength(indexHeaderJson, "utf8"); + if (indexHeaderBytes > limits.maxHeaderBytes) { + throw error("resource_limit", "index", "checkpoint index header exceeds maxHeaderBytes", indexHeaderBytes, limits.maxHeaderBytes, "bytes"); + } + const rawIndexHeader = Buffer.from(indexHeaderJson, "utf8"); + artifactBytes = checkedAdd( + artifactBytes, + await writeWireFrame(destination, rawIndexHeader, storedIndex), + "checkpoint artifact bytes", + "index", + "bytes" + ); + if (endDestination) await endWritable(destination); + const index: CheckpointArtifactV2IndexSummary = { + sequence: indexHeader.sequence, + headerBytes: rawIndexHeader.length, + decodedBytes: decodedIndex.length, + decodedSha256: indexHeader.decodedSha256, + storedBytes: storedIndex.length, + storedSha256: indexHeader.storedSha256, + maxRecordBytes: encodedIndexRecord.recordBytes, + }; + return { + schemaVersion: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + artifactBytes, + data: dataTotals(frames), + frames, + components, + index, + }; + } catch (cause) { + if (endDestination && !destination.destroyed) destination.destroy(); + throw cause; + } +} + +class BoundedReadable { + private readonly iterator: AsyncIterator; + private readonly iteratorListeners: Array<{ + event: string | symbol; + listener: (...args: any[]) => void; + }> = []; + private readonly queue: Buffer[] = []; + private queueOffset = 0; + private queuedBytes = 0; + private ended = false; + private closed = false; + + constructor( + private readonly source: Readable, + private readonly signal: AbortSignal | undefined + ) { + this.iterator = source[Symbol.asyncIterator](); + } + + private async next(stage: CheckpointArtifactV2ErrorStage): Promise> { + assertNotCancelled(this.signal, stage); + const listenersBefore = new Map( + this.source.eventNames().map((event) => [event, this.source.rawListeners(event)]) + ); + const pending = this.iterator.next(); + for (const event of this.source.eventNames()) { + const existing = listenersBefore.get(event) ?? []; + for (const listener of this.source.rawListeners(event)) { + if (!existing.includes(listener) && !this.iteratorListeners.some((entry) => ( + entry.event === event && entry.listener === listener + ))) { + this.iteratorListeners.push({ event, listener }); + } + } + } + if (!this.signal) return await pending; + + let didAbort = false; + let onAbort!: () => void; + const interrupted = new Promise((_resolve, reject) => { + onAbort = () => { + if (didAbort) return; + didAbort = true; + // Interrupt an in-flight iterator.next() without emitting an unrelated + // stream error. The raced promise still observes its eventual result. + if (!this.source.destroyed) this.source.destroy(); + reject(cancellationError(stage)); + }; + this.signal!.addEventListener("abort", onAbort, { once: true }); + if (this.signal!.aborted) onAbort(); + }); + try { + return await Promise.race([pending, interrupted]); + } finally { + this.signal.removeEventListener("abort", onAbort); + } + } + + private async fill(bytes: number, stage: CheckpointArtifactV2ErrorStage): Promise { + while (this.queuedBytes < bytes && !this.ended) { + const next = await this.next(stage); + if (next.done) { + this.ended = true; + break; + } + const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value as Uint8Array); + if (chunk.length > 0) { + this.queue.push(chunk); + this.queuedBytes += chunk.length; + } + } + } + + async readExactly(bytes: number, stage: CheckpointArtifactV2ErrorStage): Promise { + await this.fill(bytes, stage); + if (this.queuedBytes < bytes) { + throw error("corrupt", stage, `checkpoint artifact ended with ${this.queuedBytes} of ${bytes} required bytes`); + } + const first = this.queue[0]; + const firstAvailable = first.length - this.queueOffset; + if (firstAvailable >= bytes) { + const result = first.subarray(this.queueOffset, this.queueOffset + bytes); + this.queueOffset += bytes; + this.queuedBytes -= bytes; + if (this.queueOffset === first.length) { + this.queue.shift(); + this.queueOffset = 0; + } + return result; + } + const result = Buffer.allocUnsafe(bytes); + let written = 0; + while (written < bytes) { + const chunk = this.queue[0]; + const available = chunk.length - this.queueOffset; + const take = Math.min(available, bytes - written); + chunk.copy(result, written, this.queueOffset, this.queueOffset + take); + written += take; + this.queueOffset += take; + this.queuedBytes -= take; + if (this.queueOffset === chunk.length) { + this.queue.shift(); + this.queueOffset = 0; + } + } + return result; + } + + async requireEof(): Promise { + await this.fill(1, "eof"); + if (this.queuedBytes !== 0) throw error("corrupt", "eof", "checkpoint artifact has trailing bytes after its terminal index"); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + if (!this.source.destroyed) this.source.destroy(); + try { + await this.iterator.return?.(); + } catch { + // The codec's parse/read error remains authoritative. Destroying the + // owned source above releases its backing descriptor and listeners. + } + for (const { event, listener } of this.iteratorListeners) { + this.source.removeListener(event, listener); + } + this.iteratorListeners.length = 0; + } +} + +function decodeUtf8(raw: Buffer, stage: CheckpointArtifactV2ErrorStage): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(raw); + } catch { + throw error("corrupt", stage, "checkpoint artifact contains invalid UTF-8"); + } +} + +function enforceJsonDepth( + text: string, + limits: CheckpointArtifactV2Limits, + stage: CheckpointArtifactV2ErrorStage +): void { + let depth = 0; + let inString = false; + let escaped = false; + for (let index = 0; index < text.length; index++) { + const character = text[index]; + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === "\"") inString = false; + continue; + } + if (character === "\"") { + inString = true; + continue; + } + if (character === "{" || character === "[") { + depth += 1; + if (depth > limits.maxJsonDepth) { + throw error( + "resource_limit", + stage, + "checkpoint JSON exceeds maxJsonDepth", + depth, + limits.maxJsonDepth, + "depth" + ); + } + } else if ((character === "}" || character === "]") && depth > 0) { + depth -= 1; + } + } +} + +function exactCompactJson( + value: unknown, + text: string, + stage: CheckpointArtifactV2ErrorStage +): boolean { + try { + return JSON.stringify(value) === text; + } catch { + throw error("corrupt", stage, "checkpoint JSON cannot be canonicalized within runtime bounds"); + } +} + +function parseJsonObject( + raw: Buffer, + stage: CheckpointArtifactV2ErrorStage, + limits: CheckpointArtifactV2Limits +): Record { + const text = decodeUtf8(raw, stage); + enforceJsonDepth(text, limits, stage); + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw error("corrupt", stage, "checkpoint artifact contains invalid JSON"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw error("corrupt", stage, "checkpoint artifact header/index must be a JSON object"); + } + if (!exactCompactJson(value, text, stage)) { + throw error("corrupt", stage, "checkpoint artifact JSON is not in exact compact schema order"); + } + return value as Record; +} + +function validateCommonHeader( + value: Record, + prefixStoredBytes: number, + stage: CheckpointArtifactV2ErrorStage +): void { + if (typeof value.version === "number" && value.version !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION) { + throw error("unsupported", stage, `unsupported checkpoint frame version ${value.version}`); + } + if (value.version !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION + || !isSafeNonnegativeInteger(value.sequence) + || !isSafeNonnegativeInteger(value.recordCount) + || !isSafeNonnegativeInteger(value.decodedBytes) + || !isSafeNonnegativeInteger(value.storedBytes) + || typeof value.decodedSha256 !== "string" || !SHA256_PATTERN.test(value.decodedSha256) + || typeof value.storedSha256 !== "string" || !SHA256_PATTERN.test(value.storedSha256)) { + throw error("corrupt", stage, "checkpoint frame header has invalid required fields"); + } + if (value.encoding !== "gzip") { + throw error(typeof value.encoding === "string" ? "unsupported" : "corrupt", stage, "checkpoint frame encoding must be gzip"); + } + if (value.storedBytes !== prefixStoredBytes) { + throw error("corrupt", stage, "checkpoint stored length prefix does not match its header"); + } +} + +function parseHeader( + raw: Buffer, + prefixStoredBytes: number, + limits: CheckpointArtifactV2Limits +): DataFrameHeader | IndexFrameHeader { + const value = parseJsonObject(raw, "header", limits); + if (typeof value.version === "number" && value.version !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION) { + throw error("unsupported", "header", `unsupported checkpoint frame version ${value.version}`); + } + if (value.kind === "data") { + if (!exactKeys(value, DATA_HEADER_KEYS)) throw error("corrupt", "header", "checkpoint data header fields/order do not match schema v2"); + validateCommonHeader(value, prefixStoredBytes, "header"); + componentRank(value.component, "header"); + validateField(value.field, "header"); + if (!isSafeNonnegativeInteger(value.start) || value.recordCount === 0 || value.decodedBytes === 0 || value.storedBytes === 0) { + throw error("corrupt", "header", "checkpoint data header counts must be positive and start must be nonnegative"); + } + return value as unknown as DataFrameHeader; + } + if (value.kind === "index") { + if (!exactKeys(value, INDEX_HEADER_KEYS)) throw error("corrupt", "header", "checkpoint index header fields/order do not match schema v2"); + validateCommonHeader(value, prefixStoredBytes, "index"); + if (value.recordCount !== 1 || value.decodedBytes === 0 || value.storedBytes === 0) { + throw error("corrupt", "index", "checkpoint index must contain exactly one nonempty record"); + } + return value as unknown as IndexFrameHeader; + } + throw error("unsupported", "header", `unsupported checkpoint frame kind ${String(value.kind)}`); +} + +async function gunzipBounded( + stored: Buffer, + declaredBytes: number, + limitBytes: number +): Promise { + return await new Promise((resolve, reject) => { + const decoder = createGunzip(); + const chunks: Buffer[] = []; + let bytes = 0; + decoder.on("data", (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > limitBytes) { + decoder.destroy(error("resource_limit", "payload", "decoded checkpoint frame exceeds its configured bound", bytes, limitBytes, "bytes")); + return; + } + if (bytes > declaredBytes) { + decoder.destroy(error("corrupt", "payload", "decoded checkpoint frame exceeds its declared length", bytes, declaredBytes, "bytes")); + return; + } + chunks.push(chunk); + }); + decoder.once("error", (cause: Error) => { + reject(cause instanceof CheckpointArtifactV2Error + ? cause + : error("corrupt", "payload", `checkpoint gzip frame cannot be decoded: ${cause.message}`)); + }); + decoder.once("end", () => { + if (bytes !== declaredBytes) { + reject(error("corrupt", "payload", "decoded checkpoint frame length does not match its header", bytes, declaredBytes, "bytes")); + return; + } + resolve(Buffer.concat(chunks, bytes)); + }); + decoder.end(stored); + }); +} + +async function decodeRecords( + decoded: Buffer, + expectedCount: number, + limits: CheckpointArtifactV2Limits, + callback?: (value: unknown, offset: number, recordBytes: number) => void | Promise, + collectValues = false +): Promise<{ values: unknown[]; maxRecordBytes: number }> { + const values: unknown[] = []; + let offset = 0; + let count = 0; + let maxRecordBytes = 0; + while (offset < decoded.length) { + if (decoded.length - offset < 4) throw error("corrupt", "record", "checkpoint frame ends inside a record-length prefix"); + const recordBytes = decoded.readUInt32BE(offset); + offset += 4; + if (recordBytes > limits.maxRecordBytes) { + throw error("resource_limit", "record", "checkpoint record exceeds maxRecordBytes", recordBytes, limits.maxRecordBytes, "bytes"); + } + if (recordBytes > decoded.length - offset) throw error("corrupt", "record", "checkpoint record length exceeds its decoded frame"); + count += 1; + if (count > limits.maxRecordsPerFrame) { + throw error("resource_limit", "record", "checkpoint frame exceeds maxRecordsPerFrame", count, limits.maxRecordsPerFrame, "count"); + } + const raw = decoded.subarray(offset, offset + recordBytes); + offset += recordBytes; + const text = decodeUtf8(raw, "record"); + enforceJsonDepth(text, limits, "record"); + let value: unknown; + try { + value = JSON.parse(text); + } catch { + throw error("corrupt", "record", "checkpoint frame contains invalid record JSON"); + } + if (!exactCompactJson(value, text, "record")) { + throw error("corrupt", "record", "checkpoint record JSON is not compact canonical JSON"); + } + maxRecordBytes = Math.max(maxRecordBytes, recordBytes); + if (collectValues) values.push(value); + if (callback) await callback(value, count - 1, recordBytes); + } + if (count !== expectedCount) throw error("corrupt", "record", "checkpoint frame record count does not match its header", count, expectedCount, "count"); + return { values, maxRecordBytes }; +} + +function expectedIndexValue( + frames: CheckpointArtifactV2FrameSummary[], + components: CheckpointArtifactV2ComponentSummary[] +): ArtifactIndexRecord { + return indexRecord(frames, components); +} + +export async function readCheckpointArtifactV2( + source: Readable, + options: CheckpointArtifactV2ReadOptions +): Promise { + const reader = new BoundedReadable(source, options.signal); + try { + const limits = effectiveLimits(options.limits); + const frames: CheckpointArtifactV2FrameSummary[] = []; + const components = emptyComponents(); + const expectedStarts = new Map(); + let lastTuple: { rank: number; field: string } | undefined; + let artifactBytes = 0; + let totalRecords = 0; + let totalDecodedBytes = 0; + let totalStoredBytes = 0; + + assertNotCancelled(options.signal, "magic"); + const magic = await reader.readExactly(CHECKPOINT_ARTIFACT_V2_MAGIC.length, "magic"); + artifactBytes = checkedAdd(artifactBytes, magic.length, "checkpoint artifact bytes", "magic", "bytes"); + if (!magic.equals(CHECKPOINT_ARTIFACT_V2_MAGIC)) { + const versioned = CHECKPOINT_ARTIFACT_VERSIONED_MAGIC_PROBE_PATTERN.exec(magic.toString("ascii")); + if (versioned) { + throw error("unsupported", "magic", `unsupported checkpoint artifact framing version ${versioned[1]}`); + } + throw error("corrupt", "magic", "checkpoint artifact has invalid framed-v2 magic bytes"); + } + + while (true) { + assertNotCancelled(options.signal, "prefix"); + const prefix = await reader.readExactly(8, "prefix"); + artifactBytes = checkedAdd(artifactBytes, prefix.length, "checkpoint artifact bytes", "prefix", "bytes"); + const headerBytes = prefix.readUInt32BE(0); + const storedBytes = prefix.readUInt32BE(4); + if (headerBytes === 0) { + throw error("corrupt", "header", "checkpoint frame header length must be nonzero"); + } + if (headerBytes > limits.maxHeaderBytes) { + throw error("resource_limit", "header", "checkpoint frame header exceeds maxHeaderBytes", headerBytes, limits.maxHeaderBytes, "bytes"); + } + if (storedBytes === 0) { + throw error("corrupt", "payload", "checkpoint frame stored length must be nonzero"); + } + if (storedBytes > limits.maxStoredFrameBytes) { + throw error("resource_limit", "payload", "checkpoint frame exceeds maxStoredFrameBytes", storedBytes, limits.maxStoredFrameBytes, "bytes"); + } + const rawHeader = await reader.readExactly(headerBytes, "header"); + artifactBytes = checkedAdd(artifactBytes, rawHeader.length, "checkpoint artifact bytes", "header", "bytes"); + const header = parseHeader(rawHeader, storedBytes, limits); + if (header.sequence !== frames.length) { + throw error("corrupt", "header", `checkpoint frame sequence ${header.sequence} does not match expected ${frames.length}`); + } + if (header.decodedBytes > limits.maxDecodedFrameBytes) { + throw error("resource_limit", "header", "checkpoint frame exceeds maxDecodedFrameBytes", header.decodedBytes, limits.maxDecodedFrameBytes, "bytes"); + } + const nextStoredTotal = checkedAdd(totalStoredBytes, header.storedBytes, "checkpoint stored bytes", "header", "bytes"); + if (nextStoredTotal > limits.maxTotalStoredBytes) { + throw error("resource_limit", "header", "checkpoint artifact exceeds maxTotalStoredBytes", nextStoredTotal, limits.maxTotalStoredBytes, "bytes"); + } + const nextDecodedTotal = checkedAdd(totalDecodedBytes, header.decodedBytes, "checkpoint decoded bytes", "header", "bytes"); + if (nextDecodedTotal > limits.maxTotalDecodedBytes) { + throw error("resource_limit", "header", "checkpoint artifact exceeds maxTotalDecodedBytes", nextDecodedTotal, limits.maxTotalDecodedBytes, "bytes"); + } + if (header.kind === "data") { + if (frames.length >= limits.maxFrames) { + throw error("resource_limit", "header", "checkpoint artifact exceeds maxFrames", frames.length + 1, limits.maxFrames, "count"); + } + if (header.recordCount > limits.maxRecordsPerFrame) { + throw error("resource_limit", "header", "checkpoint frame exceeds maxRecordsPerFrame", header.recordCount, limits.maxRecordsPerFrame, "count"); + } + const nextRecords = checkedAdd(totalRecords, header.recordCount, "checkpoint record count", "header", "count"); + if (nextRecords > limits.maxTotalRecords) { + throw error("resource_limit", "header", "checkpoint artifact exceeds maxTotalRecords", nextRecords, limits.maxTotalRecords, "count"); + } + const rank = componentRank(header.component, "header"); + const tuple = { rank, field: header.field }; + if (lastTuple && compareTuple(tuple, lastTuple) < 0) { + throw error("corrupt", "header", "checkpoint frames are not in component/field order"); + } + const key = `${header.component}\0${header.field}`; + const expectedStart = expectedStarts.get(key) ?? 0; + if (header.start !== expectedStart) { + throw error("corrupt", "header", `checkpoint frame for ${header.component}.${header.field} starts at ${header.start}, expected ${expectedStart}`); + } + const stored = await reader.readExactly(storedBytes, "payload"); + artifactBytes = checkedAdd(artifactBytes, stored.length, "checkpoint artifact bytes", "payload", "bytes"); + if (sha256(stored) !== header.storedSha256) throw error("corrupt", "payload", "checkpoint stored frame checksum does not match its header"); + const decoded = await gunzipBounded(stored, header.decodedBytes, limits.maxDecodedFrameBytes); + if (sha256(decoded) !== header.decodedSha256) throw error("corrupt", "payload", "checkpoint decoded frame checksum does not match its header"); + const decodedRecords = await decodeRecords(decoded, header.recordCount, limits, async (value, offset) => { + await options.onRecord({ + component: header.component, + field: header.field, + index: checkedAdd(header.start, offset, "checkpoint record index", "record", "count"), + value, + }); + }); + const summary: CheckpointArtifactV2FrameSummary = { + sequence: header.sequence, + component: header.component, + field: header.field, + start: header.start, + recordCount: header.recordCount, + headerBytes, + decodedBytes: header.decodedBytes, + decodedSha256: header.decodedSha256, + storedBytes: header.storedBytes, + storedSha256: header.storedSha256, + maxRecordBytes: decodedRecords.maxRecordBytes, + }; + frames.push(summary); + const component = components[rank]; + component.frameCount += 1; + component.recordCount += summary.recordCount; + component.decodedBytes += summary.decodedBytes; + component.storedBytes += summary.storedBytes; + component.maxRecordBytes = Math.max(component.maxRecordBytes, summary.maxRecordBytes); + expectedStarts.set( + key, + checkedAdd(header.start, header.recordCount, "checkpoint field record count", "header", "count") + ); + lastTuple = tuple; + totalRecords = nextRecords; + totalDecodedBytes = nextDecodedTotal; + totalStoredBytes = nextStoredTotal; + continue; + } + + const stored = await reader.readExactly(storedBytes, "index"); + artifactBytes = checkedAdd(artifactBytes, stored.length, "checkpoint artifact bytes", "index", "bytes"); + if (sha256(stored) !== header.storedSha256) throw error("corrupt", "index", "checkpoint stored index checksum does not match its header"); + const decoded = await gunzipBounded(stored, header.decodedBytes, limits.maxDecodedFrameBytes); + if (sha256(decoded) !== header.decodedSha256) throw error("corrupt", "index", "checkpoint decoded index checksum does not match its header"); + const decodedIndex = await decodeRecords(decoded, 1, limits, undefined, true); + const value = decodedIndex.values[0]; + if (!value || typeof value !== "object" || Array.isArray(value)) throw error("corrupt", "index", "checkpoint index record must be an object"); + const record = value as Record; + if (typeof record.version === "number" && record.version !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION) { + throw error("unsupported", "index", `unsupported checkpoint index version ${record.version}`); + } + if (!exactKeys(record, INDEX_RECORD_KEYS)) throw error("corrupt", "index", "checkpoint index record fields/order do not match schema v2"); + finalizeComponentChecksums(components, frames); + const expected = expectedIndexValue(frames, components); + if (!exactCompactJson(record, JSON.stringify(expected), "index")) { + throw error("corrupt", "index", "checkpoint terminal index does not match observed frame/component totals"); + } + await reader.requireEof(); + const index: CheckpointArtifactV2IndexSummary = { + sequence: header.sequence, + headerBytes, + decodedBytes: header.decodedBytes, + decodedSha256: header.decodedSha256, + storedBytes: header.storedBytes, + storedSha256: header.storedSha256, + maxRecordBytes: decodedIndex.maxRecordBytes, + }; + return { + schemaVersion: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + artifactBytes, + data: dataTotals(frames), + frames, + components, + index, + }; + } + } finally { + await reader.close(); + } +} diff --git a/src/checkpoints.ts b/src/checkpoints.ts index 735bc3f..0544861 100644 --- a/src/checkpoints.ts +++ b/src/checkpoints.ts @@ -5,6 +5,16 @@ import * as path from "path"; import { Readable, Transform } from "stream"; import { pipeline } from "stream/promises"; import { createGzip, gunzipSync } from "zlib"; +import { + CheckpointArtifactV2Error, + DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS, + type CheckpointArtifactV2FrameInput, + type CheckpointArtifactV2Limits, + type CheckpointArtifactV2ReadResult, + type CheckpointArtifactV2WriteResult, + readCheckpointArtifactV2, + writeCheckpointArtifactV2, +} from "./checkpoint-artifact-v2"; import { compile, scanKnots } from "./inklecate"; import { SHARED_SEARCH_CHECKPOINT_SCHEMA_VERSION, @@ -13,10 +23,12 @@ import { import { VERSION } from "./version"; export const CHECKPOINT_ARTIFACT_SCHEMA_VERSION = 1; +export const CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION = 2; export const DEFAULT_MAX_CHECKPOINT_BYTES = 512 * 1024 * 1024; export const DEFAULT_MAX_PROJECT_CHECKPOINT_BYTES = 1024 * 1024 * 1024; export const DEFAULT_CHECKPOINT_GENERATIONS = 3; export const CHECKPOINT_MANIFEST_SCHEMA_VERSION = 1; +export const CHECKPOINT_MANIFEST_V2_SCHEMA_VERSION = 2; export const DEFAULT_MAX_STORED_CHECKPOINT_READ_BYTES = DEFAULT_MAX_CHECKPOINT_BYTES; export const DEFAULT_MAX_DECOMPRESSED_CHECKPOINT_READ_BYTES = Math.min( DEFAULT_MAX_CHECKPOINT_BYTES, @@ -28,6 +40,8 @@ const MAX_CHECKPOINT_RECOVERY_MANIFESTS = 32; const CHECKPOINT_RECOVERY_SLOT_WIDTH = String(MAX_CHECKPOINT_RECOVERY_MANIFESTS - 1).length; export type CheckpointFreshness = "current" | "stale" | "path_changed"; +export type CheckpointArtifactFormat = "legacy-v1" | "framed-v2"; +type CheckpointStorageEncoding = "json" | "gzip" | "framed-v2"; export interface CheckpointWriteAccountingV1 { schemaVersion: 1; @@ -68,6 +82,11 @@ export interface CheckpointWriteAccountingV1 { storedBytesEmitted: 0; peakOutputChunkBytes: 0; }; + /** Present only for a framed-v2 write; legacy monolithic owners stay not_applied. */ + framedV2?: { + status: "applied"; + codec: CheckpointArtifactV2AccountingSummary; + }; durable: { payloadBytes: number; manifestBytes: number; @@ -85,6 +104,8 @@ export interface CheckpointArtifactReference { export interface CheckpointArtifactSummary { id: string; path: string; + /** Persisted artifact-envelope schema; independent of list/show response schemas. */ + artifactSchemaVersion: 1 | 2; artifactType: "shared-search-checkpoint"; createdAt: string; inkcheckVersion: string; @@ -96,24 +117,42 @@ export interface CheckpointArtifactSummary { payloadSizeBytes: number; metadataSizeBytes: number; sizeBytes: number; - storageEncoding: "json" | "gzip"; + storageEncoding: CheckpointStorageEncoding; + /** Privacy-safe fixed component totals projected from the bounded v2 sidecar. */ + framedV2?: CheckpointArtifactV2AccountingSummary; } export interface CheckpointStorageLimits { maxCheckpointBytes?: number; maxProjectBytes?: number; maxGenerationsPerEntrypoint?: number; + /** Storage layout only. The default remains the byte-compatible gzip v1 artifact. */ + format?: CheckpointArtifactFormat; + /** Cancellation is honored only while the payload is still private. */ + signal?: AbortSignal; + framedV2Limits?: Partial; } export interface CheckpointReadLimits { maxStoredBytes?: number; maxDecompressedBytes?: number; + signal?: AbortSignal; + framedV2Limits?: Partial; } +type CheckpointByteStorageLimits = Required>; +type CheckpointByteReadLimits = Required>; + export interface CheckpointReadAccountingV1 { schemaVersion: 1; status: "completed" | "failed"; - storageEncoding: "json" | "gzip"; + storageEncoding: CheckpointStorageEncoding; /** Effective safety limits. These values describe bounds, not allocations. */ configuredLimits: { basis: "configured_limits_not_allocated_capacity"; @@ -129,7 +168,7 @@ export interface CheckpointReadAccountingV1 { validationString: { count: 0 | 1; logicalUtf8Bytes: number }; }; storedPayload: { - status: "applied" | "not_completed"; + status: "applied" | "not_applied" | "not_completed"; count: 0 | 1; bytes: number; }; @@ -139,12 +178,12 @@ export interface CheckpointReadAccountingV1 { bytes: number; }; rawArtifactString: { - status: "applied" | "not_completed"; + status: "applied" | "not_applied" | "not_completed"; count: 0 | 1; logicalUtf8Bytes: number; }; parsedArtifactGraph: { - status: "applied" | "not_completed"; + status: "applied" | "not_applied" | "not_completed"; count: 0 | 1; /** UTF-8 bytes in the JSON source from which this graph was parsed. */ sourceLogicalUtf8Bytes: number; @@ -175,6 +214,11 @@ export interface CheckpointReadAccountingV1 { count: 0 | 1; logicalUtf8Bytes: number; }; + /** Present only for a framed-v2 read; no whole stored/decompressed/raw owner is claimed. */ + framedV2?: { + status: "applied"; + codec: CheckpointArtifactV2AccountingSummary; + }; failure?: { kind: CheckpointReadErrorKind; stage: CheckpointReadStage; @@ -183,26 +227,42 @@ export interface CheckpointReadAccountingV1 { export type CheckpointReadErrorKind = "corrupt" | "resource_limit" | "unsupported"; export type CheckpointReadStage = "manifest" | "storage" | "decompression" | "json" | "envelope"; +export type CheckpointReadLimitUnit = "bytes" | "count" | "depth"; export class CheckpointReadError extends Error { payloadVerified = false; /** Partial read-accounting receipt when the error came from open/resume. */ accounting?: CheckpointReadAccountingV1; + /** Generic resource-limit metric. Present only when the failure reports a bound. */ + readonly observed?: number; + readonly limit?: number; + readonly unit?: CheckpointReadLimitUnit; + /** Backwards-compatible byte aliases; absent for count and depth limits. */ + readonly observedBytes?: number; + readonly limitBytes?: number; constructor( public readonly kind: CheckpointReadErrorKind, public readonly stage: CheckpointReadStage, message: string, - public readonly observedBytes?: number, - public readonly limitBytes?: number + observed?: number, + limit?: number, + unit?: CheckpointReadLimitUnit ) { super(message); this.name = "CheckpointReadError"; + this.observed = observed; + this.limit = limit; + this.unit = observed === undefined && limit === undefined ? undefined : (unit ?? "bytes"); + if (this.unit === "bytes") { + this.observedBytes = observed; + this.limitBytes = limit; + } } } interface CheckpointArtifact { - artifactSchemaVersion: 1; + artifactSchemaVersion: 1 | 2; artifactType: "shared-search-checkpoint"; id: string; createdAt: string; @@ -215,9 +275,514 @@ interface CheckpointArtifact { checkpoint: SharedSearchCheckpoint; } +const CHECKPOINT_V2_COMPONENT_ORDER = [ + "configuration", "scheduler", "frontier", "witnessAncestry", "dedupe", + "semanticIndexes", "findings", "metadata", +] as const; + +export interface CheckpointArtifactV2ManifestSummary { + schemaVersion: 2; + componentOrder: typeof CHECKPOINT_V2_COMPONENT_ORDER; + artifactBytes: number; + artifactOverheadBytes: number; + data: CheckpointArtifactV2WriteResult["data"]; + components: CheckpointArtifactV2WriteResult["components"]; + index: CheckpointArtifactV2WriteResult["index"]; +} + +export interface CheckpointArtifactV2AccountingSummary { + schemaVersion: 2; + componentOrder: typeof CHECKPOINT_V2_COMPONENT_ORDER; + artifactBytes: number; + artifactOverheadBytes: number; + data: CheckpointArtifactV2WriteResult["data"]; + components: Array<{ + component: CheckpointArtifactV2WriteResult["components"][number]["component"]; + frameCount: number; + recordCount: number; + decodedBytes: number; + storedBytes: number; + maxRecordBytes: number; + }>; + index: { + sequence: number; + headerBytes: number; + decodedBytes: number; + storedBytes: number; + maxRecordBytes: number; + }; +} + +function checkpointArtifactV2Summary( + result: CheckpointArtifactV2WriteResult | CheckpointArtifactV2ReadResult +): CheckpointArtifactV2ManifestSummary { + const artifactOverheadBytes = result.artifactBytes - result.data.storedBytes - result.index.storedBytes; + if (!Number.isSafeInteger(artifactOverheadBytes) || artifactOverheadBytes < 0) { + throw corrupt("storage", "framed checkpoint codec returned inconsistent artifact overhead"); + } + return { + schemaVersion: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + componentOrder: CHECKPOINT_V2_COMPONENT_ORDER, + artifactBytes: result.artifactBytes, + artifactOverheadBytes, + data: result.data, + components: result.components, + index: result.index, + }; +} + +function checkpointArtifactV2AccountingSummary( + summary: CheckpointArtifactV2ManifestSummary +): CheckpointArtifactV2AccountingSummary { + return { + schemaVersion: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + componentOrder: CHECKPOINT_V2_COMPONENT_ORDER, + artifactBytes: summary.artifactBytes, + artifactOverheadBytes: summary.artifactOverheadBytes, + data: summary.data, + components: summary.components.map((component) => ({ + component: component.component, + frameCount: component.frameCount, + recordCount: component.recordCount, + decodedBytes: component.decodedBytes, + storedBytes: component.storedBytes, + maxRecordBytes: component.maxRecordBytes, + })), + index: { + sequence: summary.index.sequence, + headerBytes: summary.index.headerBytes, + decodedBytes: summary.index.decodedBytes, + storedBytes: summary.index.storedBytes, + maxRecordBytes: summary.index.maxRecordBytes, + }, + }; +} + +const CHECKPOINT_V2_STATE_COMPONENT_FIELDS = [ + { + component: "scheduler", + fields: ["current", "deep", "insertionOrder", "novelty", "policyCursor", "random", "rngState"], + }, + { + component: "frontier", + fields: [ + "activeStateBytes", "activeVariableBytes", "pendingBytes", "pendingStates", + "pendingVariableBytes", "peakPendingBytes", "peakPendingStates", "retainedNodes", + ], + }, + { + component: "witnessAncestry", + fields: ["ancestryPayloadBytes"], + }, + { + component: "dedupe", + fields: ["dedupeBytes", "dedupeHits", "seenChoiceSets", "seenStates"], + }, + { + component: "semanticIndexes", + fields: [ + "meaningfulVariableTransitions", "semanticIndexBytes", "variableStateCounts", + "variableTransitionCounts", "visitedKnots", + ], + }, + { + component: "findings", + fields: [ + "discoveryCurve", "endings", "findingBytes", "lastDiscoveryAtState", "runtimeErrors", + "runtimeWarnings", "sharedObservability", "visibleOutcomes", + ], + }, + { + component: "metadata", + fields: [ + "deepestStateDiscovered", "finished", "frontierCompactions", "guardChecksSinceLast", + "maxDepthReached", "ownerAccounting", "peakRetainedMemory", "releasedNodes", + "statesExplored", "totalGranted", "truncated", "truncatedBy", + ], + }, +] as const satisfies ReadonlyArray<{ + component: CheckpointArtifactV2FrameInput["component"]; + fields: readonly (keyof SharedSearchCheckpoint["state"])[]; +}>; + +const CHECKPOINT_V2_ARRAY_STATE_FIELDS = new Set([ + "deep", "endings", "novelty", "random", "runtimeErrors", "runtimeWarnings", "seenChoiceSets", + "seenStates", "variableStateCounts", "variableTransitionCounts", "visibleOutcomes", "visitedKnots", +]); + +function *checkpointV2NodePayloadRecords( + nodes: SharedSearchCheckpoint["state"]["nodes"] +): Generator { + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]; + if (node === null) continue; + yield { + index, + ...(node.stateJson === undefined ? {} : { stateJson: node.stateJson }), + ...(node.variables === undefined ? {} : { variables: node.variables }), + }; + } +} + +function *checkpointV2NodeAncestryRecords( + nodes: SharedSearchCheckpoint["state"]["nodes"] +): Generator { + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]; + if (node === null) continue; + yield { + index, + parent: node.parent, + ...(node.choiceText === undefined ? {} : { choiceText: node.choiceText }), + ...(node.choiceIndex === undefined ? {} : { choiceIndex: node.choiceIndex }), + depth: node.depth, + active: node.active, + childRefs: node.childRefs, + stateBytes: node.stateBytes, + variableBytes: node.variableBytes, + ancestryBytes: node.ancestryBytes, + }; + } +} + +function checkpointArtifactV2Frames( + artifact: CheckpointArtifact +): CheckpointArtifactV2FrameInput[] { + const state = artifact.checkpoint.state; + const frames: CheckpointArtifactV2FrameInput[] = [{ + component: "configuration", + field: "checkpoint", + start: 0, + records: [{ + schemaVersion: artifact.checkpoint.schemaVersion, + engine: artifact.checkpoint.engine, + configuration: artifact.checkpoint.configuration, + }], + }]; + for (const spec of CHECKPOINT_V2_STATE_COMPONENT_FIELDS) { + for (const field of spec.fields) { + const value = state[field]; + if (value === undefined) continue; + frames.push({ + component: spec.component, + field, + start: 0, + records: CHECKPOINT_V2_ARRAY_STATE_FIELDS.has(field) + ? value as Iterable + : [value], + }); + } + if (spec.component === "frontier") { + frames.push({ + component: "frontier", + field: "nodePayload", + start: 0, + records: checkpointV2NodePayloadRecords(state.nodes), + }); + } + if (spec.component === "witnessAncestry") { + frames.push({ + component: "witnessAncestry", + field: "nodeSlots", + start: 0, + records: [state.nodes.length], + }, { + component: "witnessAncestry", + field: "nodes", + start: 0, + records: checkpointV2NodeAncestryRecords(state.nodes), + }); + } + if (spec.component === "metadata") { + frames.push({ + component: "metadata", + field: "artifact", + start: 0, + records: [{ + artifactSchemaVersion: artifact.artifactSchemaVersion, + artifactType: artifact.artifactType, + id: artifact.id, + createdAt: artifact.createdAt, + inkcheckVersion: artifact.inkcheckVersion, + checkpointSchemaVersion: artifact.checkpointSchemaVersion, + source: artifact.source, + storySha256: artifact.storySha256, + knotsSha256: artifact.knotsSha256, + }], + }); + } + } + return frames.sort((left, right) => { + return CHECKPOINT_V2_COMPONENT_ORDER.indexOf(left.component) + - CHECKPOINT_V2_COMPONENT_ORDER.indexOf(right.component) + || (left.field < right.field ? -1 : left.field > right.field ? 1 : 0); + }); +} + +type CheckpointV2Record = { + component: CheckpointArtifactV2FrameInput["component"]; + field: string; + index: number; + value: unknown; +}; + +const CHECKPOINT_V2_SPECIAL_FIELDS = new Set([ + "configuration\0checkpoint", + "frontier\0nodePayload", + "witnessAncestry\0nodeSlots", + "witnessAncestry\0nodes", + "metadata\0artifact", +]); +const CHECKPOINT_V2_STATE_FIELDS = new Map(); +for (const spec of CHECKPOINT_V2_STATE_COMPONENT_FIELDS) { + for (const field of spec.fields) { + CHECKPOINT_V2_STATE_FIELDS.set(`${spec.component}\0${field}`, field); + } +} + +function checkpointV2Object( + value: unknown, + required: readonly string[], + optional: readonly string[], + description: string +): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw corrupt("envelope", `${description} must be an object`); + } + const record = value as Record; + const allowed = new Set([...required, ...optional]); + if (Object.keys(record).some((key) => !allowed.has(key)) + || required.some((key) => !Object.hasOwn(record, key))) { + throw corrupt("envelope", `${description} fields do not match the framed-v2 schema`); + } + return record; +} + +class CheckpointArtifactV2Assembly { + private readonly records = new Map(); + + constructor(private readonly maxNodeSlots: number) {} + + add(record: CheckpointV2Record): void { + const key = `${record.component}\0${record.field}`; + const stateField = CHECKPOINT_V2_STATE_FIELDS.get(key); + if (!stateField && !CHECKPOINT_V2_SPECIAL_FIELDS.has(key)) { + throw corrupt("envelope", `framed checkpoint contains unknown ${record.component}.${record.field} records`); + } + const values = this.records.get(key) ?? []; + if (record.index !== values.length) { + throw corrupt("envelope", `framed checkpoint ${record.component}.${record.field} record indexes are not contiguous`); + } + const scalar = key === "configuration\0checkpoint" + || key === "witnessAncestry\0nodeSlots" + || key === "metadata\0artifact" + || (stateField !== undefined && !CHECKPOINT_V2_ARRAY_STATE_FIELDS.has(stateField)); + if (scalar && values.length > 0) { + throw corrupt("envelope", `framed checkpoint ${record.component}.${record.field} has duplicate scalar records`); + } + values.push(record.value); + this.records.set(key, values); + } + + private values(component: string, field: string): unknown[] { + return this.records.get(`${component}\0${field}`) ?? []; + } + + private one(component: string, field: string, optional = false): unknown { + const values = this.values(component, field); + if (values.length === 0 && optional) return undefined; + if (values.length !== 1) { + throw corrupt("envelope", `framed checkpoint ${component}.${field} must contain exactly one record`); + } + return values[0]; + } + + artifact(expectedId: string, accounting: CheckpointReadAccountingV1): CheckpointArtifact { + const header = checkpointV2Object( + this.one("configuration", "checkpoint"), + ["schemaVersion", "engine", "configuration"], + [], + "framed checkpoint configuration header" + ); + if (header.schemaVersion !== SHARED_SEARCH_CHECKPOINT_SCHEMA_VERSION) { + throw unsupported( + "envelope", + `unsupported shared checkpoint schema ${String(header.schemaVersion)}; use a compatible Inkcheck version or migrate the checkpoint` + ); + } + const stateValue = ( + component: string, + field: K, + optional = false + ): SharedSearchCheckpoint["state"][K] => { + if (CHECKPOINT_V2_ARRAY_STATE_FIELDS.has(field)) { + return this.values(component, field) as SharedSearchCheckpoint["state"][K]; + } + return this.one(component, field, optional) as SharedSearchCheckpoint["state"][K]; + }; + + const nodeSlots = this.one("witnessAncestry", "nodeSlots"); + if (!Number.isSafeInteger(nodeSlots) || (nodeSlots as number) < 0) { + throw corrupt("envelope", "framed checkpoint node-slot count must be a non-negative safe integer"); + } + if ((nodeSlots as number) > this.maxNodeSlots) { + throw resourceLimit( + "envelope", + `framed checkpoint node-slot count ${String(nodeSlots)} exceeds its ${this.maxNodeSlots}-slot bound`, + nodeSlots as number, + this.maxNodeSlots, + "count" + ); + } + const nodes: SharedSearchCheckpoint["state"]["nodes"] = Array(nodeSlots as number).fill(null); + const payloadByIndex = new Map>(); + for (const value of this.values("frontier", "nodePayload")) { + const payload = checkpointV2Object( + value, + ["index"], + ["stateJson", "variables"], + "framed checkpoint node payload" + ); + if (!Number.isSafeInteger(payload.index) || (payload.index as number) < 0 + || (payload.index as number) >= nodes.length || payloadByIndex.has(payload.index as number)) { + throw corrupt("envelope", "framed checkpoint node payload index is invalid or duplicated"); + } + payloadByIndex.set(payload.index as number, payload); + } + const ancestryIndexes = new Set(); + for (const value of this.values("witnessAncestry", "nodes")) { + const ancestry = checkpointV2Object( + value, + [ + "index", "parent", "depth", "active", "childRefs", "stateBytes", "variableBytes", + "ancestryBytes", + ], + ["choiceText", "choiceIndex"], + "framed checkpoint node ancestry" + ); + const index = ancestry.index; + if (!Number.isSafeInteger(index) || (index as number) < 0 || (index as number) >= nodes.length + || ancestryIndexes.has(index as number)) { + throw corrupt("envelope", "framed checkpoint node ancestry index is invalid or duplicated"); + } + const payload = payloadByIndex.get(index as number); + if (!payload) { + throw corrupt("envelope", "framed checkpoint node ancestry is missing its split payload record"); + } + ancestryIndexes.add(index as number); + nodes[index as number] = { + ...(Object.hasOwn(payload, "stateJson") ? { stateJson: payload.stateJson as string } : {}), + ...(Object.hasOwn(payload, "variables") + ? { variables: payload.variables as Record } + : {}), + parent: ancestry.parent as number | null, + ...(Object.hasOwn(ancestry, "choiceText") ? { choiceText: ancestry.choiceText as string } : {}), + ...(Object.hasOwn(ancestry, "choiceIndex") ? { choiceIndex: ancestry.choiceIndex as number } : {}), + depth: ancestry.depth as number, + active: ancestry.active as boolean, + childRefs: ancestry.childRefs as number, + stateBytes: ancestry.stateBytes as number, + variableBytes: ancestry.variableBytes as number, + ancestryBytes: ancestry.ancestryBytes as number, + }; + } + if (payloadByIndex.size !== ancestryIndexes.size) { + throw corrupt("envelope", "framed checkpoint has a node payload without matching ancestry metadata"); + } + + const meaningfulVariableTransitions = stateValue( + "semanticIndexes", "meaningfulVariableTransitions", true + ); + const sharedObservability = stateValue("findings", "sharedObservability", true); + const ownerAccounting = stateValue("metadata", "ownerAccounting", true); + const checkpoint: SharedSearchCheckpoint = { + schemaVersion: SHARED_SEARCH_CHECKPOINT_SCHEMA_VERSION, + engine: header.engine as SharedSearchCheckpoint["engine"], + configuration: header.configuration as SharedSearchCheckpoint["configuration"], + state: { + endings: stateValue("findings", "endings"), + visibleOutcomes: stateValue("findings", "visibleOutcomes"), + runtimeErrors: stateValue("findings", "runtimeErrors"), + runtimeWarnings: stateValue("findings", "runtimeWarnings"), + visitedKnots: stateValue("semanticIndexes", "visitedKnots"), + seenStates: stateValue("dedupe", "seenStates"), + seenChoiceSets: stateValue("dedupe", "seenChoiceSets"), + variableStateCounts: stateValue("semanticIndexes", "variableStateCounts"), + variableTransitionCounts: stateValue("semanticIndexes", "variableTransitionCounts"), + ...(meaningfulVariableTransitions === undefined ? {} : { meaningfulVariableTransitions }), + nodes, + deep: stateValue("scheduler", "deep"), + random: stateValue("scheduler", "random"), + novelty: stateValue("scheduler", "novelty"), + rngState: stateValue("scheduler", "rngState"), + policyCursor: stateValue("scheduler", "policyCursor"), + insertionOrder: stateValue("scheduler", "insertionOrder"), + current: stateValue("scheduler", "current"), + pendingStates: stateValue("frontier", "pendingStates"), + pendingBytes: stateValue("frontier", "pendingBytes"), + pendingVariableBytes: stateValue("frontier", "pendingVariableBytes"), + activeStateBytes: stateValue("frontier", "activeStateBytes"), + activeVariableBytes: stateValue("frontier", "activeVariableBytes"), + peakPendingStates: stateValue("frontier", "peakPendingStates"), + peakPendingBytes: stateValue("frontier", "peakPendingBytes"), + retainedNodes: stateValue("frontier", "retainedNodes"), + dedupeBytes: stateValue("dedupe", "dedupeBytes"), + semanticIndexBytes: stateValue("semanticIndexes", "semanticIndexBytes"), + releasedNodes: stateValue("metadata", "releasedNodes"), + frontierCompactions: stateValue("metadata", "frontierCompactions"), + guardChecksSinceLast: stateValue("metadata", "guardChecksSinceLast"), + statesExplored: stateValue("metadata", "statesExplored"), + totalGranted: stateValue("metadata", "totalGranted"), + dedupeHits: stateValue("dedupe", "dedupeHits"), + maxDepthReached: stateValue("metadata", "maxDepthReached"), + deepestStateDiscovered: stateValue("metadata", "deepestStateDiscovered"), + lastDiscoveryAtState: stateValue("findings", "lastDiscoveryAtState"), + discoveryCurve: stateValue("findings", "discoveryCurve"), + truncated: stateValue("metadata", "truncated"), + truncatedBy: stateValue("metadata", "truncatedBy"), + finished: stateValue("metadata", "finished"), + findingBytes: stateValue("findings", "findingBytes"), + ancestryPayloadBytes: stateValue("witnessAncestry", "ancestryPayloadBytes"), + peakRetainedMemory: stateValue("metadata", "peakRetainedMemory"), + ...(sharedObservability === undefined ? {} : { sharedObservability }), + ...(ownerAccounting === undefined ? {} : { ownerAccounting }), + }, + }; + const metadata = checkpointV2Object( + this.one("metadata", "artifact"), + [ + "artifactSchemaVersion", "artifactType", "id", "createdAt", "inkcheckVersion", + "checkpointSchemaVersion", "source", "storySha256", "knotsSha256", + ], + [], + "framed checkpoint artifact metadata" + ); + if (metadata.artifactSchemaVersion !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION) { + throw unsupported( + "envelope", + `unsupported checkpoint artifact schema ${String(metadata.artifactSchemaVersion)}; use a compatible Inkcheck version or migrate the artifact` + ); + } + const artifact: CheckpointArtifact = { + artifactSchemaVersion: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + artifactType: metadata.artifactType as CheckpointArtifact["artifactType"], + id: metadata.id as string, + createdAt: metadata.createdAt as string, + inkcheckVersion: metadata.inkcheckVersion as string, + checkpointSchemaVersion: metadata.checkpointSchemaVersion as number, + source: metadata.source as CheckpointArtifact["source"], + storySha256: metadata.storySha256 as string, + knotsSha256: metadata.knotsSha256 as string, + configuration: checkpoint.configuration, + checkpoint, + }; + return validateParsedArtifact(artifact, expectedId, accounting); + } +} + interface CheckpointArtifactManifest { - manifestSchemaVersion: 1; - artifactSchemaVersion: 1; + manifestSchemaVersion: 1 | 2; + artifactSchemaVersion: 1 | 2; artifactType: "shared-search-checkpoint"; id: string; createdAt: string; @@ -227,9 +792,10 @@ interface CheckpointArtifactManifest { engine: string; totalGranted: number; statesExplored: number; - storageEncoding: "json" | "gzip"; + storageEncoding: CheckpointStorageEncoding; artifactSizeBytes: number; artifactSha256: string; + framedV2?: CheckpointArtifactV2ManifestSummary; manifestSha256: string; } @@ -241,9 +807,10 @@ interface CheckpointRecord extends CheckpointArtifactSummary { interface LoadedCheckpointArtifact { artifact: CheckpointArtifact; file: string; - storageEncoding: "json" | "gzip"; + storageEncoding: CheckpointStorageEncoding; payloadSizeBytes: number; payloadSha256: string; + framedV2?: CheckpointArtifactV2ManifestSummary; accounting: CheckpointReadAccountingV1; } @@ -302,8 +869,9 @@ function checkpointsDirectory(projectRoot: string): string { return path.join(path.resolve(projectRoot), ".inkcheck", "checkpoints"); } -function checkpointRelativePath(id: string, encoding: "json" | "gzip" = "gzip"): string { - return path.posix.join(".inkcheck", "checkpoints", `${id}.${encoding === "gzip" ? "json.gz" : "json"}`); +function checkpointRelativePath(id: string, encoding: CheckpointStorageEncoding = "gzip"): string { + const suffix = encoding === "framed-v2" ? "inkcp" : encoding === "gzip" ? "json.gz" : "json"; + return path.posix.join(".inkcheck", "checkpoints", `${id}.${suffix}`); } function validateId(id: string): void { @@ -312,9 +880,16 @@ function validateId(id: string): void { } } -function checkpointDestination(projectRoot: string, id: string): string { +function checkpointDestination( + projectRoot: string, + id: string, + format: CheckpointArtifactFormat = "legacy-v1" +): string { validateId(id); - return path.join(checkpointsDirectory(projectRoot), `${id}.json.gz`); + return path.join( + checkpointsDirectory(projectRoot), + `${id}.${format === "framed-v2" ? "inkcp" : "json.gz"}` + ); } function checkpointManifestFile(projectRoot: string, id: string): string { @@ -357,13 +932,46 @@ function checkpointRecoveryPayloadTemporaryFile(projectRoot: string, id: string, return `${checkpointRecoveryManifestFile(projectRoot, id, slot)}.payload.tmp`; } -function checkpointFile(projectRoot: string, id: string): string { +function checkpointPublicationPayloadFile(projectRoot: string, id: string): string { validateId(id); - const compressed = checkpointDestination(projectRoot, id); - if (fs.existsSync(compressed)) return compressed; - const legacy = path.join(checkpointsDirectory(projectRoot), `${id}.json`); - if (fs.existsSync(legacy)) return legacy; - return compressed; + return path.join(checkpointsDirectory(projectRoot), `.${id}.payload.commit`); +} + +function checkpointDestinationForEncoding( + projectRoot: string, + id: string, + encoding: CheckpointStorageEncoding +): string { + validateId(id); + const directory = checkpointsDirectory(projectRoot); + if (encoding === "framed-v2") return path.join(directory, `${id}.inkcp`); + if (encoding === "gzip") return path.join(directory, `${id}.json.gz`); + return path.join(directory, `${id}.json`); +} + +function checkpointCandidateFiles(projectRoot: string, id: string): string[] { + validateId(id); + const directory = checkpointsDirectory(projectRoot); + return [ + path.join(directory, `${id}.inkcp`), + path.join(directory, `${id}.json.gz`), + path.join(directory, `${id}.json`), + ]; +} + +function checkpointFile(projectRoot: string, id: string): string { + const candidates = checkpointCandidateFiles(projectRoot, id); + const existing = candidates.filter((candidate) => fs.existsSync(candidate)); + if (existing.length > 1) { + throw corrupt("storage", `checkpoint ${id} has duplicate artifact layouts; retain only one valid copy`); + } + return existing[0] ?? checkpointDestination(projectRoot, id); +} + +function checkpointStorageEncoding(file: string): CheckpointStorageEncoding { + if (file.endsWith(".inkcp")) return "framed-v2"; + if (file.endsWith(".gz")) return "gzip"; + return "json"; } function sourcePath(projectRoot: string, entrypoint: string): string { @@ -461,8 +1069,8 @@ function exactByteSum(description: string, values: readonly number[]): number { } function checkpointReadAccounting( - storageEncoding: "json" | "gzip", - limits: Required + storageEncoding: CheckpointStorageEncoding, + limits: CheckpointByteReadLimits ): CheckpointReadAccountingV1 { return { schemaVersion: 1, @@ -481,17 +1089,19 @@ function checkpointReadAccounting( parsedGraph: { count: 0, sourceLogicalUtf8Bytes: 0 }, validationString: { count: 0, logicalUtf8Bytes: 0 }, }, - storedPayload: { status: "not_completed", count: 0, bytes: 0 }, + storedPayload: storageEncoding === "framed-v2" + ? { status: "not_applied", count: 0, bytes: 0 } + : { status: "not_completed", count: 0, bytes: 0 }, decompressedPayload: storageEncoding === "gzip" ? { status: "not_completed", count: 0, bytes: 0 } : { status: "not_applied", count: 0, bytes: 0 }, rawArtifactString: { - status: "not_completed", + status: storageEncoding === "framed-v2" ? "not_applied" : "not_completed", count: 0, logicalUtf8Bytes: 0, }, parsedArtifactGraph: { - status: "not_completed", + status: storageEncoding === "framed-v2" ? "not_applied" : "not_completed", count: 0, sourceLogicalUtf8Bytes: 0, }, @@ -605,9 +1215,10 @@ function resourceLimit( stage: CheckpointReadStage, message: string, observedBytes: number | undefined, - limitBytes: number + limitBytes: number, + unit: CheckpointReadLimitUnit = "bytes" ): CheckpointReadError { - return new CheckpointReadError("resource_limit", stage, message, observedBytes, limitBytes); + return new CheckpointReadError("resource_limit", stage, message, observedBytes, limitBytes, unit); } function validateStoredEntrypoint(projectRoot: string, entrypoint: string, stage: "manifest" | "envelope"): void { @@ -618,7 +1229,7 @@ function validateStoredEntrypoint(projectRoot: string, entrypoint: string, stage } } -function checkpointReadLimits(input: CheckpointReadLimits): Required { +function checkpointReadLimits(input: CheckpointReadLimits): CheckpointByteReadLimits { const requestedStored = input.maxStoredBytes ?? DEFAULT_MAX_STORED_CHECKPOINT_READ_BYTES; const requestedDecompressed = input.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_CHECKPOINT_READ_BYTES; for (const [name, value] of Object.entries({ @@ -677,7 +1288,7 @@ function readBoundedBuffer( function readLegacyJsonBuffer( file: string, - limits: Required, + limits: CheckpointByteReadLimits, onBufferAllocated?: (bytes: number) => void ): Buffer { const fd = fs.openSync(file, "r"); @@ -743,7 +1354,7 @@ function readLegacyJsonBuffer( function decompressCheckpointJson( compressed: Buffer, - limits: Required, + limits: CheckpointByteReadLimits, accounting?: CheckpointReadAccountingV1 ): string { let decompressed: Buffer; @@ -843,29 +1454,56 @@ function samePayloadDigest(left: CheckpointPayloadDigest, right: CheckpointPaylo && left.sha256 === right.sha256; } -function parseArtifact( - raw: string, - expectedId?: string, - accounting?: CheckpointReadAccountingV1 -): CheckpointArtifact { - let value: unknown; +function removeMatchingPublicationPayload( + projectRoot: string, + id: string, + expected: CheckpointPayloadDigest, + maxStoredBytes: number +): boolean { + const neutral = checkpointPublicationPayloadFile(projectRoot, id); + let stat: fs.Stats; try { - value = JSON.parse(raw); - } catch { - throw corrupt("json", "checkpoint artifact is corrupt JSON; remove it or restore a valid copy before reopening it"); + stat = fs.statSync(neutral); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; } - if (accounting) { - accounting.parsedArtifactGraph = { - status: "applied", - count: 1, - sourceLogicalUtf8Bytes: Buffer.byteLength(raw, "utf8"), - }; + // Reject a clearly foreign inode/size before hashing it under this caller's + // cap. A later writer may legitimately have produced a larger layout with + // different limits; its neutral must be left to that writer, not surfaced as + // a resource failure in the already-verified canonical operation. + if ((expected.inode !== 0 && stat.ino !== 0 + && (stat.dev !== expected.device || stat.ino !== expected.inode)) + || stat.size !== expected.sizeBytes) return false; + let current: CheckpointPayloadDigest; + try { + current = fileDigest(neutral, maxStoredBytes); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + if (!samePayloadDigest(current, expected)) return false; + try { + fs.rmSync(neutral); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; } + syncDirectory(checkpointsDirectory(projectRoot)); + return true; +} + +function validateParsedArtifact( + value: unknown, + expectedId?: string, + accounting?: CheckpointReadAccountingV1 +): CheckpointArtifact { if (!value || typeof value !== "object") { throw corrupt("envelope", "checkpoint artifact must be a JSON object"); } const artifact = value as Partial; - if (artifact.artifactSchemaVersion !== CHECKPOINT_ARTIFACT_SCHEMA_VERSION) { + if (artifact.artifactSchemaVersion !== CHECKPOINT_ARTIFACT_SCHEMA_VERSION + && artifact.artifactSchemaVersion !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION) { throw unsupported( "envelope", `unsupported checkpoint artifact schema ${String(artifact.artifactSchemaVersion)}; use a compatible Inkcheck version or migrate the artifact` @@ -939,7 +1577,28 @@ function parseArtifact( return artifact as CheckpointArtifact; } -function loadArtifactDetailed( +function parseArtifact( + raw: string, + expectedId?: string, + accounting?: CheckpointReadAccountingV1 +): CheckpointArtifact { + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + throw corrupt("json", "checkpoint artifact is corrupt JSON; remove it or restore a valid copy before reopening it"); + } + if (accounting) { + accounting.parsedArtifactGraph = { + status: "applied", + count: 1, + sourceLogicalUtf8Bytes: Buffer.byteLength(raw, "utf8"), + }; + } + return validateParsedArtifact(value, expectedId, accounting); +} + +function loadLegacyArtifactDetailed( projectRoot: string, id: string, inputLimits: CheckpointReadLimits = {} @@ -947,7 +1606,10 @@ function loadArtifactDetailed( const file = checkpointFile(projectRoot, id); if (!fs.existsSync(file)) throw new Error(`checkpoint not found: ${id}`); const limits = checkpointReadLimits(inputLimits); - const storageEncoding = file.endsWith(".gz") ? "gzip" : "json"; + const storageEncoding = checkpointStorageEncoding(file); + if (storageEncoding === "framed-v2") { + throw unsupported("storage", "framed-v2 checkpoints require the version-2 streaming reader"); + } const accounting = checkpointReadAccounting(storageEncoding, limits); try { let storedManifest: CheckpointArtifactManifest | undefined; @@ -1002,6 +1664,9 @@ function loadArtifactDetailed( logicalUtf8Bytes: Buffer.byteLength(raw, "utf8"), }; const artifact = parseArtifact(raw, id, accounting); + if (artifact.artifactSchemaVersion !== CHECKPOINT_ARTIFACT_SCHEMA_VERSION) { + throw unsupported("envelope", "a legacy JSON checkpoint cannot contain a framed-v2 artifact envelope"); + } validateStoredEntrypoint(projectRoot, artifact.source.entrypoint, "envelope"); const loaded: LoadedCheckpointArtifact = { artifact, @@ -1022,6 +1687,175 @@ function loadArtifactDetailed( } } +function checkpointAbortError(message = "checkpoint artifact operation was cancelled"): Error { + const aborted = new Error(message); + aborted.name = "AbortError"; + return aborted; +} + +function checkpointReadErrorFromV2(error: CheckpointArtifactV2Error): Error { + if (error.kind === "cancelled") return checkpointAbortError(error.message); + const stage: CheckpointReadStage = error.stage === "record" + ? "json" + : error.stage === "payload" + ? "decompression" + : "storage"; + const metric = error.metric; + return new CheckpointReadError( + error.kind, + stage, + error.message, + metric === undefined ? undefined : error.observed, + metric === undefined ? undefined : error.limit, + metric + ); +} + +async function loadFramedArtifactDetailed( + projectRoot: string, + id: string, + inputLimits: CheckpointReadLimits = {} +): Promise { + const file = checkpointFile(projectRoot, id); + if (!fs.existsSync(file)) throw new Error(`checkpoint not found: ${id}`); + if (checkpointStorageEncoding(file) !== "framed-v2") { + throw unsupported("storage", "legacy checkpoints require the schema-v1 reader"); + } + const limits = checkpointReadLimits(inputLimits); + const accounting = checkpointReadAccounting("framed-v2", limits); + try { + inputLimits.signal?.throwIfAborted(); + const manifestFile = checkpointManifestFile(projectRoot, id); + if (!fs.existsSync(manifestFile)) { + throw corrupt("manifest", "framed-v2 checkpoints require their versioned metadata manifest"); + } + accounting.manifest.status = "not_completed"; + const storedManifest = readCheckpointManifest(projectRoot, id, accounting).manifest; + validateManifestStorage(projectRoot, id, file, storedManifest); + if (!storedManifest.framedV2) { + throw corrupt("manifest", "framed-v2 checkpoint metadata is missing its component summary"); + } + inputLimits.signal?.throwIfAborted(); + const maxNodeSlots = inputLimits.framedV2Limits?.maxTotalRecords + ?? DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalRecords; + const assembly = new CheckpointArtifactV2Assembly(maxNodeSlots); + const requestedStored = inputLimits.framedV2Limits?.maxTotalStoredBytes + ?? DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalStoredBytes; + const requestedDecoded = inputLimits.framedV2Limits?.maxTotalDecodedBytes + ?? DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalDecodedBytes; + let codec: CheckpointArtifactV2ReadResult; + let payloadSizeBytes = 0; + let payloadSha256 = ""; + const fd = fs.openSync(file, "r"); + const opened = fs.fstatSync(fd); + if (!opened.isFile()) { + fs.closeSync(fd); + throw corrupt("storage", "stored checkpoint artifact must be a regular file"); + } + if (opened.size > limits.maxStoredBytes) { + fs.closeSync(fd); + throw resourceLimit( + "storage", + `stored checkpoint artifact is ${opened.size} bytes, above the ${limits.maxStoredBytes}-byte readback limit`, + opened.size, + limits.maxStoredBytes + ); + } + const source = fs.createReadStream(file, { fd, autoClose: false, start: 0 }); + const digest = new CheckpointReadDigestTransform(limits.maxStoredBytes); + try { + [codec] = await Promise.all([ + readCheckpointArtifactV2(digest, { + limits: { + ...inputLimits.framedV2Limits, + maxTotalStoredBytes: Math.min(requestedStored, limits.maxStoredBytes), + maxTotalDecodedBytes: Math.min(requestedDecoded, limits.maxDecompressedBytes), + }, + signal: inputLimits.signal, + onRecord: (record) => assembly.add(record), + }), + pipeline(source, digest), + ]); + const after = fs.fstatSync(fd); + let visible: fs.Stats; + try { + visible = fs.statSync(file); + } catch { + throw corrupt("storage", "checkpoint artifact path changed while its opened payload was being decoded"); + } + const openedIdentityChanged = opened.dev !== 0 && after.dev !== 0 + && (opened.dev !== after.dev || opened.ino !== after.ino); + const pathIdentityChanged = opened.dev !== 0 && visible.dev !== 0 + && (opened.dev !== visible.dev || opened.ino !== visible.ino); + if (openedIdentityChanged || pathIdentityChanged || after.size !== opened.size + || visible.size !== opened.size || digest.bytes !== opened.size) { + throw corrupt("storage", "checkpoint artifact changed while its opened payload was being decoded"); + } + payloadSizeBytes = digest.bytes; + payloadSha256 = digest.digest(); + } catch (error) { + if (!source.destroyed) source.destroy(); + if (inputLimits.signal?.aborted) throw checkpointAbortError(); + if (error instanceof CheckpointArtifactV2Error) throw checkpointReadErrorFromV2(error); + throw error; + } finally { + try { + fs.closeSync(fd); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBADF") throw error; + } + } + if (payloadSha256 !== storedManifest.artifactSha256) { + throw corrupt("storage", "checkpoint artifact bytes do not match its metadata checksum; restore or regenerate it"); + } + if (codec.artifactBytes !== payloadSizeBytes) { + throw corrupt("storage", "framed checkpoint codec byte count does not match its metadata"); + } + const framedV2 = checkpointArtifactV2Summary(codec); + if (JSON.stringify(framedV2) !== JSON.stringify(storedManifest.framedV2)) { + throw corrupt("manifest", "framed checkpoint component summary does not match its streamed payload"); + } + accounting.framedV2 = { + status: "applied", + codec: checkpointArtifactV2AccountingSummary(framedV2), + }; + const artifact = assembly.artifact(id, accounting); + validateStoredEntrypoint(projectRoot, artifact.source.entrypoint, "envelope"); + const loaded: LoadedCheckpointArtifact = { + artifact, + file, + storageEncoding: "framed-v2", + payloadSizeBytes, + payloadSha256, + framedV2, + accounting, + }; + validateManifestForLoaded(projectRoot, loaded, storedManifest); + finalizeCheckpointReadAccounting(accounting); + return loaded; + } catch (error) { + if (error instanceof CheckpointReadError) { + error.accounting = finalizeCheckpointReadAccounting(accounting, error); + } + throw error; + } +} + +async function loadArtifactDetailed( + projectRoot: string, + id: string, + inputLimits: CheckpointReadLimits = {} +): Promise { + const file = checkpointFile(projectRoot, id); + inputLimits.signal?.throwIfAborted(); + if (checkpointStorageEncoding(file) === "framed-v2") { + return loadFramedArtifactDetailed(projectRoot, id, inputLimits); + } + const loaded = loadLegacyArtifactDetailed(projectRoot, id, inputLimits); + inputLimits.signal?.throwIfAborted(); + return loaded; +} + function summary(projectRoot: string, loaded: LoadedCheckpointArtifact): CheckpointArtifactSummary { const { artifact } = loaded; const manifestFile = checkpointManifestFile(projectRoot, artifact.id); @@ -1029,6 +1863,7 @@ function summary(projectRoot: string, loaded: LoadedCheckpointArtifact): Checkpo return { id: artifact.id, path: checkpointRelativePath(artifact.id, loaded.storageEncoding), + artifactSchemaVersion: artifact.artifactSchemaVersion, artifactType: "shared-search-checkpoint", createdAt: artifact.createdAt, inkcheckVersion: artifact.inkcheckVersion, @@ -1041,18 +1876,31 @@ function summary(projectRoot: string, loaded: LoadedCheckpointArtifact): Checkpo metadataSizeBytes, sizeBytes: loaded.payloadSizeBytes + metadataSizeBytes, storageEncoding: loaded.storageEncoding, + ...(loaded.framedV2 + ? { framedV2: checkpointArtifactV2AccountingSummary(loaded.framedV2) } + : {}), }; } function manifestForArtifact( artifact: CheckpointArtifact, - storageEncoding: "json" | "gzip", + storageEncoding: CheckpointStorageEncoding, artifactSizeBytes: number, - artifactSha256: string + artifactSha256: string, + framedV2?: CheckpointArtifactV2ManifestSummary ): CheckpointArtifactManifest { + const framed = storageEncoding === "framed-v2"; + if (framed !== (artifact.artifactSchemaVersion === CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION)) { + throw new Error("checkpoint artifact schema does not match its requested storage encoding"); + } + if (framed !== (framedV2 !== undefined)) { + throw new Error("framed checkpoint metadata summary does not match its storage encoding"); + } const body: Omit = { - manifestSchemaVersion: CHECKPOINT_MANIFEST_SCHEMA_VERSION, - artifactSchemaVersion: CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + manifestSchemaVersion: framed + ? CHECKPOINT_MANIFEST_V2_SCHEMA_VERSION + : CHECKPOINT_MANIFEST_SCHEMA_VERSION, + artifactSchemaVersion: artifact.artifactSchemaVersion, artifactType: "shared-search-checkpoint", id: artifact.id, createdAt: artifact.createdAt, @@ -1065,6 +1913,7 @@ function manifestForArtifact( storageEncoding, artifactSizeBytes, artifactSha256, + ...(framedV2 ? { framedV2 } : {}), }; return { ...body, manifestSha256: manifestDigest(body) }; } @@ -1089,6 +1938,7 @@ function manifestBody( storageEncoding: manifest.storageEncoding, artifactSizeBytes: manifest.artifactSizeBytes, artifactSha256: manifest.artifactSha256, + ...(manifest.framedV2 ? { framedV2: manifest.framedV2 } : {}), }; } @@ -1110,6 +1960,119 @@ function serializedManifest(manifest: CheckpointArtifactManifest): string { return JSON.stringify(manifest); } +function exactOrderedKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function safeManifestCount(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function validateCheckpointArtifactV2ManifestSummary( + value: unknown, + artifactSizeBytes: number +): CheckpointArtifactV2ManifestSummary { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw corrupt("manifest", "framed checkpoint summary must be an object"); + } + const summary = value as Record; + if (typeof summary.schemaVersion === "number" + && summary.schemaVersion !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION) { + throw unsupported( + "manifest", + `unsupported framed checkpoint summary schema ${String(summary.schemaVersion)}; use a compatible Inkcheck version` + ); + } + if (!exactOrderedKeys(summary, [ + "schemaVersion", "componentOrder", "artifactBytes", "artifactOverheadBytes", + "data", "components", "index", + ]) || summary.schemaVersion !== CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION) { + throw corrupt("manifest", "framed checkpoint summary fields/order do not match schema v2"); + } + if (!Array.isArray(summary.componentOrder) + || JSON.stringify(summary.componentOrder) !== JSON.stringify(CHECKPOINT_V2_COMPONENT_ORDER)) { + throw corrupt("manifest", "framed checkpoint component order does not match schema v2"); + } + if (!safeManifestCount(summary.artifactBytes) + || !safeManifestCount(summary.artifactOverheadBytes) + || summary.artifactBytes !== artifactSizeBytes) { + throw corrupt("manifest", "framed checkpoint artifact byte totals are invalid"); + } + if (!summary.data || typeof summary.data !== "object" || Array.isArray(summary.data) + || !exactOrderedKeys(summary.data as Record, [ + "frameCount", "recordCount", "decodedBytes", "storedBytes", "maxRecordBytes", + ])) { + throw corrupt("manifest", "framed checkpoint data totals fields/order do not match schema v2"); + } + const data = summary.data as Record; + if (![data.frameCount, data.recordCount, data.decodedBytes, data.storedBytes, data.maxRecordBytes] + .every(safeManifestCount)) { + throw corrupt("manifest", "framed checkpoint data totals must be non-negative safe integers"); + } + if (!Array.isArray(summary.components) + || summary.components.length !== CHECKPOINT_V2_COMPONENT_ORDER.length) { + throw corrupt("manifest", "framed checkpoint summary must contain all fixed components"); + } + const shaPattern = /^[0-9a-f]{64}$/; + const components = summary.components.map((value, index) => { + if (!value || typeof value !== "object" || Array.isArray(value) + || !exactOrderedKeys(value as Record, [ + "component", "frameCount", "recordCount", "decodedBytes", "decodedSha256", + "storedBytes", "storedSha256", "maxRecordBytes", + ])) { + throw corrupt("manifest", "framed checkpoint component summary fields/order do not match schema v2"); + } + const component = value as Record; + if (component.component !== CHECKPOINT_V2_COMPONENT_ORDER[index] + || ![ + component.frameCount, component.recordCount, component.decodedBytes, + component.storedBytes, component.maxRecordBytes, + ].every(safeManifestCount) + || typeof component.decodedSha256 !== "string" || !shaPattern.test(component.decodedSha256) + || typeof component.storedSha256 !== "string" || !shaPattern.test(component.storedSha256)) { + throw corrupt("manifest", "framed checkpoint component summary values are invalid"); + } + return component; + }); + if (!summary.index || typeof summary.index !== "object" || Array.isArray(summary.index) + || !exactOrderedKeys(summary.index as Record, [ + "sequence", "headerBytes", "decodedBytes", "decodedSha256", "storedBytes", + "storedSha256", "maxRecordBytes", + ])) { + throw corrupt("manifest", "framed checkpoint index summary fields/order do not match schema v2"); + } + const index = summary.index as Record; + if (![index.sequence, index.headerBytes, index.decodedBytes, index.storedBytes, index.maxRecordBytes] + .every(safeManifestCount) + || typeof index.decodedSha256 !== "string" || !shaPattern.test(index.decodedSha256) + || typeof index.storedSha256 !== "string" || !shaPattern.test(index.storedSha256)) { + throw corrupt("manifest", "framed checkpoint index summary values are invalid"); + } + const componentTotals = components.reduce<{ + frameCount: number; + recordCount: number; + decodedBytes: number; + storedBytes: number; + maxRecordBytes: number; + }>((totals, component) => ({ + frameCount: totals.frameCount + (component.frameCount as number), + recordCount: totals.recordCount + (component.recordCount as number), + decodedBytes: totals.decodedBytes + (component.decodedBytes as number), + storedBytes: totals.storedBytes + (component.storedBytes as number), + maxRecordBytes: Math.max(totals.maxRecordBytes, component.maxRecordBytes as number), + }), { frameCount: 0, recordCount: 0, decodedBytes: 0, storedBytes: 0, maxRecordBytes: 0 }); + if (Object.keys(componentTotals).some((key) => { + const typed = key as keyof typeof componentTotals; + return componentTotals[typed] !== data[typed]; + }) || index.sequence !== data.frameCount + || summary.artifactOverheadBytes !== (summary.artifactBytes as number) + - (data.storedBytes as number) - (index.storedBytes as number)) { + throw corrupt("manifest", "framed checkpoint summary totals are internally inconsistent"); + } + return summary as unknown as CheckpointArtifactV2ManifestSummary; +} + function parseManifest( raw: string, expectedId: string, @@ -1132,7 +2095,8 @@ function parseManifest( } const manifest = value as Partial; if (typeof manifest.manifestSchemaVersion === "number" - && manifest.manifestSchemaVersion !== CHECKPOINT_MANIFEST_SCHEMA_VERSION) { + && manifest.manifestSchemaVersion !== CHECKPOINT_MANIFEST_SCHEMA_VERSION + && manifest.manifestSchemaVersion !== CHECKPOINT_MANIFEST_V2_SCHEMA_VERSION) { throw unsupported( "manifest", `unsupported checkpoint metadata manifest schema ${String(manifest.manifestSchemaVersion)}; use a compatible Inkcheck version` @@ -1142,17 +2106,27 @@ function parseManifest( "manifestSchemaVersion", "artifactSchemaVersion", "artifactType", "id", "createdAt", "inkcheckVersion", "checkpointSchemaVersion", "entrypoint", "engine", "totalGranted", "statesExplored", "storageEncoding", "artifactSizeBytes", "artifactSha256", "manifestSha256", + ...(manifest.manifestSchemaVersion === CHECKPOINT_MANIFEST_V2_SCHEMA_VERSION + ? ["framedV2" as const] + : []), ]; const actualKeys = Object.keys(manifest).sort(); if (actualKeys.length !== expectedKeys.length || actualKeys.some((key, index) => key !== [...expectedKeys].sort()[index])) { throw corrupt("manifest", "checkpoint metadata manifest fields do not match its schema"); } - if (manifest.manifestSchemaVersion !== CHECKPOINT_MANIFEST_SCHEMA_VERSION) { + if (manifest.manifestSchemaVersion !== CHECKPOINT_MANIFEST_SCHEMA_VERSION + && manifest.manifestSchemaVersion !== CHECKPOINT_MANIFEST_V2_SCHEMA_VERSION) { throw corrupt("manifest", "checkpoint metadata manifest is missing its schema version"); } - if (manifest.artifactSchemaVersion !== CHECKPOINT_ARTIFACT_SCHEMA_VERSION - || manifest.checkpointSchemaVersion !== SHARED_SEARCH_CHECKPOINT_SCHEMA_VERSION) { + const validLayout = manifest.manifestSchemaVersion === CHECKPOINT_MANIFEST_SCHEMA_VERSION + ? manifest.artifactSchemaVersion === CHECKPOINT_ARTIFACT_SCHEMA_VERSION + && (manifest.storageEncoding === "json" || manifest.storageEncoding === "gzip") + && manifest.framedV2 === undefined + : manifest.artifactSchemaVersion === CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION + && manifest.storageEncoding === "framed-v2" + && manifest.framedV2 !== undefined; + if (!validLayout || manifest.checkpointSchemaVersion !== SHARED_SEARCH_CHECKPOINT_SCHEMA_VERSION) { throw unsupported( "manifest", `unsupported checkpoint schema in metadata manifest; use a compatible Inkcheck version or migrate the artifact` @@ -1163,12 +2137,21 @@ function parseManifest( || typeof manifest.inkcheckVersion !== "string" || typeof manifest.entrypoint !== "string" || typeof manifest.engine !== "string" || !Number.isSafeInteger(manifest.totalGranted) || !Number.isSafeInteger(manifest.statesExplored) - || (manifest.storageEncoding !== "json" && manifest.storageEncoding !== "gzip") + || (manifest.storageEncoding !== "json" && manifest.storageEncoding !== "gzip" + && manifest.storageEncoding !== "framed-v2") || !Number.isSafeInteger(manifest.artifactSizeBytes) || (manifest.artifactSizeBytes ?? 0) < 1 || typeof manifest.artifactSha256 !== "string" || !/^[0-9a-f]{64}$/.test(manifest.artifactSha256) + || (manifest.framedV2 !== undefined + && (!manifest.framedV2 || typeof manifest.framedV2 !== "object")) || typeof manifest.manifestSha256 !== "string" || !/^[0-9a-f]{64}$/.test(manifest.manifestSha256)) { throw corrupt("manifest", "checkpoint metadata manifest is missing required fields; restore or regenerate it"); } + if (manifest.manifestSchemaVersion === CHECKPOINT_MANIFEST_V2_SCHEMA_VERSION) { + manifest.framedV2 = validateCheckpointArtifactV2ManifestSummary( + manifest.framedV2, + manifest.artifactSizeBytes as number + ); + } const complete = manifest as CheckpointArtifactManifest; if (manifestDigest(manifestBody(complete), accounting) !== complete.manifestSha256) { throw corrupt("manifest", "checkpoint metadata manifest content does not match its canonical checksum"); @@ -1215,7 +2198,7 @@ function validateManifestStorage( manifest: CheckpointArtifactManifest ): void { validateStoredEntrypoint(projectRoot, manifest.entrypoint, "manifest"); - const storageEncoding = file.endsWith(".gz") ? "gzip" : "json"; + const storageEncoding = checkpointStorageEncoding(file); if (manifest.storageEncoding !== storageEncoding) { throw corrupt("manifest", `checkpoint ${id} metadata does not match its storage encoding`); } @@ -1278,11 +2261,12 @@ function recordFromManifest(projectRoot: string, id: string, file: string): Chec const manifestFile = checkpointManifestFile(projectRoot, id); const { manifest, sizeBytes: metadataSizeBytes } = readCheckpointManifest(projectRoot, id); validateManifestStorage(projectRoot, id, file, manifest); - const storageEncoding = file.endsWith(".gz") ? "gzip" : "json"; + const storageEncoding = checkpointStorageEncoding(file); const payloadSizeBytes = manifest.artifactSizeBytes; return { id, path: checkpointRelativePath(id, storageEncoding), + artifactSchemaVersion: manifest.artifactSchemaVersion, artifactType: "shared-search-checkpoint", createdAt: manifest.createdAt, inkcheckVersion: manifest.inkcheckVersion, @@ -1295,6 +2279,9 @@ function recordFromManifest(projectRoot: string, id: string, file: string): Chec metadataSizeBytes, sizeBytes: payloadSizeBytes + metadataSizeBytes, storageEncoding, + ...(manifest.framedV2 + ? { framedV2: checkpointArtifactV2AccountingSummary(manifest.framedV2) } + : {}), file, manifestFile, }; @@ -1307,18 +2294,21 @@ function checkpointRecords( const directory = checkpointsDirectory(projectRoot); if (!fs.existsSync(directory)) return []; const names = fs.readdirSync(directory) - .filter((name) => /^checkpoint-[0-9a-f]{24}\.json(?:\.gz)?$/.test(name)); + .filter((name) => /^checkpoint-[0-9a-f]{24}\.(?:json(?:\.gz)?|inkcp)$/.test(name)); const ids = new Set(); return names.map((name) => { - const id = name.slice(0, name.indexOf(".json")); + const id = name.slice(0, name.indexOf(".")); if (ids.has(id)) { - throw corrupt("storage", `checkpoint ${id} has duplicate JSON and gzip artifacts; retain only one valid copy`); + throw corrupt("storage", `checkpoint ${id} has duplicate artifact layouts; retain only one valid copy`); } ids.add(id); const file = path.join(directory, name); const manifestFile = checkpointManifestFile(projectRoot, id); if (fs.existsSync(manifestFile)) return recordFromManifest(projectRoot, id, file); - return { ...summary(projectRoot, loadArtifactDetailed(projectRoot, id, inputLimits)), file }; + if (checkpointStorageEncoding(file) === "framed-v2") { + throw corrupt("manifest", `framed checkpoint ${id} is missing its required versioned metadata manifest`); + } + return { ...summary(projectRoot, loadLegacyArtifactDetailed(projectRoot, id, inputLimits)), file }; }); } @@ -1360,16 +2350,55 @@ class ByteLimitTransform extends Transform { } } +class CheckpointReadDigestTransform extends Transform { + bytes = 0; + private readonly hash = createHash("sha256"); + + constructor(private readonly limit: number) { + super(); + } + + override _transform( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null, data?: Buffer) => void + ): void { + this.bytes += chunk.length; + if (this.bytes > this.limit) { + callback(resourceLimit( + "storage", + `stored checkpoint artifact exceeds the ${this.limit}-byte readback limit`, + this.bytes, + this.limit + )); + return; + } + this.hash.update(chunk); + callback(null, chunk); + } + + digest(): string { + return this.hash.digest("hex"); + } +} + interface CheckpointArtifactWriteAccounting { - serialization: Extract; - compression: Extract; + serialization?: Extract; + compression?: Extract; + framedV2?: NonNullable; } interface WrittenCheckpointArtifact extends CheckpointArtifactWriteAccounting { sizeBytes: number; sha256: string; + framedV2Manifest?: CheckpointArtifactV2ManifestSummary; } +// Integration-owned validation failures are raised before writeFramedArtifact +// creates a destination stream. This private subtype is the only failed-write +// path that may truthfully join a concurrently committed same-ID winner. +class CheckpointFramedPreflightError extends CheckpointArtifactV2Error {} + const CHECKPOINT_GZIP_WINDOW_BITS = 15; const CHECKPOINT_GZIP_MEMORY_LEVEL = 8; @@ -1385,8 +2414,9 @@ function zlibWorkspaceCapacityBytes(): number { async function writeCompressedArtifact( temporary: string, artifact: CheckpointArtifact, - limits: Required, - precreated = false + limits: CheckpointByteStorageLimits, + precreated = false, + signal?: AbortSignal ): Promise { const kind = limits.maxCheckpointBytes <= limits.maxProjectBytes ? "single" : "project"; const limit = Math.min(limits.maxCheckpointBytes, limits.maxProjectBytes); @@ -1421,7 +2451,8 @@ async function writeCompressedArtifact( // this durable result-window boundary. compressor, limiter, - destination + destination, + { signal } ); // Windows requires a writable handle for fsync even after the stream has // closed; reopening r+ preserves the same durability step on every platform. @@ -1457,6 +2488,110 @@ async function writeCompressedArtifact( }; } +async function writeFramedArtifact( + temporary: string, + artifact: CheckpointArtifact, + limits: CheckpointByteStorageLimits, + framedV2Limits: Partial | undefined, + signal: AbortSignal | undefined +): Promise { + signal?.throwIfAborted(); + checkpointTestBarrier("during-candidate-preflight"); + signal?.throwIfAborted(); + const kind = limits.maxCheckpointBytes <= limits.maxProjectBytes ? "single" : "project"; + const limit = Math.min(limits.maxCheckpointBytes, limits.maxProjectBytes); + const requestedStoredLimit = framedV2Limits?.maxTotalStoredBytes + ?? DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalStoredBytes; + const effectiveFramedLimits = { ...DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS, ...framedV2Limits }; + for (const [name, value] of Object.entries(effectiveFramedLimits)) { + const metric: CheckpointReadLimitUnit | undefined = name === "maxJsonDepth" + ? "depth" + : name.endsWith("Bytes") + ? "bytes" + : ["maxRecordsPerFrame", "maxFrames", "maxTotalRecords"].includes(name) + ? "count" + : undefined; + if (!metric) { + throw new CheckpointFramedPreflightError( + "unsupported", + "header", + `unsupported checkpoint artifact limit ${name}` + ); + } + if (!Number.isSafeInteger(value) || value <= 0 || value > 0xffffffff) { + throw new CheckpointFramedPreflightError( + "resource_limit", + "header", + `${name} must be a positive 32-bit safe integer`, + value, + 0xffffffff, + metric + ); + } + } + if (effectiveFramedLimits.maxRecordBytes + 4 > effectiveFramedLimits.maxDecodedFrameBytes) { + throw new CheckpointFramedPreflightError( + "resource_limit", + "record", + "maxRecordBytes plus its length prefix must fit maxDecodedFrameBytes", + effectiveFramedLimits.maxRecordBytes + 4, + effectiveFramedLimits.maxDecodedFrameBytes, + "bytes" + ); + } + const maxNodeSlots = framedV2Limits?.maxTotalRecords + ?? DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalRecords; + if (Number.isSafeInteger(maxNodeSlots) && maxNodeSlots > 0 + && artifact.checkpoint.state.nodes.length > maxNodeSlots) { + throw new CheckpointFramedPreflightError( + "resource_limit", + "record", + `framed checkpoint node-slot count ${artifact.checkpoint.state.nodes.length} exceeds maxTotalRecords`, + artifact.checkpoint.state.nodes.length, + maxNodeSlots, + "count" + ); + } + const limiter = new ByteLimitTransform(kind, limit); + const destination = fs.createWriteStream(temporary, { flags: "r+", mode: 0o600 }); + const codecLimits = { + ...framedV2Limits, + maxTotalStoredBytes: Math.min(requestedStoredLimit, limit), + }; + let codec: CheckpointArtifactV2WriteResult; + try { + [codec] = await Promise.all([ + writeCheckpointArtifactV2(limiter, checkpointArtifactV2Frames(artifact), { + limits: codecLimits, + signal, + }), + pipeline(limiter, destination), + ]); + } catch (error) { + if (signal?.aborted) throw checkpointAbortError(); + if (error instanceof CheckpointArtifactV2Error && error.kind === "cancelled") { + throw checkpointReadErrorFromV2(error); + } + throw error; + } + if (codec.artifactBytes !== limiter.bytes) { + throw new Error("framed checkpoint codec byte count does not match the durable output stream"); + } + const fd = fs.openSync(temporary, "r+"); + try { + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + const summary = checkpointArtifactV2Summary(codec); + return { + sizeBytes: limiter.bytes, + sha256: limiter.digest(), + framedV2: { status: "applied", codec: checkpointArtifactV2AccountingSummary(summary) }, + framedV2Manifest: summary, + }; +} + function oldestFirst(a: T, b: T): number { return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); } @@ -1464,13 +2599,14 @@ function oldestFirst(a: T, b: T): n function pruneCheckpoints( projectRoot: string, protectedId: string, - limits: Required + limits: CheckpointByteStorageLimits ): string[] { let records = checkpointRecords(projectRoot); const removed: string[] = []; const remove = (record: CheckpointRecord) => { fs.rmSync(record.file, { force: true }); if (record.manifestFile) fs.rmSync(record.manifestFile, { force: true }); + fs.rmSync(checkpointPublicationPayloadFile(projectRoot, record.id), { force: true }); removed.push(record.id); records = records.filter((candidate) => candidate.id !== record.id); }; @@ -1492,7 +2628,7 @@ function pruneCheckpoints( return removed; } -function storageLimits(input: CheckpointStorageLimits): Required { +function storageLimits(input: CheckpointStorageLimits): CheckpointByteStorageLimits { const limits = { maxCheckpointBytes: input.maxCheckpointBytes ?? DEFAULT_MAX_CHECKPOINT_BYTES, maxProjectBytes: input.maxProjectBytes ?? DEFAULT_MAX_PROJECT_CHECKPOINT_BYTES, @@ -1811,7 +2947,8 @@ function reserveCheckpointTransaction(projectRoot: string, id: string): Checkpoi "manifest", `checkpoint ${id} has reached its bounded ${MAX_CHECKPOINT_RECOVERY_MANIFESTS}-file recovery-manifest limit; complete or remove the stale transaction before retrying`, MAX_CHECKPOINT_RECOVERY_MANIFESTS, - MAX_CHECKPOINT_RECOVERY_MANIFESTS + MAX_CHECKPOINT_RECOVERY_MANIFESTS, + "count" ); } @@ -1846,14 +2983,31 @@ function manifestMatchesVisiblePayload( relative: string, checkpoint: SharedSearchCheckpoint ): boolean { - const storageEncoding = file.endsWith(".gz") ? "gzip" : "json"; + const storageEncoding = checkpointStorageEncoding(file); + return manifestMatchesCheckpointPayload( + projectRoot, + id, + manifest, + payload, + relative, + checkpoint + ) && manifest.storageEncoding === storageEncoding; +} + +function manifestMatchesCheckpointPayload( + projectRoot: string, + id: string, + manifest: CheckpointArtifactManifest, + payload: { sizeBytes: number; sha256: string }, + relative: string, + checkpoint: SharedSearchCheckpoint +): boolean { try { validateStoredEntrypoint(projectRoot, manifest.entrypoint, "manifest"); } catch { return false; } return manifest.id === id - && manifest.storageEncoding === storageEncoding && manifest.artifactSizeBytes === payload.sizeBytes && manifest.artifactSha256 === payload.sha256 && expectedManifestMatchesCheckpoint(manifest, relative, checkpoint); @@ -1887,6 +3041,38 @@ function matchingRecoveryManifest( return undefined; } +function matchingRecoveryManifestForNeutralPayload( + projectRoot: string, + id: string, + payload: { sizeBytes: number; sha256: string }, + relative: string, + checkpoint: SharedSearchCheckpoint +): RecoveryManifest | undefined { + let match: RecoveryManifest | undefined; + for (const candidate of recoveryManifestSourceFiles(projectRoot, id)) { + try { + const recovery = readRecoveryManifest(candidate.file, id, candidate.slot); + if (!manifestMatchesCheckpointPayload( + projectRoot, + id, + recovery.manifest, + payload, + relative, + checkpoint + )) continue; + if (match && match.raw !== recovery.raw) { + throw corrupt("manifest", `checkpoint ${id} has ambiguous recovery metadata for its committed payload`); + } + match = recovery; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + if (error instanceof CheckpointReadError && error.kind === "corrupt") continue; + throw error; + } + } + return match; +} + function readMatchingCanonicalManifest( projectRoot: string, id: string, @@ -2247,37 +3433,151 @@ function recoverPublishedCheckpoint( relative: string, id: string, checkpoint: SharedSearchCheckpoint, - limits: Required, + limits: CheckpointByteStorageLimits, expectedTransaction?: CheckpointTransaction ): RecoveredCheckpointPair | undefined { // Healthy canonical pairs stay metadata-only here. Probe the fixed slot set // before opening or hashing a potentially very large payload. if (!hasRecoveryManifest(projectRoot, id)) return undefined; - const file = checkpointFile(projectRoot, id); - if (!fs.existsSync(file)) return undefined; const digestLimit = Math.min( limits.maxCheckpointBytes, limits.maxProjectBytes ); - const payload = fileDigest(file, digestLimit); - const recovery = matchingRecoveryManifest( - projectRoot, - id, - file, - payload, - relative, - checkpoint - ); - if (!recovery) return undefined; - promoteRecoveryManifest( - projectRoot, - id, - file, - payload, - relative, - checkpoint, - recovery - ); + const directory = checkpointsDirectory(projectRoot); + const neutral = checkpointPublicationPayloadFile(projectRoot, id); + const visible = checkpointCandidateFiles(projectRoot, id) + .filter((candidate) => fs.existsSync(candidate)); + if (visible.length > 1) { + throw corrupt("storage", `checkpoint ${id} has duplicate artifact layouts during publication recovery`); + } + let file: string; + let payload: CheckpointPayloadDigest; + let recovery: RecoveryManifest | undefined; + let expectedManifestRaw: string; + if (visible.length === 1) { + // A canonical public pathname is the publication authority. A later + // writer may already have reused the neutral name while this owner or a + // helper was paused, so that neutral inode must not redirect recovery. + file = visible[0]; + payload = fileDigest(file, digestLimit); + const canonical = readMatchingCanonicalManifest( + projectRoot, + id, + file, + payload, + relative, + checkpoint + ); + if (canonical) { + expectedManifestRaw = canonical.raw; + } else { + recovery = matchingRecoveryManifest( + projectRoot, + id, + file, + payload, + relative, + checkpoint + ); + if (!recovery) return undefined; + promoteRecoveryManifest( + projectRoot, + id, + file, + payload, + relative, + checkpoint, + recovery + ); + expectedManifestRaw = recovery.raw; + } + } else if (fs.existsSync(neutral)) { + // Before any public pathname exists, the sole neutral hard link remains + // the no-clobber authority and its recovery record selects the suffix. + if (checkpointCandidateFiles(projectRoot, id) + .some((candidate) => fs.existsSync(candidate))) { + return recoverPublishedCheckpoint( + projectRoot, + relative, + id, + checkpoint, + limits, + expectedTransaction + ); + } + let committed: CheckpointPayloadDigest; + try { + committed = fileDigest(neutral, digestLimit); + recovery = matchingRecoveryManifestForNeutralPayload( + projectRoot, + id, + committed, + relative, + checkpoint + ); + } catch (error) { + // A public winner can appear while a later, larger neutral is being + // inspected under this caller's lower cap. Public precedence makes the + // foreign neutral irrelevant; restart there instead of leaking its + // resource error into the verified canonical operation. + if (checkpointCandidateFiles(projectRoot, id) + .some((candidate) => fs.existsSync(candidate))) { + return recoverPublishedCheckpoint( + projectRoot, + relative, + id, + checkpoint, + limits, + expectedTransaction + ); + } + throw error; + } + if (checkpointCandidateFiles(projectRoot, id) + .some((candidate) => fs.existsSync(candidate))) { + return recoverPublishedCheckpoint( + projectRoot, + relative, + id, + checkpoint, + limits, + expectedTransaction + ); + } + if (!recovery) { + throw corrupt( + "manifest", + `checkpoint ${id} committed payload has no matching bounded recovery metadata` + ); + } + file = checkpointDestinationForEncoding(projectRoot, id, recovery.manifest.storageEncoding); + try { + fs.linkSync(neutral, file); + syncDirectory(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + payload = fileDigest(file, digestLimit); + if (!samePayloadDigest(committed, payload)) { + throw corrupt( + "storage", + `checkpoint ${id} public payload does not match its neutral no-clobber commit` + ); + } + checkpointTestBarrier("after-recovery-publication-link"); + promoteRecoveryManifest( + projectRoot, + id, + file, + payload, + relative, + checkpoint, + recovery + ); + expectedManifestRaw = recovery.raw; + } else { + return undefined; + } // Reopen and hash the payload after manifest publication. This closes the // prune/replacement window: recovery metadata is retained if the path now // names different bytes (or a different regular file where inode identity @@ -2296,7 +3596,7 @@ function recoverPublishedCheckpoint( verifiedPayload, relative, checkpoint, - recovery.raw + expectedManifestRaw ); if (!verifiedManifest) { throw corrupt( @@ -2308,6 +3608,9 @@ function recoverPublishedCheckpoint( // matched against the rehashed visible payload. Only this point permits // recovery cleanup; a crash before it leaves enough durable metadata to retry. cleanupRecoveryManifests(projectRoot, id, expectedTransaction); + // Only the neutral hard link for this verified public inode is ours to + // remove. A distinct neutral can already belong to a later active writer. + removeMatchingPublicationPayload(projectRoot, id, verifiedPayload, digestLimit); return { manifest: verifiedManifest.manifest, metadataSizeBytes: verifiedManifest.sizeBytes, @@ -2317,16 +3620,25 @@ function recoverPublishedCheckpoint( } type CheckpointTestStage = + | "before-reserve-transaction" + | "before-candidate-write" + | "during-candidate-preflight" | "after-recovery-manifest" + | "after-recovery-publication-link" + | "after-neutral-publication" + | "before-publication-link" | "after-payload-publication" | "after-legacy-manifest-temporary" | "before-manifest-displacement" | "after-manifest-displacement"; function checkpointTestBarrier(stage: CheckpointTestStage): void { - const gate = process.env.INKCHECK_TEST_CHECKPOINT_GATE; - if (process.env.NODE_ENV !== "test" || !gate - || process.env.INKCHECK_TEST_CHECKPOINT_GATE_STAGE !== stage) return; + if (process.env.NODE_ENV !== "test") return; + const gate = [ + [process.env.INKCHECK_TEST_CHECKPOINT_GATE, process.env.INKCHECK_TEST_CHECKPOINT_GATE_STAGE], + [process.env.INKCHECK_TEST_CHECKPOINT_GATE_2, process.env.INKCHECK_TEST_CHECKPOINT_GATE_STAGE_2], + ].find((entry) => entry[0] !== undefined && entry[1] === stage)?.[0]; + if (!gate) return; fs.writeFileSync(`${gate}.${process.pid}.ready`, stage); const deadline = Date.now() + 15_000; while (!fs.existsSync(gate)) { @@ -2396,7 +3708,7 @@ function publishCheckpointManifest( function enforceDurableCheckpointLimits( bytes: number, - limits: Required + limits: CheckpointByteStorageLimits ): void { if (bytes > limits.maxCheckpointBytes) { throw new CheckpointSizeLimitError("single", bytes, limits.maxCheckpointBytes); @@ -2445,6 +3757,7 @@ function checkpointWriteAccounting( storedBytesEmitted: 0, peakOutputChunkBytes: 0, }, + ...(written?.framedV2 ? { framedV2: written.framedV2 } : {}), durable: { payloadBytes, manifestBytes, @@ -2458,7 +3771,7 @@ async function reuseCheckpointArtifact( relative: string, identity: CheckpointIdentity, checkpoint: SharedSearchCheckpoint, - limits: Required, + limits: CheckpointByteStorageLimits, expectedTransaction?: CheckpointTransaction, operation?: { outcome: CheckpointWriteAccountingV1["outcome"]; @@ -2467,16 +3780,17 @@ async function reuseCheckpointArtifact( ): Promise { const { id } = identity; const directory = checkpointsDirectory(root); - const existing = checkpointFile(root, id); + let existing = checkpointFile(root, id); let payloadBytes: number; let manifestBytes: number; const recovered = recoverPublishedCheckpoint(root, relative, id, checkpoint, limits, expectedTransaction); if (recovered) { + existing = checkpointFile(root, id); payloadBytes = recovered.payloadSizeBytes; manifestBytes = recovered.metadataSizeBytes; } else { try { - const loaded = loadArtifactDetailed(root, id); + const loaded = await loadArtifactDetailed(root, id); const manifestFile = checkpointManifestFile(root, id); if (!fs.existsSync(manifestFile)) { const manifest = manifestForArtifact( @@ -2501,6 +3815,7 @@ async function reuseCheckpointArtifact( // matches this exact requested checkpoint and a fixed-memory full payload // digest verifies the otherwise-unparseable bytes. if (!(error instanceof CheckpointReadError) || error.kind !== "resource_limit") throw error; + existing = checkpointFile(root, id); if (!fs.existsSync(checkpointManifestFile(root, id))) throw error; const { manifest, sizeBytes: metadataSizeBytes } = readCheckpointManifest(root, id); validateManifestStorage(root, id, existing, manifest); @@ -2532,6 +3847,7 @@ async function reuseCheckpointArtifact( [payloadBytes, manifestBytes] ); enforceDurableCheckpointLimits(sizeBytes, limits); + existing = checkpointFile(root, id); if (process.platform !== "win32") { fs.chmodSync(existing, 0o600); const manifestFile = checkpointManifestFile(root, id); @@ -2539,7 +3855,7 @@ async function reuseCheckpointArtifact( } const pruned = pruneCheckpoints(root, id, limits); if (pruned.length > 0) syncDirectory(directory); - const encoding = checkpointFile(root, id).endsWith(".gz") ? "gzip" : "json"; + const encoding = checkpointStorageEncoding(checkpointFile(root, id)); return { id, path: checkpointRelativePath(id, encoding), @@ -2553,13 +3869,21 @@ async function saveCheckpointArtifactExclusive( relative: string, identity: CheckpointIdentity, checkpoint: SharedSearchCheckpoint, - limits: Required + limits: CheckpointByteStorageLimits, + format: CheckpointArtifactFormat, + signal?: AbortSignal, + framedV2Limits?: Partial ): Promise { const { id } = identity; const directory = checkpointsDirectory(root); - const destination = checkpointDestination(root, id); - const existing = checkpointFile(root, id); - if (fs.existsSync(existing)) return reuseCheckpointArtifact(root, relative, identity, checkpoint, limits); + const destination = checkpointDestination(root, id, format); + const neutral = checkpointPublicationPayloadFile(root, id); + const hasCommittedCandidate = () => fs.existsSync(neutral) + || checkpointCandidateFiles(root, id).some((candidate) => fs.existsSync(candidate)); + if (hasCommittedCandidate()) { + signal?.throwIfAborted(); + return reuseCheckpointArtifact(root, relative, identity, checkpoint, limits); + } // Validate the existing retention set before creating a new durable file. // Corrupt old state must not turn a successful write into a partial cleanup. try { @@ -2568,16 +3892,20 @@ async function saveCheckpointArtifactExclusive( // A same-ID writer can publish between the initial existence check and // retention validation. Reopen its durable transaction instead of making // validation inflate a sidecar-free (and potentially huge) frontier. - if (fs.existsSync(checkpointFile(root, id))) { + if (hasCommittedCandidate()) { + signal?.throwIfAborted(); return reuseCheckpointArtifact(root, relative, identity, checkpoint, limits); } throw error; } - if (fs.existsSync(checkpointFile(root, id))) { + if (hasCommittedCandidate()) { + signal?.throwIfAborted(); return reuseCheckpointArtifact(root, relative, identity, checkpoint, limits); } const artifact: CheckpointArtifact = { - artifactSchemaVersion: CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + artifactSchemaVersion: format === "framed-v2" + ? CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION + : CHECKPOINT_ARTIFACT_SCHEMA_VERSION, artifactType: "shared-search-checkpoint", id, createdAt: new Date().toISOString(), @@ -2589,14 +3917,80 @@ async function saveCheckpointArtifactExclusive( configuration: checkpoint.configuration, checkpoint, }; - const transaction = reserveCheckpointTransaction(root, id); + checkpointTestBarrier("before-reserve-transaction"); + let transaction: CheckpointTransaction; + try { + signal?.throwIfAborted(); + transaction = reserveCheckpointTransaction(root, id); + } catch (error) { + // Cancellation always wins over a concurrently committed candidate. A + // caller that withdrew this request must never receive a reused success. + if (signal?.aborted) signal.throwIfAborted(); + if ((error as Error)?.name === "AbortError") throw error; + // A same-ID writer can commit after the final pre-reservation check while + // this loser is scanning the bounded recovery slots. Its verified durable + // pair makes a slot/preflight failure irrelevant to this logical ID. + if (hasCommittedCandidate()) { + return reuseCheckpointArtifact(root, relative, identity, checkpoint, limits); + } + throw error; + } const temporary = transaction.temporary; let publishedPayload = false; let completedPair = false; let written: WrittenCheckpointArtifact | undefined; try { - written = await writeCompressedArtifact(temporary, artifact, limits, true); - const manifest = manifestForArtifact(artifact, "gzip", written.sizeBytes, written.sha256); + signal?.throwIfAborted(); + checkpointTestBarrier("before-candidate-write"); + // The test barrier models arbitrary synchronous work between the early + // recovery check and format-specific preflight. Recheck withdrawal first. + signal?.throwIfAborted(); + if (hasCommittedCandidate()) { + const reference = await reuseCheckpointArtifact( + root, + relative, + identity, + checkpoint, + limits, + transaction + ); + completedPair = true; + return reference; + } + try { + written = format === "framed-v2" + ? await writeFramedArtifact(temporary, artifact, limits, framedV2Limits, signal) + : await writeCompressedArtifact(temporary, artifact, limits, true, signal); + } catch (error) { + // Cancellation wins even if another writer committed while this request + // was validating its format-specific limits. + if (signal?.aborted) signal.throwIfAborted(); + if ((error as Error)?.name === "AbortError") throw error; + // Only integration preflight failures are proven to occur before a + // destination stream exists. Codec/pipeline failures may still have a + // sibling stream settling, so a momentary file-size probe is not proof + // that no candidate bytes were or will be emitted. + if (!(error instanceof CheckpointFramedPreflightError) + || !hasCommittedCandidate()) throw error; + const reference = await reuseCheckpointArtifact( + root, + relative, + identity, + checkpoint, + limits, + transaction + ); + completedPair = true; + return reference; + } + signal?.throwIfAborted(); + const manifest = manifestForArtifact( + artifact, + format === "framed-v2" ? "framed-v2" : "gzip", + written.sizeBytes, + written.sha256, + written.framedV2Manifest + ); const rawManifest = serializedManifest(manifest); enforceDurableCheckpointLimits(exactByteSum( "durable checkpoint pair byte count", @@ -2607,14 +4001,77 @@ async function saveCheckpointArtifactExclusive( writeRecoveryManifest(root, id, transaction.slot, rawManifest); checkpointTestBarrier("after-recovery-manifest"); checkpointTestCrash("after-recovery-manifest"); + signal?.throwIfAborted(); try { - fs.linkSync(temporary, destination); + // Every layout competes for this one neutral hard-link pathname. The + // winning inode, not the public suffix, is the cross-format no-clobber + // serialization point. Its durable recovery manifest selects the one + // public layout that a retry is allowed to complete. + fs.linkSync(temporary, neutral); publishedPayload = true; syncDirectory(directory); - checkpointTestCrash("after-payload-publication"); + checkpointTestBarrier("after-neutral-publication"); + checkpointTestCrash("after-neutral-publication"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } + if (publishedPayload) { + const visible = checkpointCandidateFiles(root, id).filter((candidate) => fs.existsSync(candidate)); + if (visible.length > 0) { + // A helper can recover this writer's neutral payload, publish the + // manifest-selected public name, and remove the neutral link while the + // owner is paused. The sole payload still belongs to this transaction + // only when it is the same inode as this transaction's neutral/private + // candidate, not merely byte-identical to another deterministic writer. + const digestLimit = Math.min(limits.maxCheckpointBytes, limits.maxProjectBytes); + const publicDigest = visible.length === 1 + ? fileDigest(visible[0], digestLimit) + : undefined; + // The neutral pathname can be reused by a third writer after a helper + // removes this transaction's link. The claimed temporary remains the + // authoritative, protected inode for this writer until cleanup. + const ownerDigest = fileDigest(temporary, digestLimit); + const helperPublishedThisCandidate = publicDigest !== undefined + && path.resolve(visible[0]) === path.resolve(destination) + && publicDigest.sizeBytes === written.sizeBytes + && publicDigest.sha256 === written.sha256 + && samePayloadDigest(ownerDigest, publicDigest); + if (!helperPublishedThisCandidate) { + // A later writer may already own the shared neutral pathname. Only + // this transaction's protected candidate authorizes its removal. + removeMatchingPublicationPayload(root, id, ownerDigest, digestLimit); + publishedPayload = false; + } + } else { + checkpointTestBarrier("before-publication-link"); + try { + fs.linkSync(neutral, destination); + syncDirectory(directory); + checkpointTestCrash("after-payload-publication"); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST" && code !== "ENOENT") throw error; + const current = checkpointCandidateFiles(root, id) + .filter((candidate) => fs.existsSync(candidate)); + const digestLimit = Math.min(limits.maxCheckpointBytes, limits.maxProjectBytes); + // As above, the shared neutral name may already belong to another + // transaction; only this claimed temporary proves ownership. + const ownerDigest = fileDigest(temporary, digestLimit); + const helperPublishedThisCandidate = current.length === 1 + && path.resolve(current[0]) === path.resolve(destination) + && (() => { + const digest = fileDigest(current[0], digestLimit); + return digest.sizeBytes === written.sizeBytes + && digest.sha256 === written.sha256 + && samePayloadDigest(ownerDigest, digest); + })(); + if (current.length > 0 && !helperPublishedThisCandidate) { + removeMatchingPublicationPayload(root, id, ownerDigest, digestLimit); + publishedPayload = false; + } + } + } + } const reference = await reuseCheckpointArtifact( root, relative, @@ -2630,11 +4087,18 @@ async function saveCheckpointArtifactExclusive( completedPair = true; return reference; } catch (error) { + // Before this transaction owns the neutral commit, cancellation must not + // be converted into success merely because a concurrent winner is now + // visible. After neutral publication, recovery is deliberately + // non-cancellable so the durable pair can be completed. + if (!publishedPayload && signal?.aborted) signal.throwIfAborted(); + if (!publishedPayload && (error as Error)?.name === "AbortError") throw error; // Once another writer has completed the canonical pair, its verified // cleanup may unlink this loser's private slot while an fd is still open. // Treat an ensuing path-level ENOENT as loss of the no-clobber race and // reopen the winner; a corrupt/orphan final still fails closed there. - if ((error as NodeJS.ErrnoException).code === "ENOENT" && fs.existsSync(destination)) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" + && checkpointCandidateFiles(root, id).some((candidate) => fs.existsSync(candidate))) { const reference = await reuseCheckpointArtifact( root, relative, @@ -2677,9 +4141,23 @@ export async function saveCheckpointArtifact( const identity = checkpointIdentity(relative, checkpoint); const directory = checkpointsDirectory(root); const limits = storageLimits(inputLimits); + const format = inputLimits.format ?? "legacy-v1"; + if (format !== "legacy-v1" && format !== "framed-v2") { + throw new RangeError("checkpoint format must be legacy-v1 or framed-v2"); + } + inputLimits.signal?.throwIfAborted(); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); if (process.platform !== "win32") fs.chmodSync(directory, 0o700); - return saveCheckpointArtifactExclusive(root, relative, identity, checkpoint, limits); + return saveCheckpointArtifactExclusive( + root, + relative, + identity, + checkpoint, + limits, + format, + inputLimits.signal, + inputLimits.framedV2Limits + ); } export function listCheckpointArtifacts( @@ -2715,7 +4193,7 @@ export async function openCheckpointArtifact( artifact: CheckpointArtifactSummary & { freshness: CheckpointFreshness }; accounting: CheckpointReadAccountingV1; }> { - const loaded = loadArtifactDetailed(projectRoot, id, readLimits); + const loaded = await loadArtifactDetailed(projectRoot, id, readLimits); const current = await freshness(projectRoot, loaded.artifact); return { artifact: { ...summary(projectRoot, loaded), freshness: current.freshness }, @@ -2733,7 +4211,7 @@ export async function loadCheckpointForResume( entrypoint: string; accounting: CheckpointReadAccountingV1; }> { - const loaded = loadArtifactDetailed(projectRoot, id, readLimits); + const loaded = await loadArtifactDetailed(projectRoot, id, readLimits); const current = await freshness(projectRoot, loaded.artifact); if (current.freshness !== "current") { throw new Error(`checkpoint ${id} is ${current.freshness}; resume requires the exact source and knot map used to create it`); diff --git a/src/cli.ts b/src/cli.ts index 8f38076..cc28c76 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -285,6 +285,8 @@ async function main() { if (command === "list" && values.length === 0) { const checkpoints = listCheckpointArtifacts(projectRoot); console.log(json + // Compatibility: this versions the list response envelope. Each + // item reports its persisted layout in artifactSchemaVersion. ? JSON.stringify({ checkpointArtifactSchemaVersion: CHECKPOINT_ARTIFACT_SCHEMA_VERSION, checkpoints }, null, 2) : checkpoints.length ? checkpoints.map((checkpoint) => `${checkpoint.id} ${checkpoint.totalGranted} granted ${checkpoint.createdAt} ${checkpoint.entrypoint}`).join("\n") diff --git a/src/discovery.ts b/src/discovery.ts index 6c1cd68..1c6114b 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -6,6 +6,7 @@ import { VERSION } from "./version"; import { CONFIG_SCHEMA_VERSION } from "./config"; import { CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, DEFAULT_CHECKPOINT_GENERATIONS, DEFAULT_MAX_CHECKPOINT_BYTES, DEFAULT_MAX_PROJECT_CHECKPOINT_BYTES, @@ -54,7 +55,20 @@ export const MAX_INSPECTION_OVERVIEW_BYTES = 16 * 1024; export interface InkcheckCapabilities { schemaVersion: number; inkcheckVersion: string; - schemas: { report: number; config: number; projectInspection: number; artifact: number; checkpointArtifact: number; searchSession: number; regressionArtifact: number; campaignPolicy: number }; + schemas: { + report: number; + config: number; + projectInspection: number; + artifact: number; + /** Latest supported persisted checkpoint artifact envelope. */ + checkpointArtifact: number; + checkpointArtifactSupported: number[]; + checkpointArtifactDefaultWrite: number; + checkpointListResponse: number; + searchSession: number; + regressionArtifact: number; + campaignPolicy: number; + }; limits: { maxDepth: number; maxStates: number; @@ -122,7 +136,13 @@ export function capabilities(): InkcheckCapabilities { config: CONFIG_SCHEMA_VERSION, projectInspection: PROJECT_INSPECTION_SCHEMA_VERSION, artifact: ARTIFACT_SCHEMA_VERSION, - checkpointArtifact: CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + checkpointArtifact: CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + checkpointArtifactSupported: [ + CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + ], + checkpointArtifactDefaultWrite: CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + checkpointListResponse: CHECKPOINT_ARTIFACT_SCHEMA_VERSION, searchSession: SEARCH_SESSION_SCHEMA_VERSION, regressionArtifact: REGRESSION_ARTIFACT_SCHEMA_VERSION, campaignPolicy: CAMPAIGN_POLICY_SCHEMA_VERSION, diff --git a/src/search-sessions.ts b/src/search-sessions.ts index d83000a..0f02779 100644 --- a/src/search-sessions.ts +++ b/src/search-sessions.ts @@ -759,6 +759,7 @@ function campaignDiskBytes( ...[...checkpoints].flatMap((id) => [ path.join(projectRoot, ".inkcheck", "checkpoints", `${id}.json`), path.join(projectRoot, ".inkcheck", "checkpoints", `${id}.json.gz`), + path.join(projectRoot, ".inkcheck", "checkpoints", `${id}.inkcp`), ]), ]; return files.reduce((total, file) => total + (fs.existsSync(file) ? fs.statSync(file).size : 0), 0); diff --git a/test/agent-skill.test.js b/test/agent-skill.test.js index ccbcf03..ee812e4 100644 --- a/test/agent-skill.test.js +++ b/test/agent-skill.test.js @@ -53,6 +53,18 @@ test("bundled agent skill is compact, versioned, progressively linked, and packa const packageFiles = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).files; assert.ok(packageFiles.includes("skills/inkcheck")); + const runtimeSchemas = capabilities().schemas; + const packagedCapabilities = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "tool.json"), "utf8") + ).cli.jsonSchema.capabilities; + assert.strictEqual(runtimeSchemas.checkpointArtifact, 2); + assert.deepStrictEqual(runtimeSchemas.checkpointArtifactSupported, [1, 2]); + assert.strictEqual(runtimeSchemas.checkpointArtifactDefaultWrite, 1); + assert.strictEqual(runtimeSchemas.checkpointListResponse, 1); + for (const field of [ + "checkpointArtifact", "checkpointArtifactSupported", "checkpointArtifactDefaultWrite", + "checkpointListResponse", + ]) assert.match(packagedCapabilities, new RegExp(`\\b${field}\\b`)); }); test("ten golden exercises cover the declared agent QA curriculum and reproduce their signals", async () => { diff --git a/test/checkpoint-artifact-v2.test.js b/test/checkpoint-artifact-v2.test.js new file mode 100644 index 0000000..2a0d83b --- /dev/null +++ b/test/checkpoint-artifact-v2.test.js @@ -0,0 +1,607 @@ +const { test } = require("node:test"); +const assert = require("node:assert"); +const { constants: bufferConstants } = require("node:buffer"); +const crypto = require("node:crypto"); +const { getEventListeners } = require("node:events"); +const { Readable, PassThrough } = require("node:stream"); +const { gzipSync, gunzipSync } = require("node:zlib"); + +const { + CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER, + CHECKPOINT_ARTIFACT_V2_MAGIC, + CheckpointArtifactV2Error, + readCheckpointArtifactV2, + writeCheckpointArtifactV2, +} = require("../dist/checkpoint-artifact-v2"); + +async function artifactFrom(inputs, options = {}) { + const destination = new PassThrough(); + const chunks = []; + destination.on("data", (chunk) => chunks.push(chunk)); + const result = await writeCheckpointArtifactV2(destination, inputs, options); + return { artifact: Buffer.concat(chunks), result }; +} + +function fragmented(buffer, width = 7) { + return Readable.from((function* () { + for (let offset = 0; offset < buffer.length; offset += width) { + yield buffer.subarray(offset, Math.min(offset + width, buffer.length)); + } + })()); +} + +async function readArtifact(artifact, options = {}) { + const records = []; + const result = await readCheckpointArtifactV2(fragmented(artifact), { + ...options, + onRecord: async (record) => { + records.push(record); + await options.onRecord?.(record); + }, + }); + return { records, result }; +} + +function recordPayload(value) { + const body = Buffer.from(JSON.stringify(value), "utf8"); + const prefix = Buffer.allocUnsafe(4); + prefix.writeUInt32BE(body.length, 0); + return Buffer.concat([prefix, body]); +} + +function pseudoRandomBase64(seed, bytes) { + const output = Buffer.allocUnsafe(bytes); + let state = seed >>> 0; + for (let index = 0; index < bytes; index++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + output[index] = state & 0xff; + } + return output.toString("base64"); +} + +function wireFrames(artifact) { + const frames = []; + let offset = CHECKPOINT_ARTIFACT_V2_MAGIC.length; + while (offset < artifact.length) { + const frameOffset = offset; + const headerBytes = artifact.readUInt32BE(offset); + const storedBytes = artifact.readUInt32BE(offset + 4); + offset += 8; + const headerOffset = offset; + const header = JSON.parse(artifact.subarray(offset, offset + headerBytes).toString("utf8")); + offset += headerBytes; + const storedOffset = offset; + offset += storedBytes; + frames.push({ frameOffset, headerOffset, storedOffset, end: offset, headerBytes, storedBytes, header }); + } + return frames; +} + +function wireFrame(header, stored) { + const rawHeader = Buffer.from(JSON.stringify(header), "utf8"); + const prefix = Buffer.allocUnsafe(8); + prefix.writeUInt32BE(rawHeader.length, 0); + prefix.writeUInt32BE(stored.length, 4); + return Buffer.concat([prefix, rawHeader, stored]); +} + +function digest(value) { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function stalledAsyncIterable() { + let markStarted; + const state = { + returnCalls: 0, + started: new Promise((resolve) => { + markStarted = resolve; + }), + }; + state.values = { + [Symbol.asyncIterator]() { + return { + next() { + markStarted(); + return new Promise(() => {}); + }, + return() { + state.returnCalls += 1; + return new Promise(() => {}); + }, + }; + }, + }; + return state; +} + +test("framed v2 round-trips ordered records, auto-splits frames, and reports exact accounting", async () => { + const inputs = [ + { + component: "configuration", + field: "identity", + start: 0, + records: [{ z: 1, a: 2 }, ["x", 3], null], + }, + { + component: "metadata", + field: "summary", + start: 0, + records: [{ states: 4, finished: false }], + }, + ]; + const written = await artifactFrom(inputs, { limits: { maxRecordsPerFrame: 2 } }); + assert.ok(written.artifact.subarray(0, CHECKPOINT_ARTIFACT_V2_MAGIC.length).equals(CHECKPOINT_ARTIFACT_V2_MAGIC)); + assert.strictEqual(written.result.data.frameCount, 3); + assert.strictEqual(written.result.data.recordCount, 4); + assert.strictEqual(written.result.artifactBytes, written.artifact.length); + assert.deepStrictEqual(written.result.components.map((item) => item.component), CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER); + assert.match(written.result.components[0].decodedSha256, /^[0-9a-f]{64}$/); + assert.strictEqual( + written.result.components[1].decodedSha256, + crypto.createHash("sha256").digest("hex"), + "an absent component has the stable empty aggregate digest" + ); + + const opened = await readArtifact(written.artifact); + assert.deepStrictEqual(opened.result, written.result); + assert.deepStrictEqual(opened.records.map(({ component, field, index, value }) => ({ component, field, index, value })), [ + { component: "configuration", field: "identity", index: 0, value: { z: 1, a: 2 } }, + { component: "configuration", field: "identity", index: 1, value: ["x", 3] }, + { component: "configuration", field: "identity", index: 2, value: null }, + { component: "metadata", field: "summary", index: 0, value: { states: 4, finished: false } }, + ]); + assert.strictEqual(JSON.stringify(opened.records[0].value), '{"z":1,"a":2}', "record property order is preserved"); +}); + +test("stored-size overflow deterministically re-splits an incompressible multi-record frame", async () => { + const values = [ + { blob: pseudoRandomBase64(0x12345678, 8 * 1024) }, + { blob: pseudoRandomBase64(0x9abcdef0, 8 * 1024) }, + ]; + const singles = values.map((value) => gzipSync(recordPayload(value), { level: 1 }).length); + const combined = gzipSync(Buffer.concat(values.map(recordPayload)), { level: 1 }).length; + const maxSingle = Math.max(...singles); + assert.ok(combined > maxSingle + 1024, "fixture must distinguish one record from two"); + const storedLimit = Math.floor((maxSingle + combined) / 2); + const decodedBytes = values.map(recordPayload).reduce((sum, value) => sum + value.length, 0); + const written = await artifactFrom([{ + component: "findings", + field: "payload", + start: 0, + records: values, + }], { + limits: { + maxStoredFrameBytes: storedLimit, + maxDecodedFrameBytes: decodedBytes + 32, + maxRecordBytes: Math.max(...values.map((value) => Buffer.byteLength(JSON.stringify(value)))) + 32, + }, + }); + assert.strictEqual(written.result.data.frameCount, 2); + assert.deepStrictEqual(written.result.frames.map((frame) => frame.start), [0, 1]); + assert.ok(written.result.frames.every((frame) => frame.storedBytes <= storedLimit)); + const opened = await readArtifact(written.artifact); + assert.deepStrictEqual(opened.records.map((record) => record.value), values); +}); + +test("reader rejects checksum corruption and any bytes after the terminal index", async () => { + const written = await artifactFrom([{ + component: "scheduler", + field: "state", + start: 0, + records: [{ cursor: 1 }, { cursor: 2 }], + }]); + const first = wireFrames(written.artifact)[0]; + const corrupt = Buffer.from(written.artifact); + corrupt[first.storedOffset + Math.floor(first.storedBytes / 2)] ^= 0x01; + await assert.rejects( + () => readArtifact(corrupt), + (cause) => cause instanceof CheckpointArtifactV2Error && cause.kind === "corrupt" && cause.stage === "payload" + ); + await assert.rejects( + () => readArtifact(Buffer.concat([written.artifact, Buffer.from([0])])), + (cause) => cause instanceof CheckpointArtifactV2Error && cause.kind === "corrupt" && cause.stage === "eof" + ); +}); + +test("corrupt numeric mismatches distinguish decoded bytes from record counts", async () => { + const written = await artifactFrom([{ + component: "scheduler", + field: "state", + start: 0, + records: [{ cursor: 1 }], + }]); + const first = wireFrames(written.artifact)[0]; + const stored = written.artifact.subarray(first.storedOffset, first.end); + + for (const declaredBytes of [first.header.decodedBytes - 1, first.header.decodedBytes + 1]) { + const tampered = Buffer.concat([ + written.artifact.subarray(0, first.frameOffset), + wireFrame({ ...first.header, decodedBytes: declaredBytes }, stored), + written.artifact.subarray(first.end), + ]); + await assert.rejects( + () => readArtifact(tampered), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "corrupt" + && cause.stage === "payload" + && cause.observed === first.header.decodedBytes + && cause.limit === declaredBytes + && cause.metric === "bytes" + ); + } + + const countTampered = Buffer.concat([ + written.artifact.subarray(0, first.frameOffset), + wireFrame({ ...first.header, recordCount: first.header.recordCount + 1 }, stored), + written.artifact.subarray(first.end), + ]); + await assert.rejects( + () => readArtifact(countTampered), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "corrupt" + && cause.stage === "record" + && cause.observed === first.header.recordCount + && cause.limit === first.header.recordCount + 1 + && cause.metric === "count" + ); +}); + +test("magic distinguishes future framing from arbitrary corruption and truncation", async () => { + await assert.rejects( + () => readArtifact(Buffer.from("INKCHECKCP3\r\n", "ascii")), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "unsupported" + && cause.stage === "magic" + ); + await assert.rejects( + () => readArtifact(Buffer.from("INKCHECKCP10\r\n", "ascii")), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "unsupported" + && cause.stage === "magic" + ); + await assert.rejects( + () => readArtifact(Buffer.alloc(CHECKPOINT_ARTIFACT_V2_MAGIC.length, 0x58)), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "corrupt" + && cause.stage === "magic" + ); + await assert.rejects( + () => readArtifact(CHECKPOINT_ARTIFACT_V2_MAGIC.subarray(0, -1)), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "corrupt" + && cause.stage === "magic" + ); +}); + +test("zero-length frame header and stored prefixes are corruption, not limits", async () => { + const zeroHeader = Buffer.alloc(8); + zeroHeader.writeUInt32BE(1, 4); + await assert.rejects( + () => readArtifact(Buffer.concat([CHECKPOINT_ARTIFACT_V2_MAGIC, zeroHeader])), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "corrupt" + && cause.stage === "header" + && cause.metric === undefined + ); + + const zeroStored = Buffer.alloc(8); + zeroStored.writeUInt32BE(1, 0); + await assert.rejects( + () => readArtifact(Buffer.concat([CHECKPOINT_ARTIFACT_V2_MAGIC, zeroStored])), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "corrupt" + && cause.stage === "payload" + && cause.metric === undefined + ); +}); + +test("terminal index binds component checksums and observed frame totals", async () => { + const written = await artifactFrom([{ + component: "semanticIndexes", + field: "entries", + start: 0, + records: [{ key: "alpha" }, { key: "beta" }], + }]); + const frames = wireFrames(written.artifact); + const indexFrame = frames.at(-1); + const decoded = gunzipSync(written.artifact.subarray(indexFrame.storedOffset, indexFrame.end)); + const recordBytes = decoded.readUInt32BE(0); + const index = JSON.parse(decoded.subarray(4, 4 + recordBytes).toString("utf8")); + index.components[5].decodedSha256 = "0".repeat(64); + const replacementDecoded = recordPayload(index); + const replacementStored = gzipSync(replacementDecoded, { level: 1 }); + const replacementHeader = { + ...indexFrame.header, + decodedBytes: replacementDecoded.length, + decodedSha256: crypto.createHash("sha256").update(replacementDecoded).digest("hex"), + storedBytes: replacementStored.length, + storedSha256: crypto.createHash("sha256").update(replacementStored).digest("hex"), + }; + const rawHeader = Buffer.from(JSON.stringify(replacementHeader), "utf8"); + const prefix = Buffer.allocUnsafe(8); + prefix.writeUInt32BE(rawHeader.length, 0); + prefix.writeUInt32BE(replacementStored.length, 4); + const tampered = Buffer.concat([ + written.artifact.subarray(0, indexFrame.frameOffset), + prefix, + rawHeader, + replacementStored, + ]); + let provisionalCallbacks = 0; + await assert.rejects( + () => readArtifact(tampered, { onRecord: () => { provisionalCallbacks += 1; } }), + (cause) => cause instanceof CheckpointArtifactV2Error && cause.kind === "corrupt" && cause.stage === "index" + ); + assert.strictEqual(provisionalCallbacks, 2, "records delivered before terminal verification remain provisional"); +}); + +test("declared bounds fail before payload decode and cancellation is honored between frames", async () => { + const written = await artifactFrom([{ + component: "frontier", + field: "nodes", + start: 0, + records: [{ value: "x".repeat(80) }, { value: "y".repeat(80) }], + }], { limits: { maxRecordsPerFrame: 1 } }); + + await assert.rejects( + () => readArtifact(written.artifact, { + limits: { maxDecodedFrameBytes: 64, maxRecordBytes: 60 }, + }), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "resource_limit" + && cause.stage === "header" + && cause.metric === "bytes" + ); + + const controller = new AbortController(); + let callbacks = 0; + await assert.rejects( + () => readArtifact(written.artifact, { + signal: controller.signal, + onRecord: () => { + callbacks += 1; + controller.abort(); + }, + }), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "cancelled" + && cause.message === "checkpoint artifact operation was cancelled" + ); + assert.strictEqual(callbacks, 1, "abort takes effect at the next frame boundary"); +}); + +test("future frame and index versions remain unsupported when they add fields", async () => { + const written = await artifactFrom([{ + component: "configuration", + field: "identity", + start: 0, + records: [{ value: 1 }], + }]); + const frames = wireFrames(written.artifact); + const first = frames[0]; + const futureHeader = { ...first.header, version: 3, futureFlag: true }; + const futureHeaderArtifact = Buffer.concat([ + written.artifact.subarray(0, first.frameOffset), + wireFrame(futureHeader, written.artifact.subarray(first.storedOffset, first.end)), + written.artifact.subarray(first.end), + ]); + await assert.rejects( + () => readArtifact(futureHeaderArtifact), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "unsupported" + && cause.stage === "header" + ); + + const indexFrame = frames.at(-1); + const decoded = gunzipSync(written.artifact.subarray(indexFrame.storedOffset, indexFrame.end)); + const recordBytes = decoded.readUInt32BE(0); + const futureIndex = JSON.parse(decoded.subarray(4, 4 + recordBytes).toString("utf8")); + futureIndex.version = 3; + futureIndex.futureFlag = true; + const replacementDecoded = recordPayload(futureIndex); + const replacementStored = gzipSync(replacementDecoded, { level: 1 }); + const replacementHeader = { + ...indexFrame.header, + decodedBytes: replacementDecoded.length, + decodedSha256: digest(replacementDecoded), + storedBytes: replacementStored.length, + storedSha256: digest(replacementStored), + }; + const futureIndexArtifact = Buffer.concat([ + written.artifact.subarray(0, indexFrame.frameOffset), + wireFrame(replacementHeader, replacementStored), + ]); + await assert.rejects( + () => readArtifact(futureIndexArtifact), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "unsupported" + && cause.stage === "index" + ); +}); + +test("reader enforces JSON depth before canonical reserialization", async () => { + let value = 0; + for (let depth = 0; depth < 40; depth++) value = [value]; + const written = await artifactFrom([{ + component: "configuration", + field: "identity", + start: 0, + records: [value], + }]); + await assert.rejects( + () => readArtifact(written.artifact, { limits: { maxJsonDepth: 32 } }), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "resource_limit" + && cause.stage === "record" + && cause.observed === 33 + && cause.limit === 32 + && cause.metric === "depth" + ); + + let tooDeep = 0; + for (let depth = 0; depth < 513; depth++) tooDeep = [tooDeep]; + const destination = new PassThrough(); + destination.resume(); + await assert.rejects( + () => writeCheckpointArtifactV2(destination, [{ + component: "configuration", + field: "identity", + start: 0, + records: [tooDeep], + }]), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "resource_limit" + && cause.stage === "record" + && cause.observed === 513 + && cause.limit === 512 + && cause.metric === "depth" + ); + assert.strictEqual(destination.destroyed, true); +}); + +test("resource-limit metrics distinguish frame and record counts from bytes and depth", async () => { + await assert.rejects( + () => artifactFrom([{ + component: "configuration", + field: "identity", + start: 0, + records: [1, 2], + }], { limits: { maxFrames: 1, maxRecordsPerFrame: 1 } }), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "resource_limit" + && cause.stage === "header" + && cause.observed === 2 + && cause.limit === 1 + && cause.metric === "count" + ); + + await assert.rejects( + () => artifactFrom([{ + component: "configuration", + field: "identity", + start: 0, + records: [1, 2], + }], { limits: { maxTotalRecords: 1 } }), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "resource_limit" + && cause.stage === "record" + && cause.observed === 2 + && cause.limit === 1 + && cause.metric === "count" + ); +}); + +test("reader owns its source on errors and abort interrupts a stalled read", async () => { + class OpenReadable extends Readable { + constructor(chunk) { + super(); + this.chunk = chunk; + } + + _read() { + if (!this.chunk) return; + const chunk = this.chunk; + this.chunk = undefined; + this.push(chunk); + } + } + + const invalid = new OpenReadable(Buffer.alloc(CHECKPOINT_ARTIFACT_V2_MAGIC.length, 0x58)); + await assert.rejects( + () => readCheckpointArtifactV2(invalid, { onRecord() {} }), + (cause) => cause instanceof CheckpointArtifactV2Error && cause.stage === "magic" + ); + assert.strictEqual(invalid.destroyed, true, "an early parse failure closes the owned source"); + for (const event of ["close", "error", "end", "readable", "finish"]) { + assert.strictEqual(invalid.listenerCount(event), 0, `iterator ${event} listener is released`); + } + + const stalled = new OpenReadable(); + const controller = new AbortController(); + const reading = readCheckpointArtifactV2(stalled, { + signal: controller.signal, + onRecord() {}, + }); + setImmediate(() => controller.abort()); + await assert.rejects( + () => reading, + (cause) => cause instanceof CheckpointArtifactV2Error && cause.kind === "cancelled" + ); + assert.strictEqual(stalled.destroyed, true, "abort closes a source blocked in iterator.next()"); + assert.strictEqual(getEventListeners(controller.signal, "abort").length, 0, "abort listener is released"); + for (const event of ["close", "error", "end", "readable", "finish"]) { + assert.strictEqual(stalled.listenerCount(event), 0, `aborted iterator ${event} listener is released`); + } +}); + +test("writer aborts stalled outer and record iterators without awaiting return", async () => { + const outer = stalledAsyncIterable(); + const outerDestination = new PassThrough(); + outerDestination.resume(); + const outerController = new AbortController(); + const outerWrite = writeCheckpointArtifactV2(outerDestination, outer.values, { + signal: outerController.signal, + }); + await outer.started; + outerController.abort(); + await assert.rejects( + () => outerWrite, + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "cancelled" + && cause.stage === "prefix" + ); + assert.strictEqual(outer.returnCalls, 1, "outer iterator receives best-effort return"); + assert.strictEqual(outerDestination.destroyed, true, "outer stall releases the owned destination"); + assert.strictEqual(getEventListeners(outerController.signal, "abort").length, 0); + + const records = stalledAsyncIterable(); + const recordDestination = new PassThrough(); + recordDestination.resume(); + const recordController = new AbortController(); + const recordWrite = writeCheckpointArtifactV2(recordDestination, [{ + component: "configuration", + field: "identity", + start: 0, + records: records.values, + }], { signal: recordController.signal }); + await records.started; + recordController.abort(); + await assert.rejects( + () => recordWrite, + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "cancelled" + && cause.stage === "record" + ); + assert.strictEqual(records.returnCalls, 1, "record iterator receives best-effort return"); + assert.strictEqual(recordDestination.destroyed, true, "record stall releases the owned destination"); + assert.strictEqual(getEventListeners(recordController.signal, "abort").length, 0); +}); + +test("configured limits cannot exceed runtime string or structural ceilings", async () => { + const source = new Readable({ read() {} }); + await assert.rejects( + () => readCheckpointArtifactV2(source, { + limits: { maxHeaderBytes: bufferConstants.MAX_STRING_LENGTH + 1 }, + onRecord() {}, + }), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "resource_limit" + && cause.stage === "header" + && cause.limit === bufferConstants.MAX_STRING_LENGTH + && cause.metric === "bytes" + ); + assert.strictEqual(source.destroyed, true, "invalid read limits still close the owned source"); + + const destination = new PassThrough(); + await assert.rejects( + () => writeCheckpointArtifactV2(destination, [], { limits: { maxJsonDepth: 513 } }), + (cause) => cause instanceof CheckpointArtifactV2Error + && cause.kind === "resource_limit" + && cause.stage === "record" + && cause.limit === 512 + && cause.metric === "depth" + ); + assert.strictEqual(destination.destroyed, true, "invalid write limits close the owned destination"); +}); diff --git a/test/checkpoint-v2.test.js b/test/checkpoint-v2.test.js new file mode 100644 index 0000000..add9d88 --- /dev/null +++ b/test/checkpoint-v2.test.js @@ -0,0 +1,1359 @@ +const { test } = require("node:test"); +const assert = require("node:assert"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn, spawnSync } = require("node:child_process"); +const { Readable } = require("node:stream"); +const checkpointV2Codec = require("../dist/checkpoint-artifact-v2"); +const { + CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER, + CHECKPOINT_ARTIFACT_V2_MAGIC, + CheckpointArtifactV2Error, + DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS, + readCheckpointArtifactV2, + writeCheckpointArtifactV2, +} = checkpointV2Codec; + +const { compile, scanKnots } = require("../dist/inklecate"); +const { exploreSharedResumable } = require("../dist/explore"); +const { + CheckpointReadError, + listCheckpointArtifacts, + loadCheckpointForResume, + openCheckpointArtifact, + saveCheckpointArtifact, +} = require("../dist/checkpoints"); + +const FIXTURE = path.join(__dirname, "fixtures", "search", "low-dedup-wide.ink"); +const CLI = path.join(__dirname, "..", "dist", "cli.js"); + +async function projectCheckpoint(maxStates = 73) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "inkcheck-checkpoint-v2-")); + const story = path.join(root, "story.ink"); + fs.copyFileSync(FIXTURE, story); + const compiled = await compile(story); + assert.strictEqual(compiled.success, true); + const knots = scanKnots(story); + const options = { + maxDepth: 150, + maxStates, + seed: 7, + preserveTurnState: false, + preserveRandomState: false, + sharedObservabilityIntervalStates: 25, + }; + const checkpoint = exploreSharedResumable(compiled.storyJson, knots, [], options).checkpoint; + assert.ok(checkpoint); + return { root, story, compiled, knots, options, checkpoint }; +} + +function checkpointDirectory(root) { + return path.join(root, ".inkcheck", "checkpoints"); +} + +function publicLayouts(root, id) { + const directory = checkpointDirectory(root); + if (!fs.existsSync(directory)) return []; + return fs.readdirSync(directory).filter((name) => new RegExp( + `^${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.(?:inkcp|json|json\\.gz)$` + ).test(name)); +} + +function assertPublicFramedSummary(summary) { + assert.deepStrictEqual(Object.keys(summary), [ + "schemaVersion", "componentOrder", "artifactBytes", "artifactOverheadBytes", + "data", "components", "index", + ]); + assert.strictEqual(summary.schemaVersion, 2); + assert.deepStrictEqual(summary.componentOrder, CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER); + for (const value of [summary.artifactBytes, summary.artifactOverheadBytes]) { + assert.ok(Number.isSafeInteger(value) && value >= 0); + } + assert.deepStrictEqual(Object.keys(summary.data), [ + "frameCount", "recordCount", "decodedBytes", "storedBytes", "maxRecordBytes", + ]); + assert.deepStrictEqual(summary.components.map((component) => component.component), + CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER); + for (const component of summary.components) { + assert.deepStrictEqual(Object.keys(component), [ + "component", "frameCount", "recordCount", "decodedBytes", "storedBytes", "maxRecordBytes", + ]); + for (const value of Object.values(component).slice(1)) { + assert.ok(Number.isSafeInteger(value) && value >= 0); + } + } + assert.deepStrictEqual(Object.keys(summary.index), [ + "sequence", "headerBytes", "decodedBytes", "storedBytes", "maxRecordBytes", + ]); + for (const value of [...Object.values(summary.data), ...Object.values(summary.index)]) { + assert.ok(Number.isSafeInteger(value) && value >= 0); + } +} + +function runSaveWorker(root, format, extraEnv = {}) { + const source = String.raw` + const fs = require("node:fs"); + const path = require("node:path"); + const { compile, scanKnots } = require(${JSON.stringify(path.join(__dirname, "..", "dist", "inklecate.js"))}); + const { exploreSharedResumable } = require(${JSON.stringify(path.join(__dirname, "..", "dist", "explore.js"))}); + const { saveCheckpointArtifact } = require(${JSON.stringify(path.join(__dirname, "..", "dist", "checkpoints.js"))}); + (async () => { + const root = process.argv[1]; + const story = path.join(root, "story.ink"); + const compiled = await compile(story); + const checkpoint = exploreSharedResumable(compiled.storyJson, scanKnots(story), [], { + maxDepth: 150, maxStates: 73, seed: 7, + preserveTurnState: false, preserveRandomState: false, + sharedObservabilityIntervalStates: 25, + }).checkpoint; + if (process.env.INKCHECK_TEST_FIXED_TIME) { + const NativeDate = Date; + const fixedTime = NativeDate.parse(process.env.INKCHECK_TEST_FIXED_TIME); + global.Date = class extends NativeDate { + constructor(...values) { + if (values.length > 0) super(...values); + else super(fixedTime); + } + static now() { return fixedTime; } + }; + } + const options = { format: process.argv[2] }; + if (process.env.INKCHECK_TEST_MAX_CHECKPOINT_BYTES) { + options.maxCheckpointBytes = Number(process.env.INKCHECK_TEST_MAX_CHECKPOINT_BYTES); + } + if (process.env.INKCHECK_TEST_MAX_PROJECT_BYTES) { + options.maxProjectBytes = Number(process.env.INKCHECK_TEST_MAX_PROJECT_BYTES); + } + if (process.env.INKCHECK_TEST_V2_MAX_TOTAL_RECORDS) { + options.framedV2Limits = { + ...options.framedV2Limits, + maxTotalRecords: Number(process.env.INKCHECK_TEST_V2_MAX_TOTAL_RECORDS), + }; + } + const abortFile = process.env.INKCHECK_TEST_ABORT_FILE; + if (abortFile) { + options.signal = { + get aborted() { return fs.existsSync(abortFile); }, + throwIfAborted() { + if (!this.aborted) return; + const error = new Error("checkpoint test request aborted"); + error.name = "AbortError"; + throw error; + }, + addEventListener() {}, + removeEventListener() {}, + }; + } + const reference = await saveCheckpointArtifact(root, story, checkpoint, options); + process.stdout.write(JSON.stringify(reference)); + })().catch((error) => { console.error(error); process.exitCode = 1; }); + `; + const child = spawn(process.execPath, ["-e", source, root, format], { + cwd: path.join(__dirname, ".."), + env: { ...process.env, ...extraEnv }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + return { + child, + completed: new Promise((resolve) => child.on("close", (status, signal) => { + resolve({ status, signal, stdout, stderr }); + })), + }; +} + +async function waitFor(file, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (!fs.existsSync(file)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${file}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +test("framed v2 preserves the schema-v1 stable ID, logical checkpoint, and exact split resume", async () => { + const legacy = await projectCheckpoint(); + const framed = await projectCheckpoint(); + try { + const v1 = await saveCheckpointArtifact(legacy.root, legacy.story, legacy.checkpoint); + const v2 = await saveCheckpointArtifact(framed.root, framed.story, framed.checkpoint, { + format: "framed-v2", + }); + const expectedId = `checkpoint-${crypto.createHash("sha256") + .update("story.ink").update("\0").update(JSON.stringify(framed.checkpoint)) + .digest("hex").slice(0, 24)}`; + assert.strictEqual(v1.id, expectedId); + assert.strictEqual(v2.id, expectedId); + assert.match(v1.path, /\.json\.gz$/); + assert.match(v2.path, /\.inkcp$/); + assert.strictEqual("framedV2" in v1.accounting, false); + assert.strictEqual(v1.accounting.serialization.status, "applied"); + assert.strictEqual(v1.accounting.compression.status, "applied"); + assert.strictEqual(v2.accounting.serialization.status, "not_applied"); + assert.strictEqual(v2.accounting.compression.status, "not_applied"); + assert.strictEqual(v2.accounting.framedV2.status, "applied"); + + const loaded = await loadCheckpointForResume(framed.root, v2.id); + assert.deepStrictEqual(loaded.checkpoint, framed.checkpoint); + const resumed = exploreSharedResumable( + framed.compiled.storyJson, + framed.knots, + [], + { ...framed.options, maxStates: 500 }, + loaded.checkpoint + ); + const uninterrupted = exploreSharedResumable( + framed.compiled.storyJson, + framed.knots, + [], + { ...framed.options, maxStates: 500 } + ); + assert.deepStrictEqual(resumed, uninterrupted); + + const listed = listCheckpointArtifacts(framed.root); + assert.strictEqual(listCheckpointArtifacts(legacy.root)[0].artifactSchemaVersion, 1); + assert.strictEqual(listed[0].artifactSchemaVersion, 2); + assert.strictEqual(listed[0].storageEncoding, "framed-v2"); + assert.strictEqual(listed[0].framedV2.components.length, 8); + assert.doesNotMatch(JSON.stringify(listed[0].framedV2), /sha256/i, + "public metadata projects numeric component totals without private checksums"); + const manifest = JSON.parse(fs.readFileSync( + path.join(checkpointDirectory(framed.root), `${v2.id}.meta.json`), + "utf8" + )); + assert.match(manifest.framedV2.components[0].decodedSha256, /^[0-9a-f]{64}$/); + } finally { + fs.rmSync(legacy.root, { recursive: true, force: true }); + fs.rmSync(framed.root, { recursive: true, force: true }); + } +}); + +test("CLI list/show distinguish their response schema from a framed artifact schema", async () => { + const fixture = await projectCheckpoint(); + try { + const checkpoint = structuredClone(fixture.checkpoint); + checkpoint.state.runtimeWarnings.push("PRIVATE_AUTHORED_WARNING_7f3a"); + const retainedNode = checkpoint.state.nodes.find((node) => node !== null + && node.stateJson !== undefined && node.choiceText !== undefined); + assert.ok(retainedNode); + retainedNode.stateJson = JSON.stringify({ privateMarker: "PRIVATE_STATE_7f3a" }); + retainedNode.choiceText = "PRIVATE_CHOICE_7f3a"; + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, checkpoint, { + format: "framed-v2", + }); + const listed = spawnSync(process.execPath, [CLI, "checkpoints", "list", "--json"], { + cwd: fixture.root, + encoding: "utf8", + }); + assert.strictEqual(listed.status, 0, listed.stderr); + const listOutput = JSON.parse(listed.stdout); + assert.strictEqual(listOutput.checkpointArtifactSchemaVersion, 1, + "the compatibility top-level field versions the list response"); + assert.strictEqual(listOutput.checkpoints[0].id, saved.id); + assert.strictEqual(listOutput.checkpoints[0].artifactSchemaVersion, 2); + assert.strictEqual(listOutput.checkpoints[0].storageEncoding, "framed-v2"); + assertPublicFramedSummary(listOutput.checkpoints[0].framedV2); + + const shown = spawnSync(process.execPath, [CLI, "checkpoints", "show", saved.id, "--json"], { + cwd: fixture.root, + encoding: "utf8", + }); + assert.strictEqual(shown.status, 0, shown.stderr); + const showOutput = JSON.parse(shown.stdout); + assert.strictEqual(showOutput.artifact.id, saved.id); + assert.strictEqual(showOutput.artifact.artifactSchemaVersion, 2); + assert.strictEqual(showOutput.artifact.storageEncoding, "framed-v2"); + assert.strictEqual(showOutput.artifact.freshness, "current"); + assertPublicFramedSummary(showOutput.artifact.framedV2); + assert.deepStrictEqual(showOutput.artifact.framedV2, listOutput.checkpoints[0].framedV2); + for (const raw of [listed.stdout, shown.stdout]) { + assert.doesNotMatch(raw, /PRIVATE_(?:AUTHORED_WARNING|STATE|CHOICE)_7f3a/); + assert.doesNotMatch(raw, + /"(?:frames|field|decodedSha256|storedSha256|stateJson|variables|choiceText|runtimeWarnings)"\s*:/); + assert.doesNotMatch(raw, /sha256/i); + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("outer byte caps do not silently raise framed codec defaults", async () => { + const fixture = await projectCheckpoint(); + const originalWrite = checkpointV2Codec.writeCheckpointArtifactV2; + const originalRead = checkpointV2Codec.readCheckpointArtifactV2; + let writeLimits; + let readLimits; + checkpointV2Codec.writeCheckpointArtifactV2 = (destination, inputs, options) => { + writeLimits = options?.limits; + return originalWrite(destination, inputs, options); + }; + checkpointV2Codec.readCheckpointArtifactV2 = (source, options) => { + readLimits = options?.limits; + return originalRead(source, options); + }; + try { + const raisedOuterCap = DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalStoredBytes + 1024; + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + maxCheckpointBytes: raisedOuterCap, + maxProjectBytes: raisedOuterCap, + }); + await openCheckpointArtifact(fixture.root, saved.id, { + maxStoredBytes: raisedOuterCap, + maxDecompressedBytes: raisedOuterCap, + }); + assert.strictEqual( + writeLimits.maxTotalStoredBytes, + DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalStoredBytes + ); + assert.strictEqual( + readLimits.maxTotalStoredBytes, + DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalStoredBytes + ); + assert.ok( + readLimits.maxTotalDecodedBytes <= DEFAULT_CHECKPOINT_ARTIFACT_V2_LIMITS.maxTotalDecodedBytes + ); + } finally { + checkpointV2Codec.writeCheckpointArtifactV2 = originalWrite; + checkpointV2Codec.readCheckpointArtifactV2 = originalRead; + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("opposite format requests reuse one verified layout and mixed generations retain deterministically", async () => { + const first = await projectCheckpoint(10); + const second = await projectCheckpoint(10); + try { + const legacy = await saveCheckpointArtifact(first.root, first.story, first.checkpoint); + const requestedV2 = await saveCheckpointArtifact(first.root, first.story, first.checkpoint, { + format: "framed-v2", + }); + assert.strictEqual(requestedV2.path, legacy.path); + assert.strictEqual(requestedV2.accounting.outcome, "reused"); + assert.deepStrictEqual(publicLayouts(first.root, legacy.id), [`${legacy.id}.json.gz`]); + + const framed = await saveCheckpointArtifact(second.root, second.story, second.checkpoint, { + format: "framed-v2", + }); + const requestedLegacy = await saveCheckpointArtifact(second.root, second.story, second.checkpoint); + assert.strictEqual(requestedLegacy.path, framed.path); + assert.strictEqual(requestedLegacy.accounting.outcome, "reused"); + assert.deepStrictEqual(publicLayouts(second.root, framed.id), [`${framed.id}.inkcp`]); + + for (const [budget, format] of [[20, "legacy-v1"], [30, "framed-v2"], [40, "legacy-v1"]]) { + const checkpoint = exploreSharedResumable(second.compiled.storyJson, second.knots, [], { + ...second.options, + maxStates: budget, + }).checkpoint; + await saveCheckpointArtifact(second.root, second.story, checkpoint, { format }); + } + const retained = listCheckpointArtifacts(second.root); + assert.strictEqual(retained.length, 3); + assert.ok(retained.some((item) => item.storageEncoding === "framed-v2")); + assert.ok(retained.some((item) => item.storageEncoding === "gzip")); + } finally { + fs.rmSync(first.root, { recursive: true, force: true }); + fs.rmSync(second.root, { recursive: true, force: true }); + } +}); + +test("a real cross-process mixed-format race joins the winner published between visible check and link", async () => { + const fixture = await projectCheckpoint(); + const gate = path.join(fixture.root, "neutral-gate"); + try { + const v2 = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: gate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "before-publication-link", + }); + await waitFor(`${gate}.${v2.child.pid}.ready`); + const v1 = runSaveWorker(fixture.root, "legacy-v1", { NODE_ENV: "test" }); + const v1Result = await v1.completed; + assert.strictEqual(v1Result.status, 0, v1Result.stderr); + fs.writeFileSync(gate, "release"); + const v2Result = await v2.completed; + assert.strictEqual(v2Result.status, 0, v2Result.stderr); + const left = JSON.parse(v1Result.stdout); + const right = JSON.parse(v2Result.stdout); + assert.strictEqual(left.id, right.id); + assert.strictEqual(left.accounting.outcome, "reused"); + assert.strictEqual("framedV2" in left.accounting, false); + assert.strictEqual(right.accounting.outcome, "created"); + assert.strictEqual(right.accounting.framedV2.status, "applied"); + assert.strictEqual(right.accounting.framedV2.codec.artifactBytes, + fs.statSync(path.join(checkpointDirectory(fixture.root), `${right.id}.inkcp`)).size); + assert.deepStrictEqual(publicLayouts(fixture.root, left.id), [`${left.id}.inkcp`]); + assert.strictEqual(listCheckpointArtifacts(fixture.root).length, 1); + assert.strictEqual(fs.readdirSync(checkpointDirectory(fixture.root)) + .some((name) => name.includes(left.id) && name.startsWith(".")), false); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("helper recovery preserves the neutral owner's created framed receipt", async () => { + const fixture = await projectCheckpoint(); + const ownerGate = path.join(fixture.root, "helper-recovery-owner-gate"); + const helperGate = path.join(fixture.root, "helper-recovery-helper-gate"); + try { + const owner = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: ownerGate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "after-neutral-publication", + }); + await waitFor(`${ownerGate}.${owner.child.pid}.ready`); + const helper = runSaveWorker(fixture.root, "legacy-v1", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: helperGate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "after-recovery-publication-link", + }); + await waitFor(`${helperGate}.${helper.child.pid}.ready`); + assert.strictEqual(fs.readdirSync(checkpointDirectory(fixture.root)) + .some((name) => name.endsWith(".payload.commit")), true, + "the neutral relink path remains while helper verification is paused"); + fs.writeFileSync(ownerGate, "release"); + const ownerResult = await owner.completed; + assert.strictEqual(ownerResult.status, 0, ownerResult.stderr); + fs.writeFileSync(helperGate, "release"); + const helperResult = await helper.completed; + assert.strictEqual(helperResult.status, 0, helperResult.stderr); + + const created = JSON.parse(ownerResult.stdout); + const reused = JSON.parse(helperResult.stdout); + assert.strictEqual(created.id, reused.id); + assert.match(created.path, /\.inkcp$/); + assert.strictEqual(created.accounting.outcome, "created"); + assert.strictEqual(created.accounting.serialization.status, "not_applied"); + assert.strictEqual(created.accounting.compression.status, "not_applied"); + assert.strictEqual(created.accounting.framedV2.status, "applied"); + assert.strictEqual(created.accounting.framedV2.codec.artifactBytes, + fs.statSync(path.join(checkpointDirectory(fixture.root), `${created.id}.inkcp`)).size); + assert.strictEqual(reused.accounting.outcome, "reused"); + assert.strictEqual("framedV2" in reused.accounting, false); + assert.deepStrictEqual(publicLayouts(fixture.root, created.id), [`${created.id}.inkcp`]); + + const later = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint); + assert.strictEqual(later.accounting.outcome, "reused"); + assert.strictEqual(later.path, created.path); + assert.strictEqual(fs.readdirSync(checkpointDirectory(fixture.root)) + .some((name) => name.startsWith(`.${created.id}.`)), false, + "helper and owner cleanup leave no winner slot that can be mistaken for a loser"); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("public recovery stays authoritative while a third writer reuses the neutral link", async () => { + const fixture = await projectCheckpoint(); + const contenderManifestGate = path.join(fixture.root, "three-writer-contender-manifest-gate"); + const contenderNeutralGate = path.join(fixture.root, "three-writer-contender-neutral-gate"); + const ownerNeutralGate = path.join(fixture.root, "three-writer-owner-neutral-gate"); + let contender; + let owner; + try { + contender = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: contenderManifestGate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "after-recovery-manifest", + INKCHECK_TEST_CHECKPOINT_GATE_2: contenderNeutralGate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE_2: "after-neutral-publication", + }); + await waitFor(`${contenderManifestGate}.${contender.child.pid}.ready`); + + owner = runSaveWorker(fixture.root, "legacy-v1", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: ownerNeutralGate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "after-neutral-publication", + // The canonical legacy pair fits this cap, while the later framed + // neutral does not. Public recovery must reject the foreign inode by + // stat before attempting a checksum under the owner's lower limit. + INKCHECK_TEST_MAX_CHECKPOINT_BYTES: "20000", + INKCHECK_TEST_MAX_PROJECT_BYTES: "1000000", + }); + await waitFor(`${ownerNeutralGate}.${owner.child.pid}.ready`); + + // This helper publishes the legacy owner's neutral inode and removes that + // link after verifying the canonical pair. The framed contender can then + // occupy the same neutral pathname with a different inode. + const helper = runSaveWorker(fixture.root, "framed-v2", { NODE_ENV: "test" }); + const helperResult = await helper.completed; + assert.strictEqual(helperResult.status, 0, helperResult.stderr); + const reusedByHelper = JSON.parse(helperResult.stdout); + assert.match(reusedByHelper.path, /\.json\.gz$/); + assert.strictEqual(reusedByHelper.accounting.outcome, "reused"); + + fs.writeFileSync(contenderManifestGate, "release"); + await waitFor(`${contenderNeutralGate}.${contender.child.pid}.ready`); + + const directory = checkpointDirectory(fixture.root); + const neutral = path.join(directory, `.${reusedByHelper.id}.payload.commit`); + const publicPayload = path.join(directory, `${reusedByHelper.id}.json.gz`); + const neutralStat = fs.statSync(neutral); + const publicStat = fs.statSync(publicPayload); + assert.notDeepStrictEqual( + [neutralStat.dev, neutralStat.ino], + [publicStat.dev, publicStat.ino], + "the later contender owns a distinct neutral inode" + ); + const contenderTemporary = fs.readdirSync(directory) + .filter((name) => name.startsWith(`.${reusedByHelper.id}.recovery-`) + && name.endsWith(".payload.tmp")) + .map((name) => path.join(directory, name)) + .find((candidate) => { + const stat = fs.statSync(candidate); + return stat.dev === neutralStat.dev && stat.ino === neutralStat.ino; + }); + assert.ok(contenderTemporary, "the later neutral remains bound to its protected candidate"); + + fs.writeFileSync(ownerNeutralGate, "release"); + const ownerResult = await owner.completed; + assert.strictEqual(ownerResult.status, 0, ownerResult.stderr); + const created = JSON.parse(ownerResult.stdout); + assert.strictEqual(created.accounting.outcome, "created"); + assert.strictEqual(created.accounting.serialization.status, "applied"); + assert.strictEqual(created.accounting.compression.status, "applied"); + + const neutralAfterOwner = fs.statSync(neutral); + assert.deepStrictEqual( + [neutralAfterOwner.dev, neutralAfterOwner.ino], + [neutralStat.dev, neutralStat.ino], + "the legacy owner leaves the later writer's foreign neutral intact" + ); + + fs.writeFileSync(contenderNeutralGate, "release"); + const contenderResult = await contender.completed; + assert.strictEqual(contenderResult.status, 0, contenderResult.stderr); + const reusedByContender = JSON.parse(contenderResult.stdout); + assert.strictEqual(reusedByContender.accounting.outcome, "reused"); + assert.strictEqual(reusedByContender.accounting.framedV2.status, "applied"); + + const results = [created, reusedByHelper, reusedByContender]; + assert.strictEqual(results.filter((result) => result.accounting.outcome === "created").length, 1); + assert.ok(results.every((result) => result.id === created.id && result.path === created.path)); + assert.deepStrictEqual(publicLayouts(fixture.root, created.id), [`${created.id}.json.gz`]); + assert.strictEqual(listCheckpointArtifacts(fixture.root).length, 1); + assert.strictEqual(fs.readdirSync(directory) + .some((name) => name.startsWith(`.${created.id}.`)), false, + "all three transactions leave no hidden same-ID state"); + } finally { + if (fs.existsSync(fixture.root)) { + for (const gate of [contenderManifestGate, contenderNeutralGate, ownerNeutralGate]) { + try { fs.writeFileSync(gate, "release"); } catch {} + } + } + await Promise.allSettled([ + contender?.completed ?? Promise.resolve(), + owner?.completed ?? Promise.resolve(), + ]); + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("byte-identical v2 writers use inode identity to join the distinct public winner", async () => { + const fixture = await projectCheckpoint(); + const preflightGate = path.join(fixture.root, "identical-v2-preflight-gate"); + const writtenGate = path.join(fixture.root, "identical-v2-written-gate"); + const fixedTime = "2030-01-02T03:04:05.000Z"; + try { + const loser = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: preflightGate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "during-candidate-preflight", + INKCHECK_TEST_CHECKPOINT_GATE_2: writtenGate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE_2: "after-recovery-manifest", + INKCHECK_TEST_FIXED_TIME: fixedTime, + }); + await waitFor(`${preflightGate}.${loser.child.pid}.ready`); + const winner = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_FIXED_TIME: fixedTime, + }); + const winnerResult = await winner.completed; + assert.strictEqual(winnerResult.status, 0, winnerResult.stderr); + const created = JSON.parse(winnerResult.stdout); + fs.writeFileSync(preflightGate, "release"); + await waitFor(`${writtenGate}.${loser.child.pid}.ready`); + + const directory = checkpointDirectory(fixture.root); + const privateCandidates = fs.readdirSync(directory) + .filter((name) => name.startsWith(`.${created.id}.recovery-`) + && name.endsWith(".payload.tmp")); + assert.strictEqual(privateCandidates.length, 1); + const privateCandidate = path.join(directory, privateCandidates[0]); + const publicPayload = path.join(directory, `${created.id}.inkcp`); + assert.strictEqual( + crypto.createHash("sha256").update(fs.readFileSync(privateCandidate)).digest("hex"), + crypto.createHash("sha256").update(fs.readFileSync(publicPayload)).digest("hex"), + "fixed createdAt makes the two independently encoded payloads byte-identical" + ); + const privateStat = fs.statSync(privateCandidate); + const publicStat = fs.statSync(publicPayload); + assert.notDeepStrictEqual( + [privateStat.dev, privateStat.ino], + [publicStat.dev, publicStat.ino], + "byte-identical candidates are still distinct publication identities" + ); + + fs.writeFileSync(writtenGate, "release"); + const loserResult = await loser.completed; + assert.strictEqual(loserResult.status, 0, loserResult.stderr); + + const reused = JSON.parse(loserResult.stdout); + assert.strictEqual(created.id, reused.id); + assert.strictEqual(created.path, reused.path); + assert.match(created.path, /\.inkcp$/); + assert.strictEqual(created.accounting.outcome, "created"); + assert.strictEqual(reused.accounting.outcome, "reused"); + assert.strictEqual(created.accounting.framedV2.status, "applied"); + assert.strictEqual(reused.accounting.framedV2.status, "applied", + "the byte-identical loser truthfully reports its completed private encode"); + assert.strictEqual(created.accounting.framedV2.codec.artifactBytes, + reused.accounting.framedV2.codec.artifactBytes); + assert.deepStrictEqual(publicLayouts(fixture.root, created.id), [`${created.id}.inkcp`]); + assert.strictEqual(listCheckpointArtifacts(fixture.root).length, 1); + assert.strictEqual(fs.readdirSync(checkpointDirectory(fixture.root)) + .some((name) => name.startsWith(`.${created.id}.`)), false); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("restrictive v2 preflight joins a committed legacy winner with truthful not-applied accounting", async () => { + const fixture = await projectCheckpoint(); + const gate = path.join(fixture.root, "preflight-gate"); + try { + const loser = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: gate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "during-candidate-preflight", + INKCHECK_TEST_V2_MAX_TOTAL_RECORDS: "1", + }); + await waitFor(`${gate}.${loser.child.pid}.ready`); + const winner = runSaveWorker(fixture.root, "legacy-v1", { NODE_ENV: "test" }); + const winnerResult = await winner.completed; + assert.strictEqual(winnerResult.status, 0, winnerResult.stderr); + fs.writeFileSync(gate, "release"); + const loserResult = await loser.completed; + assert.strictEqual(loserResult.status, 0, loserResult.stderr); + + const created = JSON.parse(winnerResult.stdout); + const reused = JSON.parse(loserResult.stdout); + assert.strictEqual(created.id, reused.id); + assert.match(reused.path, /\.json\.gz$/); + assert.strictEqual(reused.accounting.outcome, "reused"); + assert.strictEqual(reused.accounting.serialization.status, "not_applied"); + assert.strictEqual(reused.accounting.compression.status, "not_applied"); + assert.strictEqual("framedV2" in reused.accounting, false); + assert.deepStrictEqual(publicLayouts(fixture.root, reused.id), [`${reused.id}.json.gz`]); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("an aborted loser cannot reuse a winner committed while candidate work is gated", async () => { + const fixture = await projectCheckpoint(); + const gate = path.join(fixture.root, "abort-winner-gate"); + const abortFile = path.join(fixture.root, "abort-request"); + try { + const loser = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: gate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "before-candidate-write", + INKCHECK_TEST_ABORT_FILE: abortFile, + }); + await waitFor(`${gate}.${loser.child.pid}.ready`); + const winner = runSaveWorker(fixture.root, "legacy-v1", { NODE_ENV: "test" }); + const winnerResult = await winner.completed; + assert.strictEqual(winnerResult.status, 0, winnerResult.stderr); + fs.writeFileSync(abortFile, "abort"); + fs.writeFileSync(gate, "release"); + const loserResult = await loser.completed; + assert.strictEqual(loserResult.status, 1, loserResult.stderr); + assert.match(loserResult.stderr, /AbortError/); + + const created = JSON.parse(winnerResult.stdout); + assert.deepStrictEqual(publicLayouts(fixture.root, created.id), [`${created.id}.json.gz`]); + assert.deepStrictEqual( + (await loadCheckpointForResume(fixture.root, created.id)).checkpoint, + fixture.checkpoint + ); + assert.strictEqual(fs.readdirSync(checkpointDirectory(fixture.root)) + .some((name) => name.startsWith(`.${created.id}.`)), false); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("an outer ENOENT winner fallback preserves cancellation before neutral publication", async () => { + const fixture = await projectCheckpoint(); + const originalCodecWrite = checkpointV2Codec.writeCheckpointArtifactV2; + const originalWriteFileSync = fs.writeFileSync; + let encodedResolve; + let releaseResolve; + const encoded = new Promise((resolve) => { encodedResolve = resolve; }); + const release = new Promise((resolve) => { releaseResolve = resolve; }); + let armAbort = false; + let aborted = false; + const signal = { + get aborted() { return aborted; }, + throwIfAborted() { + if (armAbort) { + armAbort = false; + aborted = true; + return; + } + if (!aborted) return; + const error = new Error("checkpoint test request aborted"); + error.name = "AbortError"; + throw error; + }, + addEventListener() {}, + removeEventListener() {}, + }; + checkpointV2Codec.writeCheckpointArtifactV2 = async (...args) => { + const result = await originalCodecWrite(...args); + encodedResolve(); + await release; + return result; + }; + try { + const losingSave = saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + signal, + }); + await encoded; + const winner = runSaveWorker(fixture.root, "legacy-v1", { NODE_ENV: "test" }); + const winnerResult = await winner.completed; + assert.strictEqual(winnerResult.status, 0, winnerResult.stderr); + fs.writeFileSync = (file, ...args) => { + if (aborted && typeof file === "string" && /\.recovery-\d+\.meta\.json$/.test(file)) { + const error = new Error("simulated recovery-manifest path loss"); + error.code = "ENOENT"; + throw error; + } + return originalWriteFileSync(file, ...args); + }; + armAbort = true; + releaseResolve(); + await assert.rejects(losingSave, (error) => error?.name === "AbortError"); + + const created = JSON.parse(winnerResult.stdout); + assert.deepStrictEqual(publicLayouts(fixture.root, created.id), [`${created.id}.json.gz`]); + assert.strictEqual((await saveCheckpointArtifact( + fixture.root, + fixture.story, + fixture.checkpoint, + { format: "framed-v2" } + )).accounting.outcome, "reused"); + } finally { + checkpointV2Codec.writeCheckpointArtifactV2 = originalCodecWrite; + fs.writeFileSync = originalWriteFileSync; + releaseResolve?.(); + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("a committed winner supersedes a losing recovery-slot reservation failure", async () => { + const fixture = await projectCheckpoint(); + const gate = path.join(fixture.root, "reserve-gate"); + try { + const loser = runSaveWorker(fixture.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_GATE: gate, + INKCHECK_TEST_CHECKPOINT_GATE_STAGE: "before-reserve-transaction", + }); + await waitFor(`${gate}.${loser.child.pid}.ready`); + const winner = runSaveWorker(fixture.root, "legacy-v1", { NODE_ENV: "test" }); + const winnerResult = await winner.completed; + assert.strictEqual(winnerResult.status, 0, winnerResult.stderr); + const created = JSON.parse(winnerResult.stdout); + const directory = checkpointDirectory(fixture.root); + for (let slot = 0; slot < 32; slot++) { + fs.writeFileSync(path.join( + directory, + `.${created.id}.recovery-${String(slot).padStart(2, "0")}.meta.json.claim` + ), JSON.stringify({ schemaVersion: 1, pid: process.pid }), { flag: "wx", mode: 0o600 }); + } + fs.writeFileSync(gate, "release"); + const loserResult = await loser.completed; + assert.strictEqual(loserResult.status, 0, loserResult.stderr); + const reused = JSON.parse(loserResult.stdout); + assert.strictEqual(reused.id, created.id); + assert.strictEqual(reused.accounting.outcome, "reused"); + assert.strictEqual(reused.accounting.serialization.status, "not_applied"); + assert.strictEqual(reused.accounting.compression.status, "not_applied"); + assert.strictEqual("framedV2" in reused.accounting, false); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("a partial private candidate failure is not converted into winner reuse", async () => { + const fixture = await projectCheckpoint(); + const originalWrite = checkpointV2Codec.writeCheckpointArtifactV2; + const partialFailure = new Error("intentional partial candidate failure"); + let emittedResolve; + let releaseReject; + const emitted = new Promise((resolve) => { emittedResolve = resolve; }); + const release = new Promise((resolve) => { releaseReject = resolve; }); + checkpointV2Codec.writeCheckpointArtifactV2 = async (destination) => { + await new Promise((resolve, reject) => { + destination.write(Buffer.from("partial-framed-candidate"), (error) => { + if (error) reject(error); + else resolve(); + }); + }); + emittedResolve(); + await release; + destination.destroy(partialFailure); + throw partialFailure; + }; + try { + const losingSave = saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + }); + await emitted; + const deadline = Date.now() + 10_000; + while (true) { + const candidate = fs.readdirSync(checkpointDirectory(fixture.root)) + .find((name) => name.endsWith(".payload.tmp")); + if (candidate && fs.statSync(path.join(checkpointDirectory(fixture.root), candidate)).size > 0) break; + if (Date.now() >= deadline) throw new Error("timed out waiting for partial checkpoint bytes"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + const winner = runSaveWorker(fixture.root, "legacy-v1", { NODE_ENV: "test" }); + const winnerResult = await winner.completed; + assert.strictEqual(winnerResult.status, 0, winnerResult.stderr); + releaseReject(); + await assert.rejects(losingSave, (error) => error === partialFailure); + + const created = JSON.parse(winnerResult.stdout); + assert.deepStrictEqual(publicLayouts(fixture.root, created.id), [`${created.id}.json.gz`]); + const retry = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + }); + assert.strictEqual(retry.accounting.outcome, "reused"); + assert.strictEqual(retry.path, created.path); + } finally { + checkpointV2Codec.writeCheckpointArtifactV2 = originalWrite; + releaseReject(); + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("framed corruption, resource bounds, cancellation, duplicates, and future schemas fail closed", async () => { + const fixture = await projectCheckpoint(); + try { + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + }); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id, { + framedV2Limits: { maxRecordBytes: 1 }, + }), + (error) => error instanceof CheckpointReadError && error.kind === "resource_limit" + ); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id, { + maxStoredBytes: fs.statSync(path.join(checkpointDirectory(fixture.root), `${saved.id}.inkcp`)).size - 1, + }), + (error) => error instanceof CheckpointReadError && error.kind === "resource_limit" + ); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + () => loadCheckpointForResume(fixture.root, saved.id, { signal: controller.signal }), + (error) => error?.name === "AbortError" + ); + + const payload = path.join(checkpointDirectory(fixture.root), `${saved.id}.inkcp`); + const original = fs.readFileSync(payload); + const corruptPayload = Buffer.from(original); + corruptPayload[Math.floor(corruptPayload.length / 2)] ^= 0xff; + fs.writeFileSync(payload, corruptPayload); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id), + (error) => error instanceof CheckpointReadError && error.kind === "corrupt" + ); + fs.writeFileSync(payload, original); + + fs.copyFileSync(payload, path.join(checkpointDirectory(fixture.root), `${saved.id}.json.gz`)); + assert.throws(() => listCheckpointArtifacts(fixture.root), /duplicate artifact layouts/); + fs.rmSync(path.join(checkpointDirectory(fixture.root), `${saved.id}.json.gz`)); + + const manifestFile = path.join(checkpointDirectory(fixture.root), `${saved.id}.meta.json`); + const originalManifest = fs.readFileSync(manifestFile, "utf8"); + fs.rmSync(manifestFile); + assert.throws(() => listCheckpointArtifacts(fixture.root), /missing its required versioned metadata manifest/); + fs.writeFileSync(manifestFile, originalManifest); + + const malformed = JSON.parse(originalManifest); + malformed.framedV2.components[0].frameCount = -1; + fs.writeFileSync(manifestFile, JSON.stringify(malformed)); + assert.throws( + () => listCheckpointArtifacts(fixture.root), + (error) => error instanceof CheckpointReadError && error.kind === "corrupt" + ); + + const manifest = JSON.parse(originalManifest); + manifest.manifestSchemaVersion = 3; + fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + assert.throws( + () => listCheckpointArtifacts(fixture.root), + (error) => error instanceof CheckpointReadError && error.kind === "unsupported" + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("framed typed failures retain enclosing accounting without unverified partial component totals", async () => { + const fixture = await projectCheckpoint(); + try { + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + }); + const payload = path.join(checkpointDirectory(fixture.root), `${saved.id}.inkcp`); + const payloadBytes = fs.statSync(payload).size; + const assertPartialReceipt = (error, kind, stage) => { + assert.ok(error instanceof CheckpointReadError); + assert.strictEqual(error.kind, kind); + assert.strictEqual(error.stage, stage); + assert.strictEqual(error.accounting.status, "failed"); + assert.deepStrictEqual(error.accounting.failure, { kind, stage }); + assert.strictEqual(error.accounting.manifest.status, "applied"); + assert.strictEqual("framedV2" in error.accounting, false, + "verified aggregate totals are omitted until the codec reaches its terminal index and EOF"); + assert.strictEqual(error.accounting.storedPayload.status, "not_applied"); + assert.strictEqual(error.accounting.decompressedPayload.status, "not_applied"); + assert.strictEqual(error.accounting.rawArtifactString.status, "not_applied"); + assert.strictEqual(error.accounting.parsedArtifactGraph.status, "not_applied"); + return true; + }; + + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id, { maxStoredBytes: payloadBytes - 1 }), + (error) => { + assertPartialReceipt(error, "resource_limit", "storage"); + assert.strictEqual(error.unit, "bytes"); + assert.strictEqual(error.observed, payloadBytes); + assert.strictEqual(error.limit, payloadBytes - 1); + assert.strictEqual(error.observedBytes, payloadBytes); + assert.strictEqual(error.limitBytes, payloadBytes - 1); + return true; + } + ); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id, { + framedV2Limits: { maxTotalRecords: 1 }, + }), + (error) => { + assertPartialReceipt(error, "resource_limit", "storage"); + assert.strictEqual(error.unit, "count"); + assert.strictEqual(error.observed, 2); + assert.strictEqual(error.limit, 1); + assert.strictEqual(error.observedBytes, undefined); + assert.strictEqual(error.limitBytes, undefined); + return true; + } + ); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id, { + framedV2Limits: { maxJsonDepth: 1 }, + }), + (error) => { + assertPartialReceipt(error, "resource_limit", "json"); + assert.strictEqual(error.unit, "depth"); + assert.ok(error.observed > error.limit); + assert.strictEqual(error.limit, 1); + assert.strictEqual(error.observedBytes, undefined); + assert.strictEqual(error.limitBytes, undefined); + return true; + } + ); + + const original = fs.readFileSync(payload); + const corruptCount = Buffer.from(original); + const firstHeaderBytes = corruptCount.readUInt32BE(CHECKPOINT_ARTIFACT_V2_MAGIC.length); + const firstHeaderOffset = CHECKPOINT_ARTIFACT_V2_MAGIC.length + 8; + const firstHeader = JSON.parse(corruptCount.subarray( + firstHeaderOffset, + firstHeaderOffset + firstHeaderBytes + ).toString("utf8")); + const observedRecordCount = firstHeader.recordCount; + firstHeader.recordCount += firstHeader.recordCount % 10 === 9 ? -1 : 1; + const corruptCountHeader = Buffer.from(JSON.stringify(firstHeader), "utf8"); + assert.strictEqual(corruptCountHeader.length, firstHeaderBytes, + "record-count tampering must retain the framed header width"); + corruptCountHeader.copy(corruptCount, firstHeaderOffset); + fs.writeFileSync(payload, corruptCount); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id), + (error) => { + assertPartialReceipt(error, "corrupt", "json"); + assert.strictEqual(error.unit, "count"); + assert.strictEqual(error.observed, observedRecordCount); + assert.strictEqual(error.limit, firstHeader.recordCount); + assert.strictEqual(error.observedBytes, undefined); + assert.strictEqual(error.limitBytes, undefined); + return true; + } + ); + fs.writeFileSync(payload, original); + + const corrupt = Buffer.from(original); + let offset = CHECKPOINT_ARTIFACT_V2_MAGIC.length; + for (let frame = 0; frame < 2; frame++) { + const headerBytes = corrupt.readUInt32BE(offset); + const storedBytes = corrupt.readUInt32BE(offset + 4); + const storedOffset = offset + 8 + headerBytes; + if (frame === 1) corrupt[storedOffset + Math.floor(storedBytes / 2)] ^= 0xff; + offset = storedOffset + storedBytes; + } + fs.writeFileSync(payload, corrupt); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id), + (error) => assertPartialReceipt(error, "corrupt", "decompression") + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("framed reads hash and decode one opened payload identity and reject path replacement", async () => { + const fixture = await projectCheckpoint(); + try { + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + }); + const payload = path.join(checkpointDirectory(fixture.root), `${saved.id}.inkcp`); + const originalOpen = fs.openSync; + let payloadOpens = 0; + fs.openSync = (candidate, ...args) => { + if (path.resolve(String(candidate)) === path.resolve(payload)) payloadOpens += 1; + return originalOpen(candidate, ...args); + }; + try { + await loadCheckpointForResume(fixture.root, saved.id); + } finally { + fs.openSync = originalOpen; + } + assert.strictEqual(payloadOpens, 1, "framed decode hashes the exact stream instead of making a digest pass"); + + const held = `${payload}.held`; + const originalCreateReadStream = fs.createReadStream; + let replaced = false; + fs.createReadStream = (candidate, options) => { + const stream = originalCreateReadStream(candidate, options); + if (!replaced && path.resolve(String(candidate)) === path.resolve(payload)) { + replaced = true; + fs.renameSync(payload, held); + fs.copyFileSync(held, payload); + } + return stream; + }; + try { + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id), + (error) => error instanceof CheckpointReadError + && error.kind === "corrupt" && error.stage === "storage" + ); + } finally { + fs.createReadStream = originalCreateReadStream; + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("a stalled framed public read aborts promptly with AbortError", async () => { + const fixture = await projectCheckpoint(); + try { + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + }); + const payload = path.join(checkpointDirectory(fixture.root), `${saved.id}.inkcp`); + const prefix = fs.readFileSync(payload).subarray(0, 16); + const controller = new AbortController(); + const originalCreateReadStream = fs.createReadStream; + let stalled; + fs.createReadStream = (candidate, options) => { + if (path.resolve(String(candidate)) !== path.resolve(payload)) { + return originalCreateReadStream(candidate, options); + } + let emitted = false; + stalled = new Readable({ + read() { + if (emitted) return; + emitted = true; + this.push(prefix); + setImmediate(() => controller.abort()); + }, + }); + return stalled; + }; + try { + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id, { signal: controller.signal }), + (error) => error?.name === "AbortError" + ); + assert.strictEqual(stalled.destroyed, true, "abort tears down the stalled payload stream"); + } finally { + fs.createReadStream = originalCreateReadStream; + stalled?.destroy(); + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("deep ancestry and large semantic/finding components split into bounded frames", async () => { + const fixture = await projectCheckpoint(10); + try { + const checkpoint = structuredClone(fixture.checkpoint); + checkpoint.state.nodes = Array.from({ length: 400 }, (_, index) => ({ + stateJson: JSON.stringify({ index }), + variables: { index }, + parent: index === 0 ? null : index - 1, + choiceText: `choice-${index}`, + choiceIndex: 0, + depth: index, + active: true, + childRefs: index === 399 ? 0 : 1, + stateBytes: Buffer.byteLength(JSON.stringify({ index })), + variableBytes: Buffer.byteLength(JSON.stringify({ index })), + ancestryBytes: 64 + Buffer.byteLength(`choice-${index}`), + })); + checkpoint.state.seenStates = Array.from({ length: 2_000 }, (_, index) => `state-${index}`); + checkpoint.state.runtimeWarnings = Array.from({ length: 1_000 }, (_, index) => `warning-${index}`); + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, checkpoint, { + format: "framed-v2", + framedV2Limits: { + maxRecordsPerFrame: 11, + maxDecodedFrameBytes: 512 * 1024, + maxRecordBytes: 256 * 1024, + }, + }); + const loaded = await loadCheckpointForResume(fixture.root, saved.id, { + framedV2Limits: { + maxRecordsPerFrame: 11, + maxDecodedFrameBytes: 512 * 1024, + maxRecordBytes: 256 * 1024, + }, + }); + assert.deepStrictEqual(loaded.checkpoint, checkpoint); + const components = Object.fromEntries( + loaded.artifact.framedV2.components.map((component) => [component.component, component]) + ); + assert.ok(components.frontier.frameCount > 10); + assert.ok(components.witnessAncestry.frameCount > 10); + assert.ok(components.dedupe.frameCount > 10); + assert.ok(components.findings.frameCount > 10); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("a tiny framed nodeSlots claim is bounded before allocation and writers cannot emit it", async () => { + const fixture = await projectCheckpoint(); + try { + const saved = await saveCheckpointArtifact(fixture.root, fixture.story, fixture.checkpoint, { + format: "framed-v2", + }); + const directory = checkpointDirectory(fixture.root); + const payload = path.join(directory, `${saved.id}.inkcp`); + const manifestFile = path.join(directory, `${saved.id}.meta.json`); + const inputs = []; + let current; + await readCheckpointArtifactV2(fs.createReadStream(payload), { + onRecord(record) { + const key = `${record.component}\0${record.field}`; + if (!current || current.key !== key) { + current = { + key, + component: record.component, + field: record.field, + start: 0, + records: [], + }; + inputs.push(current); + } + current.records.push(record.component === "witnessAncestry" && record.field === "nodeSlots" + ? 1_001 + : record.value); + }, + }); + const replacement = `${payload}.replacement`; + const codec = await writeCheckpointArtifactV2(fs.createWriteStream(replacement, { + flags: "wx", + mode: 0o600, + }), inputs.map(({ key: _key, ...input }) => input)); + fs.renameSync(replacement, payload); + + const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8")); + const bytes = fs.readFileSync(payload); + manifest.artifactSizeBytes = bytes.length; + manifest.artifactSha256 = crypto.createHash("sha256").update(bytes).digest("hex"); + manifest.framedV2 = { + schemaVersion: 2, + componentOrder: CHECKPOINT_ARTIFACT_V2_COMPONENT_ORDER, + artifactBytes: codec.artifactBytes, + artifactOverheadBytes: codec.artifactBytes - codec.data.storedBytes - codec.index.storedBytes, + data: codec.data, + components: codec.components, + index: codec.index, + }; + const body = { + manifestSchemaVersion: manifest.manifestSchemaVersion, + artifactSchemaVersion: manifest.artifactSchemaVersion, + artifactType: manifest.artifactType, + id: manifest.id, + createdAt: manifest.createdAt, + inkcheckVersion: manifest.inkcheckVersion, + checkpointSchemaVersion: manifest.checkpointSchemaVersion, + entrypoint: manifest.entrypoint, + engine: manifest.engine, + totalGranted: manifest.totalGranted, + statesExplored: manifest.statesExplored, + storageEncoding: manifest.storageEncoding, + artifactSizeBytes: manifest.artifactSizeBytes, + artifactSha256: manifest.artifactSha256, + framedV2: manifest.framedV2, + }; + manifest.manifestSha256 = crypto.createHash("sha256").update(JSON.stringify(body)).digest("hex"); + fs.writeFileSync(manifestFile, JSON.stringify(manifest)); + await assert.rejects( + () => openCheckpointArtifact(fixture.root, saved.id, { + framedV2Limits: { maxTotalRecords: 1_000 }, + }), + (error) => error instanceof CheckpointReadError + && error.kind === "resource_limit" && error.stage === "envelope" + && error.unit === "count" && error.observed === 1_001 && error.limit === 1_000 + && error.observedBytes === undefined && error.limitBytes === undefined + ); + + const writerRoot = await projectCheckpoint(); + try { + const oversized = structuredClone(writerRoot.checkpoint); + oversized.state.nodes = Array(1_001).fill(null); + const originalCreateWriteStream = fs.createWriteStream; + let writeStreams = 0; + fs.createWriteStream = (...args) => { + writeStreams += 1; + return originalCreateWriteStream(...args); + }; + try { + await assert.rejects( + () => saveCheckpointArtifact(writerRoot.root, writerRoot.story, oversized, { + format: "framed-v2", + framedV2Limits: { maxTotalRecords: 1_000 }, + }), + (error) => error instanceof CheckpointArtifactV2Error + && error.kind === "resource_limit" && error.metric === "count" + && error.observed === 1_001 && error.limit === 1_000 + ); + } finally { + fs.createWriteStream = originalCreateWriteStream; + } + assert.strictEqual(writeStreams, 0, "node-slot preflight rejects before opening a destination stream"); + } finally { + fs.rmSync(writerRoot.root, { recursive: true, force: true }); + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("pre-publication cancellation cleans private state and a v2 crash recovers one complete pair", async () => { + const cancelled = await projectCheckpoint(500); + const crashed = await projectCheckpoint(); + const neutralCrashed = await projectCheckpoint(); + const legacyNeutralCrashed = await projectCheckpoint(); + try { + const controller = new AbortController(); + setImmediate(() => controller.abort()); + await assert.rejects( + () => saveCheckpointArtifact(cancelled.root, cancelled.story, cancelled.checkpoint, { + format: "framed-v2", + signal: controller.signal, + }), + (error) => error?.name === "AbortError" + ); + const cancelledNames = fs.existsSync(checkpointDirectory(cancelled.root)) + ? fs.readdirSync(checkpointDirectory(cancelled.root)) + : []; + assert.strictEqual(cancelledNames.some((name) => /\.(?:inkcp|json|json\.gz|meta\.json)$/.test(name)), false); + assert.strictEqual(cancelledNames.some((name) => name.endsWith(".payload.commit")), false); + + const worker = runSaveWorker(crashed.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_CRASH_STAGE: "after-payload-publication", + }); + const result = await worker.completed; + assert.strictEqual(result.status, 86, result.stderr); + const recovered = await saveCheckpointArtifact(crashed.root, crashed.story, crashed.checkpoint); + assert.match(recovered.path, /\.inkcp$/); + assert.deepStrictEqual( + (await loadCheckpointForResume(crashed.root, recovered.id)).checkpoint, + crashed.checkpoint + ); + assert.deepStrictEqual(publicLayouts(crashed.root, recovered.id), [`${recovered.id}.inkcp`]); + + const neutralWorker = runSaveWorker(neutralCrashed.root, "framed-v2", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_CRASH_STAGE: "after-neutral-publication", + }); + const neutralResult = await neutralWorker.completed; + assert.strictEqual(neutralResult.status, 86, neutralResult.stderr); + assert.strictEqual(fs.readdirSync(checkpointDirectory(neutralCrashed.root)) + .some((name) => /^checkpoint-.*\.(?:inkcp|json|json\.gz)$/.test(name)), false); + const neutralRecovered = await saveCheckpointArtifact( + neutralCrashed.root, + neutralCrashed.story, + neutralCrashed.checkpoint + ); + assert.match(neutralRecovered.path, /\.inkcp$/, + "a legacy-v1 retry completes the manifest-selected v2 neutral payload"); + assert.deepStrictEqual( + (await loadCheckpointForResume(neutralCrashed.root, neutralRecovered.id)).checkpoint, + neutralCrashed.checkpoint + ); + + const legacyNeutralWorker = runSaveWorker(legacyNeutralCrashed.root, "legacy-v1", { + NODE_ENV: "test", + INKCHECK_TEST_CHECKPOINT_CRASH_STAGE: "after-neutral-publication", + }); + const legacyNeutralResult = await legacyNeutralWorker.completed; + assert.strictEqual(legacyNeutralResult.status, 86, legacyNeutralResult.stderr); + assert.strictEqual(fs.readdirSync(checkpointDirectory(legacyNeutralCrashed.root)) + .some((name) => /^checkpoint-.*\.(?:inkcp|json|json\.gz)$/.test(name)), false); + const legacyNeutralRecovered = await saveCheckpointArtifact( + legacyNeutralCrashed.root, + legacyNeutralCrashed.story, + legacyNeutralCrashed.checkpoint, + { format: "framed-v2", framedV2Limits: { maxRecordBytes: 1 } } + ); + assert.match(legacyNeutralRecovered.path, /\.json\.gz$/, + "restrictive v2 preflight cannot supersede a manifest-selected legacy neutral payload"); + assert.strictEqual(legacyNeutralRecovered.accounting.outcome, "reused"); + assert.strictEqual(legacyNeutralRecovered.accounting.serialization.status, "not_applied"); + assert.strictEqual(legacyNeutralRecovered.accounting.compression.status, "not_applied"); + assert.strictEqual("framedV2" in legacyNeutralRecovered.accounting, false); + assert.deepStrictEqual( + (await loadCheckpointForResume( + legacyNeutralCrashed.root, + legacyNeutralRecovered.id + )).checkpoint, + legacyNeutralCrashed.checkpoint + ); + } finally { + fs.rmSync(cancelled.root, { recursive: true, force: true }); + fs.rmSync(crashed.root, { recursive: true, force: true }); + fs.rmSync(neutralCrashed.root, { recursive: true, force: true }); + fs.rmSync(legacyNeutralCrashed.root, { recursive: true, force: true }); + } +}); diff --git a/test/inkcheck.test.js b/test/inkcheck.test.js index 26b8f65..e63e50b 100644 --- a/test/inkcheck.test.js +++ b/test/inkcheck.test.js @@ -51,6 +51,7 @@ const { } = require("../dist/discovery"); const { CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, CheckpointReadError, CheckpointSizeLimitError, listCheckpointArtifacts, @@ -259,7 +260,13 @@ test("capabilities explicitly reports supported and unavailable features", () => assert.strictEqual(value.schemas.report, 1); assert.strictEqual(value.schemas.config, CONFIG_SCHEMA_VERSION); assert.strictEqual(value.schemas.artifact, 1); - assert.strictEqual(value.schemas.checkpointArtifact, CHECKPOINT_ARTIFACT_SCHEMA_VERSION); + assert.strictEqual(value.schemas.checkpointArtifact, CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION); + assert.deepStrictEqual(value.schemas.checkpointArtifactSupported, [ + CHECKPOINT_ARTIFACT_SCHEMA_VERSION, + CHECKPOINT_ARTIFACT_V2_SCHEMA_VERSION, + ]); + assert.strictEqual(value.schemas.checkpointArtifactDefaultWrite, CHECKPOINT_ARTIFACT_SCHEMA_VERSION); + assert.strictEqual(value.schemas.checkpointListResponse, 1); assert.strictEqual(value.limits.maxCheckpointBytes, 512 * 1024 * 1024); assert.strictEqual(value.limits.maxProjectCheckpointBytes, 1024 * 1024 * 1024); assert.strictEqual(value.limits.checkpointGenerationsPerEntrypoint, 3); @@ -2230,7 +2237,7 @@ test("checkpoint readback is resource-bounded while manifests keep listing metad } const incompatibleManifest = JSON.parse(originalManifest); - incompatibleManifest.manifestSchemaVersion = 2; + incompatibleManifest.manifestSchemaVersion = 3; incompatibleManifest.futureSchemaField = { framedPayload: true }; fs.writeFileSync(manifestFile, JSON.stringify(incompatibleManifest)); assert.throws(() => listCheckpointArtifacts(tmp), (error) => { @@ -2989,8 +2996,11 @@ test("checkpoint publication serializes cross-process same-ID writers and recove assert.ok(error instanceof CheckpointReadError); assert.strictEqual(error.kind, "resource_limit"); assert.strictEqual(error.stage, "manifest"); - assert.strictEqual(error.observedBytes, 32); - assert.strictEqual(error.limitBytes, 32); + assert.strictEqual(error.unit, "count"); + assert.strictEqual(error.observed, 32); + assert.strictEqual(error.limit, 32); + assert.strictEqual(error.observedBytes, undefined); + assert.strictEqual(error.limitBytes, undefined); return true; }); assert.deepStrictEqual(fs.readdirSync(directory).sort(), cappedNames, @@ -5234,6 +5244,22 @@ test("NDJSON progress contract docs stay linked and privacy-focused", () => { assert.match(docs, /must not contain:[\s\S]*story source text[\s\S]*choice prose[\s\S]*variable names or values/); }); +test("framed checkpoint-v2 contract is linked, packaged, opt-in, and non-promotional", () => { + const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); + const docs = fs.readFileSync(path.join(ROOT, "docs", "shared-checkpoint-artifact-v2.md"), "utf8"); + const packageJson = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); + assert.match(readme, /docs\/shared-checkpoint-artifact-v2\.md/); + assert.ok(packageJson.files.includes("docs/shared-checkpoint-artifact-v2.md")); + assert.match(docs, /opt-in, framed storage codec/); + assert.match(docs, /continue to write the\s+existing streamed-gzip/); + assert.match(docs, /stable checkpoint-ID algorithm/); + assert.match(docs, /exactly one verified public\s+layout/); + assert.match(docs, /integrity checks, not authentication/); + assert.match(docs, /not format promotion/); + assert.match(docs, /does not make framed v2 the\s+default/); + assert.match(docs, /does not[\s\S]*establish\s+an InkBench improvement claim/); +}); + test("shared observability contract is linked, packaged, and explicit about its partial boundary", () => { const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); const docs = fs.readFileSync(path.join(ROOT, "docs", "shared-search-observability.md"), "utf8"); diff --git a/tool.json b/tool.json index b4b8aa6..657d252 100644 --- a/tool.json +++ b/tool.json @@ -131,7 +131,7 @@ "jsonSchema": { "reportEnvelope": "{ schemaVersion: 1, inkcheckVersion, storyFingerprint: {algorithm, source, value}, effectiveConfiguration, bindingLimit, compile, stats?, profile?, explore?, nextRun?, runs?, checkpoint?: {saved, id?, path?, pruned?, resumedFrom?, reason?} }", "evidenceStream": "NDJSON schemaVersion 1 events: run_start {inkcheckVersion, stateBudget, effectiveConfiguration}; ending {id, elapsedMs, firstDiscoveredAtState, choiceIndices, foundBy?}; runtime_error {id, elapsedMs, firstDiscoveredAtState, message, choiceIndices, sourceLocation?, foundBy?}; benchmark_signal {id, signal, elapsedMs, firstDiscoveredAtState, choiceIndices}; run_end {inkcheckVersion, elapsedMs, effectiveConfiguration, compile, explore, resources, evidence}. Absence of run_end means interruption.", - "capabilities": "{ schemaVersion, inkcheckVersion, schemas: {report, config, projectInspection, artifact, checkpointArtifact, searchSession, regressionArtifact, campaignPolicy}, limits: {..., defaultMcpSessionWindowStates, maxMcpSessionWindowStates, maxMcpSessionTotalStates, maxMcpSessionFiles, maxMcpSessionEvents, maxMcpCampaignWindows, maxRegressionPinBytes, maxRegressionPinsPerProject}, searchModes, resumableSearchSurfaces, features: {projectInspection, indexedWitnesses, assertions, goals, localReportArtifacts, savedFindingLookup, resumableSearch, interactiveSearchSessions, campaignResultWindows, sessionWitnessReplay, sessionRegressionPins} }", + "capabilities": "{ schemaVersion, inkcheckVersion, schemas: {report, config, projectInspection, artifact, checkpointArtifact, checkpointArtifactSupported, checkpointArtifactDefaultWrite, checkpointListResponse, searchSession, regressionArtifact, campaignPolicy}, limits: {..., defaultMcpSessionWindowStates, maxMcpSessionWindowStates, maxMcpSessionTotalStates, maxMcpSessionFiles, maxMcpSessionEvents, maxMcpCampaignWindows, maxRegressionPinBytes, maxRegressionPinsPerProject}, searchModes, resumableSearchSurfaces, features: {projectInspection, indexedWitnesses, assertions, goals, localReportArtifacts, savedFindingLookup, resumableSearch, interactiveSearchSessions, campaignResultWindows, sessionWitnessReplay, sessionRegressionPins} }", "searchSession": "{ schemaVersion, inkcheckVersion, sessionCapability?: bearer-secret, session: {revision, status, recoverable, totalGranted, statesExplored, latestReportId, latestCheckpointId?, bindingLimit, findings, events, droppedEventCount}, campaign?: {campaignId, intent, status, stopReason, spend, ceilings, unusedStates, windows, disclosure, latestWindow?}, savedFindings, nextOperation }", "regressionPin": "private runtime-only artifact with stable pin ID, session/entrypoint binding, indexed choices, story seed, baseline fingerprint, and hashed expected runtime errors; check returns fixed|still_failing|path_changed without search states", "savedFindings": "{ artifact: {id, entrypoint, freshness}, findings: [{id, kind, section, hasWitness, hasReplay, sourceLocation?}], page: {limit, returned, total, nextCursor} }; fetch returns one summary plus complete finding; replay returns summary plus playtest result",