@ricsam/r5d-worker 0.0.79 → 0.0.81

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 CHANGED
@@ -7,7 +7,13 @@ r5d-worker start --label macos
7
7
  r5d-worker --version
8
8
  ```
9
9
 
10
- The worker uses the existing `r5dctl` config file and accepts `R5D_WORKER_TOKEN` or `R5D_API_KEY`. Project checkouts are managed under:
10
+ Authorize the exact worker label before starting it:
11
+
12
+ ```sh
13
+ r5dctl auth login --worker-label macos
14
+ ```
15
+
16
+ The worker uses that label-bound credential from the existing `r5dctl` config file or `R5D_WORKER_TOKEN`. `R5D_API_KEY` remains an accepted legacy environment-variable name, but its value must still be the exact label-bound worker credential; a general API key or device credential cannot connect a worker. Project checkouts are managed under:
11
17
 
12
18
  ```text
13
19
  ~/.r5d/projects/<namespace>/<project>/<branch-name>
package/dist/cjs/main.cjs CHANGED
@@ -629,7 +629,7 @@ function preparePlanEnvForShell(input) {
629
629
  import_node_fs.default.mkdirSync(plansDir, { recursive: true });
630
630
  return planEnv(input.planRoot, input.target, input.activePlanId);
631
631
  }
