@indigoai-us/hq-cloud 6.14.7 → 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 (69) 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/rescue-classify-ordering.test.js +105 -0
  20. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  21. package/dist/cli/rescue-core.d.ts +1 -0
  22. package/dist/cli/rescue-core.d.ts.map +1 -1
  23. package/dist/cli/rescue-core.js +478 -300
  24. package/dist/cli/rescue-core.js.map +1 -1
  25. package/dist/cli/rescue-snapshot.d.ts +14 -0
  26. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  27. package/dist/cli/rescue-snapshot.js +39 -0
  28. package/dist/cli/rescue-snapshot.js.map +1 -0
  29. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  30. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  31. package/dist/cli/rescue-snapshot.test.js +46 -0
  32. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  33. package/dist/cli/sync.d.ts.map +1 -1
  34. package/dist/cli/sync.js +13 -0
  35. package/dist/cli/sync.js.map +1 -1
  36. package/dist/cli/sync.test.js +51 -0
  37. package/dist/cli/sync.test.js.map +1 -1
  38. package/dist/object-io.d.ts +19 -2
  39. package/dist/object-io.d.ts.map +1 -1
  40. package/dist/object-io.js +168 -32
  41. package/dist/object-io.js.map +1 -1
  42. package/dist/object-io.test.js +157 -1
  43. package/dist/object-io.test.js.map +1 -1
  44. package/dist/signals/get.test.js +21 -1
  45. package/dist/signals/get.test.js.map +1 -1
  46. package/dist/signals/list.test.js +21 -1
  47. package/dist/signals/list.test.js.map +1 -1
  48. package/dist/sources/get.test.js +21 -1
  49. package/dist/sources/get.test.js.map +1 -1
  50. package/dist/sources/list.test.js +21 -1
  51. package/dist/sources/list.test.js.map +1 -1
  52. package/package.json +3 -2
  53. package/src/backup-prune.test.ts +98 -0
  54. package/src/backup-prune.ts +182 -0
  55. package/src/bin/backup-prune-runner.ts +33 -0
  56. package/src/bin/sync-runner-watch-loop.ts +18 -0
  57. package/src/bin/sync-runner.ts +2 -0
  58. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  59. package/src/cli/rescue-core.ts +261 -86
  60. package/src/cli/rescue-snapshot.test.ts +57 -0
  61. package/src/cli/rescue-snapshot.ts +51 -0
  62. package/src/cli/sync.test.ts +62 -0
  63. package/src/cli/sync.ts +18 -0
  64. package/src/object-io.test.ts +175 -0
  65. package/src/object-io.ts +213 -32
  66. package/src/signals/get.test.ts +26 -2
  67. package/src/signals/list.test.ts +26 -2
  68. package/src/sources/get.test.ts +26 -2
  69. 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 {
@@ -132,7 +151,18 @@ function redactGitDiagnostic(output: string, ghToken: string): string {
132
151
  /** `command -v <bin>` — is a binary on PATH? */
133
152
  function hasCmd(bin: string, env: NodeJS.ProcessEnv): boolean {
134
153
  const r = spawnSync(bin, ["--version"], { env, stdio: "ignore" });
135
- 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);
136
166
  }
137
167
 
138
168
  // fs predicates mirroring bash test operators.
@@ -165,6 +195,14 @@ function lstatOrNull(p: string): fs.Stats | null {
165
195
  return null;
166
196
  }
167
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
+ }
168
206
  function lexists(p: string): boolean {
169
207
  return lstatOrNull(p) !== null;
170
208
  }
@@ -211,6 +249,7 @@ export function parseRescueArgs(argv: string[]): Config {
211
249
  doBackup: process.env.HQ_RESCUE_NO_BACKUP === "1" ? false : true,
212
250
  backupRoot: process.env.HQ_BACKUP_DIR || path.join(os.homedir(), ".hq", "backups"),
213
251
  backupRetentionDays: process.env.HQ_BACKUP_RETENTION_DAYS || "7",
252
+ backupRetentionCount: process.env.HQ_BACKUP_RETENTION_COUNT || "2",
214
253
  };
215
254
 
216
255
  const usage = (): never => {
@@ -437,6 +476,10 @@ function doRescue(
437
476
  }
438
477
 
439
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);
440
483
 
441
484
  // --- Read prior sync-point metadata (BEFORE the wipe) ---
442
485
  let prevSyncSha = "";
