@ricsam/r5d-worker 0.0.132 → 0.0.133

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 (47) hide show
  1. package/dist/cjs/atomic-rename.cjs +303 -0
  2. package/dist/cjs/git-blob-hash.cjs +41 -0
  3. package/dist/cjs/main.cjs +187 -38
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/three-way-merge.cjs +346 -0
  6. package/dist/cjs/working-tree-mirror.cjs +1049 -64
  7. package/dist/cjs/workspace-command-sync-policy.cjs +8 -4
  8. package/dist/cjs/workspace-command-targets.cjs +63 -0
  9. package/dist/cjs/workspace-filesystem-job-types.cjs +11 -1
  10. package/dist/cjs/workspace-filesystem-jobs.cjs +2 -0
  11. package/dist/cjs/workspace-git-sync.cjs +846 -61
  12. package/dist/cjs/workspace-hydration-ledger.cjs +66 -0
  13. package/dist/cjs/workspace-hydration-merge.cjs +433 -0
  14. package/dist/cjs/workspace-hydration-recovery-state.cjs +53 -0
  15. package/dist/cjs/workspace-merge-projection.cjs +81 -10
  16. package/dist/cjs/workspace-project-config-policy.cjs +19 -12
  17. package/dist/mjs/atomic-rename.mjs +261 -0
  18. package/dist/mjs/git-blob-hash.mjs +16 -0
  19. package/dist/mjs/main.mjs +196 -39
  20. package/dist/mjs/package.json +1 -1
  21. package/dist/mjs/three-way-merge.mjs +318 -0
  22. package/dist/mjs/working-tree-mirror.mjs +1035 -64
  23. package/dist/mjs/workspace-command-sync-policy.mjs +8 -4
  24. package/dist/mjs/workspace-command-targets.mjs +37 -0
  25. package/dist/mjs/workspace-filesystem-job-types.mjs +11 -1
  26. package/dist/mjs/workspace-filesystem-jobs.mjs +4 -0
  27. package/dist/mjs/workspace-git-sync.mjs +854 -62
  28. package/dist/mjs/workspace-hydration-ledger.mjs +42 -0
  29. package/dist/mjs/workspace-hydration-merge.mjs +399 -0
  30. package/dist/mjs/workspace-hydration-recovery-state.mjs +29 -0
  31. package/dist/mjs/workspace-merge-projection.mjs +85 -11
  32. package/dist/mjs/workspace-project-config-policy.mjs +16 -10
  33. package/dist/types/atomic-rename.d.ts +78 -0
  34. package/dist/types/git-blob-hash.d.ts +10 -0
  35. package/dist/types/main.d.ts +21 -2
  36. package/dist/types/three-way-merge.d.ts +77 -0
  37. package/dist/types/working-tree-mirror.d.ts +270 -7
  38. package/dist/types/workspace-command-sync-policy.d.ts +12 -6
  39. package/dist/types/workspace-command-targets.d.ts +37 -0
  40. package/dist/types/workspace-filesystem-job-types.d.ts +46 -4
  41. package/dist/types/workspace-git-sync.d.ts +125 -3
  42. package/dist/types/workspace-hydration-ledger.d.ts +43 -0
  43. package/dist/types/workspace-hydration-merge.d.ts +95 -0
  44. package/dist/types/workspace-hydration-recovery-state.d.ts +10 -0
  45. package/dist/types/workspace-merge-projection.d.ts +19 -1
  46. package/dist/types/workspace-project-config-policy.d.ts +17 -3
  47. package/package.json +2 -2
@@ -6,15 +6,30 @@ import {
6
6
  workspaceMergeProjectionSupport
7
7
  } from "./workspace-merge-projection.mjs";
8
8
  import { workspaceFilesystemExecutor } from "./workspace-filesystem-executor.mjs";
9
+ import { gitBlobHash, gitObjectHashAlgorithmFor } from "./git-blob-hash.mjs";
9
10
  import {
11
+ applyWorkingTreeMirror,
10
12
  captureWorkingTreeMirrorPlan,
11
13
  fsyncWorkingTreePaths,
12
14
  inspectWorkingTree,
15
+ inspectWorkingTreePath,
13
16
  mirrorWorkingTree,
14
17
  normalizeWorkingTreeRelativePath,
18
+ planPreparedWorkingTreeMirror,
15
19
  planWorkingTreeMirror,
16
- restoreWorkingTreeMirrorScope
20
+ prepareWorkingTreeMirror,
21
+ restoreWorkingTreeMirrorScope,
22
+ rollbackWorkingTreeMirrorJournal,
23
+ workingTreeDisplacedRetentionDirectory,
24
+ WorkingTreeTargetChangedError
17
25
  } from "./working-tree-mirror.mjs";
26
+ import {
27
+ decideWorkspaceHydrationMerge,
28
+ readHydrationBinaryPaths,
29
+ describeHydrationSkipPaths,
30
+ parseWorkspaceSubtreeChanges
31
+ } from "./workspace-hydration-merge.mjs";
32
+ import { WorkspaceHydrationPreBlobLedger } from "./workspace-hydration-ledger.mjs";
18
33
  import { assertManagedDirectoryPath } from "./workspace-mount-boundary.mjs";
19
34
  const WORKSPACE_GIT_BRANCH = "main";
20
35
  const MAX_WORKSPACE_GIT_DIFF_BYTES = 5 * 1024 * 1024;
@@ -22,7 +37,36 @@ const WORKSPACE_GIT_CONFIRMED_LARGE_DIFF_PUSH_OPTION = "r5d-confirm-large-diff-v
22
37
  const WORKSPACE_GIT_INTEGRATED_REF = "refs/r5d/workspace-local/integrated";
23
38
  const WORKSPACE_GIT_HYDRATED_RECEIPT = "r5d/workspace-hydrated-head";
24
39
  const WORKSPACE_GIT_HYDRATION_TRANSACTION = "r5d/workspace-hydration-transaction";
40
+ const WORKSPACE_GIT_HYDRATION_RECOVERIES = "r5d/workspace-hydration-recoveries";
41
+ const WORKSPACE_GIT_HYDRATION_PRESERVED_BASES = "r5d/workspace-hydration-preserved-bases";
25
42
  const WORKSPACE_GIT_CHECKOUT_DURABILITY = "r5d/workspace-checkout-durability";
