@ricsam/r5d-worker 0.0.89 → 0.0.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/cjs/main.cjs +155 -52
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-snapshot-recovery-runner.cjs +171 -0
- package/dist/cjs/project-worktrees.cjs +183 -23
- package/dist/mjs/main.mjs +159 -57
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-snapshot-recovery-runner.mjs +135 -0
- package/dist/mjs/project-worktrees.mjs +183 -23
- package/dist/types/project-snapshot-recovery-runner.d.ts +10 -0
- package/dist/types/project-worktrees.d.ts +13 -1
- package/package.json +1 -1
- package/dist/cjs/heartbeat.cjs +0 -49
- package/dist/mjs/heartbeat.mjs +0 -21
- package/dist/types/heartbeat.d.ts +0 -10
package/dist/mjs/package.json
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { recoverStaleProjectWorktreeSnapshots } from "./project-worktrees.mjs";
|
|
4
|
+
const PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND = "__recover-project-snapshots";
|
|
5
|
+
function requireAbsolutePath(value, label) {
|
|
6
|
+
if (!value) throw new Error(`Missing ${label}`);
|
|
7
|
+
const resolved = path.resolve(value);
|
|
8
|
+
if (resolved !== value) throw new Error(`${label} must be an absolute path`);
|
|
9
|
+
return resolved;
|
|
10
|
+
}
|
|
11
|
+
function parseHelperArguments(args) {
|
|
12
|
+
let projectsRoot;
|
|
13
|
+
let snapshotRoot;
|
|
14
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
15
|
+
const argument = args[index];
|
|
16
|
+
if (argument === "--projects-root") {
|
|
17
|
+
projectsRoot = args[index + 1];
|
|
18
|
+
index += 1;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (argument === "--snapshot-root") {
|
|
22
|
+
snapshotRoot = args[index + 1];
|
|
23
|
+
index += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`Unknown project snapshot recovery helper argument: ${argument ?? "(missing)"}`);
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
projectsRoot: requireAbsolutePath(projectsRoot, "projects root"),
|
|
30
|
+
snapshotRoot: requireAbsolutePath(snapshotRoot, "snapshot root")
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function writeHelperMessage(message) {
|
|
34
|
+
process.stdout.write(`${JSON.stringify(message)}
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
function runProjectSnapshotRecoveryHelper(args) {
|
|
38
|
+
const { projectsRoot, snapshotRoot } = parseHelperArguments(args);
|
|
39
|
+
const result = recoverStaleProjectWorktreeSnapshots({
|
|
40
|
+
projectsRoot,
|
|
41
|
+
temporaryRoot: snapshotRoot,
|
|
42
|
+
onProgress: (progress) => writeHelperMessage({ type: "progress", progress })
|
|
43
|
+
});
|
|
44
|
+
writeHelperMessage({ type: "result", result });
|
|
45
|
+
return result.failed.length === 0 ? 0 : 1;
|
|
46
|
+
}
|
|
47
|
+
function isRecoveryProgress(value) {
|
|
48
|
+
if (!value || typeof value !== "object") return false;
|
|
49
|
+
const progress = value;
|
|
50
|
+
return progress.operation === "restore" && (progress.phase === "scanning" || progress.phase === "copying" || progress.phase === "moving" || progress.phase === "syncing" || progress.phase === "cleaning") && typeof progress.projectRoot === "string" && Number.isSafeInteger(progress.completedBranches) && Number.isSafeInteger(progress.totalBranches) && typeof progress.completedBytes === "number" && Number.isFinite(progress.completedBytes) && (progress.totalBytes === void 0 || typeof progress.totalBytes === "number" && Number.isFinite(progress.totalBytes));
|
|
51
|
+
}
|
|
52
|
+
function isRecoveryResult(value) {
|
|
53
|
+
if (!value || typeof value !== "object") return false;
|
|
54
|
+
const result = value;
|
|
55
|
+
return Array.isArray(result.restored) && Array.isArray(result.removed) && Array.isArray(result.failed);
|
|
56
|
+
}
|
|
57
|
+
function recoveryChildEnvironment() {
|
|
58
|
+
const environment = {};
|
|
59
|
+
for (const name of ["PATH", "TMPDIR", "TMP", "TEMP", "SystemRoot", "WINDIR"]) {
|
|
60
|
+
const value = process.env[name];
|
|
61
|
+
if (value !== void 0) environment[name] = value;
|
|
62
|
+
}
|
|
63
|
+
return environment;
|
|
64
|
+
}
|
|
65
|
+
async function recoverProjectSnapshotsInChild(input) {
|
|
66
|
+
const entrypoint = process.argv[1];
|
|
67
|
+
if (!entrypoint) throw new Error("Cannot locate the r5d-worker runtime entrypoint for snapshot recovery");
|
|
68
|
+
const child = spawn(
|
|
69
|
+
process.execPath,
|
|
70
|
+
[
|
|
71
|
+
entrypoint,
|
|
72
|
+
PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
|
|
73
|
+
"--projects-root",
|
|
74
|
+
path.resolve(input.projectsRoot),
|
|
75
|
+
"--snapshot-root",
|
|
76
|
+
path.resolve(input.snapshotRoot)
|
|
77
|
+
],
|
|
78
|
+
{
|
|
79
|
+
env: recoveryChildEnvironment(),
|
|
80
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
const terminateOnParentExit = () => {
|
|
84
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
|
|
85
|
+
};
|
|
86
|
+
const parentProcessEvents = process;
|
|
87
|
+
parentProcessEvents.once("exit", terminateOnParentExit);
|
|
88
|
+
let stdout = "";
|
|
89
|
+
let stderr = "";
|
|
90
|
+
let result;
|
|
91
|
+
child.stdout.setEncoding("utf8");
|
|
92
|
+
child.stdout.on("data", (chunk) => {
|
|
93
|
+
stdout += chunk;
|
|
94
|
+
while (true) {
|
|
95
|
+
const newline = stdout.indexOf("\n");
|
|
96
|
+
if (newline < 0) break;
|
|
97
|
+
const line = stdout.slice(0, newline);
|
|
98
|
+
stdout = stdout.slice(newline + 1);
|
|
99
|
+
if (!line.trim()) continue;
|
|
100
|
+
let message;
|
|
101
|
+
try {
|
|
102
|
+
message = JSON.parse(line);
|
|
103
|
+
} catch {
|
|
104
|
+
stderr += `Invalid recovery helper output: ${line}
|
|
105
|
+
`;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!message || typeof message !== "object") continue;
|
|
109
|
+
const record = message;
|
|
110
|
+
if (record.type === "progress" && isRecoveryProgress(record.progress)) input.onProgress?.(record.progress);
|
|
111
|
+
if (record.type === "result" && isRecoveryResult(record.result)) result = record.result;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
child.stderr.setEncoding("utf8");
|
|
115
|
+
child.stderr.on("data", (chunk) => {
|
|
116
|
+
stderr = `${stderr}${chunk}`.slice(-32768);
|
|
117
|
+
});
|
|
118
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
119
|
+
child.once("error", reject);
|
|
120
|
+
child.once("close", (code, signal) => {
|
|
121
|
+
if (signal) reject(new Error(`Project snapshot recovery helper exited on signal ${signal}`));
|
|
122
|
+
else resolve(code ?? 1);
|
|
123
|
+
});
|
|
124
|
+
}).finally(() => parentProcessEvents.off("exit", terminateOnParentExit));
|
|
125
|
+
if (exitCode !== 0 || !result) {
|
|
126
|
+
const detail = stderr.trim();
|
|
127
|
+
throw new Error(detail || `Project snapshot recovery helper exited with status ${exitCode}`);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
|
|
133
|
+
recoverProjectSnapshotsInChild,
|
|
134
|
+
runProjectSnapshotRecoveryHelper
|
|
135
|
+
};
|
|
@@ -265,7 +265,7 @@ function normalizeSnapshotManifest(value) {
|
|
|
265
265
|
if (record.version !== PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION) {
|
|
266
266
|
throw new Error("Unsupported project worktree snapshot manifest version");
|
|
267
267
|
}
|
|
268
|
-
if (record.state !== "building" && record.state !== "prepared" && record.state !== "consumed") {
|
|
268
|
+
if (record.state !== "building" && record.state !== "prepared" && record.state !== "restoring" && record.state !== "consumed") {
|
|
269
269
|
throw new Error("Project worktree snapshot manifest has an invalid state");
|
|
270
270
|
}
|
|
271
271
|
if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
|
|
@@ -346,7 +346,7 @@ function assertSameSnapshotIdentity(expected, actual) {
|
|
|
346
346
|
function advanceSnapshotState(snapshotRoot, expected, state) {
|
|
347
347
|
const current = readSnapshotManifest(snapshotRoot);
|
|
348
348
|
assertSameSnapshotIdentity(expected, current);
|
|
349
|
-
const allowed = current.state === "building" && state === "prepared" || current.state === "prepared" && state === "consumed" || current.state === state;
|
|
349
|
+
const allowed = current.state === "building" && state === "prepared" || current.state === "prepared" && state === "restoring" || current.state === "prepared" && state === "consumed" || current.state === "restoring" && state === "consumed" || current.state === state;
|
|
350
350
|
if (!allowed) throw new Error(`Project worktree snapshot cannot advance from ${current.state} to ${state}`);
|
|
351
351
|
return current.state === state ? current : writeSnapshotManifest(snapshotRoot, { ...current, state });
|
|
352
352
|
}
|
|
@@ -370,6 +370,20 @@ function copyCheckoutTree(sourceRoot, targetRoot) {
|
|
|
370
370
|
verbatimSymlinks: true
|
|
371
371
|
});
|
|
372
372
|
}
|
|
373
|
+
function measureCheckoutTree(root) {
|
|
374
|
+
const stat = fs.lstatSync(root);
|
|
375
|
+
if (stat.isSymbolicLink()) return 0;
|
|
376
|
+
if (stat.isFile()) return stat.size;
|
|
377
|
+
if (!stat.isDirectory()) return 0;
|
|
378
|
+
let bytes = 0;
|
|
379
|
+
for (const entry of fs.readdirSync(root).sort()) bytes += measureCheckoutTree(path.join(root, entry));
|
|
380
|
+
return bytes;
|
|
381
|
+
}
|
|
382
|
+
function snapshotBranchMeasurements(branches, sourcePath) {
|
|
383
|
+
return new Map(
|
|
384
|
+
branches.map(({ branchName, snapshotPresent }) => [branchName, snapshotPresent ? measureCheckoutTree(sourcePath(branchName)) : 0])
|
|
385
|
+
);
|
|
386
|
+
}
|
|
373
387
|
function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
|
|
374
388
|
let entries;
|
|
375
389
|
try {
|
|
@@ -392,7 +406,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
|
|
|
392
406
|
}
|
|
393
407
|
}
|
|
394
408
|
}
|
|
395
|
-
function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
409
|
+
function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
|
|
396
410
|
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
397
411
|
const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
|
|
398
412
|
assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
|
|
@@ -419,13 +433,57 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
|
419
433
|
branches: branchSnapshots,
|
|
420
434
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
421
435
|
});
|
|
436
|
+
onProgress?.({
|
|
437
|
+
operation: "snapshot",
|
|
438
|
+
phase: "scanning",
|
|
439
|
+
projectRoot: resolvedProjectRoot,
|
|
440
|
+
completedBranches: 0,
|
|
441
|
+
totalBranches: branchSnapshots.length,
|
|
442
|
+
completedBytes: 0
|
|
443
|
+
});
|
|
444
|
+
const branchBytes = snapshotBranchMeasurements(branchSnapshots, (branchName) => branchPath(resolvedProjectRoot, branchName));
|
|
445
|
+
const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
|
|
446
|
+
let completedBytes = 0;
|
|
447
|
+
let completedBranches = 0;
|
|
422
448
|
for (const { branchName, snapshotPresent } of branchSnapshots) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
449
|
+
onProgress?.({
|
|
450
|
+
operation: "snapshot",
|
|
451
|
+
phase: "copying",
|
|
452
|
+
projectRoot: resolvedProjectRoot,
|
|
453
|
+
branchName,
|
|
454
|
+
completedBranches,
|
|
455
|
+
totalBranches: branchSnapshots.length,
|
|
456
|
+
completedBytes,
|
|
457
|
+
totalBytes
|
|
458
|
+
});
|
|
459
|
+
if (snapshotPresent) {
|
|
460
|
+
const snapshotPath = fullSnapshotPath(root, branchName);
|
|
461
|
+
copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
|
|
462
|
+
paths.set(branchName, snapshotPath);
|
|
463
|
+
}
|
|
464
|
+
completedBytes += branchBytes.get(branchName) ?? 0;
|
|
465
|
+
completedBranches += 1;
|
|
466
|
+
onProgress?.({
|
|
467
|
+
operation: "snapshot",
|
|
468
|
+
phase: "copying",
|
|
469
|
+
projectRoot: resolvedProjectRoot,
|
|
470
|
+
branchName,
|
|
471
|
+
completedBranches,
|
|
472
|
+
totalBranches: branchSnapshots.length,
|
|
473
|
+
completedBytes,
|
|
474
|
+
totalBytes
|
|
475
|
+
});
|
|
427
476
|
}
|
|
428
477
|
const treesRoot = path.join(root, "trees");
|
|
478
|
+
onProgress?.({
|
|
479
|
+
operation: "snapshot",
|
|
480
|
+
phase: "syncing",
|
|
481
|
+
projectRoot: resolvedProjectRoot,
|
|
482
|
+
completedBranches,
|
|
483
|
+
totalBranches: branchSnapshots.length,
|
|
484
|
+
completedBytes,
|
|
485
|
+
totalBytes
|
|
486
|
+
});
|
|
429
487
|
if (fs.existsSync(treesRoot)) fsyncTree(treesRoot);
|
|
430
488
|
manifest = advanceSnapshotState(root, manifest, "prepared");
|
|
431
489
|
return { root, manifest, paths };
|
|
@@ -434,33 +492,127 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
|
434
492
|
throw error;
|
|
435
493
|
}
|
|
436
494
|
}
|
|
437
|
-
function restoreProjectWorktreeSnapshot(snapshotRoot, manifest) {
|
|
438
|
-
|
|
495
|
+
function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
|
|
496
|
+
let current = readSnapshotManifest(snapshotRoot);
|
|
439
497
|
assertSameSnapshotIdentity(manifest, current);
|
|
440
|
-
if (current.state !== "prepared"
|
|
498
|
+
if (current.state !== "prepared" && current.state !== "restoring") {
|
|
499
|
+
throw new Error(`Project worktree snapshot is ${current.state}, not prepared or restoring`);
|
|
500
|
+
}
|
|
501
|
+
const treesRoot = path.join(snapshotRoot, "trees");
|
|
502
|
+
const treesRootPresent = fs.existsSync(treesRoot);
|
|
503
|
+
const treesRootStat = treesRootPresent ? fs.lstatSync(treesRoot) : null;
|
|
504
|
+
if (treesRootStat && (!treesRootStat.isDirectory() || treesRootStat.isSymbolicLink())) {
|
|
505
|
+
throw new Error("Project worktree snapshot trees root must be a real directory");
|
|
506
|
+
}
|
|
507
|
+
const branchSourceRoot = current.state === "restoring" && !treesRootPresent ? current.projectRoot : treesRoot;
|
|
441
508
|
for (const { branchName, snapshotPresent } of current.branches) {
|
|
442
509
|
const targetPath = branchPath(current.projectRoot, branchName);
|
|
443
510
|
assertNoSymlinkBranchPathComponents(current.projectRoot, targetPath);
|
|
444
|
-
|
|
445
|
-
|
|
511
|
+
const sourcePath = path.join(branchSourceRoot, ...branchName.split("/"));
|
|
512
|
+
if (!snapshotPresent) {
|
|
513
|
+
if (current.state === "restoring" && !treesRootPresent && fs.existsSync(sourcePath)) {
|
|
514
|
+
throw new Error(`Restored project branch unexpectedly exists: ${branchName}`);
|
|
515
|
+
}
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
446
518
|
const stat = fs.lstatSync(sourcePath);
|
|
447
519
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
448
520
|
throw new Error(`Project worktree snapshot branch must be a real directory: ${branchName}`);
|
|
449
521
|
}
|
|
450
522
|
}
|
|
523
|
+
onProgress?.({
|
|
524
|
+
operation: "restore",
|
|
525
|
+
phase: "scanning",
|
|
526
|
+
projectRoot: current.projectRoot,
|
|
527
|
+
completedBranches: 0,
|
|
528
|
+
totalBranches: current.branches.length,
|
|
529
|
+
completedBytes: 0
|
|
530
|
+
});
|
|
531
|
+
const branchBytes = snapshotBranchMeasurements(current.branches, (branchName) => path.join(branchSourceRoot, ...branchName.split("/")));
|
|
532
|
+
const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
|
|
533
|
+
const presentBranches = current.branches.filter(({ snapshotPresent }) => snapshotPresent);
|
|
534
|
+
const destinationParent = path.dirname(current.projectRoot);
|
|
535
|
+
fs.mkdirSync(destinationParent, { recursive: true });
|
|
536
|
+
const canMoveWholeSnapshot = presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === fs.lstatSync(destinationParent).dev);
|
|
537
|
+
if (canMoveWholeSnapshot) {
|
|
538
|
+
if (current.state === "prepared") current = advanceSnapshotState(snapshotRoot, current, "restoring");
|
|
539
|
+
onProgress?.({
|
|
540
|
+
operation: "restore",
|
|
541
|
+
phase: "moving",
|
|
542
|
+
projectRoot: current.projectRoot,
|
|
543
|
+
completedBranches: treesRootPresent ? 0 : current.branches.length,
|
|
544
|
+
totalBranches: current.branches.length,
|
|
545
|
+
completedBytes: treesRootPresent ? 0 : totalBytes,
|
|
546
|
+
totalBytes
|
|
547
|
+
});
|
|
548
|
+
if (treesRootPresent) {
|
|
549
|
+
fs.rmSync(current.projectRoot, { recursive: true, force: true });
|
|
550
|
+
fsyncDirectoryChain(destinationParent, path.dirname(destinationParent));
|
|
551
|
+
fs.renameSync(treesRoot, current.projectRoot);
|
|
552
|
+
fsyncDirectory(snapshotRoot);
|
|
553
|
+
}
|
|
554
|
+
fsyncManagedProjectRootEntry(current.projectRoot);
|
|
555
|
+
onProgress?.({
|
|
556
|
+
operation: "restore",
|
|
557
|
+
phase: "moving",
|
|
558
|
+
projectRoot: current.projectRoot,
|
|
559
|
+
completedBranches: current.branches.length,
|
|
560
|
+
totalBranches: current.branches.length,
|
|
561
|
+
completedBytes: totalBytes,
|
|
562
|
+
totalBytes
|
|
563
|
+
});
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (current.state === "restoring") {
|
|
567
|
+
throw new Error("Project worktree snapshot entered atomic restore state without a movable snapshot tree");
|
|
568
|
+
}
|
|
451
569
|
for (const { branchName } of [...current.branches].sort((left, right) => right.branchName.length - left.branchName.length)) {
|
|
452
570
|
const targetPath = branchPath(current.projectRoot, branchName);
|
|
453
571
|
const targetParent = path.dirname(targetPath);
|
|
454
572
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
455
573
|
if (fs.existsSync(targetParent)) fsyncDirectoryChain(targetParent, current.projectRoot);
|
|
456
574
|
}
|
|
575
|
+
let completedBytes = 0;
|
|
576
|
+
let completedBranches = 0;
|
|
457
577
|
for (const { branchName, snapshotPresent } of current.branches) {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
578
|
+
onProgress?.({
|
|
579
|
+
operation: "restore",
|
|
580
|
+
phase: "copying",
|
|
581
|
+
projectRoot: current.projectRoot,
|
|
582
|
+
branchName,
|
|
583
|
+
completedBranches,
|
|
584
|
+
totalBranches: current.branches.length,
|
|
585
|
+
completedBytes,
|
|
586
|
+
totalBytes
|
|
587
|
+
});
|
|
588
|
+
if (snapshotPresent) {
|
|
589
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
590
|
+
copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
|
|
591
|
+
fsyncTree(targetPath);
|
|
592
|
+
fsyncDirectoryChain(path.dirname(targetPath), current.projectRoot);
|
|
593
|
+
}
|
|
594
|
+
completedBytes += branchBytes.get(branchName) ?? 0;
|
|
595
|
+
completedBranches += 1;
|
|
596
|
+
onProgress?.({
|
|
597
|
+
operation: "restore",
|
|
598
|
+
phase: "copying",
|
|
599
|
+
projectRoot: current.projectRoot,
|
|
600
|
+
branchName,
|
|
601
|
+
completedBranches,
|
|
602
|
+
totalBranches: current.branches.length,
|
|
603
|
+
completedBytes,
|
|
604
|
+
totalBytes
|
|
605
|
+
});
|
|
463
606
|
}
|
|
607
|
+
onProgress?.({
|
|
608
|
+
operation: "restore",
|
|
609
|
+
phase: "syncing",
|
|
610
|
+
projectRoot: current.projectRoot,
|
|
611
|
+
completedBranches,
|
|
612
|
+
totalBranches: current.branches.length,
|
|
613
|
+
completedBytes,
|
|
614
|
+
totalBytes
|
|
615
|
+
});
|
|
464
616
|
fs.mkdirSync(current.projectRoot, { recursive: true });
|
|
465
617
|
fsyncManagedProjectRootEntry(current.projectRoot);
|
|
466
618
|
}
|
|
@@ -481,7 +633,7 @@ function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFs
|
|
|
481
633
|
function rollbackProjectWorktreeSnapshot(snapshot) {
|
|
482
634
|
const current = readSnapshotManifest(snapshot.root);
|
|
483
635
|
assertSameSnapshotIdentity(snapshot.manifest, current);
|
|
484
|
-
if (current.state === "prepared") {
|
|
636
|
+
if (current.state === "prepared" || current.state === "restoring") {
|
|
485
637
|
restoreProjectWorktreeSnapshot(snapshot.root, current);
|
|
486
638
|
consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, current);
|
|
487
639
|
return;
|
|
@@ -563,8 +715,8 @@ function recoverStaleProjectWorktreeSnapshots(input) {
|
|
|
563
715
|
assertNoSymlinkPathComponents(projectsRoot, manifest.projectRoot);
|
|
564
716
|
const ownedByThisProcessSession = manifest.ownerProcessId === currentProcessId && manifest.ownerSessionId === currentOwnerSessionId;
|
|
565
717
|
if (ownedByThisProcessSession || ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
|
|
566
|
-
if (manifest.state === "prepared") {
|
|
567
|
-
restoreProjectWorktreeSnapshot(candidate, manifest);
|
|
718
|
+
if (manifest.state === "prepared" || manifest.state === "restoring") {
|
|
719
|
+
restoreProjectWorktreeSnapshot(candidate, manifest, input.onProgress);
|
|
568
720
|
const consumed = advanceSnapshotState(candidate, manifest, "consumed");
|
|
569
721
|
removeSnapshotStorage(candidate);
|
|
570
722
|
assertSameSnapshotIdentity(manifest, consumed);
|
|
@@ -750,7 +902,11 @@ async function initializeLinkedLayout(input) {
|
|
|
750
902
|
);
|
|
751
903
|
const checkoutPath = branchPath(input.projectRoot, branch.branchName);
|
|
752
904
|
fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
|
|
753
|
-
await gitAsync(
|
|
905
|
+
await gitAsync(
|
|
906
|
+
primaryPath,
|
|
907
|
+
["worktree", "add", "--force", checkoutPath, branch.branchName],
|
|
908
|
+
`create linked worktree ${branch.branchName}`
|
|
909
|
+
);
|
|
754
910
|
}
|
|
755
911
|
return mirrorHeads;
|
|
756
912
|
}
|
|
@@ -784,7 +940,11 @@ async function reconcileLinkedLayout(input) {
|
|
|
784
940
|
fs.rmSync(checkoutPath, { recursive: true, force: true });
|
|
785
941
|
await gitAsync(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
|
|
786
942
|
fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
|
|
787
|
-
await gitAsync(
|
|
943
|
+
await gitAsync(
|
|
944
|
+
primaryPath,
|
|
945
|
+
["worktree", "add", "--force", checkoutPath, branch.branchName],
|
|
946
|
+
`create linked worktree ${branch.branchName}`
|
|
947
|
+
);
|
|
788
948
|
}
|
|
789
949
|
return { mirrorHeads, mutatedBranches };
|
|
790
950
|
}
|
|
@@ -811,7 +971,7 @@ async function ensureProjectWorktrees(input) {
|
|
|
811
971
|
assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
|
|
812
972
|
let snapshot;
|
|
813
973
|
const ensureSnapshot = () => {
|
|
814
|
-
snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot);
|
|
974
|
+
snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot, input.onSnapshotProgress);
|
|
815
975
|
return snapshot;
|
|
816
976
|
};
|
|
817
977
|
try {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { recoverStaleProjectWorktreeSnapshots, type ProjectWorktreeSnapshotProgress } from "./project-worktrees";
|
|
2
|
+
export declare const PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND = "__recover-project-snapshots";
|
|
3
|
+
type ProjectSnapshotRecoveryResult = ReturnType<typeof recoverStaleProjectWorktreeSnapshots>;
|
|
4
|
+
export declare function runProjectSnapshotRecoveryHelper(args: readonly string[]): number;
|
|
5
|
+
export declare function recoverProjectSnapshotsInChild(input: {
|
|
6
|
+
projectsRoot: string;
|
|
7
|
+
snapshotRoot: string;
|
|
8
|
+
onProgress?: (progress: ProjectWorktreeSnapshotProgress) => void;
|
|
9
|
+
}): Promise<ProjectSnapshotRecoveryResult>;
|
|
10
|
+
export {};
|
|
@@ -2,7 +2,7 @@ export declare const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-"
|
|
|
2
2
|
declare const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION: 1;
|
|
3
3
|
type ProjectWorktreeSnapshotManifest = {
|
|
4
4
|
version: typeof PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION;
|
|
5
|
-
state: "building" | "prepared" | "consumed";
|
|
5
|
+
state: "building" | "prepared" | "restoring" | "consumed";
|
|
6
6
|
ownerProcessId: number;
|
|
7
7
|
ownerSessionId: string;
|
|
8
8
|
projectRoot: string;
|
|
@@ -12,6 +12,16 @@ type ProjectWorktreeSnapshotManifest = {
|
|
|
12
12
|
}>;
|
|
13
13
|
createdAt: string;
|
|
14
14
|
};
|
|
15
|
+
export type ProjectWorktreeSnapshotProgress = {
|
|
16
|
+
operation: "snapshot" | "restore";
|
|
17
|
+
phase: "scanning" | "copying" | "moving" | "syncing" | "cleaning";
|
|
18
|
+
projectRoot: string;
|
|
19
|
+
branchName?: string;
|
|
20
|
+
completedBranches: number;
|
|
21
|
+
totalBranches: number;
|
|
22
|
+
completedBytes: number;
|
|
23
|
+
totalBytes?: number;
|
|
24
|
+
};
|
|
15
25
|
export type ProjectWorktreeBranch = {
|
|
16
26
|
branchId?: string;
|
|
17
27
|
branchName: string;
|
|
@@ -68,6 +78,7 @@ export declare function recoverStaleProjectWorktreeSnapshots(input: {
|
|
|
68
78
|
processAlive?: (processId: number) => boolean;
|
|
69
79
|
currentProcessId?: number;
|
|
70
80
|
currentOwnerSessionId?: string;
|
|
81
|
+
onProgress?: (progress: ProjectWorktreeSnapshotProgress) => void;
|
|
71
82
|
}): {
|
|
72
83
|
restored: string[];
|
|
73
84
|
removed: string[];
|
|
@@ -110,6 +121,7 @@ export declare function ensureProjectWorktrees(input: {
|
|
|
110
121
|
} | null;
|
|
111
122
|
exactMirrorBranches?: ReadonlySet<string>;
|
|
112
123
|
snapshotRoot?: string;
|
|
124
|
+
onSnapshotProgress?: (progress: ProjectWorktreeSnapshotProgress) => void;
|
|
113
125
|
}): Promise<ProjectWorktreeState[]>;
|
|
114
126
|
export declare function projectWorktreeOperationInProgress(checkoutPath: string): boolean;
|
|
115
127
|
export declare function createLinkedProjectBranch(input: {
|
package/package.json
CHANGED
package/dist/cjs/heartbeat.cjs
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (target, all) => {
|
|
7
|
-
for (var name in all)
|
|
8
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
-
};
|
|
10
|
-
var __copyProps = (to, from, except, desc) => {
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
-
for (let key of __getOwnPropNames(from))
|
|
13
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
-
}
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
-
var heartbeat_exports = {};
|
|
20
|
-
__export(heartbeat_exports, {
|
|
21
|
-
WORKER_HEARTBEAT_INTERVAL_MS: () => WORKER_HEARTBEAT_INTERVAL_MS,
|
|
22
|
-
WORKER_HEARTBEAT_TIMEOUT_MS: () => WORKER_HEARTBEAT_TIMEOUT_MS,
|
|
23
|
-
grantWorkerHeartbeatBusyGrace: () => grantWorkerHeartbeatBusyGrace,
|
|
24
|
-
hasWorkerHeartbeatTimedOut: () => hasWorkerHeartbeatTimedOut,
|
|
25
|
-
hasWorkerHeartbeatTimedOutWithBusyGrace: () => hasWorkerHeartbeatTimedOutWithBusyGrace
|
|
26
|
-
});
|
|
27
|
-
module.exports = __toCommonJS(heartbeat_exports);
|
|
28
|
-
const WORKER_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
29
|
-
const WORKER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
30
|
-
function hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
31
|
-
return lastHeartbeatAt !== null && now - lastHeartbeatAt >= timeoutMs;
|
|
32
|
-
}
|
|
33
|
-
function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt, currentGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS, graceMs = WORKER_HEARTBEAT_INTERVAL_MS) {
|
|
34
|
-
if (!hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs)) return currentGrace;
|
|
35
|
-
if (currentGrace?.heartbeatAt === lastHeartbeatAt) return currentGrace;
|
|
36
|
-
return { heartbeatAt: lastHeartbeatAt, expiresAt: now + graceMs };
|
|
37
|
-
}
|
|
38
|
-
function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt, busyGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
39
|
-
if (lastHeartbeatAt !== null && busyGrace?.heartbeatAt === lastHeartbeatAt && now < busyGrace.expiresAt) return false;
|
|
40
|
-
return hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs);
|
|
41
|
-
}
|
|
42
|
-
// Annotate the CommonJS export names for ESM import in node:
|
|
43
|
-
0 && (module.exports = {
|
|
44
|
-
WORKER_HEARTBEAT_INTERVAL_MS,
|
|
45
|
-
WORKER_HEARTBEAT_TIMEOUT_MS,
|
|
46
|
-
grantWorkerHeartbeatBusyGrace,
|
|
47
|
-
hasWorkerHeartbeatTimedOut,
|
|
48
|
-
hasWorkerHeartbeatTimedOutWithBusyGrace
|
|
49
|
-
});
|
package/dist/mjs/heartbeat.mjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
const WORKER_HEARTBEAT_INTERVAL_MS = 15e3;
|
|
2
|
-
const WORKER_HEARTBEAT_TIMEOUT_MS = 45e3;
|
|
3
|
-
function hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
4
|
-
return lastHeartbeatAt !== null && now - lastHeartbeatAt >= timeoutMs;
|
|
5
|
-
}
|
|
6
|
-
function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt, currentGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS, graceMs = WORKER_HEARTBEAT_INTERVAL_MS) {
|
|
7
|
-
if (!hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs)) return currentGrace;
|
|
8
|
-
if (currentGrace?.heartbeatAt === lastHeartbeatAt) return currentGrace;
|
|
9
|
-
return { heartbeatAt: lastHeartbeatAt, expiresAt: now + graceMs };
|
|
10
|
-
}
|
|
11
|
-
function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt, busyGrace, now = Date.now(), timeoutMs = WORKER_HEARTBEAT_TIMEOUT_MS) {
|
|
12
|
-
if (lastHeartbeatAt !== null && busyGrace?.heartbeatAt === lastHeartbeatAt && now < busyGrace.expiresAt) return false;
|
|
13
|
-
return hasWorkerHeartbeatTimedOut(lastHeartbeatAt, now, timeoutMs);
|
|
14
|
-
}
|
|
15
|
-
export {
|
|
16
|
-
WORKER_HEARTBEAT_INTERVAL_MS,
|
|
17
|
-
WORKER_HEARTBEAT_TIMEOUT_MS,
|
|
18
|
-
grantWorkerHeartbeatBusyGrace,
|
|
19
|
-
hasWorkerHeartbeatTimedOut,
|
|
20
|
-
hasWorkerHeartbeatTimedOutWithBusyGrace
|
|
21
|
-
};
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export declare const WORKER_HEARTBEAT_INTERVAL_MS = 15000;
|
|
2
|
-
export declare const WORKER_HEARTBEAT_TIMEOUT_MS = 45000;
|
|
3
|
-
export declare function hasWorkerHeartbeatTimedOut(lastHeartbeatAt: number | null, now?: number, timeoutMs?: number): boolean;
|
|
4
|
-
export type WorkerHeartbeatBusyGrace = {
|
|
5
|
-
heartbeatAt: number;
|
|
6
|
-
expiresAt: number;
|
|
7
|
-
};
|
|
8
|
-
/** Grant one bounded grace period for one actual server-heartbeat generation. */
|
|
9
|
-
export declare function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt: number | null, currentGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number, graceMs?: number): WorkerHeartbeatBusyGrace | null;
|
|
10
|
-
export declare function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt: number | null, busyGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number): boolean;
|