632
- async function verifyR5dctlAuth(baseUrl, token) {
632
+ async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
633
633
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
634
634
  let response;
635
635
  try {
@@ -658,13 +658,16 @@ async function verifyR5dctlAuth(baseUrl, token) {
658
658
  throw new WorkerServerUnavailableError(`Server unavailable at ${baseUrl} (${response.status})${detail}`);
659
659
  }
660
660
  throw new Error(
661
- `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl}\` or pass --token/--api-key for this server.`
661
+ `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl} --worker-label ${workerLabel}\` or pass that exact label-bound worker token.`
662
662
  );
663
663
  }
664
664
  function resolveCredentials(options, config) {
665
665
  const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
666
666
  if (!token) {
667
- throw new Error("Authentication required. Run `r5dctl auth login` or set R5D_WORKER_TOKEN/R5D_API_KEY.");
667
+ const workerLabel = options.label ?? "<label>";
668
+ throw new Error(
669
+ `Authentication required. Run \`r5dctl auth login --worker-label ${workerLabel}\` or set R5D_WORKER_TOKEN to that label-bound token.`
670
+ );
668
671
  }
669
672
  return {
670
673
  baseUrl: normalizeBaseUrl(
@@ -3021,7 +3024,7 @@ async function startWorker(options) {
3021
3024
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
3022
3025
  `);
3023
3026
  runGit(["--version"]);
3024
- await verifyR5dctlAuth(baseUrl, token);
3027
+ await verifyR5dctlAuth(baseUrl, token, label);
3025
3028
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3026
3029
  if (!startupProjectSnapshotRecoveryCompleted) {
3027
3030
  const snapshotRecovery = (0, import_project_worktrees.recoverStaleProjectWorktreeSnapshots)({
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.79",
3
+ "version": "0.0.81",
4
4
  "type": "commonjs"
5
5
  }
@@ -389,6 +389,7 @@ function copyCheckoutTree(sourceRoot, targetRoot) {
389
389
  dereference: false,
390
390
  errorOnExist: true,
391
391
  force: false,
392
+ mode: import_node_fs.default.constants.COPYFILE_FICLONE,
392
393
  preserveTimestamps: true,
393
394
  verbatimSymlinks: true
394
395
  });
@@ -769,6 +770,7 @@ function reconcileLinkedLayout(input) {
769
770
  tryGit(primaryPath, ["worktree", "prune"]);
770
771
  fetchProjectHeads({ ...input, primaryPath });
771
772
  const mirrorHeads = /* @__PURE__ */ new Map();
773
+ const mutatedBranches = /* @__PURE__ */ new Set();
772
774
  for (const branch of input.branches) {
773
775
  const checkoutPath = branchPath(input.projectRoot, branch.branchName);
774
776
  const resolved = seedForBranch({
@@ -779,16 +781,22 @@ function reconcileLinkedLayout(input) {
779
781
  });
780
782
  mirrorHeads.set(branch.branchName, resolved.mirrorHead);
781
783
  if (branch.branchName === input.primaryBranchName || commonGitDirectory(checkoutPath) === commonGitDirectory(primaryPath)) {
784
+ const currentHead = git(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
785
+ if (currentHead === resolved.seed) continue;
786
+ input.beforeDestructiveMutation();
787
+ mutatedBranches.add(branch.branchName);
782
788
  preserveHead(checkoutPath);
783
789
  git(checkoutPath, ["reset", "--hard", resolved.seed], `anchor linked worktree ${branch.branchName}`);
784
790
  continue;
785
791
  }
792
+ input.beforeDestructiveMutation();
793
+ mutatedBranches.add(branch.branchName);
786
794
  import_node_fs.default.rmSync(checkoutPath, { recursive: true, force: true });
787
795
  git(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
788
796
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(checkoutPath), { recursive: true });
789
797
  git(primaryPath, ["worktree", "add", "--force", checkoutPath, branch.branchName], `create linked worktree ${branch.branchName}`);
790
798
  }
791
- return mirrorHeads;
799
+ return { mirrorHeads, mutatedBranches };
792
800
  }
793
801
  function aheadBehind(checkoutPath, branchName, primaryBranchName, defaultBranch) {
794
802
  const originBranchName = projectOriginBranchName({ branchName, primaryBranchName, defaultBranch });
@@ -805,7 +813,13 @@ function ensureProjectWorktrees(input) {
805
813
  if (!input.branches.some(({ branchName }) => branchName === input.primaryBranchName)) {
806
814
  throw new Error(`Primary project branch ${input.primaryBranchName} is missing from the workspace configuration`);
807
815
  }
808
- const snapshot = snapshotBranchTrees(projectRoot, input.branches, input.snapshotRoot);
816
+ const snapshotStorageRoot = import_node_path.default.resolve(input.snapshotRoot ?? import_node_os.default.tmpdir());
817
+ assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
818
+ let snapshot;
819
+ const ensureSnapshot = () => {
820
+ snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot);
821
+ return snapshot;
822
+ };
809
823
  try {
810
824
  import_node_fs.default.mkdirSync(projectRoot, { recursive: true });
811
825
  const linked = hasCompatibleLinkedProjectWorktreeLayout({
@@ -813,9 +827,27 @@ function ensureProjectWorktrees(input) {
813
827
  primaryBranchName: input.primaryBranchName,
814
828
  branches: input.branches
815
829
  });
816
- const mirrorHeads = linked ? reconcileLinkedLayout({ ...input, projectRoot, defaultBranch }) : initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
830
+ let mirrorHeads;
831
+ let mutatedBranches;
832
+ if (linked) {
833
+ ({ mirrorHeads, mutatedBranches } = reconcileLinkedLayout({
834
+ ...input,
835
+ projectRoot,
836
+ defaultBranch,
837
+ beforeDestructiveMutation: ensureSnapshot
838
+ }));
839
+ } else {
840
+ ensureSnapshot();
841
+ mirrorHeads = initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
842
+ mutatedBranches = new Set(input.branches.map(({ branchName }) => branchName));
843
+ }
844
+ const preparedSnapshot = snapshot;
845
+ if (mutatedBranches.size > 0 && !preparedSnapshot) {
846
+ throw new Error("Project worktree mutation started without a prepared recovery snapshot");
847
+ }
817
848
  for (const branch of input.branches) {
818
- const snapshotPath = snapshot.paths.get(branch.branchName);
849
+ if (!mutatedBranches.has(branch.branchName)) continue;
850
+ const snapshotPath = preparedSnapshot?.paths.get(branch.branchName);
819
851
  if (!snapshotPath) continue;
820
852
  (0, import_working_tree_mirror.mirrorWorkingTree)({
821
853
  sourceRoot: snapshotPath,
@@ -835,9 +867,10 @@ function ensureProjectWorktrees(input) {
835
867
  ...divergence
836
868
  };
837
869
  }).sort((left, right) => left.branchName.localeCompare(right.branchName));
838
- persistReconciledProjectAndConsumeSnapshot(snapshot);
870
+ if (snapshot) persistReconciledProjectAndConsumeSnapshot(snapshot);
839
871
  return states;
840
872
  } catch (error) {
873
+ if (!snapshot) throw error;
841
874
  try {
842
875
  rollbackProjectWorktreeSnapshot(snapshot);
843
876
  } catch (recoveryError) {
@@ -264,7 +264,7 @@ function filesEqual(leftPath, rightPath, entry) {
264
264
  import_node_fs.default.closeSync(right);
265
265
  }
266
266
  }
267
- function copyRegularFile(sourcePath, targetPath, entry) {
267
+ function copyRegularFile(sourcePath, targetPath, entry, durability) {
268
268
  const source = import_node_fs.default.openSync(sourcePath, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
269
269
  const temporaryPath = import_node_path.default.join(import_node_path.default.dirname(targetPath), `${MIRROR_TEMPORARY_FILE_PREFIX}${(0, import_node_crypto.randomUUID)()}.tmp`);
270
270
  let target;
@@ -290,7 +290,7 @@ function copyRegularFile(sourcePath, targetPath, entry) {
290
290
  offset += bytesRead;
291
291
  }
292
292
  import_node_fs.default.fchmodSync(target, entry.mode & 511);
293
- import_node_fs.default.fsyncSync(target);
293
+ if (durability === "per_entry") import_node_fs.default.fsyncSync(target);
294
294
  const finalSourceStat = import_node_fs.default.fstatSync(source);
295
295
  if (!finalSourceStat.isFile() || finalSourceStat.size !== sourceStat.size || (finalSourceStat.mode & 511) !== (sourceStat.mode & 511) || finalSourceStat.mtimeMs !== sourceStat.mtimeMs || finalSourceStat.ctimeMs !== sourceStat.ctimeMs) {
296
296
  throw new Error(`Working-tree source changed while it was being mirrored: ${sourcePath}`);
@@ -298,11 +298,13 @@ function copyRegularFile(sourcePath, targetPath, entry) {
298
298
  import_node_fs.default.closeSync(target);
299
299
  target = void 0;
300
300
  import_node_fs.default.renameSync(temporaryPath, targetPath);
301
- const parent = import_node_fs.default.openSync(import_node_path.default.dirname(targetPath), import_node_fs.default.constants.O_RDONLY);
302
- try {
303
- import_node_fs.default.fsyncSync(parent);
304
- } finally {
305
- import_node_fs.default.closeSync(parent);
301
+ if (durability === "per_entry") {
302
+ const parent = import_node_fs.default.openSync(import_node_path.default.dirname(targetPath), import_node_fs.default.constants.O_RDONLY);
303
+ try {
304
+ import_node_fs.default.fsyncSync(parent);
305
+ } finally {
306
+ import_node_fs.default.closeSync(parent);
307
+ }
306
308
  }
307
309
  } finally {
308
310
  if (target !== void 0) import_node_fs.default.closeSync(target);
@@ -310,7 +312,7 @@ function copyRegularFile(sourcePath, targetPath, entry) {
310
312
  import_node_fs.default.rmSync(temporaryPath, { force: true });
311
313
  }
312
314
  }
313
- function copyEntry(sourceRoot, targetRoot, relativePath, entry) {
315
+ function copyEntry(sourceRoot, targetRoot, relativePath, entry, durability) {
314
316
  const targetPath = import_node_path.default.join(targetRoot, ...relativePath.split("/"));
315
317
  assertInside(targetRoot, targetPath);
316
318
  const parent = import_node_path.default.posix.dirname(relativePath);
@@ -353,12 +355,13 @@ function copyEntry(sourceRoot, targetRoot, relativePath, entry) {
353
355
  }
354
356
  const targetStat = target.kind === "entry" ? target.stat : null;
355
357
  if (targetStat && !targetStat.isFile()) removeEntry(targetRoot, relativePath);
356
- copyRegularFile(sourcePath, targetPath, entry);
358
+ copyRegularFile(sourcePath, targetPath, entry, durability);
357
359
  }
358
360
  }
359
361
  function mirrorWorkingTree(input) {
360
362
  const sourceRoot = import_node_path.default.resolve(input.sourceRoot);
361
363
  const targetRoot = import_node_path.default.resolve(input.targetRoot);
364
+ const durability = input.durability ?? "per_entry";
362
365
  if (!inspectDirectoryRoot(targetRoot, { allowMissing: true, label: "Working-tree target root" })) {
363
366
  import_node_fs.default.mkdirSync(targetRoot, { recursive: true });
364
367
  }
@@ -371,7 +374,7 @@ function mirrorWorkingTree(input) {
371
374
  for (const [relativePath, entry] of [...desired.entries()].sort(
372
375
  ([left], [right]) => left.split("/").length - right.split("/").length || left.localeCompare(right)
373
376
  )) {
374
- copyEntry(sourceRoot, targetRoot, relativePath, entry);
377
+ copyEntry(sourceRoot, targetRoot, relativePath, entry, durability);
375
378
  }
376
379
  return { paths: [...desired.keys()].filter((relativePath) => desired.get(relativePath)?.kind !== "directory").sort() };
377
380
  }
@@ -645,7 +645,16 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
645
645
  const snapshotPath = import_node_path.default.join(snapshotRoot, snapshotName);
646
646
  import_node_fs.default.mkdirSync(snapshotPath, { mode: 448 });
647
647
  if (sourceStatus) {
648
- (0, import_working_tree_mirror.mirrorWorkingTree)({ sourceRoot: sourcePath, targetRoot: snapshotPath, sourceMode: "all", deletionMode: "all" });
648
+ (0, import_working_tree_mirror.mirrorWorkingTree)({
649
+ sourceRoot: sourcePath,
650
+ targetRoot: snapshotPath,
651
+ sourceMode: "all",
652
+ deletionMode: "all",
653
+ // This tree is still private and the source authority is untouched.
654
+ // The single recursive barrier below makes the complete snapshot
655
+ // durable before its directory entry is published or hydration starts.
656
+ durability: "deferred_private_staging"
657
+ });
649
658
  }
650
659
  return {
651
660
  id: mount.id,
package/dist/mjs/main.mjs CHANGED
@@ -633,7 +633,7 @@ function preparePlanEnvForShell(input) {
633
633
  fs.mkdirSync(plansDir, { recursive: true });
634
634
  return planEnv(input.planRoot, input.target, input.activePlanId);
635
635
  }
636
- async function verifyR5dctlAuth(baseUrl, token) {
636
+ async function verifyR5dctlAuth(baseUrl, token, workerLabel) {
637
637
  const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
638
638
  let response;
639
639
  try {
@@ -662,13 +662,16 @@ async function verifyR5dctlAuth(baseUrl, token) {
662
662
  throw new WorkerServerUnavailableError(`Server unavailable at ${baseUrl} (${response.status})${detail}`);
663
663
  }
664
664
  throw new Error(
665
- `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl}\` or pass --token/--api-key for this server.`
665
+ `Authentication failed against ${baseUrl} (${response.status})${detail}. Run \`r5dctl auth login --base-url ${baseUrl} --worker-label ${workerLabel}\` or pass that exact label-bound worker token.`
666
666
  );
667
667
  }
668
668
  function resolveCredentials(options, config) {
669
669
  const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
670
670
  if (!token) {
671
- throw new Error("Authentication required. Run `r5dctl auth login` or set R5D_WORKER_TOKEN/R5D_API_KEY.");
671
+ const workerLabel = options.label ?? "<label>";
672
+ throw new Error(
673
+ `Authentication required. Run \`r5dctl auth login --worker-label ${workerLabel}\` or set R5D_WORKER_TOKEN to that label-bound token.`
674
+ );
672
675
  }
673
676
  return {
674
677
  baseUrl: normalizeBaseUrl(
@@ -3025,7 +3028,7 @@ async function startWorker(options) {
3025
3028
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
3026
3029
  `);
3027
3030
  runGit(["--version"]);
3028
- await verifyR5dctlAuth(baseUrl, token);
3031
+ await verifyR5dctlAuth(baseUrl, token, label);
3029
3032
  const initializedWorkspaceState = await workspaceSyncSingleFlight.runExclusive(() => {
3030
3033
  if (!startupProjectSnapshotRecoveryCompleted) {
3031
3034
  const snapshotRecovery = recoverStaleProjectWorktreeSnapshots({
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.79",
3
+ "version": "0.0.81",
4
4
  "type": "module"
5
5
  }
@@ -336,6 +336,7 @@ function copyCheckoutTree(sourceRoot, targetRoot) {
336
336
  dereference: false,
337
337
  errorOnExist: true,
338
338
  force: false,
339
+ mode: fs.constants.COPYFILE_FICLONE,
339
340
  preserveTimestamps: true,
340
341
  verbatimSymlinks: true
341
342
  });
@@ -716,6 +717,7 @@ function reconcileLinkedLayout(input) {
716
717
  tryGit(primaryPath, ["worktree", "prune"]);
717
718
  fetchProjectHeads({ ...input, primaryPath });
718
719
  const mirrorHeads = /* @__PURE__ */ new Map();
720
+ const mutatedBranches = /* @__PURE__ */ new Set();
719
721
  for (const branch of input.branches) {
720
722
  const checkoutPath = branchPath(input.projectRoot, branch.branchName);
721
723
  const resolved = seedForBranch({
@@ -726,16 +728,22 @@ function reconcileLinkedLayout(input) {
726
728
  });
727
729
  mirrorHeads.set(branch.branchName, resolved.mirrorHead);
728
730
  if (branch.branchName === input.primaryBranchName || commonGitDirectory(checkoutPath) === commonGitDirectory(primaryPath)) {
731
+ const currentHead = git(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
732
+ if (currentHead === resolved.seed) continue;
733
+ input.beforeDestructiveMutation();
734
+ mutatedBranches.add(branch.branchName);
729
735
  preserveHead(checkoutPath);
730
736
  git(checkoutPath, ["reset", "--hard", resolved.seed], `anchor linked worktree ${branch.branchName}`);
731
737
  continue;
732
738
  }
739
+ input.beforeDestructiveMutation();
740
+ mutatedBranches.add(branch.branchName);
733
741
  fs.rmSync(checkoutPath, { recursive: true, force: true });
734
742
  git(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
735
743
  fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
736
744
  git(primaryPath, ["worktree", "add", "--force", checkoutPath, branch.branchName], `create linked worktree ${branch.branchName}`);
737
745
  }
738
- return mirrorHeads;
746
+ return { mirrorHeads, mutatedBranches };
739
747
  }
740
748
  function aheadBehind(checkoutPath, branchName, primaryBranchName, defaultBranch) {
741
749
  const originBranchName = projectOriginBranchName({ branchName, primaryBranchName, defaultBranch });
@@ -752,7 +760,13 @@ function ensureProjectWorktrees(input) {
752
760
  if (!input.branches.some(({ branchName }) => branchName === input.primaryBranchName)) {
753
761
  throw new Error(`Primary project branch ${input.primaryBranchName} is missing from the workspace configuration`);
754
762
  }
755
- const snapshot = snapshotBranchTrees(projectRoot, input.branches, input.snapshotRoot);
763
+ const snapshotStorageRoot = path.resolve(input.snapshotRoot ?? os.tmpdir());
764
+ assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
765
+ let snapshot;
766
+ const ensureSnapshot = () => {
767
+ snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot);
768
+ return snapshot;
769
+ };
756
770
  try {
757
771
  fs.mkdirSync(projectRoot, { recursive: true });
758
772
  const linked = hasCompatibleLinkedProjectWorktreeLayout({
@@ -760,9 +774,27 @@ function ensureProjectWorktrees(input) {
760
774
  primaryBranchName: input.primaryBranchName,
761
775
  branches: input.branches
762
776
  });
763
- const mirrorHeads = linked ? reconcileLinkedLayout({ ...input, projectRoot, defaultBranch }) : initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
777
+ let mirrorHeads;
778
+ let mutatedBranches;
779
+ if (linked) {
780
+ ({ mirrorHeads, mutatedBranches } = reconcileLinkedLayout({
781
+ ...input,
782
+ projectRoot,
783
+ defaultBranch,
784
+ beforeDestructiveMutation: ensureSnapshot
785
+ }));
786
+ } else {
787
+ ensureSnapshot();
788
+ mirrorHeads = initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
789
+ mutatedBranches = new Set(input.branches.map(({ branchName }) => branchName));
790
+ }
791
+ const preparedSnapshot = snapshot;
792
+ if (mutatedBranches.size > 0 && !preparedSnapshot) {
793
+ throw new Error("Project worktree mutation started without a prepared recovery snapshot");
794
+ }
764
795
  for (const branch of input.branches) {
765
- const snapshotPath = snapshot.paths.get(branch.branchName);
796
+ if (!mutatedBranches.has(branch.branchName)) continue;
797
+ const snapshotPath = preparedSnapshot?.paths.get(branch.branchName);
766
798
  if (!snapshotPath) continue;
767
799
  mirrorWorkingTree({
768
800
  sourceRoot: snapshotPath,
@@ -782,9 +814,10 @@ function ensureProjectWorktrees(input) {
782
814
  ...divergence
783
815
  };
784
816
  }).sort((left, right) => left.branchName.localeCompare(right.branchName));
785
- persistReconciledProjectAndConsumeSnapshot(snapshot);
817
+ if (snapshot) persistReconciledProjectAndConsumeSnapshot(snapshot);
786
818
  return states;
787
819
  } catch (error) {
820
+ if (!snapshot) throw error;
788
821
  try {
789
822
  rollbackProjectWorktreeSnapshot(snapshot);
790
823
  } catch (recoveryError) {
@@ -229,7 +229,7 @@ function filesEqual(leftPath, rightPath, entry) {
229
229
  fs.closeSync(right);
230
230
  }
231
231
  }
232
- function copyRegularFile(sourcePath, targetPath, entry) {
232
+ function copyRegularFile(sourcePath, targetPath, entry, durability) {
233
233
  const source = fs.openSync(sourcePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
234
234
  const temporaryPath = path.join(path.dirname(targetPath), `${MIRROR_TEMPORARY_FILE_PREFIX}${randomUUID()}.tmp`);
235
235
  let target;
@@ -255,7 +255,7 @@ function copyRegularFile(sourcePath, targetPath, entry) {
255
255
  offset += bytesRead;
256
256
  }
257
257
  fs.fchmodSync(target, entry.mode & 511);
258
- fs.fsyncSync(target);
258
+ if (durability === "per_entry") fs.fsyncSync(target);
259
259
  const finalSourceStat = fs.fstatSync(source);
260
260
  if (!finalSourceStat.isFile() || finalSourceStat.size !== sourceStat.size || (finalSourceStat.mode & 511) !== (sourceStat.mode & 511) || finalSourceStat.mtimeMs !== sourceStat.mtimeMs || finalSourceStat.ctimeMs !== sourceStat.ctimeMs) {
261
261
  throw new Error(`Working-tree source changed while it was being mirrored: ${sourcePath}`);
@@ -263,11 +263,13 @@ function copyRegularFile(sourcePath, targetPath, entry) {
263
263
  fs.closeSync(target);
264
264
  target = void 0;
265
265
  fs.renameSync(temporaryPath, targetPath);
266
- const parent = fs.openSync(path.dirname(targetPath), fs.constants.O_RDONLY);
267
- try {
268
- fs.fsyncSync(parent);
269
- } finally {
270
- fs.closeSync(parent);
266
+ if (durability === "per_entry") {
267
+ const parent = fs.openSync(path.dirname(targetPath), fs.constants.O_RDONLY);
268
+ try {
269
+ fs.fsyncSync(parent);
270
+ } finally {
271
+ fs.closeSync(parent);
272
+ }
271
273
  }
272
274
  } finally {
273
275
  if (target !== void 0) fs.closeSync(target);
@@ -275,7 +277,7 @@ function copyRegularFile(sourcePath, targetPath, entry) {
275
277
  fs.rmSync(temporaryPath, { force: true });
276
278
  }
277
279
  }
278
- function copyEntry(sourceRoot, targetRoot, relativePath, entry) {
280
+ function copyEntry(sourceRoot, targetRoot, relativePath, entry, durability) {
279
281
  const targetPath = path.join(targetRoot, ...relativePath.split("/"));
280
282
  assertInside(targetRoot, targetPath);
281
283
  const parent = path.posix.dirname(relativePath);
@@ -318,12 +320,13 @@ function copyEntry(sourceRoot, targetRoot, relativePath, entry) {
318
320
  }
319
321
  const targetStat = target.kind === "entry" ? target.stat : null;
320
322
  if (targetStat && !targetStat.isFile()) removeEntry(targetRoot, relativePath);
321
- copyRegularFile(sourcePath, targetPath, entry);
323
+ copyRegularFile(sourcePath, targetPath, entry, durability);
322
324
  }
323
325
  }
324
326
  function mirrorWorkingTree(input) {
325
327
  const sourceRoot = path.resolve(input.sourceRoot);
326
328
  const targetRoot = path.resolve(input.targetRoot);
329
+ const durability = input.durability ?? "per_entry";
327
330
  if (!inspectDirectoryRoot(targetRoot, { allowMissing: true, label: "Working-tree target root" })) {
328
331
  fs.mkdirSync(targetRoot, { recursive: true });
329
332
  }
@@ -336,7 +339,7 @@ function mirrorWorkingTree(input) {
336
339
  for (const [relativePath, entry] of [...desired.entries()].sort(
337
340
  ([left], [right]) => left.split("/").length - right.split("/").length || left.localeCompare(right)
338
341
  )) {
339
- copyEntry(sourceRoot, targetRoot, relativePath, entry);
342
+ copyEntry(sourceRoot, targetRoot, relativePath, entry, durability);
340
343
  }
341
344
  return { paths: [...desired.keys()].filter((relativePath) => desired.get(relativePath)?.kind !== "directory").sort() };
342
345
  }
@@ -600,7 +600,16 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
600
600
  const snapshotPath = path.join(snapshotRoot, snapshotName);
601
601
  fs.mkdirSync(snapshotPath, { mode: 448 });
602
602
  if (sourceStatus) {
603
- mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: snapshotPath, sourceMode: "all", deletionMode: "all" });
603
+ mirrorWorkingTree({
604
+ sourceRoot: sourcePath,
605
+ targetRoot: snapshotPath,
606
+ sourceMode: "all",
607
+ deletionMode: "all",
608
+ // This tree is still private and the source authority is untouched.
609
+ // The single recursive barrier below makes the complete snapshot
610
+ // durable before its directory entry is published or hydration starts.
611
+ durability: "deferred_private_staging"
612
+ });
604
613
  }
605
614
  return {
606
615
  id: mount.id,
@@ -1,5 +1,6 @@
1
1
  export type WorkingTreeSourceMode = "all" | "git";
2
2
  export type WorkingTreeDeletionMode = "all" | "git";
3
+ export type WorkingTreeMirrorDurability = "per_entry" | "deferred_private_staging";
3
4
  type TreeEntry = {
4
5
  kind: "directory";
5
6
  mode: number;
@@ -25,6 +26,12 @@ export declare function mirrorWorkingTree(input: {
25
26
  targetRoot: string;
26
27
  sourceMode: WorkingTreeSourceMode;
27
28
  deletionMode: WorkingTreeDeletionMode;
29
+ /**
30
+ * Defer destination fsync only while building an unpublished private tree.
31
+ * The caller must durably fsync that complete tree before publishing it or
32
+ * mutating the source authority.
33
+ */
34
+ durability?: WorkingTreeMirrorDurability;
28
35
  }): {
29
36
  paths: string[];
30
37
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.79",
3
+ "version": "0.0.81",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",