@ricsam/r5d-worker 0.0.123 → 0.0.125

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.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/command-launcher.cjs +6 -2
  3. package/dist/cjs/main.cjs +496 -238
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/project-checkout-garbage.cjs +32 -3
  6. package/dist/cjs/project-workspace-state.cjs +51 -35
  7. package/dist/cjs/project-worktrees.cjs +79 -34
  8. package/dist/cjs/recovery-journal-protocol.cjs +56 -0
  9. package/dist/cjs/recovery-journal-runtime.cjs +133 -0
  10. package/dist/cjs/recovery-journal-thread.cjs +9 -0
  11. package/dist/cjs/recovery-journal.cjs +735 -0
  12. package/dist/cjs/recovery-store.cjs +8 -0
  13. package/dist/cjs/session-file-mutations.cjs +61 -0
  14. package/dist/cjs/working-tree-mirror.cjs +1 -0
  15. package/dist/cjs/workspace-command-sync-policy.cjs +37 -8
  16. package/dist/cjs/workspace-filesystem-executor-thread.cjs +36 -0
  17. package/dist/cjs/workspace-filesystem-executor.cjs +327 -0
  18. package/dist/cjs/workspace-filesystem-job-types.cjs +134 -0
  19. package/dist/cjs/workspace-filesystem-jobs.cjs +57 -0
  20. package/dist/cjs/workspace-git-sync.cjs +275 -201
  21. package/dist/cjs/workspace-mount-hold-fence.cjs +120 -0
  22. package/dist/mjs/command-launcher.mjs +6 -2
  23. package/dist/mjs/main.mjs +499 -244
  24. package/dist/mjs/package.json +1 -1
  25. package/dist/mjs/project-checkout-garbage.mjs +30 -2
  26. package/dist/mjs/project-workspace-state.mjs +51 -35
  27. package/dist/mjs/project-worktrees.mjs +74 -34
  28. package/dist/mjs/recovery-journal-protocol.mjs +30 -0
  29. package/dist/mjs/recovery-journal-runtime.mjs +112 -0
  30. package/dist/mjs/recovery-journal-thread.mjs +8 -0
  31. package/dist/mjs/recovery-journal.mjs +687 -0
  32. package/dist/mjs/recovery-store.mjs +8 -0
  33. package/dist/mjs/session-file-mutations.mjs +37 -0
  34. package/dist/mjs/working-tree-mirror.mjs +1 -0
  35. package/dist/mjs/workspace-command-sync-policy.mjs +37 -8
  36. package/dist/mjs/workspace-filesystem-executor-thread.mjs +38 -0
  37. package/dist/mjs/workspace-filesystem-executor.mjs +287 -0
  38. package/dist/mjs/workspace-filesystem-job-types.mjs +106 -0
  39. package/dist/mjs/workspace-filesystem-jobs.mjs +51 -0
  40. package/dist/mjs/workspace-git-sync.mjs +264 -202
  41. package/dist/mjs/workspace-mount-hold-fence.mjs +95 -0
  42. package/dist/types/command-launcher.d.ts +2 -1
  43. package/dist/types/main.d.ts +30 -19
  44. package/dist/types/project-checkout-garbage.d.ts +19 -2
  45. package/dist/types/project-workspace-state.d.ts +9 -9
  46. package/dist/types/project-worktrees.d.ts +49 -5
  47. package/dist/types/recovery-journal-protocol.d.ts +35 -0
  48. package/dist/types/recovery-journal-runtime.d.ts +12 -0
  49. package/dist/types/recovery-journal-stall-fixture.d.ts +1 -0
  50. package/dist/types/recovery-journal-thread.d.ts +1 -0
  51. package/dist/types/recovery-journal.d.ts +246 -0
  52. package/dist/types/recovery-store.d.ts +6 -0
  53. package/dist/types/session-file-mutations.d.ts +22 -0
  54. package/dist/types/workspace-command-sync-policy.d.ts +22 -7
  55. package/dist/types/workspace-filesystem-executor-thread.d.ts +1 -0
  56. package/dist/types/workspace-filesystem-executor.d.ts +123 -0
  57. package/dist/types/workspace-filesystem-job-types.d.ts +246 -0
  58. package/dist/types/workspace-filesystem-jobs.d.ts +7 -0
  59. package/dist/types/workspace-git-sync.d.ts +113 -7
  60. package/dist/types/workspace-mount-hold-fence.d.ts +42 -0
  61. package/package.json +1 -1
  62. package/dist/cjs/project-snapshot-recovery-runner.cjs +0 -171
  63. package/dist/mjs/project-snapshot-recovery-runner.mjs +0 -135
  64. package/dist/types/project-snapshot-recovery-runner.d.ts +0 -10
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.123",
3
+ "version": "0.0.125",
4
4
  "type": "module"
5
5
  }
