@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.
- package/dist/backup-prune.d.ts +68 -0
- package/dist/backup-prune.d.ts.map +1 -0
- package/dist/backup-prune.js +196 -0
- package/dist/backup-prune.js.map +1 -0
- package/dist/backup-prune.test.d.ts +2 -0
- package/dist/backup-prune.test.d.ts.map +1 -0
- package/dist/backup-prune.test.js +89 -0
- package/dist/backup-prune.test.js.map +1 -0
- package/dist/bin/backup-prune-runner.d.ts +3 -0
- package/dist/bin/backup-prune-runner.d.ts.map +1 -0
- package/dist/bin/backup-prune-runner.js +50 -0
- package/dist/bin/backup-prune-runner.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +28 -0
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +105 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.d.ts +1 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +478 -300
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-snapshot.d.ts +14 -0
- package/dist/cli/rescue-snapshot.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.js +39 -0
- package/dist/cli/rescue-snapshot.js.map +1 -0
- package/dist/cli/rescue-snapshot.test.d.ts +2 -0
- package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
- package/dist/cli/rescue-snapshot.test.js +46 -0
- package/dist/cli/rescue-snapshot.test.js.map +1 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +13 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +51 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/object-io.d.ts +19 -2
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +168 -32
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +157 -1
- package/dist/object-io.test.js.map +1 -1
- package/dist/signals/get.test.js +21 -1
- package/dist/signals/get.test.js.map +1 -1
- package/dist/signals/list.test.js +21 -1
- package/dist/signals/list.test.js.map +1 -1
- package/dist/sources/get.test.js +21 -1
- package/dist/sources/get.test.js.map +1 -1
- package/dist/sources/list.test.js +21 -1
- package/dist/sources/list.test.js.map +1 -1
- package/package.json +3 -2
- package/src/backup-prune.test.ts +98 -0
- package/src/backup-prune.ts +182 -0
- package/src/bin/backup-prune-runner.ts +33 -0
- package/src/bin/sync-runner-watch-loop.ts +18 -0
- package/src/bin/sync-runner.ts +2 -0
- package/src/cli/rescue-classify-ordering.test.ts +121 -0
- package/src/cli/rescue-core.ts +261 -86
- package/src/cli/rescue-snapshot.test.ts +57 -0
- package/src/cli/rescue-snapshot.ts +51 -0
- package/src/cli/sync.test.ts +62 -0
- package/src/cli/sync.ts +18 -0
- package/src/object-io.test.ts +175 -0
- package/src/object-io.ts +213 -32
- package/src/signals/get.test.ts +26 -2
- package/src/signals/list.test.ts +26 -2
- package/src/sources/get.test.ts +26 -2
- package/src/sources/list.test.ts +26 -2
package/src/cli/rescue-core.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1201
|
+
out(
|
|
1202
|
+
` A filtered pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`,
|
|
1203
|
+
);
|
|
1142
1204
|
}
|
|
1143
1205
|
|
|
1144
|
-
|
|
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({
|
|
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
|
|
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
|
-
*
|
|
2084
|
-
*
|
|
2085
|
-
*
|
|
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
|
-
|
|
2114
|
-
|
|
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
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
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
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
if (
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
+
}
|