@indigoai-us/hq-cloud 6.14.6 → 6.14.8

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 (97) hide show
  1. package/dist/backup-prune.d.ts +68 -0
  2. package/dist/backup-prune.d.ts.map +1 -0
  3. package/dist/backup-prune.js +196 -0
  4. package/dist/backup-prune.js.map +1 -0
  5. package/dist/backup-prune.test.d.ts +2 -0
  6. package/dist/backup-prune.test.d.ts.map +1 -0
  7. package/dist/backup-prune.test.js +89 -0
  8. package/dist/backup-prune.test.js.map +1 -0
  9. package/dist/bin/backup-prune-runner.d.ts +3 -0
  10. package/dist/bin/backup-prune-runner.d.ts.map +1 -0
  11. package/dist/bin/backup-prune-runner.js +50 -0
  12. package/dist/bin/backup-prune-runner.js.map +1 -0
  13. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-loop.js +28 -0
  15. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +2 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js.map +1 -1
  19. package/dist/cli/reindex.d.ts.map +1 -1
  20. package/dist/cli/reindex.js +5 -3
  21. package/dist/cli/reindex.js.map +1 -1
  22. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  23. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  24. package/dist/cli/rescue-core.d.ts +7 -0
  25. package/dist/cli/rescue-core.d.ts.map +1 -1
  26. package/dist/cli/rescue-core.js +509 -282
  27. package/dist/cli/rescue-core.js.map +1 -1
  28. package/dist/cli/rescue-snapshot.d.ts +14 -0
  29. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  30. package/dist/cli/rescue-snapshot.js +39 -0
  31. package/dist/cli/rescue-snapshot.js.map +1 -0
  32. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  33. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  34. package/dist/cli/rescue-snapshot.test.js +46 -0
  35. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  36. package/dist/cli/rescue.test.js +24 -0
  37. package/dist/cli/rescue.test.js.map +1 -1
  38. package/dist/cli/share.d.ts.map +1 -1
  39. package/dist/cli/share.js +12 -11
  40. package/dist/cli/share.js.map +1 -1
  41. package/dist/cli/sync.d.ts.map +1 -1
  42. package/dist/cli/sync.js +41 -6
  43. package/dist/cli/sync.js.map +1 -1
  44. package/dist/cli/sync.test.js +51 -0
  45. package/dist/cli/sync.test.js.map +1 -1
  46. package/dist/local-path-codec.d.ts +19 -0
  47. package/dist/local-path-codec.d.ts.map +1 -0
  48. package/dist/local-path-codec.js +69 -0
  49. package/dist/local-path-codec.js.map +1 -0
  50. package/dist/object-io.d.ts +19 -2
  51. package/dist/object-io.d.ts.map +1 -1
  52. package/dist/object-io.js +168 -32
  53. package/dist/object-io.js.map +1 -1
  54. package/dist/object-io.test.js +157 -1
  55. package/dist/object-io.test.js.map +1 -1
  56. package/dist/s3.d.ts +8 -0
  57. package/dist/s3.d.ts.map +1 -1
  58. package/dist/s3.js +52 -22
  59. package/dist/s3.js.map +1 -1
  60. package/dist/scope-shrink.d.ts.map +1 -1
  61. package/dist/scope-shrink.js +4 -3
  62. package/dist/scope-shrink.js.map +1 -1
  63. package/dist/signals/get.test.js +21 -1
  64. package/dist/signals/get.test.js.map +1 -1
  65. package/dist/signals/list.test.js +21 -1
  66. package/dist/signals/list.test.js.map +1 -1
  67. package/dist/sources/get.test.js +21 -1
  68. package/dist/sources/get.test.js.map +1 -1
  69. package/dist/sources/list.test.js +21 -1
  70. package/dist/sources/list.test.js.map +1 -1
  71. package/package.json +3 -2
  72. package/scripts/presign-transport-e2e.mjs +57 -10
  73. package/scripts/vault-rebaseline.sh +49 -1
  74. package/scripts/vault-rescue.sh +42 -1
  75. package/src/backup-prune.test.ts +98 -0
  76. package/src/backup-prune.ts +182 -0
  77. package/src/bin/backup-prune-runner.ts +33 -0
  78. package/src/bin/sync-runner-watch-loop.ts +18 -0
  79. package/src/bin/sync-runner.ts +2 -0
  80. package/src/cli/reindex.ts +5 -3
  81. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  82. package/src/cli/rescue-core.ts +311 -89
  83. package/src/cli/rescue-snapshot.test.ts +57 -0
  84. package/src/cli/rescue-snapshot.ts +51 -0
  85. package/src/cli/rescue.test.ts +34 -0
  86. package/src/cli/share.ts +12 -11
  87. package/src/cli/sync.test.ts +62 -0
  88. package/src/cli/sync.ts +51 -7
  89. package/src/local-path-codec.ts +95 -0
  90. package/src/object-io.test.ts +175 -0
  91. package/src/object-io.ts +213 -32
  92. package/src/s3.ts +61 -12
  93. package/src/scope-shrink.ts +4 -3
  94. package/src/signals/get.test.ts +26 -2
  95. package/src/signals/list.test.ts +26 -2
  96. package/src/sources/get.test.ts +26 -2
  97. package/src/sources/list.test.ts +26 -2