43
+ const WORKSPACE_MERGE_HYDRATION_SWITCH = "R5D_MERGE_HYDRATION";
44
+ function workspaceMergeHydrationEnabled() {
45
+ return process.env[WORKSPACE_MERGE_HYDRATION_SWITCH] !== "0";
46
+ }
47
+ const hydrationPreBlobLedgers = /* @__PURE__ */ new Map();
48
+ function hydrationPreBlobLedger(workspacePath) {
49
+ const key = path.resolve(workspacePath);
50
+ let ledger = hydrationPreBlobLedgers.get(key);
51
+ if (!ledger) {
52
+ ledger = new WorkspaceHydrationPreBlobLedger();
53
+ hydrationPreBlobLedgers.set(key, ledger);
54
+ }
55
+ return ledger;
56
+ }
57
+ class WorkspaceHydrationRecoveryRequiredError extends Error {
58
+ constructor(hydrationRecovery, options) {
59
+ super(
60
+ `Interrupted guarded workspace hydration ${hydrationRecovery.transactionId} requires explicit preservation recovery; visible files and the prior receipt are preserved, and snapshots remain at ${hydrationRecovery.transactionPath} (mounts: ${hydrationRecovery.mounts.map(({ id }) => id).join(", ")})${hydrationRecovery.mounts.some(({ retainedPaths }) => retainedPaths?.length) ? `; displaced entries retained at ${hydrationRecovery.mounts.flatMap(({ retainedPaths }) => retainedPaths ?? []).join(", ")}` : ""}`,
61
+ options
62
+ );
63
+ this.hydrationRecovery = hydrationRecovery;
64
+ this.name = "WorkspaceHydrationRecoveryRequiredError";
65
+ }
66
+ hydrationRecovery;
67
+ code = "hydration_recovery_required";
68
+ }
69
+ const HYDRATION_TRANSACTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
26
70
  function workspaceGitMountData(mount) {
27
71
  return {
28
72
  id: mount.id,
@@ -878,9 +922,8 @@ async function runWorkspaceCheckoutTransition(workspacePath, mutate) {
878
922
  await throwAfterWorkspaceCheckoutRecovery(workspacePath, error);
879
923
  }
880
924
  }
881
- function parseHydrationTransactionManifest(transactionPath) {
925
+ function parseHydrationTransactionManifest(transactionPath, workspacePath = path.resolve(transactionPath, "..", "..", ".."), options = {}) {
882
926
  const manifestPath = path.join(transactionPath, "manifest.json");
883
- const workspacePath = path.resolve(transactionPath, "..", "..", "..");
884
927
  const status = fs.lstatSync(manifestPath);
885
928
  if (!status.isFile() || status.isSymbolicLink()) {
886
929
  throw new Error(`Workspace hydration transaction manifest is not a regular file: ${manifestPath}`);
@@ -895,22 +938,26 @@ function parseHydrationTransactionManifest(transactionPath) {
895
938
  throw new Error(`Workspace hydration transaction manifest is invalid: ${manifestPath}`);
896
939
  }
897
940
  const candidate = parsed;
898
- if (candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 || !Array.isArray(candidate.mounts)) {
941
+ if (candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 || !Array.isArray(candidate.mounts) || candidate.preservedMounts !== void 0 && (candidate.version !== 5 || !Array.isArray(candidate.preservedMounts))) {
899
942
  throw new Error(`Workspace hydration transaction manifest is invalid: ${manifestPath}`);
900
943
  }
944
+ if (candidate.version === 5 && (typeof candidate.transactionId !== "string" || !HYDRATION_TRANSACTION_ID.test(candidate.transactionId))) {
945
+ throw new Error(`Workspace hydration transaction identity is invalid: ${manifestPath}`);
946
+ }
901
947
  const parseReceipt = (rawReceipt) => {
902
948
  if (rawReceipt === null) return null;
903
949
  if (!rawReceipt || typeof rawReceipt !== "object") {
904
950
  throw new Error(`Workspace hydration transaction manifest is invalid: ${manifestPath}`);
905
951
  }
906
- return parseWorkspaceHydrationReceiptContent(workspacePath, manifestPath, `${JSON.stringify(rawReceipt)}
907
- `);
952
+ const content = `${JSON.stringify(rawReceipt)}
953
+ `;
954
+ return options.verifyReceiptCommits === false ? parseWorkspaceHydrationReceiptContentUnverified(manifestPath, content).receipt : parseWorkspaceHydrationReceiptContent(workspacePath, manifestPath, content);
908
955
  };
909
956
  const targetReceipt = parseReceipt(candidate.targetReceipt);
910
957
  const receiptBefore = parseReceipt(candidate.receiptBefore);
911
958
  const parseScope = (rawScope, existed) => {
912
959
  if (rawScope === void 0) return void 0;
913
- if (candidate.version !== 4 || !existed || !rawScope || typeof rawScope !== "object") {
960
+ if (candidate.version !== 4 && candidate.version !== 5 || !existed || !rawScope || typeof rawScope !== "object") {
914
961
  throw new Error(`Workspace hydration transaction manifest is invalid: ${manifestPath}`);
915
962
  }
916
963
  const parsePaths = (rawPaths) => {
@@ -929,7 +976,7 @@ function parseHydrationTransactionManifest(transactionPath) {
929
976
  };
930
977
  const seenIds = /* @__PURE__ */ new Set();
931
978
  const seenSnapshots = /* @__PURE__ */ new Set();
932
- const mounts = candidate.mounts.map((rawMount) => {
979
+ const parsedMounts = [...candidate.mounts, ...candidate.preservedMounts ?? []].map((rawMount) => {
933
980
  if (!rawMount || typeof rawMount !== "object") {
934
981
  throw new Error(`Workspace hydration transaction manifest is invalid: ${manifestPath}`);
935
982
  }
@@ -944,6 +991,9 @@ function parseHydrationTransactionManifest(transactionPath) {
944
991
  seenIds.add(mount.id);
945
992
  seenSnapshots.add(mount.snapshotName);
946
993
  const scope = parseScope(rawMount.scope, mount.existed);
994
+ if (candidate.version === 5 && mount.recovery !== "snapshot" && mount.recovery !== "guarded" || candidate.version !== 5 && mount.recovery !== void 0 || mount.recovery === "guarded" && (!mount.existed || !scope)) {
995
+ throw new Error(`Workspace hydration recovery policy is invalid: ${manifestPath}`);
996
+ }
947
997
  return {
948
998
  id: mount.id,
949
999
  incarnationKey: mount.incarnationKey,
@@ -952,16 +1002,22 @@ function parseHydrationTransactionManifest(transactionPath) {
952
1002
  workspaceRelativePath: mount.workspaceRelativePath,
953
1003
  existed: mount.existed,
954
1004
  snapshotName: mount.snapshotName,
955
- ...scope ? { scope } : {}
1005
+ ...scope ? { scope } : {},
1006
+ ...candidate.version === 5 ? { recovery: mount.recovery } : {}
956
1007
  };
957
1008
  });
958
1009
  return {
959
- version: candidate.version === 4 ? 4 : 3,
1010
+ version: candidate.version === 5 ? 5 : candidate.version === 4 ? 4 : 3,
1011
+ ...candidate.version === 5 ? { transactionId: candidate.transactionId } : {},
960
1012
  targetReceipt,
961
1013
  receiptBefore,
962
- mounts
1014
+ mounts: parsedMounts.slice(0, candidate.mounts.length),
1015
+ ...candidate.preservedMounts !== void 0 ? { preservedMounts: parsedMounts.slice(candidate.mounts.length) } : {}
963
1016
  };
964
1017
  }
1018
+ function hydrationEvidenceMounts(manifest) {
1019
+ return [...manifest.mounts, ...manifest.preservedMounts ?? []];
1020
+ }
965
1021
  function removeHydrationTransaction(workspacePath) {
966
1022
  const transactionPath = hydrationTransactionPath(workspacePath);
967
1023
  fs.rmSync(transactionPath, { recursive: true, force: true });
@@ -976,7 +1032,303 @@ function hydrationTransactionCommitted(transactionPath) {
976
1032
  }
977
1033
  return true;
978
1034
  }
1035
+ function hydrationRetainedEvidence(mount) {
1036
+ if (!lstatIfExists(mount.sourcePath)) return null;
1037
+ assertManagedDirectoryPath({
1038
+ trustedRoot: mount.durabilityRootPath,
1039
+ candidate: mount.sourcePath,
1040
+ label: `Hydration evidence mount ${mount.id}`
1041
+ });
1042
+ const dotGit = path.join(mount.sourcePath, ".git");
1043
+ if (lstatIfExists(dotGit)?.isSymbolicLink()) throw new Error(`Hydration evidence Git directory is a symlink: ${dotGit}`);
1044
+ const directory = workingTreeDisplacedRetentionDirectory(mount.sourcePath);
1045
+ if (!directory) return null;
1046
+ assertManagedDirectoryPath({
1047
+ trustedRoot: mount.durabilityRootPath,
1048
+ candidate: directory,
1049
+ label: `Hydration retained evidence ${mount.id}`
1050
+ });
1051
+ if (!lstatIfExists(directory)) return null;
1052
+ return {
1053
+ directory,
1054
+ paths: fs.readdirSync(directory).sort().map((name) => path.join(directory, name))
1055
+ };
1056
+ }
1057
+ function fsyncHydrationRetainedEvidence(mount) {
1058
+ const evidence = hydrationRetainedEvidence(mount);
1059
+ if (!evidence || evidence.paths.length === 0) return;
1060
+ fsyncTree(evidence.directory);
1061
+ for (let directory = path.dirname(evidence.directory); ; directory = path.dirname(directory)) {
1062
+ fsyncDirectory(directory);
1063
+ if (directory === path.resolve(mount.durabilityRootPath)) break;
1064
+ }
1065
+ }
1066
+ function readHydrationArchivedEvidence(transactionPath, manifest) {
1067
+ const recordPath = path.join(transactionPath, "retained-evidence.json");
1068
+ const status = lstatIfExists(recordPath);
1069
+ if (!status) return [];
1070
+ if (!status.isFile() || status.isSymbolicLink()) throw new Error(`Invalid hydration retained-evidence record: ${recordPath}`);
1071
+ const record = JSON.parse(fs.readFileSync(recordPath, "utf8"));
1072
+ if (record.version !== 1 || !Array.isArray(record.entries)) throw new Error(`Invalid hydration retained-evidence record: ${recordPath}`);
1073
+ const seen = /* @__PURE__ */ new Set();
1074
+ return record.entries.map((raw) => {
1075
+ if (!raw || typeof raw !== "object") throw new Error(`Invalid hydration retained-evidence entry: ${recordPath}`);
1076
+ const entry = raw;
1077
+ const mount = hydrationEvidenceMounts(manifest).find(({ id }) => id === entry.mountId);
1078
+ const parts = typeof entry.archiveRelativePath === "string" ? entry.archiveRelativePath.split("/") : [];
1079
+ const sourceRelative = typeof entry.sourcePath === "string" && mount ? path.relative(mount.durabilityRootPath, entry.sourcePath) : "";
1080
+ if (!mount || typeof entry.sourcePath !== "string" || path.resolve(entry.sourcePath) !== entry.sourcePath || !sourceRelative || sourceRelative === ".." || sourceRelative.startsWith(`..${path.sep}`) || path.isAbsolute(sourceRelative) || parts.length !== 4 || parts[0] !== "displaced" || !HYDRATION_TRANSACTION_ID.test(parts[1]) || parts[2] !== mount.snapshotName || !parts[3] || parts[3] === "." || parts[3] === ".." || parts[3].includes("\\") || seen.has(entry.archiveRelativePath)) {
1081
+ throw new Error(`Invalid hydration retained-evidence entry: ${recordPath}`);
1082
+ }
1083
+ const archivedPath = path.join(transactionPath, ...parts);
1084
+ assertManagedDirectoryPath({
1085
+ trustedRoot: transactionPath,
1086
+ candidate: path.dirname(archivedPath),
1087
+ label: "Archived hydration evidence"
1088
+ });
1089
+ if (!lstatIfExists(archivedPath)) throw new Error(`Archived hydration evidence is missing: ${archivedPath}`);
1090
+ seen.add(entry.archiveRelativePath);
1091
+ return { mountId: entry.mountId, sourcePath: entry.sourcePath, archiveRelativePath: entry.archiveRelativePath };
1092
+ });
1093
+ }
1094
+ function captureHydrationRetainedEvidence(transactionPath, manifest) {
1095
+ const entries = readHydrationArchivedEvidence(transactionPath, manifest);
1096
+ const captureId = crypto.randomUUID();
1097
+ let captured = false;
1098
+ for (const mount of hydrationEvidenceMounts(manifest)) {
1099
+ const evidence = hydrationRetainedEvidence(mount);
1100
+ if (!evidence || evidence.paths.length === 0) continue;
1101
+ fsyncHydrationRetainedEvidence(mount);
1102
+ const relativeDirectory = `displaced/${captureId}/${mount.snapshotName}`;
1103
+ const destinationDirectory = path.join(transactionPath, relativeDirectory);
1104
+ assertManagedDirectoryPath({ trustedRoot: transactionPath, candidate: destinationDirectory, label: "Hydration evidence capture" });
1105
+ fs.mkdirSync(destinationDirectory, { recursive: true, mode: 448 });
1106
+ for (const sourcePath of evidence.paths) {
1107
+ const archiveRelativePath = `${relativeDirectory}/${path.basename(sourcePath)}`;
1108
+ fs.cpSync(sourcePath, path.join(transactionPath, archiveRelativePath), {
1109
+ recursive: true,
1110
+ dereference: false,
1111
+ verbatimSymlinks: true,
1112
+ errorOnExist: true,
1113
+ force: false,
1114
+ preserveTimestamps: true
1115
+ });
1116
+ entries.push({ mountId: mount.id, sourcePath, archiveRelativePath });
1117
+ captured = true;
1118
+ }
1119
+ }
1120
+ if (captured) {
1121
+ fsyncTree(path.join(transactionPath, "displaced"));
1122
+ const temporaryPath = path.join(transactionPath, `.retained-evidence.${crypto.randomUUID()}.tmp`);
1123
+ writePrivateFileDurably(temporaryPath, `${JSON.stringify({ version: 1, entries })}
1124
+ `);
1125
+ fs.renameSync(temporaryPath, path.join(transactionPath, "retained-evidence.json"));
1126
+ fsyncDirectory(transactionPath);
1127
+ }
1128
+ return entries;
1129
+ }
1130
+ function hydrationRecoveryRequiredError(workspacePath, manifest, cause) {
1131
+ if (manifest.version !== 5 || !manifest.transactionId) throw new Error("Guarded hydration lacks a durable transaction identity");
1132
+ return new WorkspaceHydrationRecoveryRequiredError(
1133
+ {
1134
+ transactionId: manifest.transactionId,
1135
+ transactionPath: hydrationTransactionPath(workspacePath),
1136
+ mounts: hydrationEvidenceMounts(manifest).map(({ id, incarnationKey, sourcePath, durabilityRootPath, workspaceRelativePath }) => {
1137
+ const retainedPaths = hydrationRetainedEvidence({ id, sourcePath, durabilityRootPath })?.paths;
1138
+ return {
1139
+ id,
1140
+ incarnationKey,
1141
+ sourcePath,
1142
+ durabilityRootPath,
1143
+ workspaceRelativePath,
1144
+ ...retainedPaths?.length ? { retainedPaths } : {}
1145
+ };
1146
+ }),
1147
+ receiptBefore: manifest.receiptBefore
1148
+ },
1149
+ cause === void 0 ? void 0 : { cause }
1150
+ );
1151
+ }
1152
+ function runHydrationRecoveryInspectJob(input) {
1153
+ const workspacePath = path.resolve(input.workspacePath);
1154
+ const workspaceStatus = lstatIfExists(workspacePath);
1155
+ if (!workspaceStatus) return null;
1156
+ if (!workspaceStatus.isDirectory() || workspaceStatus.isSymbolicLink())
1157
+ throw new Error(`Invalid workspace hydration root: ${workspacePath}`);
1158
+ const transactionPath = hydrationTransactionPath(workspacePath);
1159
+ assertManagedDirectoryPath({
1160
+ trustedRoot: workspacePath,
1161
+ candidate: transactionPath,
1162
+ label: "Workspace hydration transaction inspection"
1163
+ });
1164
+ const status = lstatIfExists(transactionPath);
1165
+ if (!status) return null;
1166
+ if (!status.isDirectory() || status.isSymbolicLink()) throw new Error(`Invalid workspace hydration transaction: ${transactionPath}`);
1167
+ const manifest = parseHydrationTransactionManifest(transactionPath);
1168
+ if (hydrationTransactionCommitted(transactionPath) || !manifest.mounts.some(({ recovery }) => recovery === "guarded")) return null;
1169
+ return hydrationRecoveryRequiredError(workspacePath, manifest).hydrationRecovery;
1170
+ }
1171
+ function readPreservedHydrationBases(workspacePath) {
1172
+ const recordPath = path.join(workspacePath, ".git", ...WORKSPACE_GIT_HYDRATION_PRESERVED_BASES.split("/"));
1173
+ const status = lstatIfExists(recordPath);
1174
+ if (!status) return null;
1175
+ if (!status.isFile() || status.isSymbolicLink()) throw new Error(`Preserved hydration bases are not a regular file: ${recordPath}`);
1176
+ return parseWorkspaceHydrationReceiptContentUnverified(recordPath, fs.readFileSync(recordPath, "utf8")).receipt;
1177
+ }
1178
+ async function readPreservedHydrationBasesAsync(workspacePath) {
1179
+ const recordPath = path.join(workspacePath, ".git", ...WORKSPACE_GIT_HYDRATION_PRESERVED_BASES.split("/"));
1180
+ let status;
1181
+ try {
1182
+ status = await fs.promises.lstat(recordPath);
1183
+ } catch (error) {
1184
+ if (error.code === "ENOENT") return null;
1185
+ throw error;
1186
+ }
1187
+ if (!status.isFile() || status.isSymbolicLink()) throw new Error(`Preserved hydration bases are not a regular file: ${recordPath}`);
1188
+ return parseWorkspaceHydrationReceiptContentUnverified(recordPath, await fs.promises.readFile(recordPath, "utf8")).receipt;
1189
+ }
1190
+ function preserveHydrationBases(workspacePath, manifest) {
1191
+ const current = readHydratedWorkspaceReceipt(workspacePath);
1192
+ if (!workspaceHydrationReceiptsEqual(current, manifest.receiptBefore)) {
1193
+ throw new Error(`Hydration receipt changed before preservation of transaction ${manifest.transactionId}`);
1194
+ }
1195
+ const previous = readPreservedHydrationBases(workspacePath);
1196
+ const affectedIds = new Set(hydrationEvidenceMounts(manifest).map(({ id }) => id));
1197
+ const mounts = (current?.mounts ?? []).filter(
1198
+ (entry) => affectedIds.has(entry.id) || previous?.mounts.some(
1199
+ (preserved) => workspaceHydrationReceiptsEqual({ version: 3, mounts: [entry] }, { version: 3, mounts: [preserved] })
1200
+ )
1201
+ );
1202
+ const recordPath = path.join(workspacePath, ".git", ...WORKSPACE_GIT_HYDRATION_PRESERVED_BASES.split("/"));
1203
+ const temporaryPath = `${recordPath}.${crypto.randomUUID()}.tmp`;
1204
+ writePrivateFileDurably(temporaryPath, `${JSON.stringify({ version: 3, mounts })}
1205
+ `);
1206
+ fs.renameSync(temporaryPath, recordPath);
1207
+ fsyncDirectory(path.dirname(recordPath));
1208
+ }
1209
+ function runHydrationPreserveInterruptedJob(input) {
1210
+ const workspacePath = path.resolve(input.workspacePath);
1211
+ if (!HYDRATION_TRANSACTION_ID.test(input.expectedTransactionId)) throw new Error("Invalid hydration recovery transaction identity");
1212
+ const stateDirectory = workspacePrivateStateDirectory(workspacePath);
1213
+ const transactionPath = hydrationTransactionPath(workspacePath);
1214
+ const archivesPath = path.join(workspacePath, ".git", ...WORKSPACE_GIT_HYDRATION_RECOVERIES.split("/"));
1215
+ const archivePath = path.join(archivesPath, input.expectedTransactionId);
1216
+ assertManagedDirectoryPath({ trustedRoot: stateDirectory, candidate: archivesPath, label: "Workspace hydration archives" });
1217
+ const pending = lstatIfExists(transactionPath);
1218
+ if (!pending) {
1219
+ assertManagedDirectoryPath({ trustedRoot: stateDirectory, candidate: archivePath, label: "Workspace hydration archive" });
1220
+ if (!lstatIfExists(archivePath))
1221
+ throw new Error(`No pending or preserved hydration transaction matches ${input.expectedTransactionId}`);
1222
+ const archived = parseHydrationTransactionManifest(archivePath, workspacePath, { verifyReceiptCommits: false });
1223
+ const markerPath2 = path.join(archivePath, "preserved.json");
1224
+ const markerStatus = fs.lstatSync(markerPath2);
1225
+ if (!markerStatus.isFile() || markerStatus.isSymbolicLink()) throw new Error(`Invalid hydration preservation record: ${markerPath2}`);
1226
+ const marker = JSON.parse(fs.readFileSync(markerPath2, "utf8"));
1227
+ if (archived.version !== 5 || archived.transactionId !== input.expectedTransactionId || marker.version !== 1 || marker.transactionId !== archived.transactionId) {
1228
+ throw new Error(`Hydration archive identity does not match ${input.expectedTransactionId}`);
1229
+ }
1230
+ const retainedEntries2 = readHydrationArchivedEvidence(archivePath, archived).map(({ mountId, sourcePath, archiveRelativePath }) => ({
1231
+ mountId,
1232
+ sourcePath,
1233
+ archivePath: path.join(archivePath, archiveRelativePath)
1234
+ }));
1235
+ fsyncDirectory(archivePath);
1236
+ fsyncDirectory(archivesPath);
1237
+ fsyncDirectory(stateDirectory);
1238
+ return {
1239
+ transactionId: archived.transactionId,
1240
+ archivePath,
1241
+ receiptBefore: archived.receiptBefore,
1242
+ preservedMountIds: hydrationEvidenceMounts(archived).map(({ id }) => id).sort(),
1243
+ alreadyPreserved: true,
1244
+ ...retainedEntries2.length > 0 ? { retainedEntries: retainedEntries2 } : {}
1245
+ };
1246
+ }
1247
+ if (!pending.isDirectory() || pending.isSymbolicLink()) throw new Error(`Invalid workspace hydration transaction: ${transactionPath}`);
1248
+ const manifest = parseHydrationTransactionManifest(transactionPath);
1249
+ if (manifest.version !== 5 || manifest.transactionId !== input.expectedTransactionId) {
1250
+ throw new Error(
1251
+ `Stale hydration recovery transaction identity: expected ${input.expectedTransactionId}, pending ${manifest.transactionId ?? "legacy transaction"}`
1252
+ );
1253
+ }
1254
+ if (hydrationTransactionCommitted(transactionPath))
1255
+ throw new Error(`Hydration transaction ${manifest.transactionId} is committed; ordinary recovery must finish its receipt`);
1256
+ if (!manifest.mounts.some(({ recovery }) => recovery === "guarded"))
1257
+ throw new Error(`Hydration transaction ${manifest.transactionId} has no interrupted guarded mounts`);
1258
+ if (lstatIfExists(archivePath))
1259
+ throw new Error(`Hydration archive already exists while transaction ${manifest.transactionId} is pending`);
1260
+ if (!workspaceHydrationReceiptsEqual(readHydratedWorkspaceReceipt(workspacePath), manifest.receiptBefore)) {
1261
+ throw new Error(`Hydration receipt changed before preservation of transaction ${manifest.transactionId}`);
1262
+ }
1263
+ for (const mount of hydrationEvidenceMounts(manifest)) {
1264
+ assertManagedDirectoryPath({
1265
+ trustedRoot: mount.durabilityRootPath,
1266
+ candidate: mount.sourcePath,
1267
+ label: `Hydration preservation mount ${mount.id}`
1268
+ });
1269
+ const source = lstatIfExists(mount.sourcePath);
1270
+ if (mount.existed && !source) throw new Error(`Hydration preservation mount ${mount.id} is missing: ${mount.sourcePath}`);
1271
+ const snapshotPath = path.join(transactionPath, "mounts", mount.snapshotName);
1272
+ assertManagedDirectoryPath({
1273
+ trustedRoot: transactionPath,
1274
+ candidate: snapshotPath,
1275
+ label: `Hydration preservation snapshot ${mount.id}`
1276
+ });
1277
+ if (!lstatIfExists(snapshotPath)) throw new Error(`Hydration preservation snapshot ${mount.id} is missing`);
1278
+ const basis = manifest.receiptBefore?.mounts.find((entry) => entry.id === mount.id);
1279
+ if (!basis || basis.incarnationKey !== mount.incarnationKey || basis.sourcePath !== mount.sourcePath || basis.durabilityRootPath !== mount.durabilityRootPath || basis.workspaceRelativePath !== mount.workspaceRelativePath) {
1280
+ throw new Error(
1281
+ `Hydration preservation mount ${mount.id} has no matching prior receipt basis; manual salvage is required. The transaction and all snapshots remain at ${transactionPath}`
1282
+ );
1283
+ }
1284
+ }
1285
+ for (const mount of hydrationEvidenceMounts(manifest)) {
1286
+ fsyncHydratedWorkspaceMount({ ...mount, hydrationIncarnationKey: mount.incarnationKey, sourceMode: "all", hydrateDeletionMode: "all" });
1287
+ }
1288
+ const retainedEntries = captureHydrationRetainedEvidence(transactionPath, manifest).map(
1289
+ ({ mountId, sourcePath, archiveRelativePath }) => ({
1290
+ mountId,
1291
+ sourcePath,
1292
+ archivePath: path.join(archivePath, archiveRelativePath)
1293
+ })
1294
+ );
1295
+ fsyncTree(transactionPath);
1296
+ preserveHydrationBases(workspacePath, manifest);
1297
+ repairWorkspaceBasisRefs(workspacePath, manifest.receiptBefore);
1298
+ const markerPath = path.join(transactionPath, "preserved.json");
1299
+ const markerContent = `${JSON.stringify({ version: 1, transactionId: manifest.transactionId })}
1300
+ `;
1301
+ const existingMarker = lstatIfExists(markerPath);
1302
+ if (existingMarker) {
1303
+ if (!existingMarker.isFile() || existingMarker.isSymbolicLink() || fs.readFileSync(markerPath, "utf8") !== markerContent) {
1304
+ throw new Error(`Invalid hydration preservation record: ${markerPath}`);
1305
+ }
1306
+ } else {
1307
+ const markerTemporaryPath = path.join(transactionPath, `.preserved.${crypto.randomUUID()}.tmp`);
1308
+ writePrivateFileDurably(markerTemporaryPath, markerContent);
1309
+ fs.renameSync(markerTemporaryPath, markerPath);
1310
+ }
1311
+ fsyncDirectory(transactionPath);
1312
+ if (!lstatIfExists(archivesPath)) {
1313
+ fs.mkdirSync(archivesPath, { mode: 448 });
1314
+ fsyncDirectory(stateDirectory);
1315
+ }
1316
+ fs.renameSync(transactionPath, archivePath);
1317
+ fsyncDirectory(archivesPath);
1318
+ fsyncDirectory(stateDirectory);
1319
+ return {
1320
+ transactionId: manifest.transactionId,
1321
+ archivePath,
1322
+ receiptBefore: manifest.receiptBefore,
1323
+ preservedMountIds: hydrationEvidenceMounts(manifest).map(({ id }) => id).sort(),
1324
+ alreadyPreserved: false,
1325
+ ...retainedEntries.length > 0 ? { retainedEntries } : {}
1326
+ };
1327
+ }
979
1328
  function restoreHydrationTransaction(workspacePath, manifest) {
1329
+ if (manifest.mounts.some(({ recovery }) => recovery === "guarded")) {
1330
+ throw hydrationRecoveryRequiredError(workspacePath, manifest);
1331
+ }
980
1332
  const transactionPath = hydrationTransactionPath(workspacePath);
981
1333
  const durabilityScopes = /* @__PURE__ */ new Map();
982
1334
  for (const mount of manifest.mounts) {
@@ -1039,25 +1391,132 @@ function recoverPendingHydrationTransaction(workspacePath) {
1039
1391
  return;
1040
1392
  }
1041
1393
  const receipt = readHydratedWorkspaceReceipt(workspacePath);
1042
- if (manifest.targetReceipt && !workspaceHydrationReceiptsEqual(manifest.targetReceipt, manifest.receiptBefore) && workspaceHydrationReceiptsEqual(receipt, manifest.targetReceipt)) {
1394
+ if (manifest.version !== 5 && manifest.targetReceipt && !workspaceHydrationReceiptsEqual(manifest.targetReceipt, manifest.receiptBefore) && workspaceHydrationReceiptsEqual(receipt, manifest.targetReceipt)) {
1043
1395
  removeHydrationTransaction(workspacePath);
1044
1396
  return;
1045
1397
  }
1046
1398
  restoreHydrationTransaction(workspacePath, manifest);
1047
1399
  removeHydrationTransaction(workspacePath);
1048
1400
  }
1049
- function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
1401
+ function gitOutputSync(cwd, args, action, input) {
1402
+ const result = Bun.spawnSync(gitCommandArgs(args), {
1403
+ cwd,
1404
+ ...input ? { stdin: input } : {},
1405
+ stdout: "pipe",
1406
+ stderr: "pipe",
1407
+ env: workerGitProcessEnvironment()
1408
+ });
1409
+ if (result.exitCode !== 0) throw new Error(`${action}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
1410
+ return Buffer.from(result.stdout);
1411
+ }
1412
+ function workspaceSubtreeChanges(workspacePath, projectedCommit, head, workspaceRelativePath) {
1413
+ const normalized = normalizedWorkspaceMountPath(workspaceRelativePath);
1414
+ const names = [`${projectedCommit}:${normalized}`, `${head}:${normalized}`];
1415
+ const resolved = resolveGitObjectsSync(workspacePath, names);
1416
+ const emptyTree = emptyTreeHash(workspacePath);
1417
+ const subtree = (name) => {
1418
+ const object = resolved.get(name);
1419
+ return object?.type === "tree" ? object.objectId : emptyTree;
1420
+ };
1421
+ const base = subtree(names[0]);
1422
+ const theirs = subtree(names[1]);
1423
+ if (base === theirs) return parseWorkspaceSubtreeChanges(Buffer.alloc(0));
1424
+ return parseWorkspaceSubtreeChanges(
1425
+ gitOutputSync(
1426
+ workspacePath,
1427
+ ["diff-tree", "-r", "-z", "--no-renames", "--raw", base, theirs],
1428
+ "compare projected and hydration subtrees"
1429
+ )
1430
+ );
1431
+ }
1432
+ function resolveGitObjectsSync(workspacePath, names) {
1433
+ const resolved = /* @__PURE__ */ new Map();
1434
+ const unique = [...new Set(names)];
1435
+ if (unique.length === 0) return resolved;
1436
+ const output = gitOutputSync(
1437
+ workspacePath,
1438
+ ["cat-file", "--batch-check"],
1439
+ "resolve workspace objects",
1440
+ Buffer.from(`${unique.join("\n")}
1441
+ `)
1442
+ );
1443
+ const lines = output.toString().split("\n").filter((line) => line.length > 0);
1444
+ if (lines.length !== unique.length) {
1445
+ throw new Error(`Resolve workspace objects: expected ${unique.length} results, received ${lines.length}`);
1446
+ }
1447
+ unique.forEach((name, index) => {
1448
+ const match = /^([0-9a-f]{40,64}) (\S+) \d+$/.exec(lines[index]);
1449
+ resolved.set(name, match ? { objectId: match[1], type: match[2] } : null);
1450
+ });
1451
+ return resolved;
1452
+ }
1453
+ function readWorkspaceBlobs(workspacePath, objectIds) {
1454
+ const blobs = /* @__PURE__ */ new Map();
1455
+ const unique = [...new Set(objectIds)];
1456
+ if (unique.length === 0) return blobs;
1457
+ const output = gitOutputSync(
1458
+ workspacePath,
1459
+ ["cat-file", "--batch"],
1460
+ "read projected workspace blobs",
1461
+ Buffer.from(`${unique.join("\n")}
1462
+ `)
1463
+ );
1464
+ let offset = 0;
1465
+ while (offset < output.length) {
1466
+ const newline = output.indexOf(10, offset);
1467
+ if (newline < 0) break;
1468
+ const header = output.subarray(offset, newline).toString();
1469
+ offset = newline + 1;
1470
+ const match = /^([0-9a-f]{40,64}) (\S+) (\d+)$/u.exec(header);
1471
+ if (!match) {
1472
+ if (/ missing$/u.test(header)) continue;
1473
+ throw new Error(`Unexpected cat-file record while reading projected blobs: ${header}`);
1474
+ }
1475
+ const size = Number(match[3]);
1476
+ if (match[2] === "blob") blobs.set(match[1], Buffer.from(output.subarray(offset, offset + size)));
1477
+ offset += size + 1;
1478
+ }
1479
+ return blobs;
1480
+ }
1481
+ function revertReceiptMounts(target, before, mountIds) {
1482
+ if (!target || mountIds.size === 0) return target;
1483
+ const beforeById = new Map(before?.mounts.map((entry) => [entry.id, entry]) ?? []);
1484
+ const mounts = target.mounts.flatMap((entry) => {
1485
+ if (!mountIds.has(entry.id)) return [entry];
1486
+ const previous = beforeById.get(entry.id);
1487
+ return previous ? [previous] : [];
1488
+ }).sort(compareWorkspaceHydrationMountBasis);
1489
+ return { version: 3, mounts };
1490
+ }
1491
+ function rewriteHydrationTransactionManifest(workspacePath, manifest) {
1492
+ const transactionPath = hydrationTransactionPath(workspacePath);
1493
+ const temporaryPath = path.join(transactionPath, `.manifest.${process.pid}.${crypto.randomUUID()}.tmp`);
1494
+ try {
1495
+ writePrivateFileDurably(temporaryPath, `${JSON.stringify(manifest)}
1496
+ `);
1497
+ fs.renameSync(temporaryPath, path.join(transactionPath, "manifest.json"));
1498
+ fsyncDirectory(transactionPath);
1499
+ } catch (error) {
1500
+ fs.rmSync(temporaryPath, { force: true });
1501
+ throw error;
1502
+ }
1503
+ }
1504
+ function beginHydrationTransaction(workspacePath, mounts, targetReceipt, mergeBases) {
1050
1505
  recoverPendingHydrationTransaction(workspacePath);
1051
1506
  const stateDirectory = workspacePrivateStateDirectory(workspacePath);
1052
1507
  const transactionPath = hydrationTransactionPath(workspacePath);
1053
1508
  const stagingPath = path.join(stateDirectory, `.workspace-hydration-transaction.${process.pid}.${crypto.randomUUID()}.tmp`);
1054
1509
  fs.mkdirSync(stagingPath, { mode: 448 });
1055
1510
  const durabilityScopes = /* @__PURE__ */ new Map();
1511
+ const mergePlans = /* @__PURE__ */ new Map();
1512
+ const skippedMounts = [];
1056
1513
  try {
1057
1514
  const snapshotRoot = path.join(stagingPath, "mounts");
1058
1515
  fs.mkdirSync(snapshotRoot, { mode: 448 });
1059
1516
  const receiptBefore = readHydratedWorkspaceReceipt(workspacePath);
1060
- const manifestMounts = mounts.map((mount, index) => {
1517
+ const head = mergeBases && Object.keys(mergeBases).length > 0 ? revParse(workspacePath, "HEAD") : null;
1518
+ const manifestMounts = [];
1519
+ for (const mount of mounts) {
1061
1520
  const sourcePath = path.resolve(mount.sourcePath);
1062
1521
  const durabilityRootPath = path.resolve(mount.durabilityRootPath);
1063
1522
  assertManagedDirectoryPath({
@@ -1069,45 +1528,89 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
1069
1528
  if (sourceStatus && (!sourceStatus.isDirectory() || sourceStatus.isSymbolicLink())) {
1070
1529
  throw new Error(`Workspace hydration snapshot source is not a regular directory: ${sourcePath}`);
1071
1530
  }
1072
- const snapshotName = String(index);
1531
+ const snapshotName = String(manifestMounts.length);
1073
1532
  const snapshotPath = path.join(snapshotRoot, snapshotName);
1074
- fs.mkdirSync(snapshotPath, { mode: 448 });
1075
1533
  let scope;
1076
- if (sourceStatus && mount.hydrateDeletionMode === "git") {
1534
+ const basis = mergeBases?.[mount.id];
1535
+ if (sourceStatus && mount.hydrateDeletionMode === "git" && basis && head) {
1077
1536
  const outerPath = workspaceMountOuterPath(workspacePath, mount);
1078
1537
  assertManagedDirectoryPath({
1079
1538
  trustedRoot: workspacePath,
1080
1539
  candidate: outerPath,
1081
1540
  label: `Workspace mount ${mount.id} outer source`
1082
1541
  });
1083
- const plan = planWorkingTreeMirror({
1542
+ const prepared = prepareWorkingTreeMirror({
1084
1543
  sourceRoot: outerPath,
1085
1544
  targetRoot: sourcePath,
1086
1545
  sourceMode: "all",
1087
1546
  deletionMode: "git",
1088
1547
  sourceGitlinks: outerSubtreeGitlinks(workspacePath, mount)
1089
1548
  });
1549
+ const decision = decideWorkspaceHydrationMerge({
1550
+ prepared,
1551
+ changes: workspaceSubtreeChanges(workspacePath, basis.projectedCommit, head, mount.workspaceRelativePath),
1552
+ projectedFiles: basis.projectedFiles,
1553
+ ...basis.readAtMs === void 0 ? {} : { readAtMs: basis.readAtMs },
1554
+ readBaseBlobs: (objectIds) => readWorkspaceBlobs(workspacePath, objectIds),
1555
+ readBinaryPaths: (relativePaths) => readHydrationBinaryPaths({ checkoutRoot: prepared.targetRoot, outerRoot: prepared.sourceRoot, relativePaths })
1556
+ });
1557
+ if (decision.kind !== "apply") {
1558
+ skippedMounts.push({
1559
+ mountId: mount.id,
1560
+ reason: decision.kind === "conflict" ? "hydration_merge_conflict" : "hydration_target_changed",
1561
+ paths: decision.paths
1562
+ });
1563
+ continue;
1564
+ }
1565
+ fs.mkdirSync(snapshotPath, { mode: 448 });
1566
+ const plan = planPreparedWorkingTreeMirror(prepared, { selection: decision.selection, overrides: decision.overrides });
1090
1567
  scope = captureWorkingTreeMirrorPlan({
1091
1568
  plan,
1092
1569
  targetRoot: sourcePath,
1093
1570
  snapshotRoot: snapshotPath,
1094
- // Same private-staging deferral as the complete snapshot below.
1095
1571
  durability: "deferred_private_staging"
1096
1572
  });
1097
- durabilityScopes.set(mount.id, [...plan.desired.keys()]);
1098
- } else if (sourceStatus) {
1099
- mirrorWorkingTree({
1100
- sourceRoot: sourcePath,
1101
- targetRoot: snapshotPath,
1573
+ durabilityScopes.set(mount.id, [.../* @__PURE__ */ new Set([...plan.desired.keys(), ...decision.selection.remove])]);
1574
+ mergePlans.set(mount.id, { prepared, decision, plan, snapshotPath: path.join(transactionPath, "mounts", snapshotName), basis });
1575
+ } else if (sourceStatus && mount.hydrateDeletionMode === "git") {
1576
+ fs.mkdirSync(snapshotPath, { mode: 448 });
1577
+ const outerPath = workspaceMountOuterPath(workspacePath, mount);
1578
+ assertManagedDirectoryPath({
1579
+ trustedRoot: workspacePath,
1580
+ candidate: outerPath,
1581
+ label: `Workspace mount ${mount.id} outer source`
1582
+ });
1583
+ const plan = planWorkingTreeMirror({
1584
+ sourceRoot: outerPath,
1585
+ targetRoot: sourcePath,
1102
1586
  sourceMode: "all",
1103
- deletionMode: "all",
1104
- // This tree is still private and the source authority is untouched.
1105
- // The single recursive barrier below makes the complete snapshot
1106
- // durable before its directory entry is published or hydration starts.
1587
+ deletionMode: "git",
1588
+ sourceGitlinks: outerSubtreeGitlinks(workspacePath, mount)
1589
+ });
1590
+ scope = captureWorkingTreeMirrorPlan({
1591
+ plan,
1592
+ targetRoot: sourcePath,
1593
+ snapshotRoot: snapshotPath,
1594
+ // Same private-staging deferral as the complete snapshot below.
1107
1595
  durability: "deferred_private_staging"
1108
1596
  });
1597
+ durabilityScopes.set(mount.id, [...plan.desired.keys()]);
1598
+ } else {
1599
+ fs.mkdirSync(snapshotPath, { mode: 448 });
1600
+ if (sourceStatus) {
1601
+ mirrorWorkingTree({
1602
+ sourceRoot: sourcePath,
1603
+ targetRoot: snapshotPath,
1604
+ sourceMode: "all",
1605
+ deletionMode: "all",
1606
+ // This tree is still private and the source authority is untouched.
1607
+ // The single recursive barrier below makes the complete snapshot
1608
+ // durable before its directory entry is published or hydration starts.
1609
+ durability: "deferred_private_staging"
1610
+ });
1611
+ }
1109
1612
  }
1110
- return {
1613
+ manifestMounts.push({
1111
1614
  id: mount.id,
1112
1615
  incarnationKey: mount.hydrationIncarnationKey,
1113
1616
  sourcePath,
@@ -1115,23 +1618,27 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
1115
1618
  workspaceRelativePath: mount.workspaceRelativePath,
1116
1619
  existed: Boolean(sourceStatus),
1117
1620
  snapshotName,
1118
- ...scope ? { scope } : {}
1119
- };
1120
- });
1621
+ ...scope ? { scope } : {},
1622
+ recovery: mergePlans.has(mount.id) ? "guarded" : "snapshot"
1623
+ });
1624
+ }
1625
+ const effectiveTargetReceipt = revertReceiptMounts(targetReceipt, receiptBefore, new Set(skippedMounts.map(({ mountId }) => mountId)));
1626
+ const guarded = mergePlans.size > 0;
1121
1627
  const manifest = {
1122
- // Older workers reject version 4, so a scoped snapshot can never be
1123
- // replayed as a complete copy after a downgrade.
1124
- version: manifestMounts.some((mount) => mount.scope) ? 4 : 3,
1125
- targetReceipt,
1628
+ // Older workers reject guarded transactions rather than replaying them
1629
+ // through an unguarded snapshot restore after a downgrade.
1630
+ version: guarded ? 5 : manifestMounts.some((mount) => mount.scope) ? 4 : 3,
1631
+ ...guarded ? { transactionId: crypto.randomUUID() } : {},
1632
+ targetReceipt: effectiveTargetReceipt,
1126
1633
  receiptBefore,
1127
- mounts: manifestMounts
1634
+ mounts: guarded ? manifestMounts : manifestMounts.map(({ recovery: _recovery, ...mount }) => mount)
1128
1635
  };
1129
1636
  writePrivateFileDurably(path.join(stagingPath, "manifest.json"), `${JSON.stringify(manifest)}
1130
1637
  `);
1131
1638
  fsyncTree(stagingPath);
1132
1639
  fs.renameSync(stagingPath, transactionPath);
1133
1640
  fsyncDirectory(stateDirectory);
1134
- return { manifest, durabilityScopes };
1641
+ return { manifest, durabilityScopes, mergePlans, skippedMounts, targetReceipt: effectiveTargetReceipt };
1135
1642
  } catch (error) {
1136
1643
  fs.rmSync(stagingPath, { recursive: true, force: true });
1137
1644
  fsyncDirectory(stateDirectory);
@@ -1140,7 +1647,9 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
1140
1647
  }
1141
1648
  function markHydrationTransactionDurable(workspacePath) {
1142
1649
  const transactionPath = hydrationTransactionPath(workspacePath);
1143
- writePrivateFileDurably(path.join(transactionPath, "committed"), "committed\n");
1650
+ const temporaryPath = path.join(transactionPath, `.committed.${crypto.randomUUID()}.tmp`);
1651
+ writePrivateFileDurably(temporaryPath, "committed\n");
1652
+ fs.renameSync(temporaryPath, path.join(transactionPath, "committed"));
1144
1653
  fsyncDirectory(transactionPath);
1145
1654
  }
1146
1655
  function gitVisibleDurabilityPaths(mount, sourcePath, sourceStatus) {
@@ -1292,6 +1801,22 @@ async function configureWorkspaceRepository(input) {
1292
1801
  "configure durable workspace commits and references"
1293
1802
  );
1294
1803
  await gitAsync(input.workspacePath, ["config", "--local", "core.fsyncMethod", "fsync"], "configure workspace fsync method");
1804
+ const attributesPath = path.join(input.workspacePath, ".git", "info", "attributes");
1805
+ const attributes = [
1806
+ "# r5d workspace clone: checkout bytes are carried verbatim; no conversion or filter may change what projection reads or hydration writes.",
1807
+ "* -text -eol -filter -ident -working-tree-encoding",
1808
+ ""
1809
+ ].join("\n");
1810
+ let currentAttributes = null;
1811
+ try {
1812
+ currentAttributes = await fs.promises.readFile(attributesPath, "utf8");
1813
+ } catch (error) {
1814
+ if (error.code !== "ENOENT") throw error;
1815
+ }
1816
+ if (currentAttributes !== attributes) {
1817
+ await fs.promises.mkdir(path.dirname(attributesPath), { recursive: true });
1818
+ await fs.promises.writeFile(attributesPath, attributes, "utf8");
1819
+ }
1295
1820
  }
1296
1821
  async function configureExistingWorkspaceGitForRemediation(input) {
1297
1822
  const workspacePath = path.resolve(input.workspacePath);
@@ -1423,7 +1948,7 @@ function activeMounts(mounts) {
1423
1948
  }
1424
1949
  return { active, tombstones, skipped };
1425
1950
  }
1426
- function mirrorMountsToWorkspace(workspacePath, mounts) {
1951
+ function mirrorMountsToWorkspace(workspacePath, mounts, projectedFiles) {
1427
1952
  const projected = [];
1428
1953
  for (const mount of mounts) {
1429
1954
  assertManagedDirectoryPath({
@@ -1440,7 +1965,8 @@ function mirrorMountsToWorkspace(workspacePath, mounts) {
1440
1965
  sourceRoot: mount.sourcePath,
1441
1966
  targetRoot: path.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/")),
1442
1967
  sourceMode: mount.sourceMode,
1443
- deletionMode: "all"
1968
+ deletionMode: "all",
1969
+ ...projectedFiles ? { projected: projectedFiles } : {}
1444
1970
  });
1445
1971
  projected.push({ mount, gitlinks: mirrored.gitlinks });
1446
1972
  }
@@ -1551,11 +2077,60 @@ function outerSubtreeGitlinks(workspacePath, mount) {
1551
2077
  function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
1552
2078
  const hydratedMountIds = [];
1553
2079
  const skippedMountIds = [];
2080
+ const mergeSkips = [];
2081
+ const rollbackScopes = /* @__PURE__ */ new Map();
2082
+ const preBlobs = {};
1554
2083
  for (const mount of mounts) {
1555
2084
  if (!options.ignoreBusy && mount.busy?.()) {
1556
2085
  skippedMountIds.push(mount.id);
1557
2086
  continue;
1558
2087
  }
2088
+ const mergePlan = options.mergePlans?.get(mount.id);
2089
+ if (mergePlan) {
2090
+ const journal = { entries: [] };
2091
+ const runtimeJournal = { journal, snapshotPath: mergePlan.snapshotPath };
2092
+ options.journals?.set(mount.id, runtimeJournal);
2093
+ try {
2094
+ applyWorkingTreeMirror(mergePlan.prepared, {
2095
+ selection: mergePlan.decision.selection,
2096
+ guards: {
2097
+ expectations: mergePlan.decision.expectations,
2098
+ overrides: mergePlan.decision.overrides,
2099
+ preBlobs: mergePlan.decision.preBlobs,
2100
+ journal
2101
+ }
2102
+ });
2103
+ } catch (error) {
2104
+ if (!(error instanceof WorkingTreeTargetChangedError)) throw error;
2105
+ const rolledBack = rollbackWorkingTreeMirrorJournal({
2106
+ journal,
2107
+ targetRoot: mount.sourcePath,
2108
+ snapshotRoot: mergePlan.snapshotPath
2109
+ });
2110
+ runtimeJournal.rolledBack = rolledBack;
2111
+ const retainedPaths = [
2112
+ ...new Set([...error.retained, ...rolledBack.retained ?? []].map(({ path: retainedPath }) => retainedPath))
2113
+ ].sort();
2114
+ if (retainedPaths.length > 0) {
2115
+ process.stderr.write(
2116
+ `[r5d-worker] hydration preserved displaced writer entries for mount ${mount.id}: ${retainedPaths.join(", ")}
2117
+ `
2118
+ );
2119
+ }
2120
+ options.onMergeRollback?.(mount, rolledBack);
2121
+ mergeSkips.push({
2122
+ mountId: mount.id,
2123
+ reason: "hydration_target_changed",
2124
+ paths: [.../* @__PURE__ */ new Set([error.relativePath, ...rolledBack.abandoned])].sort(),
2125
+ ...retainedPaths.length > 0 ? { retainedPaths } : {}
2126
+ });
2127
+ rollbackScopes.set(mount.id, rolledBack.paths);
2128
+ continue;
2129
+ }
2130
+ hydratedMountIds.push(mount.id);
2131
+ preBlobs[mount.id] = Object.fromEntries(mergePlan.decision.preBlobs);
2132
+ continue;
2133
+ }
1559
2134
  const sourceRoot = workspaceMountOuterPath(workspacePath, mount);
1560
2135
  assertManagedDirectoryPath({
1561
2136
  trustedRoot: workspacePath,
@@ -1601,7 +2176,7 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
1601
2176
  });
1602
2177
  hydratedMountIds.push(mount.id);
1603
2178
  }
1604
- return { hydratedMountIds: hydratedMountIds.sort(), skippedMountIds: skippedMountIds.sort() };
2179
+ return { hydratedMountIds: hydratedMountIds.sort(), skippedMountIds: skippedMountIds.sort(), mergeSkips, rollbackScopes, preBlobs };
1605
2180
  }
1606
2181
  async function selectMountsWithUnchangedOuterSubtree(workspacePath, receipt, head, mounts) {
1607
2182
  const candidates = mounts.flatMap((mount) => {
@@ -1661,7 +2236,7 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
1661
2236
  };
1662
2237
  const noopCandidate = computeSelection();
1663
2238
  if (noopCandidate.hydrationMounts.length === 0 && noopCandidate.advancedDurabilityMounts.length === 0 && noopCandidate.targetReceipt && lstatIfExists(hydrationTransactionPath(workspacePath)) === null && workspaceHydrationReceiptsEqual(receiptBeforeTransaction, noopCandidate.targetReceipt) && await hydratedWorkspaceReceiptFileIsV3(workspacePath) && await workspaceBasisRefsAreCurrentAsync(workspacePath, noopCandidate.targetReceipt)) {
1664
- return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds };
2239
+ return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds, mergeSkips: [] };
1665
2240
  }
1666
2241
  const hooks = input.hydrationHooks;
1667
2242
  const context = {
@@ -1676,13 +2251,30 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
1676
2251
  const heldMountIds = [...new Set([...hydrationMounts, ...advancedDurabilityMounts].map(({ id }) => id))];
1677
2252
  const releaseHold = hooks?.holdMounts(heldMountIds, `hydration transaction ${context.transactionId}`);
1678
2253
  try {
2254
+ const mergeBases = {};
2255
+ for (const mount of hydrationMounts) {
2256
+ const basis = input.mergeBases?.get(mount.id);
2257
+ if (basis) mergeBases[mount.id] = basis;
2258
+ }
1679
2259
  const hydration = await workspaceFilesystemExecutor().run("hydration_transaction", {
1680
2260
  workspacePath,
1681
2261
  hydrationMounts: hydrationMounts.map(workspaceGitMountData),
1682
2262
  advancedDurabilityMounts: advancedDurabilityMounts.map(workspaceGitMountData),
1683
- targetReceipt
2263
+ targetReceipt,
2264
+ ...Object.keys(mergeBases).length > 0 ? { mergeBases } : {}
1684
2265
  });
1685
- return { hydratedMountIds: hydration.hydratedMountIds, skippedMountIds: selection.skippedMountIds };
2266
+ const ledger = hydrationPreBlobLedger(workspacePath);
2267
+ for (const mount of hydrationMounts) {
2268
+ if (!hydration.hydratedMountIds.includes(mount.id)) continue;
2269
+ const preBlobs = mergeBases[mount.id] ? hydration.hydratedPreBlobs[mount.id] : void 0;
2270
+ if (preBlobs) ledger.record(mount, preBlobs);
2271
+ else ledger.clearMount(mount);
2272
+ }
2273
+ return {
2274
+ hydratedMountIds: hydration.hydratedMountIds,
2275
+ skippedMountIds: [.../* @__PURE__ */ new Set([...selection.skippedMountIds, ...hydration.skippedMounts.map(({ mountId }) => mountId)])].sort(),
2276
+ mergeSkips: hydration.skippedMounts
2277
+ };
1686
2278
  } finally {
1687
2279
  releaseHold?.();
1688
2280
  }
@@ -1692,28 +2284,90 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
1692
2284
  }
1693
2285
  function runHydrationTransactionJob(input) {
1694
2286
  const workspacePath = path.resolve(input.workspacePath);
1695
- const { hydrationMounts, advancedDurabilityMounts, targetReceipt } = input;
1696
- const { manifest, durabilityScopes } = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
2287
+ const { hydrationMounts, advancedDurabilityMounts } = input;
2288
+ const begun = beginHydrationTransaction(workspacePath, hydrationMounts, input.targetReceipt, input.mergeBases);
2289
+ const { durabilityScopes, mergePlans, skippedMounts } = begun;
2290
+ let manifest = begun.manifest;
2291
+ let targetReceipt = begun.targetReceipt;
2292
+ const skippedIds = new Set(skippedMounts.map(({ mountId }) => mountId));
2293
+ const journals = /* @__PURE__ */ new Map();
2294
+ const excludeRolledBackMount = (mount, rollback) => {
2295
+ const settledMount = manifest.mounts.find(({ id }) => id === mount.id);
2296
+ if (!settledMount) return;
2297
+ fsyncHydratedWorkspaceMount(mount, rollback.paths);
2298
+ fsyncHydrationRetainedEvidence(mount);
2299
+ if (rollback.retained?.length) {
2300
+ process.stderr.write(
2301
+ `[r5d-worker] hydration rollback retained writer entries for mount ${mount.id}: ${rollback.retained.map(({ path: retainedPath }) => retainedPath).join(", ")}
2302
+ `
2303
+ );
2304
+ }
2305
+ const nextReceipt = revertReceiptMounts(targetReceipt, manifest.receiptBefore, /* @__PURE__ */ new Set([mount.id]));
2306
+ const nextManifest = {
2307
+ ...manifest,
2308
+ targetReceipt: nextReceipt,
2309
+ mounts: manifest.mounts.filter(({ id }) => id !== mount.id),
2310
+ preservedMounts: [...manifest.preservedMounts ?? [], settledMount]
2311
+ };
2312
+ rewriteHydrationTransactionManifest(workspacePath, nextManifest);
2313
+ manifest = nextManifest;
2314
+ targetReceipt = nextReceipt;
2315
+ };
1697
2316
  try {
1698
- const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1699
- const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
2317
+ const activeMounts2 = hydrationMounts.filter(({ id }) => !skippedIds.has(id));
2318
+ const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, activeMounts2, {
2319
+ ignoreBusy: true,
2320
+ mergePlans,
2321
+ journals,
2322
+ onMergeRollback: excludeRolledBackMount
2323
+ });
2324
+ if (hydration.mergeSkips.length > 0) {
2325
+ for (const skip of hydration.mergeSkips) {
2326
+ skippedMounts.push(skip);
2327
+ skippedIds.add(skip.mountId);
2328
+ }
2329
+ }
2330
+ const expectedHydratedIds = activeMounts2.map(({ id }) => id).filter((id) => !skippedIds.has(id)).sort();
1700
2331
  if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
1701
2332
  throw new Error("Workspace hydration did not include every required mount");
1702
2333
  }
1703
- fsyncHydratedWorkspaceMounts(advancedDurabilityMounts, durabilityScopes);
2334
+ fsyncHydratedWorkspaceMounts(
2335
+ advancedDurabilityMounts.filter(({ id }) => !skippedIds.has(id)),
2336
+ durabilityScopes
2337
+ );
1704
2338
  markHydrationTransactionDurable(workspacePath);
1705
2339
  if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1706
2340
  removeHydrationTransaction(workspacePath);
1707
- return { hydratedMountIds: hydration.hydratedMountIds };
2341
+ return { hydratedMountIds: hydration.hydratedMountIds, skippedMounts, hydratedPreBlobs: hydration.preBlobs };
1708
2342
  } catch (error) {
1709
2343
  const transactionPath = hydrationTransactionPath(workspacePath);
1710
2344
  if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
1711
2345
  throw error;
1712
2346
  }
1713
2347
  try {
2348
+ for (const [mountId, mergePlan] of mergePlans) {
2349
+ const mount = hydrationMounts.find(({ id }) => id === mountId);
2350
+ if (!mount || !manifest.mounts.some(({ id }) => id === mountId)) continue;
2351
+ const runtimeJournal = journals.get(mountId);
2352
+ const rolledBack = runtimeJournal ? runtimeJournal.rolledBack ?? rollbackWorkingTreeMirrorJournal({
2353
+ journal: runtimeJournal.journal,
2354
+ targetRoot: mount.sourcePath,
2355
+ snapshotRoot: mergePlan.snapshotPath
2356
+ }) : { paths: [], abandoned: [] };
2357
+ if (runtimeJournal) runtimeJournal.rolledBack = rolledBack;
2358
+ excludeRolledBackMount(mount, rolledBack);
2359
+ }
1714
2360
  restoreHydrationTransaction(workspacePath, manifest);
1715
2361
  removeHydrationTransaction(workspacePath);
1716
2362
  } catch (restoreError) {
2363
+ const pendingManifest = parseHydrationTransactionManifest(transactionPath);
2364
+ if (pendingManifest.mounts.some(({ recovery }) => recovery === "guarded")) {
2365
+ throw hydrationRecoveryRequiredError(
2366
+ workspacePath,
2367
+ pendingManifest,
2368
+ new AggregateError([error, restoreError], "Guarded hydration rollback did not settle")
2369
+ );
2370
+ }
1717
2371
  throw new AggregateError(
1718
2372
  [error, restoreError],
1719
2373
  `Workspace hydration failed and its durable snapshot could not be restored: ${error instanceof Error ? error.message : String(error)}`
@@ -1775,6 +2429,7 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
1775
2429
  });
1776
2430
  const currentHead = await revParseAsync(workspacePath, "HEAD");
1777
2431
  const currentReceipt = await readHydratedWorkspaceReceiptAsync(workspacePath);
2432
+ const preservedBases = await readPreservedHydrationBasesAsync(workspacePath);
1778
2433
  if (!currentHead) return;
1779
2434
  const outerHeadContainsMount = (mount) => tryGitAsync(workspacePath, ["cat-file", "-e", `HEAD:${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`]);
1780
2435
  const configuredBasisMounts = configuredWorkspaceHydrationBasisMounts(workspacePath, mounts, options.deferMountIds);
@@ -1784,7 +2439,7 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
1784
2439
  if (workspaceHydrationReceiptMatchesMounts(currentReceipt, currentHead, configuredBasisMounts)) return;
1785
2440
  const busyMountIds = new Set(options.ignoreBusy ? [] : uncoveredBasisMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
1786
2441
  const recoveryMounts = uncoveredBasisMounts.filter(
1787
- (mount) => workspaceHydrationReceiptMountBasisHead(currentReceipt, mount) === null || !options.preserveStaleBases && !busyMountIds.has(mount.id)
2442
+ (mount) => workspaceHydrationReceiptMountBasisHead(currentReceipt, mount) === null || !options.preserveStaleBases && !busyMountIds.has(mount.id) && workspaceHydrationReceiptMountBasisHead(preservedBases, mount) !== workspaceHydrationReceiptMountBasisHead(currentReceipt, mount)
1788
2443
  );
1789
2444
  const idleBasislessMounts = recoveryMounts.filter((mount) => !busyMountIds.has(mount.id));
1790
2445
  const hydrationTargets = [];
@@ -1814,6 +2469,7 @@ async function resetWorkspaceGit(input) {
1814
2469
  if (busyResetMountIds.length > 0) {
1815
2470
  throw new Error(`Workspace reset cannot run while mounts are busy: ${busyResetMountIds.sort().join(", ")}`);
1816
2471
  }
2472
+ hydrationPreBlobLedger(workspacePath).clear();
1817
2473
  const initial = await ensureWorkspaceGitClone({ ...input, workspacePath });
1818
2474
  await workspaceFilesystemExecutor().run("hydration_recovery", { workspacePath, preserveResolutionInProgress: false });
1819
2475
  const status = gitResult(workspacePath, ["status", "--porcelain=v1", "-z"]);
@@ -2157,6 +2813,24 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2157
2813
  const mergedProjectionMounts = [];
2158
2814
  const projectionSkippedIds = /* @__PURE__ */ new Set();
2159
2815
  let projectionWarning;
2816
+ const projectedMountFiles = /* @__PURE__ */ new Map();
2817
+ const mergedProjectionBases = /* @__PURE__ */ new Map();
2818
+ const projectedMountBases = /* @__PURE__ */ new Map();
2819
+ const mountSkipReasons = {};
2820
+ const staleRewriteLedger = hydrationPreBlobLedger(workspacePath);
2821
+ const staleRewriteBlobsFor = (mount) => {
2822
+ const preBlobs = staleRewriteLedger.preBlobs(mount);
2823
+ return preBlobs.size > 0 ? Object.fromEntries(preBlobs) : void 0;
2824
+ };
2825
+ const recordStaleRewrite = (mount, paths) => {
2826
+ projectionSkippedIds.add(mount.id);
2827
+ mountSkipReasons[mount.id] = { reason: "stale_rewrite_detected", paths: [...paths] };
2828
+ const holders = mount.describeHolders?.();
2829
+ process.stderr.write(
2830
+ `[r5d-worker] stale rewrite detected in mount ${mount.id}: ${describeHydrationSkipPaths(mount.sourcePath, paths)} hold exactly the bytes the last hydration replaced; the mount stays pinned at its recorded basis until they change${holders ? `; holders: ${holders}` : ""}
2831
+ `
2832
+ );
2833
+ };
2160
2834
  const refreshCycleSkippedMounts = (mounts = input.mounts) => {
2161
2835
  for (const mount of mounts) {
2162
2836
  const projectionToken = projectionMutationTokens.get(mount.id);
@@ -2179,7 +2853,8 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2179
2853
  const token = projectionMutationTokens.get(id);
2180
2854
  return token === void 0 ? [] : [[id, token]];
2181
2855
  })
2182
- )
2856
+ ),
2857
+ ...Object.keys(mountSkipReasons).length > 0 ? { mountSkipReasons: { ...mountSkipReasons } } : {}
2183
2858
  };
2184
2859
  };
2185
2860
  const mountProjectionIsCurrent = (mount) => mount.lastProjectedMutationToken !== void 0 && !mount.deleteWhenSourceMissing && mount.preserveLocalOnHydrationBasisChange !== true && projectionBasisHead !== null && workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount) === projectionBasisHead && projectionMutationTokens.get(mount.id) === mount.lastProjectedMutationToken && mount.mutationToken?.() === mount.lastProjectedMutationToken;
@@ -2209,6 +2884,7 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2209
2884
  const releaseMergeHold = input.hydrationHooks?.holdMounts([mount.id], `merge projection of mount ${mount.id}`);
2210
2885
  let merged;
2211
2886
  try {
2887
+ const staleRewriteBlobs = staleRewriteBlobsFor(mount);
2212
2888
  merged = await workspaceFilesystemExecutor().run("projection_merge", {
2213
2889
  workspacePath,
2214
2890
  mount: {
@@ -2219,11 +2895,22 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2219
2895
  },
2220
2896
  basisHead,
2221
2897
  currentHead: projectionBasisHead,
2222
- attemptId
2898
+ attemptId,
2899
+ ...staleRewriteBlobs ? { staleRewriteBlobs } : {}
2223
2900
  });
2224
2901
  } finally {
2225
2902
  releaseMergeHold?.();
2226
2903
  }
2904
+ if (merged.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, merged.staleRewriteCleared);
2905
+ if (merged.kind === "stale_rewrite") {
2906
+ recordStaleRewrite(mount, merged.paths);
2907
+ continue;
2908
+ }
2909
+ mergedProjectionBases.set(mount.id, {
2910
+ projectedCommit: merged.oursCommit,
2911
+ projectedFiles: merged.projectedFiles,
2912
+ readAtMs: merged.readAtMs
2913
+ });
2227
2914
  if (merged.kind === "conflict") {
2228
2915
  const refs = snapshotConflict({ workspacePath, attemptId, localHead: merged.oursCommit, remoteHead: projectionBasisHead });
2229
2916
  return {
@@ -2276,17 +2963,44 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2276
2963
  currentHeadBeforeHydration,
2277
2964
  uncoveredCompletionMounts
2278
2965
  ) : /* @__PURE__ */ new Set();
2966
+ const hydrationMounts = uncoveredCompletionMounts.filter(({ id }) => !unchangedMountIds.has(id));
2967
+ const mergeBases = /* @__PURE__ */ new Map();
2968
+ if (workspaceMergeHydrationEnabled()) {
2969
+ for (const mount of hydrationMounts) {
2970
+ const basis = projectedMountBases.get(mount.id);
2971
+ if (basis) mergeBases.set(mount.id, basis);
2972
+ }
2973
+ }
2279
2974
  const hydration = await hydrateWorkspaceGitMountsTransactionally({
2280
2975
  workspacePath,
2281
- mounts: uncoveredCompletionMounts.filter(({ id }) => !unchangedMountIds.has(id)),
2976
+ mounts: hydrationMounts,
2282
2977
  durabilityMounts: uncoveredCompletionBasisMounts.filter(({ id }) => !unchangedMountIds.has(id)),
2283
2978
  carryMounts: uncoveredCompletionMounts.filter(({ id }) => unchangedMountIds.has(id)),
2284
2979
  receiptMounts: completionReceiptMounts,
2285
2980
  requiredMounts: uncoveredRequiredLiveMounts.filter(({ id }) => !unchangedMountIds.has(id)),
2286
2981
  recordCurrentHead: true,
2287
- hydrationHooks: input.hydrationHooks
2982
+ hydrationHooks: input.hydrationHooks,
2983
+ ...mergeBases.size > 0 ? { mergeBases } : {}
2288
2984
  });
2289
2985
  for (const id of hydration.skippedMountIds) cycleSkippedMountIds.add(id);
2986
+ if (currentHeadBeforeHydration) {
2987
+ for (const id of hydration.hydratedMountIds) {
2988
+ if (mergeBases.has(id)) projectedMountBases.set(id, { projectedCommit: currentHeadBeforeHydration, projectedFiles: null });
2989
+ }
2990
+ }
2991
+ for (const skip of hydration.mergeSkips) {
2992
+ const mount = hydrationMounts.find(({ id }) => id === skip.mountId);
2993
+ const basis = mount ? workspaceHydrationReceiptMountBasisHead(currentReceiptBeforeHydration, mount) : null;
2994
+ mountSkipReasons[skip.mountId] = {
2995
+ reason: skip.reason,
2996
+ paths: skip.paths,
2997
+ ...skip.retainedPaths?.length ? { retainedPaths: skip.retainedPaths } : {}
2998
+ };
2999
+ process.stderr.write(
3000
+ `[r5d-worker] merge hydration pinned mount ${skip.mountId} at ${basis ?? "its recorded basis"} (${skip.reason}): ${skip.reason === "hydration_merge_conflict" ? "writes that landed after the projection read conflict with the inbound change at" : "the checkout changed while it was being hydrated at"} ${describeHydrationSkipPaths(mount?.sourcePath ?? skip.mountId, skip.paths)}; the next projection merges it from that basis${skip.retainedPaths?.length ? `; displaced writer entries preserved at ${skip.retainedPaths.join(", ")}` : ""}
3001
+ `
3002
+ );
3003
+ }
2290
3004
  refreshCycleSkippedMounts(completionReceiptMounts);
2291
3005
  if (requireCurrentHeadReceipt) {
2292
3006
  const currentHead = await revParseAsync(workspacePath, "HEAD");
@@ -2327,10 +3041,11 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2327
3041
  };
2328
3042
  const projectedFastMounts = [];
2329
3043
  const currentProjectionMounts = [];
3044
+ const selectedActiveIdSet = new Set(selected.active.map(({ id }) => id));
2330
3045
  if (!input.skipMountMirror) {
2331
3046
  await beginWorkspaceCheckoutTransition(workspacePath);
2332
3047
  try {
2333
- const selectedActiveIds = new Set(selected.active.map(({ id }) => id));
3048
+ const selectedActiveIds = selectedActiveIdSet;
2334
3049
  const fastActiveMounts = fastProjectionMounts.filter(({ id }) => selectedActiveIds.has(id));
2335
3050
  const fastTombstones = fastProjectionMounts.filter(({ id }) => !selectedActiveIds.has(id));
2336
3051
  for (const mount of fastActiveMounts) {
@@ -2343,15 +3058,27 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2343
3058
  continue;
2344
3059
  }
2345
3060
  const releaseMirrorHold = input.hydrationHooks?.holdMounts([mount.id], `projection of mount ${mount.id}`);
3061
+ let staleRewritePaths;
2346
3062
  try {
3063
+ const staleRewriteBlobs = staleRewriteBlobsFor(mount);
2347
3064
  const mirrored = await workspaceFilesystemExecutor().run("projection_mirror", {
2348
3065
  workspacePath,
2349
- mount: workspaceGitMountData(mount)
3066
+ mount: workspaceGitMountData(mount),
3067
+ ...staleRewriteBlobs ? { staleRewriteBlobs } : {}
2350
3068
  });
2351
- projectedGitlinks.push({ mount, gitlinks: mirrored.gitlinks });
3069
+ staleRewritePaths = mirrored.staleRewritePaths;
3070
+ if (mirrored.staleRewriteCleared.length > 0) staleRewriteLedger.forget(mount, mirrored.staleRewriteCleared);
3071
+ if (staleRewritePaths.length === 0) {
3072
+ projectedGitlinks.push({ mount, gitlinks: mirrored.gitlinks });
3073
+ projectedMountFiles.set(mount.id, { files: mirrored.projectedFiles, readAtMs: mirrored.readAtMs });
3074
+ }
2352
3075
  } finally {
2353
3076
  releaseMirrorHold?.();
2354
3077
  }
3078
+ if (staleRewritePaths.length > 0) {
3079
+ recordStaleRewrite(mount, staleRewritePaths);
3080
+ continue;
3081
+ }
2355
3082
  projectedFastMounts.push(mount);
2356
3083
  }
2357
3084
  await workspaceFilesystemExecutor().run("projection_outer_apply", {
@@ -2378,6 +3105,18 @@ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2378
3105
  if (!input.skipMountMirror) {
2379
3106
  const projectedHead = await revParseAsync(workspacePath, "HEAD");
2380
3107
  if (!projectedHead) throw new Error("Workspace projection did not retain a local HEAD");
3108
+ for (const mount of projectedFastMounts) {
3109
+ if (!selectedActiveIdSet.has(mount.id)) continue;
3110
+ const read = projectedMountFiles.get(mount.id);
3111
+ projectedMountBases.set(mount.id, {
3112
+ projectedCommit: projectedHead,
3113
+ projectedFiles: read?.files ?? null,
3114
+ ...read ? { readAtMs: read.readAtMs } : {}
3115
+ });
3116
+ }
3117
+ for (const mount of currentProjectionMounts)
3118
+ projectedMountBases.set(mount.id, { projectedCommit: projectedHead, projectedFiles: null });
3119
+ for (const [mountId, basis] of mergedProjectionBases) projectedMountBases.set(mountId, basis);
2381
3120
  const targetProjectionReceipt = receiptWithMountsAtHead(
2382
3121
  projectionBasisReceipt,
2383
3122
  projectedHead,
@@ -2557,9 +3296,52 @@ ${push.stdout}`)) {
2557
3296
  }
2558
3297
  throw new Error(`Workspace push did not converge after ${maxPushAttempts} attempts`);
2559
3298
  }
3299
+ function detectStaleRewrites(workspacePath, mount, staleRewriteBlobs) {
3300
+ const outerRoot = workspaceMountOuterPath(workspacePath, mount);
3301
+ const stale = [];
3302
+ const cleared = [];
3303
+ for (const [relativePath, blob] of Object.entries(staleRewriteBlobs)) {
3304
+ const algorithm = gitObjectHashAlgorithmFor(blob);
3305
+ if (!algorithm) continue;
3306
+ const source = inspectWorkingTreePath(mount.sourcePath, relativePath);
3307
+ if (source.kind !== "entry" || !source.stat.isFile()) {
3308
+ cleared.push(relativePath);
3309
+ continue;
3310
+ }
3311
+ const content = fs.readFileSync(source.absolutePath);
3312
+ const outer = inspectWorkingTreePath(outerRoot, relativePath);
3313
+ const outerEqual = outer.kind === "entry" && outer.stat.isFile() && fs.readFileSync(outer.absolutePath).equals(content);
3314
+ if (gitBlobHash(content, algorithm) !== blob) {
3315
+ if (!outerEqual) cleared.push(relativePath);
3316
+ continue;
3317
+ }
3318
+ if (outerEqual) {
3319
+ cleared.push(relativePath);
3320
+ continue;
3321
+ }
3322
+ stale.push(relativePath);
3323
+ }
3324
+ return { stale: stale.sort(), cleared: cleared.sort() };
3325
+ }
2560
3326
  function runProjectionMirrorJob(input) {
2561
- const projected = mirrorMountsToWorkspace(path.resolve(input.workspacePath), [input.mount]);
2562
- return { gitlinks: projected[0]?.gitlinks ?? [] };
3327
+ const workspacePath = path.resolve(input.workspacePath);
3328
+ let staleRewriteCleared = [];
3329
+ if (input.staleRewriteBlobs) {
3330
+ const detected = detectStaleRewrites(workspacePath, input.mount, input.staleRewriteBlobs);
3331
+ staleRewriteCleared = detected.cleared;
3332
+ if (detected.stale.length > 0) {
3333
+ return { gitlinks: [], projectedFiles: {}, readAtMs: Date.now(), staleRewritePaths: detected.stale, staleRewriteCleared };
3334
+ }
3335
+ }
3336
+ const projectedFiles = /* @__PURE__ */ new Map();
3337
+ const projected = mirrorMountsToWorkspace(workspacePath, [input.mount], projectedFiles);
3338
+ return {
3339
+ gitlinks: projected[0]?.gitlinks ?? [],
3340
+ projectedFiles: Object.fromEntries(projectedFiles),
3341
+ readAtMs: Date.now(),
3342
+ staleRewritePaths: [],
3343
+ staleRewriteCleared
3344
+ };
2563
3345
  }
2564
3346
  function runProjectionOuterApplyJob(input) {
2565
3347
  const workspacePath = path.resolve(input.workspacePath);
@@ -2577,11 +3359,16 @@ function runProjectionReceiptJob(input) {
2577
3359
  return null;
2578
3360
  }
2579
3361
  const workspaceGitSyncTestHarness = {
3362
+ beginHydrationTransaction,
2580
3363
  commandArgs: gitCommandArgs,
2581
3364
  workspaceCloneCommandArgs,
2582
3365
  configureWorkspaceRepository,
2583
3366
  mirrorMountsToWorkspace,
2584
- hydrateMountsFromWorkspace: hydrateWorkspaceGitMounts
3367
+ hydrateMountsFromWorkspace: hydrateWorkspaceGitMounts,
3368
+ hydrationPreBlobLedger,
3369
+ resetHydrationPreBlobLedgers() {
3370
+ hydrationPreBlobLedgers.clear();
3371
+ }
2585
3372
  };
2586
3373
  export {
2587
3374
  MAX_WORKSPACE_GIT_DIFF_BYTES,
@@ -2589,7 +3376,10 @@ export {
2589
3376
  WORKSPACE_GIT_CHECKOUT_DURABILITY,
2590
3377
  WORKSPACE_GIT_CONFIRMED_LARGE_DIFF_PUSH_OPTION,
2591
3378
  WORKSPACE_GIT_HYDRATED_RECEIPT,
3379
+ WORKSPACE_GIT_HYDRATION_RECOVERIES,
2592
3380
  WORKSPACE_GIT_HYDRATION_TRANSACTION,
3381
+ WORKSPACE_MERGE_HYDRATION_SWITCH,
3382
+ WorkspaceHydrationRecoveryRequiredError,
2593
3383
  WorkspaceRemediationAncestryError,
2594
3384
  configureExistingWorkspaceGitForRemediation,
2595
3385
  ensureWorkspaceGitClone,
@@ -2599,7 +3389,9 @@ export {
2599
3389
  runCheckoutDurabilityRecoveryJob,
2600
3390
  runCheckoutTransitionBeginJob,
2601
3391
  runCheckoutTransitionCompleteJob,
3392
+ runHydrationPreserveInterruptedJob,
2602
3393
  runHydrationRawJob,
3394
+ runHydrationRecoveryInspectJob,
2603
3395
  runHydrationRecoveryJob,
2604
3396
  runHydrationTransactionJob,
2605
3397
  runOuterCheckoutFsyncJob,