@@ -488,7 +531,7 @@ function doRescue(
488
531
  out("==> Mode: preserve-list (default)\n");
489
532
  const extra = cfg.extraPreserve.length ? `, ${cfg.extraPreserve.join(" ")}` : "";
490
533
  out(
491
- `==> 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`,
492
535
  );
493
536
  }
494
537
  if (cfg.preserveSubpaths.length !== 0) {
@@ -513,7 +556,7 @@ function doRescue(
513
556
  }
514
557
  if (cfg.doBackup) {
515
558
  out(
516
- `==> 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`,
517
560
  );
518
561
  } else {
519
562
  out("==> Safety backup: OFF (--no-backup / HQ_RESCUE_NO_BACKUP=1)\n");
@@ -524,7 +567,7 @@ function doRescue(
524
567
  if (!cfg.assumeYes && !cfg.dryRun) {
525
568
  let backupLine = " * NO pre-op backup (--no-backup),";
526
569
  if (cfg.doBackup)
527
- 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,`;
528
571
  let cloudLine = "";
529
572
  if (cfg.cloudUpdate)
530
573
  cloudLine =
@@ -677,6 +720,10 @@ function doRescue(
677
720
  "--exclude=.hq-sync-journal.json",
678
721
  "--exclude=.hq",
679
722
  "--exclude=.hq-conflicts",
723
+ "--exclude=/worktrees",
724
+ "--exclude=/.worktrees",
725
+ "--exclude=/.sst-sandbox-home",
726
+ "--exclude=/.tmp-codex-asar",
680
727
  "--include=/companies/",
681
728
  "--include=/companies/_template/***",
682
729
  "--exclude=/companies/*",
@@ -738,42 +785,6 @@ function doRescue(
738
785
  actions: [],
739
786
  };
740
787
 
741
- // --- Pre-operation safety snapshot (BEFORE any destructive op) ---
742
- let backupDir = "";
743
- if (cfg.doBackup && !cfg.dryRun && wipeToplevel.length !== 0) {
744
- backupDir = path.join(cfg.backupRoot, `pre-update-${RUN_TS}`);
745
- out("\n");
746
- out(`==> Safety snapshot -> ${backupDir}\n`);
747
- fs.mkdirSync(backupDir, { recursive: true });
748
- const rsyncAvailable = hasCmd("rsync", env);
749
- for (const rootRel of wipeToplevel) {
750
- const srcAbs = path.join(hqRoot, rootRel);
751
- if (!lexists(srcAbs)) continue;
752
- const parentRel = path.dirname(rootRel);
753
- const destParent = path.join(backupDir, parentRel);
754
- fs.mkdirSync(destParent, { recursive: true });
755
- if (rsyncAvailable) {
756
- const r = run(
757
- "rsync",
758
- [
759
- "-a",
760
- "--exclude=node_modules/",
761
- "--exclude=.git/",
762
- toCygwinRsyncPath(srcAbs),
763
- toCygwinRsyncPath(destParent + "/"),
764
- ],
765
- { env },
766
- );
767
- if (r.status !== 0) cpA(srcAbs, destParent);
768
- } else {
769
- cpA(srcAbs, destParent);
770
- }
771
- }
772
- out(
773
- ` snapshot complete (restore any file: cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>")\n`,
774
- );
775
- }
776
-
777
788
  // --- Walk + classify (PHASE 1: read-only) ---
778
789
  // Classification mutates nothing on disk: every outcome is recorded as a
779
790
  // typed RescueDecision. Destructive ops (delete, rescue-move,
@@ -882,7 +893,50 @@ function doRescue(
882
893
  throw new ExitError(overlayPreflight.status || 1);
883
894
  }
884
895
  ctx.actions = buildRescueActions(ctx);
885
- 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);
886
940
 
887
941
  // --- Back up preserve-subpaths to a mktemp shuttle ---
888
942
  const shuttle = path.join(tmpdir, "preserve");
@@ -1117,20 +1171,26 @@ function doRescue(
1117
1171
  lines.push(" (no files were moved or deleted)");
1118
1172
  }
1119
1173
  lines.push("", "## Restore");
1120
- 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
+ );
1121
1177
  lines.push("Restore a single file:");
1122
1178
  lines.push(` cp "${backupDir}/<relpath>" "${hqRoot}/<relpath>"`);
1123
1179
  lines.push("Restore everything (overwrites current scaffold — use with care):");
1124
1180
  lines.push(` rsync -a "${backupDir}/" "${hqRoot}/"`);
1125
- 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
+ );
1126
1185
  try {
1127
1186
  fs.writeFileSync(path.join(backupDir, "RECOVERY.md"), lines.join("\n") + "\n");
1128
1187
  } catch {
1129
1188
  /* best-effort, matches `|| true` */
1130
1189
  }
