@sema-agent/core 5.31.0 → 5.33.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 +96 -0
- package/dist/agents/cascade.d.ts +49 -1
- package/dist/agents/cascade.js +2 -2
- package/dist/agents/verify.d.ts +70 -4
- package/dist/agents/verify.js +62 -16
- package/dist/core/checkpoint-store.d.ts +95 -0
- package/dist/core/checkpoint-store.js +40 -0
- package/dist/core/hooks.d.ts +14 -6
- package/dist/core/hooks.js +14 -3
- package/dist/core/memory-engine/file-backend.d.ts +172 -22
- package/dist/core/memory-engine/file-backend.js +877 -79
- package/dist/core/memory-engine/memory-backend-contract.js +33 -0
- package/dist/core/runner/assemble-result.d.ts +7 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/prepare-acquire-reconcile.d.ts +72 -0
- package/dist/core/runner/prepare-acquire-reconcile.js +126 -0
- package/dist/core/runner/prepare-config-doors.d.ts +140 -0
- package/dist/core/runner/prepare-config-doors.js +250 -0
- package/dist/core/runner/prepare-safety-scan.d.ts +53 -0
- package/dist/core/runner/prepare-safety-scan.js +80 -0
- package/dist/core/runner/prepare-task.d.ts +28 -80
- package/dist/core/runner/prepare-task.js +102 -586
- package/dist/core/runner/prepare-workspace-restore.d.ts +102 -0
- package/dist/core/runner/prepare-workspace-restore.js +144 -0
- package/dist/core/runner/runtask.js +8 -2
- package/dist/core/tool-policy.d.ts +25 -0
- package/dist/core/types.d.ts +149 -13
- package/dist/index.d.ts +5 -4
- package/dist/index.js +2 -2
- package/dist/orchestration/workflow-governance.d.ts +6 -4
- package/dist/tools/fs/bash-readonly-classifier.d.ts +9 -3
- package/dist/tools/fs/bash-readonly-classifier.js +4 -1
- package/dist/tools/fs/fs-bash.d.ts +19 -3
- package/dist/tools/fs/fs-bash.js +26 -1
- package/dist/tools/fs/index.d.ts +27 -7
- package/dist/tools/fs/index.js +7 -2
- package/dist/tools/fs/read-deny.d.ts +66 -8
- package/dist/tools/fs/read-deny.js +75 -39
- package/dist/tools/fs/read-face.d.ts +24 -2
- package/dist/tools/fs/read-face.js +9 -0
- package/dist/tools/fs/search.js +2 -0
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import { jaccardDistance, termSet } from "../memory-vector.js";
|
|
|
5
5
|
import { MAX_MEMORY_BYTES } from "../memory.js";
|
|
6
6
|
import { inlineUntrusted } from "../untrusted-text.js";
|
|
7
7
|
import { computeEntryRev, entryFromFile, isValidEntryId, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
|
|
8
|
-
import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, claimRootScope, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirName, writeAllSync, } from "./layout.js";
|
|
8
|
+
import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, claimRootScope, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, isContainedIn, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, writeAllSync, } from "./layout.js";
|
|
9
9
|
import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
|
|
10
10
|
export const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
11
11
|
export const DEFAULT_MAX_ENTRY_DEPTH = 3;
|
|
@@ -63,16 +63,257 @@ export function scanEntryFiles(dir, opts = {}) {
|
|
|
63
63
|
return out;
|
|
64
64
|
}
|
|
65
65
|
const LEDGER_FILE = "revs.json";
|
|
66
|
+
const LEDGER_SCHEMA_VERSION = 2;
|
|
67
|
+
const LEDGER_V1_BACKUP_FILE = "revs.v1.json.bak";
|
|
68
|
+
const TRANSFERS_FILE = "transfers.jsonl";
|
|
66
69
|
const JOURNAL_FILE = "journal.json";
|
|
67
70
|
const TXN_LOCK_STALE_MS = 30_000;
|
|
68
71
|
const TXN_LOCK_WAIT_MS = 15_000;
|
|
69
72
|
const TXN_LOCK_STEAL_GRACE_MS = 250;
|
|
70
73
|
const SHADOW_DIR = "shadow";
|
|
74
|
+
function rowsRev(rows, id) {
|
|
75
|
+
return rows[id]?.rev;
|
|
76
|
+
}
|
|
77
|
+
function rowsBinding(rows, id) {
|
|
78
|
+
const row = rows[id];
|
|
79
|
+
if (row === undefined || typeof row.scope !== "string" || typeof row.slug !== "string")
|
|
80
|
+
return undefined;
|
|
81
|
+
return { scope: row.scope, slug: row.slug };
|
|
82
|
+
}
|
|
83
|
+
function rowsEntries(rows) {
|
|
84
|
+
return Object.entries(rows);
|
|
85
|
+
}
|
|
86
|
+
function deleteRow(rows, id) {
|
|
87
|
+
delete rows[id];
|
|
88
|
+
}
|
|
89
|
+
function hasUnboundRows(rows) {
|
|
90
|
+
return rowsEntries(rows).some(([id]) => rowsBinding(rows, id) === undefined);
|
|
91
|
+
}
|
|
92
|
+
function unboundRowIds(rows) {
|
|
93
|
+
return rowsEntries(rows)
|
|
94
|
+
.filter(([id]) => rowsBinding(rows, id) === undefined)
|
|
95
|
+
.map(([id]) => id);
|
|
96
|
+
}
|
|
97
|
+
function bindingEquals(a, b) {
|
|
98
|
+
if (a === undefined || b === undefined)
|
|
99
|
+
return a === b;
|
|
100
|
+
return a.scope === b.scope && a.slug === b.slug;
|
|
101
|
+
}
|
|
102
|
+
function bindRowTracked(rows, id, next, transfers, channel) {
|
|
103
|
+
const old = rows[id];
|
|
104
|
+
const oldBinding = rowsBinding(rows, id);
|
|
105
|
+
const row = { ...(old ?? {}), rev: next.rev, scope: next.scope, slug: next.slug, at: next.at };
|
|
106
|
+
if (oldBinding !== undefined && !bindingEquals(oldBinding, { scope: next.scope, slug: next.slug })) {
|
|
107
|
+
row.prev = { scope: oldBinding.scope, slug: oldBinding.slug, at: next.at };
|
|
108
|
+
transfers.push({ ev: randomUUID(), channel, id, from: oldBinding, to: { scope: next.scope, slug: next.slug }, at: next.at });
|
|
109
|
+
}
|
|
110
|
+
else if (old !== undefined && oldBinding === undefined) {
|
|
111
|
+
transfers.push({ ev: randomUUID(), channel: "migration-bind", id, to: { scope: next.scope, slug: next.slug }, at: next.at });
|
|
112
|
+
}
|
|
113
|
+
rows[id] = row;
|
|
114
|
+
}
|
|
115
|
+
function cloneRows(rows) {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [id, row] of rowsEntries(rows))
|
|
118
|
+
out[id] = { ...row, ...(row.prev !== undefined ? { prev: { ...row.prev } } : {}) };
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function ledgerEnvelope(rows, extras) {
|
|
122
|
+
return { v: LEDGER_SCHEMA_VERSION, ...(extras ?? {}), rows };
|
|
123
|
+
}
|
|
124
|
+
function slugEscapes(slug) {
|
|
125
|
+
if (slug.length === 0 || slug.startsWith("/") || slug.includes("\\"))
|
|
126
|
+
return true;
|
|
127
|
+
return slug.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
|
|
128
|
+
}
|
|
129
|
+
function validateLedgerRowsV2(rowsRaw, path) {
|
|
130
|
+
if (!rowsRaw || typeof rowsRaw !== "object" || Array.isArray(rowsRaw)) {
|
|
131
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger rows have the wrong shape: ${path}`);
|
|
132
|
+
}
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const [id, rowRaw] of Object.entries(rowsRaw)) {
|
|
135
|
+
if (!rowRaw || typeof rowRaw !== "object" || Array.isArray(rowRaw)) {
|
|
136
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has the wrong shape: ${path}`);
|
|
137
|
+
}
|
|
138
|
+
const row = rowRaw;
|
|
139
|
+
if (typeof row.rev !== "string") {
|
|
140
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has no string rev: ${path}`);
|
|
141
|
+
}
|
|
142
|
+
const hasScope = row.scope !== undefined;
|
|
143
|
+
const hasSlug = row.slug !== undefined;
|
|
144
|
+
if (hasScope !== hasSlug) {
|
|
145
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} is half-bound (scope and slug must appear together): ${path}`);
|
|
146
|
+
}
|
|
147
|
+
if (hasScope && (typeof row.scope !== "string" || typeof row.slug !== "string")) {
|
|
148
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a non-string binding: ${path}`);
|
|
149
|
+
}
|
|
150
|
+
if (typeof row.slug === "string" && slugEscapes(row.slug)) {
|
|
151
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a path-escaping slug: ${path}`);
|
|
152
|
+
}
|
|
153
|
+
if (row.at !== undefined && (typeof row.at !== "number" || !Number.isInteger(row.at))) {
|
|
154
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a non-integer 'at': ${path}`);
|
|
155
|
+
}
|
|
156
|
+
if (row.prev !== undefined) {
|
|
157
|
+
const prev = row.prev;
|
|
158
|
+
if (!prev || typeof prev !== "object" || Array.isArray(prev) || typeof prev.scope !== "string" || typeof prev.slug !== "string" || typeof prev.at !== "number" || !Number.isInteger(prev.at)) {
|
|
159
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a malformed 'prev' trace: ${path}`);
|
|
160
|
+
}
|
|
161
|
+
if (slugEscapes(prev.slug)) {
|
|
162
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger row ${JSON.stringify(id)} has a path-escaping 'prev' slug: ${path}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
out[id] = { ...row };
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function parseLedgerText(raw, path) {
|
|
170
|
+
let parsed;
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(raw);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger is unparseable: ${path}`);
|
|
176
|
+
}
|
|
177
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
178
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger has the wrong shape: ${path}`);
|
|
179
|
+
}
|
|
180
|
+
const rec = parsed;
|
|
181
|
+
if ("v" in rec) {
|
|
182
|
+
const v = rec.v;
|
|
183
|
+
if (typeof v !== "number" || !Number.isInteger(v)) {
|
|
184
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger schema version is not an integer: ${path}`);
|
|
185
|
+
}
|
|
186
|
+
if (v > LEDGER_SCHEMA_VERSION) {
|
|
187
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger schema v${v} is newer than this engine supports (v${LEDGER_SCHEMA_VERSION}) — refusing to reinterpret it: ${path}`);
|
|
188
|
+
}
|
|
189
|
+
if (v !== LEDGER_SCHEMA_VERSION) {
|
|
190
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger has an unknown enveloped schema v${v}: ${path}`);
|
|
191
|
+
}
|
|
192
|
+
const rows = validateLedgerRowsV2(rec.rows, path);
|
|
193
|
+
const envelopeExtras = {};
|
|
194
|
+
for (const [k, val] of Object.entries(rec)) {
|
|
195
|
+
if (k !== "v" && k !== "rows")
|
|
196
|
+
envelopeExtras[k] = val;
|
|
197
|
+
}
|
|
198
|
+
return { form: "v2", rows, envelopeExtras };
|
|
199
|
+
}
|
|
200
|
+
const rows = {};
|
|
201
|
+
for (const [k, v] of Object.entries(rec)) {
|
|
202
|
+
if (typeof v !== "string") {
|
|
203
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger entry ${JSON.stringify(k)} is not a string: ${path}`);
|
|
204
|
+
}
|
|
205
|
+
rows[k] = { rev: v };
|
|
206
|
+
}
|
|
207
|
+
return { form: "v1", rows };
|
|
208
|
+
}
|
|
209
|
+
function transferEventInvalid(raw) {
|
|
210
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
211
|
+
return "event is not an object";
|
|
212
|
+
const e = raw;
|
|
213
|
+
if (typeof e.ev !== "string" || e.ev.length === 0)
|
|
214
|
+
return "missing event id 'ev'";
|
|
215
|
+
if (typeof e.at !== "number" || !Number.isInteger(e.at))
|
|
216
|
+
return "missing/non-integer 'at'";
|
|
217
|
+
const bindingInvalid = (b) => {
|
|
218
|
+
if (!b || typeof b !== "object" || Array.isArray(b))
|
|
219
|
+
return true;
|
|
220
|
+
const r = b;
|
|
221
|
+
return typeof r.scope !== "string" || typeof r.slug !== "string";
|
|
222
|
+
};
|
|
223
|
+
if (e.channel === "migration") {
|
|
224
|
+
if (typeof e.boundRows !== "number" || !Number.isInteger(e.boundRows))
|
|
225
|
+
return "migration summary missing integer 'boundRows'";
|
|
226
|
+
if (typeof e.unboundRows !== "number" || !Number.isInteger(e.unboundRows))
|
|
227
|
+
return "migration summary missing integer 'unboundRows'";
|
|
228
|
+
if (e.id !== undefined || e.from !== undefined || e.to !== undefined)
|
|
229
|
+
return "migration summary must not carry id/from/to";
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
if (e.channel === "adopted-move" || e.channel === "applyPatches-move" || e.channel === "migration-bind") {
|
|
233
|
+
if (typeof e.id !== "string" || e.id.length === 0)
|
|
234
|
+
return "row transfer missing 'id'";
|
|
235
|
+
if (bindingInvalid(e.to))
|
|
236
|
+
return "row transfer missing/malformed 'to'";
|
|
237
|
+
if (e.channel === "migration-bind") {
|
|
238
|
+
if (e.from !== undefined && bindingInvalid(e.from))
|
|
239
|
+
return "malformed 'from'";
|
|
240
|
+
}
|
|
241
|
+
else if (bindingInvalid(e.from)) {
|
|
242
|
+
return `'${e.channel}' requires a well-formed 'from'`;
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
return `unknown channel ${JSON.stringify(e.channel)}`;
|
|
247
|
+
}
|
|
248
|
+
function readControlFileOrAbsent(path, what) {
|
|
249
|
+
try {
|
|
250
|
+
return readFileSync(path, "utf8");
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
const code = err.code;
|
|
254
|
+
if (code === "ENOENT")
|
|
255
|
+
return undefined;
|
|
256
|
+
throw new ControlPlaneCorruptError(`${what} could not be read (${code ?? "io error"}) at ${path} — a read failure is not absence (fail-closed); fix the filesystem fault and retry`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function journalOpInvalid(raw, directoryRoot) {
|
|
260
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
261
|
+
return "op is not an object";
|
|
262
|
+
const op = raw;
|
|
263
|
+
if (typeof op.target !== "string" || op.target.length === 0)
|
|
264
|
+
return "missing string target";
|
|
265
|
+
if (op.staged !== undefined && typeof op.staged !== "string")
|
|
266
|
+
return "non-string staged";
|
|
267
|
+
switch (op.kind) {
|
|
268
|
+
case "write":
|
|
269
|
+
if (typeof op.content !== "string")
|
|
270
|
+
return "write op without full content";
|
|
271
|
+
if (!isContainedIn(directoryRoot, op.target))
|
|
272
|
+
return "write target escapes the store root";
|
|
273
|
+
return undefined;
|
|
274
|
+
case "delete":
|
|
275
|
+
if (op.content !== undefined)
|
|
276
|
+
return "delete op must not carry content";
|
|
277
|
+
if (!isContainedIn(directoryRoot, op.target))
|
|
278
|
+
return "delete target escapes the store root";
|
|
279
|
+
return undefined;
|
|
280
|
+
case "shadow-write":
|
|
281
|
+
if (typeof op.content !== "string")
|
|
282
|
+
return "shadow-write op without full content";
|
|
283
|
+
if (!isValidEntryId(op.target))
|
|
284
|
+
return "shadow target is not an entry id";
|
|
285
|
+
return undefined;
|
|
286
|
+
case "shadow-delete":
|
|
287
|
+
if (op.content !== undefined)
|
|
288
|
+
return "shadow-delete op must not carry content";
|
|
289
|
+
if (!isValidEntryId(op.target))
|
|
290
|
+
return "shadow target is not an entry id";
|
|
291
|
+
return undefined;
|
|
292
|
+
default:
|
|
293
|
+
return "unknown op kind";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function probeProjection(absPath, id) {
|
|
297
|
+
let text;
|
|
298
|
+
try {
|
|
299
|
+
text = readFileSync(absPath, "utf8");
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
return err.code === "ENOENT" ? "absent" : "undecidable";
|
|
303
|
+
}
|
|
304
|
+
return parseEntryFile(text).id === id ? "present" : "undecidable";
|
|
305
|
+
}
|
|
71
306
|
export class FileMemoryEngineBackend {
|
|
72
307
|
directoryRoot;
|
|
73
308
|
controlPlaneRoot;
|
|
74
309
|
now;
|
|
75
310
|
ledger;
|
|
311
|
+
envelopeExtras;
|
|
312
|
+
persistedSchemaVersion;
|
|
313
|
+
announcedV1Compat = false;
|
|
314
|
+
unboundRowsKnown = false;
|
|
315
|
+
adoptionNoticeKeys = new Set();
|
|
316
|
+
transfersAppendFault;
|
|
76
317
|
inboundFindings = [];
|
|
77
318
|
batchScan;
|
|
78
319
|
constructor(dir, opts = {}) {
|
|
@@ -84,12 +325,28 @@ export class FileMemoryEngineBackend {
|
|
|
84
325
|
ensureDirExists(dir);
|
|
85
326
|
ensureDirExists(this.controlPlaneRoot);
|
|
86
327
|
this.recoverJournal({ unlocked: true });
|
|
328
|
+
try {
|
|
329
|
+
this.loadLedger();
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
}
|
|
333
|
+
if (this.persistedSchemaVersion === "v1" || (this.ledger !== undefined && hasUnboundRows(this.ledger)))
|
|
334
|
+
this.tryOpportunisticMigrate();
|
|
87
335
|
}
|
|
88
336
|
checkControlPlane() {
|
|
89
337
|
this.recoverJournal({ unlocked: true });
|
|
90
338
|
this.ledger = undefined;
|
|
91
339
|
this.loadLedger();
|
|
92
340
|
registeredScopes(this.controlPlaneRoot);
|
|
341
|
+
if (this.persistedSchemaVersion === "v1" || hasUnboundRows(this.loadLedger()))
|
|
342
|
+
this.tryOpportunisticMigrate();
|
|
343
|
+
const rows = this.loadLedger();
|
|
344
|
+
if (this.persistedSchemaVersion === "v2" && hasUnboundRows(rows)) {
|
|
345
|
+
const ids = unboundRowIds(rows);
|
|
346
|
+
this.enqueueExternalItems([
|
|
347
|
+
`memory ledger has ${ids.length} unbound row(s) (no derivable committed projection): ${ids.slice(0, 8).join(", ")}${ids.length > 8 ? ", …" : ""} — v2 binding guarantees do not cover them until they converge (duplicate projections need an unrestricted harvest or manual removal)`,
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
93
350
|
}
|
|
94
351
|
drainInboundFindings() {
|
|
95
352
|
const out = this.inboundFindings;
|
|
@@ -106,56 +363,422 @@ export class FileMemoryEngineBackend {
|
|
|
106
363
|
if (this.ledger)
|
|
107
364
|
return this.ledger;
|
|
108
365
|
const path = join(this.controlPlaneRoot, LEDGER_FILE);
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
raw = readFileSync(path, "utf8");
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
raw = undefined;
|
|
115
|
-
}
|
|
366
|
+
const raw = readControlFileOrAbsent(path, "committed-rev ledger");
|
|
116
367
|
if (raw === undefined) {
|
|
117
368
|
this.ledger = {};
|
|
369
|
+
this.persistedSchemaVersion = "v2";
|
|
370
|
+
this.unboundRowsKnown = false;
|
|
118
371
|
return this.ledger;
|
|
119
372
|
}
|
|
120
|
-
|
|
373
|
+
const parsed = parseLedgerText(raw, path);
|
|
374
|
+
this.ledger = parsed.rows;
|
|
375
|
+
this.envelopeExtras = parsed.envelopeExtras;
|
|
376
|
+
this.persistedSchemaVersion = parsed.form;
|
|
377
|
+
this.unboundRowsKnown = hasUnboundRows(parsed.rows);
|
|
378
|
+
if (parsed.form === "v1" && !this.announcedV1Compat) {
|
|
379
|
+
this.announcedV1Compat = true;
|
|
380
|
+
this.enqueueExternalItems(["memory ledger v1-compat active — v2 binding guarantees not yet in force (the migration runs at the next uncontended entry)"]);
|
|
381
|
+
}
|
|
382
|
+
return this.ledger;
|
|
383
|
+
}
|
|
384
|
+
saveLedger() {
|
|
385
|
+
if (!this.ledger)
|
|
386
|
+
return;
|
|
387
|
+
if (this.persistedSchemaVersion !== "v2") {
|
|
388
|
+
throw new ControlPlaneCorruptError("committed-rev ledger write refused: the on-disk ledger is still schema v1 and the migration has not run (mutations must migrate first)");
|
|
389
|
+
}
|
|
390
|
+
atomicWriteFileSync(join(this.controlPlaneRoot, LEDGER_FILE), `${JSON.stringify(ledgerEnvelope(this.ledger, this.envelopeExtras), null, 2)}\n`);
|
|
391
|
+
this.unboundRowsKnown = hasUnboundRows(this.ledger);
|
|
392
|
+
}
|
|
393
|
+
persistLedgerV1(rows) {
|
|
394
|
+
const map = {};
|
|
395
|
+
for (const [id, row] of rowsEntries(rows))
|
|
396
|
+
map[id] = row.rev;
|
|
397
|
+
atomicWriteFileSync(join(this.controlPlaneRoot, LEDGER_FILE), `${JSON.stringify(map, null, 2)}\n`);
|
|
398
|
+
this.unboundRowsKnown = Object.keys(map).length > 0;
|
|
399
|
+
}
|
|
400
|
+
enqueueExternalItems(items) {
|
|
401
|
+
if (items.length === 0)
|
|
402
|
+
return;
|
|
121
403
|
try {
|
|
122
|
-
|
|
404
|
+
enqueueMemoryAnnouncement(this.controlPlaneRoot, { kind: "external", at: this.now(), items });
|
|
123
405
|
}
|
|
124
406
|
catch {
|
|
125
|
-
throw new ControlPlaneCorruptError(`committed-rev ledger is unparseable: ${path}`);
|
|
126
407
|
}
|
|
127
|
-
|
|
128
|
-
|
|
408
|
+
}
|
|
409
|
+
announceAdoptionNotice(key, item) {
|
|
410
|
+
if (this.adoptionNoticeKeys.has(key))
|
|
411
|
+
return;
|
|
412
|
+
this.adoptionNoticeKeys.add(key);
|
|
413
|
+
this.enqueueExternalItems([item]);
|
|
414
|
+
}
|
|
415
|
+
bindingAbsPath(binding) {
|
|
416
|
+
return join(scopeDirFor(this.directoryRoot, this.controlPlaneRoot, binding.scope), `${binding.slug}.md`);
|
|
417
|
+
}
|
|
418
|
+
bindingRelPath(binding) {
|
|
419
|
+
return relative(this.directoryRoot, this.bindingAbsPath(binding));
|
|
420
|
+
}
|
|
421
|
+
censusProjectionsLocked() {
|
|
422
|
+
let complete = true;
|
|
423
|
+
const byId = new Map();
|
|
424
|
+
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
425
|
+
const dir = this.scopeDir(scope);
|
|
426
|
+
const isRoot = dir === this.directoryRoot;
|
|
427
|
+
const exclude = isRoot ? this.excludedSubdirNames(scope) : undefined;
|
|
428
|
+
const walk = (d, depth) => {
|
|
429
|
+
let names;
|
|
430
|
+
try {
|
|
431
|
+
names = readdirSync(d);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
if (err.code === "ENOENT" && depth === 0)
|
|
435
|
+
return;
|
|
436
|
+
complete = false;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
for (const name of names.sort()) {
|
|
440
|
+
if (name.startsWith(".") || name === MEMORY_INDEX_FILENAME)
|
|
441
|
+
continue;
|
|
442
|
+
const p = join(d, name);
|
|
443
|
+
let st;
|
|
444
|
+
try {
|
|
445
|
+
st = lstatSync(p);
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
complete = false;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (st.isSymbolicLink())
|
|
452
|
+
continue;
|
|
453
|
+
if (st.isDirectory()) {
|
|
454
|
+
if (depth === 0 && exclude?.has(name))
|
|
455
|
+
continue;
|
|
456
|
+
if (depth + 1 <= DEFAULT_MAX_ENTRY_DEPTH)
|
|
457
|
+
walk(p, depth + 1);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (!st.isFile() || !name.endsWith(".md"))
|
|
461
|
+
continue;
|
|
462
|
+
let text;
|
|
463
|
+
try {
|
|
464
|
+
text = readFileSync(p, "utf8");
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
complete = false;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const id = parseEntryFile(text).id;
|
|
471
|
+
if (id === undefined)
|
|
472
|
+
continue;
|
|
473
|
+
const list = byId.get(id) ?? [];
|
|
474
|
+
list.push({ scope, slug: relative(dir, p).replace(/\.md$/, "") });
|
|
475
|
+
byId.set(id, list);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
walk(dir, 0);
|
|
479
|
+
}
|
|
480
|
+
return { complete, byId };
|
|
481
|
+
}
|
|
482
|
+
writeV1BackupOnce() {
|
|
483
|
+
const raw = readControlFileOrAbsent(join(this.controlPlaneRoot, LEDGER_FILE), "committed-rev ledger");
|
|
484
|
+
if (raw === undefined)
|
|
485
|
+
throw new ControlPlaneCorruptError(`committed-rev ledger vanished under the migration lock: ${join(this.controlPlaneRoot, LEDGER_FILE)}`);
|
|
486
|
+
const bak = join(this.controlPlaneRoot, LEDGER_V1_BACKUP_FILE);
|
|
487
|
+
let fd;
|
|
488
|
+
try {
|
|
489
|
+
fd = openSync(bak, "wx", 0o600);
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
if (err.code === "EEXIST")
|
|
493
|
+
return;
|
|
494
|
+
throw err;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
writeAllSync(fd, raw);
|
|
498
|
+
fsyncSync(fd);
|
|
129
499
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
throw new ControlPlaneCorruptError(`committed-rev ledger entry ${JSON.stringify(k)} is not a string: ${path}`);
|
|
500
|
+
finally {
|
|
501
|
+
closeSync(fd);
|
|
133
502
|
}
|
|
134
|
-
this.ledger = parsed;
|
|
135
|
-
return this.ledger;
|
|
136
503
|
}
|
|
137
|
-
|
|
138
|
-
|
|
504
|
+
tryAcquireTxnLockSync() {
|
|
505
|
+
const lockDir = this.txnLockDir();
|
|
506
|
+
const ownerPath = join(lockDir, "owner");
|
|
507
|
+
const tryMkdir = () => {
|
|
508
|
+
try {
|
|
509
|
+
mkdirSync(this.controlPlaneRoot, { recursive: true });
|
|
510
|
+
mkdirSync(lockDir);
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
let acquired = tryMkdir();
|
|
518
|
+
if (!acquired) {
|
|
519
|
+
let mtimeMs;
|
|
520
|
+
try {
|
|
521
|
+
mtimeMs = statSync(lockDir).mtimeMs;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
mtimeMs = undefined;
|
|
525
|
+
}
|
|
526
|
+
if (mtimeMs !== undefined && this.now() - mtimeMs > TXN_LOCK_STALE_MS) {
|
|
527
|
+
let tokenPresent = false;
|
|
528
|
+
try {
|
|
529
|
+
statSync(ownerPath);
|
|
530
|
+
tokenPresent = true;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
}
|
|
534
|
+
if (tokenPresent) {
|
|
535
|
+
const tomb = join(this.controlPlaneRoot, `txn.lock.stale-${randomUUID()}`);
|
|
536
|
+
let stolen = false;
|
|
537
|
+
try {
|
|
538
|
+
renameSync(lockDir, tomb);
|
|
539
|
+
stolen = true;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
}
|
|
543
|
+
if (stolen) {
|
|
544
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
545
|
+
acquired = tryMkdir();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (!acquired)
|
|
550
|
+
return undefined;
|
|
551
|
+
}
|
|
552
|
+
const token = randomUUID();
|
|
553
|
+
try {
|
|
554
|
+
const fd = openSync(ownerPath, "w", 0o600);
|
|
555
|
+
try {
|
|
556
|
+
writeAllSync(fd, token);
|
|
557
|
+
fsyncSync(fd);
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
closeSync(fd);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch (err) {
|
|
564
|
+
try {
|
|
565
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
}
|
|
569
|
+
throw new Error(`memory txn lock owner token could not be written at ${ownerPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
token,
|
|
573
|
+
release: () => {
|
|
574
|
+
try {
|
|
575
|
+
if (readFileSync(ownerPath, "utf8") !== token)
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
catch {
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
tryOpportunisticMigrate() {
|
|
590
|
+
const lock = this.tryAcquireTxnLockSync();
|
|
591
|
+
if (lock === undefined)
|
|
592
|
+
return;
|
|
593
|
+
try {
|
|
594
|
+
this.recoverJournal();
|
|
595
|
+
this.migrateLockedIfNeeded(lock.token);
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
lock.release();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
migrateLockedIfNeeded(lockToken) {
|
|
602
|
+
this.ledger = undefined;
|
|
603
|
+
const rows = this.loadLedger();
|
|
604
|
+
if (this.persistedSchemaVersion === "v2" && !hasUnboundRows(rows))
|
|
139
605
|
return;
|
|
140
|
-
|
|
606
|
+
const fullMigration = this.persistedSchemaVersion === "v1";
|
|
607
|
+
const census = this.censusProjectionsLocked();
|
|
608
|
+
if (!census.complete) {
|
|
609
|
+
this.announceAdoptionNotice("migration-incomplete", `memory ledger ${fullMigration ? "v1→v2 migration" : "unbound-row convergence"} abandoned this pass — the projection census could not read every scope (bindings derived from a partial view could be wrong); it retries at the next entry`);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const now = this.now();
|
|
613
|
+
const next = {};
|
|
614
|
+
const bound = [];
|
|
615
|
+
const unbound = [];
|
|
616
|
+
const events = [];
|
|
617
|
+
for (const [id, row] of rowsEntries(rows)) {
|
|
618
|
+
if (!fullMigration && rowsBinding(rows, id) !== undefined) {
|
|
619
|
+
next[id] = { ...row };
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const projections = census.byId.get(id) ?? [];
|
|
623
|
+
const sole = projections[0];
|
|
624
|
+
if (projections.length === 1 && sole !== undefined) {
|
|
625
|
+
next[id] = { ...row, scope: sole.scope, slug: sole.slug };
|
|
626
|
+
bound.push(id);
|
|
627
|
+
if (!fullMigration)
|
|
628
|
+
events.push({ ev: randomUUID(), channel: "migration-bind", id, to: sole, at: now });
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
next[id] = { ...row };
|
|
632
|
+
unbound.push(`${id} (${projections.length} projection(s))`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
if (fullMigration) {
|
|
636
|
+
this.writeV1BackupOnce();
|
|
637
|
+
events.push({ ev: randomUUID(), channel: "migration", boundRows: bound.length, unboundRows: unbound.length, at: now });
|
|
638
|
+
}
|
|
639
|
+
else if (bound.length === 0) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
this.assertTxnLockOwnership(lockToken);
|
|
643
|
+
this.precheckTransfersAppendable();
|
|
644
|
+
const journal = { txn: `${Date.now()}-migrate`, ops: [], ledger2: ledgerEnvelope(next, this.envelopeExtras), transfers: events };
|
|
645
|
+
atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
|
|
646
|
+
this.ledger = next;
|
|
647
|
+
this.persistedSchemaVersion = "v2";
|
|
648
|
+
this.saveLedger();
|
|
649
|
+
this.appendTransfers(events);
|
|
650
|
+
rmSync(join(this.controlPlaneRoot, JOURNAL_FILE), { force: true });
|
|
651
|
+
this.enqueueExternalItems([
|
|
652
|
+
fullMigration
|
|
653
|
+
? `memory ledger upgraded to v2 (${bound.length} row(s) bound, ${unbound.length} unbound${unbound.length > 0 ? `: ${unbound.slice(0, 8).join(", ")}${unbound.length > 8 ? ", …" : ""}` : ""})`
|
|
654
|
+
: `memory ledger unbound row(s) converged to bound: ${bound.slice(0, 8).join(", ")}${bound.length > 8 ? ", …" : ""}`,
|
|
655
|
+
]);
|
|
656
|
+
}
|
|
657
|
+
readTransferEvs() {
|
|
658
|
+
const path = join(this.controlPlaneRoot, TRANSFERS_FILE);
|
|
659
|
+
const raw = readControlFileOrAbsent(path, "transfer evidence log");
|
|
660
|
+
const evs = new Set();
|
|
661
|
+
if (raw === undefined)
|
|
662
|
+
return evs;
|
|
663
|
+
const lines = raw.split("\n");
|
|
664
|
+
for (let i = 0; i < lines.length; i++) {
|
|
665
|
+
const line = lines[i];
|
|
666
|
+
if (line === undefined || line === "")
|
|
667
|
+
continue;
|
|
668
|
+
let parsed;
|
|
669
|
+
try {
|
|
670
|
+
parsed = JSON.parse(line);
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
const isTail = lines.slice(i + 1).every((l) => l === "");
|
|
674
|
+
if (isTail) {
|
|
675
|
+
try {
|
|
676
|
+
ensureDirExists(join(this.controlPlaneRoot, QUARANTINE_DIR));
|
|
677
|
+
atomicWriteFileSync(join(this.controlPlaneRoot, QUARANTINE_DIR, `transfers-torn-tail-${this.now()}.fragment`), line);
|
|
678
|
+
atomicWriteFileSync(path, lines.slice(0, i).join("\n") + (i > 0 ? "\n" : ""));
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
throw new ControlPlaneCorruptError(`transfer evidence log has a torn tail that could not be quarantined: ${path}`);
|
|
682
|
+
}
|
|
683
|
+
this.enqueueExternalItems([`transfer evidence log had a torn tail (crash mid-append) — the fragment is quarantined and the sound prefix stands: ${TRANSFERS_FILE}`]);
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is unparseable (not a torn tail — fail-closed): ${path}`);
|
|
687
|
+
}
|
|
688
|
+
const invalid = transferEventInvalid(parsed);
|
|
689
|
+
if (invalid !== undefined)
|
|
690
|
+
throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is invalid (${invalid}): ${path}`);
|
|
691
|
+
evs.add(parsed.ev);
|
|
692
|
+
}
|
|
693
|
+
return evs;
|
|
694
|
+
}
|
|
695
|
+
precheckTransfersAppendable() {
|
|
696
|
+
this.readTransferEvs();
|
|
697
|
+
const fd = openSync(join(this.controlPlaneRoot, TRANSFERS_FILE), "a", 0o600);
|
|
698
|
+
closeSync(fd);
|
|
699
|
+
}
|
|
700
|
+
appendTransfers(events) {
|
|
701
|
+
if (events.length === 0)
|
|
702
|
+
return;
|
|
703
|
+
this.transfersAppendFault?.();
|
|
704
|
+
const existing = this.readTransferEvs();
|
|
705
|
+
const missing = events.filter((e) => !existing.has(e.ev));
|
|
706
|
+
if (missing.length === 0)
|
|
707
|
+
return;
|
|
708
|
+
const fd = openSync(join(this.controlPlaneRoot, TRANSFERS_FILE), "a", 0o600);
|
|
709
|
+
try {
|
|
710
|
+
writeAllSync(fd, missing.map((e) => `${JSON.stringify(e)}\n`).join(""));
|
|
711
|
+
fsyncSync(fd);
|
|
712
|
+
}
|
|
713
|
+
finally {
|
|
714
|
+
closeSync(fd);
|
|
715
|
+
}
|
|
141
716
|
}
|
|
142
717
|
recoverJournal(opts = {}) {
|
|
143
718
|
const jp = join(this.controlPlaneRoot, JOURNAL_FILE);
|
|
144
719
|
if (opts.unlocked && this.txnInFlight())
|
|
145
720
|
return;
|
|
146
|
-
const raw =
|
|
721
|
+
const raw = readControlFileOrAbsent(jp, "transaction journal");
|
|
147
722
|
if (raw !== undefined) {
|
|
148
723
|
let journal;
|
|
149
724
|
try {
|
|
150
725
|
const parsed = JSON.parse(raw);
|
|
151
|
-
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops)
|
|
726
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ops))
|
|
152
727
|
throw new Error("wrong shape");
|
|
153
|
-
}
|
|
154
728
|
journal = parsed;
|
|
155
729
|
}
|
|
156
730
|
catch {
|
|
157
731
|
throw new ControlPlaneCorruptError(`transaction journal is unparseable: ${jp}`);
|
|
158
732
|
}
|
|
733
|
+
const hasV2 = journal.ledger2 !== undefined;
|
|
734
|
+
const hasV1 = journal.ledger !== undefined;
|
|
735
|
+
if (hasV2 === hasV1) {
|
|
736
|
+
throw new ControlPlaneCorruptError(`transaction journal has ${hasV2 ? "both ledger snapshots" : "no ledger snapshot"} (wrong shape): ${jp}`);
|
|
737
|
+
}
|
|
738
|
+
let snapshotRows;
|
|
739
|
+
let snapshotExtras;
|
|
740
|
+
if (hasV2) {
|
|
741
|
+
const env = journal.ledger2;
|
|
742
|
+
if (!env || typeof env !== "object" || Array.isArray(env) || env.v !== LEDGER_SCHEMA_VERSION) {
|
|
743
|
+
throw new ControlPlaneCorruptError(`transaction journal ledger2 snapshot has the wrong shape: ${jp}`);
|
|
744
|
+
}
|
|
745
|
+
snapshotRows = validateLedgerRowsV2(env.rows, jp);
|
|
746
|
+
snapshotExtras = {};
|
|
747
|
+
for (const [k, val] of Object.entries(env))
|
|
748
|
+
if (k !== "v" && k !== "rows")
|
|
749
|
+
snapshotExtras[k] = val;
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
const map = journal.ledger;
|
|
753
|
+
if (!map || typeof map !== "object" || Array.isArray(map))
|
|
754
|
+
throw new ControlPlaneCorruptError(`transaction journal ledger snapshot has the wrong shape: ${jp}`);
|
|
755
|
+
snapshotRows = {};
|
|
756
|
+
for (const [id, rev] of Object.entries(map)) {
|
|
757
|
+
if (typeof rev !== "string")
|
|
758
|
+
throw new ControlPlaneCorruptError(`transaction journal v1 ledger snapshot entry ${JSON.stringify(id)} is not a string: ${jp}`);
|
|
759
|
+
snapshotRows[id] = { rev };
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
for (const op of journal.ops) {
|
|
763
|
+
const invalid = journalOpInvalid(op, this.directoryRoot);
|
|
764
|
+
if (invalid !== undefined) {
|
|
765
|
+
throw new ControlPlaneCorruptError(`transaction journal op is invalid (${invalid}; kind=${JSON.stringify(op.kind)} target=${JSON.stringify(op.target)}) — the whole journal is refused and kept at ${jp} (control data is malformed: repair via the explicit control-plane rebuild path, not a replay guess)`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (journal.transfers !== undefined) {
|
|
769
|
+
if (!Array.isArray(journal.transfers))
|
|
770
|
+
throw new ControlPlaneCorruptError(`transaction journal transfers is not an array: ${jp}`);
|
|
771
|
+
for (const e of journal.transfers) {
|
|
772
|
+
const invalid = transferEventInvalid(e);
|
|
773
|
+
if (invalid !== undefined)
|
|
774
|
+
throw new ControlPlaneCorruptError(`transaction journal transfer event is invalid (${invalid}): ${jp}`);
|
|
775
|
+
if (e.channel !== "migration" && !bindingEquals(rowsBinding(snapshotRows, e.id), e.to)) {
|
|
776
|
+
throw new ControlPlaneCorruptError(`transaction journal transfer event for ${JSON.stringify(e.id)} disagrees with the snapshot binding: ${jp}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (hasV1)
|
|
780
|
+
throw new ControlPlaneCorruptError(`transaction journal carries transfers with a v1 snapshot (impossible form): ${jp}`);
|
|
781
|
+
}
|
|
159
782
|
for (const op of journal.ops) {
|
|
160
783
|
try {
|
|
161
784
|
if (op.kind === "write" && op.content !== undefined) {
|
|
@@ -173,11 +796,23 @@ export class FileMemoryEngineBackend {
|
|
|
173
796
|
rmSync(this.shadowPath(op.target), { force: true });
|
|
174
797
|
}
|
|
175
798
|
}
|
|
176
|
-
catch {
|
|
799
|
+
catch (err) {
|
|
800
|
+
const code = err.code;
|
|
801
|
+
throw new ControlPlaneCorruptError(`transaction journal replay failed on op kind=${op.kind} target=${JSON.stringify(op.target)} (${code ?? (err instanceof Error ? err.message : String(err))}) — the journal is kept at ${jp}; an I/O failure retries at the next construction, a persistent fault needs the explicit control-plane rebuild path`);
|
|
177
802
|
}
|
|
178
803
|
}
|
|
179
|
-
|
|
180
|
-
|
|
804
|
+
if (hasV2) {
|
|
805
|
+
this.ledger = snapshotRows;
|
|
806
|
+
this.envelopeExtras = snapshotExtras;
|
|
807
|
+
this.persistedSchemaVersion = "v2";
|
|
808
|
+
this.saveLedger();
|
|
809
|
+
this.appendTransfers(journal.transfers ?? []);
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
this.ledger = snapshotRows;
|
|
813
|
+
this.persistedSchemaVersion = "v1";
|
|
814
|
+
this.persistLedgerV1(snapshotRows);
|
|
815
|
+
}
|
|
181
816
|
rmSync(jp, { force: true });
|
|
182
817
|
}
|
|
183
818
|
this.sweepStagedFiles();
|
|
@@ -186,8 +821,11 @@ export class FileMemoryEngineBackend {
|
|
|
186
821
|
try {
|
|
187
822
|
return this.now() - statSync(this.txnLockDir()).mtimeMs < TXN_LOCK_STALE_MS;
|
|
188
823
|
}
|
|
189
|
-
catch {
|
|
190
|
-
|
|
824
|
+
catch (err) {
|
|
825
|
+
const code = err.code;
|
|
826
|
+
if (code === "ENOENT")
|
|
827
|
+
return false;
|
|
828
|
+
throw new ControlPlaneCorruptError(`txn lock state could not be probed (${code ?? "io error"}) at ${this.txnLockDir()} — a probe failure is not "no writer" (fail-closed)`);
|
|
191
829
|
}
|
|
192
830
|
}
|
|
193
831
|
sweepStagedFiles() {
|
|
@@ -269,13 +907,17 @@ export class FileMemoryEngineBackend {
|
|
|
269
907
|
if (memo !== undefined)
|
|
270
908
|
return memo;
|
|
271
909
|
}
|
|
910
|
+
if (this.persistedSchemaVersion === "v1" || this.unboundRowsKnown)
|
|
911
|
+
this.tryOpportunisticMigrate();
|
|
272
912
|
const dir = this.scopeDir(scope);
|
|
273
913
|
const isRoot = dir === this.directoryRoot;
|
|
274
914
|
const files = scanEntryFiles(dir, { exclude: isRoot ? this.excludedSubdirNames(scope) : undefined });
|
|
275
915
|
const entries = [];
|
|
276
|
-
const
|
|
916
|
+
const rows = sync ? this.loadLedger() : undefined;
|
|
277
917
|
let ledgerChanged = false;
|
|
278
918
|
const adoptedExternal = [];
|
|
919
|
+
const moveEvents = [];
|
|
920
|
+
const moveAnnouncements = [];
|
|
279
921
|
const skippedUnreadable = [];
|
|
280
922
|
for (const f of files) {
|
|
281
923
|
let text;
|
|
@@ -290,35 +932,44 @@ export class FileMemoryEngineBackend {
|
|
|
290
932
|
if (parsed.id === undefined)
|
|
291
933
|
continue;
|
|
292
934
|
let entry = entryFromFile(text, parsed.id, f.slug, scope);
|
|
293
|
-
if (
|
|
294
|
-
const committed =
|
|
935
|
+
if (rows) {
|
|
936
|
+
const committed = rowsRev(rows, entry.id);
|
|
937
|
+
const binding = rowsBinding(rows, entry.id);
|
|
938
|
+
const relPath = relative(this.directoryRoot, f.path);
|
|
939
|
+
if (committed !== undefined && binding !== undefined && !(binding.scope === scope && binding.slug === f.slug)) {
|
|
940
|
+
const probe = probeProjection(this.bindingAbsPath(binding), entry.id);
|
|
941
|
+
if (probe === "present") {
|
|
942
|
+
this.announceAdoptionNotice(`copy|${relPath}|${entry.rev}`, `duplicate projection of committed entry ${entry.id} at ${JSON.stringify(inlineUntrusted(relPath))} — the committed projection lives at ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))}; the copy is not served (a copy is not a scope credential) and stays on disk for an unrestricted harvest or manual removal to adjudicate`);
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (probe === "undecidable") {
|
|
946
|
+
this.announceAdoptionNotice(`bindprobe|${relPath}`, `committed entry ${entry.id} appears at ${JSON.stringify(inlineUntrusted(relPath))} but is bound to ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))}, and the bound projection could not be probed conclusively (a read failure, or a foreign file at that address — not ENOENT) — nothing is served or adopted this pass`);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (probeOnly)
|
|
950
|
+
return undefined;
|
|
951
|
+
if (lockToken !== undefined)
|
|
952
|
+
this.assertTxnLockOwnership(lockToken);
|
|
953
|
+
const adopted = this.adoptMoveCandidateLocked(rows, entry, f.path, f.slug, scope, relPath, text, committed, binding, moveEvents, moveAnnouncements, adoptedExternal);
|
|
954
|
+
if (adopted !== undefined) {
|
|
955
|
+
entries.push(adopted);
|
|
956
|
+
ledgerChanged = true;
|
|
957
|
+
}
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
295
960
|
if (committed !== entry.rev) {
|
|
296
961
|
if (probeOnly)
|
|
297
962
|
return undefined;
|
|
298
963
|
if (lockToken !== undefined)
|
|
299
964
|
this.assertTxnLockOwnership(lockToken);
|
|
300
|
-
const relPath = relative(this.directoryRoot, f.path);
|
|
301
965
|
let finding = this.inboundGate(relPath, text);
|
|
302
|
-
if (finding === undefined && committed !== undefined)
|
|
303
|
-
|
|
304
|
-
const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
|
|
305
|
-
if (committedFm?.provenance?.kind === "repo_file") {
|
|
306
|
-
const stripsProvenance = entry.frontmatter.provenance === undefined;
|
|
307
|
-
const stripsTrust = committedFm.trust !== undefined && entry.frontmatter.trust === undefined;
|
|
308
|
-
if (stripsProvenance || stripsTrust) {
|
|
309
|
-
finding = {
|
|
310
|
-
path: relPath,
|
|
311
|
-
code: "invalid",
|
|
312
|
-
reason: `inbound memory change blocked: repo_file provenance whitewash refused — the on-disk change drops ${stripsProvenance ? "provenance" : "trust"} from a repo-ingested entry (quarantined; committed content restored)`,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
966
|
+
if (finding === undefined && committed !== undefined)
|
|
967
|
+
finding = this.whitewashInboundFinding(relPath, entry, "restored");
|
|
317
968
|
if (finding) {
|
|
318
969
|
const shadowText = this.containInboundReject(finding, f.path, text, entry.id, committed);
|
|
319
970
|
if (shadowText === undefined) {
|
|
320
971
|
if (committed !== undefined) {
|
|
321
|
-
|
|
972
|
+
deleteRow(rows, entry.id);
|
|
322
973
|
ledgerChanged = true;
|
|
323
974
|
}
|
|
324
975
|
continue;
|
|
@@ -327,8 +978,8 @@ export class FileMemoryEngineBackend {
|
|
|
327
978
|
}
|
|
328
979
|
else {
|
|
329
980
|
if (committed !== undefined)
|
|
330
|
-
adoptedExternal.push(
|
|
331
|
-
|
|
981
|
+
adoptedExternal.push(relPath);
|
|
982
|
+
bindRowTracked(rows, entry.id, { rev: entry.rev, scope, slug: f.slug, at: this.now() }, moveEvents, "adopted-move");
|
|
332
983
|
try {
|
|
333
984
|
atomicWriteFileSync(this.shadowPath(entry.id), text);
|
|
334
985
|
}
|
|
@@ -350,7 +1001,25 @@ export class FileMemoryEngineBackend {
|
|
|
350
1001
|
if (ledgerChanged) {
|
|
351
1002
|
if (lockToken !== undefined)
|
|
352
1003
|
this.assertTxnLockOwnership(lockToken);
|
|
353
|
-
|
|
1004
|
+
if (moveEvents.length > 0) {
|
|
1005
|
+
this.precheckTransfersAppendable();
|
|
1006
|
+
const journal = {
|
|
1007
|
+
txn: `${Date.now()}-${randomUUID().slice(0, 8)}`,
|
|
1008
|
+
ops: [],
|
|
1009
|
+
ledger2: ledgerEnvelope(rows, this.envelopeExtras),
|
|
1010
|
+
transfers: moveEvents,
|
|
1011
|
+
};
|
|
1012
|
+
atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
|
|
1013
|
+
this.saveLedger();
|
|
1014
|
+
this.appendTransfers(moveEvents);
|
|
1015
|
+
rmSync(join(this.controlPlaneRoot, JOURNAL_FILE), { force: true });
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
this.saveLedger();
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (moveAnnouncements.length > 0) {
|
|
1022
|
+
this.enqueueExternalItems(moveAnnouncements.slice(0, 12).concat(moveAnnouncements.length > 12 ? [`…and ${moveAnnouncements.length - 12} more adopted move(s)`] : []));
|
|
354
1023
|
}
|
|
355
1024
|
if (adoptedExternal.length > 0) {
|
|
356
1025
|
try {
|
|
@@ -384,6 +1053,58 @@ export class FileMemoryEngineBackend {
|
|
|
384
1053
|
this.batchScan.set(scope, entries);
|
|
385
1054
|
return entries;
|
|
386
1055
|
}
|
|
1056
|
+
whitewashInboundFinding(relPath, entry, containment) {
|
|
1057
|
+
const committedShadow = this.readCommittedShadow(entry.id);
|
|
1058
|
+
const committedFm = committedShadow !== undefined ? parseEntryFile(committedShadow).frontmatter : undefined;
|
|
1059
|
+
if (committedFm?.provenance?.kind !== "repo_file")
|
|
1060
|
+
return undefined;
|
|
1061
|
+
const stripsProvenance = entry.frontmatter.provenance === undefined;
|
|
1062
|
+
const stripsTrust = committedFm.trust !== undefined && entry.frontmatter.trust === undefined;
|
|
1063
|
+
if (!stripsProvenance && !stripsTrust)
|
|
1064
|
+
return undefined;
|
|
1065
|
+
return {
|
|
1066
|
+
path: relPath,
|
|
1067
|
+
code: "invalid",
|
|
1068
|
+
reason: `inbound memory change blocked: repo_file provenance whitewash refused — the on-disk change drops ${stripsProvenance ? "provenance" : "trust"} from a repo-ingested entry ${containment === "restored" ? "(quarantined; committed content restored)" : "(quarantined)"}`,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
adoptMoveCandidateLocked(rows, entry, absPath, slug, scope, relPath, text, committedRev, from, moveEvents, moveAnnouncements, adoptedExternal) {
|
|
1072
|
+
const census = this.censusProjectionsLocked();
|
|
1073
|
+
if (!census.complete) {
|
|
1074
|
+
this.inboundFindings.push({
|
|
1075
|
+
path: relPath,
|
|
1076
|
+
code: "unreadable",
|
|
1077
|
+
reason: `move adoption deferred: committed entry ${entry.id} lost its committed projection, but the store-wide projection census could not read every scope — nothing is served or adopted off a partial view; the next pass retries`,
|
|
1078
|
+
});
|
|
1079
|
+
return undefined;
|
|
1080
|
+
}
|
|
1081
|
+
const projections = census.byId.get(entry.id) ?? [];
|
|
1082
|
+
const others = projections.filter((p) => !(p.scope === scope && p.slug === slug));
|
|
1083
|
+
if (others.length > 0) {
|
|
1084
|
+
this.announceAdoptionNotice(`ambiguous|${entry.id}|${projections
|
|
1085
|
+
.map((p) => `${p.scope}/${p.slug}`)
|
|
1086
|
+
.sort()
|
|
1087
|
+
.join(",")}`, `committed entry ${entry.id} lost its committed projection and now appears at ${projections.length} path(s) — no copy is a scope credential, so none is served or adopted (read order never picks an owner); an unrestricted harvest or manual removal adjudicates which is real`);
|
|
1088
|
+
return undefined;
|
|
1089
|
+
}
|
|
1090
|
+
const gateFinding = this.inboundGate(relPath, text) ?? (committedRev !== entry.rev ? this.whitewashInboundFinding(relPath, entry, "quarantined-only") : undefined);
|
|
1091
|
+
if (gateFinding !== undefined) {
|
|
1092
|
+
this.containInboundReject(gateFinding, absPath, text, entry.id, undefined);
|
|
1093
|
+
return undefined;
|
|
1094
|
+
}
|
|
1095
|
+
const now = this.now();
|
|
1096
|
+
bindRowTracked(rows, entry.id, { rev: entry.rev, scope, slug, at: now }, moveEvents, "adopted-move");
|
|
1097
|
+
moveAnnouncements.push(`out-of-session memory move adopted: ${entry.id} ${JSON.stringify(inlineUntrusted(this.bindingRelPath(from)))} → ${JSON.stringify(inlineUntrusted(relPath))} — re-read it before relying on prior knowledge of it`);
|
|
1098
|
+
if (committedRev !== entry.rev) {
|
|
1099
|
+
adoptedExternal.push(relPath);
|
|
1100
|
+
try {
|
|
1101
|
+
atomicWriteFileSync(this.shadowPath(entry.id), text);
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return entry;
|
|
1107
|
+
}
|
|
387
1108
|
async readScopeAdopting(scope, timings) {
|
|
388
1109
|
const probed = this.readScope(scope, true, true);
|
|
389
1110
|
if (probed !== undefined)
|
|
@@ -393,6 +1114,7 @@ export class FileMemoryEngineBackend {
|
|
|
393
1114
|
this.assertTxnLockOwnership(lock.token);
|
|
394
1115
|
this.ledger = undefined;
|
|
395
1116
|
this.recoverJournal();
|
|
1117
|
+
this.migrateLockedIfNeeded(lock.token);
|
|
396
1118
|
return this.readScope(scope, true, false, lock.token);
|
|
397
1119
|
}
|
|
398
1120
|
catch (err) {
|
|
@@ -453,6 +1175,8 @@ export class FileMemoryEngineBackend {
|
|
|
453
1175
|
restrictedIdlessScope;
|
|
454
1176
|
restrictedIdlessBaseline;
|
|
455
1177
|
readScopeCommitted(scope, audit) {
|
|
1178
|
+
if (this.persistedSchemaVersion === "v1" || this.unboundRowsKnown)
|
|
1179
|
+
this.tryOpportunisticMigrate();
|
|
456
1180
|
const dir = this.scopeDir(scope);
|
|
457
1181
|
const isRoot = dir === this.directoryRoot;
|
|
458
1182
|
const files = scanEntryFiles(dir, {
|
|
@@ -471,7 +1195,7 @@ export class FileMemoryEngineBackend {
|
|
|
471
1195
|
: undefined,
|
|
472
1196
|
});
|
|
473
1197
|
this.ledger = undefined;
|
|
474
|
-
const
|
|
1198
|
+
const rows = this.loadLedger();
|
|
475
1199
|
const idlessSeen = audit && scope === this.restrictedIdlessScope ? new Set() : undefined;
|
|
476
1200
|
const entries = [];
|
|
477
1201
|
for (const f of files) {
|
|
@@ -496,8 +1220,38 @@ export class FileMemoryEngineBackend {
|
|
|
496
1220
|
continue;
|
|
497
1221
|
}
|
|
498
1222
|
const entry = entryFromFile(text, parsed.id, f.slug, scope);
|
|
499
|
-
const committed =
|
|
1223
|
+
const committed = rowsRev(rows, entry.id);
|
|
1224
|
+
const binding = rowsBinding(rows, entry.id);
|
|
1225
|
+
const boundElsewhere = binding !== undefined && !(binding.scope === scope && binding.slug === f.slug);
|
|
500
1226
|
if (committed === entry.rev) {
|
|
1227
|
+
if (boundElsewhere) {
|
|
1228
|
+
if (audit) {
|
|
1229
|
+
const boundRel = this.bindingRelPath(binding);
|
|
1230
|
+
const probe = probeProjection(this.bindingAbsPath(binding), entry.id);
|
|
1231
|
+
if (probe === "present") {
|
|
1232
|
+
this.recordRestrictedFinding(`bindcopy|${rel}|${entry.rev}`, {
|
|
1233
|
+
path: rel,
|
|
1234
|
+
code: "restricted_divergence",
|
|
1235
|
+
reason: `this file carries the bytes of committed entry ${entry.id}, whose committed projection is in place at ${JSON.stringify(inlineUntrusted(boundRel))} — a filesystem copy is not a scope credential, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
else if (probe === "absent") {
|
|
1239
|
+
this.recordRestrictedFinding(`bindmove|${rel}|${entry.rev}`, {
|
|
1240
|
+
path: rel,
|
|
1241
|
+
code: "restricted_divergence",
|
|
1242
|
+
reason: `committed entry ${entry.id} is bound to ${JSON.stringify(inlineUntrusted(boundRel))} but its projection appears here instead — an unattributed move, or a delete-then-plant; this adoption-restricted session serves neither reading and leaves adjudication to an unrestricted session`,
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
this.recordRestrictedFinding(`bindprobe|${rel}`, {
|
|
1247
|
+
path: rel,
|
|
1248
|
+
code: "unreadable",
|
|
1249
|
+
reason: `committed entry ${entry.id} is bound elsewhere and the bound projection could not be probed conclusively (a read failure, not absence) — the binding audit for this file is incomplete; it is withheld this pass`,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
501
1255
|
if (audit && this.readCommittedShadow(entry.id) === undefined) {
|
|
502
1256
|
try {
|
|
503
1257
|
atomicWriteFileSync(this.shadowPath(entry.id), text);
|
|
@@ -511,11 +1265,11 @@ export class FileMemoryEngineBackend {
|
|
|
511
1265
|
const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
|
|
512
1266
|
if (shadowText !== undefined) {
|
|
513
1267
|
const shadowEntry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
514
|
-
if (shadowEntry.rev === entry.rev) {
|
|
1268
|
+
if (shadowEntry.rev === entry.rev && !boundElsewhere) {
|
|
515
1269
|
entries.push(entry);
|
|
516
1270
|
continue;
|
|
517
1271
|
}
|
|
518
|
-
const otherProjection = this.idProjectionElsewhere(entry.id, f.path);
|
|
1272
|
+
const otherProjection = binding !== undefined ? (boundElsewhere ? this.bindingRelPath(binding) : undefined) : this.idProjectionElsewhere(entry.id, f.path);
|
|
519
1273
|
if (otherProjection !== undefined) {
|
|
520
1274
|
if (audit) {
|
|
521
1275
|
const finding = this.inboundGate(rel, text);
|
|
@@ -526,7 +1280,9 @@ export class FileMemoryEngineBackend {
|
|
|
526
1280
|
this.recordRestrictedFinding(`dupserve|${rel}|${entry.rev}`, {
|
|
527
1281
|
path: rel,
|
|
528
1282
|
code: "restricted_divergence",
|
|
529
|
-
reason:
|
|
1283
|
+
reason: binding !== undefined
|
|
1284
|
+
? `this file diverges from committed entry ${entry.id}, whose committed projection is bound at ${JSON.stringify(inlineUntrusted(otherProjection))} — the committed copy may only stand in at the entry's committed projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`
|
|
1285
|
+
: `this file carries the id of a committed entry that has another projection (${otherProjection}) — the committed copy may only stand in for an entry's unique projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
|
|
530
1286
|
});
|
|
531
1287
|
}
|
|
532
1288
|
}
|
|
@@ -595,6 +1351,7 @@ export class FileMemoryEngineBackend {
|
|
|
595
1351
|
restrictedAdoptionView(opts) {
|
|
596
1352
|
const audit = opts?.audit === true;
|
|
597
1353
|
if (audit) {
|
|
1354
|
+
this.restrictedFindingKeys = new Set();
|
|
598
1355
|
this.restrictedIdlessScope = typeof opts?.writeScope === "string" ? opts.writeScope : undefined;
|
|
599
1356
|
this.restrictedIdlessBaseline = undefined;
|
|
600
1357
|
}
|
|
@@ -615,7 +1372,15 @@ export class FileMemoryEngineBackend {
|
|
|
615
1372
|
this.readScopeCommitted(scope, true);
|
|
616
1373
|
const idlessBaseline = opts?.idlessWriteScope !== undefined && opts.idlessWriteScope === this.restrictedIdlessScope ? this.restrictedIdlessBaseline : undefined;
|
|
617
1374
|
const idlessScope = idlessBaseline !== undefined ? opts?.idlessWriteScope : undefined;
|
|
618
|
-
|
|
1375
|
+
if (this.persistedSchemaVersion === "v1" || hasUnboundRows(this.loadLedger()))
|
|
1376
|
+
this.tryOpportunisticMigrate();
|
|
1377
|
+
const rows = { ...this.loadLedger() };
|
|
1378
|
+
if (this.persistedSchemaVersion === "v2" && hasUnboundRows(rows)) {
|
|
1379
|
+
const unboundIds = unboundRowIds(rows);
|
|
1380
|
+
this.enqueueExternalItems([
|
|
1381
|
+
`memory ledger has ${unboundIds.length} unbound row(s) (no derivable committed projection): ${unboundIds.slice(0, 8).join(", ")}${unboundIds.length > 8 ? ", …" : ""} — v2 binding guarantees do not cover them until they converge`,
|
|
1382
|
+
]);
|
|
1383
|
+
}
|
|
619
1384
|
const present = new Map();
|
|
620
1385
|
let complete = true;
|
|
621
1386
|
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
@@ -652,12 +1417,13 @@ export class FileMemoryEngineBackend {
|
|
|
652
1417
|
}
|
|
653
1418
|
}
|
|
654
1419
|
for (const [id, paths] of present) {
|
|
655
|
-
if (paths.length < 2 ||
|
|
1420
|
+
if (paths.length < 2 || rowsRev(rows, id) === undefined)
|
|
656
1421
|
continue;
|
|
1422
|
+
const dupBinding = rowsBinding(rows, id);
|
|
657
1423
|
this.recordRestrictedFinding(`dup|${id}|${paths.sort().join(",")}`, {
|
|
658
1424
|
path: paths[0] ?? id,
|
|
659
1425
|
code: "restricted_divergence",
|
|
660
|
-
reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; this adoption-restricted session flags it and an unrestricted session's harvest adjudicates
|
|
1426
|
+
reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; ${dupBinding !== undefined ? `the committed projection is ${JSON.stringify(inlineUntrusted(this.bindingRelPath(dupBinding)))}, the other path(s) carry no account backing` : "this adoption-restricted session flags it"} and an unrestricted session's harvest adjudicates`,
|
|
661
1427
|
});
|
|
662
1428
|
}
|
|
663
1429
|
if (!complete) {
|
|
@@ -668,16 +1434,30 @@ export class FileMemoryEngineBackend {
|
|
|
668
1434
|
});
|
|
669
1435
|
return;
|
|
670
1436
|
}
|
|
671
|
-
for (const [id,
|
|
672
|
-
|
|
673
|
-
|
|
1437
|
+
for (const [id, row] of rowsEntries(rows)) {
|
|
1438
|
+
const binding = rowsBinding(rows, id);
|
|
1439
|
+
const paths = present.get(id) ?? [];
|
|
1440
|
+
if (binding === undefined) {
|
|
1441
|
+
if (paths.length > 0)
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
else {
|
|
1445
|
+
if (paths.includes(this.bindingRelPath(binding)))
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
674
1448
|
this.ledger = undefined;
|
|
675
|
-
|
|
1449
|
+
const current = this.loadLedger();
|
|
1450
|
+
if (rowsRev(current, id) === undefined)
|
|
676
1451
|
continue;
|
|
677
|
-
|
|
678
|
-
|
|
1452
|
+
if (!bindingEquals(rowsBinding(current, id), binding))
|
|
1453
|
+
continue;
|
|
1454
|
+
const aliens = binding !== undefined ? paths.filter((p) => p !== this.bindingRelPath(binding)) : [];
|
|
1455
|
+
this.recordRestrictedFinding(`missing|${id}|${row.rev}`, {
|
|
1456
|
+
path: binding !== undefined ? this.bindingRelPath(binding) : `id:${id}`,
|
|
679
1457
|
code: "restricted_divergence",
|
|
680
|
-
reason:
|
|
1458
|
+
reason: binding !== undefined
|
|
1459
|
+
? `committed memory entry ${id} is missing from its committed projection ${JSON.stringify(inlineUntrusted(this.bindingRelPath(binding)))} with no transaction backing the removal${aliens.length > 0 ? ` while byte-carriers of its id appear at ${aliens.join(", ")} — an unattributed move, or a delete-then-plant` : ""}; this adoption-restricted session neither restores nor forgets it — an unrestricted session adjudicates`
|
|
1460
|
+
: `committed memory entry ${id} is missing from disk with no transaction backing the removal; this adoption-restricted session neither restores nor forgets it — an unrestricted session can restore it from the committed copy`,
|
|
681
1461
|
});
|
|
682
1462
|
}
|
|
683
1463
|
}
|
|
@@ -825,12 +1605,14 @@ export class FileMemoryEngineBackend {
|
|
|
825
1605
|
async applyPatchesLocked(patches, lockToken) {
|
|
826
1606
|
this.ledger = undefined;
|
|
827
1607
|
this.recoverJournal();
|
|
828
|
-
|
|
1608
|
+
this.migrateLockedIfNeeded(lockToken);
|
|
1609
|
+
const rows = cloneRows(this.loadLedger());
|
|
829
1610
|
const report = { applied: [], conflicts: [] };
|
|
830
1611
|
const ops = [];
|
|
1612
|
+
const transfers = [];
|
|
831
1613
|
const plannedTargets = new Set();
|
|
832
1614
|
const nonDeleteIds = new Set();
|
|
833
|
-
const plannedDeletes = new
|
|
1615
|
+
const plannedDeletes = new Map();
|
|
834
1616
|
this.batchScan = new Map();
|
|
835
1617
|
try {
|
|
836
1618
|
for (const patch of patches) {
|
|
@@ -846,7 +1628,7 @@ export class FileMemoryEngineBackend {
|
|
|
846
1628
|
}
|
|
847
1629
|
nonDeleteIds.add(patch.id);
|
|
848
1630
|
}
|
|
849
|
-
this.planOne(patch, report, ops,
|
|
1631
|
+
this.planOne(patch, report, ops, rows, plannedTargets, plannedDeletes, transfers);
|
|
850
1632
|
}
|
|
851
1633
|
catch (err) {
|
|
852
1634
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: `io error: ${err instanceof Error ? err.message : String(err)}` });
|
|
@@ -876,8 +1658,11 @@ export class FileMemoryEngineBackend {
|
|
|
876
1658
|
}
|
|
877
1659
|
op.staged = staged;
|
|
878
1660
|
}
|
|
1661
|
+
const coherentTransfers = transfers.filter((e) => e.channel === "migration" || bindingEquals(rowsBinding(rows, e.id), e.to));
|
|
879
1662
|
this.assertTxnLockOwnership(lockToken);
|
|
880
|
-
|
|
1663
|
+
if (coherentTransfers.length > 0)
|
|
1664
|
+
this.precheckTransfersAppendable();
|
|
1665
|
+
const journal = { txn, ops, ledger2: ledgerEnvelope(rows, this.envelopeExtras), ...(coherentTransfers.length > 0 ? { transfers: coherentTransfers } : {}) };
|
|
881
1666
|
atomicWriteFileSync(join(this.controlPlaneRoot, JOURNAL_FILE), JSON.stringify(journal));
|
|
882
1667
|
for (const op of ops) {
|
|
883
1668
|
if (op.kind === "write" && op.staged) {
|
|
@@ -893,16 +1678,21 @@ export class FileMemoryEngineBackend {
|
|
|
893
1678
|
rmSync(this.shadowPath(op.target), { force: true });
|
|
894
1679
|
}
|
|
895
1680
|
}
|
|
896
|
-
this.ledger =
|
|
1681
|
+
this.ledger = rows;
|
|
897
1682
|
this.saveLedger();
|
|
1683
|
+
this.appendTransfers(coherentTransfers);
|
|
898
1684
|
rmSync(join(this.controlPlaneRoot, JOURNAL_FILE), { force: true });
|
|
899
1685
|
return report;
|
|
900
1686
|
}
|
|
901
|
-
planOne(patch, report, ops,
|
|
1687
|
+
planOne(patch, report, ops, rows, plannedTargets, plannedDeletes, transfers) {
|
|
902
1688
|
if (!isValidEntryId(patch.id)) {
|
|
903
1689
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: `invalid_id_shape (id ${JSON.stringify(patch.id)} fails the frontmatter id contract — the read side would drop it, leaving an applied-but-invisible entry)` });
|
|
904
1690
|
return;
|
|
905
1691
|
}
|
|
1692
|
+
if (patch.op !== "delete" && patch.entry !== undefined && slugEscapes(patch.entry.slug)) {
|
|
1693
|
+
report.conflicts.push({ op: patch.op, id: patch.id, reason: `invalid_slug_shape (slug ${JSON.stringify(patch.entry.slug)} is empty, absolute, or path-escaping — a projection must stay inside its scope dir)` });
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
906
1696
|
if (patch.op === "add") {
|
|
907
1697
|
const entry = patch.entry;
|
|
908
1698
|
if (!entry) {
|
|
@@ -913,7 +1703,7 @@ export class FileMemoryEngineBackend {
|
|
|
913
1703
|
const existingAnywhere = locatedAnywhere !== undefined && plannedDeletes.has(patch.id) ? undefined : locatedAnywhere;
|
|
914
1704
|
if (patch.guard === "absent") {
|
|
915
1705
|
if (existingAnywhere) {
|
|
916
|
-
const currentRev =
|
|
1706
|
+
const currentRev = rowsRev(rows, patch.id) ?? existingAnywhere.entry.rev;
|
|
917
1707
|
if (currentRev !== computeEntryRev(entry)) {
|
|
918
1708
|
report.conflicts.push({ op: "add", id: patch.id, reason: "add_guard_absent_conflict (id already exists at a different rev; add-if-absent refuses to overwrite)", currentRev });
|
|
919
1709
|
return;
|
|
@@ -956,7 +1746,14 @@ export class FileMemoryEngineBackend {
|
|
|
956
1746
|
plannedTargets.add(target);
|
|
957
1747
|
ops.push({ kind: "write", target, content });
|
|
958
1748
|
ops.push({ kind: "shadow-write", target: entry.id, content });
|
|
959
|
-
|
|
1749
|
+
const now = this.now();
|
|
1750
|
+
bindRowTracked(rows, entry.id, { rev: computeEntryRev(entry), scope: entry.scope, slug, at: now }, transfers, "applyPatches-move");
|
|
1751
|
+
const priorBinding = plannedDeletes.get(entry.id);
|
|
1752
|
+
const justBound = rows[entry.id];
|
|
1753
|
+
if (priorBinding !== undefined && justBound !== undefined && !bindingEquals(priorBinding, { scope: entry.scope, slug })) {
|
|
1754
|
+
rows[entry.id] = { ...justBound, prev: { scope: priorBinding.scope, slug: priorBinding.slug, at: now } };
|
|
1755
|
+
transfers.push({ ev: randomUUID(), channel: "applyPatches-move", id: entry.id, from: priorBinding, to: { scope: entry.scope, slug }, at: now });
|
|
1756
|
+
}
|
|
960
1757
|
report.applied.push({ op: "add", id: entry.id, slug });
|
|
961
1758
|
return;
|
|
962
1759
|
}
|
|
@@ -982,7 +1779,7 @@ export class FileMemoryEngineBackend {
|
|
|
982
1779
|
}
|
|
983
1780
|
}
|
|
984
1781
|
}
|
|
985
|
-
const currentRev =
|
|
1782
|
+
const currentRev = rowsRev(rows, patch.id) ?? found.entry.rev;
|
|
986
1783
|
if (patch.baseRev !== undefined && patch.baseRev !== currentRev) {
|
|
987
1784
|
report.conflicts.push({ op: patch.op, id: patch.id, reason: "rev mismatch (concurrent change)", baseRev: patch.baseRev, currentRev });
|
|
988
1785
|
const shadowText = this.readCommittedShadow(patch.id);
|
|
@@ -994,8 +1791,9 @@ export class FileMemoryEngineBackend {
|
|
|
994
1791
|
if (patch.op === "delete") {
|
|
995
1792
|
ops.push({ kind: "delete", target: found.path });
|
|
996
1793
|
ops.push({ kind: "shadow-delete", target: patch.id });
|
|
997
|
-
|
|
998
|
-
|
|
1794
|
+
const deletedBinding = rowsBinding(rows, patch.id);
|
|
1795
|
+
deleteRow(rows, patch.id);
|
|
1796
|
+
plannedDeletes.set(patch.id, deletedBinding);
|
|
999
1797
|
report.applied.push({ op: "delete", id: patch.id, slug: found.entry.slug });
|
|
1000
1798
|
return;
|
|
1001
1799
|
}
|
|
@@ -1012,7 +1810,7 @@ export class FileMemoryEngineBackend {
|
|
|
1012
1810
|
plannedTargets.add(target);
|
|
1013
1811
|
ops.push({ kind: "write", target, content });
|
|
1014
1812
|
ops.push({ kind: "shadow-write", target: entry.id, content });
|
|
1015
|
-
|
|
1813
|
+
bindRowTracked(rows, entry.id, { rev: computeEntryRev(entry), scope: entry.scope, slug: entry.slug, at: this.now() }, transfers, "applyPatches-move");
|
|
1016
1814
|
report.applied.push({ op: "update", id: entry.id, slug: entry.slug });
|
|
1017
1815
|
}
|
|
1018
1816
|
locateById(id, preferScope) {
|