@@ -19,11 +19,33 @@ function stageProjectCheckoutForDeletion(projectRoot, branchName, checkoutPath)
19
19
  fs.renameSync(checkoutPath, target);
20
20
  return target;
21
21
  }
22
+ function stageProjectForDeletion(projectsRoot, projectRoot) {
23
+ const root = path.resolve(projectsRoot);
24
+ const resolved = path.resolve(projectRoot);
25
+ const relative = path.relative(root, resolved);
26
+ const segments = relative.split(path.sep);
27
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || segments.length !== 2 || segments.some((segment) => !segment || segment.startsWith("."))) {
28
+ throw new Error(`Cannot stage ${projectRoot} for deletion: not a project root under ${projectsRoot}`);
29
+ }
30
+ if (!isRealDirectory(resolved)) throw new Error(`Cannot stage ${projectRoot} for deletion: not a directory`);
31
+ const garbageRoot = path.join(root, segments[0], PROJECT_DELETED_CHECKOUTS_DIRECTORY);
32
+ fs.mkdirSync(garbageRoot, { recursive: true });
33
+ if (!isRealDirectory(garbageRoot)) throw new Error(`Cannot stage ${projectRoot} for deletion: ${garbageRoot} is not a directory`);
34
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
35
+ const target = path.join(garbageRoot, `${segments[1]}-${timestamp}-${randomUUID().slice(0, 8)}`);
36
+ fs.renameSync(resolved, target);
37
+ return target;
38
+ }
22
39
  function isStagedProjectCheckoutPath(projectsRoot, candidate) {
23
40
  const relative = path.relative(path.resolve(projectsRoot), path.resolve(candidate));
24
41
  if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return false;
25
42
  const segments = relative.split(path.sep);
26
- return segments.length === 4 && segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && segments[3] !== "" && !segments[3].startsWith(".");
43
+ const entry = segments[segments.length - 1];
44
+ if (!entry || entry.startsWith(".")) return false;
45
+ if (segments.length === 4)
46
+ return segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && !segments[0].startsWith(".") && !segments[1].startsWith(".");
47
+ if (segments.length === 3) return segments[1] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && !segments[0].startsWith(".");
48
+ return false;
27
49
  }
