sentinelayer-cli 0.46.4 → 0.48.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/README.md +63 -3
- package/package.json +12 -7
- package/skills/omar-gate-setup/SKILL.md +103 -0
- package/skills/senti-agent-identity/SKILL.md +80 -0
- package/skills/senti-judge/SKILL.md +139 -0
- package/skills/senti-room-notifications/SKILL.md +73 -0
- package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
- package/skills/senti-room-notifications/package.json +1 -0
- package/skills/senti-room-notifications/test-classifier.js +109 -0
- package/skills/senti-room-notifications/test-receipt.js +54 -0
- package/skills/sentinelayer-cli/SKILL.md +222 -0
- package/src/agents/devtestbot/runner.js +22 -11
- package/src/agents/devtestbot/tool.js +24 -11
- package/src/agents/jules/fix-cycle.js +7 -1
- package/src/agents/jules/tools/runtime-audit.js +17 -12
- package/src/agents/shared-tools/dispatch-core.js +10 -5
- package/src/ai/aidenid.js +4 -2
- package/src/ai/domain-target-store.js +11 -5
- package/src/ai/identity-store.js +11 -5
- package/src/ai/site-store.js +8 -4
- package/src/audit/orchestrator.js +21 -14
- package/src/audit/package.js +6 -4
- package/src/audit/persona-loop.js +7 -0
- package/src/audit/replay.js +4 -2
- package/src/auth/gate.js +46 -8
- package/src/auth/service.js +34 -15
- package/src/auth/session-store.js +105 -47
- package/src/board/evidence-resolvers.js +3 -4
- package/src/cli.js +23 -3
- package/src/commands/ai/identity-lifecycle.js +30 -26
- package/src/commands/ai/provision-governance.js +32 -24
- package/src/commands/ai/shared.js +6 -4
- package/src/commands/audit.js +14 -12
- package/src/commands/auth.js +24 -4
- package/src/commands/board.js +2 -2
- package/src/commands/chat.js +8 -6
- package/src/commands/judge.js +125 -0
- package/src/commands/legacy-args.js +1 -0
- package/src/commands/mcp.js +45 -52
- package/src/commands/omargate.js +2 -1
- package/src/commands/plugin.js +11 -3
- package/src/commands/review.js +9 -4
- package/src/commands/scan.js +7 -7
- package/src/commands/session.js +607 -57
- package/src/commands/skills.js +45 -0
- package/src/commands/watch.js +9 -7
- package/src/config/output-filesystem.js +156 -0
- package/src/config/schema.js +4 -2
- package/src/config/service.js +40 -4
- package/src/cost/history.js +15 -9
- package/src/daemon/artifact-lineage.js +15 -12
- package/src/daemon/assignment-ledger.js +31 -25
- package/src/daemon/ast-drift.js +7 -3
- package/src/daemon/budget-governor.js +26 -20
- package/src/daemon/error-worker.js +28 -22
- package/src/daemon/fix-cycle.js +5 -4
- package/src/daemon/hybrid-mapper.js +19 -16
- package/src/daemon/ingest-refresh.js +4 -0
- package/src/daemon/jira-lifecycle.js +21 -17
- package/src/daemon/operator-control.js +18 -15
- package/src/daemon/reliability-lane.js +18 -15
- package/src/daemon/scope-engine.js +12 -8
- package/src/daemon/watchdog.js +15 -12
- package/src/engram/build-state.js +24 -3
- package/src/engram/capability-index.js +367 -0
- package/src/engram/compile-graph.js +1063 -0
- package/src/engram/contracts/action-receipt.js +82 -0
- package/src/engram/contracts/capability-index.js +259 -0
- package/src/engram/contracts/capability.js +377 -0
- package/src/engram/contracts/content-hash.js +8 -0
- package/src/engram/contracts/evidence.js +258 -0
- package/src/engram/contracts/graph.js +594 -0
- package/src/engram/contracts/identity.js +8 -0
- package/src/engram/contracts/index.js +149 -0
- package/src/engram/contracts/media-asset.js +85 -0
- package/src/engram/contracts/money.js +249 -0
- package/src/engram/contracts/source-snapshot.js +57 -0
- package/src/engram/contracts/task-context-pack.js +114 -0
- package/src/engram/contracts/temporal-segment.js +72 -0
- package/src/engram/contracts/versions.js +96 -0
- package/src/engram/extract-html.js +692 -0
- package/src/engram/graph-store.js +877 -0
- package/src/engram/index.js +98 -14
- package/src/engram/ingest-site.js +132 -0
- package/src/engram/live-state.js +1180 -0
- package/src/engram/namespace.js +34 -1
- package/src/engram/retrieve-site.js +753 -0
- package/src/engram/safe-reader.js +597 -0
- package/src/engram/source-intake.js +251 -0
- package/src/engram/store.js +227 -9
- package/src/ingest/engine.js +21 -28
- package/src/judge/verdict.js +305 -0
- package/src/legacy-cli.js +96 -19
- package/src/mcp/bridge-inputs.js +28 -4
- package/src/mcp/cli-command-tools.js +70 -0
- package/src/mcp/registry.js +17 -13
- package/src/mcp/session-stdio-server.js +297 -19
- package/src/memory/blackboard.js +5 -4
- package/src/net/gh-api-path.js +35 -9
- package/src/net/test-egress-guard.js +16 -4
- package/src/plugin/manifest.js +12 -8
- package/src/review/ai-review.js +110 -10
- package/src/review/investor-dd-devtestbot.js +17 -6
- package/src/review/investor-dd-orchestrator.js +21 -13
- package/src/review/local-review.js +26 -16
- package/src/review/omargate-cache.js +5 -5
- package/src/review/omargate-orchestrator.js +89 -17
- package/src/review/replay.js +9 -4
- package/src/review/report.js +22 -13
- package/src/review/typesafe-severity.js +201 -0
- package/src/session/admission-auth.js +34 -7
- package/src/session/admission.js +322 -71
- package/src/session/agent-access.js +137 -0
- package/src/session/agent-registry.js +2 -1
- package/src/session/attachment-summary.js +77 -0
- package/src/session/edit-summary.js +19 -0
- package/src/session/invitations.js +65 -1
- package/src/session/mentions.js +17 -9
- package/src/session/message-edits.js +5 -4
- package/src/session/preview.js +10 -2
- package/src/session/recap.js +3 -2
- package/src/session/room-files.js +519 -0
- package/src/session/route-classes.js +22 -8
- package/src/session/setup-guides.js +4 -0
- package/src/session/standup.js +192 -0
- package/src/session/sync.js +229 -10
- package/src/session/tickets.js +41 -0
- package/src/session/transcript.js +2 -1
- package/src/session/wake/resolve-target.js +7 -3
- package/src/skills/listener-runtime.cjs +92 -0
- package/src/skills/service.js +207 -0
- package/src/swarm/factory.js +5 -5
- package/src/swarm/pentest.js +9 -9
- package/src/swarm/report.js +4 -4
- package/src/swarm/runtime.js +15 -12
- package/src/swarm/scenario-dsl.js +7 -5
- package/src/telemetry/ledger.js +16 -7
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENGRAM §EW03 — Site-scoped immutable source snapshots + atomically published
|
|
3
|
+
* graph generations.
|
|
4
|
+
*
|
|
5
|
+
* Extends the §2 namespace store with:
|
|
6
|
+
* - Immutable content-addressed source snapshots per site (EW02 sourceSnapshotSchema)
|
|
7
|
+
* - Graph projection from observations (EW02 graphSchema + validateGraphIntegrity)
|
|
8
|
+
* - Atomic graph publish (readers always see one complete version)
|
|
9
|
+
* - Versioned rollback (republish any prior version)
|
|
10
|
+
* - Site-scoped read isolation via namespace.js authorize() — fail-closed
|
|
11
|
+
* - Integrity verification on every read (sha256 mismatch = refused)
|
|
12
|
+
* - Tombstone support + restart cleanup of stale *.tmp files
|
|
13
|
+
*
|
|
14
|
+
* No database, no network, no new dependencies. Node builtins + existing zod
|
|
15
|
+
* contracts only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fsp from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
21
|
+
import { authorize, AccessDeniedError } from "./namespace.js";
|
|
22
|
+
import { sourceSnapshotSchema } from "./contracts/source-snapshot.js";
|
|
23
|
+
import { graphSchema, validateGraphIntegrity } from "./contracts/graph.js";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Helpers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function sha256(buf) {
|
|
30
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isAlive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Version validation — prevents path traversal through version numbers
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
function validateVersion(v, label) {
|
|
47
|
+
if (!Number.isSafeInteger(v) || v < 1) {
|
|
48
|
+
if (Number.isFinite(v) && v > 0 && Number.isInteger(v) && !Number.isSafeInteger(v)) {
|
|
49
|
+
throw Object.assign(new Error(`${label}: version must be a safe integer, got ${String(v)}`), { name: "TypeError" });
|
|
50
|
+
}
|
|
51
|
+
throw Object.assign(new Error(`${label}: version must be a positive safe integer, got ${String(v)}`), { name: "TypeError" });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Namespace directory — collision-resistant via sha256, not sanitization
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function graphNamespaceDir(root, namespace) {
|
|
60
|
+
const h = sha256(Buffer.from(namespace.raw, "utf-8"));
|
|
61
|
+
return path.join(root, ".sentinelayer", "engram-graph", h);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function ensureNamespaceDir(root, namespace) {
|
|
65
|
+
const dir = graphNamespaceDir(root, namespace);
|
|
66
|
+
const nsFile = path.join(dir, "namespace.json");
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
70
|
+
// Try to create namespace.json with wx (exclusive write-once)
|
|
71
|
+
const fd = await fsp.open(nsFile, "wx");
|
|
72
|
+
try {
|
|
73
|
+
await fd.writeFile(JSON.stringify({ raw: namespace.raw }) + "\n", "utf-8");
|
|
74
|
+
} finally {
|
|
75
|
+
await fd.close();
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err && err.code === "EEXIST") {
|
|
79
|
+
// File already exists — verify it matches
|
|
80
|
+
const stored = await readJSON(nsFile);
|
|
81
|
+
if (!stored || stored.raw !== namespace.raw) {
|
|
82
|
+
throw new AccessDeniedError(
|
|
83
|
+
`Namespace directory collision: expected raw "${namespace.raw}" but found "${stored?.raw || "corrupt"}"`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return dir;
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return dir;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readJSON(file) {
|
|
95
|
+
let raw;
|
|
96
|
+
try {
|
|
97
|
+
raw = await fsp.readFile(file, "utf-8");
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (error && error.code === "ENOENT") return null;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(raw);
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readRaw(file) {
|
|
110
|
+
try {
|
|
111
|
+
return await fsp.readFile(file);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error && error.code === "ENOENT") return null;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Graph record: store + verify integrity
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async function readGraphRecord(file) {
|
|
123
|
+
// Read raw bytes and parse JSON
|
|
124
|
+
const raw = await readRaw(file);
|
|
125
|
+
if (raw === null) return null;
|
|
126
|
+
let record;
|
|
127
|
+
try {
|
|
128
|
+
record = JSON.parse(raw.toString("utf-8"));
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
// Recompute hash from content fields only (excludes contentHash itself)
|
|
133
|
+
if (!record.contentHash) return null;
|
|
134
|
+
const content = { graph: record.graph, version: record.version, publishedAt: record.publishedAt };
|
|
135
|
+
const computedHash = sha256(Buffer.from(JSON.stringify(content, null, 2) + "\n", "utf-8"));
|
|
136
|
+
if (record.contentHash !== computedHash) {
|
|
137
|
+
return null; // tampered or corrupt — refuse to serve
|
|
138
|
+
}
|
|
139
|
+
return record;
|
|
140
|
+
}
|
|
141
|
+
function makeGraphRecord(graph, version, publishedAt) {
|
|
142
|
+
const content = { graph, version, publishedAt };
|
|
143
|
+
const contentHash = sha256(Buffer.from(JSON.stringify(content, null, 2) + "\n", "utf-8"));
|
|
144
|
+
return { ...content, contentHash };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Error classes
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
class GraphValidationError extends Error {
|
|
152
|
+
constructor(message) { super(message); this.name = "GraphValidationError"; this.status = 422; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class VersionNotFoundError extends Error {
|
|
156
|
+
constructor(message) { super(message); this.name = "VersionNotFoundError"; this.status = 404; }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class IntegrityError extends Error {
|
|
160
|
+
constructor(message) { super(message); this.name = "IntegrityError"; this.status = 500; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { AccessDeniedError, GraphValidationError, VersionNotFoundError, IntegrityError };
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Source snapshots (immutable, content-addressed, site-scoped)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const SOURCES_DIR = "sources";
|
|
170
|
+
const OBJECTS_DIR = "objects";
|
|
171
|
+
const TOMBSTONES_DIR = "tombstones";
|
|
172
|
+
|
|
173
|
+
async function writeSourceSnapshot(root, consent, caller, namespace, snapshot, bytes) {
|
|
174
|
+
authorize(caller, namespace, "write", { consent });
|
|
175
|
+
await ensureNamespaceDir(root, namespace);
|
|
176
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
177
|
+
throw new GraphValidationError("bytes must be a non-empty Buffer");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const parsed = sourceSnapshotSchema.parse(snapshot);
|
|
181
|
+
|
|
182
|
+
// Tenant scope IS the namespace: refuse if tenantScope differs
|
|
183
|
+
if (parsed.tenantScope !== namespace.raw) {
|
|
184
|
+
throw new AccessDeniedError(
|
|
185
|
+
`Source snapshot tenantScope "${parsed.tenantScope}" does not match namespace "${namespace.raw}"`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Compute content hash from bytes (not caller-supplied)
|
|
190
|
+
const computedHash = sha256(bytes);
|
|
191
|
+
if (parsed.contentHash && parsed.contentHash !== computedHash) {
|
|
192
|
+
throw new GraphValidationError(
|
|
193
|
+
`Supplied contentHash "${parsed.contentHash}" does not match computed sha256 "${computedHash}"`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// P2-1: Store bytes using link-based atomic create.
|
|
198
|
+
// Write to temp, then fsp.link(tmp, objects/<sha>).
|
|
199
|
+
// On EEXIST, verify integrity; if corrupt, atomically replace.
|
|
200
|
+
const objectsDir = path.join(graphNamespaceDir(root, namespace), OBJECTS_DIR);
|
|
201
|
+
await fsp.mkdir(objectsDir, { recursive: true });
|
|
202
|
+
const objFile = path.join(objectsDir, computedHash);
|
|
203
|
+
const objTmp = `${objFile}.${process.pid}.${randomUUID()}.objtmp`;
|
|
204
|
+
|
|
205
|
+
let reused = false;
|
|
206
|
+
await fsp.writeFile(objTmp, bytes);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
await fsp.link(objTmp, objFile);
|
|
210
|
+
// Success — clean up temp
|
|
211
|
+
await fsp.unlink(objTmp).catch(() => {});
|
|
212
|
+
} catch (error) {
|
|
213
|
+
await fsp.unlink(objTmp).catch(() => {});
|
|
214
|
+
if (error && error.code === "EEXIST") {
|
|
215
|
+
// Object already exists — verify integrity
|
|
216
|
+
const existing = await readRaw(objFile);
|
|
217
|
+
if (existing === null) {
|
|
218
|
+
// Race: file vanished between EEXIST and read. Try again.
|
|
219
|
+
await fsp.writeFile(objTmp, bytes);
|
|
220
|
+
await fsp.rename(objTmp, objFile);
|
|
221
|
+
} else {
|
|
222
|
+
const existingHash = sha256(existing);
|
|
223
|
+
if (existingHash !== computedHash) {
|
|
224
|
+
// Corrupt object detected — atomically replace (rename temp over it)
|
|
225
|
+
await fsp.writeFile(objTmp, bytes);
|
|
226
|
+
await fsp.rename(objTmp, objFile);
|
|
227
|
+
// Log the repair (NOT reported as reused)
|
|
228
|
+
} else {
|
|
229
|
+
reused = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// P2-4: Write snapshot metadata as ONE FILE PER RECORD (not NDJSON append)
|
|
238
|
+
// Bound: snapshot metadata must be <= 64 KiB
|
|
239
|
+
const sourcesDir = path.join(graphNamespaceDir(root, namespace), SOURCES_DIR);
|
|
240
|
+
const snapshotRecord = { ...parsed, contentHash: computedHash };
|
|
241
|
+
const snapMeta = JSON.stringify(snapshotRecord);
|
|
242
|
+
if (Buffer.byteLength(snapMeta, "utf-8") > 65536) {
|
|
243
|
+
throw new GraphValidationError(
|
|
244
|
+
"Snapshot metadata exceeds 64 KiB limit (" + Buffer.byteLength(snapMeta, "utf-8") + " bytes)"
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const snapFile = path.join(sourcesDir, `${computedHash}-${randomUUID()}.json`);
|
|
248
|
+
await fsp.mkdir(sourcesDir, { recursive: true });
|
|
249
|
+
const snapTmp = `${snapFile}.${process.pid}.${randomUUID()}.tmp`;
|
|
250
|
+
await fsp.writeFile(snapTmp, snapMeta + "\n", "utf-8");
|
|
251
|
+
await fsp.rename(snapTmp, snapFile);
|
|
252
|
+
|
|
253
|
+
return { written: true, reused, contentHash: computedHash, snapshot: snapshotRecord };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function listSourceSnapshots(root, consent, caller, namespace) {
|
|
257
|
+
authorize(caller, namespace, "read", { consent });
|
|
258
|
+
await ensureNamespaceDir(root, namespace);
|
|
259
|
+
const dir = path.join(graphNamespaceDir(root, namespace), SOURCES_DIR);
|
|
260
|
+
try {
|
|
261
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
262
|
+
const items = [];
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
265
|
+
const raw = await fsp.readFile(path.join(dir, entry.name), "utf-8");
|
|
266
|
+
try { items.push(JSON.parse(raw)); } catch { /* skip corrupt files */ }
|
|
267
|
+
}
|
|
268
|
+
return items;
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (error && error.code === "ENOENT") return [];
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function tombstoneSource(root, consent, caller, namespace, sourceId, reason) {
|
|
276
|
+
authorize(caller, namespace, "write", { consent });
|
|
277
|
+
await ensureNamespaceDir(root, namespace);
|
|
278
|
+
|
|
279
|
+
const reasonStr = String(reason || "removed");
|
|
280
|
+
|
|
281
|
+
// P2-4: Bound — tombstone reason at most 4 KiB
|
|
282
|
+
if (Buffer.byteLength(reasonStr, "utf-8") > 4096) {
|
|
283
|
+
throw new GraphValidationError(
|
|
284
|
+
"Tombstone reason exceeds 4 KiB limit (" + Buffer.byteLength(reasonStr, "utf-8") + " bytes)"
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const dir = path.join(graphNamespaceDir(root, namespace), TOMBSTONES_DIR);
|
|
289
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
290
|
+
const record = {
|
|
291
|
+
sourceId,
|
|
292
|
+
reason: reasonStr,
|
|
293
|
+
tombstonedAt: new Date().toISOString(),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// P2-4: ONE FILE PER RECORD (not NDJSON append)
|
|
297
|
+
const recordFile = path.join(dir, `${randomUUID()}.json`);
|
|
298
|
+
const recTmp = `${recordFile}.${process.pid}.${randomUUID()}.tmp`;
|
|
299
|
+
await fsp.writeFile(recTmp, JSON.stringify(record) + "\n", "utf-8");
|
|
300
|
+
await fsp.rename(recTmp, recordFile);
|
|
301
|
+
|
|
302
|
+
return { tombstoned: true, sourceId, reason: reasonStr };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
// Graph publishing (atomic, versioned, integrity-checked)
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
const GRAPHS_DIR = "graphs";
|
|
310
|
+
const INDEX_CACHE_FILE = "index.cache.json";
|
|
311
|
+
|
|
312
|
+
const LOCK_FILE = ".publish.lock";
|
|
313
|
+
const LOCK_STALE_MS = 30_000;
|
|
314
|
+
const LOCK_BACKOFF_MIN = 10;
|
|
315
|
+
const LOCK_BACKOFF_MAX = 50;
|
|
316
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
317
|
+
const RENAME_MAX_RETRIES = 5;
|
|
318
|
+
const RELEASE_LOCK_DEADLINE_MS = 5_000;
|
|
319
|
+
|
|
320
|
+
function sleep(ms) {
|
|
321
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isTransientFsErr(err) {
|
|
325
|
+
return err && (err.code === "EPERM" || err.code === "EBUSY");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function withPublishLock(dir, fn) {
|
|
329
|
+
const lockFile = path.join(dir, LOCK_FILE);
|
|
330
|
+
let lastLockErr = null;
|
|
331
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
332
|
+
const token = randomUUID();
|
|
333
|
+
|
|
334
|
+
// R9-2: back-off helper with jitter
|
|
335
|
+
const jitterWait = () => sleep(
|
|
336
|
+
Math.floor(Math.random() * (LOCK_BACKOFF_MAX - LOCK_BACKOFF_MIN)) + LOCK_BACKOFF_MIN
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
let attempt = 0;
|
|
340
|
+
while (true) {
|
|
341
|
+
attempt++;
|
|
342
|
+
|
|
343
|
+
// Write lock content to a temp file, then atomically link to lockFile.
|
|
344
|
+
// link(2) is atomic and fails with EEXIST if the target exists.
|
|
345
|
+
// This eliminates the empty-lock window.
|
|
346
|
+
|
|
347
|
+
// R9-2: check deadline at top of every iteration so NO path can skip it
|
|
348
|
+
if (Date.now() >= deadline) {
|
|
349
|
+
const lastErr = lastLockErr && lastLockErr.code ? " (last error: " + lastLockErr.code + ")" : "";
|
|
350
|
+
throw new Error("Timed out acquiring publish lock for namespace" + lastErr);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const tmpFile = `${lockFile}.${process.pid}.${randomUUID()}.locktmp`;
|
|
354
|
+
const lockContent = JSON.stringify({
|
|
355
|
+
token,
|
|
356
|
+
pid: process.pid,
|
|
357
|
+
createdAt: new Date().toISOString(),
|
|
358
|
+
});
|
|
359
|
+
await fsp.mkdir(path.dirname(lockFile), { recursive: true });
|
|
360
|
+
await fsp.writeFile(tmpFile, lockContent, "utf-8");
|
|
361
|
+
|
|
362
|
+
// R12-2: Acquire the lock in its own try/catch.
|
|
363
|
+
// Run fn() OUTSIDE that catch, in try/finally (release in finally).
|
|
364
|
+
// fn errors propagate unchanged, exactly once.
|
|
365
|
+
let acquired = false;
|
|
366
|
+
try {
|
|
367
|
+
await fsp.link(tmpFile, lockFile);
|
|
368
|
+
acquired = true;
|
|
369
|
+
} catch (linkErr) {
|
|
370
|
+
// Clean up the temp file
|
|
371
|
+
await fsp.unlink(tmpFile).catch(() => {});
|
|
372
|
+
if (linkErr && linkErr.code === "EEXIST") {
|
|
373
|
+
// Lock is held — check if it is breakable (below)
|
|
374
|
+
lastLockErr = linkErr;
|
|
375
|
+
} else if (linkErr && (linkErr.code === "EPERM" || linkErr.code === "EBUSY")) {
|
|
376
|
+
// R9-2: Transient contention — back off and retry
|
|
377
|
+
lastLockErr = linkErr;
|
|
378
|
+
await jitterWait();
|
|
379
|
+
} else {
|
|
380
|
+
throw linkErr;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (acquired) {
|
|
385
|
+
// We hold the lock — clean up the temp file
|
|
386
|
+
await fsp.unlink(tmpFile).catch(() => {});
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
return await fn();
|
|
390
|
+
} finally {
|
|
391
|
+
// R13-1: one release routine for both paths — retry reads and unlinks
|
|
392
|
+
// until RELEASE_LOCK_DEADLINE_MS; never swallow silently
|
|
393
|
+
const releaseDeadline = Date.now() + RELEASE_LOCK_DEADLINE_MS;
|
|
394
|
+
let ownLock = false;
|
|
395
|
+
|
|
396
|
+
// Phase 1: read the lock with backoff for transient EPERM/EBUSY
|
|
397
|
+
while (true) {
|
|
398
|
+
try {
|
|
399
|
+
const current = await readJSON(lockFile);
|
|
400
|
+
if (current && current.token === token) {
|
|
401
|
+
ownLock = true;
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
} catch (re) {
|
|
405
|
+
if (isTransientFsErr(re) && Date.now() < releaseDeadline) {
|
|
406
|
+
await jitterWait();
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
// unrecoverable read error or deadline — we created this lock;
|
|
410
|
+
// proceed to unlink as if it were ours
|
|
411
|
+
ownLock = true;
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Phase 2: unlink if ours; retry with backoff
|
|
417
|
+
if (ownLock) {
|
|
418
|
+
let released = false;
|
|
419
|
+
while (Date.now() < releaseDeadline) {
|
|
420
|
+
try {
|
|
421
|
+
await fsp.unlink(lockFile);
|
|
422
|
+
released = true;
|
|
423
|
+
break;
|
|
424
|
+
} catch (ue) {
|
|
425
|
+
if (!isTransientFsErr(ue)) {
|
|
426
|
+
// Non-transient unlink error — surface it
|
|
427
|
+
if (typeof process.emitWarning === "function") {
|
|
428
|
+
process.emitWarning(
|
|
429
|
+
"Publish committed but lock unlink failed at " + lockFile + ": " + (ue && ue.message),
|
|
430
|
+
{ type: "PublishLockReleaseWarning" }
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
await jitterWait();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!released) {
|
|
439
|
+
// Lock still exists at deadline — emit warning
|
|
440
|
+
try {
|
|
441
|
+
const stillThere = await fsp.stat(lockFile).then(() => true, () => false);
|
|
442
|
+
if (stillThere) {
|
|
443
|
+
if (typeof process.emitWarning === "function") {
|
|
444
|
+
process.emitWarning(
|
|
445
|
+
"Publish committed but lock was not released at " + lockFile,
|
|
446
|
+
{ type: "PublishLockReleaseWarning" }
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} catch { /* best-effort */ }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// If we got here without returning, the lock is released but we didn't return fn()'s value
|
|
455
|
+
// This should not happen — fn() either returns or throws.
|
|
456
|
+
// But if it somehow resolves to undefined after finally, re-throw.
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Lock held by another process (or transient EPERM/EBUSY) — check if we can break it
|
|
460
|
+
let raw;
|
|
461
|
+
try {
|
|
462
|
+
raw = await fsp.readFile(lockFile, "utf-8");
|
|
463
|
+
} catch (err2) {
|
|
464
|
+
if (err2 && err2.code === "ENOENT") {
|
|
465
|
+
// Owner released the lock — retry immediately (once is fine)
|
|
466
|
+
lastLockErr = err2;
|
|
467
|
+
continue;
|
|
468
|
+
} else if (err2 && (err2.code === "EPERM" || err2.code === "EBUSY")) {
|
|
469
|
+
// R10-1: Windows deletion-pending — back off with jitter and retry
|
|
470
|
+
lastLockErr = err2;
|
|
471
|
+
await jitterWait();
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
throw err2;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let lockInfo;
|
|
478
|
+
try {
|
|
479
|
+
lockInfo = JSON.parse(raw);
|
|
480
|
+
} catch {
|
|
481
|
+
lockInfo = null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (lockInfo === null || typeof lockInfo !== "object") {
|
|
485
|
+
// Corrupt or unparsable lock — NEVER break it
|
|
486
|
+
throw new Error(
|
|
487
|
+
"Publish lock is corrupt or unparsable at " + lockFile + ". " +
|
|
488
|
+
"Manual intervention may be required."
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (typeof lockInfo.pid !== "number" || typeof lockInfo.createdAt !== "string") {
|
|
493
|
+
// Malformed lock — NEVER break it
|
|
494
|
+
throw new Error(
|
|
495
|
+
"Publish lock is malformed (missing pid or createdAt) at " + lockFile + ". " +
|
|
496
|
+
"Manual intervention may be required."
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const age = Date.now() - new Date(lockInfo.createdAt).getTime();
|
|
501
|
+
const ownerDead = !isAlive(lockInfo.pid);
|
|
502
|
+
|
|
503
|
+
if (ownerDead && age > LOCK_STALE_MS) {
|
|
504
|
+
// Dead owner + stale — safe to break
|
|
505
|
+
await fsp.unlink(lockFile).catch(() => {});
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// R9-2: Back off before retrying — deadline already checked at loop top
|
|
510
|
+
await jitterWait();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Derive the version index from committed vN.json files on disk.
|
|
515
|
+
// The index is BUILT from verified files — it is never a separately
|
|
516
|
+
// overwritten list. index.cache.json is a write-through cache for speed.
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
async function buildIndexFromDisk(graphsDir) {
|
|
520
|
+
const versions = [];
|
|
521
|
+
let entries;
|
|
522
|
+
try {
|
|
523
|
+
entries = await fsp.readdir(graphsDir, { withFileTypes: true });
|
|
524
|
+
} catch (err) {
|
|
525
|
+
if (err && err.code === "ENOENT") return { lastVersion: 0, versions: [] };
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const entry of entries) {
|
|
530
|
+
if (!entry.isFile()) continue;
|
|
531
|
+
const m = entry.name.match(/^v(\d+)\.json$/);
|
|
532
|
+
if (!m) continue;
|
|
533
|
+
const v = Number(m[1]);
|
|
534
|
+
if (!Number.isSafeInteger(v) || v < 1) continue;
|
|
535
|
+
|
|
536
|
+
const filePath = path.join(graphsDir, entry.name);
|
|
537
|
+
const record = await readGraphRecord(filePath);
|
|
538
|
+
if (record === null) continue; // tampered, corrupt, or unreadable
|
|
539
|
+
|
|
540
|
+
versions.push({
|
|
541
|
+
version: record.version,
|
|
542
|
+
publishedAt: record.publishedAt,
|
|
543
|
+
contentHash: record.contentHash,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
versions.sort((a, b) => a.version - b.version);
|
|
548
|
+
const lastVersion = versions.length > 0 ? versions[versions.length - 1].version : 0;
|
|
549
|
+
|
|
550
|
+
// Write cache for speed
|
|
551
|
+
const cache = { lastVersion, versions };
|
|
552
|
+
try {
|
|
553
|
+
await fsp.mkdir(graphsDir, { recursive: true });
|
|
554
|
+
const cacheFile = path.join(graphsDir, INDEX_CACHE_FILE);
|
|
555
|
+
const cacheTmp = `${cacheFile}.${process.pid}.${randomUUID()}.tmp`;
|
|
556
|
+
await fsp.writeFile(cacheTmp, JSON.stringify(cache, null, 2) + "\n", "utf-8");
|
|
557
|
+
await fsp.rename(cacheTmp, cacheFile);
|
|
558
|
+
} catch { /* best-effort: cache write failure is non-fatal */ }
|
|
559
|
+
|
|
560
|
+
return cache;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function maxVersionOnDisk(dir) {
|
|
564
|
+
let max = 0;
|
|
565
|
+
try {
|
|
566
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
567
|
+
for (const entry of entries) {
|
|
568
|
+
if (!entry.isFile()) continue;
|
|
569
|
+
const m = entry.name.match(/^v(\d+)\.json$/);
|
|
570
|
+
if (m) {
|
|
571
|
+
const v = Number(m[1]);
|
|
572
|
+
// R12-1: Count ALL vN.json files by NAME, valid or not.
|
|
573
|
+
// A corrupt file stays where it is, never listed or served,
|
|
574
|
+
// and its number is skipped forever.
|
|
575
|
+
if (Number.isSafeInteger(v) && v > max) max = v;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch (err) {
|
|
579
|
+
if (err && err.code === "ENOENT") return 0;
|
|
580
|
+
throw err;
|
|
581
|
+
}
|
|
582
|
+
return max;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async function readGraphIndex(dir) {
|
|
587
|
+
// Try the write-through cache first
|
|
588
|
+
const cached = await readJSON(path.join(dir, INDEX_CACHE_FILE));
|
|
589
|
+
if (cached !== null && Number.isInteger(cached.lastVersion) && Array.isArray(cached.versions)) {
|
|
590
|
+
// Verify cache: scan disk for any file that would change the index
|
|
591
|
+
const onDisk = await buildIndexFromDisk(dir);
|
|
592
|
+
if (JSON.stringify(onDisk) === JSON.stringify(cached)) {
|
|
593
|
+
return cached;
|
|
594
|
+
}
|
|
595
|
+
return onDisk;
|
|
596
|
+
}
|
|
597
|
+
return buildIndexFromDisk(dir);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function publishGraph(root, consent, caller, namespace, graph) {
|
|
601
|
+
authorize(caller, namespace, "write", { consent });
|
|
602
|
+
await ensureNamespaceDir(root, namespace);
|
|
603
|
+
|
|
604
|
+
// Validate through the EW02 contract
|
|
605
|
+
const schemaResult = graphSchema.safeParse(graph);
|
|
606
|
+
if (!schemaResult.success) {
|
|
607
|
+
const messages = schemaResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
|
|
608
|
+
throw new GraphValidationError(`Graph schema invalid: ${messages.join("; ")}`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const integrity = validateGraphIntegrity(schemaResult.data);
|
|
612
|
+
if (!integrity.valid) {
|
|
613
|
+
throw new GraphValidationError(`Graph integrity failed: ${integrity.errors.join("; ")}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const dir = path.join(graphNamespaceDir(root, namespace), GRAPHS_DIR);
|
|
617
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
618
|
+
|
|
619
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
620
|
+
|
|
621
|
+
// Acquire lock as a fairness hint only — correctness does not depend on it
|
|
622
|
+
return withPublishLock(dir, async () => {
|
|
623
|
+
const publishedAt = new Date().toISOString();
|
|
624
|
+
|
|
625
|
+
// Retry loop: commit by EXCLUSIVE CREATE via fsp.link
|
|
626
|
+
// EEXIST means another publisher took N — recompute and retry
|
|
627
|
+
while (true) {
|
|
628
|
+
// Recompute N from disk each attempt (new versions may have landed)
|
|
629
|
+
const index = await readGraphIndex(dir);
|
|
630
|
+
const diskMax = await maxVersionOnDisk(dir);
|
|
631
|
+
const nextVersion = Math.max(index.lastVersion, diskMax) + 1;
|
|
632
|
+
|
|
633
|
+
const record = makeGraphRecord(graph, nextVersion, publishedAt);
|
|
634
|
+
const versionFile = path.join(dir, `v${nextVersion}.json`);
|
|
635
|
+
|
|
636
|
+
// Write to a unique temp file, then link atomically
|
|
637
|
+
const verTmp = `${versionFile}.${process.pid}.${randomUUID()}.vertmp`;
|
|
638
|
+
const raw = JSON.stringify(record, null, 2) + "\n";
|
|
639
|
+
await fsp.writeFile(verTmp, raw, "utf-8");
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
await fsp.link(verTmp, versionFile);
|
|
643
|
+
// Success — clean up temp and exit retry loop
|
|
644
|
+
await fsp.unlink(verTmp).catch(() => {});
|
|
645
|
+
return record;
|
|
646
|
+
} catch (linkErr) {
|
|
647
|
+
await fsp.unlink(verTmp).catch(() => {});
|
|
648
|
+
if (linkErr && linkErr.code === "EEXIST") {
|
|
649
|
+
// R12-1: NEVER overwrite or reuse a version file.
|
|
650
|
+
// EEXIST always means recompute and retry.
|
|
651
|
+
// Corrupt files stay where they are, their number skipped forever.
|
|
652
|
+
if (Date.now() >= deadline) {
|
|
653
|
+
throw new Error("Timed out committing graph version due to contention");
|
|
654
|
+
}
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
throw linkErr;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function readCurrentGraph(root, consent, caller, namespace) {
|
|
664
|
+
authorize(caller, namespace, "read", { consent });
|
|
665
|
+
await ensureNamespaceDir(root, namespace);
|
|
666
|
+
|
|
667
|
+
const dir = path.join(graphNamespaceDir(root, namespace), GRAPHS_DIR);
|
|
668
|
+
|
|
669
|
+
// current = highest verified committed version (derived from disk)
|
|
670
|
+
const index = await readGraphIndex(dir);
|
|
671
|
+
|
|
672
|
+
if (index.versions.length === 0) {
|
|
673
|
+
return { graph: null, version: 0, reason: "no graph published" };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const latest = index.versions[index.versions.length - 1];
|
|
677
|
+
|
|
678
|
+
const versionFile = path.join(dir, `v${latest.version}.json`);
|
|
679
|
+
const record = await readGraphRecord(versionFile);
|
|
680
|
+
|
|
681
|
+
if (record === null) {
|
|
682
|
+
return { graph: null, version: 0, reason: `version ${latest.version} file missing, tampered, or unreadable` };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return { graph: record.graph, version: record.version, publishedAt: record.publishedAt, contentHash: record.contentHash };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function readGraphVersion(root, consent, caller, namespace, version) {
|
|
689
|
+
authorize(caller, namespace, "read", { consent });
|
|
690
|
+
await ensureNamespaceDir(root, namespace);
|
|
691
|
+
|
|
692
|
+
validateVersion(version, "version");
|
|
693
|
+
const graphsDir = path.join(graphNamespaceDir(root, namespace), GRAPHS_DIR);
|
|
694
|
+
const versionFile = path.join(graphsDir, `v${version}.json`);
|
|
695
|
+
// Containment assertion — prevent path traversal
|
|
696
|
+
if (path.dirname(path.resolve(versionFile)) !== path.resolve(graphsDir)) {
|
|
697
|
+
throw Object.assign(new Error(`version traverses outside graphs directory`), { name: "TypeError" });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const record = await readGraphRecord(versionFile);
|
|
701
|
+
|
|
702
|
+
if (record === null) {
|
|
703
|
+
return { graph: null, version, reason: "version not found" };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// P2-2: Verify the version is in the index (index is derived from verified files)
|
|
707
|
+
const index = await readGraphIndex(graphsDir);
|
|
708
|
+
const idxEntry = index.versions.find((v) => v.version === version);
|
|
709
|
+
if (!idxEntry) {
|
|
710
|
+
return { graph: null, version, reason: `version ${version} not found in index` };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return { graph: record.graph, version: record.version, publishedAt: record.publishedAt, contentHash: record.contentHash };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function rollbackGraph(root, consent, caller, namespace, targetVersion) {
|
|
717
|
+
authorize(caller, namespace, "write", { consent });
|
|
718
|
+
await ensureNamespaceDir(root, namespace);
|
|
719
|
+
|
|
720
|
+
validateVersion(targetVersion, "targetVersion");
|
|
721
|
+
const graphsDir = path.join(graphNamespaceDir(root, namespace), GRAPHS_DIR);
|
|
722
|
+
const versionFile = path.join(graphsDir, `v${targetVersion}.json`);
|
|
723
|
+
// Containment assertion — prevent path traversal
|
|
724
|
+
if (path.dirname(path.resolve(versionFile)) !== path.resolve(graphsDir)) {
|
|
725
|
+
throw Object.assign(new Error(`version traverses outside graphs directory`), { name: "TypeError" });
|
|
726
|
+
}
|
|
727
|
+
const record = await readGraphRecord(versionFile);
|
|
728
|
+
|
|
729
|
+
if (record === null) {
|
|
730
|
+
throw new VersionNotFoundError(`Version ${targetVersion} not found`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const index = await readGraphIndex(graphsDir);
|
|
734
|
+
if (!index.versions.some((v) => v.version === targetVersion)) {
|
|
735
|
+
throw new VersionNotFoundError(`Version ${targetVersion} not in index`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Rollback creates a NEW version with the rolled-back graph.
|
|
739
|
+
// History is append-only; the new version records rolledBackFrom.
|
|
740
|
+
const rolledGraph = JSON.parse(JSON.stringify(record.graph));
|
|
741
|
+
const publishedAt = new Date().toISOString();
|
|
742
|
+
|
|
743
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
744
|
+
|
|
745
|
+
return withPublishLock(graphsDir, async () => {
|
|
746
|
+
while (true) {
|
|
747
|
+
const currentIndex = await readGraphIndex(graphsDir);
|
|
748
|
+
const diskMax = await maxVersionOnDisk(graphsDir);
|
|
749
|
+
const nextVersion = Math.max(currentIndex.lastVersion, diskMax) + 1;
|
|
750
|
+
|
|
751
|
+
const newRecord = {
|
|
752
|
+
graph: rolledGraph,
|
|
753
|
+
version: nextVersion,
|
|
754
|
+
publishedAt,
|
|
755
|
+
rolledBackFrom: targetVersion,
|
|
756
|
+
contentHash: undefined,
|
|
757
|
+
};
|
|
758
|
+
newRecord.contentHash = sha256(Buffer.from(
|
|
759
|
+
JSON.stringify({ graph: newRecord.graph, version: newRecord.version, publishedAt: newRecord.publishedAt }, null, 2) + "\n",
|
|
760
|
+
"utf-8"
|
|
761
|
+
));
|
|
762
|
+
|
|
763
|
+
const newVersionFile = path.join(graphsDir, `v${nextVersion}.json`);
|
|
764
|
+
const verTmp = `${newVersionFile}.${process.pid}.${randomUUID()}.vertmp`;
|
|
765
|
+
const raw = JSON.stringify(newRecord, null, 2) + "\n";
|
|
766
|
+
await fsp.writeFile(verTmp, raw, "utf-8");
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
await fsp.link(verTmp, newVersionFile);
|
|
770
|
+
await fsp.unlink(verTmp).catch(() => {});
|
|
771
|
+
return { rolledBack: true, version: nextVersion, rolledBackFrom: targetVersion, previousVersion: currentIndex.lastVersion };
|
|
772
|
+
} catch (linkErr) {
|
|
773
|
+
await fsp.unlink(verTmp).catch(() => {});
|
|
774
|
+
if (linkErr && linkErr.code === "EEXIST") {
|
|
775
|
+
// R12-1: NEVER overwrite or reuse a version file.
|
|
776
|
+
// EEXIST always means recompute and retry.
|
|
777
|
+
if (Date.now() >= deadline) {
|
|
778
|
+
throw new Error("Timed out committing rollback version due to contention");
|
|
779
|
+
}
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
throw linkErr;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function listGraphVersions(root, consent, caller, namespace) {
|
|
789
|
+
authorize(caller, namespace, "read", { consent });
|
|
790
|
+
await ensureNamespaceDir(root, namespace);
|
|
791
|
+
|
|
792
|
+
const dir = path.join(graphNamespaceDir(root, namespace), GRAPHS_DIR);
|
|
793
|
+
const index = await readGraphIndex(dir);
|
|
794
|
+
|
|
795
|
+
return index.versions;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Restart cleanup: remove leftover *.tmp files
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
async function cleanupTmpFiles(root, consent, caller, namespace) {
|
|
803
|
+
// P2-3: Require authorization
|
|
804
|
+
authorize(caller, namespace, "write", { consent });
|
|
805
|
+
await ensureNamespaceDir(root, namespace);
|
|
806
|
+
|
|
807
|
+
const dir = graphNamespaceDir(root, namespace);
|
|
808
|
+
const graphDir = path.join(dir, GRAPHS_DIR);
|
|
809
|
+
|
|
810
|
+
const TMP_CLEANUP_AGE_MS = 10 * 60 * 1000; // 10 minutes
|
|
811
|
+
const tmpPattern = /\.\d+\.[0-9a-f-]+\.tmp$/;
|
|
812
|
+
const cleaned = [];
|
|
813
|
+
|
|
814
|
+
// P2-3: Run under the publish lock
|
|
815
|
+
return withPublishLock(graphDir, async () => {
|
|
816
|
+
for (const subdir of [dir, graphDir]) {
|
|
817
|
+
try {
|
|
818
|
+
const entries = await fsp.readdir(subdir, { withFileTypes: true });
|
|
819
|
+
for (const entry of entries) {
|
|
820
|
+
if (entry.isFile() && tmpPattern.test(entry.name)) {
|
|
821
|
+
const fullPath = path.join(subdir, entry.name);
|
|
822
|
+
try {
|
|
823
|
+
const stat = await fsp.stat(fullPath);
|
|
824
|
+
// P2-3: Delete only *.tmp older than 10 minutes
|
|
825
|
+
if (Date.now() - stat.mtimeMs >= TMP_CLEANUP_AGE_MS) {
|
|
826
|
+
await fsp.unlink(fullPath);
|
|
827
|
+
cleaned.push(fullPath);
|
|
828
|
+
}
|
|
829
|
+
} catch (err) {
|
|
830
|
+
if (err && err.code === "ENOENT") continue;
|
|
831
|
+
throw err;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
} catch (error) {
|
|
836
|
+
if (error && error.code === "ENOENT") continue;
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return { cleaned };
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// Factory
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
|
|
848
|
+
export function createGraphStore({ storeRoot, consent } = {}) {
|
|
849
|
+
const root = path.resolve(String(storeRoot || "."));
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
writeSourceSnapshot: (caller, namespace, snapshot, bytes) =>
|
|
853
|
+
writeSourceSnapshot(root, consent, caller, namespace, snapshot, bytes),
|
|
854
|
+
listSourceSnapshots: (caller, namespace) =>
|
|
855
|
+
listSourceSnapshots(root, consent, caller, namespace),
|
|
856
|
+
tombstoneSource: (caller, namespace, sourceId, reason) =>
|
|
857
|
+
tombstoneSource(root, consent, caller, namespace, sourceId, reason),
|
|
858
|
+
|
|
859
|
+
publishGraph: (caller, namespace, graph) =>
|
|
860
|
+
publishGraph(root, consent, caller, namespace, graph),
|
|
861
|
+
readCurrentGraph: (caller, namespace) =>
|
|
862
|
+
readCurrentGraph(root, consent, caller, namespace),
|
|
863
|
+
readGraphVersion: (caller, namespace, version) =>
|
|
864
|
+
readGraphVersion(root, consent, caller, namespace, version),
|
|
865
|
+
listGraphVersions: (caller, namespace) =>
|
|
866
|
+
listGraphVersions(root, consent, caller, namespace),
|
|
867
|
+
|
|
868
|
+
rollbackGraph: (caller, namespace, targetVersion) =>
|
|
869
|
+
rollbackGraph(root, consent, caller, namespace, targetVersion),
|
|
870
|
+
|
|
871
|
+
cleanupTmpFiles: (caller, namespace) =>
|
|
872
|
+
cleanupTmpFiles(root, consent, caller, namespace),
|
|
873
|
+
|
|
874
|
+
_root: root,
|
|
875
|
+
_graphNamespaceDir: (namespace) => graphNamespaceDir(root, namespace),
|
|
876
|
+
};
|
|
877
|
+
}
|