@sema-agent/core 5.65.0 → 6.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 (159) hide show
  1. package/CHANGELOG.md +46 -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 +2 -1
  33. package/dist/core/governance-codes.js +14 -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 +20 -0
  65. package/dist/core/permission-rule-consent.js +12 -3
  66. package/dist/core/permission-rule-model.d.ts +67 -7
  67. package/dist/core/permission-rule-model.js +53 -7
  68. package/dist/core/permission-rule-store.js +15 -10
  69. package/dist/core/permission-rule-sync.js +15 -11
  70. package/dist/core/retention-policy.d.ts +9 -0
  71. package/dist/core/retention-policy.js +5 -2
  72. package/dist/core/retention.d.ts +13 -2
  73. package/dist/core/runner/assemble-result.d.ts +19 -1
  74. package/dist/core/runner/assemble-result.js +17 -2
  75. package/dist/core/runner/compaction-call-options.d.ts +93 -0
  76. package/dist/core/runner/compaction-call-options.js +3 -0
  77. package/dist/core/runner/memory-capture-optout.d.ts +80 -0
  78. package/dist/core/runner/memory-capture-optout.js +53 -0
  79. package/dist/core/runner/prepare-config-doors.d.ts +5 -0
  80. package/dist/core/runner/prepare-config-doors.js +16 -0
  81. package/dist/core/runner/prepare-hands-readface.d.ts +110 -5
  82. package/dist/core/runner/prepare-hands-readface.js +99 -7
  83. package/dist/core/runner/prepare-memory.d.ts +88 -0
  84. package/dist/core/runner/prepare-memory.js +305 -24
  85. package/dist/core/runner/prepare-task.d.ts +141 -1
  86. package/dist/core/runner/prepare-task.js +443 -79
  87. package/dist/core/runner/runtask.d.ts +9 -20
  88. package/dist/core/runner/runtask.js +133 -96
  89. package/dist/core/runner/session-file-state-replay.d.ts +18 -10
  90. package/dist/core/runner/session-file-state-replay.js +52 -1
  91. package/dist/core/runner/tool-disclosure.js +2 -1
  92. package/dist/core/runner/turn-attachments.d.ts +22 -12
  93. package/dist/core/session-store.d.ts +1 -1
  94. package/dist/core/session-store.js +6 -1
  95. package/dist/core/session.d.ts +34 -1
  96. package/dist/core/store-contracts/file-history-store-contract.d.ts +3 -0
  97. package/dist/core/store-contracts/file-history-store-contract.js +720 -0
  98. package/dist/core/task-registry-shared.js +11 -1
  99. package/dist/core/tool-errors.js +1 -0
  100. package/dist/core/tool-policy.d.ts +172 -1
  101. package/dist/core/tool-policy.js +32 -1
  102. package/dist/core/tool-result-store.js +2 -1
  103. package/dist/core/trace.d.ts +24 -0
  104. package/dist/core/types.d.ts +784 -89
  105. package/dist/core/types.js +4 -3
  106. package/dist/core/untrusted-text.d.ts +1 -1
  107. package/dist/core/untrusted-text.js +8 -0
  108. package/dist/core/workflow-run-store-contract.js +6 -2
  109. package/dist/core/workflow-run-store.d.ts +4 -1
  110. package/dist/engine/compaction/compaction.d.ts +88 -10
  111. package/dist/engine/compaction/compaction.js +109 -30
  112. package/dist/engine/execution-env/node-execution-env.d.ts +9 -1
  113. package/dist/engine/execution-env/node-execution-env.js +28 -0
  114. package/dist/engine/harness/agent-harness.d.ts +52 -1
  115. package/dist/engine/harness/agent-harness.js +36 -1
  116. package/dist/engine/harness/types.d.ts +26 -1
  117. package/dist/engine/llm/types.d.ts +50 -4
  118. package/dist/engine/loop/agent-loop.d.ts +5 -1
  119. package/dist/engine/loop/agent-loop.js +25 -0
  120. package/dist/engine/loop/types.d.ts +19 -0
  121. package/dist/engine/lsp/node-lsp-manager.d.ts +1 -1
  122. package/dist/engine/session/session.js +1 -1
  123. package/dist/index.d.ts +18 -8
  124. package/dist/index.js +14 -6
  125. package/dist/orchestration/run-workflow-tool.d.ts +20 -2
  126. package/dist/orchestration/run-workflow-tool.js +22 -3
  127. package/dist/orchestration/workflow-governance.d.ts +59 -1
  128. package/dist/orchestration/workflow-governance.js +61 -8
  129. package/dist/orchestration/workflow-meta.d.ts +4 -2
  130. package/dist/orchestration/workflow-primitives.js +56 -13
  131. package/dist/orchestration/workflow-types.d.ts +78 -2
  132. package/dist/orchestration/workflow.d.ts +20 -0
  133. package/dist/orchestration/workflow.js +163 -14
  134. package/dist/prompt-assembly/event-registry.js +1 -1
  135. package/dist/prompts/default.d.ts +7 -7
  136. package/dist/stores/file/file-history-store.d.ts +368 -0
  137. package/dist/stores/file/file-history-store.js +1248 -0
  138. package/dist/stores/file/index.d.ts +22 -13
  139. package/dist/stores/file/index.js +4 -4
  140. package/dist/stores/file/permission-rule-store.js +1 -0
  141. package/dist/stores/file/strategy-store.d.ts +3 -3
  142. package/dist/tools/fs/bash-readonly-classifier.d.ts +87 -3
  143. package/dist/tools/fs/bash-readonly-classifier.js +106 -4
  144. package/dist/tools/fs/fs-bash.js +9 -5
  145. package/dist/tools/fs/fs-shared.d.ts +52 -1
  146. package/dist/tools/fs/fs-shared.js +14 -0
  147. package/dist/tools/fs/fs-write.d.ts +5 -5
  148. package/dist/tools/fs/fs-write.js +71 -14
  149. package/dist/tools/fs/index.d.ts +6 -1
  150. package/dist/tools/fs/index.js +1 -1
  151. package/dist/tools/web.js +2 -1
  152. package/package.json +5 -1
  153. package/test/export-surface.snapshot.json +155 -23
  154. package/dist/core/file-snapshot-store.d.ts +0 -165
  155. package/dist/core/file-snapshot-store.js +0 -259
  156. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +0 -13
  157. package/dist/core/store-contracts/file-snapshot-store-contract.js +0 -134
  158. package/dist/stores/file/file-snapshot-store.d.ts +0 -58
  159. package/dist/stores/file/file-snapshot-store.js +0 -353