28
50
  function ownedStagedPath(projectsRoot, candidate) {
29
51
  const root = path.resolve(projectsRoot);
@@ -56,6 +78,11 @@ function discoverStagedProjectCheckouts(projectsRoot) {
56
78
  const staged = [];
57
79
  for (const namespace of realDirectoryEntries(root)) {
58
80
  if (namespace.startsWith(".")) continue;
81
+ const namespaceGarbageRoot = path.join(root, namespace, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
82
+ for (const entry of realDirectoryEntries(namespaceGarbageRoot)) {
83
+ const candidate = path.join(namespaceGarbageRoot, entry);
84
+ if (ownedStagedPath(root, candidate)) staged.push(candidate);
85
+ }
59
86
  for (const project of realDirectoryEntries(path.join(root, namespace))) {
60
87
  if (project.startsWith(".")) continue;
61
88
  const garbageRoot = path.join(root, namespace, project, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
@@ -146,5 +173,6 @@ export {
146
173
  isStagedProjectCheckoutPath,
147
174
  ownedStagedPath,
148
175
  removeStagedProjectCheckout,
149
- stageProjectCheckoutForDeletion
176
+ stageProjectCheckoutForDeletion,
177
+ stageProjectForDeletion
150
178
  };
@@ -280,47 +280,63 @@ function lstatIfExists(targetPath) {
280
280
  throw error;
281
281
  }
282
282
  }
283
- function ensureDurableStateRoot(stateRoot) {
283
+ async function fsyncDirectoryAsync(directory) {
284
+ const handle = await fs.promises.open(directory, "r");
285
+ try {
286
+ await handle.sync();
287
+ } finally {
288
+ await handle.close();
289
+ }
290
+ }
291
+ async function lstatIfExistsAsync(targetPath) {
292
+ try {
293
+ return await fs.promises.lstat(targetPath);
294
+ } catch (error) {
295
+ if (error.code === "ENOENT") return null;
296
+ throw error;
297
+ }
298
+ }
299
+ async function ensureDurableStateRoot(stateRoot) {
284
300
  const resolvedStateRoot = path.resolve(stateRoot);
285
301
  let existingAncestor = resolvedStateRoot;
286
- while (!lstatIfExists(existingAncestor)) {
302
+ while (!await lstatIfExistsAsync(existingAncestor)) {
287
303
  const parent = path.dirname(existingAncestor);
288
304
  if (parent === existingAncestor) throw new Error(`Project workspace state has no existing ancestor: ${resolvedStateRoot}`);
289
305
  existingAncestor = parent;
290
306
  }
291
- const ancestorStat = lstatIfExists(existingAncestor);
307
+ const ancestorStat = await lstatIfExistsAsync(existingAncestor);
292
308
  if (!ancestorStat || ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory()) {
293
309
  throw new Error(`Project workspace state must descend from a real directory: ${resolvedStateRoot}`);
294
310
  }
295
- fs.mkdirSync(resolvedStateRoot, { recursive: true, mode: 448 });
311
+ await fs.promises.mkdir(resolvedStateRoot, { recursive: true, mode: 448 });
296
312
  let current = resolvedStateRoot;
297
313
  while (true) {
298
- const stat = lstatIfExists(current);
314
+ const stat = await lstatIfExistsAsync(current);
299
315
  if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
300
316
  throw new Error(`Project workspace state path must be a real directory: ${current}`);
301
317
  }
302
- fsyncDirectory(current);
318
+ await fsyncDirectoryAsync(current);
303
319
  if (current === existingAncestor) break;
304
320
  current = path.dirname(current);
305
321
  }
306
322
  }
307
- function writeStateAtomically(stateRoot, statePath, state, afterStateRootFsync) {
308
- ensureDurableStateRoot(stateRoot);
323
+ async function writeStateAtomically(stateRoot, statePath, state, afterStateRootFsync) {
324
+ await ensureDurableStateRoot(stateRoot);
309
325
  afterStateRootFsync?.();
310
326
  const temporaryPath = path.join(stateRoot, `.${PROJECT_WORKSPACE_STATE_FILE}.${process.pid}.${randomUUID()}.tmp`);
311
- let descriptor;
327
+ let handle;
312
328
  try {
313
- descriptor = fs.openSync(temporaryPath, "wx", 384);
314
- fs.writeFileSync(descriptor, `${JSON.stringify(normalizeState(state), null, 2)}
329
+ handle = await fs.promises.open(temporaryPath, "wx", 384);
330
+ await handle.writeFile(`${JSON.stringify(normalizeState(state), null, 2)}
315
331
  `, "utf8");
316
- fs.fsyncSync(descriptor);
317
- fs.closeSync(descriptor);
318
- descriptor = void 0;
319
- fs.renameSync(temporaryPath, statePath);
320
- fsyncDirectory(stateRoot);
332
+ await handle.sync();
333
+ await handle.close();
334
+ handle = void 0;
335
+ await fs.promises.rename(temporaryPath, statePath);
336
+ await fsyncDirectoryAsync(stateRoot);
321
337
  } finally {
322
- if (descriptor !== void 0) fs.closeSync(descriptor);
323
- fs.rmSync(temporaryPath, { force: true });
338
+ if (handle !== void 0) await handle.close();
339
+ await fs.promises.rm(temporaryPath, { force: true });
324
340
  }
325
341
  }
326
342
  function createBranchTombstone(project, branchName) {
@@ -696,7 +712,7 @@ class ProjectWorkspaceStateStore {
696
712
  locallyPendingCreatedBranches: state.locallyPendingCreatedBranches
697
713
  });
698
714
  }
699
- reconcile(input) {
715
+ async reconcile(input) {
700
716
  const previous = readState(this.statePath);
701
717
  const authoritativeDesiredProjects = normalizeDesiredProjects(input.desiredProjects);
702
718
  for (const project of authoritativeDesiredProjects) {
@@ -772,11 +788,11 @@ class ProjectWorkspaceStateStore {
772
788
  enabledRepositoryTransitions: previous.enabledRepositoryTransitions.filter(({ projectId }) => authoritativeProjectIds.has(projectId)),
773
789
  tombstones
774
790
  });
775
- writeStateAtomically(this.stateRoot, this.statePath, nextState);
791
+ await writeStateAtomically(this.stateRoot, this.statePath, nextState);
776
792
  return stateView(this.projectsRoot, nextState);
777
793
  }
778
794
  /** Record a successful enabled reseed without letting disabled config consume it. */
779
- recordEnabledRepositoryTransition(input) {
795
+ async recordEnabledRepositoryTransition(input) {
780
796
  const [transition] = normalizeEnabledRepositoryTransitions([input]);
781
797
  const state = readState(this.statePath);
782
798
  if (!state.desiredProjects.some(({ projectId }) => projectId === transition.projectId)) {
@@ -785,11 +801,11 @@ class ProjectWorkspaceStateStore {
785
801
  state.enabledRepositoryTransitions = state.enabledRepositoryTransitions.filter(({ projectId }) => projectId !== transition.projectId);
786
802
  state.enabledRepositoryTransitions.push(transition);
787
803
  state.enabledRepositoryTransitions.sort((left, right) => left.projectId.localeCompare(right.projectId));
788
- writeStateAtomically(this.stateRoot, this.statePath, state);
804
+ await writeStateAtomically(this.stateRoot, this.statePath, state);
789
805
  return stateView(this.projectsRoot, state);
790
806
  }
791
807
  /** Persist the create intent before invoking Git so a crash cannot orphan an untracked worktree. */
792
- recordPendingCreatedBranch(input) {
808
+ async recordPendingCreatedBranch(input) {
793
809
  const [pending] = normalizePendingCreatedBranches([input]);
794
810
  const state = readState(this.statePath);
795
811
  const existingPending = state.locallyPendingCreatedBranches.find(
@@ -812,11 +828,11 @@ class ProjectWorkspaceStateStore {
812
828
  state.locallyPendingCreatedBranches.sort(
813
829
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
814
830
  );
815
- writeStateAtomically(this.stateRoot, this.statePath, state);
831
+ await writeStateAtomically(this.stateRoot, this.statePath, state);
816
832
  return stateView(this.projectsRoot, state);
817
833
  }
818
834
  /** Roll back an intent only when Git failed before creating its branch. */
819
- rollbackPendingCreatedBranch(input) {
835
+ async rollbackPendingCreatedBranch(input) {
820
836
  const [pending] = normalizePendingCreatedBranches([input]);
821
837
  const state = readState(this.statePath);
822
838
  const key = pendingCreatedBranchKey(pending);
@@ -829,11 +845,11 @@ class ProjectWorkspaceStateStore {
829
845
  );
830
846
  const project = state.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
831
847
  if (project) project.branches = project.branches.filter(({ branchName }) => branchName !== pending.branchName);
832
- writeStateAtomically(this.stateRoot, this.statePath, state);
848
+ await writeStateAtomically(this.stateRoot, this.statePath, state);
833
849
  return stateView(this.projectsRoot, state);
834
850
  }
835
851
  /** Clear a create intent after an authoritative desired config contains it. */
836
- clearPendingCreatedBranch(input) {
852
+ async clearPendingCreatedBranch(input) {
837
853
  const [pending] = normalizePendingCreatedBranches([input]);
838
854
  const state = readState(this.statePath);
839
855
  const key = pendingCreatedBranchKey(pending);
@@ -843,11 +859,11 @@ class ProjectWorkspaceStateStore {
843
859
  state.locallyPendingCreatedBranches = state.locallyPendingCreatedBranches.filter(
844
860
  (candidate) => pendingCreatedBranchKey(candidate) !== key
845
861
  );
846
- writeStateAtomically(this.stateRoot, this.statePath, state);
862
+ await writeStateAtomically(this.stateRoot, this.statePath, state);
847
863
  return stateView(this.projectsRoot, state);
848
864
  }
849
865
  /** Persist a branch tombstone before mutating its linked worktree or local ref. */
850
- beginBranchDeletion(input) {
866
+ async beginBranchDeletion(input) {
851
867
  const [target] = normalizePendingCreatedBranches([input]);
852
868
  const state = readState(this.statePath);
853
869
  const project = state.desiredProjects.find(({ projectId }) => projectId === target.projectId);
@@ -863,10 +879,10 @@ class ProjectWorkspaceStateStore {
863
879
  state.locallyPendingCreatedBranches = state.locallyPendingCreatedBranches.filter(
864
880
  (candidate) => pendingCreatedBranchKey(candidate) !== key
865
881
  );
866
- writeStateAtomically(this.stateRoot, this.statePath, state);
882
+ await writeStateAtomically(this.stateRoot, this.statePath, state);
867
883
  return stateView(this.projectsRoot, state);
868
884
  }
869
- recordTreePublication(input) {
885
+ async recordTreePublication(input) {
870
886
  if (!GIT_OBJECT_ID_PATTERN.test(input.publishedHead)) throw new Error("Invalid published workspace head");
871
887
  const state = readState(this.statePath);
872
888
  const ids = new Set(input.tombstoneIds);
@@ -879,10 +895,10 @@ class ProjectWorkspaceStateStore {
879
895
  state.tombstones = state.tombstones.filter(
880
896
  (tombstone) => !tombstone.treePublishedHead || tombstone.kind === "branch" && !branchIsCurrentlyDesired(state.desiredProjects, tombstone.projectId, tombstone.branchName)
881
897
  );
882
- if (changed) writeStateAtomically(this.stateRoot, this.statePath, state);
898
+ if (changed) await writeStateAtomically(this.stateRoot, this.statePath, state);
883
899
  return stateView(this.projectsRoot, state);
884
900
  }
885
- recordMirrorRefDeletion(input) {
901
+ async recordMirrorRefDeletion(input) {
886
902
  validateManagedBranchName(input.branchName);
887
903
  const state = readState(this.statePath);
888
904
  const tombstone = state.tombstones.find(({ id }) => id === input.tombstoneId);
@@ -901,13 +917,13 @@ class ProjectWorkspaceStateStore {
901
917
  if (branchNames.every((branchName) => tombstone.mirrorRefsDeleted.includes(branchName))) {
902
918
  state.tombstones = state.tombstones.filter(({ id }) => id !== tombstone.id);
903
919
  }
904
- writeStateAtomically(this.stateRoot, this.statePath, state);
920
+ await writeStateAtomically(this.stateRoot, this.statePath, state);
905
921
  return stateView(this.projectsRoot, state);
906
922
  }
907
923
  }
908
924
  const projectWorkspaceStateTestHarness = {
909
925
  writeEmptyStateAtDurableRootBoundary(stateRoot, afterStateRootFsync) {
910
- writeStateAtomically(stateRoot, path.join(stateRoot, PROJECT_WORKSPACE_STATE_FILE), EMPTY_STATE, afterStateRootFsync);
926
+ return writeStateAtomically(stateRoot, path.join(stateRoot, PROJECT_WORKSPACE_STATE_FILE), EMPTY_STATE, afterStateRootFsync);
911
927
  }
912
928
  };
913
929
  export {
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
6
6
  import { validateManagedBranchName } from "./managed-paths.mjs";
7
- import { stageProjectCheckoutForDeletion } from "./project-checkout-garbage.mjs";
7
+ import { stageProjectCheckoutForDeletion, stageProjectForDeletion } from "./project-checkout-garbage.mjs";
8
8
  import {
9
9
  canonicalizeLocalProjectMirrorRefs,
10
10
  parseProjectMirrorRefListing,
@@ -14,6 +14,7 @@ import {
14
14
  projectMirrorRefsTokens
15
15
  } from "./project-mirror-refs-token.mjs";
16
16
  import { mirrorWorkingTree } from "./working-tree-mirror.mjs";
17
+ import { workspaceFilesystemExecutor } from "./workspace-filesystem-executor.mjs";
17
18
  const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
18
19
  const PROJECT_WORKTREE_SNAPSHOT_MANIFEST = "transaction.json";
19
20
  const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION = 1;
@@ -429,7 +430,7 @@ function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
429
430
  }
430
431
  }
431
432
  }
432
- function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, scope = "project") {
433
+ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, scope, ownerSessionId) {
433
434
  const resolvedProjectRoot = path.resolve(projectRoot);
434
435
  const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
435
436
  assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
@@ -452,7 +453,7 @@ function snapshotBranchTrees(projectRoot, branches, temporaryRoot, onProgress, s
452
453
  state: "building",
453
454
  scope,
454
455
  ownerProcessId: process.pid,
455
- ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
456
+ ownerSessionId,
456
457
  projectRoot: resolvedProjectRoot,
457
458
  branches: branchSnapshots,
458
459
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -975,7 +976,7 @@ async function reconcileLinkedLayout(input) {
975
976
  plan.push({ branchName: branch.branchName, checkoutPath, seed: resolved.seed, action: keep ? "keep" : "reset" });
976
977
  }
977
978
  const mutatedBranches = new Set(plan.filter(({ action }) => action !== "keep").map(({ branchName }) => branchName));
978
- if (mutatedBranches.size > 0) input.beforeDestructiveMutation(mutatedBranches);
979
+ if (mutatedBranches.size > 0) await input.beforeDestructiveMutation(mutatedBranches);
979
980
  for (const entry of plan) {
980
981
  if (entry.action === "keep") continue;
981
982
  if (entry.action === "reset") {
@@ -1016,13 +1017,19 @@ async function ensureProjectWorktrees(input) {
1016
1017
  const snapshotStorageRoot = path.resolve(input.snapshotRoot ?? os.tmpdir());
1017
1018
  assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
1018
1019
  let snapshot;
1019
- const ensureSnapshot = (branchNames) => {
1020
- snapshot ??= snapshotBranchTrees(
1021
- projectRoot,
1022
- branchNames ? input.branches.filter(({ branchName }) => branchNames.has(branchName)) : input.branches,
1023
- snapshotStorageRoot,
1024
- input.onSnapshotProgress,
1025
- branchNames ? "branches" : "project"
1020
+ const ensureSnapshot = async (branchNames) => {
1021
+ snapshot ??= await workspaceFilesystemExecutor().run(
1022
+ "project_snapshot_create",
1023
+ {
1024
+ projectRoot,
1025
+ branches: (branchNames ? input.branches.filter(({ branchName }) => branchNames.has(branchName)) : input.branches).map((branch) => ({
1026
+ ...branch
1027
+ })),
1028
+ temporaryRoot: snapshotStorageRoot,
1029
+ scope: branchNames ? "branches" : "project",
1030
+ ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID
1031
+ },
1032
+ { onProgress: input.onSnapshotProgress }
1026
1033
  );
1027
1034
  return snapshot;
1028
1035
  };
@@ -1040,10 +1047,12 @@ async function ensureProjectWorktrees(input) {
1040
1047
  ...input,
1041
1048
  projectRoot,
1042
1049
  defaultBranch,
1043
- beforeDestructiveMutation: ensureSnapshot
1050
+ beforeDestructiveMutation: async (branchNames) => {
1051
+ await ensureSnapshot(branchNames);
1052
+ }
1044
1053
  }));
1045
1054
  } else {
1046
- ensureSnapshot();
1055
+ await ensureSnapshot();
1047
1056
  mirrorHeads = await initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
1048
1057
  mutatedBranches = new Set(input.branches.map(({ branchName }) => branchName));
1049
1058
  }
@@ -1051,17 +1060,12 @@ async function ensureProjectWorktrees(input) {
1051
1060
  if (mutatedBranches.size > 0 && !preparedSnapshot) {
1052
1061
  throw new Error("Project worktree mutation started without a prepared recovery snapshot");
1053
1062
  }
1054
- for (const branch of input.branches) {
1055
- if (!mutatedBranches.has(branch.branchName)) continue;
1063
+ const snapshotMirrors = input.branches.flatMap((branch) => {
1064
+ if (!mutatedBranches.has(branch.branchName)) return [];
1056
1065
  const snapshotPath = preparedSnapshot?.paths.get(branch.branchName);
1057
- if (!snapshotPath) continue;
1058
- mirrorWorkingTree({
1059
- sourceRoot: snapshotPath,
1060
- targetRoot: branchPath(projectRoot, branch.branchName),
1061
- sourceMode: "all",
1062
- deletionMode: "git"
1063
- });
1064
- }
1066
+ return snapshotPath ? [{ snapshotPath, targetPath: branchPath(projectRoot, branch.branchName) }] : [];
1067
+ });
1068
+ if (snapshotMirrors.length > 0) await workspaceFilesystemExecutor().run("project_snapshot_apply", { mirrors: snapshotMirrors });
1065
1069
  const states = [];
1066
1070
  for (const branch of input.branches) {
1067
1071
  const checkoutPath = branchPath(projectRoot, branch.branchName);
@@ -1077,24 +1081,23 @@ async function ensureProjectWorktrees(input) {
1077
1081
  states.sort((left, right) => left.branchName.localeCompare(right.branchName));
1078
1082
  if (snapshot) {
1079
1083
  const primaryGitDirectory = path.join(branchPath(projectRoot, input.primaryBranchName), ".git");
1080
- persistReconciledProjectAndConsumeSnapshot(
1084
+ await workspaceFilesystemExecutor().run("project_snapshot_persist", {
1081
1085
  snapshot,
1082
- void 0,
1083
- snapshot.manifest.scope === "branches" ? [
1086
+ durabilityPaths: snapshot.manifest.scope === "branches" ? [
1084
1087
  ...snapshot.manifest.branches.map(({ branchName }) => branchPath(projectRoot, branchName)),
1085
1088
  // Branch refs and worktree registrations moved in the shared
1086
1089
  // administrative directory; objects are fsynced by git itself.
1087
1090
  path.join(primaryGitDirectory, "refs"),
1088
1091
  path.join(primaryGitDirectory, "packed-refs"),
1089
1092
  path.join(primaryGitDirectory, "worktrees")
1090
- ] : void 0
1091
- );
1093
+ ] : null
1094
+ });
1092
1095
  }
1093
1096
  return states;
1094
1097
  } catch (error) {
1095
1098
  if (!snapshot) throw error;
1096
1099
  try {
1097
- rollbackProjectWorktreeSnapshot(snapshot);
1100
+ await workspaceFilesystemExecutor().run("project_snapshot_rollback", { snapshot });
1098
1101
  } catch (recoveryError) {
1099
1102
  throw new Error(
1100
1103
  `Project worktree reconciliation failed and its snapshot could not be fully restored; recovery state is retained at ${snapshot.root}`,
@@ -1225,7 +1228,7 @@ function createLinkedProjectBranch(input) {
1225
1228
  throw error;
1226
1229
  }
1227
1230
  }
1228
- function createOrRetryLinkedProjectBranch(input) {
1231
+ async function createOrRetryLinkedProjectBranch(input) {
1229
1232
  if (input.pendingRetry) {
1230
1233
  deleteLinkedProjectBranch({
1231
1234
  projectRoot: input.projectRoot,
@@ -1233,7 +1236,12 @@ function createOrRetryLinkedProjectBranch(input) {
1233
1236
  branchName: input.branchName
1234
1237
  });
1235
1238
  }
1236
- return createLinkedProjectBranch(input);
1239
+ return await workspaceFilesystemExecutor().run("project_branch_create", {
1240
+ projectRoot: input.projectRoot,
1241
+ sourceBranchName: input.sourceBranchName,
1242
+ branchName: input.branchName,
1243
+ workingTree: input.workingTree
1244
+ });
1237
1245
  }
1238
1246
  function classifyProjectCheckout(input) {
1239
1247
  const checkoutPath = branchPath(input.projectRoot, input.branchName);
@@ -1327,7 +1335,8 @@ function removeProjectWorktrees(input) {
1327
1335
  throw new Error(`Project branch ${branchName} has an in-progress Git operation`);
1328
1336
  }
1329
1337
  }
1330
- fs.rmSync(projectRoot, { recursive: true, force: true });
1338
+ if (!fs.existsSync(projectRoot)) return {};
1339
+ return { stagedForCollectionPath: stageProjectForDeletion(input.projectsRoot, projectRoot) };
1331
1340
  }
1332
1341
  async function deleteProjectMirrorBranch(input) {
1333
1342
  validateManagedBranchName(input.branchName);
@@ -1570,11 +1579,37 @@ async function reseedProjectHeadsFromMirror(input) {
1570
1579
  reseeded: moved
1571
1580
  }));
1572
1581
  }
1582
+ function runProjectSnapshotCreateJob(input, context) {
1583
+ return snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot, context.progress, input.scope, input.ownerSessionId);
1584
+ }
1585
+ function runProjectSnapshotApplyJob(input) {
1586
+ for (const { snapshotPath, targetPath } of input.mirrors) {
1587
+ mirrorWorkingTree({ sourceRoot: snapshotPath, targetRoot: targetPath, sourceMode: "all", deletionMode: "git" });
1588
+ }
1589
+ return null;
1590
+ }
1591
+ function runProjectSnapshotPersistJob(input) {
1592
+ persistReconciledProjectAndConsumeSnapshot(input.snapshot, void 0, input.durabilityPaths ?? void 0);
1593
+ return null;
1594
+ }
1595
+ function runProjectSnapshotRollbackJob(input) {
1596
+ rollbackProjectWorktreeSnapshot(input.snapshot);
1597
+ return null;
1598
+ }
1573
1599
  const projectWorktreesTestHarness = {
1574
1600
  commandArgs: gitCommandArgs,
1575
1601
  configureRepository,
1576
1602
  createPreparedSnapshot(input) {
1577
- return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot, void 0, input.scope).root };
1603
+ return {
1604
+ root: snapshotBranchTrees(
1605
+ input.projectRoot,
1606
+ input.branches,
1607
+ input.temporaryRoot,
1608
+ void 0,
1609
+ input.scope ?? "project",
1610
+ PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID
1611
+ ).root
1612
+ };
1578
1613
  },
1579
1614
  consumeSnapshot(snapshotRoot) {
1580
1615
  const manifest = readSnapshotManifest(snapshotRoot);
@@ -1590,6 +1625,7 @@ const projectWorktreesTestHarness = {
1590
1625
  };
1591
1626
  export {
1592
1627
  PROJECT_REMOVED_CHECKOUTS_DIRECTORY,
1628
+ PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
1593
1629
  PROJECT_WORKTREE_SNAPSHOT_PREFIX,
1594
1630
  ProjectBranchCreationRollbackIncompleteError,
1595
1631
  applyObservedProjectMirrorHeads,
@@ -1613,5 +1649,9 @@ export {
1613
1649
  pushProjectMirrorHeads,
1614
1650
  recoverStaleProjectWorktreeSnapshots,
1615
1651
  removeProjectWorktrees,
1616
- reseedProjectHeadsFromMirror
1652
+ reseedProjectHeadsFromMirror,
1653
+ runProjectSnapshotApplyJob,
1654
+ runProjectSnapshotCreateJob,
1655
+ runProjectSnapshotPersistJob,
1656
+ runProjectSnapshotRollbackJob
1617
1657
  };
@@ -0,0 +1,30 @@
1
+ const WORKER_RECOVERY_JOURNAL_OPS = [
2
+ "admit",
3
+ "readmitUnknownBranchDeletion",
4
+ "record",
5
+ "unknown",
6
+ "unknownRun",
7
+ "acknowledgeResult",
8
+ "acknowledgeOutput",
9
+ "acknowledgeTerminal",
10
+ "cancelSession",
11
+ "isRequestCancelled",
12
+ "operations",
13
+ "recoveryState",
14
+ "terminals",
15
+ "outputReplayBegin",
16
+ "outputReplayNext",
17
+ "outputReplayEnd"
18
+ ];
19
+ function isWorkerRecoveryJournalOp(value) {
20
+ return typeof value === "string" && WORKER_RECOVERY_JOURNAL_OPS.includes(value);
21
+ }
22
+ function serializeWorkerRecoveryJournalError(error) {
23
+ if (!(error instanceof Error)) return { name: "Error", message: String(error) };
24
+ return { name: error.name, message: error.message, ...typeof error.stack === "string" ? { stack: error.stack } : {} };
25
+ }
26
+ export {
27
+ WORKER_RECOVERY_JOURNAL_OPS,
28
+ isWorkerRecoveryJournalOp,
29
+ serializeWorkerRecoveryJournalError
30
+ };
@@ -0,0 +1,112 @@
1
+ import {
2
+ isWorkerRecoveryJournalOp,
3
+ serializeWorkerRecoveryJournalError
4
+ } from "./recovery-journal-protocol.mjs";
5
+ import { WorkerRecoveryStore } from "./recovery-store.mjs";
6
+ function serveRecoveryJournal(port, filename, hooks = {}) {
7
+ const store = new WorkerRecoveryStore(filename);
8
+ const outputReplays = /* @__PURE__ */ new Map();
9
+ let nextReplayId = 1;
10
+ function runOp(op, args) {
11
+ switch (op) {
12
+ case "admit": {
13
+ const message = args[0];
14
+ const admission = store.admit(message);
15
+ return {
16
+ admission,
17
+ response: admission === "new" ? void 0 : store.response(message.requestId),
18
+ cancelled: store.isRequestCancelled(message.requestId)
19
+ };
20
+ }
21
+ case "readmitUnknownBranchDeletion":
22
+ return store.readmitUnknownBranchDeletion(args[0]);
23
+ case "record": {
24
+ const message = args[0];
25
+ if (typeof message.requestId === "string" && store.isUnknown(message.requestId)) return { suppressed: true, recorded: message };
26
+ return { suppressed: false, recorded: store.record(message) };
27
+ }
28
+ case "unknown":
29
+ store.unknown(args[0]);
30
+ return null;
31
+ case "unknownRun":
32
+ store.unknownRun(args[0]);
33
+ return null;
34
+ case "acknowledgeResult":
35
+ store.acknowledgeResult(args[0]);
36
+ return null;
37
+ case "acknowledgeOutput":
38
+ store.acknowledgeOutput(args[0], args[1], args[2]);
39
+ return null;
40
+ case "acknowledgeTerminal":
41
+ store.acknowledgeTerminal(args[0]);
42
+ return null;
43
+ case "cancelSession":
44
+ store.cancelSession(args[0]);
45
+ return null;
46
+ case "isRequestCancelled":
47
+ return store.isRequestCancelled(args[0]);
48
+ case "operations":
49
+ return store.operations();
50
+ case "recoveryState": {
51
+ const requests = args[0];
52
+ return {
53
+ operations: store.operations(),
54
+ cursors: store.outputCursors(),
55
+ responses: store.responses(requests),
56
+ terminals: store.terminals()
57
+ };
58
+ }
59
+ case "terminals":
60
+ return store.terminals();
61
+ case "outputReplayBegin": {
62
+ const replayId = nextReplayId++;
63
+ outputReplays.set(replayId, store.output(args[0]));
64
+ return replayId;
65
+ }
66
+ case "outputReplayNext": {
67
+ const replay = outputReplays.get(args[0]);
68
+ if (!replay) return { frames: [], done: true };
69
+ const maxFrames = args[1];
70
+ const frames = [];
71
+ while (frames.length < maxFrames) {
72
+ const next = replay.next();
73
+ if (next.done) {
74
+ outputReplays.delete(args[0]);
75
+ return { frames, done: true };
76
+ }
77
+ frames.push(next.value);
78
+ }
79
+ return { frames, done: false };
80
+ }
81
+ case "outputReplayEnd":
82
+ outputReplays.delete(args[0]);
83
+ return null;
84
+ }
85
+ }
86
+ port.on("message", (message) => {
87
+ if (!message || typeof message !== "object") return;
88
+ const request = message;
89
+ if (request.type !== "batch" || typeof request.id !== "number" || !Array.isArray(request.entries)) return;
90
+ const entries = request.entries;
91
+ try {
92
+ hooks.beforeBatch?.();
93
+ const results = store.batch(
94
+ () => entries.map((entry) => {
95
+ if (!isWorkerRecoveryJournalOp(entry.op)) throw new Error(`Unknown recovery journal operation: ${String(entry.op)}`);
96
+ return { seq: entry.seq, value: runOp(entry.op, entry.args) };
97
+ })
98
+ );
99
+ port.postMessage({ type: "batch_result", id: request.id, results });
100
+ } catch (error) {
101
+ port.postMessage({
102
+ type: "batch_failure",
103
+ id: request.id,
104
+ error: serializeWorkerRecoveryJournalError(error)
105
+ });
106
+ }
107
+ });
108
+ port.postMessage({ type: "ready", ledgerId: store.ledgerId });
109
+ }
110
+ export {
111
+ serveRecoveryJournal
112
+ };
@@ -0,0 +1,8 @@
1
+ import { isMainThread, parentPort, workerData } from "node:worker_threads";
2
+ import { serveRecoveryJournal } from "./recovery-journal-runtime.mjs";
3
+ if (isMainThread || !parentPort) {
4
+ throw new Error("The recovery journal thread must be started by the worker runtime");
5
+ }
6
+ const data = workerData;
7
+ if (!data || typeof data.filename !== "string" || !data.filename) throw new Error("The recovery journal thread needs a store filename");
8
+ serveRecoveryJournal(parentPort, data.filename);