@sema-agent/core 5.64.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.
- package/CHANGELOG.md +70 -0
- package/dist/agents/subagent.d.ts +2 -2
- package/dist/agents/subagent.js +11 -0
- package/dist/agents/verify.d.ts +1 -1
- package/dist/brain/anthropic.js +1 -1
- package/dist/brain/errors.d.ts +29 -0
- package/dist/brain/errors.js +20 -0
- package/dist/brain/open-responses.js +2 -2
- package/dist/brain/route-adjudicator.d.ts +8 -1
- package/dist/brain/route-adjudicator.js +1 -0
- package/dist/brain/status-sink.js +12 -1
- package/dist/brain/stream-engine.js +17 -6
- package/dist/core/auto-compaction.d.ts +26 -0
- package/dist/core/auto-compaction.js +7 -2
- package/dist/core/auto-mode-arming.d.ts +138 -0
- package/dist/core/auto-mode-arming.js +181 -0
- package/dist/core/auto-mode-defaults.d.ts +13 -0
- package/dist/core/auto-mode-defaults.js +5 -0
- package/dist/core/auto-mode-prompt.d.ts +14 -3
- package/dist/core/auto-mode-prompt.js +10 -7
- package/dist/core/auto-mode-rebuild.d.ts +75 -0
- package/dist/core/auto-mode-rebuild.js +41 -0
- package/dist/core/auto-mode.d.ts +15 -0
- package/dist/core/auto-mode.js +4 -2
- package/dist/core/checkpoint-store.d.ts +113 -4
- package/dist/core/context-edit.d.ts +47 -5
- package/dist/core/context-guard.d.ts +1 -1
- package/dist/core/file-history-retention.d.ts +106 -0
- package/dist/core/file-history-retention.js +36 -0
- package/dist/core/file-history-store.d.ts +768 -0
- package/dist/core/file-history-store.js +880 -0
- package/dist/core/governance-codes.d.ts +2 -1
- package/dist/core/governance-codes.js +14 -0
- package/dist/core/hooks.d.ts +48 -8
- package/dist/core/hooks.js +39 -22
- package/dist/core/lsp.d.ts +2 -2
- package/dist/core/mcp.d.ts +29 -7
- package/dist/core/memory-engine/consolidation-driver.d.ts +11 -0
- package/dist/core/memory-engine/consolidation-driver.js +71 -4
- package/dist/core/memory-engine/consolidation.d.ts +25 -2
- package/dist/core/memory-engine/consolidation.js +4 -1
- package/dist/core/memory-engine/distiller.d.ts +84 -1
- package/dist/core/memory-engine/distiller.js +68 -0
- package/dist/core/memory-engine/dual-root.js +6 -0
- package/dist/core/memory-engine/engine.d.ts +329 -15
- package/dist/core/memory-engine/engine.js +364 -34
- package/dist/core/memory-engine/file-backend.d.ts +30 -0
- package/dist/core/memory-engine/file-backend.js +14 -13
- package/dist/core/memory-engine/frontmatter.d.ts +22 -1
- package/dist/core/memory-engine/frontmatter.js +3 -0
- package/dist/core/memory-engine/header-hints.d.ts +5 -0
- package/dist/core/memory-engine/index.d.ts +5 -4
- package/dist/core/memory-engine/index.js +5 -4
- package/dist/core/memory-engine/layout.d.ts +88 -2
- package/dist/core/memory-engine/layout.js +112 -3
- package/dist/core/memory-engine/provenance-wording.d.ts +7 -0
- package/dist/core/memory-engine/provenance-wording.js +3 -0
- package/dist/core/memory-engine/tools.d.ts +89 -8
- package/dist/core/memory-engine/tools.js +263 -22
- package/dist/core/memory-engine/types.d.ts +80 -1
- package/dist/core/memory-recall.d.ts +6 -0
- package/dist/core/memory.d.ts +27 -1
- package/dist/core/memory.js +16 -2
- package/dist/core/permission-rule-consent.d.ts +20 -0
- package/dist/core/permission-rule-consent.js +12 -3
- package/dist/core/permission-rule-model.d.ts +67 -7
- package/dist/core/permission-rule-model.js +53 -7
- package/dist/core/permission-rule-store.js +15 -10
- package/dist/core/permission-rule-sync.js +15 -11
- package/dist/core/remote-env.d.ts +3 -3
- package/dist/core/retention-policy.d.ts +9 -0
- package/dist/core/retention-policy.js +5 -2
- package/dist/core/retention.d.ts +13 -2
- package/dist/core/runner/assemble-result.d.ts +19 -1
- package/dist/core/runner/assemble-result.js +17 -2
- package/dist/core/runner/compaction-call-options.d.ts +93 -0
- package/dist/core/runner/compaction-call-options.js +3 -0
- package/dist/core/runner/memory-capture-optout.d.ts +80 -0
- package/dist/core/runner/memory-capture-optout.js +53 -0
- package/dist/core/runner/prepare-config-doors.d.ts +5 -0
- package/dist/core/runner/prepare-config-doors.js +16 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +119 -5
- package/dist/core/runner/prepare-hands-readface.js +103 -8
- package/dist/core/runner/prepare-memory.d.ts +88 -0
- package/dist/core/runner/prepare-memory.js +306 -25
- package/dist/core/runner/prepare-task.d.ts +156 -5
- package/dist/core/runner/prepare-task.js +488 -98
- package/dist/core/runner/runtask.d.ts +27 -20
- package/dist/core/runner/runtask.js +283 -99
- package/dist/core/runner/session-file-state-replay.d.ts +18 -10
- package/dist/core/runner/session-file-state-replay.js +52 -1
- package/dist/core/runner/tool-disclosure.js +2 -1
- package/dist/core/runner/turn-attachments.d.ts +22 -12
- package/dist/core/session-store.d.ts +1 -1
- package/dist/core/session-store.js +6 -1
- package/dist/core/session.d.ts +34 -1
- package/dist/core/store-contracts/file-history-store-contract.d.ts +3 -0
- package/dist/core/store-contracts/file-history-store-contract.js +720 -0
- package/dist/core/stub-env.d.ts +4 -0
- package/dist/core/stub-env.js +1 -0
- package/dist/core/task-registry-shared.js +30 -2
- package/dist/core/tool-errors.js +1 -0
- package/dist/core/tool-policy.d.ts +172 -1
- package/dist/core/tool-policy.js +32 -1
- package/dist/core/tool-result-store.js +2 -1
- package/dist/core/trace.d.ts +24 -0
- package/dist/core/types.d.ts +875 -97
- package/dist/core/types.js +4 -3
- package/dist/core/untrusted-text.d.ts +1 -1
- package/dist/core/untrusted-text.js +8 -0
- package/dist/core/workflow-run-store-contract.js +17 -0
- package/dist/core/workflow-run-store.d.ts +20 -0
- package/dist/core/workflow-run-store.js +1 -0
- package/dist/engine/compaction/compaction.d.ts +88 -10
- package/dist/engine/compaction/compaction.js +109 -30
- package/dist/engine/execution-env/node-execution-env.d.ts +9 -1
- package/dist/engine/execution-env/node-execution-env.js +28 -0
- package/dist/engine/harness/agent-harness.d.ts +52 -1
- package/dist/engine/harness/agent-harness.js +36 -1
- package/dist/engine/harness/types.d.ts +44 -1
- package/dist/engine/llm/types.d.ts +50 -4
- package/dist/engine/loop/agent-loop.d.ts +5 -1
- package/dist/engine/loop/agent-loop.js +25 -0
- package/dist/engine/loop/types.d.ts +19 -0
- package/dist/engine/lsp/node-lsp-manager.d.ts +1 -1
- package/dist/engine/session/session.js +1 -1
- package/dist/index.d.ts +18 -8
- package/dist/index.js +14 -6
- package/dist/orchestration/run-workflow-tool.d.ts +20 -2
- package/dist/orchestration/run-workflow-tool.js +22 -3
- package/dist/orchestration/workflow-governance.d.ts +59 -1
- package/dist/orchestration/workflow-governance.js +61 -8
- package/dist/orchestration/workflow-meta.d.ts +4 -2
- package/dist/orchestration/workflow-primitives.js +56 -13
- package/dist/orchestration/workflow-types.d.ts +112 -1
- package/dist/orchestration/workflow-types.js +2 -2
- package/dist/orchestration/workflow.d.ts +20 -0
- package/dist/orchestration/workflow.js +182 -14
- package/dist/prompt-assembly/event-registry.js +1 -1
- package/dist/prompts/default.d.ts +15 -7
- package/dist/prompts/default.js +3 -0
- package/dist/stores/file/file-history-store.d.ts +368 -0
- package/dist/stores/file/file-history-store.js +1248 -0
- package/dist/stores/file/index.d.ts +22 -13
- package/dist/stores/file/index.js +4 -4
- package/dist/stores/file/permission-rule-store.js +1 -0
- package/dist/stores/file/strategy-store.d.ts +3 -3
- package/dist/tools/fs/bash-readonly-classifier.d.ts +87 -3
- package/dist/tools/fs/bash-readonly-classifier.js +106 -4
- package/dist/tools/fs/fs-bash.js +9 -5
- package/dist/tools/fs/fs-shared.d.ts +52 -1
- package/dist/tools/fs/fs-shared.js +14 -0
- package/dist/tools/fs/fs-write.d.ts +5 -5
- package/dist/tools/fs/fs-write.js +71 -14
- package/dist/tools/fs/index.d.ts +6 -1
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/web.js +2 -1
- package/package.json +5 -1
- package/test/export-surface.snapshot.json +159 -23
- package/dist/core/file-snapshot-store.d.ts +0 -165
- package/dist/core/file-snapshot-store.js +0 -259
- package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +0 -13
- package/dist/core/store-contracts/file-snapshot-store-contract.js +0 -134
- package/dist/stores/file/file-snapshot-store.d.ts +0 -58
- package/dist/stores/file/file-snapshot-store.js +0 -353
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, fsyncSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { applyFileRestoreViaEnv, boundaryPublishVerdict, worldStillMatchesMint, captureFileBackupViaEnv, modeProvenDifferent, previewFileDelta, describeTrackKey, fileHistoryExportDigest, resolveTrackKey, trackKeyOf, validateFileHistoryExport, } from "../../core/file-history-store.js";
|
|
5
|
+
import { fileHistoryBoundariesToKeep, resolveFileHistoryRetention } from "../../core/file-history-retention.js";
|
|
6
|
+
import { atomicWriteFile, ensureDir, sanitizePathComponent, sanitizeScope, writeThenLink } from "./fs-atomic.js";
|
|
7
|
+
import { isAbsolutePathForm } from "../../tools/fs/safety.js";
|
|
8
|
+
import { assertAdoptionBootGate } from "./adoption/marker.js";
|
|
9
|
+
function trackedDigest(rows) {
|
|
10
|
+
const canonical = [...rows]
|
|
11
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
12
|
+
.map(([key, rec]) => [
|
|
13
|
+
key,
|
|
14
|
+
{
|
|
15
|
+
baseAvailable: rec.baseAvailable,
|
|
16
|
+
mintId: rec.mintId ?? null,
|
|
17
|
+
keyKind: rec.keyKind ?? null,
|
|
18
|
+
keyFamily: rec.keyFamily ?? null,
|
|
19
|
+
observedByLaterTouch: rec.observedByLaterTouch === true,
|
|
20
|
+
versions: [...rec.versions]
|
|
21
|
+
.sort((x, y) => x.version - y.version)
|
|
22
|
+
.map((v) => ({ version: v.version, blobHash: v.blobHash, mode: v.mode ?? null, parentIdent: v.parentIdent ?? null, backupTime: v.backupTime })),
|
|
23
|
+
},
|
|
24
|
+
]);
|
|
25
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
function boundaryDigest(files) {
|
|
28
|
+
const canonical = [...files].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
29
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
30
|
+
}
|
|
31
|
+
const EMPTY_LEDGER = () => ({
|
|
32
|
+
filesChanged: [],
|
|
33
|
+
identicalSkipped: [],
|
|
34
|
+
refused: [],
|
|
35
|
+
failed: [],
|
|
36
|
+
});
|
|
37
|
+
const WINDOWS_DEVICE_NAMES = new Set(["con", "prn", "aux", "nul", ...Array.from({ length: 10 }, (_, i) => `com${i}`), ...Array.from({ length: 10 }, (_, i) => `lpt${i}`)]);
|
|
38
|
+
function historyPathComponent(raw) {
|
|
39
|
+
const safe = sanitizePathComponent(raw);
|
|
40
|
+
if (safe.length > 200)
|
|
41
|
+
throw new Error(`file history: path component is too long (${safe.length} > 200): ${JSON.stringify(raw.slice(0, 32))}…`);
|
|
42
|
+
if (WINDOWS_DEVICE_NAMES.has(safe.split(".")[0].toLowerCase()))
|
|
43
|
+
throw new Error(`file history: ${JSON.stringify(safe)} is a reserved device name — it would address a device, not a record`);
|
|
44
|
+
if (!/^[A-Za-z0-9_]/.test(safe))
|
|
45
|
+
throw new Error(`file history: path component must start with a letter, digit or underscore: ${JSON.stringify(safe)}`);
|
|
46
|
+
return safe;
|
|
47
|
+
}
|
|
48
|
+
function descriptorOf(rec) {
|
|
49
|
+
return rec.keyKind !== undefined && rec.keyFamily !== undefined ? { kind: rec.keyKind, family: rec.keyFamily } : undefined;
|
|
50
|
+
}
|
|
51
|
+
const STALE_JOURNAL_MS = 60 * 60 * 1000;
|
|
52
|
+
function pidIsAlive(pid) {
|
|
53
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
return err.code === "EPERM";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function parseJsonRecord(raw) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
72
|
+
}
|
|
73
|
+
function leaseJournalBlob(line, live) {
|
|
74
|
+
const rec = parseJsonRecord(line);
|
|
75
|
+
if (rec !== null && typeof rec.blob === "string")
|
|
76
|
+
live.add(rec.blob);
|
|
77
|
+
}
|
|
78
|
+
function isTrackedPair(el) {
|
|
79
|
+
if (!Array.isArray(el) || el.length !== 2 || typeof el[0] !== "string")
|
|
80
|
+
return false;
|
|
81
|
+
const rec = el[1];
|
|
82
|
+
if (typeof rec !== "object" || rec === null || Array.isArray(rec))
|
|
83
|
+
return false;
|
|
84
|
+
const versions = rec.versions;
|
|
85
|
+
return Array.isArray(versions) && versions.every((v) => typeof v === "object" && v !== null && !Array.isArray(v));
|
|
86
|
+
}
|
|
87
|
+
function trackedRowsOf(parsed) {
|
|
88
|
+
if (parsed === null || !Array.isArray(parsed.tracked))
|
|
89
|
+
return null;
|
|
90
|
+
const rows = [];
|
|
91
|
+
for (const el of parsed.tracked) {
|
|
92
|
+
if (!isTrackedPair(el))
|
|
93
|
+
return null;
|
|
94
|
+
rows.push(el);
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
function nestedSetAdd(map, scope, value) {
|
|
99
|
+
let inner = map.get(scope);
|
|
100
|
+
if (inner === undefined)
|
|
101
|
+
map.set(scope, (inner = new Set()));
|
|
102
|
+
inner.add(value);
|
|
103
|
+
}
|
|
104
|
+
function nestedMapSet(map, scope, key, value) {
|
|
105
|
+
let inner = map.get(scope);
|
|
106
|
+
if (inner === undefined)
|
|
107
|
+
map.set(scope, (inner = new Map()));
|
|
108
|
+
inner.set(key, value);
|
|
109
|
+
}
|
|
110
|
+
function safeRm(path) {
|
|
111
|
+
try {
|
|
112
|
+
rmSync(path, { force: true, recursive: true });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export class FileFileHistoryStore {
|
|
120
|
+
base;
|
|
121
|
+
blobsDir;
|
|
122
|
+
scopesDir;
|
|
123
|
+
onCorruptRead;
|
|
124
|
+
spentAttempts = new Map();
|
|
125
|
+
activeCaptures = new Map();
|
|
126
|
+
activeImports = new Map();
|
|
127
|
+
reapedCache = new Map();
|
|
128
|
+
boundaryKeep;
|
|
129
|
+
lastCommittedAt = 0;
|
|
130
|
+
seededScopes = new Set();
|
|
131
|
+
nextCommittedAt(scope) {
|
|
132
|
+
if (!this.seededScopes.has(scope)) {
|
|
133
|
+
this.seededScopes.add(scope);
|
|
134
|
+
const dir = this.boundaryDir(scope);
|
|
135
|
+
try {
|
|
136
|
+
for (const f of readdirSync(dir)) {
|
|
137
|
+
if (!f.endsWith(".json"))
|
|
138
|
+
continue;
|
|
139
|
+
const order = this.boundaryOrderOf(join(dir, f));
|
|
140
|
+
if (order !== undefined && order > this.lastCommittedAt)
|
|
141
|
+
this.lastCommittedAt = order;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this.lastCommittedAt = Math.max(Date.now(), this.lastCommittedAt + 1);
|
|
148
|
+
return this.lastCommittedAt;
|
|
149
|
+
}
|
|
150
|
+
constructor(root, opts) {
|
|
151
|
+
assertAdoptionBootGate(root, "FileFileHistoryStore");
|
|
152
|
+
this.boundaryKeep = resolveFileHistoryRetention("FileFileHistoryStore", opts?.retention);
|
|
153
|
+
this.base = join(root, "file-history");
|
|
154
|
+
this.blobsDir = join(this.base, "blobs");
|
|
155
|
+
this.scopesDir = join(this.base, "scopes");
|
|
156
|
+
ensureDir(this.blobsDir);
|
|
157
|
+
ensureDir(this.scopesDir);
|
|
158
|
+
this.onCorruptRead = opts?.onCorruptRead;
|
|
159
|
+
}
|
|
160
|
+
applyBoundaryRetention(scope, justCommitted) {
|
|
161
|
+
if (this.boundaryKeep === null)
|
|
162
|
+
return;
|
|
163
|
+
try {
|
|
164
|
+
const dir = this.boundaryDir(scope);
|
|
165
|
+
let names;
|
|
166
|
+
try {
|
|
167
|
+
names = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
if (err.code !== "ENOENT") {
|
|
171
|
+
this.disclose(dir, `boundary directory unreadable (${err.code ?? "unknown"}) — the retention pass was skipped, so this scope is NOT bounded until it can be read`);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (names.length <= this.boundaryKeep)
|
|
176
|
+
return;
|
|
177
|
+
const readable = [];
|
|
178
|
+
for (const f of names) {
|
|
179
|
+
const path = join(dir, f);
|
|
180
|
+
if (this.readBoundary(path) === null) {
|
|
181
|
+
this.disclose(join(dir, f), "a boundary in this scope cannot be judged — the retention pass is disabled for the WHOLE scope until it is repaired or removed, so this scope is NOT bounded (trimming around a record whose references cannot be read destroys the versions only it points at)");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
readable.push({ id: f.slice(0, -".json".length), order: this.boundaryOrderOf(path) ?? 0 });
|
|
185
|
+
}
|
|
186
|
+
const ordered = readable.sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((e) => e.id);
|
|
187
|
+
const keepIds = fileHistoryBoundariesToKeep(ordered, justCommitted, this.boundaryKeep);
|
|
188
|
+
if (keepIds === null)
|
|
189
|
+
return;
|
|
190
|
+
this.reap(scope, keepIds).catch(() => { });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
disclose(path, reason) {
|
|
196
|
+
try {
|
|
197
|
+
const r = this.onCorruptRead?.({ path, reason });
|
|
198
|
+
if (typeof r?.then === "function")
|
|
199
|
+
r.then(undefined, () => { });
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
scopeDir(scope) {
|
|
205
|
+
return join(this.scopesDir, sanitizeScope(scope));
|
|
206
|
+
}
|
|
207
|
+
trackedPath(scope) {
|
|
208
|
+
return join(this.scopeDir(scope), "tracked.json");
|
|
209
|
+
}
|
|
210
|
+
boundaryDir(scope) {
|
|
211
|
+
return join(this.scopeDir(scope), "boundaries");
|
|
212
|
+
}
|
|
213
|
+
tryBoundaryPath(scope, entryId) {
|
|
214
|
+
try {
|
|
215
|
+
return join(this.boundaryDir(scope), `${historyPathComponent(entryId)}.json`);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
blobPath(hash) {
|
|
222
|
+
return join(this.blobsDir, sanitizePathComponent(hash));
|
|
223
|
+
}
|
|
224
|
+
readTrackedState(scope) {
|
|
225
|
+
const path = this.trackedPath(scope);
|
|
226
|
+
let raw;
|
|
227
|
+
try {
|
|
228
|
+
raw = readFileSync(path, "utf8");
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
if (err.code === "ENOENT")
|
|
232
|
+
return { corrupt: false, tracked: new Map() };
|
|
233
|
+
this.disclose(path, `tracked set unreadable (${err.code ?? "unknown"}) — this scope's history is UNUSABLE until it is repaired`);
|
|
234
|
+
return { corrupt: true, tracked: new Map() };
|
|
235
|
+
}
|
|
236
|
+
const parsed = parseJsonRecord(raw);
|
|
237
|
+
if (parsed === null) {
|
|
238
|
+
this.disclose(path, "unparseable tracked set (torn/zero-byte write?) — this scope's history is UNUSABLE until it is repaired");
|
|
239
|
+
return { corrupt: true, tracked: new Map() };
|
|
240
|
+
}
|
|
241
|
+
const rows = trackedRowsOf(parsed);
|
|
242
|
+
if (rows === null) {
|
|
243
|
+
this.disclose(path, "tracked set is not a pair array of [key, record] rows — this scope's history is UNUSABLE until it is repaired");
|
|
244
|
+
return { corrupt: true, tracked: new Map() };
|
|
245
|
+
}
|
|
246
|
+
if (parsed.digest !== undefined && (typeof parsed.digest !== "string" || parsed.digest !== trackedDigest(rows))) {
|
|
247
|
+
this.disclose(path, "tracked set fails its own integrity digest (edited in place, or written by something that is not this store) — this scope's history is UNUSABLE until it is repaired");
|
|
248
|
+
return { corrupt: true, tracked: new Map() };
|
|
249
|
+
}
|
|
250
|
+
return { corrupt: false, tracked: new Map(rows) };
|
|
251
|
+
}
|
|
252
|
+
readTracked(scope) {
|
|
253
|
+
return this.readTrackedState(scope).tracked;
|
|
254
|
+
}
|
|
255
|
+
writeTracked(scope, tracked) {
|
|
256
|
+
const dir = this.scopeDir(scope);
|
|
257
|
+
ensureDir(dir);
|
|
258
|
+
const rows = [...tracked];
|
|
259
|
+
const doc = { formatVersion: "file-history/1", scope, tracked: rows, digest: trackedDigest(rows) };
|
|
260
|
+
atomicWriteFile(join(this.base, "tmp"), this.trackedPath(scope), JSON.stringify(doc));
|
|
261
|
+
}
|
|
262
|
+
readBoundary(path) {
|
|
263
|
+
let raw;
|
|
264
|
+
try {
|
|
265
|
+
raw = readFileSync(path, "utf8");
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
if (err.code === "ENOENT")
|
|
269
|
+
return null;
|
|
270
|
+
this.disclose(path, `boundary unreadable (${err.code ?? "unknown"}) — read as no boundary`);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
{
|
|
274
|
+
const doc = parseJsonRecord(raw);
|
|
275
|
+
if (doc === null) {
|
|
276
|
+
this.disclose(path, "unparseable boundary (torn/zero-byte write?) — read as no boundary");
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
if (doc.formatVersion !== "file-history/1" || !Array.isArray(doc.files)) {
|
|
280
|
+
this.disclose(path, "boundary is not a file-history/1 pair array — read as no boundary");
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const parsed = { formatVersion: doc.formatVersion, files: doc.files, digest: doc.digest };
|
|
284
|
+
for (const pair of parsed.files) {
|
|
285
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string" || !Number.isInteger(pair[1]) || pair[1] < 1) {
|
|
286
|
+
this.disclose(path, "boundary carries a malformed mapping row — read as no boundary");
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (new Set(parsed.files.map(([k]) => k)).size !== parsed.files.length) {
|
|
291
|
+
this.disclose(path, "boundary maps the same path twice — read as no boundary");
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
if (typeof parsed.digest !== "string" || parsed.digest !== boundaryDigest(parsed.files)) {
|
|
295
|
+
this.disclose(path, "boundary fails its own integrity digest (rows added or dropped?) — read as no boundary");
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return new Map(parsed.files);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
boundaryCommittedAt(path) {
|
|
302
|
+
try {
|
|
303
|
+
const doc = parseJsonRecord(readFileSync(path, "utf8"));
|
|
304
|
+
const at = doc?.committedAt;
|
|
305
|
+
return typeof at === "number" && Number.isFinite(at) ? at : undefined;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
unjudgeableBoundaryIdsOf(scope) {
|
|
312
|
+
const dir = this.boundaryDir(scope);
|
|
313
|
+
let names;
|
|
314
|
+
try {
|
|
315
|
+
names = readdirSync(dir);
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
return err.code === "ENOENT" ? null : [];
|
|
319
|
+
}
|
|
320
|
+
const bad = names.filter((f) => f.endsWith(".json") && this.readBoundary(join(dir, f)) === null).map((f) => f.slice(0, -".json".length));
|
|
321
|
+
return bad.length > 0 ? bad : null;
|
|
322
|
+
}
|
|
323
|
+
carryConsumedIds(fromScope, toScope) {
|
|
324
|
+
const spent = this.readReapedIds(fromScope);
|
|
325
|
+
if (spent === null) {
|
|
326
|
+
this.disclose(this.reapedLogPath(fromScope), "source consumed-id ledger unreadable — the adoption is refused rather than published without its lineage's tombstones");
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
if (spent.size === 0)
|
|
330
|
+
return true;
|
|
331
|
+
return this.markReaped(toScope, [...spent]);
|
|
332
|
+
}
|
|
333
|
+
reapedLogPath(scope) {
|
|
334
|
+
return `${this.scopeDir(scope)}.reaped.log`;
|
|
335
|
+
}
|
|
336
|
+
readReapedIds(scope) {
|
|
337
|
+
const path = this.reapedLogPath(scope);
|
|
338
|
+
let size;
|
|
339
|
+
try {
|
|
340
|
+
size = statSync(path).size;
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
if (err.code === "ENOENT")
|
|
344
|
+
return new Set();
|
|
345
|
+
this.disclose(path, `consumed-id ledger unreadable (${err.code ?? "unknown"})`);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const cached = this.reapedCache.get(scope);
|
|
349
|
+
if (cached !== undefined && cached.size === size)
|
|
350
|
+
return cached.ids;
|
|
351
|
+
try {
|
|
352
|
+
const ids = new Set(readFileSync(path, "utf8")
|
|
353
|
+
.split("\n")
|
|
354
|
+
.map((l) => l.trim())
|
|
355
|
+
.filter((l) => l !== ""));
|
|
356
|
+
this.reapedCache.set(scope, { size, ids });
|
|
357
|
+
return ids;
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
this.disclose(path, `consumed-id ledger unreadable (${err.code ?? "unknown"})`);
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
markReaped(scope, ids) {
|
|
365
|
+
if (ids.length === 0)
|
|
366
|
+
return true;
|
|
367
|
+
const path = this.reapedLogPath(scope);
|
|
368
|
+
try {
|
|
369
|
+
ensureDir(this.scopesDir);
|
|
370
|
+
const payload = ids.map((id) => `${id}\n`).join("");
|
|
371
|
+
const fd = openSync(path, "a");
|
|
372
|
+
try {
|
|
373
|
+
writeFileSync(fd, payload);
|
|
374
|
+
fsyncSync(fd);
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
closeSync(fd);
|
|
378
|
+
}
|
|
379
|
+
const cached = this.reapedCache.get(scope);
|
|
380
|
+
if (cached === undefined)
|
|
381
|
+
this.reapedCache.delete(scope);
|
|
382
|
+
else
|
|
383
|
+
this.reapedCache.set(scope, { size: cached.size + Buffer.byteLength(payload, "utf8"), ids: new Set([...cached.ids, ...ids]) });
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
this.disclose(path, `consumed-id ledger could not be appended (${err.code ?? "unknown"}) — the boundaries it names are NOT being reaped, so this scope is not bounded until it can be written`);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
boundaryOrderOf(path) {
|
|
392
|
+
const stamped = this.boundaryCommittedAt(path);
|
|
393
|
+
if (stamped !== undefined)
|
|
394
|
+
return stamped;
|
|
395
|
+
try {
|
|
396
|
+
return statSync(path).mtimeMs;
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
putBlob(hash, bytes) {
|
|
403
|
+
try {
|
|
404
|
+
writeThenLink(this.blobPath(hash), bytes);
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
if (err.code !== "EEXIST")
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async trackEdit(scope, absPath, env, root, signal) {
|
|
412
|
+
try {
|
|
413
|
+
const { key, kind, family } = describeTrackKey(root, absPath);
|
|
414
|
+
const before = this.readTrackedState(scope);
|
|
415
|
+
if (before.corrupt)
|
|
416
|
+
return { ok: false, error: { code: "track_failed", message: `file history for scope "${scope}" is unreadable — refusing to mint a first-touch record over an unusable graph` } };
|
|
417
|
+
const existing = before.tracked.get(key);
|
|
418
|
+
if (existing !== undefined) {
|
|
419
|
+
if (existing.observedByLaterTouch !== true) {
|
|
420
|
+
existing.observedByLaterTouch = true;
|
|
421
|
+
this.writeTracked(scope, before.tracked);
|
|
422
|
+
}
|
|
423
|
+
return { ok: true, minted: false };
|
|
424
|
+
}
|
|
425
|
+
const cap = await captureFileBackupViaEnv(env, absPath, root, signal);
|
|
426
|
+
const after = this.readTrackedState(scope);
|
|
427
|
+
if (after.corrupt)
|
|
428
|
+
return { ok: false, error: { code: "track_failed", message: `file history for scope "${scope}" became unreadable during the first-touch capture — nothing was recorded` } };
|
|
429
|
+
const tracked = after.tracked;
|
|
430
|
+
const raced = tracked.get(key);
|
|
431
|
+
if (raced !== undefined) {
|
|
432
|
+
if (raced.observedByLaterTouch !== true) {
|
|
433
|
+
raced.observedByLaterTouch = true;
|
|
434
|
+
this.writeTracked(scope, tracked);
|
|
435
|
+
}
|
|
436
|
+
return { ok: true, minted: false };
|
|
437
|
+
}
|
|
438
|
+
if (cap.state === "aborted") {
|
|
439
|
+
return { ok: false, error: { code: "aborted", message: `first-touch backup of ${absPath} aborted` } };
|
|
440
|
+
}
|
|
441
|
+
const now = new Date().toISOString();
|
|
442
|
+
const mintId = randomBytes(8).toString("hex");
|
|
443
|
+
if (cap.state === "unreadable") {
|
|
444
|
+
tracked.set(key, { baseAvailable: false, mintId, keyKind: kind, keyFamily: family, versions: [] });
|
|
445
|
+
this.writeTracked(scope, tracked);
|
|
446
|
+
return { ok: true, minted: true };
|
|
447
|
+
}
|
|
448
|
+
if (cap.state === "file") {
|
|
449
|
+
this.putBlob(cap.hash, cap.bytes);
|
|
450
|
+
tracked.set(key, { baseAvailable: true, mintId, keyKind: kind, keyFamily: family, versions: [{ version: 1, blobHash: cap.hash, backupTime: now, ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) }] });
|
|
451
|
+
this.writeTracked(scope, tracked);
|
|
452
|
+
return { ok: true, minted: true };
|
|
453
|
+
}
|
|
454
|
+
tracked.set(key, { baseAvailable: true, mintId, keyKind: kind, keyFamily: family, versions: [{ version: 1, blobHash: null, backupTime: now, ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) }] });
|
|
455
|
+
this.writeTracked(scope, tracked);
|
|
456
|
+
return { ok: true, minted: true };
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
return { ok: false, error: { code: "track_failed", message: `first-touch record for ${absPath} could not be persisted: ${err.message}` } };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async annulTrack(scope, absPath, root, verify) {
|
|
463
|
+
try {
|
|
464
|
+
const key = trackKeyOf(root, absPath);
|
|
465
|
+
const state = this.readTrackedState(scope);
|
|
466
|
+
if (state.corrupt) {
|
|
467
|
+
return { ok: false, error: { code: "conflict", message: `file history for scope "${scope}" is unreadable — the first-touch record (if any) was left in place; repair the graph before retraction can be judged` } };
|
|
468
|
+
}
|
|
469
|
+
const tracked = state.tracked;
|
|
470
|
+
const rec = tracked.get(key);
|
|
471
|
+
if (rec === undefined)
|
|
472
|
+
return { ok: true };
|
|
473
|
+
for (const entryId of this.listBoundaryIds(scope)) {
|
|
474
|
+
const bp = this.tryBoundaryPath(scope, entryId);
|
|
475
|
+
const mapping = bp === null ? null : this.readBoundary(bp);
|
|
476
|
+
if (mapping?.has(key) === true) {
|
|
477
|
+
return { ok: false, error: { code: "conflict", message: `first-touch record for ${absPath} is referenced by a committed boundary — it is history now, not a retractable mint` } };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (rec.versions.length > 1) {
|
|
481
|
+
return { ok: false, error: { code: "conflict", message: `first-touch record for ${absPath} has grown past its first version — only the mint itself is retractable` } };
|
|
482
|
+
}
|
|
483
|
+
if (rec.observedByLaterTouch === true) {
|
|
484
|
+
return { ok: false, error: { code: "conflict", message: `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` } };
|
|
485
|
+
}
|
|
486
|
+
const proof = verify === undefined ? undefined : await worldStillMatchesMint(verify.env, absPath, root, rec.baseAvailable, rec.versions[0]?.blobHash ?? null, verify.signal);
|
|
487
|
+
if (proof !== undefined && !proof.unchanged) {
|
|
488
|
+
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` } };
|
|
489
|
+
}
|
|
490
|
+
const fresh = this.readTrackedState(scope);
|
|
491
|
+
if (fresh.corrupt)
|
|
492
|
+
return { ok: false, error: { code: "conflict", message: `file history for scope "${scope}" became unreadable — the record was left in place` } };
|
|
493
|
+
const still = fresh.tracked.get(key);
|
|
494
|
+
if (still === undefined)
|
|
495
|
+
return { ok: true };
|
|
496
|
+
if (still.observedByLaterTouch === true || still.versions.length > 1) {
|
|
497
|
+
return { ok: false, error: { code: "conflict", message: `first-touch record for ${absPath} stopped being a retractable mint while this retraction was being proven` } };
|
|
498
|
+
}
|
|
499
|
+
if (still.mintId !== rec.mintId || still.baseAvailable !== rec.baseAvailable || still.versions.length !== rec.versions.length || still.versions[0]?.blobHash !== rec.versions[0]?.blobHash || still.versions[0]?.backupTime !== rec.versions[0]?.backupTime) {
|
|
500
|
+
return { ok: false, error: { code: "conflict", message: `first-touch record for ${absPath} was replaced while this retraction was being proven — the record now there belongs to a newer edit` } };
|
|
501
|
+
}
|
|
502
|
+
for (const entryId of this.listBoundaryIds(scope)) {
|
|
503
|
+
const bp2 = this.tryBoundaryPath(scope, entryId);
|
|
504
|
+
const mapping2 = bp2 === null ? null : this.readBoundary(bp2);
|
|
505
|
+
if (mapping2?.has(key) === true) {
|
|
506
|
+
return { ok: false, error: { code: "conflict", message: `first-touch record for ${absPath} was frozen by a boundary committed while this retraction was being proven — it is history now` } };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
fresh.tracked.delete(key);
|
|
510
|
+
this.writeTracked(scope, fresh.tracked);
|
|
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
|
+
scopeIsEmptySync(scope) {
|
|
518
|
+
const state = this.readTrackedState(scope);
|
|
519
|
+
if (state.corrupt)
|
|
520
|
+
return false;
|
|
521
|
+
return state.tracked.size === 0 && this.listBoundaryIds(scope).length === 0;
|
|
522
|
+
}
|
|
523
|
+
publishStagedScopeSync(scope, staging) {
|
|
524
|
+
if (!this.scopeIsEmptySync(scope))
|
|
525
|
+
return "not_empty";
|
|
526
|
+
const target = this.scopeDir(scope);
|
|
527
|
+
const destroyed = [];
|
|
528
|
+
for (const f of (() => {
|
|
529
|
+
try {
|
|
530
|
+
return readdirSync(this.boundaryDir(scope));
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
})()) {
|
|
536
|
+
if (f.endsWith(".json"))
|
|
537
|
+
destroyed.push(f.slice(0, -".json".length));
|
|
538
|
+
}
|
|
539
|
+
if (!this.markReaped(scope, destroyed))
|
|
540
|
+
return "ledger_failed";
|
|
541
|
+
safeRm(target);
|
|
542
|
+
renameSync(staging, target);
|
|
543
|
+
return "published";
|
|
544
|
+
}
|
|
545
|
+
listBoundaryIds(scope) {
|
|
546
|
+
const dir = this.boundaryDir(scope);
|
|
547
|
+
let entries;
|
|
548
|
+
try {
|
|
549
|
+
entries = readdirSync(dir);
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (err.code !== "ENOENT") {
|
|
553
|
+
this.disclose(dir, `boundary directory unreadable (${err.code ?? "unknown"}) — listed as no boundaries`);
|
|
554
|
+
}
|
|
555
|
+
return [];
|
|
556
|
+
}
|
|
557
|
+
return entries
|
|
558
|
+
.filter((f) => f.endsWith(".json"))
|
|
559
|
+
.filter((f) => this.readBoundary(join(dir, f)) !== null)
|
|
560
|
+
.map((f) => f.slice(0, -".json".length));
|
|
561
|
+
}
|
|
562
|
+
async snapshot(scope, entryId, env, root, signal) {
|
|
563
|
+
const bpath = this.tryBoundaryPath(scope, entryId);
|
|
564
|
+
if (bpath === null)
|
|
565
|
+
return { ok: false, error: { code: "snapshot_failed", message: `unsafe boundary key ${JSON.stringify(entryId)}` } };
|
|
566
|
+
if (this.readBoundary(bpath) !== null)
|
|
567
|
+
return { ok: true };
|
|
568
|
+
const consumed = this.readReapedIds(scope);
|
|
569
|
+
if (consumed === null) {
|
|
570
|
+
return { ok: false, error: { code: "snapshot_failed", message: `the consumed-entry ledger for scope "${scope}" cannot be read — refusing to mint a boundary while it is impossible to tell a fresh entry id from one retention already reaped` } };
|
|
571
|
+
}
|
|
572
|
+
if (consumed.has(entryId)) {
|
|
573
|
+
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` } };
|
|
574
|
+
}
|
|
575
|
+
if (this.spentAttempts.get(scope)?.has(entryId) === true) {
|
|
576
|
+
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` } };
|
|
577
|
+
}
|
|
578
|
+
if (this.activeCaptures.get(scope)?.has(entryId) === true) {
|
|
579
|
+
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)` } };
|
|
580
|
+
}
|
|
581
|
+
const journal = join(this.scopeDir(scope), "staging", `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}.jsonl`);
|
|
582
|
+
nestedMapSet(this.activeCaptures, scope, entryId, journal);
|
|
583
|
+
try {
|
|
584
|
+
ensureDir(join(this.scopeDir(scope), "staging"));
|
|
585
|
+
appendFileSync(journal, `${JSON.stringify({ entryId, pid: process.pid, startedAt: new Date().toISOString() })}\n`);
|
|
586
|
+
const state = this.readTrackedState(scope);
|
|
587
|
+
if (state.corrupt) {
|
|
588
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
589
|
+
return { ok: false, error: { code: "snapshot_failed", message: `file history for scope "${scope}" is unreadable — a boundary minted over an unusable graph would claim to restore state that is not there` } };
|
|
590
|
+
}
|
|
591
|
+
const tracked = state.tracked;
|
|
592
|
+
const staged = new Map();
|
|
593
|
+
for (const [key, rec] of tracked) {
|
|
594
|
+
if (signal?.aborted) {
|
|
595
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
596
|
+
return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted` } };
|
|
597
|
+
}
|
|
598
|
+
const resolved = resolveTrackKey(root, key, descriptorOf(rec));
|
|
599
|
+
if (!resolved.ok) {
|
|
600
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
601
|
+
return { ok: false, error: { code: "snapshot_failed", message: resolved.reason } };
|
|
602
|
+
}
|
|
603
|
+
const cap = await captureFileBackupViaEnv(env, resolved.abs, root, signal);
|
|
604
|
+
if (cap.state === "aborted") {
|
|
605
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
606
|
+
return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted` } };
|
|
607
|
+
}
|
|
608
|
+
if (cap.state === "unreadable") {
|
|
609
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
610
|
+
return { ok: false, error: { code: "snapshot_failed", message: cap.cause } };
|
|
611
|
+
}
|
|
612
|
+
const last = rec.versions[rec.versions.length - 1];
|
|
613
|
+
if (cap.state === "absent") {
|
|
614
|
+
if (last !== undefined && last.blobHash === null)
|
|
615
|
+
staged.set(key, { kind: "unchanged", version: last.version });
|
|
616
|
+
else
|
|
617
|
+
staged.set(key, { kind: "changed", blobHash: null, ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (last !== undefined && last.blobHash === cap.hash && !modeProvenDifferent(last.mode, cap.mode)) {
|
|
621
|
+
staged.set(key, { kind: "unchanged", version: last.version });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
appendFileSync(journal, `${JSON.stringify({ blob: cap.hash })}\n`);
|
|
625
|
+
this.putBlob(cap.hash, cap.bytes);
|
|
626
|
+
staged.set(key, { kind: "changed", blobHash: cap.hash, ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
|
|
627
|
+
}
|
|
628
|
+
if (!boundaryPublishVerdict({ aborted: signal?.aborted === true }).publish) {
|
|
629
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
630
|
+
return { ok: false, error: { code: "aborted", message: `boundary capture for entry "${entryId}" aborted (fenced — never committed)` } };
|
|
631
|
+
}
|
|
632
|
+
const currentState = this.readTrackedState(scope);
|
|
633
|
+
if (currentState.corrupt) {
|
|
634
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
635
|
+
return { ok: false, error: { code: "snapshot_failed", message: `file history for scope "${scope}" became unreadable during the capture — no boundary was published` } };
|
|
636
|
+
}
|
|
637
|
+
const current = currentState.tracked;
|
|
638
|
+
for (const [key, cap] of staged) {
|
|
639
|
+
if (cap.kind !== "unchanged")
|
|
640
|
+
continue;
|
|
641
|
+
if (current.get(key)?.versions.some((v) => v.version === cap.version) !== true) {
|
|
642
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
643
|
+
return { ok: false, error: { code: "snapshot_failed", message: `staged reference ${key}@v${cap.version} no longer exists (reaped mid-capture)` } };
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const mapping = [];
|
|
647
|
+
const now = new Date().toISOString();
|
|
648
|
+
for (const [key, cap] of staged) {
|
|
649
|
+
if (cap.kind === "unchanged") {
|
|
650
|
+
mapping.push([key, cap.version]);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
const rec = current.get(key);
|
|
654
|
+
if (rec === undefined)
|
|
655
|
+
continue;
|
|
656
|
+
const last = rec.versions[rec.versions.length - 1];
|
|
657
|
+
const version = last !== undefined ? last.version + 1 : rec.baseAvailable ? 1 : 2;
|
|
658
|
+
rec.versions.push({ version, blobHash: cap.blobHash, backupTime: now, ...(cap.mode !== undefined ? { mode: cap.mode } : {}), ...(cap.parentIdent !== undefined ? { parentIdent: cap.parentIdent } : {}) });
|
|
659
|
+
mapping.push([key, version]);
|
|
660
|
+
}
|
|
661
|
+
this.writeTracked(scope, current);
|
|
662
|
+
ensureDir(this.boundaryDir(scope));
|
|
663
|
+
const doc = { formatVersion: "file-history/1", files: mapping, digest: boundaryDigest(mapping), committedAt: this.nextCommittedAt(scope) };
|
|
664
|
+
try {
|
|
665
|
+
writeThenLink(bpath, JSON.stringify(doc));
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
if (err.code !== "EEXIST")
|
|
669
|
+
throw err;
|
|
670
|
+
if (this.readBoundary(bpath) === null) {
|
|
671
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
672
|
+
return { ok: false, error: { code: "snapshot_failed", message: `a boundary file for entry "${entryId}" already exists but cannot be read — this turn has no usable boundary (the unreadable file is left in place for an operator rather than silently replaced)` } };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
this.applyBoundaryRetention(scope, entryId);
|
|
676
|
+
return { ok: true };
|
|
677
|
+
}
|
|
678
|
+
catch (err) {
|
|
679
|
+
nestedSetAdd(this.spentAttempts, scope, entryId);
|
|
680
|
+
return { ok: false, error: { code: "snapshot_failed", message: `boundary for entry "${entryId}" could not be committed: ${err.message}` } };
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
this.activeCaptures.get(scope)?.delete(entryId);
|
|
684
|
+
safeRm(journal);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
async restore(scope, entryId, env, root, signal) {
|
|
688
|
+
const ledger = EMPTY_LEDGER();
|
|
689
|
+
let modeNotPreserved = 0;
|
|
690
|
+
try {
|
|
691
|
+
const bpath = this.tryBoundaryPath(scope, entryId);
|
|
692
|
+
const boundary = bpath === null ? null : this.readBoundary(bpath);
|
|
693
|
+
if (boundary === null) {
|
|
694
|
+
return { outcome: "failed", ...ledger, error: { code: "not_found", message: `no history boundary for entry "${entryId}"` } };
|
|
695
|
+
}
|
|
696
|
+
const state = this.readTrackedState(scope);
|
|
697
|
+
if (state.corrupt) {
|
|
698
|
+
return { outcome: "failed", ...ledger, error: { code: "restore_failed", message: `file history for scope "${scope}" is unreadable — the boundary exists but its version records cannot be read, so NOTHING was restored` } };
|
|
699
|
+
}
|
|
700
|
+
const tracked = state.tracked;
|
|
701
|
+
for (const key of [...tracked.keys()].sort()) {
|
|
702
|
+
const rec = tracked.get(key);
|
|
703
|
+
if (signal?.aborted) {
|
|
704
|
+
ledger.failed.push({ path: key, reason: "aborted before attempt" });
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
let targetVersion = boundary.get(key);
|
|
708
|
+
if (targetVersion === undefined) {
|
|
709
|
+
if (!rec.baseAvailable) {
|
|
710
|
+
ledger.refused.push({ path: key, reason: "base_unavailable" });
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
targetVersion = 1;
|
|
714
|
+
}
|
|
715
|
+
const version = rec.versions.find((v) => v.version === targetVersion);
|
|
716
|
+
if (version === undefined) {
|
|
717
|
+
ledger.failed.push({ path: key, reason: `version ${targetVersion} record missing (store integrity)` });
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
const resolved = resolveTrackKey(root, key, descriptorOf(rec));
|
|
721
|
+
if (!resolved.ok) {
|
|
722
|
+
ledger.refused.push({ path: key, reason: resolved.reason });
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
let target;
|
|
726
|
+
if (version.blobHash === null)
|
|
727
|
+
target = { absent: true };
|
|
728
|
+
else {
|
|
729
|
+
let bytes;
|
|
730
|
+
try {
|
|
731
|
+
bytes = readFileSync(this.blobPath(version.blobHash));
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
bytes = undefined;
|
|
735
|
+
}
|
|
736
|
+
if (bytes === undefined) {
|
|
737
|
+
ledger.failed.push({ path: key, reason: `blob ${version.blobHash} missing (store integrity)` });
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (createHash("sha256").update(bytes).digest("hex") !== version.blobHash) {
|
|
741
|
+
ledger.failed.push({ path: key, reason: `blob ${version.blobHash} fails content-address integrity (store corruption) — the file was left untouched` });
|
|
742
|
+
this.disclose(this.blobPath(version.blobHash), "blob bytes do not hash to their own name — the restore refused to apply them");
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
target = { bytes, hash: version.blobHash };
|
|
746
|
+
}
|
|
747
|
+
let applied;
|
|
748
|
+
try {
|
|
749
|
+
applied = await applyFileRestoreViaEnv(env, resolved.abs, target, {
|
|
750
|
+
root,
|
|
751
|
+
...(version.mode !== undefined ? { mode: version.mode } : {}),
|
|
752
|
+
...(version.parentIdent !== undefined ? { parentIdent: version.parentIdent } : {}),
|
|
753
|
+
...(signal !== undefined ? { signal } : {}),
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
applied = { outcome: "failed", reason: err.message };
|
|
758
|
+
}
|
|
759
|
+
if (applied.outcome === "applied")
|
|
760
|
+
ledger.filesChanged.push(key);
|
|
761
|
+
else if (applied.outcome === "identical")
|
|
762
|
+
ledger.identicalSkipped.push(key);
|
|
763
|
+
else if (applied.outcome === "refused")
|
|
764
|
+
ledger.refused.push({ path: key, reason: applied.reason });
|
|
765
|
+
else
|
|
766
|
+
ledger.failed.push({ path: key, reason: applied.reason });
|
|
767
|
+
if ((applied.outcome === "applied" || applied.outcome === "identical") && applied.modeNotPreserved === true)
|
|
768
|
+
modeNotPreserved++;
|
|
769
|
+
}
|
|
770
|
+
return { outcome: ledger.refused.length > 0 || ledger.failed.length > 0 ? "partial" : "complete", ...ledger, ...(modeNotPreserved > 0 ? { modeNotPreserved } : {}) };
|
|
771
|
+
}
|
|
772
|
+
catch (err) {
|
|
773
|
+
const touched = ledger.filesChanged.length > 0 || ledger.identicalSkipped.length > 0 || ledger.refused.length > 0 || ledger.failed.length > 0;
|
|
774
|
+
return { outcome: touched ? "partial" : "failed", ...ledger, error: { code: "restore_failed", message: err.message } };
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
async canRestore(scope, entryId) {
|
|
778
|
+
const bpath = this.tryBoundaryPath(scope, entryId);
|
|
779
|
+
return bpath !== null && this.readBoundary(bpath) !== null;
|
|
780
|
+
}
|
|
781
|
+
async diffStats(scope, entryId, env, root) {
|
|
782
|
+
try {
|
|
783
|
+
const bpath = this.tryBoundaryPath(scope, entryId);
|
|
784
|
+
const boundary = bpath === null ? null : this.readBoundary(bpath);
|
|
785
|
+
if (boundary === null)
|
|
786
|
+
return null;
|
|
787
|
+
const state = this.readTrackedState(scope);
|
|
788
|
+
if (state.corrupt)
|
|
789
|
+
return null;
|
|
790
|
+
const filesChanged = [];
|
|
791
|
+
let insertions = 0;
|
|
792
|
+
let deletions = 0;
|
|
793
|
+
for (const key of [...state.tracked.keys()].sort()) {
|
|
794
|
+
const rec = state.tracked.get(key);
|
|
795
|
+
let targetVersion = boundary.get(key);
|
|
796
|
+
if (targetVersion === undefined) {
|
|
797
|
+
if (!rec.baseAvailable)
|
|
798
|
+
continue;
|
|
799
|
+
targetVersion = 1;
|
|
800
|
+
}
|
|
801
|
+
const version = rec.versions.find((v) => v.version === targetVersion);
|
|
802
|
+
const resolved = resolveTrackKey(root, key, descriptorOf(rec));
|
|
803
|
+
if (version === undefined || !resolved.ok)
|
|
804
|
+
continue;
|
|
805
|
+
let targetBytes;
|
|
806
|
+
if (version.blobHash !== null) {
|
|
807
|
+
try {
|
|
808
|
+
targetBytes = readFileSync(this.blobPath(version.blobHash));
|
|
809
|
+
}
|
|
810
|
+
catch {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
if (createHash("sha256").update(targetBytes).digest("hex") !== version.blobHash)
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const delta = await previewFileDelta(env, resolved.abs, version.blobHash, targetBytes, version.mode);
|
|
817
|
+
if (delta === undefined)
|
|
818
|
+
continue;
|
|
819
|
+
filesChanged.push(key);
|
|
820
|
+
insertions += delta.insertions;
|
|
821
|
+
deletions += delta.deletions;
|
|
822
|
+
}
|
|
823
|
+
return { filesChanged, insertions, deletions };
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async reap(scope, keepEntryIds) {
|
|
830
|
+
const keep = new Set(keepEntryIds);
|
|
831
|
+
let removed = 0;
|
|
832
|
+
const dir = this.boundaryDir(scope);
|
|
833
|
+
let entries = [];
|
|
834
|
+
try {
|
|
835
|
+
entries = readdirSync(dir);
|
|
836
|
+
}
|
|
837
|
+
catch (err) {
|
|
838
|
+
if (err.code !== "ENOENT") {
|
|
839
|
+
this.disclose(dir, `boundary directory unreadable (${err.code ?? "unknown"}) — the WHOLE reap is refused (0 dropped, no version or blob collection) because an unlistable directory cannot distinguish "no boundaries" from "boundaries we cannot see"`);
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const mappings = new Map();
|
|
844
|
+
for (const file of entries) {
|
|
845
|
+
if (!file.endsWith(".json"))
|
|
846
|
+
continue;
|
|
847
|
+
const mapping = this.readBoundary(join(dir, file));
|
|
848
|
+
if (mapping === null) {
|
|
849
|
+
this.disclose(join(dir, file), "a boundary in this scope cannot be judged — the WHOLE reap is refused (0 dropped, nothing collected) until it is repaired or removed, because trimming around a record whose references cannot be read destroys the versions only it points at");
|
|
850
|
+
return 0;
|
|
851
|
+
}
|
|
852
|
+
mappings.set(file.slice(0, -".json".length), mapping);
|
|
853
|
+
}
|
|
854
|
+
const survivors = new Set(keep);
|
|
855
|
+
for (const file of entries) {
|
|
856
|
+
if (!file.endsWith(".json"))
|
|
857
|
+
continue;
|
|
858
|
+
const id = file.slice(0, -".json".length);
|
|
859
|
+
if (keep.has(id))
|
|
860
|
+
continue;
|
|
861
|
+
if (!this.markReaped(scope, [id])) {
|
|
862
|
+
survivors.add(id);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (safeRm(join(dir, file))) {
|
|
866
|
+
removed++;
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
survivors.add(id);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const referenced = new Map();
|
|
873
|
+
for (const [id, mapping] of mappings) {
|
|
874
|
+
if (!survivors.has(id))
|
|
875
|
+
continue;
|
|
876
|
+
for (const [key, v] of mapping) {
|
|
877
|
+
let s = referenced.get(key);
|
|
878
|
+
if (s === undefined)
|
|
879
|
+
referenced.set(key, (s = new Set()));
|
|
880
|
+
s.add(v);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
const tracked = this.readTracked(scope);
|
|
884
|
+
if (tracked.size > 0) {
|
|
885
|
+
for (const [key, rec] of tracked) {
|
|
886
|
+
rec.versions = rec.versions.filter((v) => v.version === 1 || (referenced.get(key)?.has(v.version) ?? false));
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
this.writeTracked(scope, tracked);
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
this.disclose(this.trackedPath(scope), `version-chain trim could not be committed (${err.message}) — trim skipped this pass; untrimmed versions keep their blobs live`);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
this.collectBlobs();
|
|
896
|
+
return removed;
|
|
897
|
+
}
|
|
898
|
+
collectBlobs() {
|
|
899
|
+
const live = new Set();
|
|
900
|
+
let complete = true;
|
|
901
|
+
let scopeDirs;
|
|
902
|
+
try {
|
|
903
|
+
scopeDirs = readdirSync(this.scopesDir);
|
|
904
|
+
}
|
|
905
|
+
catch (err) {
|
|
906
|
+
if (err.code === "ENOENT")
|
|
907
|
+
return;
|
|
908
|
+
this.disclose(this.scopesDir, `scope root unreadable (${err.code ?? "unknown"}) — blob GC aborted, every blob kept`);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
const liveJournals = new Set([...[...this.activeCaptures.values()].flatMap((m) => [...m.values()]), ...[...this.activeImports.values()].flatMap((set) => [...set])]);
|
|
912
|
+
for (const name of scopeDirs) {
|
|
913
|
+
const sdir = join(this.scopesDir, name);
|
|
914
|
+
try {
|
|
915
|
+
if (!statSync(sdir).isDirectory())
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const tpath = join(sdir, "tracked.json");
|
|
922
|
+
if (existsSync(tpath)) {
|
|
923
|
+
let doc;
|
|
924
|
+
try {
|
|
925
|
+
doc = parseJsonRecord(readFileSync(tpath, "utf8"));
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
doc = null;
|
|
929
|
+
}
|
|
930
|
+
const rows = trackedRowsOf(doc);
|
|
931
|
+
if (rows === null) {
|
|
932
|
+
complete = false;
|
|
933
|
+
this.disclose(tpath, "tracked set unreadable during the live-set scan — blob GC aborted, every blob kept");
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
if (doc?.digest !== undefined && (typeof doc.digest !== "string" || doc.digest !== trackedDigest(rows))) {
|
|
937
|
+
complete = false;
|
|
938
|
+
this.disclose(tpath, "tracked set fails its own integrity digest during the live-set scan — blob GC aborted, every blob kept (a live set built from damaged rows would delete the bytes a repair needs)");
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
for (const [, rec] of rows)
|
|
942
|
+
for (const v of rec.versions)
|
|
943
|
+
if (v.blobHash !== null)
|
|
944
|
+
live.add(v.blobHash);
|
|
945
|
+
}
|
|
946
|
+
const stagingDir = join(sdir, "staging");
|
|
947
|
+
let journals = [];
|
|
948
|
+
try {
|
|
949
|
+
journals = readdirSync(stagingDir);
|
|
950
|
+
}
|
|
951
|
+
catch (err) {
|
|
952
|
+
if (err.code !== "ENOENT") {
|
|
953
|
+
complete = false;
|
|
954
|
+
this.disclose(stagingDir, `staging directory unreadable (${err.code ?? "unknown"}) — blob GC aborted, every blob kept`);
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
for (const j of journals) {
|
|
959
|
+
const jpath = join(stagingDir, j);
|
|
960
|
+
let raw;
|
|
961
|
+
try {
|
|
962
|
+
raw = readFileSync(jpath, "utf8");
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
if (err.code === "ENOENT")
|
|
966
|
+
continue;
|
|
967
|
+
complete = false;
|
|
968
|
+
this.disclose(jpath, "staging journal unreadable — blob GC aborted, every blob kept");
|
|
969
|
+
break;
|
|
970
|
+
}
|
|
971
|
+
const lines = raw.split("\n").filter((l) => l !== "");
|
|
972
|
+
const header = parseJsonRecord(lines[0] ?? "");
|
|
973
|
+
if (header === null || typeof header.pid !== "number" || typeof header.startedAt !== "string") {
|
|
974
|
+
this.disclose(jpath, "staging journal header is unreadable — the journal and its leased blobs are KEPT (an unknown owner is never proof of death)");
|
|
975
|
+
for (const line of lines)
|
|
976
|
+
leaseJournalBlob(line, live);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
const ownedHere = liveJournals.has(jpath);
|
|
980
|
+
const ownerAlive = !ownedHere && typeof header.pid === "number" && pidIsAlive(header.pid);
|
|
981
|
+
const startedMs = Date.parse(header.startedAt ?? "");
|
|
982
|
+
const young = Number.isFinite(startedMs) && Date.now() - startedMs < STALE_JOURNAL_MS;
|
|
983
|
+
if (!ownedHere && !ownerAlive && !young) {
|
|
984
|
+
safeRm(jpath);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
for (const line of lines)
|
|
988
|
+
leaseJournalBlob(line, live);
|
|
989
|
+
}
|
|
990
|
+
if (!complete)
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
if (!complete)
|
|
994
|
+
return;
|
|
995
|
+
let blobs;
|
|
996
|
+
try {
|
|
997
|
+
blobs = readdirSync(this.blobsDir);
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
if (err.code !== "ENOENT") {
|
|
1001
|
+
this.disclose(this.blobsDir, `blob directory unreadable (${err.code ?? "unknown"}) — blob GC skipped, every blob kept`);
|
|
1002
|
+
}
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
for (const blob of blobs)
|
|
1006
|
+
if (!live.has(blob))
|
|
1007
|
+
safeRm(join(this.blobsDir, blob));
|
|
1008
|
+
}
|
|
1009
|
+
async adoptScope(fromScope, toScope) {
|
|
1010
|
+
try {
|
|
1011
|
+
if (fromScope === toScope)
|
|
1012
|
+
return { ok: true };
|
|
1013
|
+
const targetState = this.readTrackedState(toScope);
|
|
1014
|
+
if (targetState.corrupt) {
|
|
1015
|
+
return { ok: false, error: { code: "adopt_failed", message: `adoptScope target "${toScope}" has an unreadable tracked set — a scope whose bytes cannot be judged is not an empty target` } };
|
|
1016
|
+
}
|
|
1017
|
+
const targetTracked = targetState.tracked;
|
|
1018
|
+
const targetBoundaries = this.listBoundaryIds(toScope);
|
|
1019
|
+
if (targetTracked.size > 0 || targetBoundaries.length > 0) {
|
|
1020
|
+
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)` } };
|
|
1021
|
+
}
|
|
1022
|
+
const sourceState = this.readTrackedState(fromScope);
|
|
1023
|
+
if (sourceState.corrupt) {
|
|
1024
|
+
return { ok: false, error: { code: "adopt_failed", message: `adoptScope source "${fromScope}" has an unreadable tracked set — adopting it would publish boundaries with no version records behind them` } };
|
|
1025
|
+
}
|
|
1026
|
+
const source = sourceState.tracked;
|
|
1027
|
+
const unjudgeable = this.unjudgeableBoundaryIdsOf(fromScope);
|
|
1028
|
+
if (unjudgeable !== null) {
|
|
1029
|
+
return { ok: false, error: { code: "adopt_failed", message: `adoptScope source "${fromScope}" has ${unjudgeable.length > 0 ? `boundaries that cannot be judged (${unjudgeable.slice(0, 3).map((i) => JSON.stringify(i)).join(", ")}${unjudgeable.length > 3 ? ", …" : ""})` : "a boundary directory that cannot be listed"} — adopting it would publish a graph that silently drops those turns and hands their entry ids back to the fork; nothing was published` } };
|
|
1030
|
+
}
|
|
1031
|
+
const sourceBoundaries = this.listBoundaryIds(fromScope);
|
|
1032
|
+
if (source.size === 0 && sourceBoundaries.length === 0) {
|
|
1033
|
+
if (!this.carryConsumedIds(fromScope, toScope)) {
|
|
1034
|
+
return { ok: false, error: { code: "adopt_failed", message: `adoptScope could not carry the consumed-entry ledger from "${fromScope}" to "${toScope}" — refusing rather than reporting a carry that would let an already-consumed entry id be minted again in the fork` } };
|
|
1035
|
+
}
|
|
1036
|
+
return { ok: true };
|
|
1037
|
+
}
|
|
1038
|
+
for (const [key, rec] of source) {
|
|
1039
|
+
for (const v of rec.versions) {
|
|
1040
|
+
if (v.blobHash === null)
|
|
1041
|
+
continue;
|
|
1042
|
+
let bytes;
|
|
1043
|
+
try {
|
|
1044
|
+
bytes = readFileSync(this.blobPath(v.blobHash));
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
bytes = undefined;
|
|
1048
|
+
}
|
|
1049
|
+
if (bytes === undefined || createHash("sha256").update(bytes).digest("hex") !== v.blobHash) {
|
|
1050
|
+
return { ok: false, error: { code: "adopt_failed", message: `adoptScope source "${fromScope}" references blob ${v.blobHash} for ${key}@v${v.version}, which is missing or fails content-address integrity — nothing was published` } };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
const staging = `${this.scopeDir(toScope)}.adopting-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
1055
|
+
safeRm(staging);
|
|
1056
|
+
try {
|
|
1057
|
+
ensureDir(join(staging, "boundaries"));
|
|
1058
|
+
const adoptedRows = [...source];
|
|
1059
|
+
const doc = { formatVersion: "file-history/1", scope: toScope, tracked: adoptedRows, digest: trackedDigest(adoptedRows) };
|
|
1060
|
+
atomicWriteFile(join(this.base, "tmp"), join(staging, "tracked.json"), JSON.stringify(doc));
|
|
1061
|
+
for (const id of sourceBoundaries) {
|
|
1062
|
+
const bp = this.tryBoundaryPath(fromScope, id);
|
|
1063
|
+
const mapping = bp === null ? null : this.readBoundary(bp);
|
|
1064
|
+
if (mapping === null)
|
|
1065
|
+
continue;
|
|
1066
|
+
for (const [key, version] of mapping) {
|
|
1067
|
+
if (source.get(key)?.versions.some((v) => v.version === version) !== true) {
|
|
1068
|
+
throw new Error(`source boundary ${id} references ${key}@v${version}, which the source's version chains do not carry`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
const sourceCommittedAt = bp === null ? undefined : this.boundaryOrderOf(bp);
|
|
1072
|
+
const bdoc = { formatVersion: "file-history/1", files: [...mapping], digest: boundaryDigest([...mapping]), ...(sourceCommittedAt !== undefined ? { committedAt: sourceCommittedAt } : {}) };
|
|
1073
|
+
atomicWriteFile(join(this.base, "tmp"), join(staging, "boundaries", `${historyPathComponent(id)}.json`), JSON.stringify(bdoc));
|
|
1074
|
+
}
|
|
1075
|
+
if (!this.carryConsumedIds(fromScope, toScope)) {
|
|
1076
|
+
safeRm(staging);
|
|
1077
|
+
return { ok: false, error: { code: "adopt_failed", message: `adoptScope could not carry the consumed-entry ledger from "${fromScope}" to "${toScope}" — nothing was published, because a fork missing its lineage's tombstones can mint an already-consumed entry id` } };
|
|
1078
|
+
}
|
|
1079
|
+
const published = this.publishStagedScopeSync(toScope, staging);
|
|
1080
|
+
if (published !== "published") {
|
|
1081
|
+
safeRm(staging);
|
|
1082
|
+
return published === "not_empty"
|
|
1083
|
+
? { ok: false, error: { code: "conflict", message: `adoptScope target "${toScope}" gained history of its own while this adoption was being staged — nothing was published` } }
|
|
1084
|
+
: { ok: false, error: { code: "adopt_failed", message: `adoptScope could not record the consumed-entry tombstones for target "${toScope}"'s unreadable boundary residue — nothing was published, because replacing records nothing remembers would hand their already-consumed entry ids back to the fork` } };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
catch (err) {
|
|
1088
|
+
safeRm(staging);
|
|
1089
|
+
throw err;
|
|
1090
|
+
}
|
|
1091
|
+
return { ok: true };
|
|
1092
|
+
}
|
|
1093
|
+
catch (err) {
|
|
1094
|
+
return { ok: false, error: { code: "adopt_failed", message: `adoptScope failed: ${err.message}` } };
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
async exportHistory(scope) {
|
|
1098
|
+
const state = this.readTrackedState(scope);
|
|
1099
|
+
if (state.corrupt) {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
const tracked = state.tracked;
|
|
1103
|
+
const boundaryIds = this.listBoundaryIds(scope);
|
|
1104
|
+
if (tracked.size === 0 && boundaryIds.length === 0)
|
|
1105
|
+
return null;
|
|
1106
|
+
const boundaries = [];
|
|
1107
|
+
for (const entryId of [...boundaryIds].sort()) {
|
|
1108
|
+
const bp = this.tryBoundaryPath(scope, entryId);
|
|
1109
|
+
const mapping = bp === null ? null : this.readBoundary(bp);
|
|
1110
|
+
if (mapping === null)
|
|
1111
|
+
continue;
|
|
1112
|
+
boundaries.push({ entryId, files: [...mapping.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([relOrAbsPath, version]) => ({ relOrAbsPath, version })) });
|
|
1113
|
+
}
|
|
1114
|
+
return {
|
|
1115
|
+
formatVersion: "file-history/1",
|
|
1116
|
+
tracked: [...tracked.entries()]
|
|
1117
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
1118
|
+
.map(([key, rec]) => ({
|
|
1119
|
+
relOrAbsPath: key,
|
|
1120
|
+
keyKind: rec.keyKind ?? (isAbsolutePathForm(key) ? "absolute" : "root-relative"),
|
|
1121
|
+
keyFamily: rec.keyFamily ?? "posix",
|
|
1122
|
+
baseAvailable: rec.baseAvailable,
|
|
1123
|
+
versions: rec.versions.map((v) => ({ version: v.version, blobHash: v.blobHash, ...(v.mode !== undefined ? { mode: v.mode } : {}), ...(v.parentIdent !== undefined ? { parentIdent: v.parentIdent } : {}), backupTime: v.backupTime })),
|
|
1124
|
+
})),
|
|
1125
|
+
boundaries,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
async importHistory(scope, data, srcGetBlob) {
|
|
1129
|
+
try {
|
|
1130
|
+
const shaped = validateFileHistoryExport(data);
|
|
1131
|
+
if (!shaped.ok)
|
|
1132
|
+
return shaped;
|
|
1133
|
+
const existing = await this.exportHistory(scope);
|
|
1134
|
+
let identicalRepublish = false;
|
|
1135
|
+
if (existing !== null) {
|
|
1136
|
+
if (fileHistoryExportDigest(existing) === fileHistoryExportDigest(data))
|
|
1137
|
+
identicalRepublish = true;
|
|
1138
|
+
else
|
|
1139
|
+
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)` } };
|
|
1140
|
+
}
|
|
1141
|
+
const importJournal = join(this.scopeDir(scope), "staging", `import-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}.jsonl`);
|
|
1142
|
+
ensureDir(join(this.scopeDir(scope), "staging"));
|
|
1143
|
+
appendFileSync(importJournal, `${JSON.stringify({ entryId: `import:${scope}`, pid: process.pid, startedAt: new Date().toISOString() })}\n`);
|
|
1144
|
+
nestedSetAdd(this.activeImports, scope, importJournal);
|
|
1145
|
+
for (const t of data.tracked) {
|
|
1146
|
+
for (const v of t.versions) {
|
|
1147
|
+
if (v.blobHash !== null)
|
|
1148
|
+
appendFileSync(importJournal, `${JSON.stringify({ blob: v.blobHash })}\n`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return await this.runImport(scope, data, srcGetBlob, importJournal, identicalRepublish);
|
|
1152
|
+
}
|
|
1153
|
+
catch (err) {
|
|
1154
|
+
return { ok: false, error: { code: "unsupported_format", message: `importHistory failed: ${err.message}` } };
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
async runImport(scope, data, srcGetBlob, importJournal, identicalRepublish) {
|
|
1158
|
+
try {
|
|
1159
|
+
for (const t of data.tracked) {
|
|
1160
|
+
for (const v of t.versions) {
|
|
1161
|
+
if (v.blobHash === null)
|
|
1162
|
+
continue;
|
|
1163
|
+
if (existsSync(this.blobPath(v.blobHash))) {
|
|
1164
|
+
let local;
|
|
1165
|
+
try {
|
|
1166
|
+
local = readFileSync(this.blobPath(v.blobHash));
|
|
1167
|
+
}
|
|
1168
|
+
catch {
|
|
1169
|
+
local = undefined;
|
|
1170
|
+
}
|
|
1171
|
+
if (local !== undefined && createHash("sha256").update(local).digest("hex") === v.blobHash)
|
|
1172
|
+
continue;
|
|
1173
|
+
this.disclose(this.blobPath(v.blobHash), "locally stored blob does not hash to its own name — the import re-fetched it from the source");
|
|
1174
|
+
safeRm(this.blobPath(v.blobHash));
|
|
1175
|
+
}
|
|
1176
|
+
let bytes;
|
|
1177
|
+
try {
|
|
1178
|
+
bytes = await srcGetBlob(v.blobHash);
|
|
1179
|
+
}
|
|
1180
|
+
catch (err) {
|
|
1181
|
+
return { ok: false, error: { code: "unsupported_format", message: `source blob ${v.blobHash} fetch failed: ${err.message}` } };
|
|
1182
|
+
}
|
|
1183
|
+
if (bytes === undefined)
|
|
1184
|
+
return { ok: false, error: { code: "not_found", message: `source blob ${v.blobHash} is missing — the envelope's graph cannot be completed, so nothing is published` } };
|
|
1185
|
+
if (createHash("sha256").update(bytes).digest("hex") !== v.blobHash) {
|
|
1186
|
+
return { ok: false, error: { code: "unsupported_format", message: `source blob ${v.blobHash} fails content-address integrity — refused, nothing published` } };
|
|
1187
|
+
}
|
|
1188
|
+
this.putBlob(v.blobHash, bytes);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (identicalRepublish)
|
|
1192
|
+
return { ok: true };
|
|
1193
|
+
const now = await this.exportHistory(scope);
|
|
1194
|
+
if (now !== null) {
|
|
1195
|
+
if (fileHistoryExportDigest(now) === fileHistoryExportDigest(data))
|
|
1196
|
+
return { ok: true };
|
|
1197
|
+
return { ok: false, error: { code: "conflict", message: `importHistory target "${scope}" gained a DIFFERENT history while this import was fetching blobs — nothing was published` } };
|
|
1198
|
+
}
|
|
1199
|
+
const staging = `${this.scopeDir(scope)}.importing-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
1200
|
+
safeRm(staging);
|
|
1201
|
+
try {
|
|
1202
|
+
ensureDir(join(staging, "boundaries"));
|
|
1203
|
+
const tracked = data.tracked.map((t) => [
|
|
1204
|
+
t.relOrAbsPath,
|
|
1205
|
+
{
|
|
1206
|
+
baseAvailable: t.baseAvailable,
|
|
1207
|
+
keyKind: t.keyKind,
|
|
1208
|
+
keyFamily: t.keyFamily,
|
|
1209
|
+
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 })),
|
|
1210
|
+
},
|
|
1211
|
+
]);
|
|
1212
|
+
const doc = { formatVersion: "file-history/1", scope, tracked, digest: trackedDigest(tracked) };
|
|
1213
|
+
atomicWriteFile(join(this.base, "tmp"), join(staging, "tracked.json"), JSON.stringify(doc));
|
|
1214
|
+
for (const b of data.boundaries) {
|
|
1215
|
+
const bfiles = b.files.map((f) => [f.relOrAbsPath, f.version]);
|
|
1216
|
+
const bdoc = { formatVersion: "file-history/1", files: bfiles, digest: boundaryDigest(bfiles) };
|
|
1217
|
+
atomicWriteFile(join(this.base, "tmp"), join(staging, "boundaries", `${historyPathComponent(b.entryId)}.json`), JSON.stringify(bdoc));
|
|
1218
|
+
}
|
|
1219
|
+
let published;
|
|
1220
|
+
try {
|
|
1221
|
+
published = this.publishStagedScopeSync(scope, staging);
|
|
1222
|
+
}
|
|
1223
|
+
catch (err) {
|
|
1224
|
+
safeRm(staging);
|
|
1225
|
+
return { ok: false, error: { code: "adopt_failed", message: `importHistory could not publish the staged graph for "${scope}": ${err.message} — nothing was published (a local storage fault, not a malformed envelope)` } };
|
|
1226
|
+
}
|
|
1227
|
+
if (published !== "published") {
|
|
1228
|
+
safeRm(staging);
|
|
1229
|
+
return published === "not_empty"
|
|
1230
|
+
? { ok: false, error: { code: "conflict", message: `importHistory target "${scope}" gained history of its own while this import was being staged — nothing was published` } }
|
|
1231
|
+
: { ok: false, error: { code: "adopt_failed", message: `importHistory could not record the consumed-entry tombstones for target "${scope}"'s unreadable boundary residue — nothing was published, because replacing records nothing remembers would hand their already-consumed entry ids back to this scope` } };
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
safeRm(staging);
|
|
1236
|
+
throw err;
|
|
1237
|
+
}
|
|
1238
|
+
return { ok: true };
|
|
1239
|
+
}
|
|
1240
|
+
catch (err) {
|
|
1241
|
+
return { ok: false, error: { code: "unsupported_format", message: `importHistory failed: ${err.message}` } };
|
|
1242
|
+
}
|
|
1243
|
+
finally {
|
|
1244
|
+
safeRm(importJournal);
|
|
1245
|
+
this.activeImports.get(scope)?.delete(importJournal);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|