@sema-agent/core 5.65.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/agents/subagent.d.ts +2 -2
  3. package/dist/agents/subagent.js +11 -0
  4. package/dist/agents/verify.d.ts +1 -1
  5. package/dist/brain/anthropic.js +1 -1
  6. package/dist/brain/errors.d.ts +29 -0
  7. package/dist/brain/errors.js +20 -0
  8. package/dist/brain/open-responses.js +2 -2
  9. package/dist/brain/route-adjudicator.d.ts +8 -1
  10. package/dist/brain/route-adjudicator.js +1 -0
  11. package/dist/brain/status-sink.js +12 -1
  12. package/dist/brain/stream-engine.js +17 -6
  13. package/dist/core/auto-compaction.d.ts +26 -0
  14. package/dist/core/auto-compaction.js +7 -2
  15. package/dist/core/auto-mode-arming.d.ts +138 -0
  16. package/dist/core/auto-mode-arming.js +181 -0
  17. package/dist/core/auto-mode-defaults.d.ts +13 -0
  18. package/dist/core/auto-mode-defaults.js +5 -0
  19. package/dist/core/auto-mode-prompt.d.ts +14 -3
  20. package/dist/core/auto-mode-prompt.js +10 -7
  21. package/dist/core/auto-mode-rebuild.d.ts +75 -0
  22. package/dist/core/auto-mode-rebuild.js +41 -0
  23. package/dist/core/auto-mode.d.ts +15 -0
  24. package/dist/core/auto-mode.js +4 -2
  25. package/dist/core/checkpoint-store.d.ts +18 -0
  26. package/dist/core/context-edit.d.ts +47 -5
  27. package/dist/core/context-guard.d.ts +1 -1
  28. package/dist/core/file-history-retention.d.ts +106 -0
  29. package/dist/core/file-history-retention.js +36 -0
  30. package/dist/core/file-history-store.d.ts +768 -0
  31. package/dist/core/file-history-store.js +880 -0
  32. package/dist/core/governance-codes.d.ts +13 -3
  33. package/dist/core/governance-codes.js +15 -0
  34. package/dist/core/hooks.d.ts +39 -7
  35. package/dist/core/hooks.js +38 -21
  36. package/dist/core/lsp.d.ts +2 -2
  37. package/dist/core/mcp.d.ts +29 -7
  38. package/dist/core/memory-engine/consolidation-driver.d.ts +11 -0
  39. package/dist/core/memory-engine/consolidation-driver.js +71 -4
  40. package/dist/core/memory-engine/consolidation.d.ts +25 -2
  41. package/dist/core/memory-engine/consolidation.js +4 -1
  42. package/dist/core/memory-engine/distiller.d.ts +84 -1
  43. package/dist/core/memory-engine/distiller.js +68 -0
  44. package/dist/core/memory-engine/dual-root.js +3 -0
  45. package/dist/core/memory-engine/engine.d.ts +328 -15
  46. package/dist/core/memory-engine/engine.js +355 -29
  47. package/dist/core/memory-engine/file-backend.d.ts +30 -0
  48. package/dist/core/memory-engine/file-backend.js +14 -13
  49. package/dist/core/memory-engine/frontmatter.d.ts +22 -1
  50. package/dist/core/memory-engine/frontmatter.js +3 -0
  51. package/dist/core/memory-engine/header-hints.d.ts +5 -0
  52. package/dist/core/memory-engine/index.d.ts +5 -4
  53. package/dist/core/memory-engine/index.js +5 -4
  54. package/dist/core/memory-engine/layout.d.ts +88 -2
  55. package/dist/core/memory-engine/layout.js +112 -3
  56. package/dist/core/memory-engine/provenance-wording.d.ts +7 -0
  57. package/dist/core/memory-engine/provenance-wording.js +3 -0
  58. package/dist/core/memory-engine/tools.d.ts +89 -8
  59. package/dist/core/memory-engine/tools.js +263 -22
  60. package/dist/core/memory-engine/types.d.ts +64 -1
  61. package/dist/core/memory-recall.d.ts +6 -0
  62. package/dist/core/memory.d.ts +27 -1
  63. package/dist/core/memory.js +16 -2
  64. package/dist/core/permission-rule-consent.d.ts +62 -2
  65. package/dist/core/permission-rule-consent.js +105 -14
  66. package/dist/core/permission-rule-model.d.ts +118 -16
  67. package/dist/core/permission-rule-model.js +57 -9
  68. package/dist/core/permission-rule-session.d.ts +124 -0
  69. package/dist/core/permission-rule-session.js +121 -0
  70. package/dist/core/permission-rule-store.d.ts +65 -2
  71. package/dist/core/permission-rule-store.js +75 -16
  72. package/dist/core/permission-rule-sync.d.ts +9 -0
  73. package/dist/core/permission-rule-sync.js +52 -19
  74. package/dist/core/retention-policy.d.ts +9 -0
  75. package/dist/core/retention-policy.js +5 -2
  76. package/dist/core/retention.d.ts +13 -2
  77. package/dist/core/runner/assemble-result.d.ts +19 -1
  78. package/dist/core/runner/assemble-result.js +17 -2
  79. package/dist/core/runner/compaction-call-options.d.ts +93 -0
  80. package/dist/core/runner/compaction-call-options.js +3 -0
  81. package/dist/core/runner/memory-capture-optout.d.ts +80 -0
  82. package/dist/core/runner/memory-capture-optout.js +53 -0
  83. package/dist/core/runner/prepare-config-doors.d.ts +5 -0
  84. package/dist/core/runner/prepare-config-doors.js +16 -0
  85. package/dist/core/runner/prepare-hands-readface.d.ts +110 -5
  86. package/dist/core/runner/prepare-hands-readface.js +99 -7
  87. package/dist/core/runner/prepare-memory.d.ts +88 -0
  88. package/dist/core/runner/prepare-memory.js +305 -24
  89. package/dist/core/runner/prepare-task.d.ts +141 -1
  90. package/dist/core/runner/prepare-task.js +478 -81
  91. package/dist/core/runner/runtask.d.ts +9 -20
  92. package/dist/core/runner/runtask.js +133 -96
  93. package/dist/core/runner/session-file-state-replay.d.ts +18 -10
  94. package/dist/core/runner/session-file-state-replay.js +52 -1
  95. package/dist/core/runner/tool-disclosure.js +2 -1
  96. package/dist/core/runner/turn-attachments.d.ts +22 -12
  97. package/dist/core/session-store.d.ts +1 -1
  98. package/dist/core/session-store.js +6 -1
  99. package/dist/core/session.d.ts +34 -1
  100. package/dist/core/store-contracts/file-history-store-contract.d.ts +3 -0
  101. package/dist/core/store-contracts/file-history-store-contract.js +720 -0
  102. package/dist/core/store-contracts/permission-rule-sync-contract.js +15 -1
  103. package/dist/core/task-registry-shared.js +11 -1
  104. package/dist/core/tool-errors.js +1 -0
  105. package/dist/core/tool-policy.d.ts +172 -1
  106. package/dist/core/tool-policy.js +32 -1
  107. package/dist/core/tool-result-store.js +2 -1
  108. package/dist/core/trace.d.ts +31 -2
  109. package/dist/core/types.d.ts +795 -89
  110. package/dist/core/types.js +4 -3
  111. package/dist/core/untrusted-text.d.ts +1 -1
  112. package/dist/core/untrusted-text.js +8 -0
  113. package/dist/core/workflow-run-store-contract.js +6 -2
  114. package/dist/core/workflow-run-store.d.ts +4 -1
  115. package/dist/engine/compaction/compaction.d.ts +88 -10
  116. package/dist/engine/compaction/compaction.js +109 -30
  117. package/dist/engine/execution-env/node-execution-env.d.ts +9 -1
  118. package/dist/engine/execution-env/node-execution-env.js +28 -0
  119. package/dist/engine/harness/agent-harness.d.ts +52 -1
  120. package/dist/engine/harness/agent-harness.js +36 -1
  121. package/dist/engine/harness/types.d.ts +26 -1
  122. package/dist/engine/llm/types.d.ts +50 -4
  123. package/dist/engine/loop/agent-loop.d.ts +5 -1
  124. package/dist/engine/loop/agent-loop.js +25 -0
  125. package/dist/engine/loop/types.d.ts +19 -0
  126. package/dist/engine/lsp/node-lsp-manager.d.ts +1 -1
  127. package/dist/engine/session/session.js +1 -1
  128. package/dist/index.d.ts +20 -9
  129. package/dist/index.js +16 -7
  130. package/dist/orchestration/run-workflow-tool.d.ts +20 -2
  131. package/dist/orchestration/run-workflow-tool.js +22 -3
  132. package/dist/orchestration/workflow-governance.d.ts +59 -1
  133. package/dist/orchestration/workflow-governance.js +61 -8
  134. package/dist/orchestration/workflow-meta.d.ts +4 -2
  135. package/dist/orchestration/workflow-primitives.js +56 -13
  136. package/dist/orchestration/workflow-types.d.ts +78 -2
  137. package/dist/orchestration/workflow.d.ts +20 -0
  138. package/dist/orchestration/workflow.js +163 -14
  139. package/dist/prompt-assembly/event-registry.js +1 -1
  140. package/dist/prompts/default.d.ts +7 -7
  141. package/dist/stores/file/file-history-store.d.ts +368 -0
  142. package/dist/stores/file/file-history-store.js +1248 -0
  143. package/dist/stores/file/index.d.ts +22 -13
  144. package/dist/stores/file/index.js +4 -4
  145. package/dist/stores/file/permission-rule-store.d.ts +11 -0
  146. package/dist/stores/file/permission-rule-store.js +23 -9
  147. package/dist/stores/file/strategy-store.d.ts +3 -3
  148. package/dist/tools/fs/bash-readonly-classifier.d.ts +87 -3
  149. package/dist/tools/fs/bash-readonly-classifier.js +106 -4
  150. package/dist/tools/fs/fs-bash.js +9 -5
  151. package/dist/tools/fs/fs-shared.d.ts +52 -1
  152. package/dist/tools/fs/fs-shared.js +14 -0
  153. package/dist/tools/fs/fs-write.d.ts +5 -5
  154. package/dist/tools/fs/fs-write.js +71 -14
  155. package/dist/tools/fs/index.d.ts +6 -1
  156. package/dist/tools/fs/index.js +1 -1
  157. package/dist/tools/web.js +2 -1
  158. package/package.json +5 -1
  159. package/test/export-surface.snapshot.json +169 -23
  160. package/dist/core/file-snapshot-store.d.ts +0 -165
  161. package/dist/core/file-snapshot-store.js +0 -259
  162. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +0 -13
  163. package/dist/core/store-contracts/file-snapshot-store-contract.js +0 -134
  164. package/dist/stores/file/file-snapshot-store.d.ts +0 -58
  165. package/dist/stores/file/file-snapshot-store.js +0 -353