@@ -28,6 +28,17 @@ import { spawnSync } from "child_process";
28
28
  import * as fs from "fs";
29
29
  import * as os from "os";
30
30
  import * as path from "path";
31
+ import {
32
+ assertRescuePathTypeUnchanged,
33
+ isRescueTransientDirectory,
34
+ type RescuePathKind,
35
+ } from "./rescue-snapshot.js";
36
+ import {
37
+ applyBackupPrune,
38
+ canonicalRootHash,
39
+ inventoryManagedSnapshots,
40
+ type SnapshotManifest,
41
+ } from "../backup-prune.js";
31
42
 
32
43
  export interface RunRescueResult {
33
44
  status: number;
@@ -61,6 +72,13 @@ const ALWAYS_PRESERVED_NAMES = [
61
72
  // these too (hq-cloud DEFAULT_IGNORES).
62
73
  ".pnpm-store",
63
74
  "node_modules",
75
+ // Local worktrees and updater/build sandboxes are reproducible, often huge,
76
+ // and never owned by an hq-core release. Keep these root-only names outside
77
+ // both the wipe set and the safety snapshot.
78
+ "worktrees",
79
+ ".worktrees",
80
+ ".sst-sandbox-home",
81
+ ".tmp-codex-asar",
64
82
  ];
65
83
 
66
84
  interface Config {
@@ -78,6 +96,7 @@ interface Config {
78
96
  doBackup: boolean;
79
97
  backupRoot: string;
80
98
  backupRetentionDays: string;
99
+ backupRetentionCount: string;
81
100
  }
82
101
 
83
102
  class ExitError extends Error {
@@ -105,6 +124,22 @@ function run(
105
124
  };
106
125
  }
107
126
 
127
+ /**
128
+ * Cygwin's rsync is a POSIX program even when Node gave us a native Win32
129
+ * path. Keep UNC paths in Cygwin's documented `//server/share` form and map
130
+ * drive roots explicitly instead of relying on Cygwin's native-path fallback.
131
+ */
132
+ export function toCygwinRsyncPath(localPath: string): string {
133
+ const slashPath = localPath.replaceAll("\\", "/");
134
+ if (/^\/\/[^/]+\/[^/]+(?:\/|$)/.test(slashPath)) return slashPath;
135
+
136
+ const drivePath = /^([a-z]):\/(.*)$/i.exec(slashPath);
137
+ if (drivePath) {
138
+ return `/cygdrive/${drivePath[1]!.toLowerCase()}/${drivePath[2]}`;
139
+ }
140
+ return localPath;
141
+ }
142
+
108
143
  function redactGitDiagnostic(output: string, ghToken: string): string {
109
144
  let redacted = output;
110
145
  if (ghToken) redacted = redacted.replaceAll(ghToken, "***");
@@ -116,7 +151,18 @@ function redactGitDiagnostic(output: string, ghToken: string): string {
116
151
  /** `command -v <bin>` — is a binary on PATH? */
117
152
  function hasCmd(bin: string, env: NodeJS.ProcessEnv): boolean {
118
153
  const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
119
- return !r.error;
154
+ return !r.error && r.status === 0;
155
+ }
156
+
157
+ /** The overlay requires rsync; do not allocate a recovery copy when it cannot run. */
158
+ function requireWorkingRsync(env: NodeJS.ProcessEnv, err: (s: string) => void): void {
159
+ const probe = run("rsync", ["--version"], { env });
160
+ const output = `${probe.stdout}\n${probe.stderr}`;
161
+ if (probe.status === 0 && /\brsync\b.*\bversion\b/i.test(output)) return;
162
+ err("error: rsync preflight failed before any safety snapshot was allocated.\n");
163
+ err(` resolved PATH: ${env.PATH ?? "(unset)"}\n`);
164
+ err(" Install or repair rsync, then retry the HQ update.\n");
165
+ throw new ExitError(probe.status || 127);
120
166
  }
121
167
 
122
168
  // fs predicates mirroring bash test operators.
@@ -149,6 +195,14 @@ function lstatOrNull(p: string): fs.Stats | null {
149
195
  return null;
150
196
  }
151
197
  }
198
+ function rescuePathKind(p: string): RescuePathKind {
199
+ const st = lstatOrNull(p);
200
+ if (!st) return "missing";
201
+ if (st.isSymbolicLink()) return "symlink";
202
+ if (st.isFile()) return "file";
203
+ if (st.isDirectory()) return "directory";
204
+ return "other";
205
+ }
152
206
  function lexists(p: string): boolean {
153
207
  return lstatOrNull(p) !== null;
154
208
  }
@@ -195,6 +249,7 @@ export function parseRescueArgs(argv: string[]): Config {
195
249
  doBackup: process.env.HQ_RESCUE_NO_BACKUP === "1" ? false : true,
196
250
  backupRoot: process.env.HQ_BACKUP_DIR || path.join(os.homedir(), ".hq", "backups"),
197
251
  backupRetentionDays: process.env.HQ_BACKUP_RETENTION_DAYS || "7",
252
+ backupRetentionCount: process.env.HQ_BACKUP_RETENTION_COUNT || "2",
198
253
  };
199
254
 
200
255
  const usage = (): never => {
@@ -421,6 +476,10 @@ function doRescue(
421
476
  }
422
477
 
423
478
  const RUN_TS = utcStamp(new Date(), "dash");
479
+ // Both the release overlay and a restorable snapshot require rsync. A shim
480
+ // which merely starts but exits non-zero used to trigger an enormous cp -a
481
+ // fallback, then fail later at overlay. Stop before classification/allocation.
482
+ requireWorkingRsync(env, err);
424
483
 
425
484
  // --- Read prior sync-point metadata (BEFORE the wipe) ---
426
485
  let prevSyncSha = "";
@@ -472,7 +531,7 @@ function doRescue(
472
531
  out("==> Mode: preserve-list (default)\n");
473
532
  const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
474
533
  out(
475
- `==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${extra}\n`,
534
+ `==> Preserved: .git, companies (except companies/_template), personal, workspace, repos, worktrees, .worktrees, .sst-sandbox-home, .tmp-codex-asar, .github, .leak-scan, .hq-sync-journal.json, .hq, .hq-conflicts${extra}\n`,
476
535
  );
477
536
  }
478
537
  if (cfg.preserveSubpaths.length !== 0) {
@@ -497,7 +556,7 @@ function doRescue(
497
556
  }
498
557
  if (cfg.doBackup) {
499
558
  out(
500
- `==> Safety backup: ON -> ${cfg.backupRoot}/pre-update-${RUN_TS} (retention ${cfg.backupRetentionDays}d)\n`,
559
+ `==> Safety backup: ON -> ${cfg.backupRoot}/pre-update-${RUN_TS} (retention ${cfg.backupRetentionDays}d / ${cfg.backupRetentionCount} completed)\n`,
501
560
  );
502
561
  } else {
503
562
  out("==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)\n");
@@ -508,7 +567,7 @@ function doRescue(
508
567
  if (!cfg.assumeYes && !cfg.dryRun) {
509
568
  let backupLine = " * NO pre-op backup (--no-backup),";
510
569
  if (cfg.doBackup)
511
- backupLine = ` * snapshot the wipe set to ${cfg.backupRoot}/pre-update-${RUN_TS} first,`;
570
+ backupLine = ` * snapshot the release-managed wipe set (excluding worktrees/build caches) to ${cfg.backupRoot}/pre-update-${RUN_TS} first,`;
512
571
  let cloudLine = "";
513
572
  if (cfg.cloudUpdate)
514
573
  cloudLine =
@@ -661,6 +720,10 @@ function doRescue(
661
720
  "--exclude=.hq-sync-journal.json",
662
721
  "--exclude=.hq",
663
722
  "--exclude=.hq-conflicts",
723
+ "--exclude=/worktrees",
724
+ "--exclude=/.worktrees",
725
+ "--exclude=/.sst-sandbox-home",
726
+ "--exclude=/.tmp-codex-asar",
664
727
  "--include=/companies/",
665
728
  "--include=/companies/_template/***",
666
729
  "--exclude=/companies/*",
@@ -722,36 +785,6 @@ function doRescue(
722
785
  actions: [],
723
786
  };
724
787
 
725
- // --- Pre-operation safety snapshot (BEFORE any destructive op) ---
726
- let backupDir = "";
727
- if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
728
- backupDir = path.join(cfg.backupRoot, `pre-update-${RUN_TS}`);
729
- out("\n");
730
- out(`==> Safety snapshot -> ${backupDir}\n`);
731
- fs.mkdirSync(backupDir, { recursive: true });
732
- const rsyncAvailable = hasCmd("rsync", env);
733
- for (const rootRel of wipeToplevel) {
734
- const srcAbs = path.join(hqRoot, rootRel);
735
- if (!lexists(srcAbs)) continue;
736
- const parentRel = path.dirname(rootRel);
737
- const destParent = path.join(backupDir, parentRel);
738
- fs.mkdirSync(destParent, { recursive: true });
739
- if (rsyncAvailable) {
740
- const r = run(
741
- "rsync",
742
- ["-a", "--exclude=node_modules/", "--exclude=.git/", srcAbs, destParent + "/"],
743
- { env },
744
- );
745
- if (r.status !== 0) cpA(srcAbs, destParent);
746
- } else {
747
- cpA(srcAbs, destParent);
748
- }
749
- }
750
- out(
751
- ` snapshot complete (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`,
752
- );
753
- }
754
-
755
788
  // --- Walk + classify (PHASE 1: read-only) ---
756
789
  // Classification mutates nothing on disk: every outcome is recorded as a
757
790
  // typed RescueDecision. Destructive ops (delete, rescue-move,
@@ -841,8 +874,69 @@ function doRescue(
841
874
  // Actions run in classification (walk) order — the same order the old
842
875
  // interleaved walk mutated in — so per-file output and on-disk results are
843
876
  // unchanged versus before the two-phase split.
877
+ const overlayProtect: string[] = [];
878
+ if (fs.statSync(unchangedList).size > 0) {
879
+ overlayProtect.push(`--exclude-from=${unchangedList}`);
880
+ }
881
+ const overlayArgs = [
882
+ "-a",
883
+ ...overlayProtect,
884
+ ...rsyncExcludes,
885
+ toCygwinRsyncPath(srcDir + "/"),
886
+ toCygwinRsyncPath(hqRoot + "/"),
887
+ ];
888
+ // Exercise the exact overlay command before any classified delete/move. A
889
+ // Cygwin path conversion or filter failure therefore leaves the root intact.
890
+ const overlayPreflight = run("rsync", ["--dry-run", ...overlayArgs], { env });
891
+ if (overlayPreflight.status !== 0) {
892
+ err(overlayPreflight.stderr);
893
+ throw new ExitError(overlayPreflight.status || 1);
894
+ }
844
895
  ctx.actions = buildRescueActions(ctx);
845
- applyClassifiedActions(ctx, backupDir);
896
+
897
+ // Snapshot allocation is deliberately after the entire read-only walk and
898
+ // action-plan build. A no-op (or a classifier fault above) cannot consume
899
+ // backup space.
900
+ let backupDir = "";
901
+ let mutationStarted = false;
902
+ const hasDestructivePaths = ctx.actions.some((action) => action.affectedRels.length > 0);
903
+ if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0 && hasDestructivePaths) {
904
+ const prePlan = pruneManagedBackups(cfg, hqRoot, out, false);
905
+ const unresolved = prePlan.protected.filter((x) => x.reason === "recovery-required-no-later-good");
906
+ if (unresolved.length >= 2) {
907
+ err("error: safety snapshot circuit breaker is open: two unresolved recovery-required snapshots exist.\n");
908
+ err(" Inspect/restore those snapshots and repair the release source before retrying (operator override required).\n");
909
+ throw new ExitError(1);
910
+ }
911
+ requireBackupCapacity(cfg, hqRoot, wipeToplevel, env, err);
912
+ fs.mkdirSync(cfg.backupRoot, { recursive: true });
913
+ const runId = `${process.pid}-${Date.now().toString(36)}`;
914
+ backupDir = allocateSnapshotDir(cfg.backupRoot, RUN_TS, canonicalRootHash(hqRoot), runId);
915
+ out("\n");
916
+ out(`==> Safety snapshot -> ${backupDir}\n`);
917
+ try {
918
+ fs.mkdirSync(backupDir, { recursive: false });
919
+ writeSnapshotManifest(backupDir, snapshotManifest("copying", hqRoot, runId, cfg, srcSha, wipeToplevel));
920
+ for (const rootRel of wipeToplevel) {
921
+ const srcAbs = path.join(hqRoot, rootRel);
922
+ if (!lexists(srcAbs) || isRescueTransientDirectory(rootRel)) continue;
923
+ cpAToFiltered(srcAbs, path.join(backupDir, rootRel), rootRel);
924
+ }
925
+ updateSnapshotStatus(backupDir, "ready");
926
+ } catch (error) {
927
+ updateSnapshotStatus(backupDir, "failed-pre-mutation");
928
+ throw error;
929
+ }
930
+ pruneManagedBackups(cfg, hqRoot, out, true);
931
+ out(
932
+ ` snapshot ready (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`,
933
+ );
934
+ } else if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
935
+ out("\n==> Safety snapshot skipped (classification found no destructive paths).\n");
936
+ }
937
+ try {
938
+ mutationStarted = true;
939
+ applyClassifiedActions(ctx, backupDir);
846
940
 
847
941
  // --- Back up preserve-subpaths to a mktemp shuttle ---
848
942
  const shuttle = path.join(tmpdir, "preserve");
@@ -866,15 +960,22 @@ function doRescue(
866
960
 
867
961
  // --- Overlay source onto HQ root ---
868
962
  out("==> Overlaying source onto HQ root ...\n");
869
- const overlayProtect: string[] = [];
870
- if (fs.statSync(unchangedList).size > 0) {
871
- overlayProtect.push(`--exclude-from=${unchangedList}`);
872
- }
873
- const overlay = run(
874
- "rsync",
875
- ["-a", ...overlayProtect, ...rsyncExcludes, srcDir + "/", hqRoot + "/"],
876
- { env },
877
- );
963
+ // `unchangedList` is only populated during applyClassifiedActions above, so
964
+ // re-derive the --exclude-from protect list HERE. Building overlayArgs before
965
+ // classification left the list empty, so unchanged files were overlaid and
966
+ // their mtimes reset to clone time (rescue-mtime-preserve regression). The
967
+ // early overlayArgs remain correct for the pre-classification dry-run.
968
+ const overlayRunArgs =
969
+ fs.statSync(unchangedList).size > 0
970
+ ? [
971
+ "-a",
972
+ `--exclude-from=${unchangedList}`,
973
+ ...rsyncExcludes,
974
+ toCygwinRsyncPath(srcDir + "/"),
975
+ toCygwinRsyncPath(hqRoot + "/"),
976
+ ]
977
+ : overlayArgs;
978
+ const overlay = run("rsync", overlayRunArgs, { env });
878
979
  if (overlay.status !== 0) {
879
980
  // bash runs under set -e: a failed rsync aborts with its status.
880
981
  err(overlay.stderr);
@@ -1070,20 +1171,26 @@ function doRescue(
1070
1171
  lines.push(" (no files were moved or deleted)");
1071
1172
  }
1072
1173
  lines.push("", "## Restore");
1073
- lines.push("This directory holds the wipe set exactly as it was before the update.");
1174
+ lines.push(
1175
+ "This directory holds the release-managed wipe set as it was before the update; reproducible worktrees and build caches are intentionally excluded.",
1176
+ );
1074
1177
  lines.push("Restore a single file:");
1075
1178
  lines.push(` cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>"`);
1076
1179
  lines.push("Restore everything (overwrites current scaffold — use with care):");
1077
1180
  lines.push(` rsync -a "${backupDir}/" "${hqRoot}/"`);
1078
- lines.push("", `Auto-pruned after ${cfg.backupRetentionDays} days (HQ_BACKUP_RETENTION_DAYS).`);
1181
+ lines.push(
1182
+ "",
1183
+ `Retention: ${cfg.backupRetentionDays} days and ${cfg.backupRetentionCount} completed snapshots (latest recovery floor retained).`,
1184
+ );
1079
1185
  try {
1080
1186
  fs.writeFileSync(path.join(backupDir, "RECOVERY.md"), lines.join("\n") + "\n");
1081
1187
  } catch {
1082
1188
  /* best-effort, matches `|| true` */
1083
1189
  }
1190
+ updateSnapshotStatus(backupDir, "completed-good", RUN_TS);
1084
1191
  out("\n");
1085
1192
  out(`==> Pre-update snapshot + recovery manifest: ${backupDir}\n`);
1086
- pruneOldBackups(cfg, out);
1193
+ pruneManagedBackups(cfg, hqRoot, out, true);
1087
1194
  }
1088
1195
 
1089
1196
  out("\n");
@@ -1091,10 +1198,19 @@ function doRescue(
1091
1198
  out(" User-edited files were rescued under personal/ (see scan output above).\n");
1092
1199
  out(" User-only files (created by you, unknown to upstream) were left untouched.\n");
1093
1200
  if (cfg.doBackup && backupDir) {
1094
- out(` A full pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`);
1201
+ out(
1202
+ ` A filtered pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`,
1203
+ );
1095
1204
  }
1096
1205
 
1097
- return { status: 0 };
1206
+ return { status: 0 };
1207
+ } catch (error) {
1208
+ if (backupDir && isDir(backupDir)) {
1209
+ updateSnapshotStatus(backupDir, mutationStarted ? "recovery-required" : "failed-pre-mutation");
1210
+ pruneManagedBackups(cfg, hqRoot, out, true);
1211
+ }
1212
+ throw error;
1213
+ }
1098
1214
  }
1099
1215
 
1100
1216
  interface WalkCtx {
@@ -1149,6 +1265,7 @@ interface WalkCtx {
1149
1265
  interface RescueAction {
1150
1266
  label: string;
1151
1267
  affectedRels: string[];
1268
+ expectedPaths: { rel: string; kind: RescuePathKind }[];
1152
1269
  run: () => void;
1153
1270
  }
1154
1271
 
@@ -1195,8 +1312,16 @@ function recordDecision(ctx: WalkCtx, decision: RescueDecision): void {
1195
1312
 
1196
1313
  function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
1197
1314
  const completed: RescueAction[] = [];
1315
+ injectApplyPathChangeForTest(ctx);
1198
1316
  for (const action of ctx.actions) {
1199
1317
  try {
1318
+ for (const expected of action.expectedPaths) {
1319
+ assertRescuePathTypeUnchanged(
1320
+ expected.rel,
1321
+ expected.kind,
1322
+ rescuePathKind(path.join(ctx.hqRoot, expected.rel)),
1323
+ );
1324
+ }
1200
1325
  action.run();
1201
1326
  completed.push(action);
1202
1327
  } catch (err) {
@@ -1222,6 +1347,20 @@ function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
1222
1347
  }
1223
1348
  }
1224
1349
 
1350
+ /** Deterministic seam for the real reindex-vs-rescue race; never set in production. */
1351
+ function injectApplyPathChangeForTest(ctx: WalkCtx): void {
1352
+ const rel = ctx.env.HQ_RESCUE_FAULT_BEFORE_APPLY_REL;
1353
+ const kind = ctx.env.HQ_RESCUE_FAULT_BEFORE_APPLY_KIND;
1354
+ if (!rel || !kind) return;
1355
+ const abs = path.join(ctx.hqRoot, rel);
1356
+ fs.rmSync(abs, { recursive: true, force: true });
1357
+ if (kind === "directory") fs.mkdirSync(abs, { recursive: true });
1358
+ else if (kind === "file") {
1359
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
1360
+ fs.writeFileSync(abs, "fault-injected path replacement\n");
1361
+ }
1362
+ }
1363
+
1225
1364
  function rollbackCompletedActions(
1226
1365
  ctx: WalkCtx,
1227
1366
  completed: RescueAction[],
@@ -1800,7 +1939,15 @@ function renderDryRunDecision(ctx: WalkCtx, decision: RescueDecision): void {
1800
1939
  function buildRescueActions(ctx: WalkCtx): RescueAction[] {
1801
1940
  const actions: RescueAction[] = [];
1802
1941
  const add = (label: string, affectedRels: string[], runAction: () => void) => {
1803
- actions.push({ label, affectedRels, run: runAction });
1942
+ actions.push({
1943
+ label,
1944
+ affectedRels,
1945
+ expectedPaths: affectedRels.map((rel) => ({
1946
+ rel,
1947
+ kind: rescuePathKind(path.join(ctx.hqRoot, rel)),
1948
+ })),
1949
+ run: runAction,
1950
+ });
1804
1951
  };
1805
1952
 
1806
1953
  for (const decision of ctx.decisions) {
@@ -2026,17 +2173,16 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
2026
2173
  return;
2027
2174
  }
2028
2175
 
2029
- // Top-level directory: recursive walk, pruning node_modules + nested .git.
2176
+ // Top-level directory: recursive walk, pruning canonical transient state.
2030
2177
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
2031
2178
  recordDecision(ctx, classifyOne(ctx, rel));
2032
2179
  }
2033
2180
  }
2034
2181
 
2035
2182
  /**
2036
- * Mirror of `find "$root" \( -type d \( -name node_modules -o -name .git \)
2037
- * -prune \) -o \( \( -type f -o -type l \) -print0 \)`: yield repo-relative
2038
- * paths of regular files and symlinks, pruning node_modules/.git dirs and not
2039
- * descending into directory symlinks (find -P default). Deterministic order.
2183
+ * Yield repo-relative regular files and symlinks while pruning canonical
2184
+ * transient directories and never descending into directory symlinks. Order is
2185
+ * deterministic so classification and apply plans remain reproducible.
2040
2186
  */
2041
2187
  function findFilesAndSymlinks(rootAbs: string, hqRoot: string): string[] {
2042
2188
  const found: string[] = [];
@@ -2063,13 +2209,8 @@ function findFilesAndSymlinks(rootAbs: string, hqRoot: string): string[] {
2063
2209
  // - .claude/worktrees: Claude Code per-agent worktree sandboxes —
2064
2210
  // each holds a full nested HQ checkout, so not pruning them means
2065
2211
  // walking 100k+ throwaway files. The sync engine ignores them too.
2066
- if (
2067
- ent.name === "node_modules" ||
2068
- ent.name === ".git" ||
2069
- ent.name === ".pnpm-store" ||
2070
- (ent.name === "worktrees" && dir.endsWith("/.claude"))
2071
- )
2072
- continue; // prune
2212
+ const rel = abs.slice(hqRoot.length + 1);
2213
+ if (isRescueTransientDirectory(rel)) continue;
2073
2214
  walk(abs);
2074
2215
  continue;
2075
2216
  }
@@ -2131,34 +2272,82 @@ function restoreMtimesFromGit(
2131
2272
  }
2132
2273
  }
2133
2274
 
2134
- // --- prune old pre-update-* snapshots past retention ---
2135
- function pruneOldBackups(cfg: Config, out: (s: string) => void): void {
2136
- if (!isDir(cfg.backupRoot)) return;
2137
- if (!/^[0-9]+$/.test(cfg.backupRetentionDays)) return;
2138
- const days = Number(cfg.backupRetentionDays);
2139
- if (!(days > 0)) return;
2140
- // `find -mtime +N`: strictly older than (N+1)*24h by mtime.
2141
- const cutoffMs = Date.now() - (days + 1) * 24 * 60 * 60 * 1000;
2142
- let entries: string[] = [];
2275
+ function allocateSnapshotDir(backupRoot: string, runTs: string, rootHash: string, runId: string): string {
2276
+ return path.join(backupRoot, `pre-update-${runTs}-${rootHash}-${runId}`);
2277
+ }
2278
+
2279
+ function snapshotManifest(
2280
+ status: SnapshotManifest["status"], hqRoot: string, runId: string, cfg: Config,
2281
+ sourceSha: string, includedRoots: string[],
2282
+ ): SnapshotManifest {
2283
+ const now = new Date().toISOString();
2284
+ return { schemaVersion: 1, status, canonicalHqRoot: hqRoot, rootHash: canonicalRootHash(hqRoot), runId,
2285
+ creatorPid: process.pid, creatorStartedAt: now, source: cfg.sourceRepo, ref: cfg.ref, sourceSha,
2286
+ createdAt: now, includedRoots, fileCount: 0, byteCount: 0 };
2287
+ }
2288
+
2289
+ function readSnapshotManifest(backupDir: string): SnapshotManifest | null {
2290
+ try { return JSON.parse(fs.readFileSync(path.join(backupDir, "SNAPSHOT.json"), "utf8")) as SnapshotManifest; }
2291
+ catch { return null; }
2292
+ }
2293
+
2294
+ /** Publish only complete JSON; a crash can leave a temp file but never a torn manifest. */
2295
+ function writeSnapshotManifest(backupDir: string, manifest: SnapshotManifest): void {
2296
+ const target = path.join(backupDir, "SNAPSHOT.json");
2297
+ const temp = `${target}.${process.pid}.tmp`;
2298
+ fs.writeFileSync(temp, JSON.stringify(manifest, null, 2) + "\n");
2299
+ fs.renameSync(temp, target);
2300
+ }
2301
+
2302
+ function updateSnapshotStatus(backupDir: string, status: SnapshotManifest["status"], completedAt?: string): void {
2303
+ const manifest = readSnapshotManifest(backupDir);
2304
+ if (!manifest) return;
2305
+ const stats = estimateFiltered(backupDir, "");
2306
+ writeSnapshotManifest(backupDir, { ...manifest, status, fileCount: stats.files, byteCount: stats.bytes,
2307
+ ...(completedAt ? { completedAt: new Date().toISOString() } : {}) });
2308
+ }
2309
+
2310
+ function pruneManagedBackups(cfg: Config, hqRoot: string, out: (s: string) => void, apply: boolean) {
2311
+ const inventory = inventoryManagedSnapshots(cfg.backupRoot, hqRoot, (m) => {
2312
+ try { process.kill(m.creatorPid, 0); return true; } catch { return false; }
2313
+ }, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
2314
+ const plan = { candidates: inventory.candidates, protected: inventory.protected, unmanaged: inventory.unmanaged };
2315
+ out(`==> Backup retention dry-run: ${plan.candidates.length} candidate(s), ${plan.protected.length} protected, ${plan.unmanaged.length} unmanaged.\n`);
2316
+ for (const item of plan.candidates) out(` candidate ${item.entry.name}: ${item.reason}\n`);
2317
+ for (const item of plan.protected) out(` protected ${item.entry.name}: ${item.reason}\n`);
2318
+ if (apply && plan.candidates.length > 0) {
2319
+ const result = applyBackupPrune(cfg.backupRoot, hqRoot, plan, { keepGood: Number(cfg.backupRetentionCount), maxAgeDays: Number(cfg.backupRetentionDays) });
2320
+ for (const name of result.deleted) out(` deleted snapshot: ${name}\n`);
2321
+ for (const failure of result.failures) out(` retention delete failed (retained): ${failure}\n`);
2322
+ }
2323
+ return plan;
2324
+ }
2325
+
2326
+ function estimateFiltered(abs: string, rel: string): { files: number; bytes: number } {
2327
+ const st = lstatOrNull(abs);
2328
+ if (!st || (st.isDirectory() && rel && isRescueTransientDirectory(rel))) return { files: 0, bytes: 0 };
2329
+ if (st.isSymbolicLink()) return { files: 1, bytes: Buffer.byteLength(fs.readlinkSync(abs)) };
2330
+ if (st.isFile()) return { files: 1, bytes: st.size };
2331
+ if (!st.isDirectory()) return { files: 0, bytes: 0 };
2332
+ let files = 0, bytes = 0;
2333
+ for (const name of fs.readdirSync(abs)) { const x = estimateFiltered(path.join(abs, name), path.posix.join(rel, name)); files += x.files; bytes += x.bytes; }
2334
+ return { files, bytes };
2335
+ }
2336
+
2337
+ function requireBackupCapacity(cfg: Config, hqRoot: string, roots: string[], _env: NodeJS.ProcessEnv, err: (s: string) => void): void {
2143
2338
  try {
2144
- entries = fs.readdirSync(cfg.backupRoot);
2145
- } catch {
2146
- return;
2147
- }
2148
- for (const name of entries) {
2149
- if (!name.startsWith("pre-update-")) continue;
2150
- const abs = path.join(cfg.backupRoot, name);
2151
- const st = lstatOrNull(abs);
2152
- if (!st || !st.isDirectory()) continue;
2153
- if (st.mtimeMs < cutoffMs) {
2154
- out(` pruned old snapshot (> ${cfg.backupRetentionDays}d): ${name}\n`);
2155
- try {
2156
- fs.rmSync(abs, { recursive: true, force: true });
2157
- } catch {
2158
- /* best-effort */
2159
- }
2160
- }
2161
- }
2339
+ const estimate = roots.reduce((total, root) => total + estimateFiltered(path.join(hqRoot, root), root).bytes, 0);
2340
+ const stat = fs.statfsSync(isDir(cfg.backupRoot) ? cfg.backupRoot : path.dirname(cfg.backupRoot));
2341
+ const free = Number(stat.bavail) * Number(stat.bsize);
2342
+ const capacity = Number(stat.blocks) * Number(stat.bsize);
2343
+ const required = estimate + Math.max(5 * 1024 ** 3, Math.ceil(capacity * 0.1));
2344
+ if (free >= required) return;
2345
+ err(`error: insufficient free space for safety snapshot (need ${required} bytes, have ${free}).\n`);
2346
+ } catch (error) {
2347
+ err(`error: could not measure snapshot size/free space (${error instanceof Error ? error.message : String(error)}).\n`);
2348
+ }
2349
+ if (cfg.cloudUpdate) throw new ExitError(1);
2350
+ err(" Continuing only because this is an interactive rescue; use --cloud-update to fail closed.\n");
2162
2351
  }
2163
2352
 
2164
2353
  // --- recursive file count (`find <dir> -type f | wc -l`) ---
@@ -2232,6 +2421,39 @@ function cpATo(src: string, dest: string): void {
2232
2421
  }
2233
2422
  }
2234
2423
 
2424
+ /** Snapshot copy that preserves metadata while pruning canonical transient directories. */
2425
+ function cpAToFiltered(src: string, dest: string, rel: string): void {
2426
+ const st = lstatOrNull(src);
2427
+ if (!st) return;
2428
+ if (st.isDirectory() && isRescueTransientDirectory(rel)) return;
2429
+ if (st.isSymbolicLink()) {
2430
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2431
+ fs.symlinkSync(fs.readlinkSync(src), dest);
2432
+ return;
2433
+ }
2434
+ if (st.isDirectory()) {
2435
+ fs.mkdirSync(dest, { recursive: true });
2436
+ for (const name of fs.readdirSync(src)) {
2437
+ cpAToFiltered(path.join(src, name), path.join(dest, name), path.posix.join(rel, name));
2438
+ }
2439
+ try {
2440
+ fs.chmodSync(dest, st.mode);
2441
+ fs.utimesSync(dest, st.atime, st.mtime);
2442
+ } catch {
2443
+ /* metadata preservation is best-effort */
2444
+ }
2445
+ return;
2446
+ }
2447
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2448
+ fs.copyFileSync(src, dest);
2449
+ try {
2450
+ fs.chmodSync(dest, st.mode);
2451
+ fs.utimesSync(dest, st.atime, st.mtime);
2452
+ } catch {
2453
+ /* metadata preservation is best-effort */
2454
+ }
2455
+ }
2456
+
2235
2457
  /** Synchronous single-line read from stdin (mirrors bash `read -r`). */
2236
2458
  function readLineSync(): string {
2237
2459
  const fd = 0;
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ RescuePathChangedError,
4
+ assertRescuePathTypeUnchanged,
5
+ isRescueTransientDirectory,
6
+ } from "./rescue-snapshot.js";
7
+
8
+ describe("rescue transient path policy", () => {
9
+ it.each([
10
+ "worktrees",
11
+ ".worktrees",
12
+ ".claude/worktrees",
13
+ ".claude/worktrees/agent/repo",
14
+ ".sst-sandbox-home",
15
+ ".tmp-codex-asar",
16
+ ".claude/skills/demo/node_modules",
17
+ ".claude/skills/demo/.pnpm-store",
18
+ ".claude/skills/demo/.sst",
19
+ ".claude/skills/demo/.next",
20
+ ".claude/skills/demo/target",
21
+ ])("excludes reproducible directory %s", (rel) => {
22
+ expect(isRescueTransientDirectory(rel)).toBe(true);
23
+ });
24
+
25
+ it.each([
26
+ "core/worktrees.md",
27
+ "core/target-audience",
28
+ "companies/acme/knowledge/.next-steps",
29
+ "core/packages",
30
+ ])("does not overmatch meaningful path %s", (rel) => {
31
+ expect(isRescueTransientDirectory(rel)).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe("rescue apply path-type revalidation", () => {
36
+ it("accepts an unchanged path type", () => {
37
+ expect(() => assertRescuePathTypeUnchanged("core/a.md", "file", "file")).not.toThrow();
38
+ });
39
+
40
+ it("raises a typed state-change error before a classified symlink is treated as a directory", () => {
41
+ let thrown: unknown;
42
+ try {
43
+ assertRescuePathTypeUnchanged(
44
+ ".claude/skills/personal:foreman/tests",
45
+ "symlink",
46
+ "directory",
47
+ );
48
+ } catch (error) {
49
+ thrown = error;
50
+ }
51
+
52
+ expect(thrown).toBeInstanceOf(RescuePathChangedError);
53
+ expect(String(thrown)).toContain("changed after classification");
54
+ expect(String(thrown)).toContain("expected symlink, found directory");
55
+ expect(String(thrown)).not.toContain("EISDIR");
56
+ });
57
+ });