@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.
- 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/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +5 -3
- package/dist/cli/reindex.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 +7 -0
- package/dist/cli/rescue-core.d.ts.map +1 -1
- package/dist/cli/rescue-core.js +509 -282
- 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/rescue.test.js +24 -0
- package/dist/cli/rescue.test.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +12 -11
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +41 -6
- 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/local-path-codec.d.ts +19 -0
- package/dist/local-path-codec.d.ts.map +1 -0
- package/dist/local-path-codec.js +69 -0
- package/dist/local-path-codec.js.map +1 -0
- 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/s3.d.ts +8 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +52 -22
- package/dist/s3.js.map +1 -1
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +4 -3
- package/dist/scope-shrink.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/scripts/presign-transport-e2e.mjs +57 -10
- package/scripts/vault-rebaseline.sh +49 -1
- package/scripts/vault-rescue.sh +42 -1
- 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/reindex.ts +5 -3
- package/src/cli/rescue-classify-ordering.test.ts +121 -0
- package/src/cli/rescue-core.ts +311 -89
- package/src/cli/rescue-snapshot.test.ts +57 -0
- package/src/cli/rescue-snapshot.ts +51 -0
- package/src/cli/rescue.test.ts +34 -0
- package/src/cli/share.ts +12 -11
- package/src/cli/sync.test.ts +62 -0
- package/src/cli/sync.ts +51 -7
- package/src/local-path-codec.ts +95 -0
- package/src/object-io.test.ts +175 -0
- package/src/object-io.ts +213 -32
- package/src/s3.ts +61 -12
- package/src/scope-shrink.ts +4 -3
- 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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1201
|
+
out(
|
|
1202
|
+
` A filtered pre-update snapshot is at ${backupDir} (RECOVERY.md explains restore).\n`,
|
|
1203
|
+
);
|
|
1095
1204
|
}
|
|
1096
1205
|
|
|
1097
|
-
|
|
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({
|
|
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
|
|
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
|
-
*
|
|
2037
|
-
*
|
|
2038
|
-
*
|
|
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
|
-
|
|
2067
|
-
|
|
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
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
if (
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
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
|
+
});
|