@@ -1,165 +0,0 @@
1
- /** rel path of `child` under `root` in "/" form (win root) / verbatim form (posix root); null when not under.
2
- * fold "\" only under a WIN-family root ({@link isWinFormPath}); a POSIX tree may legally
3
- * contain literal backslashes in FILE NAMES, and folding them corrupted both the manifest key and the
4
- * restore target. */
5
- declare function relUnder(root: string, child: string): string | null;
6
- /** Join root + a "/"-form relPath using the ROOT's native separator (keys stay family-consistent). */
7
- declare function joinUnder(root: string, rel: string): string;
8
- import type { ExecutionEnv } from "../internal/harness-types.js";
9
- export interface FileSnapshotError {
10
- /** `too_large` = the tree exceeded a bound (fail-CLOSED, NOT a silent partial snapshot); `not_found` = no
11
- * snapshot for that key; `aborted` = the signal fired; the rest = an underlying fs op failed. */
12
- code: "too_large" | "enumerate_failed" | "read_failed" | "restore_failed" | "not_found" | "aborted";
13
- message: string;
14
- }
15
- export type FileSnapshotResult = {
16
- ok: true;
17
- } | {
18
- ok: false;
19
- error: FileSnapshotError;
20
- };
21
- /**
22
- * Capture/restore a working-tree file set keyed by `(scope, key)` — `scope` = sessionId, `key` =
23
- * `SessionTreeEntry.id` (same anchor as E18 resume-at). All methods are best-effort and NEVER throw — failures
24
- * are encoded in the returned {@link FileSnapshotResult} (mirrors the ExecutionEnv FileSystem contract).
25
- *
26
- * **v1 fidelity bounds (design/101 §defer):** files only — symlinks are NOT captured and are REMOVED by
27
- * `restore` (they are a path-escape vector); empty directories are not tracked; `restore` re-enumerates under
28
- * the SAME bounds, so a tree that grew past a bound since the snapshot cannot be rewound (fail-closed).
29
- */
30
- export interface FileSnapshotStore {
31
- /** Capture the tree under `root` for `(scope, key)`. CREATE-ONCE: a second call for an existing key is a
32
- * no-op `{ok:true}` (a session entry id is immutable, so its tree state is too). */
33
- snapshot(scope: string, key: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileSnapshotResult>;
34
- /** Converge the tree under `root` back to the snapshot for `(scope, key)`: remove obstructions/strays
35
- * (symlinks + files created since), then write every captured file — so the tree MATCHES the snapshot. */
36
- restore(scope: string, key: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileSnapshotResult>;
37
- /** Whether a snapshot exists for `(scope, key)`. */
38
- has(scope: string, key: string): Promise<boolean>;
39
- /** GC: drop every snapshot in `scope` whose key is NOT in `keepKeys` (e.g. unreachable branches). Returns the
40
- * number of snapshots removed. */
41
- reap(scope: string, keepKeys: string[]): Promise<number>;
42
- /** Every snapshot key in `scope` (order unspecified). The enumeration counterpart of {@link reap} (which
43
- * already iterates the same set internally) — exposed for a cross-backend session EXPORT (service 2c
44
- * session-sync): list a session's snapshot keys to move its {entries + file snapshots} to another
45
- * backend. A durable backend implements it as a `SELECT key WHERE scope = ?`. */
46
- listKeys(scope: string): Promise<string[]>;
47
- /**
48
- * 2c session-sync: the manifest (`relPath → blobHash`) for `(scope, key)`, or `null` if absent — to
49
- * EXPORT a snapshot's content to another backend. Pairs with {@link getBlob} for CONTENT-ADDRESSED (deduped)
50
- * blob transfer: the caller fetches each distinct hash ONCE across all of a session's snapshots. Optional — a
51
- * backend that supports cross-backend snapshot export implements it.
52
- */
53
- exportManifest?(scope: string, key: string): Promise<Map<string, string> | null>;
54
- /** 2c session-sync: the content-addressed bytes for `hash` (undefined if absent). */
55
- getBlob?(hash: string): Promise<Uint8Array | undefined>;
56
- /**
57
- * 2c session-sync: STORE a snapshot INTO this store from another store's {@link exportManifest} +
58
- * {@link getBlob} output — the import-side mirror that closes the cross-backend transfer loop. For each DISTINCT
59
- * blob hash in `manifest`, fetch its bytes via `srcGetBlob`, VERIFY content-address integrity
60
- * (`sha256(bytes) === hash`), and store it (content-addressed dedup); THEN record `(scope, key) → manifest`.
61
- *
62
- * - CREATE-ONCE: a second import for an existing key is a no-op `{ok:true}` (an entry id is immutable).
63
- * - FAIL-CLOSED: a missing OR hash-mismatched source blob → `read_failed` and NO manifest is committed (never a
64
- * partial snapshot that a later {@link restore} would silently truncate). The manifest's relPaths are NOT
65
- * re-validated here — `restore` remains the path-escape gate (it already neutralizes unsafe relPaths).
66
- * - Pure store→store: does NOT touch an `ExecutionEnv` (unlike `applyManifest`, which converges ONE snapshot to a
67
- * working tree; import preserves ALL historical snapshots in the dst store so a later rewind-to-past finds them).
68
- *
69
- * Optional — a backend that supports cross-backend snapshot import implements it.
70
- */
71
- importManifest?(scope: string, key: string, manifest: Map<string, string>, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileSnapshotResult>;
72
- /**
73
- * 2c session-sync: STORE a single content-addressed blob — the symmetric WRITE side of {@link getBlob},
74
- * for a two-phase PUSH (upload blobs, THEN import the manifest that references them). VERIFIES content-address
75
- * integrity (`sha256(bytes) === hash`) so a corrupt/mismatched upload can never contaminate the store (a later
76
- * getBlob/restore would otherwise return wrong content); a mismatch → `read_failed`, nothing stored. IMMUTABLE +
77
- * content-addressed: a repeat putBlob for the same hash is a no-op (the bytes are identical by definition).
78
- * Returns a {@link FileSnapshotResult} (NOT void) so the integrity failure is encodable under the never-throw
79
- * contract. Optional — a backend that supports a two-phase push implements it.
80
- *
81
- * ⚠️ ORCHESTRATION CAVEAT: a pushed blob is NOT yet referenced by any manifest (the matching
82
- * {@link importManifest} runs LATER), so — unlike `snapshot`/`importManifest`, which hold their blobs in an
83
- * `inFlight` live-set across the whole store-then-commit — a `putBlob`'d blob is reap-ELIGIBLE in the window
84
- * before its manifest is imported. The two-phase pusher MUST NOT `reap` the scope between PUT and import (a
85
- * grace-window). This is fail-closed, not corrupting: blobs are content-addressed (re-push is always safe) and
86
- * `importManifest` re-verifies+stores every blob before committing, so a reaped blob → `read_failed` with NO
87
- * dangling-reference manifest, never wrong content.
88
- */
89
- putBlob?(hash: string, bytes: Uint8Array): Promise<FileSnapshotResult>;
90
- }
91
- /** Bounds for the reference enumerator — a deliberate, fail-CLOSED cost policy (NOT the grep walk's silent caps). */
92
- export interface FileSnapshotBounds {
93
- /** Hard cap on file count; exceeding it REFUSES the snapshot (`too_large`) rather than silently truncating. */
94
- maxFiles: number;
95
- /** Hard cap on total bytes; exceeding it REFUSES the snapshot. */
96
- maxBytes: number;
97
- /** Directory BASENAMES skipped anywhere in the tree (cost bound). Default `.git` + `node_modules`. */
98
- ignoreDirs: Set<string>;
99
- }
100
- export declare const DEFAULT_SNAPSHOT_BOUNDS: FileSnapshotBounds;
101
- /**
102
- * Capture the working tree under `root` into a manifest `relPath → sha256`, handing each file's bytes to
103
- * `putBlob` (the backend stores them content-addressed). **The env-operating + security-critical half a durable
104
- * backend MUST reuse** (not re-implement — duplication risks re-introducing the symlink-escape / fail-closed
105
- * bugs the dual-review fixed). Fail-CLOSED: an enumerate/read/bound failure returns an error and the caller must
106
- * NOT persist a partial manifest. Never throws.
107
- *
108
- * vanished-file tolerance: a file that DISAPPEARED between enumerate and read (`not_found`) is OMITTED
109
- * from the manifest instead of failing the whole capture. This is semantically SAFE — the E19 snapshot runs
110
- * concurrently with a live tree (the `done` frame is emitted BEFORE the snapshot, so shell-side per-turn
111
- * markers, `.git/index.lock`, editor swap files etc. legitimately vanish mid-walk), and omitting a
112
- * just-deleted file yields the CONSISTENT tree state of one instant later — NOT a partial snapshot (which
113
- * would mean "captured bytes are missing for a file that exists"). No re-stat second check is needed:
114
- * `not_found` is a stable backend-independent FileErrorCode (locally the SOLE mapping of ENOENT,
115
- * node-execution-env.ts toFileError) — the read itself IS the authoritative existence probe at capture time,
116
- * and a re-stat would only re-open the same race one window later. Every OTHER read error (permission_denied/
117
- * EIO/unknown…) and any enumerate failure keeps the fail-closed whole-capture rejection: those mean "the file
118
- * is still there but we could not capture it" — omitting it WOULD be a data-lossy partial snapshot.
119
- * `skippedVanished` reports the count for observability/tests.
120
- */
121
- export declare function captureManifest(env: ExecutionEnv, root: string, bounds: FileSnapshotBounds, signal: AbortSignal | undefined, putBlob: (hash: string, bytes: Uint8Array) => void | Promise<void>): Promise<{
122
- ok: true;
123
- value: Map<string, string>;
124
- skippedVanished: number;
125
- } | {
126
- ok: false;
127
- error: FileSnapshotError;
128
- }>;
129
- /**
130
- * CONVERGE the tree under `root` to `manifest`, loading each captured file's bytes via `getBlob`. **The
131
- * env-operating + security-critical half a durable backend MUST reuse.** Order is load-bearing (+
132
- * Opus M1): PHASE 1 removes every current symlink (neutralizes a `dir -> /outside` ancestor that writeFile would
133
- * otherwise follow OUT of root) + every file created since; PHASE 2 writes each captured file, clearing a
134
- * directory that now occupies a file's path first. Never throws.
135
- */
136
- export declare function applyManifest(env: ExecutionEnv, root: string, bounds: FileSnapshotBounds, signal: AbortSignal | undefined, manifest: Map<string, string>, getBlob: (hash: string) => Uint8Array | undefined | Promise<Uint8Array | undefined>): Promise<FileSnapshotResult>;
137
- /**
138
- * In-memory, content-addressed reference {@link FileSnapshotStore}. Blobs are deduplicated by sha256 ACROSS all
139
- * snapshots (a file unchanged between turns is stored once); each snapshot keeps a manifest `relPath → hash`.
140
- * Single-process (the default-deps reference; a durable/file-backed impl mirrors `src/stores/file/`).
141
- */
142
- export declare class InMemoryFileSnapshotStore implements FileSnapshotStore {
143
- private readonly blobs;
144
- private readonly manifests;
145
- private readonly inFlight;
146
- private readonly bounds;
147
- constructor(bounds?: Partial<FileSnapshotBounds>);
148
- snapshot(scope: string, key: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileSnapshotResult>;
149
- restore(scope: string, key: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileSnapshotResult>;
150
- has(scope: string, key: string): Promise<boolean>;
151
- listKeys(scope: string): Promise<string[]>;
152
- exportManifest(scope: string, key: string): Promise<Map<string, string> | null>;
153
- getBlob(hash: string): Promise<Uint8Array | undefined>;
154
- putBlob(hash: string, bytes: Uint8Array): Promise<FileSnapshotResult>;
155
- importManifest(scope: string, key: string, manifest: Map<string, string>, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileSnapshotResult>;
156
- reap(scope: string, keepKeys: string[]): Promise<number>;
157
- }
158
- /** Test-only window (REF-C2 class pins): pure path helpers whose family-awareness carries a fence/
159
- * restore invariant. Not re-exported from src/index.ts. */
160
- export declare const __testables: {
161
- joinUnder: typeof joinUnder;
162
- relUnder: typeof relUnder;
163
- stripTrailSep: (p: string) => string;
164
- };
165
- export {};
@@ -1,259 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { isAbsolutePathForm, isWinFormPath } from "../tools/fs/safety.js";
3
- const normSlash = (p) => p.replace(/\\/g, "/");
4
- const stripTrailSep = (p) => (isWinFormPath(p) ? p.replace(/[\\/]+$/, "") : p.replace(/\/+$/, ""));
5
- const foldDrive = (p) => (/^[A-Za-z]:(\/|$)/.test(p) ? p[0].toLowerCase() + p.slice(1) : p);
6
- function relUnder(root, child) {
7
- const fold = isWinFormPath(root) ? normSlash : (p) => p;
8
- const r = foldDrive(fold(stripTrailSep(root)));
9
- const c = foldDrive(fold(child));
10
- if (c === r)
11
- return "";
12
- if (!c.startsWith(r + "/"))
13
- return null;
14
- return fold(child).slice(fold(stripTrailSep(root)).length + 1);
15
- }
16
- function joinUnder(root, rel) {
17
- const win = isWinFormPath(root);
18
- return stripTrailSep(root) + (win ? "\\" : "/") + (win ? rel.replace(/\//g, "\\") : rel);
19
- }
20
- function isUnsafeRelPath(relN) {
21
- return isAbsolutePathForm(relN) || /^[A-Za-z]:/.test(relN) || relN.split("/").includes("..");
22
- }
23
- export const DEFAULT_SNAPSHOT_BOUNDS = {
24
- maxFiles: 10000,
25
- maxBytes: 256 * 1024 * 1024,
26
- ignoreDirs: new Set([".git", "node_modules"]),
27
- };
28
- async function enumerateTree(env, root, bounds, signal) {
29
- const files = [];
30
- const symlinks = [];
31
- let totalBytes = 0;
32
- const stack = [root];
33
- while (stack.length > 0) {
34
- if (signal?.aborted)
35
- return { ok: false, error: { code: "aborted", message: "aborted" } };
36
- const dir = stack.pop();
37
- const ls = await env.listDir(dir, signal);
38
- if (!ls.ok) {
39
- return { ok: false, error: { code: "enumerate_failed", message: `listDir ${dir}: ${ls.error.code}` } };
40
- }
41
- for (const info of ls.value) {
42
- if (info.kind === "symlink") {
43
- symlinks.push(info.path);
44
- }
45
- else if (info.kind === "directory") {
46
- if (!bounds.ignoreDirs.has(info.name))
47
- stack.push(info.path);
48
- }
49
- else if (info.kind === "file") {
50
- const relPath = relUnder(root, info.path);
51
- if (relPath === null)
52
- return { ok: false, error: { code: "enumerate_failed", message: `path escapes root form: ${info.path}` } };
53
- files.push({ relPath, absPath: info.path, size: info.size });
54
- totalBytes += info.size;
55
- if (files.length > bounds.maxFiles)
56
- return { ok: false, error: { code: "too_large", message: `> ${bounds.maxFiles} files` } };
57
- if (totalBytes > bounds.maxBytes)
58
- return { ok: false, error: { code: "too_large", message: `> ${bounds.maxBytes} bytes` } };
59
- }
60
- }
61
- }
62
- return { ok: true, value: { files, symlinks } };
63
- }
64
- const SEP = String.fromCharCode(0);
65
- const skey = (scope, key) => `${scope}${SEP}${key}`;
66
- export async function captureManifest(env, root, bounds, signal, putBlob) {
67
- const enumerated = await enumerateTree(env, root, bounds, signal);
68
- if (!enumerated.ok)
69
- return enumerated;
70
- const manifest = new Map();
71
- let skippedVanished = 0;
72
- for (const f of enumerated.value.files) {
73
- if (signal?.aborted)
74
- return { ok: false, error: { code: "aborted", message: "aborted" } };
75
- const rb = await env.readBinaryFile(f.absPath, signal);
76
- if (!rb.ok) {
77
- if (rb.error.code === "not_found") {
78
- skippedVanished++;
79
- continue;
80
- }
81
- return { ok: false, error: { code: "read_failed", message: `read ${f.relPath}: ${rb.error.code}` } };
82
- }
83
- const hash = createHash("sha256").update(rb.value).digest("hex");
84
- await putBlob(hash, rb.value);
85
- manifest.set(f.relPath, hash);
86
- }
87
- return { ok: true, value: manifest, skippedVanished };
88
- }
89
- export async function applyManifest(env, root, bounds, signal, manifest, getBlob) {
90
- const cur = await enumerateTree(env, root, bounds, signal);
91
- if (!cur.ok)
92
- return cur;
93
- for (const link of cur.value.symlinks) {
94
- if (signal?.aborted)
95
- return { ok: false, error: { code: "aborted", message: "aborted" } };
96
- const rm = await env.remove(link, { force: true });
97
- if (!rm.ok)
98
- return { ok: false, error: { code: "restore_failed", message: `remove symlink ${link}: ${rm.error.code}` } };
99
- }
100
- for (const f of cur.value.files) {
101
- if (signal?.aborted)
102
- return { ok: false, error: { code: "aborted", message: "aborted" } };
103
- if (!manifest.has(f.relPath)) {
104
- const rm = await env.remove(f.absPath, { force: true });
105
- if (!rm.ok)
106
- return { ok: false, error: { code: "restore_failed", message: `remove ${f.relPath}: ${rm.error.code}` } };
107
- }
108
- }
109
- for (const [relPath, hash] of manifest) {
110
- if (signal?.aborted)
111
- return { ok: false, error: { code: "aborted", message: "aborted" } };
112
- const relN = isWinFormPath(root) ? normSlash(relPath) : relPath;
113
- if (isUnsafeRelPath(relN)) {
114
- return { ok: false, error: { code: "restore_failed", message: `unsafe relPath escapes root: ${relPath}` } };
115
- }
116
- const blob = await getBlob(hash);
117
- if (!blob)
118
- return { ok: false, error: { code: "restore_failed", message: `missing blob for ${relPath}` } };
119
- const target = joinUnder(root, relN);
120
- const fi = await env.fileInfo(target);
121
- if (fi.ok && fi.value.kind === "directory") {
122
- const rm = await env.remove(target, { recursive: true, force: true });
123
- if (!rm.ok)
124
- return { ok: false, error: { code: "restore_failed", message: `clear dir ${relPath}: ${rm.error.code}` } };
125
- }
126
- const w = await env.writeFile(target, blob);
127
- if (!w.ok)
128
- return { ok: false, error: { code: "restore_failed", message: `write ${relPath}: ${w.error.code}` } };
129
- }
130
- return { ok: true };
131
- }
132
- export class InMemoryFileSnapshotStore {
133
- blobs = new Map();
134
- manifests = new Map();
135
- inFlight = new Map();
136
- bounds;
137
- constructor(bounds) {
138
- this.bounds = {
139
- maxFiles: bounds?.maxFiles ?? DEFAULT_SNAPSHOT_BOUNDS.maxFiles,
140
- maxBytes: bounds?.maxBytes ?? DEFAULT_SNAPSHOT_BOUNDS.maxBytes,
141
- ignoreDirs: new Set(bounds?.ignoreDirs ?? DEFAULT_SNAPSHOT_BOUNDS.ignoreDirs),
142
- };
143
- }
144
- async snapshot(scope, key, env, root, signal) {
145
- const sk = skey(scope, key);
146
- if (this.manifests.has(sk) || this.inFlight.has(sk))
147
- return { ok: true };
148
- const written = new Set();
149
- this.inFlight.set(sk, written);
150
- try {
151
- const captured = await captureManifest(env, root, this.bounds, signal, (hash, bytes) => {
152
- written.add(hash);
153
- if (!this.blobs.has(hash))
154
- this.blobs.set(hash, bytes);
155
- });
156
- if (!captured.ok)
157
- return captured;
158
- this.manifests.set(sk, captured.value);
159
- return { ok: true };
160
- }
161
- finally {
162
- this.inFlight.delete(sk);
163
- }
164
- }
165
- async restore(scope, key, env, root, signal) {
166
- const manifest = this.manifests.get(skey(scope, key));
167
- if (!manifest)
168
- return { ok: false, error: { code: "not_found", message: `no snapshot for key ${key}` } };
169
- return applyManifest(env, root, this.bounds, signal, manifest, (hash) => this.blobs.get(hash));
170
- }
171
- async has(scope, key) {
172
- return this.manifests.has(skey(scope, key));
173
- }
174
- async listKeys(scope) {
175
- const prefix = `${scope}${SEP}`;
176
- const keys = [];
177
- for (const sk of this.manifests.keys()) {
178
- if (sk.startsWith(prefix))
179
- keys.push(sk.slice(prefix.length));
180
- }
181
- return keys;
182
- }
183
- async exportManifest(scope, key) {
184
- const m = this.manifests.get(skey(scope, key));
185
- return m ? new Map(m) : null;
186
- }
187
- async getBlob(hash) {
188
- return this.blobs.get(hash);
189
- }
190
- async putBlob(hash, bytes) {
191
- if (createHash("sha256").update(bytes).digest("hex") !== hash) {
192
- return { ok: false, error: { code: "read_failed", message: `blob bytes fail content-address integrity for ${hash}` } };
193
- }
194
- if (!this.blobs.has(hash))
195
- this.blobs.set(hash, bytes);
196
- return { ok: true };
197
- }
198
- async importManifest(scope, key, manifest, srcGetBlob) {
199
- const sk = skey(scope, key);
200
- if (this.manifests.has(sk) || this.inFlight.has(sk))
201
- return { ok: true };
202
- const written = new Set();
203
- this.inFlight.set(sk, written);
204
- try {
205
- for (const hash of new Set(manifest.values())) {
206
- written.add(hash);
207
- if (hash === "" || hash === "." || hash === ".." || !/^[A-Za-z0-9_.-]+$/.test(hash)) {
208
- return { ok: false, error: { code: "read_failed", message: `unsafe blob hash ${JSON.stringify(hash)}` } };
209
- }
210
- if (this.blobs.has(hash))
211
- continue;
212
- let bytes;
213
- try {
214
- bytes = await srcGetBlob(hash);
215
- }
216
- catch (err) {
217
- return { ok: false, error: { code: "read_failed", message: `source blob ${hash} fetch failed: ${err.message}` } };
218
- }
219
- if (!bytes)
220
- return { ok: false, error: { code: "read_failed", message: `missing source blob ${hash}` } };
221
- if (createHash("sha256").update(bytes).digest("hex") !== hash) {
222
- return { ok: false, error: { code: "read_failed", message: `source blob ${hash} fails content-address integrity` } };
223
- }
224
- this.blobs.set(hash, bytes);
225
- }
226
- this.manifests.set(sk, new Map(manifest));
227
- return { ok: true };
228
- }
229
- catch (err) {
230
- return { ok: false, error: { code: "read_failed", message: `source blob fetch failed: ${err.message}` } };
231
- }
232
- finally {
233
- this.inFlight.delete(sk);
234
- }
235
- }
236
- async reap(scope, keepKeys) {
237
- const keep = new Set(keepKeys);
238
- const prefix = `${scope}${SEP}`;
239
- let removed = 0;
240
- for (const sk of [...this.manifests.keys()]) {
241
- if (sk.startsWith(prefix) && !keep.has(sk.slice(prefix.length))) {
242
- this.manifests.delete(sk);
243
- removed++;
244
- }
245
- }
246
- const live = new Set();
247
- for (const hs of this.inFlight.values())
248
- for (const h of hs)
249
- live.add(h);
250
- for (const m of this.manifests.values())
251
- for (const h of m.values())
252
- live.add(h);
253
- for (const h of [...this.blobs.keys()])
254
- if (!live.has(h))
255
- this.blobs.delete(h);
256
- return removed;
257
- }
258
- }
259
- export const __testables = { joinUnder, relUnder, stripTrailSep };
@@ -1,13 +0,0 @@
1
- import type { FileSnapshotStore } from "../file-snapshot-store.js";
2
- import { type ContractAssertionRunner } from "./contract-harness.js";
3
- export declare function fileSnapshotStoreContract(make: () => FileSnapshotStore, runAssertion?: ContractAssertionRunner, options?: {
4
- /**
5
- * How quickly unreferenced BLOB BYTES become unreadable after a reap. The MANIFEST key is a hard
6
- * contract either way (invisible immediately). `"immediate"` (default — the in-memory and file
7
- * reference backends): `getBlob` of a just-unreferenced hash is `undefined` right after `reap`
8
- * returns. `"eventual"`: a durable/object-storage backend that hands byte GC to an asynchronous
9
- * sweeper (so a reap on the request path is never held hostage by external-storage latency) —
10
- * the immediate-unreadability assertion is skipped; the backend owns proving its sweeper.
11
- */
12
- blobGc?: "immediate" | "eventual";
13
- }): Promise<void>;
@@ -1,134 +0,0 @@
1
- import { strict as assert } from "node:assert";
2
- import { createHash } from "node:crypto";
3
- import { beginContract } from "./contract-harness.js";
4
- const bytes = (s) => new TextEncoder().encode(s);
5
- const sha256 = (s) => createHash("sha256").update(bytes(s)).digest("hex");
6
- const srcBlob = async (hash) => hash === sha256("alpha") ? bytes("alpha") : hash === sha256("beta") ? bytes("beta") : undefined;
7
- export async function fileSnapshotStoreContract(make, runAssertion, options) {
8
- const { run, settle } = beginContract(runAssertion);
9
- const blobGc = options?.blobGc ?? "immediate";
10
- run("kit prerequisites: exportManifest/getBlob/putBlob/importManifest are implemented (REQUIRED by this kit)", async () => {
11
- const probe = make();
12
- const missing = ["exportManifest", "getBlob", "putBlob", "importManifest"].filter((m) => typeof probe[m] !== "function");
13
- assert.equal(missing.length, 0, `backend does not implement ${missing.join(", ")} — required for the FileSnapshotStore contract kit`);
14
- });
15
- run("absent everything: has=false, listKeys=[], exportManifest=null, getBlob=undefined", async () => {
16
- const store = make();
17
- assert.equal(await store.has("sc", "k1"), false);
18
- assert.deepEqual(await store.listKeys("sc"), []);
19
- assert.equal(await store.exportManifest("sc", "k1"), null);
20
- assert.equal(await store.getBlob(sha256("alpha")), undefined);
21
- });
22
- run("putBlob verifies content-address integrity: mismatched bytes → read_failed, nothing stored", async () => {
23
- const store = make();
24
- const r = await store.putBlob(sha256("beta"), bytes("alpha"));
25
- assert.equal(r.ok, false);
26
- if (!r.ok)
27
- assert.equal(r.error.code, "read_failed");
28
- assert.equal(await store.getBlob(sha256("beta")), undefined);
29
- });
30
- run("putBlob ok; a repeat putBlob for the same hash is an idempotent no-op; getBlob round-trips the bytes", async () => {
31
- const store = make();
32
- assert.deepEqual(await store.putBlob(sha256("alpha"), bytes("alpha")), { ok: true });
33
- assert.deepEqual(await store.putBlob(sha256("alpha"), bytes("alpha")), { ok: true });
34
- assert.deepEqual([...(await store.getBlob(sha256("alpha")))], [...bytes("alpha")]);
35
- });
36
- run("importManifest commits only after every blob verifies: has=true, exportManifest round-trips", async () => {
37
- const store = make();
38
- const manifest = new Map([["a.txt", sha256("alpha")], ["b.txt", sha256("beta")]]);
39
- assert.deepEqual(await store.importManifest("sc", "k1", manifest, srcBlob), { ok: true });
40
- assert.equal(await store.has("sc", "k1"), true);
41
- const exported = await store.exportManifest("sc", "k1");
42
- assert.deepEqual([...exported.entries()].sort(), [...manifest.entries()].sort());
43
- });
44
- run("importManifest is create-once: a second import for an existing key is a no-op (ok, zero fetches, manifest unchanged)", async () => {
45
- const store = make();
46
- const manifest = new Map([["a.txt", sha256("alpha")]]);
47
- assert.deepEqual(await store.importManifest("sc", "k1", manifest, srcBlob), { ok: true });
48
- const refetched = [];
49
- const second = await store.importManifest("sc", "k1", new Map([["z.txt", sha256("beta")]]), async (h) => {
50
- refetched.push(h);
51
- return srcBlob(h);
52
- });
53
- assert.deepEqual(second, { ok: true });
54
- assert.deepEqual(refetched, []);
55
- assert.deepEqual([...(await store.exportManifest("sc", "k1")).entries()], [...manifest.entries()]);
56
- });
57
- run("importManifest is FAIL-CLOSED: a missing or hash-mismatched source blob → read_failed, no manifest committed", async () => {
58
- const store = make();
59
- const manifest = new Map([["c.txt", "0".repeat(64)]]);
60
- const miss = await store.importManifest("sc", "k2", manifest, async () => undefined);
61
- assert.equal(miss.ok, false);
62
- if (!miss.ok)
63
- assert.equal(miss.error.code, "read_failed");
64
- assert.equal(await store.has("sc", "k2"), false);
65
- const mism = await store.importManifest("sc", "k2", manifest, async () => bytes("wrong-bytes"));
66
- assert.equal(mism.ok, false);
67
- if (!mism.ok)
68
- assert.equal(mism.error.code, "read_failed");
69
- assert.equal(await store.has("sc", "k2"), false);
70
- });
71
- run("RB-361 a throwing source fetch → read_failed NAMING the blob hash, byte-identical wording (since 2.2.0)", async () => {
72
- const store = make();
73
- const hash = sha256("x");
74
- const r = await store.importManifest("sc", "k3", new Map([["d.txt", hash]]), () => Promise.reject(new Error("transport down")));
75
- assert.equal(r.ok, false);
76
- if (!r.ok) {
77
- assert.equal(r.error.code, "read_failed");
78
- assert.equal(r.error.message, `source blob ${hash} fetch failed: transport down`);
79
- }
80
- assert.equal(await store.has("sc", "k3"), false);
81
- });
82
- run("RB-361 a path-escaping hash is refused as UNSAFE before any fetch, byte-identical wording (since 2.2.0)", async () => {
83
- const store = make();
84
- const fetched = [];
85
- const r = await store.importManifest("sc", "k4", new Map([["e.txt", "../../escape"]]), async (h) => {
86
- fetched.push(h);
87
- return undefined;
88
- });
89
- assert.equal(r.ok, false);
90
- if (!r.ok) {
91
- assert.equal(r.error.code, "read_failed");
92
- assert.equal(r.error.message, `unsafe blob hash "../../escape"`);
93
- }
94
- assert.deepEqual(fetched, []);
95
- assert.equal(await store.has("sc", "k4"), false);
96
- });
97
- run("restore of a missing key → not_found (never a throw out of the seam)", async () => {
98
- const store = make();
99
- const stubEnv = {};
100
- const r = await store.restore("sc", "nope", stubEnv, "/nonexistent-root");
101
- assert.equal(r.ok, false);
102
- if (!r.ok)
103
- assert.equal(r.error.code, "not_found");
104
- });
105
- run("listKeys reflects only committed keys and is scope-isolated", async () => {
106
- const store = make();
107
- await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
108
- await store.importManifest("sc", "k2", new Map([["c.txt", "0".repeat(64)]]), async () => undefined);
109
- assert.deepEqual(((await store.listKeys("sc")) ?? []).slice().sort(), ["k1"]);
110
- assert.deepEqual(await store.listKeys("other"), []);
111
- });
112
- run("reap keeps listed keys and their blobs", async () => {
113
- const store = make();
114
- await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
115
- assert.equal(await store.reap("sc", ["k1"]), 0);
116
- assert.deepEqual(await store.listKeys("sc"), ["k1"]);
117
- assert.deepEqual([...(await store.getBlob(sha256("alpha")))], [...bytes("alpha")]);
118
- });
119
- run("reap keep-nothing drops the key IMMEDIATELY (every backend)", async () => {
120
- const store = make();
121
- await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
122
- assert.equal(await store.reap("sc", []), 1);
123
- assert.equal(await store.has("sc", "k1"), false);
124
- });
125
- if (blobGc === "immediate") {
126
- run("reap keep-nothing GCs the now-unreferenced blob bytes immediately (blobGc: immediate)", async () => {
127
- const store = make();
128
- await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
129
- assert.equal(await store.reap("sc", []), 1);
130
- assert.equal(await store.getBlob(sha256("alpha")), undefined);
131
- });
132
- }
133
- await settle();
134
- }
@@ -1,58 +0,0 @@
1
- import type { ExecutionEnv } from "../../internal/harness-types.js";
2
- import { type FileSnapshotBounds, type FileSnapshotResult, type FileSnapshotStore } from "../../core/file-snapshot-store.js";
3
- export interface FileFileSnapshotStoreOptions {
4
- /**
5
- * Disclosure sink for a durable read this store treats as ABSENT (ruled 2026-08-03). Same name and
6
- * same `{path, reason}` shape as the sibling file stores' seat — "unreadable read as missing" and
7
- * "corrupt read as missing" are one failure grammar, so they get one seat name.
8
- *
9
- * Fires for: a corrupt/unreadable MANIFEST collapsed to "no snapshot" (the never-throw seam's
10
- * documented fail-open — `has`/`restore`/`exportManifest`/`listKeys` all report absence), an
11
- * unreadable manifest DIRECTORY that degrades an enumeration to "no keys", and a blob-GC pass that
12
- * aborted because its live set could not be completed (blobs are kept — leaked, not lost).
13
- *
14
- * Never fires on plain ENOENT: a scope with no snapshots yet really is empty. Swallow-guarded.
15
- */
16
- onCorruptRead?: (info: {
17
- path: string;
18
- reason: string;
19
- }) => void;
20
- }
21
- export declare class FileFileSnapshotStore implements FileSnapshotStore {
22
- private readonly base;
23
- private readonly blobsDir;
24
- private readonly manifestsDir;
25
- /**
26
- * RB-163 (2026-07-26): the in-flight blob set is authority over a DIRECTORY, so it lives with the
27
- * directory. On the instance, a second store on the same base ran `reap()` while the first was midway
28
- * through `importManifest` — its live set did not include the peer's in-flight blobs, so it collected
29
- * blobs the peer had already published, and the import still reported `ok` with a manifest referencing
30
- * files that no longer exist. Keyed by {@link canonicalStoreKey} so two spellings of one directory
31
- * cannot each keep their own idea of what is in flight.
32
- */
33
- private get inFlight();
34
- private readonly inFlightKey;
35
- private readonly bounds;
36
- private readonly onCorruptRead;
37
- constructor(root: string, bounds?: Partial<FileSnapshotBounds>, opts?: FileFileSnapshotStoreOptions);
38
- /** The one delivery point for {@link FileFileSnapshotStoreOptions.onCorruptRead}; swallow-guarded
39
- * here so no call site has to remember (and so the never-throw seam contract cannot be broken by a
40
- * host's sink). */
41
- private disclose;
42
- private scopeDir;
43
- private manifestPath;
44
- /** manifestPath, but a key that fails the strict path-component guard → null (so the seam's NEVER-throw
45
- * contract holds for a direct SDK caller; engine keys are uuidv7 so this never fires in practice). */
46
- private tryManifestPath;
47
- private blobPath;
48
- private readManifest;
49
- snapshot(scope: string, key: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileSnapshotResult>;
50
- restore(scope: string, key: string, env: ExecutionEnv, root: string, signal?: AbortSignal): Promise<FileSnapshotResult>;
51
- listKeys(scope: string): Promise<string[]>;
52
- has(scope: string, key: string): Promise<boolean>;
53
- exportManifest(scope: string, key: string): Promise<Map<string, string> | null>;
54
- getBlob(hash: string): Promise<Uint8Array | undefined>;
55
- putBlob(hash: string, bytes: Uint8Array): Promise<FileSnapshotResult>;
56
- importManifest(scope: string, key: string, manifest: Map<string, string>, srcGetBlob: (hash: string) => Promise<Uint8Array | undefined>): Promise<FileSnapshotResult>;
57
- reap(scope: string, keepKeys: string[]): Promise<number>;
58
- }