@ricsam/r5d-worker 0.0.90 → 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 +2 -0
- package/dist/cjs/main.cjs +155 -26
- 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 -27
- 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
|
@@ -318,7 +318,7 @@ function normalizeSnapshotManifest(value) {
|
|
|
318
318
|
if (record.version !== PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION) {
|
|
319
319
|
throw new Error("Unsupported project worktree snapshot manifest version");
|
|
320
320
|
}
|
|
321
|
-
if (record.state !== "building" && record.state !== "prepared" && record.state !== "consumed") {
|
|
321
|
+
if (record.state !== "building" && record.state !== "prepared" && record.state !== "restoring" && record.state !== "consumed") {
|
|
322
322
|
throw new Error("Project worktree snapshot manifest has an invalid state");
|
|
323
323
|
}
|
|
324
324
|
if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
|
|
@@ -399,7 +399,7 @@ function assertSameSnapshotIdentity(expected, actual) {
|
|
|
399
399
|
function advanceSnapshotState(snapshotRoot, expected, state) {
|
|
400
400
|
const current = readSnapshotManifest(snapshotRoot);
|
|
401
401
|
assertSameSnapshotIdentity(expected, current);
|
|
402
|
-
const allowed = current.state === "building" && state === "prepared" || current.state === "prepared" && state === "consumed" || current.state === state;
|
|
402
|
+
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;
|
|
403
403
|
if (!allowed) throw new Error(`Project worktree snapshot cannot advance from ${current.state} to ${state}`);
|
|
404
404
|
return current.state === state ? current : writeSnapshotManifest(snapshotRoot, { ...current, state });
|
|
405
405
|
}
|
|
@@ -423,6 +423,20 @@ function copyCheckoutTree(sourceRoot, targetRoot) {
|
|
|
423
423
|
verbatimSymlinks: true
|
|
424
424
|
});
|
|
425
425
|
}
|
|
426
|
+
function measureCheckoutTree(root) {
|
|
427
|
+
const stat = import_node_fs.default.lstatSync(root);
|
|
428
|
+
if (stat.isSymbolicLink()) return 0;
|
|
429
|
+
if (stat.isFile()) return stat.size;
|
|
430
|
+
if (!stat.isDirectory()) return 0;
|
|
431
|
+
let bytes = 0;
|
|
432
|
+
for (const entry of import_node_fs.default.readdirSync(root).sort()) bytes += measureCheckoutTree(import_node_path.default.join(root, entry));
|
|
433
|
+
return bytes;
|
|
434
|
+
}
|
|
435
|
+
function snapshotBranchMeasurements(branches, sourcePath) {
|
|
436
|
+
return new Map(
|
|
437
|
+
branches.map(({ branchName, snapshotPresent }) => [branchName, snapshotPresent ? measureCheckoutTree(sourcePath(branchName)) : 0])
|
|
438
|
+
);
|
|
439
|
+
}
|
|
426
440
|
function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
|
|
427
441
|
let entries;
|
|
428
442
|
try {
|
|
@@ -445,7 +459,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
|
|
|
445
459
|
}
|
|
446
460
|
}
|
|
447
461
|
}
|
|
448
|
-
function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
462
|
+
function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress) {
|
|
449
463
|
const resolvedProjectRoot = import_node_path.default.resolve(projectRoot);
|
|
450
464
|
const resolvedTemporaryRoot = import_node_path.default.resolve(temporaryRoot ?? import_node_os.default.tmpdir());
|
|
451
465
|
assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
|
|
@@ -472,13 +486,57 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
|
472
486
|
branches: branchSnapshots,
|
|
473
487
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
474
488
|
});
|
|
489
|
+
onProgress?.({
|
|
490
|
+
operation: "snapshot",
|
|
491
|
+
phase: "scanning",
|
|
492
|
+
projectRoot: resolvedProjectRoot,
|
|
493
|
+
completedBranches: 0,
|
|
494
|
+
totalBranches: branchSnapshots.length,
|
|
495
|
+
completedBytes: 0
|
|
496
|
+
});
|
|
497
|
+
const branchBytes = snapshotBranchMeasurements(branchSnapshots, (branchName) => branchPath(resolvedProjectRoot, branchName));
|
|
498
|
+
const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
|
|
499
|
+
let completedBytes = 0;
|
|
500
|
+
let completedBranches = 0;
|
|
475
501
|
for (const { branchName, snapshotPresent } of branchSnapshots) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
502
|
+
onProgress?.({
|
|
503
|
+
operation: "snapshot",
|
|
504
|
+
phase: "copying",
|
|
505
|
+
projectRoot: resolvedProjectRoot,
|
|
506
|
+
branchName,
|
|
507
|
+
completedBranches,
|
|
508
|
+
totalBranches: branchSnapshots.length,
|
|
509
|
+
completedBytes,
|
|
510
|
+
totalBytes
|
|
511
|
+
});
|
|
512
|
+
if (snapshotPresent) {
|
|
513
|
+
const snapshotPath = fullSnapshotPath(root, branchName);
|
|
514
|
+
copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
|
|
515
|
+
paths.set(branchName, snapshotPath);
|
|
516
|
+
}
|
|
517
|
+
completedBytes += branchBytes.get(branchName) ?? 0;
|
|
518
|
+
completedBranches += 1;
|
|
519
|
+
onProgress?.({
|
|
520
|
+
operation: "snapshot",
|
|
521
|
+
phase: "copying",
|
|
522
|
+
projectRoot: resolvedProjectRoot,
|
|
523
|
+
branchName,
|
|
524
|
+
completedBranches,
|
|
525
|
+
totalBranches: branchSnapshots.length,
|
|
526
|
+
completedBytes,
|
|
527
|
+
totalBytes
|
|
528
|
+
});
|
|
480
529
|
}
|
|
481
530
|
const treesRoot = import_node_path.default.join(root, "trees");
|
|
531
|
+
onProgress?.({
|
|
532
|
+
operation: "snapshot",
|
|
533
|
+
phase: "syncing",
|
|
534
|
+
projectRoot: resolvedProjectRoot,
|
|
535
|
+
completedBranches,
|
|
536
|
+
totalBranches: branchSnapshots.length,
|
|
537
|
+
completedBytes,
|
|
538
|
+
totalBytes
|
|
539
|
+
});
|
|
482
540
|
if (import_node_fs.default.existsSync(treesRoot)) fsyncTree(treesRoot);
|
|
483
541
|
manifest = advanceSnapshotState(root, manifest, "prepared");
|
|
484
542
|
return { root, manifest, paths };
|
|
@@ -487,33 +545,127 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
|
487
545
|
throw error;
|
|
488
546
|
}
|
|
489
547
|
}
|
|
490
|
-
function restoreProjectWorktreeSnapshot(snapshotRoot, manifest) {
|
|
491
|
-
|
|
548
|
+
function restoreProjectWorktreeSnapshot(snapshotRoot, manifest, onProgress) {
|
|
549
|
+
let current = readSnapshotManifest(snapshotRoot);
|
|
492
550
|
assertSameSnapshotIdentity(manifest, current);
|
|
493
|
-
if (current.state !== "prepared"
|
|
551
|
+
if (current.state !== "prepared" && current.state !== "restoring") {
|
|
552
|
+
throw new Error(`Project worktree snapshot is ${current.state}, not prepared or restoring`);
|
|
553
|
+
}
|
|
554
|
+
const treesRoot = import_node_path.default.join(snapshotRoot, "trees");
|
|
555
|
+
const treesRootPresent = import_node_fs.default.existsSync(treesRoot);
|
|
556
|
+
const treesRootStat = treesRootPresent ? import_node_fs.default.lstatSync(treesRoot) : null;
|
|
557
|
+
if (treesRootStat && (!treesRootStat.isDirectory() || treesRootStat.isSymbolicLink())) {
|
|
558
|
+
throw new Error("Project worktree snapshot trees root must be a real directory");
|
|
559
|
+
}
|
|
560
|
+
const branchSourceRoot = current.state === "restoring" && !treesRootPresent ? current.projectRoot : treesRoot;
|
|
494
561
|
for (const { branchName, snapshotPresent } of current.branches) {
|
|
495
562
|
const targetPath = branchPath(current.projectRoot, branchName);
|
|
496
563
|
assertNoSymlinkBranchPathComponents(current.projectRoot, targetPath);
|
|
497
|
-
|
|
498
|
-
|
|
564
|
+
const sourcePath = import_node_path.default.join(branchSourceRoot, ...branchName.split("/"));
|
|
565
|
+
if (!snapshotPresent) {
|
|
566
|
+
if (current.state === "restoring" && !treesRootPresent && import_node_fs.default.existsSync(sourcePath)) {
|
|
567
|
+
throw new Error(`Restored project branch unexpectedly exists: ${branchName}`);
|
|
568
|
+
}
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
499
571
|
const stat = import_node_fs.default.lstatSync(sourcePath);
|
|
500
572
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
501
573
|
throw new Error(`Project worktree snapshot branch must be a real directory: ${branchName}`);
|
|
502
574
|
}
|
|
503
575
|
}
|
|
576
|
+
onProgress?.({
|
|
577
|
+
operation: "restore",
|
|
578
|
+
phase: "scanning",
|
|
579
|
+
projectRoot: current.projectRoot,
|
|
580
|
+
completedBranches: 0,
|
|
581
|
+
totalBranches: current.branches.length,
|
|
582
|
+
completedBytes: 0
|
|
583
|
+
});
|
|
584
|
+
const branchBytes = snapshotBranchMeasurements(current.branches, (branchName) => import_node_path.default.join(branchSourceRoot, ...branchName.split("/")));
|
|
585
|
+
const totalBytes = [...branchBytes.values()].reduce((total, bytes) => total + bytes, 0);
|
|
586
|
+
const presentBranches = current.branches.filter(({ snapshotPresent }) => snapshotPresent);
|
|
587
|
+
const destinationParent = import_node_path.default.dirname(current.projectRoot);
|
|
588
|
+
import_node_fs.default.mkdirSync(destinationParent, { recursive: true });
|
|
589
|
+
const canMoveWholeSnapshot = presentBranches.length > 0 && (current.state === "restoring" || treesRootStat !== null && treesRootStat.dev === import_node_fs.default.lstatSync(destinationParent).dev);
|
|
590
|
+
if (canMoveWholeSnapshot) {
|
|
591
|
+
if (current.state === "prepared") current = advanceSnapshotState(snapshotRoot, current, "restoring");
|
|
592
|
+
onProgress?.({
|
|
593
|
+
operation: "restore",
|
|
594
|
+
phase: "moving",
|
|
595
|
+
projectRoot: current.projectRoot,
|
|
596
|
+
completedBranches: treesRootPresent ? 0 : current.branches.length,
|
|
597
|
+
totalBranches: current.branches.length,
|
|
598
|
+
completedBytes: treesRootPresent ? 0 : totalBytes,
|
|
599
|
+
totalBytes
|
|
600
|
+
});
|
|
601
|
+
if (treesRootPresent) {
|
|
602
|
+
import_node_fs.default.rmSync(current.projectRoot, { recursive: true, force: true });
|
|
603
|
+
fsyncDirectoryChain(destinationParent, import_node_path.default.dirname(destinationParent));
|
|
604
|
+
import_node_fs.default.renameSync(treesRoot, current.projectRoot);
|
|
605
|
+
fsyncDirectory(snapshotRoot);
|
|
606
|
+
}
|
|
607
|
+
fsyncManagedProjectRootEntry(current.projectRoot);
|
|
608
|
+
onProgress?.({
|
|
609
|
+
operation: "restore",
|
|
610
|
+
phase: "moving",
|
|
611
|
+
projectRoot: current.projectRoot,
|
|
612
|
+
completedBranches: current.branches.length,
|
|
613
|
+
totalBranches: current.branches.length,
|
|
614
|
+
completedBytes: totalBytes,
|
|
615
|
+
totalBytes
|
|
616
|
+
});
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (current.state === "restoring") {
|
|
620
|
+
throw new Error("Project worktree snapshot entered atomic restore state without a movable snapshot tree");
|
|
621
|
+
}
|
|
504
622
|
for (const { branchName } of [...current.branches].sort((left, right) => right.branchName.length - left.branchName.length)) {
|
|
505
623
|
const targetPath = branchPath(current.projectRoot, branchName);
|
|
506
624
|
const targetParent = import_node_path.default.dirname(targetPath);
|
|
507
625
|
import_node_fs.default.rmSync(targetPath, { recursive: true, force: true });
|
|
508
626
|
if (import_node_fs.default.existsSync(targetParent)) fsyncDirectoryChain(targetParent, current.projectRoot);
|
|
509
627
|
}
|
|
628
|
+
let completedBytes = 0;
|
|
629
|
+
let completedBranches = 0;
|
|
510
630
|
for (const { branchName, snapshotPresent } of current.branches) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
631
|
+
onProgress?.({
|
|
632
|
+
operation: "restore",
|
|
633
|
+
phase: "copying",
|
|
634
|
+
projectRoot: current.projectRoot,
|
|
635
|
+
branchName,
|
|
636
|
+
completedBranches,
|
|
637
|
+
totalBranches: current.branches.length,
|
|
638
|
+
completedBytes,
|
|
639
|
+
totalBytes
|
|
640
|
+
});
|
|
641
|
+
if (snapshotPresent) {
|
|
642
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
643
|
+
copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
|
|
644
|
+
fsyncTree(targetPath);
|
|
645
|
+
fsyncDirectoryChain(import_node_path.default.dirname(targetPath), current.projectRoot);
|
|
646
|
+
}
|
|
647
|
+
completedBytes += branchBytes.get(branchName) ?? 0;
|
|
648
|
+
completedBranches += 1;
|
|
649
|
+
onProgress?.({
|
|
650
|
+
operation: "restore",
|
|
651
|
+
phase: "copying",
|
|
652
|
+
projectRoot: current.projectRoot,
|
|
653
|
+
branchName,
|
|
654
|
+
completedBranches,
|
|
655
|
+
totalBranches: current.branches.length,
|
|
656
|
+
completedBytes,
|
|
657
|
+
totalBytes
|
|
658
|
+
});
|
|
516
659
|
}
|
|
660
|
+
onProgress?.({
|
|
661
|
+
operation: "restore",
|
|
662
|
+
phase: "syncing",
|
|
663
|
+
projectRoot: current.projectRoot,
|
|
664
|
+
completedBranches,
|
|
665
|
+
totalBranches: current.branches.length,
|
|
666
|
+
completedBytes,
|
|
667
|
+
totalBytes
|
|
668
|
+
});
|
|
517
669
|
import_node_fs.default.mkdirSync(current.projectRoot, { recursive: true });
|
|
518
670
|
fsyncManagedProjectRootEntry(current.projectRoot);
|
|
519
671
|
}
|
|
@@ -534,7 +686,7 @@ function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFs
|
|
|
534
686
|
function rollbackProjectWorktreeSnapshot(snapshot) {
|
|
535
687
|
const current = readSnapshotManifest(snapshot.root);
|
|
536
688
|
assertSameSnapshotIdentity(snapshot.manifest, current);
|
|
537
|
-
if (current.state === "prepared") {
|
|
689
|
+
if (current.state === "prepared" || current.state === "restoring") {
|
|
538
690
|
restoreProjectWorktreeSnapshot(snapshot.root, current);
|
|
539
691
|
consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, current);
|
|
540
692
|
return;
|
|
@@ -616,8 +768,8 @@ function recoverStaleProjectWorktreeSnapshots(input) {
|
|
|
616
768
|
assertNoSymlinkPathComponents(projectsRoot, manifest.projectRoot);
|
|
617
769
|
const ownedByThisProcessSession = manifest.ownerProcessId === currentProcessId && manifest.ownerSessionId === currentOwnerSessionId;
|
|
618
770
|
if (ownedByThisProcessSession || ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
|
|
619
|
-
if (manifest.state === "prepared") {
|
|
620
|
-
restoreProjectWorktreeSnapshot(candidate, manifest);
|
|
771
|
+
if (manifest.state === "prepared" || manifest.state === "restoring") {
|
|
772
|
+
restoreProjectWorktreeSnapshot(candidate, manifest, input.onProgress);
|
|
621
773
|
const consumed = advanceSnapshotState(candidate, manifest, "consumed");
|
|
622
774
|
removeSnapshotStorage(candidate);
|
|
623
775
|
assertSameSnapshotIdentity(manifest, consumed);
|
|
@@ -803,7 +955,11 @@ async function initializeLinkedLayout(input) {
|
|
|
803
955
|
);
|
|
804
956
|
const checkoutPath = branchPath(input.projectRoot, branch.branchName);
|
|
805
957
|
import_node_fs.default.mkdirSync(import_node_path.default.dirname(checkoutPath), { recursive: true });
|
|
806
|
-
await gitAsync(
|
|
958
|
+
await gitAsync(
|
|
959
|
+
primaryPath,
|
|
960
|
+
["worktree", "add", "--force", checkoutPath, branch.branchName],
|
|
961
|
+
`create linked worktree ${branch.branchName}`
|
|
962
|
+
);
|
|
807
963
|
}
|
|
808
964
|
return mirrorHeads;
|
|
809
965
|
}
|
|
@@ -837,7 +993,11 @@ async function reconcileLinkedLayout(input) {
|
|
|
837
993
|
import_node_fs.default.rmSync(checkoutPath, { recursive: true, force: true });
|
|
838
994
|
await gitAsync(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
|
|
839
995
|
import_node_fs.default.mkdirSync(import_node_path.default.dirname(checkoutPath), { recursive: true });
|
|
840
|
-
await gitAsync(
|
|
996
|
+
await gitAsync(
|
|
997
|
+
primaryPath,
|
|
998
|
+
["worktree", "add", "--force", checkoutPath, branch.branchName],
|
|
999
|
+
`create linked worktree ${branch.branchName}`
|
|
1000
|
+
);
|
|
841
1001
|
}
|
|
842
1002
|
return { mirrorHeads, mutatedBranches };
|
|
843
1003
|
}
|
|
@@ -864,7 +1024,7 @@ async function ensureProjectWorktrees(input) {
|
|
|
864
1024
|
assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
|
|
865
1025
|
let snapshot;
|
|
866
1026
|
const ensureSnapshot = () => {
|
|
867
|
-
snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot);
|
|
1027
|
+
snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot, input.onSnapshotProgress);
|
|
868
1028
|
return snapshot;
|
|
869
1029
|
};
|
|
870
1030
|
try {
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -58,7 +58,6 @@ import { assertDisjointManagedRoots, managedProjectRoot, validateManagedBranchNa
|
|
|
58
58
|
import {
|
|
59
59
|
applyObservedProjectMirrorHeads,
|
|
60
60
|
assertProjectMirrorHeadsPushed,
|
|
61
|
-
recoverStaleProjectWorktreeSnapshots,
|
|
62
61
|
createOrRetryLinkedProjectBranch,
|
|
63
62
|
deleteLinkedProjectBranch,
|
|
64
63
|
deleteProjectMirrorBranch,
|
|
@@ -71,6 +70,11 @@ import {
|
|
|
71
70
|
pushProjectMirrorHeads,
|
|
72
71
|
removeProjectWorktrees
|
|
73
72
|
} from "./project-worktrees.mjs";
|
|
73
|
+
import {
|
|
74
|
+
PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND,
|
|
75
|
+
recoverProjectSnapshotsInChild,
|
|
76
|
+
runProjectSnapshotRecoveryHelper
|
|
77
|
+
} from "./project-snapshot-recovery-runner.mjs";
|
|
74
78
|
import {
|
|
75
79
|
ProjectWorkspacePendingBranchPathChangeError,
|
|
76
80
|
ProjectWorkspaceStateStore,
|
|
@@ -3411,28 +3415,6 @@ async function startWorker(options) {
|
|
|
3411
3415
|
runGit(["--version"]);
|
|
3412
3416
|
await verifyR5dctlAuth(baseUrl, token, label);
|
|
3413
3417
|
const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
|
|
3414
|
-
if (!startupProjectSnapshotRecoveryCompleted) {
|
|
3415
|
-
const snapshotRecovery = recoverStaleProjectWorktreeSnapshots({
|
|
3416
|
-
projectsRoot,
|
|
3417
|
-
temporaryRoot: projectWorktreeSnapshotsRoot
|
|
3418
|
-
});
|
|
3419
|
-
if (snapshotRecovery.restored.length > 0) {
|
|
3420
|
-
process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
|
|
3421
|
-
`);
|
|
3422
|
-
}
|
|
3423
|
-
if (snapshotRecovery.removed.length > 0) {
|
|
3424
|
-
process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
|
|
3425
|
-
`);
|
|
3426
|
-
}
|
|
3427
|
-
for (const failure of snapshotRecovery.failed) {
|
|
3428
|
-
process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
|
|
3429
|
-
`);
|
|
3430
|
-
}
|
|
3431
|
-
if (snapshotRecovery.failed.length > 0) {
|
|
3432
|
-
throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
|
|
3433
|
-
}
|
|
3434
|
-
startupProjectSnapshotRecoveryCompleted = true;
|
|
3435
|
-
}
|
|
3436
3418
|
fs.mkdirSync(projectsRoot, { recursive: true });
|
|
3437
3419
|
fs.mkdirSync(syncRoot, { recursive: true });
|
|
3438
3420
|
fs.mkdirSync(artifactRoot, { recursive: true });
|
|
@@ -3471,6 +3453,8 @@ async function startWorker(options) {
|
|
|
3471
3453
|
let cliUpdateInProgress = false;
|
|
3472
3454
|
let reloadAfterClose = false;
|
|
3473
3455
|
let shutdownAfterClose = false;
|
|
3456
|
+
let startupRecoveryComplete = startupProjectSnapshotRecoveryCompleted;
|
|
3457
|
+
let startupRecoveryPromise = Promise.resolve();
|
|
3474
3458
|
const automaticWorkspaceSyncIsCurrentlyAdmitted = () => workspaceAutomaticSyncIsAdmitted({
|
|
3475
3459
|
workspaceConfigured,
|
|
3476
3460
|
activeIncidentId: activeWorkspaceIncidentId,
|
|
@@ -3887,8 +3871,14 @@ async function startWorker(options) {
|
|
|
3887
3871
|
})
|
|
3888
3872
|
)
|
|
3889
3873
|
].sort();
|
|
3890
|
-
const ensureConfiguredProjects = async () => {
|
|
3891
|
-
|
|
3874
|
+
const ensureConfiguredProjects = async (onProgress) => {
|
|
3875
|
+
const projects = [...projectConfigById.values()];
|
|
3876
|
+
for (const [projectIndex, project] of projects.entries()) {
|
|
3877
|
+
onProgress?.({
|
|
3878
|
+
detail: `Configuring ${project.projectPath}`,
|
|
3879
|
+
completedItems: projectIndex,
|
|
3880
|
+
totalItems: projects.length
|
|
3881
|
+
});
|
|
3892
3882
|
validateProjectId(project.projectId);
|
|
3893
3883
|
assertRepositoryTransitionState(project);
|
|
3894
3884
|
for (const branch of project.branches) validateBranchName(branch.branchName);
|
|
@@ -3936,7 +3926,14 @@ async function startWorker(options) {
|
|
|
3936
3926
|
mirrorUrl: connection.mirrorUrl,
|
|
3937
3927
|
mirrorCredentialUsername: connection.mirrorCredentialUsername,
|
|
3938
3928
|
credentialHelper: connection.credentialHelper,
|
|
3939
|
-
gitIdentity: workspaceGitIdentity
|
|
3929
|
+
gitIdentity: workspaceGitIdentity,
|
|
3930
|
+
onSnapshotProgress: (progress) => onProgress?.({
|
|
3931
|
+
detail: progress.phase === "scanning" ? `Measuring ${project.projectPath} workspace snapshot` : progress.phase === "syncing" ? `Making ${project.projectPath} workspace snapshot durable` : `${progress.operation === "restore" ? "Restoring" : "Snapshotting"} ${project.projectPath}${progress.branchName ? `/${progress.branchName}` : ""}`,
|
|
3932
|
+
completedBytes: progress.completedBytes,
|
|
3933
|
+
completedItems: progress.completedBranches,
|
|
3934
|
+
totalItems: progress.totalBranches,
|
|
3935
|
+
...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
|
|
3936
|
+
})
|
|
3940
3937
|
});
|
|
3941
3938
|
readyProjectIds.add(project.projectId);
|
|
3942
3939
|
reconciledProjectConfigFingerprints.set(project.projectId, configFingerprint);
|
|
@@ -3971,6 +3968,11 @@ async function startWorker(options) {
|
|
|
3971
3968
|
`
|
|
3972
3969
|
);
|
|
3973
3970
|
}
|
|
3971
|
+
onProgress?.({
|
|
3972
|
+
detail: `Configured ${project.projectPath}`,
|
|
3973
|
+
completedItems: projectIndex + 1,
|
|
3974
|
+
totalItems: projects.length
|
|
3975
|
+
});
|
|
3974
3976
|
}
|
|
3975
3977
|
};
|
|
3976
3978
|
const collectAheadOfOriginBranches = async () => {
|
|
@@ -4363,6 +4365,11 @@ async function startWorker(options) {
|
|
|
4363
4365
|
if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result2);
|
|
4364
4366
|
return result2;
|
|
4365
4367
|
}
|
|
4368
|
+
sendLifecycleProgress({
|
|
4369
|
+
phase: "syncing",
|
|
4370
|
+
operationId: attemptId,
|
|
4371
|
+
detail: input.trigger.detail ?? `Synchronizing workspace (${input.trigger.type})`
|
|
4372
|
+
});
|
|
4366
4373
|
if (!input.requestId && input.sendResult !== false) {
|
|
4367
4374
|
if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
4368
4375
|
throw new Error("Cannot start autonomous workspace synchronization without an open worker control socket");
|
|
@@ -4465,6 +4472,9 @@ async function startWorker(options) {
|
|
|
4465
4472
|
}
|
|
4466
4473
|
};
|
|
4467
4474
|
if (input.sendResult !== false) sendWorkspaceSyncResult(input.requestId, result);
|
|
4475
|
+
sendLifecycleProgress(
|
|
4476
|
+
result.outcome === "failed" ? { phase: "failed", operationId: attemptId, detail: result.error ?? "Workspace synchronization failed" } : { phase: "ready", operationId: attemptId, detail: "Workspace synchronized" }
|
|
4477
|
+
);
|
|
4468
4478
|
return result;
|
|
4469
4479
|
};
|
|
4470
4480
|
const scheduleAutomaticWorkspaceSync = (trigger, delayMs = WORKSPACE_GIT_QUIET_MS) => {
|
|
@@ -4539,6 +4549,14 @@ async function startWorker(options) {
|
|
|
4539
4549
|
});
|
|
4540
4550
|
};
|
|
4541
4551
|
const configureWorkerWorkspace = async (message, receiptGeneration) => {
|
|
4552
|
+
const configurationOperationId = crypto.randomUUID();
|
|
4553
|
+
sendLifecycleProgress({
|
|
4554
|
+
phase: "configuring",
|
|
4555
|
+
operationId: configurationOperationId,
|
|
4556
|
+
detail: `Preparing ${message.projects.length} project(s)`,
|
|
4557
|
+
completedItems: 0,
|
|
4558
|
+
totalItems: message.projects.length
|
|
4559
|
+
});
|
|
4542
4560
|
const incidentDeferral = workspaceConfigurationIncidentDeferral({
|
|
4543
4561
|
requestedIncidentId: message.deferWorkspaceSyncForIncidentId,
|
|
4544
4562
|
activeIncidentId: activeWorkspaceIncidentId
|
|
@@ -4903,7 +4921,13 @@ async function startWorker(options) {
|
|
|
4903
4921
|
// checkout initialization from erasing synchronized dirt.
|
|
4904
4922
|
{ ignoreBusy: true }
|
|
4905
4923
|
);
|
|
4906
|
-
await ensureConfiguredProjects()
|
|
4924
|
+
await ensureConfiguredProjects((progress) => {
|
|
4925
|
+
sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
|
|
4926
|
+
process.stdout.write(
|
|
4927
|
+
`[r5d-worker] ${progress.detail ?? "Configuring workspace"}${progress.completedBytes === void 0 ? "" : `: ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes`}${progress.completedItems === void 0 ? "" : `, ${progress.completedItems}/${progress.totalItems ?? "?"} item(s)`}
|
|
4928
|
+
`
|
|
4929
|
+
);
|
|
4930
|
+
});
|
|
4907
4931
|
const configurationSyncAdmissionGeneration = workerAdmissionGeneration;
|
|
4908
4932
|
const assertConfigurationSyncStillAdmitted = () => {
|
|
4909
4933
|
if (receiptGeneration !== workspaceConfigurationReceiptGeneration || configurationSyncAdmissionGeneration !== workerAdmissionGeneration || !workspaceConfigurationIncidentSnapshotIsCurrent(incidentDeferral.incidentId, activeWorkspaceIncidentId) || currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
@@ -5002,6 +5026,60 @@ async function startWorker(options) {
|
|
|
5002
5026
|
Authorization: `Bearer ${token}`
|
|
5003
5027
|
}
|
|
5004
5028
|
});
|
|
5029
|
+
const sendLifecycleProgress = (progress) => {
|
|
5030
|
+
if (currentWorkerSocket !== ws || ws.readyState !== WebSocket.OPEN) return;
|
|
5031
|
+
sendWorkerMessage(ws, { type: "worker_progress", progress });
|
|
5032
|
+
};
|
|
5033
|
+
const describeSnapshotProgress = (progress) => {
|
|
5034
|
+
const project = path.relative(projectsRoot, progress.projectRoot) || path.basename(progress.projectRoot);
|
|
5035
|
+
const branch = progress.branchName ? `/${progress.branchName}` : "";
|
|
5036
|
+
if (progress.phase === "scanning") return `Scanning ${project} recovery snapshot`;
|
|
5037
|
+
if (progress.phase === "syncing") return `Making restored ${project} files durable`;
|
|
5038
|
+
if (progress.phase === "cleaning") return `Cleaning recovered ${project} snapshot`;
|
|
5039
|
+
return `Restoring ${project}${branch}`;
|
|
5040
|
+
};
|
|
5041
|
+
const performStartupRecovery = async (operationId) => {
|
|
5042
|
+
if (startupProjectSnapshotRecoveryCompleted) return;
|
|
5043
|
+
sendLifecycleProgress({ phase: "recovering", operationId, detail: "Checking interrupted workspace transactions" });
|
|
5044
|
+
const snapshotRecovery = await workspaceSyncSingleFlight.runExclusive(
|
|
5045
|
+
async () => await recoverProjectSnapshotsInChild({
|
|
5046
|
+
projectsRoot,
|
|
5047
|
+
snapshotRoot: projectWorktreeSnapshotsRoot,
|
|
5048
|
+
onProgress: (progress) => {
|
|
5049
|
+
const lifecycleProgress = {
|
|
5050
|
+
phase: "recovering",
|
|
5051
|
+
operationId,
|
|
5052
|
+
detail: describeSnapshotProgress(progress),
|
|
5053
|
+
completedBytes: progress.completedBytes,
|
|
5054
|
+
completedItems: progress.completedBranches,
|
|
5055
|
+
totalItems: progress.totalBranches,
|
|
5056
|
+
...progress.totalBytes === void 0 ? {} : { totalBytes: progress.totalBytes }
|
|
5057
|
+
};
|
|
5058
|
+
sendLifecycleProgress(lifecycleProgress);
|
|
5059
|
+
process.stdout.write(
|
|
5060
|
+
`[r5d-worker] ${lifecycleProgress.detail}: ${progress.completedBranches}/${progress.totalBranches} branch(es), ${progress.completedBytes}/${progress.totalBytes ?? "?"} bytes
|
|
5061
|
+
`
|
|
5062
|
+
);
|
|
5063
|
+
}
|
|
5064
|
+
})
|
|
5065
|
+
);
|
|
5066
|
+
if (snapshotRecovery.restored.length > 0) {
|
|
5067
|
+
process.stdout.write(`[r5d-worker] restored ${snapshotRecovery.restored.length} interrupted project snapshot(s)
|
|
5068
|
+
`);
|
|
5069
|
+
}
|
|
5070
|
+
if (snapshotRecovery.removed.length > 0) {
|
|
5071
|
+
process.stdout.write(`[r5d-worker] removed ${snapshotRecovery.removed.length} completed/incomplete project snapshot(s)
|
|
5072
|
+
`);
|
|
5073
|
+
}
|
|
5074
|
+
for (const failure of snapshotRecovery.failed) {
|
|
5075
|
+
process.stderr.write(`[r5d-worker] failed to recover stale project snapshot ${failure.path}: ${failure.error}
|
|
5076
|
+
`);
|
|
5077
|
+
}
|
|
5078
|
+
if (snapshotRecovery.failed.length > 0) {
|
|
5079
|
+
throw new Error("Project snapshot recovery is incomplete; refusing to mutate project checkouts");
|
|
5080
|
+
}
|
|
5081
|
+
startupProjectSnapshotRecoveryCompleted = true;
|
|
5082
|
+
};
|
|
5005
5083
|
const handleGracefulShutdown = () => {
|
|
5006
5084
|
if (shutdownAfterClose) return;
|
|
5007
5085
|
shutdownAfterClose = true;
|
|
@@ -5031,8 +5109,27 @@ async function startWorker(options) {
|
|
|
5031
5109
|
process.on("SIGINT", handleGracefulShutdown);
|
|
5032
5110
|
ws.addEventListener("open", () => {
|
|
5033
5111
|
currentWorkerSocket = ws;
|
|
5112
|
+
const startupOperationId = crypto.randomUUID();
|
|
5113
|
+
startupRecoveryPromise = Promise.resolve().then(async () => await performStartupRecovery(startupOperationId)).then(() => {
|
|
5114
|
+
startupRecoveryComplete = true;
|
|
5115
|
+
if (currentWorkerSocket === ws && ws.readyState === WebSocket.OPEN) {
|
|
5116
|
+
sendWorkerMessage(ws, { type: "startup_ready" });
|
|
5117
|
+
sendLifecycleProgress({
|
|
5118
|
+
phase: "configuring",
|
|
5119
|
+
operationId: crypto.randomUUID(),
|
|
5120
|
+
detail: "Waiting for workspace configuration"
|
|
5121
|
+
});
|
|
5122
|
+
}
|
|
5123
|
+
}).catch((error) => {
|
|
5124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5125
|
+
sendLifecycleProgress({ phase: "failed", operationId: startupOperationId, detail: message });
|
|
5126
|
+
process.stderr.write(`[r5d-worker] startup recovery failed: ${message}
|
|
5127
|
+
`);
|
|
5128
|
+
ws.close(1011, "Project snapshot recovery failed");
|
|
5129
|
+
});
|
|
5034
5130
|
const hello = {
|
|
5035
5131
|
type: "hello",
|
|
5132
|
+
startupReady: startupRecoveryComplete,
|
|
5036
5133
|
hostInfo: {
|
|
5037
5134
|
hostname: hostname(),
|
|
5038
5135
|
platform: process.platform,
|
|
@@ -5070,6 +5167,8 @@ async function startWorker(options) {
|
|
|
5070
5167
|
ws.addEventListener("message", (event) => {
|
|
5071
5168
|
void (async () => {
|
|
5072
5169
|
const message = JSON.parse(String(event.data));
|
|
5170
|
+
if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
|
|
5171
|
+
if (!startupRecoveryComplete && message.type !== "ping") return;
|
|
5073
5172
|
const messageAdmissionGeneration = workerAdmissionGeneration;
|
|
5074
5173
|
const assertMessageAdmission = () => assertWorkerChildAdmission({
|
|
5075
5174
|
capturedGeneration: messageAdmissionGeneration,
|
|
@@ -5097,6 +5196,19 @@ async function startWorker(options) {
|
|
|
5097
5196
|
aheadOfOriginBranches: configured.aheadOfOriginBranches,
|
|
5098
5197
|
...configured.deferredWorkspaceSyncForIncidentId ? { deferredWorkspaceSyncForIncidentId: configured.deferredWorkspaceSyncForIncidentId } : {}
|
|
5099
5198
|
});
|
|
5199
|
+
sendLifecycleProgress(
|
|
5200
|
+
configured.result.outcome === "failed" ? {
|
|
5201
|
+
phase: "failed",
|
|
5202
|
+
operationId: configured.result.attemptId,
|
|
5203
|
+
detail: configured.result.error ?? "Workspace configuration failed"
|
|
5204
|
+
} : {
|
|
5205
|
+
phase: "ready",
|
|
5206
|
+
operationId: configured.result.attemptId,
|
|
5207
|
+
detail: `${message.projects.length} project(s) configured`,
|
|
5208
|
+
completedItems: message.projects.length,
|
|
5209
|
+
totalItems: message.projects.length
|
|
5210
|
+
}
|
|
5211
|
+
);
|
|
5100
5212
|
process.stdout.write(
|
|
5101
5213
|
`[r5d-worker] workspace configured: ${message.projects.length} project(s), ${configured.pending.length} pending checkout(s)
|
|
5102
5214
|
`
|
|
@@ -5885,6 +5997,21 @@ async function startWorker(options) {
|
|
|
5885
5997
|
if (shutdownAfterClose) {
|
|
5886
5998
|
process.exit(0);
|
|
5887
5999
|
}
|
|
6000
|
+
if (!startupRecoveryComplete) {
|
|
6001
|
+
process.stderr.write("[r5d-worker] control connection closed during workspace recovery; finishing recovery before reconnecting\n");
|
|
6002
|
+
void startupRecoveryPromise.then(() => {
|
|
6003
|
+
if (!startupRecoveryComplete) {
|
|
6004
|
+
process.exit(1);
|
|
6005
|
+
return;
|
|
6006
|
+
}
|
|
6007
|
+
void startWorker(options).catch((error) => {
|
|
6008
|
+
process.stderr.write(`[r5d-worker] reconnect failed: ${error instanceof Error ? error.message : String(error)}
|
|
6009
|
+
`);
|
|
6010
|
+
process.exit(error instanceof WorkerServerUnavailableError ? WORKER_RECONNECT_EXIT_CODE : 1);
|
|
6011
|
+
});
|
|
6012
|
+
});
|
|
6013
|
+
return;
|
|
6014
|
+
}
|
|
5888
6015
|
if (activeProcesses.size > 0 || credentialBearingProcessGroups.size > 0 || activePtys.size > 0 || pendingProcessTerminals.size > 0) {
|
|
5889
6016
|
process.stderr.write(
|
|
5890
6017
|
`[r5d-worker] ${activeProcesses.size} process(es), ${credentialBearingProcessGroups.size} owned process group(s), and ${activePtys.size} PTY(s) active with ${pendingProcessTerminals.size} terminal report(s) pending; reconnecting in ${WORKER_RECONNECT_DELAY_MS}ms
|
|
@@ -5915,6 +6042,11 @@ async function startWorker(options) {
|
|
|
5915
6042
|
});
|
|
5916
6043
|
}
|
|
5917
6044
|
async function main() {
|
|
6045
|
+
if (process.argv[2] === PROJECT_SNAPSHOT_RECOVERY_HELPER_COMMAND) {
|
|
6046
|
+
const exitCode2 = runProjectSnapshotRecoveryHelper(process.argv.slice(3));
|
|
6047
|
+
if (exitCode2 !== 0) process.exit(exitCode2);
|
|
6048
|
+
return;
|
|
6049
|
+
}
|
|
5918
6050
|
const parsed = parseArgs(process.argv.slice(2));
|
|
5919
6051
|
if (parsed.command === "help") {
|
|
5920
6052
|
printHelp();
|
package/dist/mjs/package.json
CHANGED