1190
+ updateSnapshotStatus(backupDir, "completed-good", RUN_TS);
1131
1191
  out("\n");
1132
1192
  out(`==> Pre-update snapshot + recovery manifest: ${backupDir}\n`);
1133
- pruneOldBackups(cfg, out);
1193
+ pruneManagedBackups(cfg, hqRoot, out, true);
1134
1194
  }
1135
1195
 
1136
1196
  out("\n");
@@ -1138,10 +1198,19 @@ function doRescue(
1138
1198
  out(" User-edited files were rescued under personal/ (see scan output above).\n");
1139
1199
  out(" User-only files (created by you, unknown to upstream) were left untouched.\n");
1140
1200
  if (cfg.doBackup && backupDir) {
1141
- 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
+ );
1142
1204
  }
1143
1205
 
1144
- 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
+ }
1145
1214
  }
1146
1215
 
1147
1216
  interface WalkCtx {
@@ -1196,6 +1265,7 @@ interface WalkCtx {
1196
1265
  interface RescueAction {
1197
1266
  label: string;
1198
1267
  affectedRels: string[];
1268
+ expectedPaths: { rel: string; kind: RescuePathKind }[];
1199
1269
  run: () => void;
1200
1270
  }
1201
1271
 
@@ -1242,8 +1312,16 @@ function recordDecision(ctx: WalkCtx, decision: RescueDecision): void {
1242
1312
 
1243
1313
  function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
1244
1314
  const completed: RescueAction[] = [];
1315
+ injectApplyPathChangeForTest(ctx);
1245
1316
  for (const action of ctx.actions) {
1246
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
+ }
1247
1325
  action.run();
1248
1326
  completed.push(action);
1249
1327
  } catch (err) {
@@ -1269,6 +1347,20 @@ function applyClassifiedActions(ctx: WalkCtx, backupDir: string): void {
1269
1347
  }
1270
1348
  }
1271
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
+
1272
1364
  function rollbackCompletedActions(
1273
1365
  ctx: WalkCtx,
1274
1366
  completed: RescueAction[],
@@ -1847,7 +1939,15 @@ function renderDryRunDecision(ctx: WalkCtx, decision: RescueDecision): void {
1847
1939
  function buildRescueActions(ctx: WalkCtx): RescueAction[] {
1848
1940
  const actions: RescueAction[] = [];
1849
1941
  const add = (label: string, affectedRels: string[], runAction: () => void) => {
1850
- 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
+ });
1851
1951
  };
1852
1952
 
1853
1953
  for (const decision of ctx.decisions) {
@@ -2073,17 +2173,16 @@ function walkAndProcess(ctx: WalkCtx, rootRel: string): void {
2073
2173
  return;
2074
2174
  }
2075
2175
 
2076
- // Top-level directory: recursive walk, pruning node_modules + nested .git.
2176
+ // Top-level directory: recursive walk, pruning canonical transient state.
2077
2177
  for (const rel of findFilesAndSymlinks(rootAbs, ctx.hqRoot)) {
2078
2178
  recordDecision(ctx, classifyOne(ctx, rel));
2079
2179
  }
2080
2180
  }
2081
2181
 
2082
2182
  /**
2083
- * Mirror of `find "$root" \( -type d \( -name node_modules -o -name .git \)
2084
- * -prune \) -o \( \( -type f -o -type l \) -print0 \)`: yield repo-relative
2085
- * paths of regular files and symlinks, pruning node_modules/.git dirs and not
2086
- * 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.
2087
2186
  */
2088
2187
  function findFilesAndSymlinks(rootAbs: string, hqRoot: string): string[] {
2089
2188
  const found: string[] = [];
@@ -2110,13 +2209,8 @@ function findFilesAndSymlinks(rootAbs: string, hqRoot: string): string[] {
2110
2209
  // - .claude/worktrees: Claude Code per-agent worktree sandboxes —
2111
2210
  // each holds a full nested HQ checkout, so not pruning them means
2112
2211
  // walking 100k+ throwaway files. The sync engine ignores them too.
2113
- if (
2114
- ent.name === "node_modules" ||
2115
- ent.name === ".git" ||
2116
- ent.name === ".pnpm-store" ||
2117
- (ent.name === "worktrees" && dir.endsWith("/.claude"))
2118
- )
2119
- continue; // prune
2212
+ const rel = abs.slice(hqRoot.length + 1);
2213
+ if (isRescueTransientDirectory(rel)) continue;
2120
2214
  walk(abs);
2121
2215
  continue;
2122
2216
  }
@@ -2178,34 +2272,82 @@ function restoreMtimesFromGit(
2178
2272
  }
2179
2273
  }
2180
2274
 
2181
- // --- prune old pre-update-* snapshots past retention ---
2182
- function pruneOldBackups(cfg: Config, out: (s: string) => void): void {
2183
- if (!isDir(cfg.backupRoot)) return;
2184
- if (!/^[0-9]+$/.test(cfg.backupRetentionDays)) return;
2185
- const days = Number(cfg.backupRetentionDays);
2186
- if (!(days > 0)) return;
2187
- // `find -mtime +N`: strictly older than (N+1)*24h by mtime.
2188
- const cutoffMs = Date.now() - (days + 1) * 24 * 60 * 60 * 1000;
2189
- 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 {
2190
2338
  try {
2191
- entries = fs.readdirSync(cfg.backupRoot);
2192
- } catch {
2193
- return;
2194
- }
2195
- for (const name of entries) {
2196
- if (!name.startsWith("pre-update-")) continue;
2197
- const abs = path.join(cfg.backupRoot, name);
2198
- const st = lstatOrNull(abs);
2199
- if (!st || !st.isDirectory()) continue;
2200
- if (st.mtimeMs < cutoffMs) {
2201
- out(` pruned old snapshot (> ${cfg.backupRetentionDays}d): ${name}\n`);
2202
- try {
2203
- fs.rmSync(abs, { recursive: true, force: true });
2204
- } catch {
2205
- /* best-effort */
2206
- }
2207
- }
2208
- }
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");
2209
2351
  }
2210
2352
 
2211
2353
  // --- recursive file count (`find <dir> -type f | wc -l`) ---
@@ -2279,6 +2421,39 @@ function cpATo(src: string, dest: string): void {
2279
2421
  }
2280
2422
  }
2281
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
+
2282
2457
  /** Synchronous single-line read from stdin (mirrors bash `read -r`). */
2283
2458
  function readLineSync(): string {
2284
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
+ });
@@ -0,0 +1,51 @@
1
+ /** Filesystem shape captured during rescue classification. */
2
+ export type RescuePathKind = "missing" | "file" | "directory" | "symlink" | "other";
3
+
4
+
5
+ /** Raised when a path changes shape between read-only classification and apply. */
6
+ export class RescuePathChangedError extends Error {
7
+ constructor(
8
+ public readonly rel: string,
9
+ public readonly expected: RescuePathKind,
10
+ public readonly actual: RescuePathKind,
11
+ ) {
12
+ super(
13
+ `rescue path changed after classification: ${rel} (expected ${expected}, found ${actual}); ` +
14
+ "another process may be rebuilding generated HQ paths, so rescue stopped before mutation",
15
+ );
16
+ this.name = "RescuePathChangedError";
17
+ }
18
+ }
19
+
20
+ /** Return whether a directory is reproducible state that rescue must not snapshot or classify. */
21
+ export function isRescueTransientDirectory(rel: string): boolean {
22
+ const normalized = rel.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
23
+ if (
24
+ normalized === "worktrees" ||
25
+ normalized.startsWith("worktrees/") ||
26
+ normalized === ".worktrees" ||
27
+ normalized.startsWith(".worktrees/") ||
28
+ normalized === ".sst-sandbox-home" ||
29
+ normalized.startsWith(".sst-sandbox-home/") ||
30
+ normalized === ".tmp-codex-asar" ||
31
+ normalized.startsWith(".tmp-codex-asar/") ||
32
+ normalized === ".claude/worktrees" ||
33
+ normalized.startsWith(".claude/worktrees/")
34
+ ) {
35
+ return true;
36
+ }
37
+ return normalized
38
+ .split("/")
39
+ .some((part) =>
40
+ ["node_modules", ".git", ".pnpm-store", ".sst", ".next", "target"].includes(part),
41
+ );
42
+ }
43
+
44
+ /** Fail safely when apply observes a different path shape than classification did. */
45
+ export function assertRescuePathTypeUnchanged(
46
+ rel: string,
47
+ expected: RescuePathKind,
48
+ actual: RescuePathKind,
49
+ ): void {
50
+ if (expected !== actual) throw new RescuePathChangedError(rel, expected, actual);
51
+ }