@cotal-ai/workspace 0.11.6 → 0.13.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.
@@ -0,0 +1,2551 @@
1
+ import { closeSync, chmodSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { hardenPrivate } from "@cotal-ai/core";
6
+ /** Version 1 lives in `<project>/.cotal/maintenance/v1`; the lock is shared across versions. */
7
+ export const MAINTENANCE_JOURNAL_VERSION = 1;
8
+ export const MAINTENANCE_RESUME_DOCUMENT_VERSION = 1;
9
+ export const MAX_MAINTENANCE_RESUME_BYTES = 1024 * 1024;
10
+ const MAINTENANCE_ERROR = "cotal:workspace:maintenance-error";
11
+ /** Stable code and structured recourse are the consumer contract; `message` is diagnostic text. */
12
+ export class MaintenanceError extends Error {
13
+ brand = MAINTENANCE_ERROR;
14
+ code;
15
+ details;
16
+ constructor(code, message, details) {
17
+ super(message);
18
+ this.name = "MaintenanceError";
19
+ this.code = code;
20
+ this.details = details;
21
+ }
22
+ }
23
+ export function isMaintenanceError(error) {
24
+ return typeof error === "object" && error !== null &&
25
+ error.brand === MAINTENANCE_ERROR;
26
+ }
27
+ const noRecourse = [];
28
+ const STORE_ID_FILE = ".cotal-store-id";
29
+ const ATTEMPT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
30
+ const DURABLE_COMMIT_TOKEN = /^[a-f0-9]{64}$/;
31
+ function maintenanceError(code, message, details = {}) {
32
+ throw new MaintenanceError(code, message, { ...details, recourse: details.recourse ?? noRecourse });
33
+ }
34
+ function errno(error) {
35
+ return error.code;
36
+ }
37
+ function canonicalRoot(root) {
38
+ if (!root || !isAbsolute(root))
39
+ maintenanceError("invalid-path", "maintenance root must be an absolute existing directory", {
40
+ root,
41
+ paths: [root],
42
+ });
43
+ let canonical;
44
+ try {
45
+ canonical = realpathSync.native(root);
46
+ }
47
+ catch {
48
+ maintenanceError("invalid-path", "maintenance root does not exist", { root, paths: [root] });
49
+ }
50
+ if (!statSync(canonical).isDirectory())
51
+ maintenanceError("invalid-path", "maintenance root is not a directory", { root, paths: [root] });
52
+ return canonical;
53
+ }
54
+ export function maintenancePaths(root) {
55
+ const canonical = canonicalRoot(root);
56
+ const cotalDir = join(canonical, ".cotal");
57
+ const maintenanceDir = join(cotalDir, "maintenance");
58
+ const versionDir = join(maintenanceDir, `v${MAINTENANCE_JOURNAL_VERSION}`);
59
+ return {
60
+ root: canonical,
61
+ cotalDir,
62
+ maintenanceDir,
63
+ versionDir,
64
+ journal: join(versionDir, "journal.json"),
65
+ resume: join(versionDir, "resume.json"),
66
+ prepareIntent: join(versionDir, "prepare-intent.json"),
67
+ commitIntent: join(versionDir, "commit-intent.json"),
68
+ lock: join(maintenanceDir, "lock.json"),
69
+ reaper: join(maintenanceDir, "lock-reaper.json"),
70
+ };
71
+ }
72
+ function fsyncDirectory(path) {
73
+ let fd;
74
+ try {
75
+ fd = openSync(path, constants.O_RDONLY);
76
+ fsyncSync(fd);
77
+ }
78
+ catch (error) {
79
+ // Windows does not support opening/fsyncing a directory. POSIX errors are correctness failures.
80
+ if (process.platform !== "win32")
81
+ throw error;
82
+ }
83
+ finally {
84
+ if (fd !== undefined)
85
+ closeSync(fd);
86
+ }
87
+ }
88
+ function ensureDirectory(path, harden) {
89
+ if (!existsSync(path)) {
90
+ mkdirSync(path, { mode: 0o700 });
91
+ fsyncDirectory(dirname(path));
92
+ }
93
+ const stat = lstatSync(path);
94
+ if (!stat.isDirectory() || stat.isSymbolicLink())
95
+ maintenanceError("invalid-path", "maintenance path is not a real directory", { paths: [path] });
96
+ if (harden) {
97
+ if (process.platform !== "win32")
98
+ chmodSync(path, 0o700);
99
+ hardenPrivate(path, "dir");
100
+ }
101
+ }
102
+ function ensureLayout(paths) {
103
+ ensureDirectory(paths.cotalDir, false);
104
+ ensureDirectory(paths.maintenanceDir, true);
105
+ ensureDirectory(paths.versionDir, true);
106
+ }
107
+ function canonicalAbsentPath(path) {
108
+ if (!path || !isAbsolute(path))
109
+ maintenanceError("invalid-path", "store path must be absolute", { paths: [path] });
110
+ const parent = dirname(resolve(path));
111
+ let realParent;
112
+ try {
113
+ realParent = realpathSync.native(parent);
114
+ }
115
+ catch {
116
+ maintenanceError("invalid-path", "store parent does not exist", { paths: [parent] });
117
+ }
118
+ const canonical = join(realParent, basename(path));
119
+ if (pathExistsStrict(canonical))
120
+ maintenanceError("path-exists", "store path must be absent", {
121
+ paths: [canonical],
122
+ recourse: [{ action: "inspect", description: "Inspect the unexpected path before retrying.", paths: [canonical] }],
123
+ });
124
+ return canonical;
125
+ }
126
+ function canonicalCandidatePath(path) {
127
+ if (!path || !isAbsolute(path))
128
+ maintenanceError("invalid-path", "store path must be absolute", { paths: [path] });
129
+ if (pathExistsStrict(resolve(path))) {
130
+ try {
131
+ return realpathSync.native(resolve(path));
132
+ }
133
+ catch {
134
+ maintenanceError("invalid-path", "store path cannot be resolved safely", { paths: [path] });
135
+ }
136
+ }
137
+ return canonicalAbsentPath(path);
138
+ }
139
+ function pathContains(parent, child) {
140
+ const fromParent = relative(parent, child);
141
+ return fromParent === "" ||
142
+ (fromParent !== ".." && !fromParent.startsWith(`..${sep}`) && !isAbsolute(fromParent));
143
+ }
144
+ function assertPathsDoNotOverlap(a, b, message) {
145
+ if (pathContains(a, b) || pathContains(b, a))
146
+ maintenanceError("invalid-path", message, {
147
+ paths: [a, b],
148
+ recourse: [{ action: "inspect", description: "Choose disjoint source and target directory trees.", paths: [a, b] }],
149
+ });
150
+ }
151
+ function pathExistsStrict(path) {
152
+ try {
153
+ lstatSync(path);
154
+ return true;
155
+ }
156
+ catch (error) {
157
+ if (errno(error) === "ENOENT")
158
+ return false;
159
+ maintenanceError("invalid-path", "filesystem path cannot be inspected safely", { paths: [path] });
160
+ }
161
+ }
162
+ function storeGeneration(path, create) {
163
+ const marker = join(path, STORE_ID_FILE);
164
+ if (!pathExistsStrict(marker)) {
165
+ if (!create)
166
+ maintenanceError("identity-mismatch", "store generation marker is missing", {
167
+ paths: [path, marker],
168
+ recourse: [{ action: "inspect", description: "Treat this path as a replacement until its provenance is proven.", paths: [path] }],
169
+ });
170
+ writePrivateExclusive(marker, `${randomUUID()}\n`);
171
+ }
172
+ let stat;
173
+ try {
174
+ stat = lstatSync(marker);
175
+ }
176
+ catch {
177
+ maintenanceError("identity-mismatch", "store generation marker cannot be inspected", { paths: [path, marker] });
178
+ }
179
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128)
180
+ maintenanceError("identity-mismatch", "store generation marker is invalid", { paths: [path, marker] });
181
+ const generation = readFileSync(marker, "utf8").trim();
182
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(generation))
183
+ maintenanceError("identity-mismatch", "store generation marker is corrupt", { paths: [path, marker] });
184
+ return generation;
185
+ }
186
+ function storeIdentity(path, createGeneration) {
187
+ if (!path || !isAbsolute(path))
188
+ maintenanceError("invalid-path", "store path must be absolute", { paths: [path] });
189
+ let canonical;
190
+ try {
191
+ canonical = realpathSync.native(path);
192
+ }
193
+ catch (error) {
194
+ if (errno(error) === "ENOENT")
195
+ maintenanceError("path-missing", "store path is missing", { paths: [path] });
196
+ maintenanceError("invalid-path", "store path cannot be resolved safely", { paths: [path] });
197
+ }
198
+ const stat = lstatSync(canonical, { bigint: true });
199
+ if (!stat.isDirectory() || stat.isSymbolicLink())
200
+ maintenanceError("invalid-path", "store path must be a real directory", { paths: [canonical] });
201
+ return {
202
+ path: canonical, dev: stat.dev.toString(), ino: stat.ino.toString(),
203
+ generation: storeGeneration(canonical, createGeneration),
204
+ };
205
+ }
206
+ /** Read an already-bound store identity. Missing generation is a replacement ambiguity, not absence. */
207
+ export function readStoreIdentity(path) {
208
+ return storeIdentity(path, false);
209
+ }
210
+ /** Bind a stopped source or attempt-owned target to a non-reusable generation marker. */
211
+ export function ensureStoreIdentity(path) {
212
+ return storeIdentity(path, true);
213
+ }
214
+ export function sameStoreIdentity(a, b) {
215
+ return a.path === b.path && a.dev === b.dev && a.ino === b.ino && a.generation === b.generation;
216
+ }
217
+ function identityAt(path) {
218
+ try {
219
+ return readStoreIdentity(path);
220
+ }
221
+ catch (error) {
222
+ if (isMaintenanceError(error) && error.code === "path-missing")
223
+ return undefined;
224
+ throw error;
225
+ }
226
+ }
227
+ export function assertStoreIdentity(expected) {
228
+ const actual = identityAt(expected.path);
229
+ if (!actual)
230
+ maintenanceError("path-missing", "recorded store path is missing", {
231
+ paths: [expected.path], expected,
232
+ recourse: [{ action: "inspect", description: "Do not recreate the path; inspect maintenance state first.", paths: [expected.path] }],
233
+ });
234
+ if (!sameStoreIdentity(expected, actual))
235
+ maintenanceError("identity-mismatch", "recorded store path has been replaced", {
236
+ paths: [expected.path], expected, actual,
237
+ recourse: [{ action: "inspect", description: "Preserve both stores and determine which inode is authoritative.", paths: [expected.path] }],
238
+ });
239
+ return actual;
240
+ }
241
+ function writePrivateExclusive(path, data) {
242
+ let fd;
243
+ let created = false;
244
+ try {
245
+ fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
246
+ created = true;
247
+ writeFileSync(fd, data);
248
+ fsyncSync(fd);
249
+ closeSync(fd);
250
+ fd = undefined;
251
+ hardenPrivate(path, "file");
252
+ fsyncDirectory(dirname(path));
253
+ }
254
+ catch (error) {
255
+ if (fd !== undefined)
256
+ closeSync(fd);
257
+ if (created) {
258
+ try {
259
+ unlinkSync(path);
260
+ }
261
+ catch { /* remove only the path this exclusive create owned */ }
262
+ }
263
+ throw error;
264
+ }
265
+ }
266
+ function atomicWrite(path, value) {
267
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
268
+ try {
269
+ writePrivateExclusive(tmp, `${JSON.stringify(value, null, 2)}\n`);
270
+ renameSync(tmp, path);
271
+ fsyncDirectory(dirname(path));
272
+ }
273
+ catch (error) {
274
+ try {
275
+ unlinkSync(tmp);
276
+ }
277
+ catch { /* best effort for an exclusively-created temp */ }
278
+ throw error;
279
+ }
280
+ }
281
+ function defaultOwner() {
282
+ return { pid: process.pid, host: hostname(), startedAt: new Date().toISOString(), id: randomUUID() };
283
+ }
284
+ export function localProcessOwnerStatus(owner) {
285
+ if (owner.host !== hostname())
286
+ return "unknown";
287
+ try {
288
+ process.kill(owner.pid, 0);
289
+ return "alive";
290
+ }
291
+ catch (error) {
292
+ if (errno(error) === "ESRCH")
293
+ return "dead";
294
+ if (errno(error) === "EPERM")
295
+ return "alive";
296
+ return "unknown";
297
+ }
298
+ }
299
+ function parseOwner(value, label) {
300
+ const o = value;
301
+ if (!o || typeof o !== "object" || !Number.isInteger(o.pid) || o.pid <= 0 ||
302
+ typeof o.host !== "string" || !o.host || typeof o.startedAt !== "string" || !Number.isFinite(Date.parse(o.startedAt)) ||
303
+ typeof o.id !== "string" || !o.id)
304
+ maintenanceError("journal-corrupt", `invalid ${label} owner record`);
305
+ return o;
306
+ }
307
+ function readLockFile(path) {
308
+ let parsed;
309
+ try {
310
+ const stat = lstatSync(path);
311
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)
312
+ maintenanceError("journal-corrupt", "maintenance lock is not a bounded regular file", { paths: [path] });
313
+ parsed = JSON.parse(readFileSync(path, "utf8"));
314
+ }
315
+ catch (error) {
316
+ if (isMaintenanceError(error))
317
+ throw error;
318
+ if (errno(error) === "ENOENT")
319
+ throw error;
320
+ maintenanceError("journal-corrupt", "maintenance lock cannot be parsed", { paths: [path] });
321
+ }
322
+ const lock = parsed;
323
+ if (!lock || typeof lock !== "object" || typeof lock.token !== "string" || !lock.token)
324
+ maintenanceError("journal-corrupt", "maintenance lock has no token", { paths: [path] });
325
+ return { token: lock.token, owner: parseOwner(lock.owner, "lock") };
326
+ }
327
+ function removeReaper(path, token) {
328
+ try {
329
+ const current = readLockFile(path);
330
+ if (current.token === token) {
331
+ unlinkSync(path);
332
+ fsyncDirectory(dirname(path));
333
+ }
334
+ }
335
+ catch (error) {
336
+ if (errno(error) !== "ENOENT")
337
+ throw error;
338
+ }
339
+ }
340
+ export function acquireMaintenanceLock(root, options = {}) {
341
+ const paths = maintenancePaths(root);
342
+ ensureLayout(paths);
343
+ const owner = options.owner ?? defaultOwner();
344
+ parseOwner(owner, "requested lock");
345
+ const token = randomUUID();
346
+ const body = `${JSON.stringify({ token, owner }, null, 2)}\n`;
347
+ try {
348
+ writePrivateExclusive(paths.lock, body);
349
+ return { root: paths.root, path: paths.lock, token, owner };
350
+ }
351
+ catch (error) {
352
+ if (errno(error) !== "EEXIST")
353
+ throw error;
354
+ }
355
+ // One exclusive reaper serializes stale-owner removal. Without it, two reclaimers could race
356
+ // between checking the old inode and unlinking it, and one could unlink the other's new lock.
357
+ const reaperToken = randomUUID();
358
+ try {
359
+ writePrivateExclusive(paths.reaper, `${JSON.stringify({ token: reaperToken, owner }, null, 2)}\n`);
360
+ }
361
+ catch (error) {
362
+ if (errno(error) === "EEXIST") {
363
+ const stale = readLockFile(paths.reaper);
364
+ const status = (options.ownerStatus ?? localProcessOwnerStatus)(stale.owner);
365
+ if (status === "alive")
366
+ maintenanceError("lock-held", "maintenance lock recovery is already in progress", {
367
+ root: paths.root,
368
+ recourse: [{ action: "retry", description: "Retry after the current maintenance operation exits." }],
369
+ });
370
+ if (status === "unknown")
371
+ maintenanceError("lock-owner-ambiguous", "maintenance lock reaper death cannot be proven", {
372
+ root: paths.root,
373
+ paths: [paths.reaper],
374
+ recourse: [{ action: "inspect", description: "Prove the recorded reaper owner dead before retrying.", paths: [paths.reaper] }],
375
+ });
376
+ // There is no portable token-conditional unlink. Automatic recovery would let two contenders
377
+ // both approve the stale token, then one unlink the other's fresh reaper. Fail closed and make
378
+ // the deliberate single-operator recovery step explicit instead of weakening exclusion.
379
+ maintenanceError("lock-owner-ambiguous", "maintenance lock reaper is stale and requires manual recovery", {
380
+ root: paths.root,
381
+ paths: [paths.lock, paths.reaper],
382
+ recourse: [{
383
+ action: "repair",
384
+ description: "With all Cotal maintenance commands stopped, remove only the recorded stale reaper, then retry; the stale main lock will be recovered normally.",
385
+ paths: [paths.reaper],
386
+ }],
387
+ });
388
+ }
389
+ throw error;
390
+ }
391
+ try {
392
+ let existing;
393
+ try {
394
+ existing = readLockFile(paths.lock);
395
+ }
396
+ catch (error) {
397
+ if (errno(error) !== "ENOENT")
398
+ throw error;
399
+ }
400
+ if (existing) {
401
+ const status = (options.ownerStatus ?? localProcessOwnerStatus)(existing.owner);
402
+ if (status === "alive")
403
+ maintenanceError("lock-held", "maintenance lock is held by a live owner", {
404
+ root: paths.root,
405
+ recourse: [{ action: "retry", description: "Wait for the current maintenance operation to finish." }],
406
+ });
407
+ if (status === "unknown")
408
+ maintenanceError("lock-owner-ambiguous", "maintenance lock owner death cannot be proven", {
409
+ root: paths.root,
410
+ recourse: [{ action: "inspect", description: "Confirm the recorded host and process are dead before retrying.", paths: [paths.lock] }],
411
+ });
412
+ const again = readLockFile(paths.lock);
413
+ if (again.token !== existing.token)
414
+ maintenanceError("lock-held", "maintenance lock changed during stale-owner recovery", {
415
+ root: paths.root,
416
+ recourse: [{ action: "retry", description: "Retry after the current maintenance operation exits." }],
417
+ });
418
+ unlinkSync(paths.lock);
419
+ fsyncDirectory(paths.maintenanceDir);
420
+ }
421
+ }
422
+ finally {
423
+ removeReaper(paths.reaper, reaperToken);
424
+ }
425
+ // Another waiter may win after stale removal. Exclusive create remains the arbiter.
426
+ try {
427
+ writePrivateExclusive(paths.lock, body);
428
+ }
429
+ catch (error) {
430
+ if (errno(error) === "EEXIST")
431
+ maintenanceError("lock-held", "another maintenance operation acquired the recovered lock", {
432
+ root: paths.root,
433
+ recourse: [{ action: "retry", description: "Retry after the current maintenance operation exits." }],
434
+ });
435
+ throw error;
436
+ }
437
+ return { root: paths.root, path: paths.lock, token, owner };
438
+ }
439
+ function assertLock(lock) {
440
+ const paths = maintenancePaths(lock.root);
441
+ if (paths.lock !== lock.path)
442
+ maintenanceError("lock-lost", "maintenance lock belongs to a different root", { root: paths.root });
443
+ let current;
444
+ try {
445
+ current = readLockFile(lock.path);
446
+ }
447
+ catch (error) {
448
+ if (errno(error) === "ENOENT")
449
+ maintenanceError("lock-lost", "maintenance lock disappeared", { root: paths.root });
450
+ throw error;
451
+ }
452
+ if (current.token !== lock.token)
453
+ maintenanceError("lock-lost", "maintenance lock ownership changed", { root: paths.root });
454
+ }
455
+ export function releaseMaintenanceLock(lock) {
456
+ assertLock(lock);
457
+ unlinkSync(lock.path);
458
+ fsyncDirectory(dirname(lock.path));
459
+ }
460
+ export function withMaintenanceLock(root, operation, options = {}) {
461
+ const lock = acquireMaintenanceLock(root, options);
462
+ try {
463
+ return operation(lock);
464
+ }
465
+ finally {
466
+ releaseMaintenanceLock(lock);
467
+ }
468
+ }
469
+ function validIdentity(value) {
470
+ const identity = value;
471
+ return Boolean(identity && typeof identity === "object" && isAbsolute(identity.path) &&
472
+ typeof identity.dev === "string" && /^\d+$/.test(identity.dev) &&
473
+ typeof identity.ino === "string" && /^\d+$/.test(identity.ino) &&
474
+ typeof identity.generation === "string" &&
475
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(identity.generation));
476
+ }
477
+ function exactObjectKeys(value, expected) {
478
+ const prototype = Object.getPrototypeOf(value);
479
+ return (prototype === Object.prototype || prototype === null) &&
480
+ Object.keys(value).sort().join(",") === [...expected].sort().join(",") &&
481
+ Reflect.ownKeys(value).length === expected.length;
482
+ }
483
+ function canonicalListenerEndpoint(value) {
484
+ if (!value || Buffer.byteLength(value) > 2048)
485
+ return undefined;
486
+ try {
487
+ const endpoint = new URL(value);
488
+ if (!["nats:", "tls:"].includes(endpoint.protocol) || !endpoint.hostname ||
489
+ endpoint.username || endpoint.password || endpoint.search || endpoint.hash ||
490
+ (endpoint.pathname && endpoint.pathname !== "/"))
491
+ return undefined;
492
+ const port = endpoint.port || "4222";
493
+ const canonical = `${endpoint.protocol}//${endpoint.hostname.toLowerCase()}:${port}`;
494
+ return value === canonical ? canonical : undefined;
495
+ }
496
+ catch {
497
+ return undefined;
498
+ }
499
+ }
500
+ function validProofOwner(value) {
501
+ const owner = value;
502
+ return Boolean(owner && typeof owner === "object" &&
503
+ exactObjectKeys(owner, ["pid", "host", "startedAt", "id"]) &&
504
+ Number.isInteger(owner.pid) && owner.pid > 0 &&
505
+ typeof owner.host === "string" && Buffer.byteLength(owner.host) <= 255 &&
506
+ /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(owner.host) &&
507
+ typeof owner.startedAt === "string" && owner.startedAt.length <= 64 &&
508
+ Number.isFinite(Date.parse(owner.startedAt)) &&
509
+ typeof owner.id === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(owner.id));
510
+ }
511
+ function validProofIdentity(value) {
512
+ return Boolean(value && typeof value === "object" &&
513
+ exactObjectKeys(value, ["path", "dev", "ino", "generation"]) && validIdentity(value));
514
+ }
515
+ function validListenerProof(value) {
516
+ const proof = value;
517
+ return Boolean(proof && typeof proof === "object" &&
518
+ exactObjectKeys(proof, ["attemptId", "serverName", "serverNonce", "processOwner", "serverEndpoint", "target"]) &&
519
+ typeof proof.attemptId === "string" && ATTEMPT_ID.test(proof.attemptId) &&
520
+ typeof proof.serverName === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(proof.serverName) &&
521
+ typeof proof.serverNonce === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(proof.serverNonce) &&
522
+ validProofOwner(proof.processOwner) && typeof proof.serverEndpoint === "string" &&
523
+ canonicalListenerEndpoint(proof.serverEndpoint) === proof.serverEndpoint && validProofIdentity(proof.target) &&
524
+ Buffer.byteLength(JSON.stringify(proof)) <= 16 * 1024);
525
+ }
526
+ function validTimestamp(value) {
527
+ return typeof value === "string" && value.length <= 64 && Number.isFinite(Date.parse(value));
528
+ }
529
+ function validManagerCommitEvidence(value, attemptId) {
530
+ const evidence = value;
531
+ return Boolean(evidence && typeof evidence === "object" &&
532
+ exactObjectKeys(evidence, ["attemptId", "state", "durableCommitToken"]) &&
533
+ evidence.attemptId === attemptId && evidence.state === "awaitingFinalize" &&
534
+ typeof evidence.durableCommitToken === "string" && DURABLE_COMMIT_TOKEN.test(evidence.durableCommitToken));
535
+ }
536
+ function validManagerFinalizeEvidence(value, attemptId, durableCommitToken) {
537
+ const evidence = value;
538
+ return Boolean(evidence && typeof evidence === "object" &&
539
+ exactObjectKeys(evidence, ["attemptId", "state", "durableCommitToken"]) &&
540
+ evidence.attemptId === attemptId && evidence.state === "active" &&
541
+ evidence.durableCommitToken === durableCommitToken && DURABLE_COMMIT_TOKEN.test(evidence.durableCommitToken));
542
+ }
543
+ function sameManagerCommitEvidence(a, b) {
544
+ return a.attemptId === b.attemptId && a.state === b.state &&
545
+ a.durableCommitToken === b.durableCommitToken;
546
+ }
547
+ function sameManagerFinalizeEvidence(a, b) {
548
+ return a.attemptId === b.attemptId && a.state === b.state &&
549
+ a.durableCommitToken === b.durableCommitToken;
550
+ }
551
+ function validRestoreActivationEvidence(value, attemptId) {
552
+ const evidence = value;
553
+ return Boolean(evidence && typeof evidence === "object" &&
554
+ exactObjectKeys(evidence, ["attemptId", "listenerReady", "observedAt", "managerCommit", "managerFinalize"]) &&
555
+ evidence.attemptId === attemptId && evidence.listenerReady === true && validTimestamp(evidence.observedAt) &&
556
+ validManagerCommitEvidence(evidence.managerCommit, attemptId) &&
557
+ validManagerFinalizeEvidence(evidence.managerFinalize, attemptId, evidence.managerCommit.durableCommitToken));
558
+ }
559
+ function validOrdinaryResumeActivationEvidence(value, attemptId) {
560
+ const evidence = value;
561
+ return Boolean(evidence && typeof evidence === "object" &&
562
+ exactObjectKeys(evidence, ["operation", "attemptId", "state", "observedAt"]) &&
563
+ evidence.operation === "resumePreserved" && evidence.attemptId === attemptId &&
564
+ evidence.state === "awaitingCommit" && validTimestamp(evidence.observedAt));
565
+ }
566
+ function validAttemptOwnedPath(value) {
567
+ const owned = value;
568
+ if (!owned || typeof owned !== "object")
569
+ return false;
570
+ const pending = exactObjectKeys(owned, ["label", "path"]);
571
+ if (!pending && !exactObjectKeys(owned, ["label", "path", "dev", "ino"]))
572
+ return false;
573
+ if (!["clone", "destination", "staging", "quarantine", "sanitized", "config"].includes(owned.label) ||
574
+ typeof owned.path !== "string" || !isAbsolute(owned.path))
575
+ return false;
576
+ if (pending)
577
+ return true;
578
+ return typeof owned.dev === "string" && /^\d+$/.test(owned.dev) &&
579
+ typeof owned.ino === "string" && /^\d+$/.test(owned.ino);
580
+ }
581
+ function validOwnedPaths(value) {
582
+ return Array.isArray(value) && value.length <= 64 && value.every(validAttemptOwnedPath) &&
583
+ new Set(value.map((owned) => owned.path)).size === value.length;
584
+ }
585
+ function validRestoreClaim(value) {
586
+ const claim = value;
587
+ if (!claim || typeof claim !== "object" || !exactObjectKeys(claim, ["deadline", "coordinator", "owners"]))
588
+ return false;
589
+ if (typeof claim.deadline !== "string" || !Number.isFinite(Date.parse(claim.deadline)))
590
+ return false;
591
+ if (!validProofOwner(claim.coordinator))
592
+ return false;
593
+ return Array.isArray(claim.owners) && claim.owners.length <= 16 && claim.owners.every(validProofOwner);
594
+ }
595
+ function validCutContext(value) {
596
+ const cut = value;
597
+ return Boolean(cut && typeof cut === "object" && exactObjectKeys(cut, ["attemptId", "intentAt", "launch"]) &&
598
+ typeof cut.attemptId === "string" && ATTEMPT_ID.test(cut.attemptId) && validTimestamp(cut.intentAt) &&
599
+ validJsonObject(cut.launch) && typeof cut.launch.server === "string" &&
600
+ canonicalListenerEndpoint(cut.launch.server) === cut.launch.server);
601
+ }
602
+ function validCutCompletionEvidence(value, cut) {
603
+ const evidence = value;
604
+ return Boolean(evidence && typeof evidence === "object" &&
605
+ exactObjectKeys(evidence, ["attemptId", "observedAt", "managerCommit", "stopped", "listener"]) &&
606
+ evidence.attemptId === cut.attemptId && validTimestamp(evidence.observedAt) &&
607
+ evidence.managerCommit && typeof evidence.managerCommit === "object" &&
608
+ exactObjectKeys(evidence.managerCommit, ["operation", "attemptId", "state"]) &&
609
+ evidence.managerCommit.operation === "commitPreservation" &&
610
+ evidence.managerCommit.attemptId === cut.attemptId && evidence.managerCommit.state === "preserved" &&
611
+ evidence.stopped && typeof evidence.stopped === "object" &&
612
+ exactObjectKeys(evidence.stopped, ["manager", "broker", "localProcesses"]) &&
613
+ evidence.stopped.manager === true && evidence.stopped.broker === true && evidence.stopped.localProcesses === true &&
614
+ evidence.listener && typeof evidence.listener === "object" &&
615
+ exactObjectKeys(evidence.listener, ["endpoint", "unreachable"]) &&
616
+ evidence.listener.endpoint === cut.launch.server && evidence.listener.unreachable === true);
617
+ }
618
+ function normalizedListenerProof(value) {
619
+ if (!validListenerProof(value))
620
+ maintenanceError("listener-proof-invalid", "restore listener proof is invalid, non-canonical, or oversized");
621
+ return {
622
+ attemptId: value.attemptId,
623
+ serverName: value.serverName,
624
+ serverNonce: value.serverNonce,
625
+ processOwner: {
626
+ pid: value.processOwner.pid,
627
+ host: value.processOwner.host,
628
+ startedAt: value.processOwner.startedAt,
629
+ id: value.processOwner.id,
630
+ },
631
+ serverEndpoint: value.serverEndpoint,
632
+ target: {
633
+ path: value.target.path,
634
+ dev: value.target.dev,
635
+ ino: value.target.ino,
636
+ generation: value.target.generation,
637
+ },
638
+ };
639
+ }
640
+ function sameProcessOwner(a, b) {
641
+ return a.pid === b.pid && a.host === b.host && a.startedAt === b.startedAt && a.id === b.id;
642
+ }
643
+ function sameListenerProof(a, b) {
644
+ return a.attemptId === b.attemptId && a.serverName === b.serverName &&
645
+ a.serverNonce === b.serverNonce && sameProcessOwner(a.processOwner, b.processOwner) &&
646
+ a.serverEndpoint === b.serverEndpoint && sameStoreIdentity(a.target, b.target);
647
+ }
648
+ function reusesListenerIdentity(a, b) {
649
+ return a.serverName === b.serverName || a.serverNonce === b.serverNonce ||
650
+ sameProcessOwner(a.processOwner, b.processOwner);
651
+ }
652
+ function validListenerReplacements(value) {
653
+ if (!Array.isArray(value) || value.length === 0 || value.length > 1024)
654
+ return false;
655
+ const replacements = value;
656
+ for (let index = 0; index < replacements.length; index++) {
657
+ const replacement = replacements[index];
658
+ if (!replacement || typeof replacement !== "object" ||
659
+ !exactObjectKeys(replacement, ["generation", "replacedAt", "proof"]) ||
660
+ replacement.generation !== index + 1 || typeof replacement.replacedAt !== "string" ||
661
+ replacement.replacedAt.length > 64 || !Number.isFinite(Date.parse(replacement.replacedAt)) ||
662
+ !validListenerProof(replacement.proof))
663
+ return false;
664
+ if (replacements.slice(0, index).some((prior) => reusesListenerIdentity(prior.proof, replacement.proof)))
665
+ return false;
666
+ }
667
+ return Buffer.byteLength(JSON.stringify(replacements)) <= 512 * 1024;
668
+ }
669
+ function assertListenerOwnerAlive(proof) {
670
+ const status = localProcessOwnerStatus(proof.processOwner);
671
+ if (status === "unknown")
672
+ maintenanceError("listener-owner-ambiguous", "restore listener process ownership cannot be proven locally", {
673
+ attemptId: proof.attemptId,
674
+ recourse: [{ action: "inspect", description: "Prove the recorded listener owner and server nonce before recovery." }],
675
+ });
676
+ if (status === "dead")
677
+ maintenanceError("listener-owner-dead", "recorded restore listener process is not alive", {
678
+ attemptId: proof.attemptId,
679
+ recourse: [{ action: "repair", description: "Preserve both stores and relaunch through an explicit restore recovery path." }],
680
+ });
681
+ }
682
+ function validJson(value) {
683
+ if (value === null || typeof value === "string" || typeof value === "boolean")
684
+ return true;
685
+ if (typeof value === "number")
686
+ return Number.isFinite(value);
687
+ if (Array.isArray(value))
688
+ return value.every(validJson);
689
+ if (!value || typeof value !== "object")
690
+ return false;
691
+ return Object.values(value).every(validJson);
692
+ }
693
+ function validJsonObject(value) {
694
+ if (!value || typeof value !== "object" || Array.isArray(value))
695
+ return false;
696
+ const prototype = Object.getPrototypeOf(value);
697
+ return (prototype === Object.prototype || prototype === null) && validJson(value);
698
+ }
699
+ function sameJsonValue(a, b) {
700
+ if (a === b)
701
+ return true;
702
+ if (Array.isArray(a) || Array.isArray(b)) {
703
+ if (!Array.isArray(a) || !Array.isArray(b))
704
+ return false;
705
+ return a.length === b.length && a.every((item, index) => sameJsonValue(item, b[index]));
706
+ }
707
+ if (!a || !b || typeof a !== "object" || typeof b !== "object")
708
+ return false;
709
+ const aObject = a;
710
+ const bObject = b;
711
+ const aKeys = Object.keys(aObject).sort();
712
+ const bKeys = Object.keys(bObject).sort();
713
+ return aKeys.length === bKeys.length && aKeys.every((key, index) => key === bKeys[index] && sameJsonValue(aObject[key], bObject[key]));
714
+ }
715
+ function cloneJsonValue(value, seen, depth = 0, budget = { nodes: 0 }) {
716
+ if (++budget.nodes > 100_000 || depth > 64)
717
+ maintenanceError("resume-too-large", "maintenance resume document is too deeply nested or complex");
718
+ if (value === null || typeof value === "boolean")
719
+ return value;
720
+ if (typeof value === "string") {
721
+ if (Buffer.byteLength(value) > MAX_MAINTENANCE_RESUME_BYTES)
722
+ maintenanceError("resume-too-large", "maintenance resume document contains an oversized string");
723
+ return value;
724
+ }
725
+ if (typeof value === "number") {
726
+ if (!Number.isFinite(value))
727
+ maintenanceError("resume-invalid", "maintenance resume document contains a non-finite number");
728
+ return value;
729
+ }
730
+ if (!value || typeof value !== "object")
731
+ maintenanceError("resume-invalid", "maintenance resume document must contain only JSON values");
732
+ if (seen.has(value))
733
+ maintenanceError("resume-invalid", "maintenance resume document contains a cycle");
734
+ seen.add(value);
735
+ try {
736
+ if (Array.isArray(value)) {
737
+ if (Reflect.ownKeys(value).length !== value.length + 1)
738
+ maintenanceError("resume-invalid", "maintenance resume arrays must be dense plain data");
739
+ return Array.from({ length: value.length }, (_, index) => {
740
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
741
+ if (!descriptor || !descriptor.enumerable || descriptor.get || descriptor.set)
742
+ maintenanceError("resume-invalid", "maintenance resume arrays must contain data properties only");
743
+ return cloneJsonValue(descriptor.value, seen, depth + 1, budget);
744
+ });
745
+ }
746
+ const prototype = Object.getPrototypeOf(value);
747
+ if (prototype !== Object.prototype && prototype !== null)
748
+ maintenanceError("resume-invalid", "maintenance resume document objects must be plain data");
749
+ const descriptors = Object.getOwnPropertyDescriptors(value);
750
+ const keys = Object.keys(descriptors).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
751
+ if (Reflect.ownKeys(value).length !== keys.length ||
752
+ keys.some((key) => !descriptors[key].enumerable || descriptors[key].get || descriptors[key].set))
753
+ maintenanceError("resume-invalid", "maintenance resume document objects must contain enumerable data properties only");
754
+ const output = Object.create(null);
755
+ for (const key of keys)
756
+ Object.defineProperty(output, key, {
757
+ value: cloneJsonValue(descriptors[key].value, seen, depth + 1, budget),
758
+ enumerable: true,
759
+ configurable: true,
760
+ writable: true,
761
+ });
762
+ return output;
763
+ }
764
+ finally {
765
+ seen.delete(value);
766
+ }
767
+ }
768
+ function resumeBytes(document) {
769
+ if (!document || typeof document !== "object" || Array.isArray(document))
770
+ maintenanceError("resume-invalid", "maintenance resume document must be exactly { version, inventory, launch }");
771
+ const prototype = Object.getPrototypeOf(document);
772
+ const descriptors = Object.getOwnPropertyDescriptors(document);
773
+ const keys = Object.keys(descriptors).sort();
774
+ if ((prototype !== Object.prototype && prototype !== null) || Reflect.ownKeys(document).length !== 3 ||
775
+ keys.join(",") !== "inventory,launch,version" ||
776
+ keys.some((key) => !descriptors[key].enumerable || descriptors[key].get || descriptors[key].set) ||
777
+ descriptors.version.value !== MAINTENANCE_RESUME_DOCUMENT_VERSION)
778
+ maintenanceError("resume-invalid", "maintenance resume document must be exactly { version, inventory, launch }");
779
+ const normalized = {
780
+ version: MAINTENANCE_RESUME_DOCUMENT_VERSION,
781
+ inventory: cloneJsonValue(descriptors.inventory.value, new Set()),
782
+ launch: cloneJsonValue(descriptors.launch.value, new Set()),
783
+ };
784
+ const data = `${JSON.stringify(normalized, null, 2)}\n`;
785
+ const bytes = Buffer.byteLength(data);
786
+ if (bytes > MAX_MAINTENANCE_RESUME_BYTES)
787
+ maintenanceError("resume-too-large", `maintenance resume document exceeds ${MAX_MAINTENANCE_RESUME_BYTES} bytes`);
788
+ return {
789
+ document: normalized,
790
+ data,
791
+ descriptor: {
792
+ version: MAINTENANCE_RESUME_DOCUMENT_VERSION,
793
+ file: "resume.json",
794
+ bytes,
795
+ sha256: createHash("sha256").update(data).digest("hex"),
796
+ },
797
+ };
798
+ }
799
+ function validResumeDescriptor(value) {
800
+ const descriptor = value;
801
+ return Boolean(descriptor && typeof descriptor === "object" &&
802
+ descriptor.version === MAINTENANCE_RESUME_DOCUMENT_VERSION && descriptor.file === "resume.json" &&
803
+ Number.isInteger(descriptor.bytes) && descriptor.bytes > 0 && descriptor.bytes <= MAX_MAINTENANCE_RESUME_BYTES &&
804
+ typeof descriptor.sha256 === "string" && /^[0-9a-f]{64}$/.test(descriptor.sha256));
805
+ }
806
+ function sameResumeDescriptor(a, b) {
807
+ return a.version === b.version && a.file === b.file && a.bytes === b.bytes && a.sha256 === b.sha256;
808
+ }
809
+ /** Atomically persist the bounded private resume inventory and launch provenance before publishing ready. */
810
+ export function writeMaintenanceResumeDocument(lock, document) {
811
+ assertLock(lock);
812
+ const paths = maintenancePaths(lock.root);
813
+ ensureLayout(paths);
814
+ const serialized = resumeBytes(document);
815
+ const journal = readMaintenanceJournal(lock.root);
816
+ if (journal) {
817
+ if (!sameResumeDescriptor(journal.resume, serialized.descriptor))
818
+ maintenanceError("invalid-transition", "cannot replace the resume document referenced by a maintenance journal", {
819
+ root: lock.root,
820
+ paths: [paths.resume, paths.journal],
821
+ recourse: [{ action: "repair", description: "Complete or explicitly repair the current maintenance state before writing another resume document.", paths: [paths.journal] }],
822
+ });
823
+ return journal.resume;
824
+ }
825
+ const tmp = `${paths.resume}.${process.pid}.${randomUUID()}.tmp`;
826
+ try {
827
+ writePrivateExclusive(tmp, serialized.data);
828
+ renameSync(tmp, paths.resume);
829
+ fsyncDirectory(paths.versionDir);
830
+ }
831
+ catch (error) {
832
+ try {
833
+ unlinkSync(tmp);
834
+ }
835
+ catch { /* best effort for an exclusively-created private temp */ }
836
+ throw error;
837
+ }
838
+ readMaintenanceResumeDocument(lock.root, serialized.descriptor);
839
+ return serialized.descriptor;
840
+ }
841
+ /** Read exactly the fixed resume file after size, mode, SHA-256, version, and JSON-shape verification. */
842
+ export function readMaintenanceResumeDocument(root, descriptor) {
843
+ const paths = maintenancePaths(root);
844
+ if (!validResumeDescriptor(descriptor))
845
+ maintenanceError("resume-invalid", "maintenance resume descriptor is invalid", { paths: [paths.journal] });
846
+ let fd;
847
+ try {
848
+ const noFollow = constants.O_NOFOLLOW ?? 0;
849
+ fd = openSync(paths.resume, constants.O_RDONLY | noFollow);
850
+ }
851
+ catch (error) {
852
+ if (errno(error) === "ENOENT")
853
+ maintenanceError("resume-missing", "maintenance resume document is missing", {
854
+ root: paths.root,
855
+ paths: [paths.resume],
856
+ recourse: [{ action: "repair", description: "Restore the exact resume document before using this maintenance cut.", paths: [paths.resume] }],
857
+ });
858
+ if (errno(error) === "ELOOP")
859
+ maintenanceError("resume-mismatch", "maintenance resume document must not be a symlink", { paths: [paths.resume] });
860
+ maintenanceError("resume-invalid", "maintenance resume document cannot be inspected", { paths: [paths.resume] });
861
+ }
862
+ let bytes;
863
+ try {
864
+ const before = fstatSync(fd, { bigint: true });
865
+ if (!before.isFile() || before.size !== BigInt(descriptor.bytes) ||
866
+ before.size > BigInt(MAX_MAINTENANCE_RESUME_BYTES) ||
867
+ (process.platform !== "win32" && (before.mode & 63n) !== 0n))
868
+ maintenanceError("resume-mismatch", "maintenance resume document metadata does not match its descriptor", {
869
+ root: paths.root,
870
+ paths: [paths.resume],
871
+ recourse: [{ action: "inspect", description: "Do not resume or restore from a missing, replaced, or non-private document.", paths: [paths.resume] }],
872
+ });
873
+ const bounded = Buffer.alloc(descriptor.bytes + 1);
874
+ let length = 0;
875
+ while (length < bounded.length) {
876
+ const count = readSync(fd, bounded, length, bounded.length - length, null);
877
+ if (count === 0)
878
+ break;
879
+ length += count;
880
+ }
881
+ const after = fstatSync(fd, { bigint: true });
882
+ if (length !== descriptor.bytes || before.dev !== after.dev || before.ino !== after.ino ||
883
+ before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs)
884
+ maintenanceError("resume-mismatch", "maintenance resume document changed while it was being read", {
885
+ root: paths.root, paths: [paths.resume],
886
+ });
887
+ bytes = bounded.subarray(0, length);
888
+ }
889
+ finally {
890
+ closeSync(fd);
891
+ }
892
+ const digest = createHash("sha256").update(bytes).digest("hex");
893
+ if (digest !== descriptor.sha256)
894
+ maintenanceError("resume-mismatch", "maintenance resume document SHA-256 does not match its descriptor", {
895
+ root: paths.root,
896
+ paths: [paths.resume],
897
+ recourse: [{ action: "inspect", description: "Restore the exact content-addressed resume document before continuing.", paths: [paths.resume] }],
898
+ });
899
+ let parsed;
900
+ try {
901
+ parsed = JSON.parse(bytes.toString("utf8"));
902
+ }
903
+ catch {
904
+ maintenanceError("resume-invalid", "maintenance resume document is not valid JSON", { paths: [paths.resume] });
905
+ }
906
+ const normalized = resumeBytes(parsed).document;
907
+ return normalized;
908
+ }
909
+ function validRecourse(value) {
910
+ return Array.isArray(value) && value.every((item) => {
911
+ const r = item;
912
+ return Boolean(r && typeof r === "object" &&
913
+ ["retry", "inspect", "rollback", "repair", "cleanup"].includes(r.action) &&
914
+ typeof r.description === "string" &&
915
+ (r.command === undefined || typeof r.command === "string") &&
916
+ (r.paths === undefined || (Array.isArray(r.paths) && r.paths.every((p) => typeof p === "string" && isAbsolute(p)))));
917
+ });
918
+ }
919
+ function validateRestoreContext(record, path) {
920
+ const restore = record.restore;
921
+ if (!restore || typeof restore.attemptId !== "string" || !ATTEMPT_ID.test(restore.attemptId) ||
922
+ !["same-path", "alternate", "disaster"].includes(restore.method) ||
923
+ typeof restore.targetPath !== "string" || !isAbsolute(restore.targetPath))
924
+ maintenanceError("journal-corrupt", "restore context is invalid", { paths: [path] });
925
+ if (restore.target && (!validIdentity(restore.target) || restore.target.path !== restore.targetPath))
926
+ maintenanceError("journal-corrupt", "restore target identity is invalid", { paths: [path] });
927
+ if (restore.previousSource &&
928
+ (!["fallback", "retained"].includes(restore.previousSource.kind) || !validIdentity(restore.previousSource.identity)))
929
+ maintenanceError("journal-corrupt", "previous source identity is invalid", { paths: [path] });
930
+ if (restore.method === "same-path") {
931
+ if (restore.targetPath !== record.source.path || typeof restore.fallbackPath !== "string" ||
932
+ !isAbsolute(restore.fallbackPath) || dirname(restore.fallbackPath) !== dirname(record.source.path))
933
+ maintenanceError("journal-corrupt", "same-path restore paths are inconsistent", { paths: [path] });
934
+ if (record.state === "restore-ready" && !["move-pending", "source-moved"].includes(record.phase))
935
+ maintenanceError("journal-corrupt", "same-path restore phase is inconsistent", { paths: [path] });
936
+ if (record.state === "restore-ready" && record.phase === "move-pending" &&
937
+ (restore.previousSource || restore.target || restore.cleanup))
938
+ maintenanceError("journal-corrupt", "pending move unexpectedly records bound or owned paths", { paths: [path] });
939
+ if ((record.state !== "restore-ready" || record.phase === "source-moved") &&
940
+ (!restore.previousSource || restore.previousSource.kind !== "fallback" ||
941
+ restore.previousSource.identity.path !== restore.fallbackPath ||
942
+ restore.previousSource.identity.dev !== record.source.dev || restore.previousSource.identity.ino !== record.source.ino ||
943
+ restore.previousSource.identity.generation !== record.source.generation))
944
+ maintenanceError("journal-corrupt", "moved source identity is inconsistent", { paths: [path] });
945
+ }
946
+ else if (restore.method === "alternate") {
947
+ if (restore.targetPath === record.source.path || restore.fallbackPath !== undefined ||
948
+ !restore.previousSource || restore.previousSource.kind !== "retained" ||
949
+ !sameStoreIdentity(restore.previousSource.identity, record.source) ||
950
+ (record.state === "restore-ready" && record.phase !== "source-retained"))
951
+ maintenanceError("journal-corrupt", "alternate restore context is inconsistent", { paths: [path] });
952
+ if (pathContains(record.source.path, restore.targetPath) || pathContains(restore.targetPath, record.source.path))
953
+ maintenanceError("journal-corrupt", "alternate restore source and target trees overlap", {
954
+ paths: [path, record.source.path, restore.targetPath],
955
+ });
956
+ }
957
+ else if (restore.targetPath !== record.source.path || restore.fallbackPath !== undefined ||
958
+ restore.previousSource !== undefined ||
959
+ (record.state === "restore-ready" && record.phase !== "disaster-source-missing")) {
960
+ maintenanceError("journal-corrupt", "disaster restore context is inconsistent", { paths: [path] });
961
+ }
962
+ if (restore.ownedPaths !== undefined && !validOwnedPaths(restore.ownedPaths))
963
+ maintenanceError("journal-corrupt", "restore attempt-owned paths are invalid", { paths: [path] });
964
+ if (restore.cleanup) {
965
+ const cleanup = restore.cleanup;
966
+ if (!["attempt-target", "previous-source"].includes(cleanup.kind) ||
967
+ !["pending", "complete"].includes(cleanup.status) || !validIdentity(cleanup.identity) ||
968
+ cleanup.originalPath !== cleanup.identity.path || !isAbsolute(cleanup.tombPath) ||
969
+ dirname(cleanup.tombPath) !== dirname(cleanup.originalPath) ||
970
+ (cleanup.kind === "attempt-target" && (!restore.target || !sameStoreIdentity(cleanup.identity, restore.target))) ||
971
+ (cleanup.kind === "previous-source" && (!restore.previousSource || !sameStoreIdentity(cleanup.identity, restore.previousSource.identity))))
972
+ maintenanceError("journal-corrupt", "restore cleanup record is inconsistent", { paths: [path] });
973
+ }
974
+ }
975
+ function validateOrdinaryResume(record, path) {
976
+ const context = record.ordinaryResume;
977
+ if (!context || typeof context !== "object" || typeof context.attemptId !== "string" ||
978
+ !ATTEMPT_ID.test(context.attemptId) || typeof context.intentAt !== "string" ||
979
+ !Number.isFinite(Date.parse(context.intentAt)) || !validJsonObject(context.launch))
980
+ maintenanceError("journal-corrupt", "ordinary resume intent is invalid", { paths: [path] });
981
+ if (record.listenerProof !== undefined &&
982
+ (!validListenerProof(record.listenerProof) ||
983
+ record.listenerProof.attemptId !== context.attemptId ||
984
+ !sameStoreIdentity(record.listenerProof.target, record.source) ||
985
+ typeof context.launch.server !== "string" ||
986
+ canonicalListenerEndpoint(context.launch.server) !== record.listenerProof.serverEndpoint))
987
+ maintenanceError("journal-corrupt", "resume listener proof is invalid or belongs to another attempt", { paths: [path] });
988
+ if (record.listenerReplacements !== undefined &&
989
+ (!validListenerReplacements(record.listenerReplacements) ||
990
+ record.listenerReplacements.some((replacement) => replacement.proof.attemptId !== context.attemptId ||
991
+ !sameStoreIdentity(replacement.proof.target, record.source) ||
992
+ typeof context.launch.server !== "string" ||
993
+ canonicalListenerEndpoint(context.launch.server) !== replacement.proof.serverEndpoint)))
994
+ maintenanceError("journal-corrupt", "resume listener replacement history is invalid", { paths: [path] });
995
+ const currentResumeProof = record.listenerProof;
996
+ if (currentResumeProof && record.listenerReplacements?.some((replacement) => reusesListenerIdentity(replacement.proof, currentResumeProof)))
997
+ maintenanceError("journal-corrupt", "current resume listener reuses a retired identity", { paths: [path] });
998
+ if (record.state === "resume-intent") {
999
+ const extra = record;
1000
+ if (extra.activeAt !== undefined || extra.activation !== undefined || extra.degradedAt !== undefined ||
1001
+ extra.reason !== undefined || extra.recourse !== undefined || extra.managerCommittedAt !== undefined ||
1002
+ extra.managerCommit !== undefined || extra.retiredAt !== undefined || extra.retirement !== undefined)
1003
+ maintenanceError("journal-corrupt", "ordinary resume intent contains later-phase fields", { paths: [path] });
1004
+ return;
1005
+ }
1006
+ if (record.state === "resume-active") {
1007
+ if (typeof record.activeAt !== "string" || !Number.isFinite(Date.parse(record.activeAt)) ||
1008
+ !validOrdinaryResumeActivationEvidence(record.activation, context.attemptId))
1009
+ maintenanceError("journal-corrupt", "active ordinary resume is invalid", { paths: [path] });
1010
+ return;
1011
+ }
1012
+ if (record.state === "resume-committed") {
1013
+ if (typeof record.activeAt !== "string" || !Number.isFinite(Date.parse(record.activeAt)) ||
1014
+ !validOrdinaryResumeActivationEvidence(record.activation, context.attemptId) ||
1015
+ typeof record.managerCommittedAt !== "string" || !Number.isFinite(Date.parse(record.managerCommittedAt)) ||
1016
+ !validManagerCommitEvidence(record.managerCommit, context.attemptId))
1017
+ maintenanceError("journal-corrupt", "manager-committed ordinary resume is invalid", { paths: [path] });
1018
+ return;
1019
+ }
1020
+ if (record.state === "resume-degraded") {
1021
+ const hasActiveAt = record.activeAt !== undefined;
1022
+ const hasActivation = record.activation !== undefined;
1023
+ if (hasActiveAt !== hasActivation ||
1024
+ (hasActiveAt && (typeof record.activeAt !== "string" || !Number.isFinite(Date.parse(record.activeAt)) ||
1025
+ !validOrdinaryResumeActivationEvidence(record.activation, context.attemptId))) ||
1026
+ ((record.managerCommittedAt === undefined) !== (record.managerCommit === undefined)) ||
1027
+ (record.managerCommittedAt !== undefined &&
1028
+ (!validTimestamp(record.managerCommittedAt) ||
1029
+ !validManagerCommitEvidence(record.managerCommit, context.attemptId))) ||
1030
+ typeof record.degradedAt !== "string" || !Number.isFinite(Date.parse(record.degradedAt)) ||
1031
+ typeof record.reason !== "string" ||
1032
+ !record.reason || !validRecourse(record.recourse))
1033
+ maintenanceError("journal-corrupt", "degraded ordinary resume is invalid", { paths: [path] });
1034
+ return;
1035
+ }
1036
+ if (typeof record.activeAt !== "string" || !Number.isFinite(Date.parse(record.activeAt)) ||
1037
+ !validOrdinaryResumeActivationEvidence(record.activation, context.attemptId) ||
1038
+ typeof record.managerCommittedAt !== "string" || !Number.isFinite(Date.parse(record.managerCommittedAt)) ||
1039
+ !validManagerCommitEvidence(record.managerCommit, context.attemptId) ||
1040
+ typeof record.retiredAt !== "string" || !Number.isFinite(Date.parse(record.retiredAt)) ||
1041
+ !validManagerFinalizeEvidence(record.retirement, context.attemptId, record.managerCommit.durableCommitToken))
1042
+ maintenanceError("journal-corrupt", "retired ordinary resume is invalid", { paths: [path] });
1043
+ }
1044
+ function validateJournal(value, path) {
1045
+ const record = value;
1046
+ if (!record || typeof record !== "object")
1047
+ maintenanceError("journal-corrupt", "maintenance journal is not an object", { paths: [path] });
1048
+ if (record.version !== MAINTENANCE_JOURNAL_VERSION)
1049
+ maintenanceError("journal-version", "unsupported maintenance journal version", { paths: [path] });
1050
+ if (!Number.isInteger(record.revision) || record.revision <= 0 || typeof record.updatedAt !== "string" ||
1051
+ !Number.isFinite(Date.parse(record.updatedAt)) ||
1052
+ typeof record.space !== "string" || !record.space || !["auth", "open", "user"].includes(record.mode) ||
1053
+ !validIdentity(record.source) || !validResumeDescriptor(record.resume) || !validCutContext(record.cut))
1054
+ maintenanceError("journal-corrupt", "maintenance journal base fields are invalid", { paths: [path] });
1055
+ if (!["cut-intent", "cut-committed", "ready", "claimed", "restore-ready", "commit-intent", "manager-committed", "active", "degraded",
1056
+ "resume-intent", "resume-active", "resume-committed", "resume-degraded", "resume-retired"].includes(record.state))
1057
+ maintenanceError("journal-corrupt", "maintenance journal state is invalid", { paths: [path] });
1058
+ const completion = record.cutCompletion;
1059
+ if (record.state === "cut-intent" || record.state === "cut-committed") {
1060
+ if (completion !== undefined)
1061
+ maintenanceError("journal-corrupt", "an uncompleted cut cannot contain completion evidence", { paths: [path] });
1062
+ }
1063
+ else if (!validCutCompletionEvidence(completion, record.cut)) {
1064
+ maintenanceError("journal-corrupt", "completed maintenance state has invalid cut evidence", { paths: [path] });
1065
+ }
1066
+ if (record.state === "cut-committed") {
1067
+ const committed = record;
1068
+ const manager = committed.managerCommit;
1069
+ if (!validTimestamp(committed.managerCommittedAt) || !manager || typeof manager !== "object" ||
1070
+ !exactObjectKeys(manager, ["operation", "attemptId", "state"]) ||
1071
+ committed.managerCommit.operation !== "commitPreservation" ||
1072
+ committed.managerCommit.attemptId !== record.cut.attemptId ||
1073
+ committed.managerCommit.state !== "preserved")
1074
+ maintenanceError("journal-corrupt", "cut-committed manager evidence is invalid", { paths: [path] });
1075
+ }
1076
+ if (record.state === "claimed") {
1077
+ const claim = record.claim;
1078
+ if (!claim || typeof claim.attemptId !== "string" || !ATTEMPT_ID.test(claim.attemptId) ||
1079
+ typeof claim.deadline !== "string" || !Number.isFinite(Date.parse(claim.deadline)) ||
1080
+ !Array.isArray(claim.owners) || claim.owners.length > 16)
1081
+ maintenanceError("journal-corrupt", "maintenance claim is invalid", { paths: [path] });
1082
+ parseOwner(claim.coordinator, "claim coordinator");
1083
+ for (const claimOwner of claim.owners)
1084
+ parseOwner(claimOwner, "claim owner");
1085
+ if (claim.ownedPaths !== undefined && !validOwnedPaths(claim.ownedPaths))
1086
+ maintenanceError("journal-corrupt", "maintenance claim owned paths are invalid", { paths: [path] });
1087
+ }
1088
+ if (record.state === "restore-ready" && !validRestoreClaim(record.claim))
1089
+ maintenanceError("journal-corrupt", "restore-ready record has no valid liveness claim", { paths: [path] });
1090
+ if (["restore-ready", "commit-intent", "manager-committed", "active", "degraded"].includes(record.state)) {
1091
+ const restoreRecord = record;
1092
+ if (restoreRecord.state === "restore-ready" && !["move-pending", "source-moved", "source-retained", "disaster-source-missing"].includes(restoreRecord.phase))
1093
+ maintenanceError("journal-corrupt", "restore phase is invalid", { paths: [path] });
1094
+ if (restoreRecord.state !== "restore-ready" && !restoreRecord.restore.target)
1095
+ maintenanceError("journal-corrupt", "committed restore has no target identity", { paths: [path] });
1096
+ validateRestoreContext(restoreRecord, path);
1097
+ if (restoreRecord.state !== "restore-ready" &&
1098
+ !validJsonObject(restoreRecord.launch))
1099
+ maintenanceError("journal-corrupt", "restore launch metadata is invalid", { paths: [path] });
1100
+ if (restoreRecord.state !== "restore-ready" && restoreRecord.listenerProof !== undefined &&
1101
+ (!validListenerProof(restoreRecord.listenerProof) ||
1102
+ restoreRecord.listenerProof.attemptId !== restoreRecord.restore.attemptId ||
1103
+ !sameStoreIdentity(restoreRecord.listenerProof.target, restoreRecord.restore.target) ||
1104
+ typeof restoreRecord.launch.server !== "string" ||
1105
+ canonicalListenerEndpoint(restoreRecord.launch.server) !== restoreRecord.listenerProof.serverEndpoint))
1106
+ maintenanceError("journal-corrupt", "restore listener proof is invalid or belongs to another attempt", { paths: [path] });
1107
+ if (restoreRecord.state !== "restore-ready") {
1108
+ const committed = restoreRecord;
1109
+ if (committed.listenerReplacements !== undefined &&
1110
+ (!validListenerReplacements(committed.listenerReplacements) ||
1111
+ committed.listenerReplacements.some((replacement) => replacement.proof.attemptId !== committed.restore.attemptId ||
1112
+ !sameStoreIdentity(replacement.proof.target, committed.restore.target) ||
1113
+ typeof committed.launch.server !== "string" ||
1114
+ canonicalListenerEndpoint(committed.launch.server) !== replacement.proof.serverEndpoint)))
1115
+ maintenanceError("journal-corrupt", "restore listener replacement history is invalid", { paths: [path] });
1116
+ const currentProof = committed.listenerProof;
1117
+ if (currentProof && committed.listenerReplacements?.some((replacement) => reusesListenerIdentity(replacement.proof, currentProof)))
1118
+ maintenanceError("journal-corrupt", "current restore listener reuses a retired identity", { paths: [path] });
1119
+ }
1120
+ if (restoreRecord.state === "manager-committed" &&
1121
+ (!restoreRecord.listenerProof || !validTimestamp(restoreRecord.managerCommittedAt) ||
1122
+ !validManagerCommitEvidence(restoreRecord.managerCommit, restoreRecord.restore.attemptId)))
1123
+ maintenanceError("journal-corrupt", "manager-committed restore evidence is invalid", { paths: [path] });
1124
+ if (restoreRecord.state === "active" &&
1125
+ (!validTimestamp(restoreRecord.managerCommittedAt) ||
1126
+ !validManagerCommitEvidence(restoreRecord.managerCommit, restoreRecord.restore.attemptId) ||
1127
+ !Number.isFinite(Date.parse(restoreRecord.activeAt)) || !restoreRecord.listenerProof ||
1128
+ !validRestoreActivationEvidence(restoreRecord.details, restoreRecord.restore.attemptId) ||
1129
+ !sameManagerCommitEvidence(restoreRecord.managerCommit, restoreRecord.details.managerCommit)))
1130
+ maintenanceError("journal-corrupt", "active restore details are invalid", { paths: [path] });
1131
+ if (restoreRecord.state === "degraded" &&
1132
+ (((restoreRecord.managerCommittedAt === undefined) !== (restoreRecord.managerCommit === undefined)) ||
1133
+ (restoreRecord.managerCommittedAt !== undefined &&
1134
+ (!restoreRecord.listenerProof || !validTimestamp(restoreRecord.managerCommittedAt) ||
1135
+ !validManagerCommitEvidence(restoreRecord.managerCommit, restoreRecord.restore.attemptId))) ||
1136
+ (restoreRecord.activeAt !== undefined && !Number.isFinite(Date.parse(restoreRecord.activeAt))) ||
1137
+ (restoreRecord.activeAt !== undefined && (!restoreRecord.listenerProof ||
1138
+ !validRestoreActivationEvidence(restoreRecord.details, restoreRecord.restore.attemptId))) ||
1139
+ (restoreRecord.activeAt !== undefined &&
1140
+ (restoreRecord.managerCommit === undefined ||
1141
+ !sameManagerCommitEvidence(restoreRecord.managerCommit, restoreRecord.details.managerCommit))) ||
1142
+ (restoreRecord.activeAt === undefined && restoreRecord.details !== undefined) ||
1143
+ !Number.isFinite(Date.parse(restoreRecord.degradedAt)) ||
1144
+ typeof restoreRecord.reason !== "string" || !validRecourse(restoreRecord.recourse)))
1145
+ maintenanceError("journal-corrupt", "degraded restore recourse is invalid", { paths: [path] });
1146
+ }
1147
+ if (["resume-intent", "resume-active", "resume-committed", "resume-degraded", "resume-retired"].includes(record.state))
1148
+ validateOrdinaryResume(record, path);
1149
+ return record;
1150
+ }
1151
+ export function readMaintenanceJournal(root) {
1152
+ const paths = maintenancePaths(root);
1153
+ let raw;
1154
+ try {
1155
+ const stat = lstatSync(paths.journal);
1156
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
1157
+ maintenanceError("journal-corrupt", "maintenance journal is not a bounded regular file", { paths: [paths.journal] });
1158
+ raw = readFileSync(paths.journal, "utf8");
1159
+ }
1160
+ catch (error) {
1161
+ if (errno(error) === "ENOENT")
1162
+ return undefined;
1163
+ if (isMaintenanceError(error))
1164
+ throw error;
1165
+ maintenanceError("journal-corrupt", "maintenance journal cannot be read", { paths: [paths.journal] });
1166
+ }
1167
+ try {
1168
+ const record = validateJournal(JSON.parse(raw), paths.journal);
1169
+ readMaintenanceResumeDocument(paths.root, record.resume);
1170
+ return record;
1171
+ }
1172
+ catch (error) {
1173
+ if (isMaintenanceError(error))
1174
+ throw error;
1175
+ maintenanceError("journal-corrupt", "maintenance journal cannot be parsed", { paths: [paths.journal] });
1176
+ }
1177
+ }
1178
+ function currentJournal(lock) {
1179
+ assertLock(lock);
1180
+ const record = readMaintenanceJournal(lock.root);
1181
+ if (!record)
1182
+ maintenanceError("journal-missing", "maintenance journal does not exist", {
1183
+ root: lock.root,
1184
+ recourse: [{ action: "repair", description: "Complete a preserve-state cut before backup or restore." }],
1185
+ });
1186
+ return record;
1187
+ }
1188
+ function writeJournal(lock, previous, next) {
1189
+ assertLock(lock);
1190
+ const paths = maintenancePaths(lock.root);
1191
+ ensureLayout(paths);
1192
+ const record = {
1193
+ ...next,
1194
+ version: MAINTENANCE_JOURNAL_VERSION,
1195
+ revision: (previous?.revision ?? 0) + 1,
1196
+ updatedAt: new Date().toISOString(),
1197
+ };
1198
+ validateJournal(record, paths.journal);
1199
+ readMaintenanceResumeDocument(lock.root, record.resume);
1200
+ atomicWrite(paths.journal, record);
1201
+ return record;
1202
+ }
1203
+ function readyFrom(record) {
1204
+ return {
1205
+ state: "ready", space: record.space, mode: record.mode, source: record.source, resume: record.resume,
1206
+ cut: record.cut, cutCompletion: record.cutCompletion,
1207
+ };
1208
+ }
1209
+ function validPrepareIntent(value) {
1210
+ const intent = value;
1211
+ return Boolean(intent && typeof intent === "object" &&
1212
+ exactObjectKeys(intent, ["attemptId", "space", "mode", "server", "storeDir"]) &&
1213
+ typeof intent.attemptId === "string" && ATTEMPT_ID.test(intent.attemptId) &&
1214
+ typeof intent.space === "string" && intent.space &&
1215
+ ["auth", "open", "user"].includes(intent.mode) &&
1216
+ typeof intent.server === "string" && intent.server &&
1217
+ typeof intent.storeDir === "string" && isAbsolute(intent.storeDir));
1218
+ }
1219
+ export function writePreservationPrepareIntent(lock, intent) {
1220
+ assertLock(lock);
1221
+ if (!validPrepareIntent(intent))
1222
+ maintenanceError("invalid-transition", "preservation prepare intent is invalid", { root: lock.root });
1223
+ const paths = maintenancePaths(lock.root);
1224
+ ensureLayout(paths);
1225
+ if (readMaintenanceJournal(lock.root))
1226
+ maintenanceError("invalid-transition", "a maintenance journal already binds this attempt; prepare intent is stale", {
1227
+ root: lock.root, paths: [paths.journal],
1228
+ });
1229
+ atomicWrite(paths.prepareIntent, intent);
1230
+ }
1231
+ export function readPreservationPrepareIntent(root) {
1232
+ const paths = maintenancePaths(root);
1233
+ let raw;
1234
+ try {
1235
+ const stat = lstatSync(paths.prepareIntent);
1236
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024)
1237
+ maintenanceError("journal-corrupt", "preservation prepare intent is not a bounded regular file", { paths: [paths.prepareIntent] });
1238
+ raw = readFileSync(paths.prepareIntent, "utf8");
1239
+ }
1240
+ catch (error) {
1241
+ if (errno(error) === "ENOENT")
1242
+ return undefined;
1243
+ if (isMaintenanceError(error))
1244
+ throw error;
1245
+ maintenanceError("journal-corrupt", "preservation prepare intent cannot be read", { paths: [paths.prepareIntent] });
1246
+ }
1247
+ let parsed;
1248
+ try {
1249
+ parsed = JSON.parse(raw);
1250
+ }
1251
+ catch {
1252
+ maintenanceError("journal-corrupt", "preservation prepare intent cannot be parsed", { paths: [paths.prepareIntent] });
1253
+ }
1254
+ if (!validPrepareIntent(parsed))
1255
+ maintenanceError("journal-corrupt", "preservation prepare intent is invalid", { paths: [paths.prepareIntent] });
1256
+ return parsed;
1257
+ }
1258
+ export function clearPreservationPrepareIntent(lock) {
1259
+ assertLock(lock);
1260
+ const paths = maintenancePaths(lock.root);
1261
+ try {
1262
+ unlinkSync(paths.prepareIntent);
1263
+ fsyncDirectory(paths.versionDir);
1264
+ }
1265
+ catch (error) {
1266
+ if (errno(error) !== "ENOENT")
1267
+ throw error;
1268
+ }
1269
+ }
1270
+ function validCommitIntent(value) {
1271
+ const intent = value;
1272
+ return Boolean(intent && typeof intent === "object" &&
1273
+ exactObjectKeys(intent, ["attemptId"]) &&
1274
+ typeof intent.attemptId === "string" && ATTEMPT_ID.test(intent.attemptId));
1275
+ }
1276
+ export function writePreservationCommitIntent(lock, intent) {
1277
+ assertLock(lock);
1278
+ if (!validCommitIntent(intent))
1279
+ maintenanceError("invalid-transition", "preservation commit intent is invalid", { root: lock.root });
1280
+ const paths = maintenancePaths(lock.root);
1281
+ ensureLayout(paths);
1282
+ const journal = readMaintenanceJournal(lock.root);
1283
+ if (!journal || journal.state !== "cut-intent" || journal.cut.attemptId !== intent.attemptId)
1284
+ maintenanceError("invalid-transition", "commit intent requires a cut-intent journal for the same attempt", {
1285
+ root: lock.root, paths: [paths.journal],
1286
+ });
1287
+ atomicWrite(paths.commitIntent, intent);
1288
+ }
1289
+ export function readPreservationCommitIntent(root) {
1290
+ const paths = maintenancePaths(root);
1291
+ let raw;
1292
+ try {
1293
+ const stat = lstatSync(paths.commitIntent);
1294
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024)
1295
+ maintenanceError("journal-corrupt", "preservation commit intent is not a bounded regular file", { paths: [paths.commitIntent] });
1296
+ raw = readFileSync(paths.commitIntent, "utf8");
1297
+ }
1298
+ catch (error) {
1299
+ if (errno(error) === "ENOENT")
1300
+ return undefined;
1301
+ if (isMaintenanceError(error))
1302
+ throw error;
1303
+ maintenanceError("journal-corrupt", "preservation commit intent cannot be read", { paths: [paths.commitIntent] });
1304
+ }
1305
+ let parsed;
1306
+ try {
1307
+ parsed = JSON.parse(raw);
1308
+ }
1309
+ catch {
1310
+ maintenanceError("journal-corrupt", "preservation commit intent cannot be parsed", { paths: [paths.commitIntent] });
1311
+ }
1312
+ if (!validCommitIntent(parsed))
1313
+ maintenanceError("journal-corrupt", "preservation commit intent is invalid", { paths: [paths.commitIntent] });
1314
+ return parsed;
1315
+ }
1316
+ export function clearPreservationCommitIntent(lock) {
1317
+ assertLock(lock);
1318
+ const paths = maintenancePaths(lock.root);
1319
+ try {
1320
+ unlinkSync(paths.commitIntent);
1321
+ fsyncDirectory(paths.versionDir);
1322
+ }
1323
+ catch (error) {
1324
+ if (errno(error) !== "ENOENT")
1325
+ throw error;
1326
+ }
1327
+ }
1328
+ /** Persist source identity and launch provenance before manager commitment or any process stop. */
1329
+ export function beginMaintenanceCut(lock, input) {
1330
+ assertLock(lock);
1331
+ if (!ATTEMPT_ID.test(input.attemptId))
1332
+ maintenanceError("invalid-transition", "maintenance cut attempt id must be a safe token", {
1333
+ root: lock.root, attemptId: input.attemptId,
1334
+ });
1335
+ const previous = readMaintenanceJournal(lock.root);
1336
+ readMaintenanceResumeDocument(lock.root, input.resume);
1337
+ const source = ensureStoreIdentity(input.sourcePath);
1338
+ const launch = jsonObject(input.launch);
1339
+ if (typeof launch.server !== "string" || canonicalListenerEndpoint(launch.server) !== launch.server)
1340
+ maintenanceError("invalid-transition", "maintenance cut launch requires a canonical normal listener endpoint", {
1341
+ root: lock.root, attemptId: input.attemptId,
1342
+ });
1343
+ if (previous) {
1344
+ if (previous.state !== "cut-intent")
1345
+ maintenanceError("invalid-transition", `cannot begin a maintenance cut from ${previous.state}`, { root: lock.root });
1346
+ if (previous.cut.attemptId !== input.attemptId || previous.space !== input.space || previous.mode !== input.mode ||
1347
+ !sameStoreIdentity(previous.source, source) || !sameResumeDescriptor(previous.resume, input.resume) ||
1348
+ !sameJsonValue(previous.cut.launch, launch))
1349
+ maintenanceError("identity-mismatch", "maintenance cut retry does not exactly match the durable intent", {
1350
+ root: lock.root, attemptId: input.attemptId, expected: previous.source, actual: source,
1351
+ });
1352
+ return previous;
1353
+ }
1354
+ const cut = {
1355
+ attemptId: input.attemptId, intentAt: new Date().toISOString(), launch,
1356
+ };
1357
+ return writeJournal(lock, undefined, {
1358
+ state: "cut-intent", space: input.space, mode: input.mode, source, resume: input.resume, cut,
1359
+ });
1360
+ }
1361
+ /** Abandon an uncommitted cut: before `cut-committed`, no suppression is durable and no process was
1362
+ * stopped by the cut, so the intent may be safely abandoned instead of wedging on lost manager
1363
+ * memory. The content-addressed resume document is left in place for inspection. */
1364
+ export function abortMaintenanceCut(lock) {
1365
+ const record = currentJournal(lock);
1366
+ if (record.state !== "cut-intent")
1367
+ maintenanceError("invalid-transition", `only an uncommitted cut intent can be aborted, not ${record.state}`, {
1368
+ root: lock.root, paths: [record.source.path],
1369
+ });
1370
+ const paths = maintenancePaths(lock.root);
1371
+ unlinkSync(paths.journal);
1372
+ fsyncDirectory(paths.versionDir);
1373
+ return record;
1374
+ }
1375
+ /** Fsync the manager's preservation commitment BEFORE any process stop, so a crash between manager
1376
+ * commit and the ready promotion recovers idempotently without requiring a live manager. */
1377
+ export function recordPreservationManagerCommit(lock, managerCommit) {
1378
+ const record = currentJournal(lock);
1379
+ const valid = managerCommit && typeof managerCommit === "object" &&
1380
+ exactObjectKeys(managerCommit, ["operation", "attemptId", "state"]) &&
1381
+ managerCommit.operation === "commitPreservation" && managerCommit.attemptId === record.cut.attemptId &&
1382
+ managerCommit.state === "preserved";
1383
+ if (record.state === "cut-committed") {
1384
+ if (!valid)
1385
+ maintenanceError("activation-evidence-invalid", "preservation commit retry does not match the durable cut", {
1386
+ root: lock.root, attemptId: record.cut.attemptId, paths: [record.source.path],
1387
+ });
1388
+ assertStoreIdentity(record.source);
1389
+ return record;
1390
+ }
1391
+ if (record.state !== "cut-intent")
1392
+ maintenanceError("invalid-transition", `cannot record preservation commitment from ${record.state}`, {
1393
+ root: lock.root, paths: [record.source.path],
1394
+ });
1395
+ if (!valid)
1396
+ maintenanceError("activation-evidence-invalid", "preservation commitment evidence is invalid or belongs to another attempt", {
1397
+ root: lock.root, attemptId: record.cut.attemptId, paths: [record.source.path],
1398
+ });
1399
+ assertStoreIdentity(record.source);
1400
+ return writeJournal(lock, record, {
1401
+ state: "cut-committed", space: record.space, mode: record.mode, source: record.source,
1402
+ resume: record.resume, cut: record.cut,
1403
+ managerCommittedAt: new Date().toISOString(), managerCommit,
1404
+ });
1405
+ }
1406
+ /** Promote only the exact durable cut after all stopped/unreachable evidence has been supplied. */
1407
+ export function completeMaintenanceCut(lock, evidence) {
1408
+ const record = currentJournal(lock);
1409
+ if (record.state === "ready") {
1410
+ if (!validCutCompletionEvidence(evidence, record.cut))
1411
+ maintenanceError("activation-evidence-invalid", "maintenance cut completion retry does not match ready", {
1412
+ root: lock.root, attemptId: record.cut.attemptId, paths: [record.source.path],
1413
+ });
1414
+ assertStoreIdentity(record.source);
1415
+ return record;
1416
+ }
1417
+ if (record.state !== "cut-committed")
1418
+ maintenanceError("invalid-transition", `cannot complete a maintenance cut from ${record.state}`, {
1419
+ root: lock.root, paths: [record.source.path],
1420
+ });
1421
+ if (!validCutCompletionEvidence(evidence, record.cut) ||
1422
+ evidence.managerCommit.attemptId !== record.managerCommit.attemptId)
1423
+ maintenanceError("activation-evidence-invalid", "maintenance cut requires exact stopped and unreachable evidence", {
1424
+ root: lock.root, attemptId: record.cut.attemptId, paths: [record.source.path],
1425
+ });
1426
+ assertStoreIdentity(record.source);
1427
+ return writeJournal(lock, record, {
1428
+ state: "ready", space: record.space, mode: record.mode, source: record.source, resume: record.resume,
1429
+ cut: record.cut, cutCompletion: evidence,
1430
+ });
1431
+ }
1432
+ function requireReady(lock) {
1433
+ const record = currentJournal(lock);
1434
+ if (record.state !== "ready")
1435
+ maintenanceError("invalid-transition", `maintenance journal is ${record.state}, not ready`, {
1436
+ root: lock.root,
1437
+ paths: record.state === "restore-ready" ? [record.restore.targetPath, record.source.path] : [record.source.path],
1438
+ });
1439
+ assertStoreIdentity(record.source);
1440
+ return record;
1441
+ }
1442
+ export function claimMaintenanceReady(lock, claim) {
1443
+ const ready = requireReady(lock);
1444
+ if (!ATTEMPT_ID.test(claim.attemptId) || !Number.isFinite(Date.parse(claim.deadline)))
1445
+ maintenanceError("invalid-transition", "claim attempt and deadline are required", { root: lock.root });
1446
+ parseOwner(claim.coordinator, "claim coordinator");
1447
+ for (const claimOwner of claim.owners)
1448
+ parseOwner(claimOwner, "claim owner");
1449
+ if (claim.ownedPaths !== undefined && !validOwnedPaths(claim.ownedPaths))
1450
+ maintenanceError("invalid-transition", "claim owned paths are invalid", { root: lock.root, attemptId: claim.attemptId });
1451
+ return writeJournal(lock, ready, { ...readyFrom(ready), state: "claimed", claim });
1452
+ }
1453
+ /** Merge broker/watchdog owners and attempt-owned slots into the live claim. A slot journaled
1454
+ * before its path exists ("pending") is upgraded in place once the exact inode is known. */
1455
+ export function recordMaintenanceClaimResources(lock, input) {
1456
+ const record = currentJournal(lock);
1457
+ if (record.state !== "claimed")
1458
+ maintenanceError("invalid-transition", "maintenance journal has no live claim to extend", { root: lock.root });
1459
+ const owners = [...record.claim.owners, ...(input.owners ?? [])];
1460
+ const merged = [...(record.claim.ownedPaths ?? [])];
1461
+ for (const owned of input.ownedPaths ?? []) {
1462
+ const existing = merged.findIndex((entry) => entry.path === owned.path && entry.label === owned.label);
1463
+ if (existing >= 0)
1464
+ merged.splice(existing, 1, owned);
1465
+ else
1466
+ merged.push(owned);
1467
+ }
1468
+ const claim = { ...record.claim, owners, ...(merged.length ? { ownedPaths: merged } : {}) };
1469
+ if (!validRestoreClaimOwnersBound(owners) || !validOwnedPaths(merged))
1470
+ maintenanceError("invalid-transition", "claim resources are invalid", { root: lock.root, attemptId: record.claim.attemptId });
1471
+ for (const claimOwner of owners)
1472
+ parseOwner(claimOwner, "claim owner");
1473
+ return writeJournal(lock, record, { ...readyFrom(record), state: "claimed", claim });
1474
+ }
1475
+ function validRestoreClaimOwnersBound(owners) {
1476
+ return owners.length <= 16;
1477
+ }
1478
+ export function releaseMaintenanceClaim(lock, attemptId) {
1479
+ const record = currentJournal(lock);
1480
+ if (record.state !== "claimed" || record.claim.attemptId !== attemptId)
1481
+ maintenanceError("invalid-transition", "maintenance claim does not match this attempt", { root: lock.root, attemptId });
1482
+ assertStoreIdentity(record.source);
1483
+ return writeJournal(lock, record, readyFrom(record));
1484
+ }
1485
+ export function recoverStaleMaintenanceClaim(lock, options = {}) {
1486
+ const record = currentJournal(lock);
1487
+ if (record.state !== "claimed")
1488
+ maintenanceError("invalid-transition", "maintenance journal has no claim to recover", { root: lock.root });
1489
+ const now = options.now ?? new Date();
1490
+ if (now.getTime() <= Date.parse(record.claim.deadline))
1491
+ maintenanceError("claim-not-expired", "maintenance claim deadline has not elapsed", {
1492
+ root: lock.root, attemptId: record.claim.attemptId,
1493
+ recourse: [{ action: "retry", description: `Retry after ${record.claim.deadline}.` }],
1494
+ });
1495
+ const status = options.ownerStatus ?? localProcessOwnerStatus;
1496
+ const owners = [record.claim.coordinator, ...record.claim.owners];
1497
+ const statuses = owners.map(status);
1498
+ if (statuses.includes("alive"))
1499
+ maintenanceError("claim-live", "a maintenance claim owner is still alive", {
1500
+ root: lock.root, attemptId: record.claim.attemptId,
1501
+ recourse: [{ action: "retry", description: "Wait for coordinator, watchdog, and broker exit." }],
1502
+ });
1503
+ if (statuses.includes("unknown"))
1504
+ maintenanceError("claim-owner-ambiguous", "not every maintenance claim owner is proven dead", {
1505
+ root: lock.root, attemptId: record.claim.attemptId,
1506
+ recourse: [{ action: "inspect", description: "Prove coordinator, watchdog, and broker death before recovery." }],
1507
+ });
1508
+ assertStoreIdentity(record.source);
1509
+ removeOwnedPathsOrFail(lock, record.claim.attemptId, record.claim.ownedPaths);
1510
+ return writeJournal(lock, record, readyFrom(record));
1511
+ }
1512
+ /** Residue a crashed attempt may leave in a pending (pre-inode) destination slot. */
1513
+ const DESTINATION_RESIDUE = /^(stream-[0-9a-f]{24}\.snap|checkpoints\.json|manifest\.json\.[A-Za-z0-9.-]+\.tmp)$/;
1514
+ /** Remove exactly the journaled attempt-owned paths, or fail closed WITHOUT surrendering ownership.
1515
+ * Every branch must prove what it deletes: a proven slot deletes only its exact inode; a pending
1516
+ * destination slot deletes only recognizable attempt residue; anything else is preserved, and any
1517
+ * removal failure keeps the claim so a retry (not silence) finishes the cleanup. */
1518
+ function removeOwnedPathsOrFail(lock, attemptId, ownedPaths) {
1519
+ const failures = [];
1520
+ for (const owned of ownedPaths ?? []) {
1521
+ try {
1522
+ let stat;
1523
+ try {
1524
+ stat = lstatSync(owned.path, { bigint: true });
1525
+ }
1526
+ catch (error) {
1527
+ if (errno(error) === "ENOENT")
1528
+ continue;
1529
+ throw error;
1530
+ }
1531
+ // A destination with a published manifest is a COMPLETE artifact, never recovery residue.
1532
+ if (owned.label === "destination" && stat.isDirectory() && pathExistsStrict(join(owned.path, "manifest.json")))
1533
+ continue;
1534
+ if (owned.dev === undefined) {
1535
+ // Pending slot: the path was journaled before creation, so no inode proof exists.
1536
+ if (owned.label !== "destination") {
1537
+ // Non-destination slots live only inside Cotal's private attempts tree, where a live
1538
+ // journal record + claim exclusivity make any plain directory ours by construction.
1539
+ if (stat.isDirectory() && !stat.isSymbolicLink()) {
1540
+ rmSync(owned.path, { recursive: true });
1541
+ fsyncDirectory(dirname(owned.path));
1542
+ }
1543
+ else {
1544
+ failures.push(`${owned.path} (pending slot is not a plain directory)`);
1545
+ }
1546
+ continue;
1547
+ }
1548
+ // A destination is operator-named: delete only recognizable attempt residue.
1549
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
1550
+ failures.push(`${owned.path} (pending destination is not a plain directory)`);
1551
+ continue;
1552
+ }
1553
+ const entries = readdirSync(owned.path);
1554
+ const residueOnly = entries.every((name) => {
1555
+ if (!DESTINATION_RESIDUE.test(name))
1556
+ return false;
1557
+ const child = lstatSync(join(owned.path, name));
1558
+ return child.isFile() && !child.isSymbolicLink();
1559
+ });
1560
+ if (!residueOnly) {
1561
+ failures.push(`${owned.path} (pending destination holds unrecognized content)`);
1562
+ continue;
1563
+ }
1564
+ rmSync(owned.path, { recursive: true });
1565
+ fsyncDirectory(dirname(owned.path));
1566
+ continue;
1567
+ }
1568
+ if (stat.isSymbolicLink() || stat.dev.toString() !== owned.dev || stat.ino.toString() !== owned.ino)
1569
+ continue; // replaced by something that is not ours — preserve
1570
+ if (stat.isDirectory())
1571
+ rmSync(owned.path, { recursive: true });
1572
+ else
1573
+ rmSync(owned.path);
1574
+ fsyncDirectory(dirname(owned.path));
1575
+ }
1576
+ catch (error) {
1577
+ failures.push(`${owned.path} (${error.message})`);
1578
+ }
1579
+ }
1580
+ if (failures.length)
1581
+ maintenanceError("cleanup-incomplete", "attempt-owned cleanup could not be proven complete", {
1582
+ root: lock.root, attemptId,
1583
+ paths: (ownedPaths ?? []).map((owned) => owned.path),
1584
+ recourse: [{ action: "retry", description: `Resolve and retry: ${failures.join("; ")}` }],
1585
+ });
1586
+ }
1587
+ /**
1588
+ * Fsync normal-listener launch provenance before exposure. A crash after this transition is
1589
+ * intentionally ambiguous: callers must inspect/recover forward or record `resume-degraded`.
1590
+ */
1591
+ export function beginOrdinaryResume(lock, input) {
1592
+ const ready = requireReady(lock);
1593
+ if (!ATTEMPT_ID.test(input.attemptId))
1594
+ maintenanceError("invalid-transition", "ordinary resume attempt id must be a safe token", {
1595
+ root: lock.root, attemptId: input.attemptId,
1596
+ });
1597
+ const ordinaryResume = {
1598
+ attemptId: input.attemptId,
1599
+ intentAt: new Date().toISOString(),
1600
+ launch: jsonObject(input.launch),
1601
+ };
1602
+ return writeJournal(lock, ready, {
1603
+ ...readyFrom(ready), state: "resume-intent", ordinaryResume,
1604
+ });
1605
+ }
1606
+ function ordinaryListenerState(record) {
1607
+ return {
1608
+ ...(record.listenerProof ? { listenerProof: record.listenerProof } : {}),
1609
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
1610
+ };
1611
+ }
1612
+ /** Fsync the exact ordinary-resume listener identity after spawn and before activation. The
1613
+ * proof's target is the preserved SOURCE store. */
1614
+ export function bindOrdinaryResumeListener(lock, proof) {
1615
+ const record = currentJournal(lock);
1616
+ if (record.state !== "resume-intent" &&
1617
+ (record.state !== "resume-degraded" || record.managerCommit !== undefined))
1618
+ maintenanceError("invalid-transition", "resume listener can be bound only before manager commitment", {
1619
+ root: lock.root,
1620
+ });
1621
+ const normalized = normalizedListenerProof(proof);
1622
+ if (normalized.attemptId !== record.ordinaryResume.attemptId ||
1623
+ !sameStoreIdentity(normalized.target, record.source))
1624
+ maintenanceError("listener-proof-mismatch", "resume listener proof does not match the attempt and preserved source", {
1625
+ root: lock.root, attemptId: record.ordinaryResume.attemptId,
1626
+ paths: [record.source.path], expected: record.source, actual: normalized.target,
1627
+ });
1628
+ const launchServer = record.ordinaryResume.launch.server;
1629
+ if (typeof launchServer !== "string" || canonicalListenerEndpoint(launchServer) !== normalized.serverEndpoint)
1630
+ maintenanceError("listener-proof-mismatch", "resume listener endpoint does not match durable launch provenance", {
1631
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1632
+ });
1633
+ assertStoreIdentity(record.source);
1634
+ if (record.listenerReplacements?.some((replacement) => reusesListenerIdentity(replacement.proof, normalized)))
1635
+ maintenanceError("listener-proof-mismatch", "resume listener proof reuses a retired listener identity", {
1636
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1637
+ });
1638
+ assertListenerOwnerAlive(normalized);
1639
+ if (record.listenerProof) {
1640
+ if (!sameListenerProof(record.listenerProof, normalized))
1641
+ maintenanceError("listener-proof-mismatch", "ordinary resume is already bound to another listener", {
1642
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1643
+ });
1644
+ return record;
1645
+ }
1646
+ if (record.state === "resume-intent")
1647
+ return writeJournal(lock, record, {
1648
+ ...readyFrom(record), state: "resume-intent", ordinaryResume: record.ordinaryResume,
1649
+ listenerProof: normalized,
1650
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
1651
+ });
1652
+ return writeJournal(lock, record, {
1653
+ ...readyFrom(record), state: "resume-degraded", ordinaryResume: record.ordinaryResume,
1654
+ ...(record.activeAt !== undefined ? { activeAt: record.activeAt, activation: record.activation } : {}),
1655
+ degradedAt: record.degradedAt, reason: record.reason, recourse: record.recourse,
1656
+ listenerProof: normalized,
1657
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
1658
+ });
1659
+ }
1660
+ /** Retire one exactly bound, provably dead resume listener before a fresh proof may be bound. A
1661
+ * manager-committed resume never replaces its listener: the durable token is bound to it. */
1662
+ export function replaceDeadOrdinaryResumeListener(lock, proof, options = {}) {
1663
+ const record = currentJournal(lock);
1664
+ if ((record.state !== "resume-intent" && record.state !== "resume-active" && record.state !== "resume-degraded") ||
1665
+ (record.state === "resume-degraded" && record.managerCommit !== undefined))
1666
+ maintenanceError("invalid-transition", "dead resume listener replacement requires an uncommitted resume attempt", {
1667
+ root: lock.root,
1668
+ });
1669
+ if (!record.listenerProof)
1670
+ maintenanceError("listener-proof-missing", "resume listener replacement requires an existing bound proof", {
1671
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1672
+ });
1673
+ const normalized = normalizedListenerProof(proof);
1674
+ if (!sameListenerProof(record.listenerProof, normalized))
1675
+ maintenanceError("listener-proof-mismatch", "resume listener replacement does not exactly match the bound proof", {
1676
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1677
+ });
1678
+ assertStoreIdentity(record.source);
1679
+ const status = (options.ownerStatus ?? localProcessOwnerStatus)(record.listenerProof.processOwner);
1680
+ if (status === "alive")
1681
+ maintenanceError("listener-owner-alive", "bound resume listener is still alive", {
1682
+ root: lock.root, attemptId: record.ordinaryResume.attemptId,
1683
+ recourse: [{ action: "retry", description: "Stop the exact bound listener and prove process exit before replacement." }],
1684
+ });
1685
+ if (status !== "dead")
1686
+ maintenanceError("listener-owner-ambiguous", "bound resume listener death cannot be proven", {
1687
+ root: lock.root, attemptId: record.ordinaryResume.attemptId,
1688
+ recourse: [{ action: "inspect", description: "Prove the exact recorded listener process dead before replacement." }],
1689
+ });
1690
+ const history = record.listenerReplacements ?? [];
1691
+ const replacement = {
1692
+ generation: history.length + 1,
1693
+ replacedAt: new Date().toISOString(),
1694
+ proof: record.listenerProof,
1695
+ };
1696
+ const base = {
1697
+ ...readyFrom(record), ordinaryResume: record.ordinaryResume,
1698
+ listenerReplacements: [...history, replacement],
1699
+ };
1700
+ if (record.state === "resume-intent")
1701
+ return writeJournal(lock, record, { ...base, state: "resume-intent" });
1702
+ if (record.state === "resume-active")
1703
+ return writeJournal(lock, record, {
1704
+ ...base, state: "resume-active", activeAt: record.activeAt, activation: record.activation,
1705
+ });
1706
+ return writeJournal(lock, record, {
1707
+ ...base, state: "resume-degraded",
1708
+ ...(record.activeAt !== undefined ? { activeAt: record.activeAt, activation: record.activation } : {}),
1709
+ degradedAt: record.degradedAt, reason: record.reason, recourse: record.recourse,
1710
+ });
1711
+ }
1712
+ /** Record that the normal listener is ready and retained principals were activated successfully. */
1713
+ export function markOrdinaryResumeActive(lock, activation) {
1714
+ const record = currentJournal(lock);
1715
+ if (record.state !== "resume-intent" && record.state !== "resume-degraded")
1716
+ maintenanceError("invalid-transition", "ordinary resume activation requires intent or repaired degraded state", {
1717
+ root: lock.root,
1718
+ paths: [record.source.path],
1719
+ });
1720
+ assertStoreIdentity(record.source);
1721
+ if (!validOrdinaryResumeActivationEvidence(activation, record.ordinaryResume.attemptId))
1722
+ maintenanceError("activation-evidence-invalid", "ordinary resume activation evidence is invalid or belongs to another attempt", {
1723
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1724
+ });
1725
+ return writeJournal(lock, record, {
1726
+ ...readyFrom(record), state: "resume-active", ordinaryResume: record.ordinaryResume,
1727
+ ...ordinaryListenerState(record),
1728
+ activeAt: new Date().toISOString(), activation,
1729
+ });
1730
+ }
1731
+ /** Preserve an ambiguous or failed post-intent result without rolling back or deleting any bytes. */
1732
+ export function markOrdinaryResumeDegraded(lock, reason, recourse) {
1733
+ const record = currentJournal(lock);
1734
+ if (record.state !== "resume-intent" && record.state !== "resume-active")
1735
+ maintenanceError("invalid-transition", "degraded ordinary resume requires intent or active state", {
1736
+ root: lock.root,
1737
+ paths: [record.source.path],
1738
+ });
1739
+ if (!reason || !validRecourse(recourse))
1740
+ maintenanceError("invalid-transition", "degraded ordinary resume requires a reason and structured recourse", {
1741
+ root: lock.root,
1742
+ });
1743
+ assertStoreIdentity(record.source);
1744
+ return writeJournal(lock, record, {
1745
+ ...readyFrom(record), state: "resume-degraded", ordinaryResume: record.ordinaryResume,
1746
+ ...ordinaryListenerState(record),
1747
+ ...(record.state === "resume-active"
1748
+ ? { activeAt: record.activeAt, activation: record.activation }
1749
+ : {}),
1750
+ degradedAt: new Date().toISOString(), reason, recourse: [...recourse],
1751
+ });
1752
+ }
1753
+ /** Fsync the manager's suppression-retaining commit token before requesting finalization. */
1754
+ export function recordOrdinaryResumeManagerCommit(lock, evidence) {
1755
+ const record = currentJournal(lock);
1756
+ if (record.state === "resume-committed") {
1757
+ if (!validManagerCommitEvidence(evidence, record.ordinaryResume.attemptId) ||
1758
+ !sameManagerCommitEvidence(record.managerCommit, evidence))
1759
+ maintenanceError("activation-evidence-invalid", "ordinary resume commit retry does not match the durable manager token", {
1760
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1761
+ });
1762
+ assertStoreIdentity(record.source);
1763
+ return record;
1764
+ }
1765
+ if (record.state !== "resume-active")
1766
+ maintenanceError("invalid-transition", "ordinary resume manager commit requires resume-active state", {
1767
+ root: lock.root,
1768
+ paths: [record.source.path],
1769
+ });
1770
+ assertStoreIdentity(record.source);
1771
+ if (!validManagerCommitEvidence(evidence, record.ordinaryResume.attemptId))
1772
+ maintenanceError("activation-evidence-invalid", "ordinary resume commit evidence is invalid or belongs to another attempt", {
1773
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1774
+ });
1775
+ return writeJournal(lock, record, {
1776
+ ...readyFrom(record), state: "resume-committed", ordinaryResume: record.ordinaryResume,
1777
+ ...ordinaryListenerState(record),
1778
+ activeAt: record.activeAt, activation: record.activation,
1779
+ managerCommittedAt: new Date().toISOString(), managerCommit: evidence,
1780
+ });
1781
+ }
1782
+ /** Durably authorize journal consumption only after exact token-bound manager finalization. */
1783
+ export function retireOrdinaryResume(lock, retirement) {
1784
+ const record = currentJournal(lock);
1785
+ if (record.state === "resume-retired") {
1786
+ if (!validManagerFinalizeEvidence(retirement, record.ordinaryResume.attemptId, record.managerCommit.durableCommitToken) || !sameManagerFinalizeEvidence(record.retirement, retirement))
1787
+ maintenanceError("activation-evidence-invalid", "ordinary resume finalize retry does not match the durable manager token", {
1788
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1789
+ });
1790
+ assertStoreIdentity(record.source);
1791
+ return record;
1792
+ }
1793
+ if (record.state !== "resume-committed")
1794
+ maintenanceError("invalid-transition", "ordinary resume retirement requires resume-committed state", {
1795
+ root: lock.root,
1796
+ paths: [record.source.path],
1797
+ });
1798
+ assertStoreIdentity(record.source);
1799
+ if (!validManagerFinalizeEvidence(retirement, record.ordinaryResume.attemptId, record.managerCommit.durableCommitToken))
1800
+ maintenanceError("activation-evidence-invalid", "ordinary resume retirement requires exact manager finalize evidence", {
1801
+ root: lock.root, attemptId: record.ordinaryResume.attemptId, paths: [record.source.path],
1802
+ });
1803
+ return writeJournal(lock, record, {
1804
+ ...readyFrom(record), state: "resume-retired", ordinaryResume: record.ordinaryResume,
1805
+ ...ordinaryListenerState(record),
1806
+ activeAt: record.activeAt, activation: record.activation,
1807
+ managerCommittedAt: record.managerCommittedAt, managerCommit: record.managerCommit,
1808
+ retiredAt: new Date().toISOString(), retirement,
1809
+ });
1810
+ }
1811
+ /**
1812
+ * Consume only a durable retired marker. The source store and content-addressed resume document are
1813
+ * deliberately left untouched; a crash leaves either `resume-retired` or an absent journal.
1814
+ */
1815
+ export function consumeRetiredMaintenance(lock) {
1816
+ const record = currentJournal(lock);
1817
+ if (record.state !== "resume-retired")
1818
+ maintenanceError("invalid-transition", "maintenance journal can be consumed only from resume-retired", {
1819
+ root: lock.root,
1820
+ paths: [record.source.path],
1821
+ });
1822
+ assertStoreIdentity(record.source);
1823
+ readMaintenanceResumeDocument(lock.root, record.resume);
1824
+ const paths = maintenancePaths(lock.root);
1825
+ unlinkSync(paths.journal);
1826
+ fsyncDirectory(paths.versionDir);
1827
+ return record;
1828
+ }
1829
+ function restoreBase(ready, attemptId, method, targetPath) {
1830
+ if (!ATTEMPT_ID.test(attemptId))
1831
+ maintenanceError("invalid-transition", "restore attempt id must be a safe token", { root: ready.source.path, attemptId });
1832
+ return { attemptId, method, targetPath };
1833
+ }
1834
+ function newRestoreClaim(input) {
1835
+ if (!Number.isFinite(Date.parse(input.deadline)))
1836
+ maintenanceError("invalid-transition", "restore claim requires an absolute deadline");
1837
+ const claim = { deadline: input.deadline, coordinator: input.coordinator ?? defaultOwner(), owners: [] };
1838
+ if (!validRestoreClaim(claim))
1839
+ maintenanceError("invalid-transition", "restore claim coordinator is invalid");
1840
+ if (input.ownedPaths !== undefined && !validOwnedPaths(input.ownedPaths))
1841
+ maintenanceError("invalid-transition", "restore claim owned paths are invalid");
1842
+ return { claim, ...(input.ownedPaths ? { ownedPaths: input.ownedPaths } : {}) };
1843
+ }
1844
+ /** Assess the recorded pre-commit liveness claim. `live` while the deadline has not elapsed or any
1845
+ * recorded owner is alive; `stale` only when the deadline elapsed AND every owner is proven dead. */
1846
+ export function assessRestoreClaim(record, options = {}) {
1847
+ const now = options.now ?? new Date();
1848
+ if (now.getTime() <= Date.parse(record.claim.deadline))
1849
+ return "live";
1850
+ const status = options.ownerStatus ?? localProcessOwnerStatus;
1851
+ const statuses = [record.claim.coordinator, ...record.claim.owners].map(status);
1852
+ if (statuses.includes("alive"))
1853
+ return "live";
1854
+ if (statuses.includes("unknown"))
1855
+ return "ambiguous";
1856
+ return "stale";
1857
+ }
1858
+ function requireRestoreReady(lock) {
1859
+ const record = currentJournal(lock);
1860
+ if (record.state !== "restore-ready")
1861
+ maintenanceError("invalid-transition", "maintenance journal has no pre-commit restore attempt", { root: lock.root });
1862
+ return record;
1863
+ }
1864
+ /** Append broker/watchdog owners and attempt-owned working trees to the live restore attempt so
1865
+ * recovery can refuse while they live and delete exactly these inodes once they are proven dead. */
1866
+ export function recordRestoreAttemptResources(lock, input) {
1867
+ const record = requireRestoreReady(lock);
1868
+ const owners = [...record.claim.owners, ...(input.owners ?? [])];
1869
+ const claim = { ...record.claim, owners };
1870
+ const ownedPaths = [...(record.restore.ownedPaths ?? [])];
1871
+ for (const owned of input.ownedPaths ?? []) {
1872
+ const existing = ownedPaths.findIndex((entry) => entry.path === owned.path && entry.label === owned.label);
1873
+ if (existing >= 0)
1874
+ ownedPaths.splice(existing, 1, owned);
1875
+ else
1876
+ ownedPaths.push(owned);
1877
+ }
1878
+ if (!validRestoreClaim(claim))
1879
+ maintenanceError("invalid-transition", "restore claim owners are invalid", { attemptId: record.restore.attemptId });
1880
+ if (!validOwnedPaths(ownedPaths))
1881
+ maintenanceError("invalid-transition", "restore attempt-owned paths are invalid", { attemptId: record.restore.attemptId });
1882
+ const restore = { ...record.restore, ...(ownedPaths.length ? { ownedPaths } : {}) };
1883
+ return writeJournal(lock, record, {
1884
+ ...readyFrom(record), state: "restore-ready", phase: record.phase, restore, claim,
1885
+ });
1886
+ }
1887
+ export function prepareSamePathRestore(lock, input) {
1888
+ const ready = requireReady(lock);
1889
+ const targetPath = resolve(input.targetPath);
1890
+ if (targetPath !== ready.source.path)
1891
+ maintenanceError("invalid-path", "same-path restore target must be the recorded source path", {
1892
+ paths: [targetPath, ready.source.path],
1893
+ });
1894
+ const fallbackPath = canonicalAbsentPath(input.fallbackPath);
1895
+ if (dirname(fallbackPath) !== dirname(ready.source.path))
1896
+ maintenanceError("invalid-path", "same-path fallback must be a sibling of the source", {
1897
+ paths: [ready.source.path, fallbackPath],
1898
+ });
1899
+ const parentDev = statSync(dirname(fallbackPath), { bigint: true }).dev.toString();
1900
+ if (parentDev !== ready.source.dev)
1901
+ maintenanceError("invalid-path", "same-path fallback must be on the source filesystem", {
1902
+ paths: [ready.source.path, fallbackPath],
1903
+ });
1904
+ const { claim, ownedPaths } = newRestoreClaim(input.claim);
1905
+ const restore = {
1906
+ ...restoreBase(ready, input.attemptId, "same-path", targetPath), fallbackPath,
1907
+ ...(ownedPaths ? { ownedPaths } : {}),
1908
+ };
1909
+ return writeJournal(lock, ready, { ...readyFrom(ready), state: "restore-ready", phase: "move-pending", restore, claim });
1910
+ }
1911
+ export function interpretPendingStoreMove(record) {
1912
+ if (record.phase !== "move-pending" || record.restore.method !== "same-path" || !record.restore.fallbackPath)
1913
+ maintenanceError("invalid-transition", "record is not a pending same-path move", { attemptId: record.restore.attemptId });
1914
+ const source = identityAt(record.source.path);
1915
+ const fallback = identityAt(record.restore.fallbackPath);
1916
+ const sourceMatches = source ? sameStoreIdentity(record.source, source) : false;
1917
+ const expectedFallback = { ...record.source, path: record.restore.fallbackPath };
1918
+ const fallbackMatches = fallback ? sameStoreIdentity(expectedFallback, fallback) : false;
1919
+ if (sourceMatches && !fallback)
1920
+ return "not-moved";
1921
+ if (!source && fallbackMatches)
1922
+ return "moved";
1923
+ maintenanceError("ambiguous-filesystem-state", "same-path move cannot be interpreted safely", {
1924
+ attemptId: record.restore.attemptId,
1925
+ paths: [record.source.path, record.restore.fallbackPath],
1926
+ expected: record.source,
1927
+ actual: source ?? fallback,
1928
+ recourse: [{ action: "inspect", description: "Preserve both paths; do not rename or delete either store.", paths: [record.source.path, record.restore.fallbackPath] }],
1929
+ });
1930
+ }
1931
+ /**
1932
+ * Move the stopped source after interpreting either crash side. This is a cooperative filesystem
1933
+ * transition: the caller must first prove every broker and other non-Cotal store user has exited.
1934
+ * The maintenance lock excludes Cotal peers; it is not a shared-store lock for raw processes.
1935
+ */
1936
+ export function moveSamePathRestoreSource(lock) {
1937
+ const record = currentJournal(lock);
1938
+ if (record.state !== "restore-ready" || record.phase !== "move-pending" ||
1939
+ record.restore.method !== "same-path" || !record.restore.fallbackPath)
1940
+ maintenanceError("invalid-transition", "maintenance journal is not awaiting a source move", { root: lock.root });
1941
+ if (interpretPendingStoreMove(record) === "not-moved")
1942
+ renameSync(record.source.path, record.restore.fallbackPath);
1943
+ // Also fsync on crash recovery: the rename may have happened before its original parent fsync.
1944
+ fsyncDirectory(dirname(record.source.path));
1945
+ const fallback = readStoreIdentity(record.restore.fallbackPath);
1946
+ const expected = { ...record.source, path: record.restore.fallbackPath };
1947
+ if (!sameStoreIdentity(expected, fallback) || existsSync(record.source.path))
1948
+ maintenanceError("ambiguous-filesystem-state", "source rename did not produce the recorded filesystem state", {
1949
+ attemptId: record.restore.attemptId,
1950
+ paths: [record.source.path, record.restore.fallbackPath], expected, actual: fallback,
1951
+ });
1952
+ const restore = { ...record.restore, previousSource: { kind: "fallback", identity: fallback } };
1953
+ return writeJournal(lock, record, { ...readyFrom(record), state: "restore-ready", phase: "source-moved", restore, claim: record.claim });
1954
+ }
1955
+ export function prepareAlternateRestore(lock, input) {
1956
+ const ready = requireReady(lock);
1957
+ const targetPath = canonicalCandidatePath(input.targetPath);
1958
+ assertPathsDoNotOverlap(ready.source.path, targetPath, "alternate restore target must not equal, contain, or be nested under the retained source");
1959
+ if (pathExistsStrict(targetPath))
1960
+ maintenanceError("path-exists", "store path must be absent", {
1961
+ paths: [targetPath],
1962
+ recourse: [{ action: "inspect", description: "Inspect the unexpected path before retrying.", paths: [targetPath] }],
1963
+ });
1964
+ const { claim, ownedPaths } = newRestoreClaim(input.claim);
1965
+ const restore = {
1966
+ ...restoreBase(ready, input.attemptId, "alternate", targetPath),
1967
+ previousSource: { kind: "retained", identity: ready.source },
1968
+ ...(ownedPaths ? { ownedPaths } : {}),
1969
+ };
1970
+ return writeJournal(lock, ready, { ...readyFrom(ready), state: "restore-ready", phase: "source-retained", restore, claim });
1971
+ }
1972
+ export function prepareMissingSourceRestore(lock, input) {
1973
+ assertLock(lock);
1974
+ const record = currentJournal(lock);
1975
+ if (record.state !== "ready")
1976
+ maintenanceError("invalid-transition", "missing-source consent requires a ready journal", { root: lock.root });
1977
+ const existing = identityAt(record.source.path);
1978
+ if (existing)
1979
+ maintenanceError("identity-mismatch", "missing-source consent cannot accept an existing or replacement inode", {
1980
+ expected: record.source, actual: existing, paths: [record.source.path],
1981
+ });
1982
+ const targetPath = canonicalAbsentPath(input.targetPath);
1983
+ if (targetPath !== record.source.path)
1984
+ maintenanceError("invalid-path", "disaster restore target must be the missing canonical source path", {
1985
+ paths: [targetPath, record.source.path],
1986
+ });
1987
+ const { claim, ownedPaths } = newRestoreClaim(input.claim);
1988
+ const restore = {
1989
+ ...restoreBase(record, input.attemptId, "disaster", targetPath),
1990
+ ...(ownedPaths ? { ownedPaths } : {}),
1991
+ };
1992
+ return writeJournal(lock, record, { ...readyFrom(record), state: "restore-ready", phase: "disaster-source-missing", restore, claim });
1993
+ }
1994
+ export function bindRestoreTarget(lock) {
1995
+ const record = currentJournal(lock);
1996
+ if (record.state !== "restore-ready")
1997
+ maintenanceError("invalid-transition", "restore target can be bound only before commit intent", { root: lock.root });
1998
+ if (record.restore.target)
1999
+ maintenanceError("invalid-transition", "restore target is already bound", { attemptId: record.restore.attemptId });
2000
+ if (record.restore.method === "same-path" && record.phase !== "source-moved")
2001
+ maintenanceError("invalid-transition", "same-path target cannot be bound before the source move", {
2002
+ attemptId: record.restore.attemptId,
2003
+ });
2004
+ const target = ensureStoreIdentity(record.restore.targetPath);
2005
+ if (record.restore.method === "alternate")
2006
+ assertPathsDoNotOverlap(record.source.path, target.path, "alternate restore target must remain disjoint from the retained source");
2007
+ if (record.restore.previousSource &&
2008
+ target.dev === record.restore.previousSource.identity.dev && target.ino === record.restore.previousSource.identity.ino)
2009
+ maintenanceError("identity-mismatch", "restore target aliases the old source inode", {
2010
+ attemptId: record.restore.attemptId,
2011
+ expected: record.restore.previousSource.identity,
2012
+ actual: target,
2013
+ paths: [target.path, record.restore.previousSource.identity.path],
2014
+ });
2015
+ const restore = { ...record.restore, target };
2016
+ return writeJournal(lock, record, { ...readyFrom(record), state: "restore-ready", phase: record.phase, restore, claim: record.claim });
2017
+ }
2018
+ function jsonObject(value) {
2019
+ if (!validJsonObject(value))
2020
+ maintenanceError("invalid-transition", "restore metadata must be finite JSON data");
2021
+ return JSON.parse(JSON.stringify(value));
2022
+ }
2023
+ function preflightRollbackSource(record) {
2024
+ if (record.restore.method === "same-path") {
2025
+ if (record.phase === "move-pending") {
2026
+ interpretPendingStoreMove(record);
2027
+ return;
2028
+ }
2029
+ if (!record.restore.previousSource)
2030
+ maintenanceError("journal-corrupt", "moved restore has no previous source", { attemptId: record.restore.attemptId });
2031
+ assertStoreIdentity(record.restore.previousSource.identity);
2032
+ return;
2033
+ }
2034
+ if (record.restore.method === "alternate") {
2035
+ assertStoreIdentity(record.source);
2036
+ return;
2037
+ }
2038
+ if (identityAt(record.source.path))
2039
+ maintenanceError("ambiguous-filesystem-state", "missing-source rollback found an unexpected replacement", {
2040
+ attemptId: record.restore.attemptId, paths: [record.source.path], expected: record.source,
2041
+ });
2042
+ }
2043
+ export function writeRestoreCommitIntent(lock, launch) {
2044
+ const record = currentJournal(lock);
2045
+ if (record.state !== "restore-ready" || !record.restore.target)
2046
+ maintenanceError("invalid-transition", "commit intent requires a bound pre-commit target", { root: lock.root });
2047
+ assertStoreIdentity(record.restore.target);
2048
+ if (record.restore.previousSource) {
2049
+ const previous = assertStoreIdentity(record.restore.previousSource.identity);
2050
+ if (previous.dev === record.restore.target.dev && previous.ino === record.restore.target.ino)
2051
+ maintenanceError("identity-mismatch", "old source aliases the restore target at commit intent", {
2052
+ attemptId: record.restore.attemptId,
2053
+ expected: record.restore.previousSource.identity,
2054
+ actual: record.restore.target,
2055
+ paths: [previous.path, record.restore.target.path],
2056
+ });
2057
+ }
2058
+ return writeJournal(lock, record, {
2059
+ ...readyFrom(record), state: "commit-intent", restore: record.restore,
2060
+ launch: jsonObject(launch),
2061
+ });
2062
+ }
2063
+ /** Fsync the exact normal listener identity after spawn and before activation is published. */
2064
+ export function bindRestoreListener(lock, proof) {
2065
+ const record = currentJournal(lock);
2066
+ if (record.state !== "commit-intent")
2067
+ maintenanceError("invalid-transition", "restore listener can be bound only from commit intent", {
2068
+ root: lock.root,
2069
+ });
2070
+ const normalized = normalizedListenerProof(proof);
2071
+ if (normalized.attemptId !== record.restore.attemptId ||
2072
+ !sameStoreIdentity(normalized.target, record.restore.target))
2073
+ maintenanceError("listener-proof-mismatch", "restore listener proof does not match the committed attempt and target", {
2074
+ root: lock.root, attemptId: record.restore.attemptId,
2075
+ paths: [record.restore.target.path], expected: record.restore.target, actual: normalized.target,
2076
+ });
2077
+ const launchServer = record.launch.server;
2078
+ if (typeof launchServer !== "string" || canonicalListenerEndpoint(launchServer) !== normalized.serverEndpoint)
2079
+ maintenanceError("listener-proof-mismatch", "restore listener endpoint does not match committed launch provenance", {
2080
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2081
+ });
2082
+ assertStoreIdentity(record.restore.target);
2083
+ if (record.listenerReplacements?.some((replacement) => reusesListenerIdentity(replacement.proof, normalized)))
2084
+ maintenanceError("listener-proof-mismatch", "restore listener proof reuses a retired listener identity", {
2085
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2086
+ });
2087
+ assertListenerOwnerAlive(normalized);
2088
+ if (record.listenerProof) {
2089
+ if (!sameListenerProof(record.listenerProof, normalized))
2090
+ maintenanceError("listener-proof-mismatch", "restore commit intent is already bound to another listener", {
2091
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2092
+ });
2093
+ return record;
2094
+ }
2095
+ return writeJournal(lock, record, {
2096
+ ...readyFrom(record), state: "commit-intent", restore: record.restore,
2097
+ launch: record.launch, listenerProof: normalized,
2098
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
2099
+ });
2100
+ }
2101
+ /**
2102
+ * Retire one exactly bound, provably dead listener without changing the restore attempt. The
2103
+ * atomic journal transition clears the current proof before another listener may be spawned.
2104
+ */
2105
+ export function replaceDeadRestoreListener(lock, proof, options = {}) {
2106
+ const record = currentJournal(lock);
2107
+ if (record.state !== "commit-intent" && record.state !== "degraded")
2108
+ maintenanceError("invalid-transition", "dead restore listener replacement requires uncommitted commit intent or degraded state", {
2109
+ root: lock.root,
2110
+ });
2111
+ if (record.state === "degraded" &&
2112
+ (record.managerCommittedAt !== undefined || record.managerCommit !== undefined || record.activeAt !== undefined))
2113
+ maintenanceError("invalid-transition", "manager-committed restore listener cannot be replaced", {
2114
+ root: lock.root, attemptId: record.restore.attemptId,
2115
+ });
2116
+ if (!record.listenerProof)
2117
+ maintenanceError("listener-proof-missing", "restore listener replacement requires an existing bound proof", {
2118
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2119
+ });
2120
+ const normalized = normalizedListenerProof(proof);
2121
+ if (!sameListenerProof(record.listenerProof, normalized))
2122
+ maintenanceError("listener-proof-mismatch", "restore listener replacement does not exactly match the bound proof", {
2123
+ root: lock.root, attemptId: record.restore.attemptId,
2124
+ paths: [record.restore.target.path], expected: record.restore.target, actual: normalized.target,
2125
+ });
2126
+ assertStoreIdentity(record.restore.target);
2127
+ const status = (options.ownerStatus ?? localProcessOwnerStatus)(record.listenerProof.processOwner);
2128
+ if (status === "alive")
2129
+ maintenanceError("listener-owner-alive", "bound restore listener is still alive", {
2130
+ root: lock.root, attemptId: record.restore.attemptId,
2131
+ recourse: [{ action: "retry", description: "Stop the exact bound listener and prove process exit before replacement." }],
2132
+ });
2133
+ if (status !== "dead")
2134
+ maintenanceError("listener-owner-ambiguous", "bound restore listener death cannot be proven", {
2135
+ root: lock.root, attemptId: record.restore.attemptId,
2136
+ recourse: [{ action: "inspect", description: "Prove the exact recorded listener process dead before replacement." }],
2137
+ });
2138
+ const history = record.listenerReplacements ?? [];
2139
+ const replacement = {
2140
+ generation: history.length + 1,
2141
+ replacedAt: new Date().toISOString(),
2142
+ proof: record.listenerProof,
2143
+ };
2144
+ return writeJournal(lock, record, {
2145
+ ...readyFrom(record), state: "commit-intent", restore: record.restore, launch: record.launch,
2146
+ listenerReplacements: [...history, replacement],
2147
+ });
2148
+ }
2149
+ /** Fsync the manager's suppression-retaining commit token before requesting finalization. */
2150
+ export function recordRestoreManagerCommit(lock, proof, evidence) {
2151
+ const record = currentJournal(lock);
2152
+ if (record.state === "manager-committed") {
2153
+ const normalized = normalizedListenerProof(proof);
2154
+ if (!sameListenerProof(record.listenerProof, normalized))
2155
+ maintenanceError("listener-proof-mismatch", "restore commit retry does not match the durable listener proof", {
2156
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2157
+ });
2158
+ if (!validManagerCommitEvidence(evidence, record.restore.attemptId) ||
2159
+ !sameManagerCommitEvidence(record.managerCommit, evidence))
2160
+ maintenanceError("activation-evidence-invalid", "restore commit retry does not match the durable manager token", {
2161
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2162
+ });
2163
+ assertStoreIdentity(record.restore.target);
2164
+ return record;
2165
+ }
2166
+ if (record.state !== "commit-intent" && record.state !== "degraded")
2167
+ maintenanceError("invalid-transition", "restore manager commit requires commit-intent or uncommitted degraded state", {
2168
+ root: lock.root,
2169
+ });
2170
+ if (record.state === "degraded" &&
2171
+ (record.managerCommittedAt !== undefined || record.managerCommit !== undefined || record.activeAt !== undefined))
2172
+ maintenanceError("invalid-transition", "restore manager commitment is already durable", {
2173
+ root: lock.root, attemptId: record.restore.attemptId,
2174
+ });
2175
+ if (!record.listenerProof)
2176
+ maintenanceError("listener-proof-missing", "restore manager commit requires a durably bound listener proof", {
2177
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2178
+ });
2179
+ const normalized = normalizedListenerProof(proof);
2180
+ if (!sameListenerProof(record.listenerProof, normalized))
2181
+ maintenanceError("listener-proof-mismatch", "restore manager commit proof does not exactly match the bound listener", {
2182
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2183
+ });
2184
+ if (!validManagerCommitEvidence(evidence, record.restore.attemptId))
2185
+ maintenanceError("activation-evidence-invalid", "restore manager commit evidence is invalid or belongs to another attempt", {
2186
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2187
+ });
2188
+ assertStoreIdentity(record.restore.target);
2189
+ assertListenerOwnerAlive(record.listenerProof);
2190
+ return writeJournal(lock, record, {
2191
+ ...readyFrom(record), state: "manager-committed", restore: record.restore,
2192
+ launch: record.launch, listenerProof: record.listenerProof,
2193
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
2194
+ managerCommittedAt: new Date().toISOString(), managerCommit: evidence,
2195
+ });
2196
+ }
2197
+ export function markRestoreActive(lock, proof, evidence) {
2198
+ const record = currentJournal(lock);
2199
+ const normalizedProof = normalizedListenerProof(proof);
2200
+ if (record.state === "active") {
2201
+ if (!record.listenerProof || !sameListenerProof(record.listenerProof, normalizedProof))
2202
+ maintenanceError("listener-proof-mismatch", "active restore retry does not match the durable listener proof", {
2203
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2204
+ });
2205
+ if (!validManagerFinalizeEvidence(evidence, record.restore.attemptId, record.details.managerCommit.durableCommitToken) || !sameManagerFinalizeEvidence(record.details.managerFinalize, evidence))
2206
+ maintenanceError("activation-evidence-invalid", "active restore retry does not match the durable manager finalize evidence", {
2207
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2208
+ });
2209
+ assertStoreIdentity(record.restore.target);
2210
+ return record;
2211
+ }
2212
+ if (record.state !== "manager-committed")
2213
+ maintenanceError("invalid-transition", "active restore requires manager-committed state", { root: lock.root });
2214
+ if (!sameListenerProof(record.listenerProof, normalizedProof))
2215
+ maintenanceError("listener-proof-mismatch", "active restore proof does not exactly match the bound listener", {
2216
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2217
+ });
2218
+ if (!validManagerFinalizeEvidence(evidence, record.restore.attemptId, record.managerCommit.durableCommitToken))
2219
+ maintenanceError("activation-evidence-invalid", "active restore requires exact token-bound manager finalize evidence", {
2220
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2221
+ });
2222
+ assertStoreIdentity(record.restore.target);
2223
+ assertListenerOwnerAlive(record.listenerProof);
2224
+ const details = {
2225
+ attemptId: record.restore.attemptId,
2226
+ listenerReady: true,
2227
+ observedAt: new Date().toISOString(),
2228
+ managerCommit: record.managerCommit,
2229
+ managerFinalize: evidence,
2230
+ };
2231
+ return writeJournal(lock, record, {
2232
+ ...readyFrom(record), state: "active", restore: record.restore,
2233
+ launch: record.launch, listenerProof: record.listenerProof,
2234
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
2235
+ managerCommittedAt: record.managerCommittedAt, managerCommit: record.managerCommit,
2236
+ activeAt: new Date().toISOString(), details,
2237
+ });
2238
+ }
2239
+ export function markRestoreDegraded(lock, reason, recourse) {
2240
+ const record = currentJournal(lock);
2241
+ if (record.state !== "commit-intent" && record.state !== "manager-committed" && record.state !== "active")
2242
+ maintenanceError("invalid-transition", "degraded restore requires commit intent, manager-committed, or active state", { root: lock.root });
2243
+ if (!reason || !validRecourse(recourse))
2244
+ maintenanceError("invalid-transition", "degraded restore requires a reason and structured recourse", { root: lock.root });
2245
+ return writeJournal(lock, record, {
2246
+ ...readyFrom(record), state: "degraded", restore: record.restore,
2247
+ launch: record.launch, ...(record.listenerProof ? { listenerProof: record.listenerProof } : {}),
2248
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
2249
+ ...(record.state === "manager-committed"
2250
+ ? { managerCommittedAt: record.managerCommittedAt, managerCommit: record.managerCommit }
2251
+ : {}),
2252
+ ...(record.state === "active"
2253
+ ? {
2254
+ managerCommittedAt: record.managerCommittedAt,
2255
+ managerCommit: record.managerCommit,
2256
+ details: record.details,
2257
+ activeAt: record.activeAt,
2258
+ }
2259
+ : {}),
2260
+ degradedAt: new Date().toISOString(), reason, recourse: [...recourse],
2261
+ });
2262
+ }
2263
+ /** Recover forward only by re-presenting the exact bound listener plus fresh readiness evidence. */
2264
+ export function repairRestoreDegradedToActive(lock, proof, evidence) {
2265
+ const record = currentJournal(lock);
2266
+ if (record.state !== "degraded")
2267
+ maintenanceError("invalid-transition", "restore repair requires degraded state", { root: lock.root });
2268
+ if (!record.listenerProof)
2269
+ maintenanceError("listener-proof-missing", "degraded restore has no bound listener proof", {
2270
+ root: lock.root, attemptId: record.restore.attemptId,
2271
+ paths: [record.restore.target.path, record.restore.previousSource?.identity.path]
2272
+ .filter((path) => Boolean(path)),
2273
+ recourse: [{ action: "inspect", description: "Preserve both stores; listener ownership was never durably bound." }],
2274
+ });
2275
+ const normalized = normalizedListenerProof(proof);
2276
+ if (!sameListenerProof(record.listenerProof, normalized))
2277
+ maintenanceError("listener-proof-mismatch", "restore repair proof does not exactly match the bound listener", {
2278
+ root: lock.root, attemptId: record.restore.attemptId,
2279
+ paths: [record.restore.target.path, record.restore.previousSource?.identity.path]
2280
+ .filter((path) => Boolean(path)),
2281
+ });
2282
+ assertStoreIdentity(record.restore.target);
2283
+ assertListenerOwnerAlive(record.listenerProof);
2284
+ if (!record.managerCommit || !record.managerCommittedAt)
2285
+ maintenanceError("invalid-transition", "uncommitted degraded restore must record manager commitment before finalization", {
2286
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2287
+ });
2288
+ if (!validManagerFinalizeEvidence(evidence, record.restore.attemptId, record.managerCommit.durableCommitToken))
2289
+ maintenanceError("activation-evidence-invalid", "restore repair requires exact token-bound manager finalize evidence", {
2290
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2291
+ });
2292
+ const details = {
2293
+ attemptId: record.restore.attemptId,
2294
+ listenerReady: true,
2295
+ observedAt: new Date().toISOString(),
2296
+ managerCommit: record.managerCommit,
2297
+ managerFinalize: evidence,
2298
+ };
2299
+ return writeJournal(lock, record, {
2300
+ ...readyFrom(record), state: "active", restore: record.restore, launch: record.launch,
2301
+ listenerProof: record.listenerProof,
2302
+ managerCommittedAt: record.managerCommittedAt, managerCommit: record.managerCommit,
2303
+ activeAt: new Date().toISOString(), details,
2304
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
2305
+ });
2306
+ }
2307
+ function cleanupTomb(record, kind, identity) {
2308
+ return join(dirname(identity.path), `.cotal-clean-${record.restore.attemptId}-${kind}-${randomUUID()}`);
2309
+ }
2310
+ function startCleanup(lock, record, kind, identity) {
2311
+ const cleanup = {
2312
+ kind, status: "pending", originalPath: identity.path,
2313
+ tombPath: cleanupTomb(record, kind, identity), identity,
2314
+ };
2315
+ const restore = { ...record.restore, cleanup };
2316
+ if (record.state === "restore-ready")
2317
+ return writeJournal(lock, record, { ...readyFrom(record), state: "restore-ready", phase: record.phase, restore, claim: record.claim });
2318
+ return writeJournal(lock, record, {
2319
+ ...readyFrom(record), state: "active", restore: restore,
2320
+ launch: record.launch, ...(record.listenerProof ? { listenerProof: record.listenerProof } : {}),
2321
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
2322
+ managerCommittedAt: record.managerCommittedAt, managerCommit: record.managerCommit,
2323
+ activeAt: record.activeAt, details: record.details,
2324
+ });
2325
+ }
2326
+ function continueCleanup(lock, record) {
2327
+ const cleanup = record.restore.cleanup;
2328
+ if (!cleanup)
2329
+ maintenanceError("invalid-transition", "no cleanup is recorded", { attemptId: record.restore.attemptId });
2330
+ if (cleanup.status === "complete")
2331
+ return record;
2332
+ if (record.state === "active") {
2333
+ assertStoreIdentity(record.restore.target);
2334
+ if (pathContains(cleanup.originalPath, record.restore.target.path) ||
2335
+ pathContains(cleanup.tombPath, record.restore.target.path))
2336
+ maintenanceError("cleanup-forbidden", "recursive cleanup tree contains the active target", {
2337
+ root: lock.root, attemptId: record.restore.attemptId,
2338
+ paths: [cleanup.originalPath, cleanup.tombPath, record.restore.target.path],
2339
+ });
2340
+ }
2341
+ const original = identityAt(cleanup.originalPath);
2342
+ const tomb = identityAt(cleanup.tombPath);
2343
+ const expectedOriginal = cleanup.identity;
2344
+ const expectedTomb = { ...cleanup.identity, path: cleanup.tombPath };
2345
+ if (original && sameStoreIdentity(expectedOriginal, original) && !tomb) {
2346
+ renameSync(cleanup.originalPath, cleanup.tombPath);
2347
+ fsyncDirectory(dirname(cleanup.originalPath));
2348
+ }
2349
+ else if (!original && tomb && sameStoreIdentity(expectedTomb, tomb)) {
2350
+ // Crash after rename: continue with the recorded inode.
2351
+ }
2352
+ else if (!original && !tomb) {
2353
+ // Crash after removal but before the final journal write: the requested cleanup is complete.
2354
+ }
2355
+ else {
2356
+ maintenanceError("ambiguous-filesystem-state", "recorded cleanup paths do not match the journal", {
2357
+ attemptId: record.restore.attemptId,
2358
+ paths: [cleanup.originalPath, cleanup.tombPath], expected: cleanup.identity, actual: original ?? tomb,
2359
+ recourse: [{ action: "inspect", description: "Preserve every matching path and repair the journal explicitly.", paths: [cleanup.originalPath, cleanup.tombPath] }],
2360
+ });
2361
+ }
2362
+ const moved = identityAt(cleanup.tombPath);
2363
+ if (moved) {
2364
+ if (!sameStoreIdentity(expectedTomb, moved))
2365
+ maintenanceError("identity-mismatch", "cleanup tomb inode changed", {
2366
+ attemptId: record.restore.attemptId, expected: expectedTomb, actual: moved, paths: [cleanup.tombPath],
2367
+ });
2368
+ if (record.state === "active")
2369
+ assertStoreIdentity(record.restore.target);
2370
+ rmSync(cleanup.tombPath, { recursive: true });
2371
+ fsyncDirectory(dirname(cleanup.tombPath));
2372
+ }
2373
+ const restore = { ...record.restore, cleanup: { ...cleanup, status: "complete" } };
2374
+ if (record.state === "restore-ready")
2375
+ return writeJournal(lock, record, { ...readyFrom(record), state: "restore-ready", phase: record.phase, restore, claim: record.claim });
2376
+ return writeJournal(lock, record, {
2377
+ ...readyFrom(record), state: "active", restore: restore,
2378
+ launch: record.launch, ...(record.listenerProof ? { listenerProof: record.listenerProof } : {}),
2379
+ ...(record.listenerReplacements ? { listenerReplacements: record.listenerReplacements } : {}),
2380
+ managerCommittedAt: record.managerCommittedAt, managerCommit: record.managerCommit,
2381
+ activeAt: record.activeAt, details: record.details,
2382
+ });
2383
+ }
2384
+ function cleanupAttemptTarget(lock, record) {
2385
+ const target = record.restore.target;
2386
+ if (!target)
2387
+ return record;
2388
+ let current = record;
2389
+ if (!current.restore.cleanup)
2390
+ current = startCleanup(lock, current, "attempt-target", target);
2391
+ if (current.restore.cleanup?.kind !== "attempt-target")
2392
+ maintenanceError("cleanup-forbidden", "pre-commit rollback cannot run previous-source cleanup", {
2393
+ attemptId: current.restore.attemptId,
2394
+ });
2395
+ return continueCleanup(lock, current);
2396
+ }
2397
+ /** Refuse to touch a LIVE restore attempt: only the exact recorded coordinator, or a caller that
2398
+ * proves the attempt stale (deadline elapsed + every owner dead), may roll it back. */
2399
+ function assertRestoreClaimRollbackable(lock, record, options) {
2400
+ if (options.asCoordinator) {
2401
+ if (!sameProcessOwner(options.asCoordinator, record.claim.coordinator))
2402
+ maintenanceError("claim-live", "rollback caller is not the recorded restore coordinator", {
2403
+ root: lock.root, attemptId: record.restore.attemptId,
2404
+ recourse: [{ action: "retry", description: "Wait for the live restore attempt to finish or become provably stale." }],
2405
+ });
2406
+ return;
2407
+ }
2408
+ const assessment = assessRestoreClaim(record, options);
2409
+ if (assessment === "live")
2410
+ maintenanceError("claim-live", "a restore attempt claim is still live", {
2411
+ root: lock.root, attemptId: record.restore.attemptId,
2412
+ recourse: [{ action: "retry", description: `Retry after ${record.claim.deadline} once the restore coordinator, watchdogs, and brokers have exited.` }],
2413
+ });
2414
+ if (assessment === "ambiguous")
2415
+ maintenanceError("claim-owner-ambiguous", "not every restore attempt owner is proven dead", {
2416
+ root: lock.root, attemptId: record.restore.attemptId,
2417
+ recourse: [{ action: "inspect", description: "Prove the recorded restore coordinator, watchdogs, and brokers dead before recovery." }],
2418
+ });
2419
+ }
2420
+ export function rollbackRestore(lock, options = {}) {
2421
+ const initial = currentJournal(lock);
2422
+ if (initial.state === "commit-intent" || initial.state === "manager-committed" || initial.state === "active" || initial.state === "degraded") {
2423
+ const paths = [initial.restore.target.path, initial.restore.previousSource?.identity.path].filter((p) => Boolean(p));
2424
+ maintenanceError("rollback-forbidden", "restore commit intent makes rollback unsafe", {
2425
+ root: lock.root, attemptId: initial.restore.attemptId, paths,
2426
+ recourse: [
2427
+ { action: "repair", description: "Preserve the restored target and old source; recover forward.", paths },
2428
+ { action: "cleanup", description: "After healthy activation, explicitly clean the recorded old source.", command: `cotal clean restore-fallback --attempt ${initial.restore.attemptId} --force`, paths },
2429
+ ],
2430
+ });
2431
+ }
2432
+ if (initial.state !== "restore-ready")
2433
+ maintenanceError("invalid-transition", "no pre-commit restore is available to roll back", { root: lock.root });
2434
+ assertRestoreClaimRollbackable(lock, initial, options);
2435
+ if (!initial.restore.target && pathExistsStrict(initial.restore.targetPath) &&
2436
+ !(initial.restore.method === "same-path" && initial.phase === "move-pending" &&
2437
+ sameStoreIdentity(initial.source, readStoreIdentity(initial.restore.targetPath)))) {
2438
+ // A crash between target creation and the bind journal write leaves an unbound target that is
2439
+ // either EMPTY (pre-marker) or holds EXACTLY the attempt's own generation marker — the single
2440
+ // durable side effect of ensureStoreIdentity before writeJournal. Both shapes are content-
2441
+ // bounded and provably data-free, so removal is safe; anything else fails closed.
2442
+ const stat = lstatSync(initial.restore.targetPath);
2443
+ const removableUnboundTarget = () => {
2444
+ if (!stat.isDirectory() || stat.isSymbolicLink())
2445
+ return false;
2446
+ const entries = readdirSync(initial.restore.targetPath);
2447
+ if (entries.length === 0)
2448
+ return true;
2449
+ if (entries.length !== 1 || entries[0] !== STORE_ID_FILE)
2450
+ return false;
2451
+ const marker = lstatSync(join(initial.restore.targetPath, STORE_ID_FILE));
2452
+ if (!marker.isFile() || marker.isSymbolicLink() || marker.size > 128)
2453
+ return false;
2454
+ const generation = readFileSync(join(initial.restore.targetPath, STORE_ID_FILE), "utf8").trim();
2455
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(generation);
2456
+ };
2457
+ if (removableUnboundTarget()) {
2458
+ rmSync(initial.restore.targetPath, { recursive: true });
2459
+ fsyncDirectory(dirname(initial.restore.targetPath));
2460
+ }
2461
+ else {
2462
+ maintenanceError("ambiguous-filesystem-state", "unbound restore target appeared before rollback", {
2463
+ attemptId: initial.restore.attemptId,
2464
+ paths: [initial.restore.targetPath],
2465
+ recourse: [{ action: "inspect", description: "Preserve the unbound path; its ownership was never journaled.", paths: [initial.restore.targetPath] }],
2466
+ });
2467
+ }
2468
+ }
2469
+ // Never delete the restored target until the exact old-source rollback path is proven usable.
2470
+ preflightRollbackSource(initial);
2471
+ let record = cleanupAttemptTarget(lock, initial);
2472
+ if (record.restore.method === "same-path") {
2473
+ const fallbackPath = record.restore.fallbackPath;
2474
+ const source = identityAt(record.source.path);
2475
+ const fallback = identityAt(fallbackPath);
2476
+ const expectedFallback = { ...record.source, path: fallbackPath };
2477
+ if (source && sameStoreIdentity(record.source, source) && !fallback) {
2478
+ // Crash after the fallback was already returned.
2479
+ }
2480
+ else if (!source && fallback && sameStoreIdentity(expectedFallback, fallback)) {
2481
+ renameSync(fallbackPath, record.source.path);
2482
+ fsyncDirectory(dirname(record.source.path));
2483
+ assertStoreIdentity(record.source);
2484
+ }
2485
+ else {
2486
+ maintenanceError("ambiguous-filesystem-state", "same-path rollback cannot identify one unchanged source", {
2487
+ attemptId: record.restore.attemptId,
2488
+ paths: [record.source.path, fallbackPath], expected: record.source, actual: source ?? fallback,
2489
+ recourse: [{ action: "inspect", description: "Preserve both paths and repair manually; do not overwrite either store.", paths: [record.source.path, fallbackPath] }],
2490
+ });
2491
+ }
2492
+ }
2493
+ else if (record.restore.method === "alternate") {
2494
+ assertStoreIdentity(record.source);
2495
+ }
2496
+ else if (identityAt(record.source.path)) {
2497
+ maintenanceError("ambiguous-filesystem-state", "missing-source rollback found an unexpected replacement", {
2498
+ attemptId: record.restore.attemptId, paths: [record.source.path], expected: record.source,
2499
+ });
2500
+ }
2501
+ removeOwnedPathsOrFail(lock, record.restore.attemptId, record.restore.ownedPaths);
2502
+ return writeJournal(lock, record, readyFrom(record));
2503
+ }
2504
+ export function cleanupRestoreFallback(lock, attemptId) {
2505
+ let record = currentJournal(lock);
2506
+ if (record.state !== "active" || record.restore.attemptId !== attemptId)
2507
+ maintenanceError("cleanup-forbidden", "old-source cleanup requires the matching healthy active restore", {
2508
+ root: lock.root, attemptId,
2509
+ recourse: [{ action: "repair", description: "Recover the restore to healthy active state before cleanup." }],
2510
+ });
2511
+ assertStoreIdentity(record.restore.target);
2512
+ const previous = record.restore.previousSource;
2513
+ if (!previous)
2514
+ maintenanceError("cleanup-forbidden", "this restore has no retained old source", { root: lock.root, attemptId });
2515
+ if (sameStoreIdentity(previous.identity, record.restore.target) || previous.identity.path === record.restore.target.path)
2516
+ maintenanceError("cleanup-forbidden", "recorded old source aliases the active target", {
2517
+ root: lock.root, attemptId, paths: [previous.identity.path, record.restore.target.path],
2518
+ });
2519
+ if (pathContains(previous.identity.path, record.restore.target.path))
2520
+ maintenanceError("cleanup-forbidden", "old-source cleanup would recursively delete the active target", {
2521
+ root: lock.root, attemptId, paths: [previous.identity.path, record.restore.target.path],
2522
+ recourse: [{ action: "repair", description: "Preserve both trees and repair the overlapping restore journal.", paths: [previous.identity.path, record.restore.target.path] }],
2523
+ });
2524
+ if (record.restore.cleanup?.status === "complete")
2525
+ return retireCleanedRestore(lock, record);
2526
+ if (!record.restore.cleanup)
2527
+ record = startCleanup(lock, record, "previous-source", previous.identity);
2528
+ if (record.restore.cleanup?.kind !== "previous-source")
2529
+ maintenanceError("cleanup-forbidden", "active record contains an attempt-target cleanup", { root: lock.root, attemptId });
2530
+ record = continueCleanup(lock, record);
2531
+ return retireCleanedRestore(lock, record);
2532
+ }
2533
+ /** Consume a healthy restore only after exact old-source cleanup is durably complete. */
2534
+ function retireCleanedRestore(lock, record) {
2535
+ if (record.restore.cleanup?.kind !== "previous-source" || record.restore.cleanup.status !== "complete")
2536
+ maintenanceError("cleanup-forbidden", "restore retirement requires completed previous-source cleanup", {
2537
+ root: lock.root, attemptId: record.restore.attemptId,
2538
+ });
2539
+ assertStoreIdentity(record.restore.target);
2540
+ if (!validRestoreActivationEvidence(record.details, record.restore.attemptId) ||
2541
+ !sameManagerCommitEvidence(record.managerCommit, record.details.managerCommit))
2542
+ maintenanceError("activation-evidence-invalid", "restore retirement requires exact healthy manager finalization evidence", {
2543
+ root: lock.root, attemptId: record.restore.attemptId, paths: [record.restore.target.path],
2544
+ });
2545
+ readMaintenanceResumeDocument(lock.root, record.resume);
2546
+ const paths = maintenancePaths(lock.root);
2547
+ unlinkSync(paths.journal);
2548
+ fsyncDirectory(paths.versionDir);
2549
+ return record;
2550
+ }
2551
+ //# sourceMappingURL=maintenance.js.map