@@ -0,0 +1,880 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolutePathForm, isWinFormPath } from "../tools/fs/safety.js";
3
+ import { fileHistoryBoundariesToKeep, resolveFileHistoryRetention } from "./file-history-retention.js";
4
+ const normSlash = (p) => p.replace(/\\/g, "/");
5
+ const stripTrailSep = (p) => (isWinFormPath(p) ? p.replace(/[\\/]+$/, "") : p.replace(/\/+$/, ""));
6
+ const foldDrive = (p) => (/^[A-Za-z]:(\/|$)/.test(p) ? p[0].toLowerCase() + p.slice(1) : p);
7
+ function relUnder(root, child) {
8
+ const fold = isWinFormPath(root) ? normSlash : (p) => p;
9
+ const r = foldDrive(fold(stripTrailSep(root)));
10
+ const c = foldDrive(fold(child));
11
+ if (c === r)
12
+ return "";
13
+ if (!c.startsWith(r + "/"))
14
+ return null;
15
+ return fold(child).slice(fold(stripTrailSep(root)).length + 1);
16
+ }
17
+ function joinUnder(root, rel) {
18
+ const win = isWinFormPath(root);
19
+ return stripTrailSep(root) + (win ? "\\" : "/") + (win ? rel.replace(/\//g, "\\") : rel);
20
+ }
21
+ function isUnsafeRelPathLike(relN) {
22
+ return isAbsolutePathForm(relN) || /^[A-Za-z]:/.test(relN) || relN.split("/").includes("..");
23
+ }
24
+ function isAbsoluteForFamily(root, p) {
25
+ return isWinFormPath(root) ? isAbsolutePathForm(p) : p.startsWith("/");
26
+ }
27
+ function parentDirOf(absPath) {
28
+ const win = isWinFormPath(absPath);
29
+ const norm = win ? normSlash(absPath) : absPath;
30
+ const cut = norm.replace(/\/+$/, "");
31
+ const i = cut.lastIndexOf("/");
32
+ if (i <= 0)
33
+ return undefined;
34
+ const parent = cut.slice(0, i);
35
+ return win ? parent.replace(/\//g, "\\") : parent;
36
+ }
37
+ export function trackKeyFamilyOf(root) {
38
+ return isWinFormPath(root) ? "win" : "posix";
39
+ }
40
+ export function describeTrackKey(root, absPath) {
41
+ const key = trackKeyOf(root, absPath);
42
+ return { key, kind: key === absPath ? "absolute" : "root-relative", family: trackKeyFamilyOf(root) };
43
+ }
44
+ export function trackKeyOf(root, absPath) {
45
+ const rel = relUnder(root, absPath);
46
+ if (rel === null || rel === "")
47
+ return absPath;
48
+ const win = isWinFormPath(root);
49
+ const relN = win ? normSlash(rel) : rel;
50
+ if (win ? isUnsafeRelPathLike(relN) : isAbsoluteForFamily(root, relN) || relN.split("/").includes(".."))
51
+ return absPath;
52
+ return rel;
53
+ }
54
+ export function resolveTrackKey(root, key, minted) {
55
+ const win = isWinFormPath(root);
56
+ if (minted !== undefined && minted.family !== trackKeyFamilyOf(root)) {
57
+ if (minted.kind === "absolute") {
58
+ return { ok: false, reason: `foreign-family absolute key cannot be restored here (recorded under a ${minted.family} root, this root is ${trackKeyFamilyOf(root)}): ${key}` };
59
+ }
60
+ if (isUnsafeRelPathLike(normSlash(key)) || key.includes("\\")) {
61
+ return { ok: false, reason: `foreign-family key is ambiguous across path families (recorded under a ${minted.family} root, this root is ${trackKeyFamilyOf(root)}): ${key}` };
62
+ }
63
+ }
64
+ const keyN = win ? normSlash(key) : key;
65
+ if (isAbsoluteForFamily(root, keyN) || (win && /^[A-Za-z]:/.test(keyN)))
66
+ return { ok: true, abs: key };
67
+ if (win ? isUnsafeRelPathLike(keyN) : keyN.split("/").includes(".."))
68
+ return { ok: false, reason: `unsafe recorded key escapes root: ${key}` };
69
+ return { ok: true, abs: joinUnder(root, keyN) };
70
+ }
71
+ export const FILE_HISTORY_DIFF_LINE_BUDGET = 2000;
72
+ export function countRestoreLineDiff(current, target) {
73
+ const isBinary = (b) => b !== undefined && b.includes(0);
74
+ if (isBinary(current) || isBinary(target))
75
+ return { insertions: 0, deletions: 0 };
76
+ const linesOf = (b) => {
77
+ if (b === undefined)
78
+ return [];
79
+ const text = new TextDecoder().decode(b);
80
+ if (text === "")
81
+ return [];
82
+ return text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n");
83
+ };
84
+ const a = linesOf(current);
85
+ const bLines = linesOf(target);
86
+ const common = longestCommonSubsequenceLength(a, bLines);
87
+ return { deletions: a.length - common, insertions: bLines.length - common };
88
+ }
89
+ function longestCommonSubsequenceLength(a, b) {
90
+ const n = a.length;
91
+ const m = b.length;
92
+ if (n === 0 || m === 0)
93
+ return 0;
94
+ const max = Math.min(FILE_HISTORY_DIFF_LINE_BUDGET, n + m);
95
+ const offset = max;
96
+ const v = new Int32Array(2 * max + 1);
97
+ for (let d = 0; d <= max; d++) {
98
+ for (let k = -d; k <= d; k += 2) {
99
+ let x;
100
+ if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]))
101
+ x = v[offset + k + 1];
102
+ else
103
+ x = v[offset + k - 1] + 1;
104
+ let y = x - k;
105
+ while (x < n && y < m && a[x] === b[y]) {
106
+ x++;
107
+ y++;
108
+ }
109
+ v[offset + k] = x;
110
+ if (x >= n && y >= m) {
111
+ return (n + m - d) / 2;
112
+ }
113
+ }
114
+ }
115
+ return 0;
116
+ }
117
+ export function fileHistoryExportDigest(data) {
118
+ const canonical = {
119
+ formatVersion: data.formatVersion,
120
+ tracked: [...data.tracked]
121
+ .map((t) => ({
122
+ relOrAbsPath: t.relOrAbsPath,
123
+ keyKind: t.keyKind,
124
+ keyFamily: t.keyFamily,
125
+ baseAvailable: t.baseAvailable,
126
+ versions: [...t.versions]
127
+ .map((v) => ({ version: v.version, blobHash: v.blobHash, mode: v.mode ?? null, parentIdent: v.parentIdent ?? null, backupTime: v.backupTime }))
128
+ .sort((a, b) => a.version - b.version),
129
+ }))
130
+ .sort((a, b) => (a.relOrAbsPath < b.relOrAbsPath ? -1 : a.relOrAbsPath > b.relOrAbsPath ? 1 : 0)),
131
+ boundaries: [...data.boundaries]
132
+ .map((b) => ({
133
+ entryId: b.entryId,
134
+ files: [...b.files].map((f) => ({ relOrAbsPath: f.relOrAbsPath, version: f.version })).sort((x, y) => (x.relOrAbsPath < y.relOrAbsPath ? -1 : x.relOrAbsPath > y.relOrAbsPath ? 1 : 0)),
135
+ }))
136
+ .sort((a, b) => (a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : 0)),
137
+ };
138
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
139
+ }
140
+ export function validateFileHistoryExport(data) {
141
+ const bad = (message) => ({ ok: false, error: { code: "unsupported_format", message } });
142
+ if (data === null || typeof data !== "object")
143
+ return bad("file-history import payload is not an envelope");
144
+ if (data.formatVersion !== "file-history/1")
145
+ return bad(`unsupported file-history envelope format ${JSON.stringify(String(data.formatVersion))} — this engine reads "file-history/1" and refuses rather than reinterpreting a foreign epoch`);
146
+ if (!Array.isArray(data.tracked) || !Array.isArray(data.boundaries))
147
+ return bad("file-history envelope is missing its tracked/boundaries arrays");
148
+ const known = new Set();
149
+ for (const t of data.tracked) {
150
+ if (typeof t?.relOrAbsPath !== "string" || t.relOrAbsPath === "")
151
+ return bad("file-history envelope carries a tracked row with no path");
152
+ if (t.keyKind !== "root-relative" && t.keyKind !== "absolute")
153
+ return bad(`tracked row ${t.relOrAbsPath} declares an unknown key kind ${JSON.stringify(String(t.keyKind))} — a key whose shape cannot be judged is refused, never guessed`);
154
+ if (t.keyKind === "absolute") {
155
+ return bad(`tracked row ${t.relOrAbsPath} is an ABSOLUTE key — an imported graph may not carry paths outside the importing root (it would grant this envelope restore authority over an arbitrary path); same-store carry across sessions is adoptScope's seat`);
156
+ }
157
+ if (isUnsafeRelPathLike(normSlash(t.relOrAbsPath)) || t.relOrAbsPath.includes("\\")) {
158
+ return bad(`tracked row ${t.relOrAbsPath} is not a safe root-relative key (absolute form, drive spelling, '..' segment, or a separator that means different things across path families) — refused, never coerced`);
159
+ }
160
+ if (t.keyFamily !== "posix" && t.keyFamily !== "win")
161
+ return bad(`tracked row ${t.relOrAbsPath} declares an unknown key family ${JSON.stringify(String(t.keyFamily))} — a key whose provenance cannot be judged is refused, never guessed`);
162
+ if (typeof t.baseAvailable !== "boolean" || !Array.isArray(t.versions))
163
+ return bad(`tracked row ${t.relOrAbsPath} is missing its base-availability state or version chain`);
164
+ for (const v of t.versions) {
165
+ if (typeof v?.version !== "number" || !Number.isInteger(v.version) || v.version < 1)
166
+ return bad(`tracked row ${t.relOrAbsPath} carries a non-integer version number`);
167
+ if (v.blobHash !== null && (typeof v.blobHash !== "string" || !/^[0-9a-f]{64}$/.test(v.blobHash)))
168
+ return bad(`tracked row ${t.relOrAbsPath} carries a blob reference that is not a sha256 digest`);
169
+ }
170
+ if (new Set(t.versions.map((v) => v.version)).size !== t.versions.length)
171
+ return bad(`tracked row ${t.relOrAbsPath} carries the same version number twice — a chain whose versions are ambiguous cannot be published`);
172
+ for (let i = 1; i < t.versions.length; i++) {
173
+ if (t.versions[i].version <= t.versions[i - 1].version) {
174
+ return bad(`tracked row ${t.relOrAbsPath} carries a chain whose versions are not strictly increasing in order (…${t.versions[i - 1].version}, ${t.versions[i].version}…) — a later local boundary would then reference an ambiguous version`);
175
+ }
176
+ }
177
+ if (known.has(t.relOrAbsPath)) {
178
+ return bad(`file-history envelope carries the tracked row ${t.relOrAbsPath} twice — duplicate keys make the published graph differ from the validated one`);
179
+ }
180
+ if (t.baseAvailable && t.versions.length > 0 && t.versions[0].version !== 1)
181
+ return bad(`tracked row ${t.relOrAbsPath} claims an available base but its chain does not start at v1`);
182
+ if (!t.baseAvailable && t.versions.some((v) => v.version === 1))
183
+ return bad(`tracked row ${t.relOrAbsPath} carries a v1 record while declaring its base UNAVAILABLE — the DV-14 refusal state and a pristine baseline are mutually exclusive`);
184
+ known.add(t.relOrAbsPath);
185
+ }
186
+ const seen = new Set();
187
+ for (const b of data.boundaries) {
188
+ if (typeof b?.entryId !== "string" || b.entryId === "")
189
+ return bad("file-history envelope carries a boundary with no entry id");
190
+ if (seen.has(b.entryId))
191
+ return bad(`file-history envelope carries the boundary ${b.entryId} twice`);
192
+ seen.add(b.entryId);
193
+ if (!Array.isArray(b.files))
194
+ return bad(`boundary ${b.entryId} carries no file mapping`);
195
+ if (new Set(b.files.map((f) => f?.relOrAbsPath)).size !== b.files.length)
196
+ return bad(`boundary ${b.entryId} maps the same path twice — the published mapping would differ from the validated one`);
197
+ for (const f of b.files) {
198
+ if (typeof f?.relOrAbsPath !== "string" || typeof f.version !== "number")
199
+ return bad(`boundary ${b.entryId} carries a malformed file mapping`);
200
+ if (!known.has(f.relOrAbsPath))
201
+ return bad(`boundary ${b.entryId} references ${f.relOrAbsPath}, which the envelope's tracked set does not carry`);
202
+ const chain = data.tracked.find((t) => t.relOrAbsPath === f.relOrAbsPath);
203
+ if (!chain.versions.some((v) => v.version === f.version))
204
+ return bad(`boundary ${b.entryId} references ${f.relOrAbsPath}@v${f.version}, which the envelope's version chain does not carry`);
205
+ }
206
+ }
207
+ return { ok: true };
208
+ }
209
+ export function boundaryPublishVerdict(attempt) {
210
+ return attempt.aborted ? { publish: false, reason: "aborted" } : { publish: true };
211
+ }
212
+ async function parentIdentityViaEnv(env, absPath, root, signal) {
213
+ const parent = parentDirOf(absPath);
214
+ if (parent === undefined)
215
+ return undefined;
216
+ const canon = await env.canonicalPath(parent, signal);
217
+ if (!canon.ok)
218
+ return undefined;
219
+ let rel = relUnder(root, canon.value);
220
+ if (rel === null) {
221
+ const canonRoot = await env.canonicalPath(root, signal);
222
+ if (canonRoot.ok)
223
+ rel = relUnder(canonRoot.value, canon.value);
224
+ }
225
+ return rel === null ? canon.value : rel === "" ? "." : rel;
226
+ }
227
+ export async function captureFileBackupViaEnv(env, absPath, root, signal) {
228
+ if (signal?.aborted)
229
+ return { state: "aborted" };
230
+ const parentIdent = await parentIdentityViaEnv(env, absPath, root, signal);
231
+ const rb = await env.readBinaryFile(absPath, signal);
232
+ if (!rb.ok) {
233
+ if (signal?.aborted)
234
+ return { state: "aborted" };
235
+ if (rb.error.code === "not_found")
236
+ return { state: "absent", ...(parentIdent !== undefined ? { parentIdent } : {}) };
237
+ return { state: "unreadable", cause: `read ${absPath}: ${rb.error.code}` };
238
+ }
239
+ const hash = createHash("sha256").update(rb.value).digest("hex");
240
+ const mode = await (async () => {
241
+ const info = await env.fileInfo(absPath, signal);
242
+ return info.ok && typeof info.value.mode === "number" ? info.value.mode & 0o7777 : undefined;
243
+ })();
244
+ return { state: "file", bytes: rb.value, hash, ...(mode !== undefined ? { mode } : {}), ...(parentIdent !== undefined ? { parentIdent } : {}) };
245
+ }
246
+ export async function worldStillMatchesMint(env, absPath, root, baseAvailable, mintedBlobHash, signal) {
247
+ if (!baseAvailable) {
248
+ return { unchanged: false, reason: "the first touch recorded a base-UNAVAILABLE marker, whose whole purpose is to refuse a fake pristine later — an ambiguous failure may not erase it" };
249
+ }
250
+ const cap = await captureFileBackupViaEnv(env, absPath, root, signal);
251
+ if (cap.state === "absent") {
252
+ return mintedBlobHash === null ? { unchanged: true } : { unchanged: false, reason: "the file recorded at the first touch is gone now" };
253
+ }
254
+ if (cap.state === "file") {
255
+ if (mintedBlobHash === null)
256
+ return { unchanged: false, reason: "the path was recorded as NOT EXISTING at the first touch but holds bytes now" };
257
+ return cap.hash === mintedBlobHash ? { unchanged: true } : { unchanged: false, reason: "the file's bytes differ from the ones recorded at the first touch" };
258
+ }
259
+ return { unchanged: false, reason: `the path could not be re-read to prove it is unchanged (${cap.state === "aborted" ? "aborted" : cap.cause})` };
260
+ }
261
+ export function modeProvenDifferent(recorded, current) {
262
+ return recorded !== undefined && current !== undefined && recorded !== current;
263
+ }
264
+ export async function applyFileRestoreViaEnv(env, absPath, target, opts) {
265
+ const signal = opts.signal;
266
+ if (signal?.aborted)
267
+ return { outcome: "failed", reason: "aborted" };
268
+ const guard = await restorePreflight(env, absPath, opts);
269
+ if ("refused" in guard)
270
+ return guard.refused;
271
+ const cur = await env.readBinaryFile(absPath, signal);
272
+ const curAbsent = !cur.ok && cur.error.code === "not_found";
273
+ if ("absent" in target) {
274
+ if (curAbsent)
275
+ return { outcome: "identical" };
276
+ const rm = await env.remove(absPath, { force: true, ...(signal !== undefined ? { abortSignal: signal } : {}) });
277
+ if (!rm.ok)
278
+ return { outcome: "failed", reason: `remove: ${rm.error.code}` };
279
+ return { outcome: "applied" };
280
+ }
281
+ if (cur.ok && createHash("sha256").update(cur.value).digest("hex") === target.hash) {
282
+ const modeOnly = await applyRecordedMode(env, absPath, opts, guard.currentMode);
283
+ if (modeOnly === "unchanged")
284
+ return { outcome: "identical" };
285
+ if (modeOnly === "applied")
286
+ return { outcome: "applied" };
287
+ return { outcome: "identical", modeNotPreserved: true };
288
+ }
289
+ const w = await writeRestoredBytes(env, absPath, target.bytes, signal);
290
+ if (!w.ok)
291
+ return { outcome: w.refused ? "refused" : "failed", reason: `write: ${w.code}` };
292
+ const modeAfter = await applyRecordedMode(env, absPath, opts, undefined);
293
+ return { outcome: "applied", ...(modeAfter === "unpreserved" ? { modeNotPreserved: true } : {}) };
294
+ }
295
+ async function restorePreflight(env, absPath, opts) {
296
+ const info = await env.fileInfo(absPath, opts.signal);
297
+ if (info.ok && info.value.kind !== "file") {
298
+ return {
299
+ refused: {
300
+ outcome: "refused",
301
+ reason: info.value.kind === "symlink"
302
+ ? "restore target is a SYMLINK — the recorded bytes belong to the file that was edited, not to whatever the link now points at"
303
+ : `restore target is a ${info.value.kind}, not a regular file — the recorded bytes are not this object's`,
304
+ },
305
+ };
306
+ }
307
+ const currentMode = info.ok ? info.value.mode : undefined;
308
+ if (opts.parentIdent === undefined)
309
+ return { currentMode };
310
+ const now = await parentIdentityViaEnv(env, absPath, opts.root, opts.signal);
311
+ if (now === undefined)
312
+ return { currentMode };
313
+ if (now === opts.parentIdent)
314
+ return { currentMode };
315
+ return {
316
+ refused: {
317
+ outcome: "refused",
318
+ reason: `restore target's parent chain changed identity since it was recorded (recorded ${JSON.stringify(opts.parentIdent)}, now ${JSON.stringify(now)}) — writing here could land outside the task root`,
319
+ },
320
+ };
321
+ }
322
+ async function writeRestoredBytes(env, absPath, bytes, signal) {
323
+ if (env.writeFileGuarded !== undefined) {
324
+ const r = await env.writeFileGuarded(absPath, bytes, { canonicalPath: absPath, noFollow: true }, signal);
325
+ return r.ok ? { ok: true } : { ok: false, code: r.error.code, refused: r.error.code === "precondition_failed" };
326
+ }
327
+ const w = await env.writeFile(absPath, bytes, signal);
328
+ return w.ok ? { ok: true } : { ok: false, code: w.error.code, refused: false };
329
+ }
330
+ export async function previewFileDelta(env, absPath, targetHash, targetBytes, targetMode) {
331
+ const cur = await env.readBinaryFile(absPath);
332
+ const curBytes = cur.ok ? cur.value : undefined;
333
+ if (curBytes === undefined && targetBytes === undefined)
334
+ return undefined;
335
+ if (curBytes !== undefined && targetHash !== null && createHash("sha256").update(curBytes).digest("hex") === targetHash) {
336
+ if (targetMode === undefined)
337
+ return undefined;
338
+ const info = await env.fileInfo(absPath);
339
+ const currentMode = info.ok ? info.value.mode : undefined;
340
+ return modeProvenDifferent(targetMode, currentMode) ? { insertions: 0, deletions: 0 } : undefined;
341
+ }
342
+ return countRestoreLineDiff(curBytes, targetBytes);
343
+ }
344
+ async function applyRecordedMode(env, absPath, opts, currentMode) {
345
+ const want = opts.mode;
346
+ if (want === undefined)
347
+ return "unchanged";
348
+ if (currentMode === (want & 0o7777))
349
+ return "unchanged";
350
+ if (env.setFileMode === undefined)
351
+ return "unpreserved";
352
+ const r = await env.setFileMode(absPath, want & 0o7777, opts.signal);
353
+ return r.ok ? "applied" : "unpreserved";
354
+ }
355
+ export class InMemoryFileHistoryStore {
356
+ scopes = new Map();
357
+ boundaryKeep;
358
+ blobs = new Map();
359
+ inFlightBlobs = new Map();
360
+ constructor(opts) {
361
+ this.boundaryKeep = resolveFileHistoryRetention("InMemoryFileHistoryStore", opts?.retention);
362
+ }
363
+ applyBoundaryRetention(scope, justCommitted) {
364
+ if (this.boundaryKeep === null)
365
+ return;
366
+ try {
367
+ const sc = this.scopes.get(scope);
368
+ if (sc === undefined)
369
+ return;
370
+ const keepIds = fileHistoryBoundariesToKeep([...sc.boundaries.keys()], justCommitted, this.boundaryKeep);
371
+ if (keepIds === null)
372
+ return;
373
+ this.reap(scope, keepIds).catch(() => { });
374
+ }
375
+ catch {
376
+ }
377
+ }
378
+ leaseBlob(hash) {
379
+ this.inFlightBlobs.set(hash, (this.inFlightBlobs.get(hash) ?? 0) + 1);
380
+ }
381
+ releaseBlob(hash) {
382
+ const n = this.inFlightBlobs.get(hash);
383
+ if (n === undefined)
384
+ return;
385
+ if (n <= 1)
386
+ this.inFlightBlobs.delete(hash);
387
+ else
388
+ this.inFlightBlobs.set(hash, n - 1);
389
+ }
390
+ scope(scope) {
391
+ let sc = this.scopes.get(scope);
392
+ if (sc === undefined) {
393
+ sc = { tracked: new Map(), boundaries: new Map(), spentAttempts: new Set(), activeAttempts: new Set(), reapedEntries: new Set() };
394
+ this.scopes.set(scope, sc);
395
+ }
396
+ return sc;
397
+ }
398
+ persistFirstTouch(scope, key, state) {
399
+ this.scope(scope).tracked.set(key, state);
400
+ }
401
+ publishBoundary(scope, entryId, staged) {
402
+ const sc = this.scope(scope);
403
+ for (const [key, cap] of staged) {
404
+ if (cap.kind !== "unchanged")
405
+ continue;
406
+ const still = sc.tracked.get(key)?.versions.some((v) => v.version === cap.version) ?? false;
407
+ if (!still)
408
+ throw new Error(`staged reference ${key}@v${cap.version} no longer exists (reaped mid-capture)`);
409
+ }
410
+ const mapping = new Map();
411
+ const now = new Date().toISOString();
412
+ for (const [key, cap] of staged) {
413
+ if (cap.kind === "unchanged") {
414
+ mapping.set(key, cap.version);
415
+ continue;
416
+ }
417
+ const t = sc.tracked.get(key);
418
+ if (t === undefined)
419
+ continue;
420
+ const last = t.versions[t.versions.length - 1];
421
+ const version = last !== undefined ? last.version + 1 : t.baseAvailable ? 1 : 2;
422
+ t.versions.push({ version, blobHash: cap.blobHash, backupTime: now, ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
423
+ mapping.set(key, version);
424
+ }
425
+ sc.boundaries.set(entryId, mapping);
426
+ }
427
+ async trackEdit(scope, absPath, env, root, signal) {
428
+ try {
429
+ const { key, ...keyDesc } = describeTrackKey(root, absPath);
430
+ const sc = this.scope(scope);
431
+ const already = sc.tracked.get(key);
432
+ if (already !== undefined) {
433
+ already.observedByLaterTouch = true;
434
+ return { ok: true, minted: false };
435
+ }
436
+ const cap = await captureFileBackupViaEnv(env, absPath, root, signal);
437
+ const raced = sc.tracked.get(key);
438
+ if (raced !== undefined) {
439
+ raced.observedByLaterTouch = true;
440
+ return { ok: true, minted: false };
441
+ }
442
+ if (cap.state === "aborted") {
443
+ return { ok: false, error: { code: "aborted", message: `first-touch backup of ${absPath} aborted` } };
444
+ }
445
+ if (cap.state === "unreadable") {
446
+ this.persistFirstTouch(scope, key, { baseAvailable: false, keyDesc, versions: [] });
447
+ return { ok: true, minted: true };
448
+ }
449
+ if (cap.state === "file") {
450
+ this.leaseBlob(cap.hash);
451
+ try {
452
+ if (!this.blobs.has(cap.hash))
453
+ this.blobs.set(cap.hash, cap.bytes);
454
+ this.persistFirstTouch(scope, key, {
455
+ baseAvailable: true,
456
+ keyDesc,
457
+ versions: [{ version: 1, blobHash: cap.hash, backupTime: new Date().toISOString(), ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) }],
458
+ });
459
+ }
460
+ finally {
461
+ this.releaseBlob(cap.hash);
462
+ }
463
+ return { ok: true, minted: true };
464
+ }
465
+ this.persistFirstTouch(scope, key, {
466
+ baseAvailable: true,
467
+ keyDesc,
468
+ versions: [{ version: 1, blobHash: null, backupTime: new Date().toISOString(), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) }],
469
+ });
470
+ return { ok: true, minted: true };
471
+ }
472
+ catch (err) {
473
+ return { ok: false, error: { code: "track_failed", message: `first-touch record for ${absPath} could not be persisted: ${err.message}` } };
474
+ }
475
+ }
476
+ async annulTrack(scope, absPath, root, verify) {
477
+ try {
478
+ const key = trackKeyOf(root, absPath);
479
+ const sc = this.scopes.get(scope);
480
+ const t = sc?.tracked.get(key);
481
+ if (sc === undefined || t === undefined)
482
+ return { ok: true };
483
+ const ineligible = () => {
484
+ const live = sc.tracked.get(key);
485
+ if (live === undefined)
486
+ return undefined;
487
+ if (live !== t)
488
+ return `first-touch record for ${absPath} was replaced while this retraction was being proven`;
489
+ for (const mapping of sc.boundaries.values()) {
490
+ if (mapping.has(key))
491
+ return `first-touch record for ${absPath} is referenced by a committed boundary — it is history now, not a retractable mint`;
492
+ }
493
+ if (live.versions.length > 1)
494
+ return `first-touch record for ${absPath} has grown past its first version — only the mint itself is retractable`;
495
+ if (live.observedByLaterTouch === true) {
496
+ return `first-touch record for ${absPath} has since been served to another edit of the same path — it is that edit's baseline now, not a retractable mint`;
497
+ }
498
+ return undefined;
499
+ };
500
+ const before = ineligible();
501
+ if (before !== undefined)
502
+ return { ok: false, error: { code: "conflict", message: before } };
503
+ const proof = verify === undefined ? undefined : await worldStillMatchesMint(verify.env, absPath, root, t.baseAvailable, t.versions[0]?.blobHash ?? null, verify.signal);
504
+ if (proof !== undefined && !proof.unchanged) {
505
+ return { ok: false, error: { code: "conflict", message: `first-touch record for ${absPath} is NOT retractable: ${proof.reason} — the write this retraction compensates for may have changed the file, and deleting the only baseline of a modified file is worse than keeping a stale record` } };
506
+ }
507
+ const after = ineligible();
508
+ if (after !== undefined)
509
+ return { ok: false, error: { code: "conflict", message: after } };
510
+ sc.tracked.delete(key);
511
+ return { ok: true };
512
+ }
513
+ catch (err) {
514
+ return { ok: false, error: { code: "track_failed", message: `annulling the first-touch record for ${absPath} failed: ${err.message}` } };
515
+ }
516
+ }
517
+ async snapshot(scope, entryId, env, root, signal) {
518
+ const sc = this.scope(scope);
519
+ if (sc.boundaries.has(entryId))
520
+ return { ok: true };
521
+ if (sc.reapedEntries.has(entryId)) {
522
+ return { ok: false, error: { code: "snapshot_failed", message: `entry "${entryId}" already had a boundary and it was reaped by retention — a fresh capture now would record post-turn bytes under a pre-turn key, so this entry has no boundary and cannot get one back` } };
523
+ }
524
+ if (sc.spentAttempts.has(entryId)) {
525
+ return { ok: false, error: { code: "snapshot_failed", message: `capture lease for entry "${entryId}" is closed (a prior attempt failed); no complete pre-close staging exists, so this turn has no boundary` } };
526
+ }
527
+ if (sc.activeAttempts.has(entryId)) {
528
+ return { ok: false, error: { code: "snapshot_failed", message: `a capture attempt for entry "${entryId}" is already in flight — concurrent same-key capture is refused (the in-flight attempt owns the lease)` } };
529
+ }
530
+ const stagedBlobHashes = new Set();
531
+ sc.activeAttempts.add(entryId);
532
+ try {
533
+ const staged = new Map();
534
+ for (const [key, t] of [...sc.tracked]) {
535
+ if (signal?.aborted) {
536
+ sc.spentAttempts.add(entryId);
537
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted` } };
538
+ }
539
+ const resolved = resolveTrackKey(root, key, t.keyDesc);
540
+ if (!resolved.ok) {
541
+ sc.spentAttempts.add(entryId);
542
+ return { ok: false, error: { code: "snapshot_failed", message: resolved.reason } };
543
+ }
544
+ const cap = await captureFileBackupViaEnv(env, resolved.abs, root, signal);
545
+ if (cap.state === "aborted") {
546
+ sc.spentAttempts.add(entryId);
547
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted` } };
548
+ }
549
+ if (cap.state === "unreadable") {
550
+ sc.spentAttempts.add(entryId);
551
+ return { ok: false, error: { code: "snapshot_failed", message: cap.cause } };
552
+ }
553
+ const last = t.versions[t.versions.length - 1];
554
+ if (cap.state === "absent") {
555
+ if (last !== undefined && last.blobHash === null)
556
+ staged.set(key, { kind: "unchanged", version: last.version });
557
+ else
558
+ staged.set(key, { kind: "changed", blobHash: null, ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
559
+ continue;
560
+ }
561
+ if (last !== undefined && last.blobHash === cap.hash && !modeProvenDifferent(last.mode, cap.mode)) {
562
+ staged.set(key, { kind: "unchanged", version: last.version });
563
+ continue;
564
+ }
565
+ if (!stagedBlobHashes.has(cap.hash)) {
566
+ stagedBlobHashes.add(cap.hash);
567
+ this.leaseBlob(cap.hash);
568
+ }
569
+ if (!this.blobs.has(cap.hash))
570
+ this.blobs.set(cap.hash, cap.bytes);
571
+ staged.set(key, { kind: "changed", blobHash: cap.hash, ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
572
+ }
573
+ const verdict = boundaryPublishVerdict({ aborted: signal?.aborted === true });
574
+ if (!verdict.publish) {
575
+ sc.spentAttempts.add(entryId);
576
+ return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted (fenced — never committed)` } };
577
+ }
578
+ try {
579
+ this.publishBoundary(scope, entryId, staged);
580
+ }
581
+ catch (err) {
582
+ sc.spentAttempts.add(entryId);
583
+ return { ok: false, error: { code: "snapshot_failed", message: `boundary for entry "${entryId}" could not be committed: ${err.message}` } };
584
+ }
585
+ this.applyBoundaryRetention(scope, entryId);
586
+ return { ok: true };
587
+ }
588
+ catch (err) {
589
+ sc.spentAttempts.add(entryId);
590
+ return { ok: false, error: { code: "snapshot_failed", message: err.message } };
591
+ }
592
+ finally {
593
+ sc.activeAttempts.delete(entryId);
594
+ for (const h of stagedBlobHashes)
595
+ this.releaseBlob(h);
596
+ }
597
+ }
598
+ async restore(scope, entryId, env, root, signal) {
599
+ const empty = { filesChanged: [], identicalSkipped: [], refused: [], failed: [] };
600
+ try {
601
+ const sc = this.scopes.get(scope);
602
+ const boundary = sc?.boundaries.get(entryId);
603
+ if (sc === undefined || boundary === undefined) {
604
+ return { outcome: "failed", ...empty, error: { code: "not_found", message: `no history boundary for entry "${entryId}"` } };
605
+ }
606
+ const ledger = empty;
607
+ let modeNotPreserved = 0;
608
+ for (const key of [...sc.tracked.keys()].sort()) {
609
+ const t = sc.tracked.get(key);
610
+ if (signal?.aborted) {
611
+ ledger.failed.push({ path: key, reason: "aborted before attempt" });
612
+ continue;
613
+ }
614
+ let targetVersion = boundary.get(key);
615
+ if (targetVersion === undefined) {
616
+ if (!t.baseAvailable) {
617
+ ledger.refused.push({ path: key, reason: "base_unavailable" });
618
+ continue;
619
+ }
620
+ targetVersion = 1;
621
+ }
622
+ const rec = t.versions.find((v) => v.version === targetVersion);
623
+ if (rec === undefined) {
624
+ ledger.failed.push({ path: key, reason: `version ${targetVersion} record missing (store integrity)` });
625
+ continue;
626
+ }
627
+ const resolved = resolveTrackKey(root, key, t.keyDesc);
628
+ if (!resolved.ok) {
629
+ ledger.refused.push({ path: key, reason: resolved.reason });
630
+ continue;
631
+ }
632
+ let target;
633
+ if (rec.blobHash === null)
634
+ target = { absent: true };
635
+ else {
636
+ const bytes = this.blobs.get(rec.blobHash);
637
+ if (bytes === undefined) {
638
+ ledger.failed.push({ path: key, reason: `blob ${rec.blobHash} missing (store integrity)` });
639
+ continue;
640
+ }
641
+ target = { bytes, hash: rec.blobHash };
642
+ }
643
+ let applied;
644
+ try {
645
+ applied = await applyFileRestoreViaEnv(env, resolved.abs, target, {
646
+ root,
647
+ ...(rec.mode !== undefined ? { mode: rec.mode } : {}),
648
+ ...(rec.parentIdent !== undefined ? { parentIdent: rec.parentIdent } : {}),
649
+ ...(signal !== undefined ? { signal } : {}),
650
+ });
651
+ }
652
+ catch (err) {
653
+ applied = { outcome: "failed", reason: err.message };
654
+ }
655
+ if (applied.outcome === "applied")
656
+ ledger.filesChanged.push(key);
657
+ else if (applied.outcome === "identical")
658
+ ledger.identicalSkipped.push(key);
659
+ else if (applied.outcome === "refused")
660
+ ledger.refused.push({ path: key, reason: applied.reason });
661
+ else
662
+ ledger.failed.push({ path: key, reason: applied.reason });
663
+ if ((applied.outcome === "applied" || applied.outcome === "identical") && applied.modeNotPreserved === true)
664
+ modeNotPreserved++;
665
+ }
666
+ return { outcome: ledger.refused.length > 0 || ledger.failed.length > 0 ? "partial" : "complete", ...ledger, ...(modeNotPreserved > 0 ? { modeNotPreserved } : {}) };
667
+ }
668
+ catch (err) {
669
+ const touched = empty.filesChanged.length > 0 || empty.identicalSkipped.length > 0 || empty.refused.length > 0 || empty.failed.length > 0;
670
+ return { outcome: touched ? "partial" : "failed", ...empty, error: { code: "restore_failed", message: err.message } };
671
+ }
672
+ }
673
+ async canRestore(scope, entryId) {
674
+ return this.scopes.get(scope)?.boundaries.has(entryId) ?? false;
675
+ }
676
+ async diffStats(scope, entryId, env, root) {
677
+ try {
678
+ const sc = this.scopes.get(scope);
679
+ const boundary = sc?.boundaries.get(entryId);
680
+ if (sc === undefined || boundary === undefined)
681
+ return null;
682
+ const filesChanged = [];
683
+ let insertions = 0;
684
+ let deletions = 0;
685
+ for (const key of [...sc.tracked.keys()].sort()) {
686
+ const t = sc.tracked.get(key);
687
+ let targetVersion = boundary.get(key);
688
+ if (targetVersion === undefined) {
689
+ if (!t.baseAvailable)
690
+ continue;
691
+ targetVersion = 1;
692
+ }
693
+ const rec = t.versions.find((v) => v.version === targetVersion);
694
+ const resolved = resolveTrackKey(root, key, t.keyDesc);
695
+ if (rec === undefined || !resolved.ok)
696
+ continue;
697
+ const targetBytes = rec.blobHash === null ? undefined : this.blobs.get(rec.blobHash);
698
+ if (rec.blobHash !== null && targetBytes === undefined)
699
+ continue;
700
+ const delta = await previewFileDelta(env, resolved.abs, rec.blobHash, targetBytes, rec.mode);
701
+ if (delta === undefined)
702
+ continue;
703
+ filesChanged.push(key);
704
+ insertions += delta.insertions;
705
+ deletions += delta.deletions;
706
+ }
707
+ return { filesChanged, insertions, deletions };
708
+ }
709
+ catch {
710
+ return null;
711
+ }
712
+ }
713
+ async reap(scope, keepEntryIds) {
714
+ const sc = this.scopes.get(scope);
715
+ if (sc === undefined)
716
+ return 0;
717
+ const keep = new Set(keepEntryIds);
718
+ let removed = 0;
719
+ for (const id of [...sc.boundaries.keys()]) {
720
+ if (!keep.has(id)) {
721
+ sc.boundaries.delete(id);
722
+ sc.reapedEntries.add(id);
723
+ removed++;
724
+ }
725
+ }
726
+ const referenced = new Map();
727
+ for (const mapping of sc.boundaries.values()) {
728
+ for (const [key, v] of mapping) {
729
+ let s = referenced.get(key);
730
+ if (s === undefined)
731
+ referenced.set(key, (s = new Set()));
732
+ s.add(v);
733
+ }
734
+ }
735
+ for (const [key, t] of sc.tracked) {
736
+ const refs = referenced.get(key);
737
+ t.versions = t.versions.filter((v) => v.version === 1 || (refs?.has(v.version) ?? false));
738
+ }
739
+ const live = new Set(this.inFlightBlobs.keys());
740
+ for (const s of this.scopes.values()) {
741
+ for (const t of s.tracked.values())
742
+ for (const v of t.versions)
743
+ if (v.blobHash !== null)
744
+ live.add(v.blobHash);
745
+ }
746
+ for (const h of [...this.blobs.keys()])
747
+ if (!live.has(h))
748
+ this.blobs.delete(h);
749
+ return removed;
750
+ }
751
+ async adoptScope(fromScope, toScope) {
752
+ try {
753
+ if (fromScope === toScope)
754
+ return { ok: true };
755
+ const from = this.scopes.get(fromScope);
756
+ const to = this.scopes.get(toScope);
757
+ if (to !== undefined && (to.tracked.size > 0 || to.boundaries.size > 0)) {
758
+ return { ok: false, error: { code: "conflict", message: `adoptScope target "${toScope}" already has history of its own — adoption is legal only onto an EMPTY scope (the fork/resume boundary before the new session's first turn)` } };
759
+ }
760
+ if (from === undefined || (from.tracked.size === 0 && from.boundaries.size === 0)) {
761
+ if (from !== undefined && from.reapedEntries.size > 0) {
762
+ const target = this.scope(toScope);
763
+ for (const id of from.reapedEntries)
764
+ target.reapedEntries.add(id);
765
+ }
766
+ return { ok: true };
767
+ }
768
+ const copy = { tracked: new Map(), boundaries: new Map(), spentAttempts: new Set(), activeAttempts: new Set(), reapedEntries: new Set([...from.reapedEntries, ...(to?.reapedEntries ?? [])]) };
769
+ for (const [key, t] of from.tracked)
770
+ copy.tracked.set(key, { baseAvailable: t.baseAvailable, keyDesc: { ...t.keyDesc }, ...(t.observedByLaterTouch === true ? { observedByLaterTouch: true } : {}), versions: t.versions.map((v) => ({ ...v })) });
771
+ for (const [id, mapping] of from.boundaries)
772
+ copy.boundaries.set(id, new Map(mapping));
773
+ this.scopes.set(toScope, copy);
774
+ return { ok: true };
775
+ }
776
+ catch (err) {
777
+ return { ok: false, error: { code: "adopt_failed", message: `adoptScope failed: ${err.message}` } };
778
+ }
779
+ }
780
+ async exportHistory(scope) {
781
+ const sc = this.scopes.get(scope);
782
+ if (sc === undefined || (sc.tracked.size === 0 && sc.boundaries.size === 0))
783
+ return null;
784
+ return this.exportOf(sc);
785
+ }
786
+ exportOf(sc) {
787
+ return {
788
+ formatVersion: "file-history/1",
789
+ tracked: [...sc.tracked.entries()]
790
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
791
+ .map(([key, t]) => ({
792
+ relOrAbsPath: key,
793
+ keyKind: t.keyDesc.kind,
794
+ keyFamily: t.keyDesc.family,
795
+ baseAvailable: t.baseAvailable,
796
+ versions: t.versions.map((v) => ({ version: v.version, blobHash: v.blobHash, ...(v.mode !== undefined ? { mode: v.mode } : {}), ...(v.parentIdent !== undefined ? { parentIdent: v.parentIdent } : {}), backupTime: v.backupTime })),
797
+ })),
798
+ boundaries: [...sc.boundaries.entries()]
799
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
800
+ .map(([entryId, mapping]) => ({ entryId, files: [...mapping.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([relOrAbsPath, version]) => ({ relOrAbsPath, version })) })),
801
+ };
802
+ }
803
+ async importHistory(scope, data, srcGetBlob) {
804
+ try {
805
+ const shaped = validateFileHistoryExport(data);
806
+ if (!shaped.ok)
807
+ return shaped;
808
+ const existing = await this.exportHistory(scope);
809
+ if (existing !== null) {
810
+ if (fileHistoryExportDigest(existing) === fileHistoryExportDigest(data))
811
+ return { ok: true };
812
+ return { ok: false, error: { code: "conflict", message: `importHistory target "${scope}" already holds a DIFFERENT history — a first import requires an empty target and a retry is legal only for the identical envelope (atomic visibility prevents half-graphs; this law prevents publishing the wrong complete graph over locally-grown history)` } };
813
+ }
814
+ const needed = new Set();
815
+ for (const t of data.tracked)
816
+ for (const v of t.versions)
817
+ if (v.blobHash !== null)
818
+ needed.add(v.blobHash);
819
+ for (const hash of needed)
820
+ this.leaseBlob(hash);
821
+ try {
822
+ return await this.importStaged(scope, data, srcGetBlob, needed);
823
+ }
824
+ finally {
825
+ for (const hash of needed)
826
+ this.releaseBlob(hash);
827
+ }
828
+ }
829
+ catch (err) {
830
+ return { ok: false, error: { code: "unsupported_format", message: `importHistory failed: ${err.message}` } };
831
+ }
832
+ }
833
+ async importStaged(scope, data, srcGetBlob, needed) {
834
+ try {
835
+ const staged = new Map();
836
+ for (const hash of needed) {
837
+ if (this.blobs.has(hash))
838
+ continue;
839
+ let bytes;
840
+ try {
841
+ bytes = await srcGetBlob(hash);
842
+ }
843
+ catch (err) {
844
+ return { ok: false, error: { code: "unsupported_format", message: `source blob ${hash} fetch failed: ${err.message}` } };
845
+ }
846
+ if (bytes === undefined)
847
+ return { ok: false, error: { code: "not_found", message: `source blob ${hash} is missing — the envelope's graph cannot be completed, so nothing is published` } };
848
+ if (createHash("sha256").update(bytes).digest("hex") !== hash) {
849
+ return { ok: false, error: { code: "unsupported_format", message: `source blob ${hash} fails content-address integrity — refused, nothing published` } };
850
+ }
851
+ staged.set(hash, bytes);
852
+ }
853
+ const live = this.scopes.get(scope);
854
+ if (live !== undefined && (live.tracked.size > 0 || live.boundaries.size > 0)) {
855
+ const now = this.exportOf(live);
856
+ if (fileHistoryExportDigest(now) === fileHistoryExportDigest(data))
857
+ return { ok: true };
858
+ return { ok: false, error: { code: "conflict", message: `importHistory target "${scope}" gained a DIFFERENT history while this import was fetching blobs — nothing was published` } };
859
+ }
860
+ for (const [hash, bytes] of staged)
861
+ this.blobs.set(hash, bytes);
862
+ const built = { tracked: new Map(), boundaries: new Map(), spentAttempts: new Set(), activeAttempts: new Set(), reapedEntries: new Set(live?.reapedEntries ?? []) };
863
+ for (const t of data.tracked) {
864
+ built.tracked.set(t.relOrAbsPath, {
865
+ baseAvailable: t.baseAvailable,
866
+ keyDesc: { kind: t.keyKind, family: t.keyFamily },
867
+ versions: t.versions.map((v) => ({ version: v.version, blobHash: v.blobHash, ...(v.mode !== undefined ? { mode: v.mode } : {}), ...(v.parentIdent !== undefined ? { parentIdent: v.parentIdent } : {}), backupTime: v.backupTime })),
868
+ });
869
+ }
870
+ for (const b of data.boundaries)
871
+ built.boundaries.set(b.entryId, new Map(b.files.map((f) => [f.relOrAbsPath, f.version])));
872
+ this.scopes.set(scope, built);
873
+ return { ok: true };
874
+ }
875
+ catch (err) {
876
+ return { ok: false, error: { code: "unsupported_format", message: `importHistory failed: ${err.message}` } };
877
+ }
878
+ }
879
+ }
880
+ export const __testables = { joinUnder, relUnder, stripTrailSep, trackKeyOf, resolveTrackKey, parentDirOf };