@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
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import {
6
+ applyBackupPrune,
7
+ canonicalRootHash,
8
+ inventoryManagedSnapshots,
9
+ planBackupPrune,
10
+ type BackupEntry,
11
+ type SnapshotStatus,
12
+ } from "./backup-prune.js";
13
+
14
+ const DAY = 86_400_000;
15
+ const now = Date.UTC(2026, 6, 15, 12);
16
+ const rootA = "/tmp/hq-a";
17
+ const rootB = "/tmp/hq-b";
18
+ function entry(name: string, age: number, status: SnapshotStatus, root = rootA): BackupEntry {
19
+ const at = new Date(now - age * DAY).toISOString();
20
+ return { name, createdMs: now - age * DAY, completedMs: now - age * DAY, bytes: 1,
21
+ manifest: { schemaVersion: 1, status, canonicalHqRoot: root, rootHash: canonicalRootHash(root), runId: name,
22
+ creatorPid: 1, creatorStartedAt: at, source: "source", ref: "main", sourceSha: "sha", createdAt: at,
23
+ completedAt: at, includedRoots: ["core"], fileCount: 1, byteCount: 1 } };
24
+ }
25
+ const names = (x: ReturnType<typeof planBackupPrune>) => x.candidates.map((c) => c.entry.name).sort();
26
+ const protectedNames = (x: ReturnType<typeof planBackupPrune>) => x.protected.map((p) => `${p.entry.name}:${p.reason}`).sort();
27
+
28
+ describe("planBackupPrune", () => {
29
+ it("keeps the newest two known-good snapshots per canonical root", () => {
30
+ const p = planBackupPrune([entry("a1", 0, "completed-good"), entry("a2", 1, "completed-good"), entry("a3", 2, "completed-good"), entry("a4", 3, "completed-good"), entry("b1", 0, "completed-good", rootB)], now);
31
+ expect(names(p)).toEqual(["a3", "a4"]);
32
+ expect(protectedNames(p)).toContain("a1:newest-good");
33
+ expect(protectedNames(p)).toContain("b1:newest-good");
34
+ });
35
+
36
+ it("never selects the sole, old known-good snapshot", () => {
37
+ const p = planBackupPrune([entry("only", 30, "completed-good")], now);
38
+ expect(names(p)).toEqual([]);
39
+ expect(protectedNames(p)).toEqual(["only:newest-good"]);
40
+ });
41
+
42
+ it("retains copying and unsuperseded recovery evidence", () => {
43
+ const p = planBackupPrune([entry("copy", 40, "copying"), entry("recovery", 40, "recovery-required")], now);
44
+ expect(names(p)).toEqual([]);
45
+ expect(protectedNames(p)).toContain("copy:copying-not-superseded");
46
+ expect(protectedNames(p)).toContain("recovery:recovery-required-no-later-good");
47
+ });
48
+
49
+ it("requires a later good run before pruning recovery-required snapshots", () => {
50
+ const p = planBackupPrune([entry("recovery-old", 30, "recovery-required"), entry("recovery-new", 2, "recovery-required"), entry("good", 1, "completed-good")], now);
51
+ expect(names(p)).toEqual(["recovery-old"]);
52
+ expect(protectedNames(p)).toContain("recovery-new:newest-recovery-required-grace");
53
+ });
54
+
55
+ it("only prunes failed-pre-mutation snapshots after a newer ready/good snapshot", () => {
56
+ const noSuccess = planBackupPrune([entry("failure", 20, "failed-pre-mutation")], now);
57
+ expect(names(noSuccess)).toEqual([]);
58
+ const p = planBackupPrune([entry("failure-new", 1, "failed-pre-mutation"), entry("failure-old", 2, "failed-pre-mutation"), entry("ready", 0, "ready")], now);
59
+ expect(names(p)).toEqual(["failure-old"]);
60
+ });
61
+
62
+ it("clamps malformed retention so the newest good is still protected", () => {
63
+ const p = planBackupPrune([entry("one", 30, "completed-good"), entry("two", 31, "completed-good"), entry("three", 32, "completed-good")], now, { keepGood: 0, maxAgeDays: Number.NaN });
64
+ expect(names(p)).toEqual(["three"]);
65
+ expect(protectedNames(p)).toContain("one:newest-good");
66
+ });
67
+ });
68
+
69
+ describe("managed snapshot filesystem boundary", () => {
70
+ it("does not select prefix matches, mcp, symlinks, malformed metadata, or changed candidates", () => {
71
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-prune-root-"));
72
+ const backup = fs.mkdtempSync(path.join(os.tmpdir(), "hq-prune-backup-"));
73
+ try {
74
+ const make = (name: string, age: number) => {
75
+ const dir = path.join(backup, name); fs.mkdirSync(dir);
76
+ const at = new Date(now - age * DAY).toISOString();
77
+ fs.writeFileSync(path.join(dir, "SNAPSHOT.json"), JSON.stringify({ ...entry(name, age, "completed-good", root).manifest, runId: name, createdAt: at, completedAt: at }));
78
+ return dir;
79
+ };
80
+ const good1 = make("pre-update-2026-07-15T12-00-00Z-" + canonicalRootHash(root) + "-one", 0);
81
+ const good2 = make("pre-update-2026-07-14T12-00-00Z-" + canonicalRootHash(root) + "-two", 1);
82
+ const old = make("pre-update-2026-07-13T12-00-00Z-" + canonicalRootHash(root) + "-three", 2);
83
+ fs.mkdirSync(path.join(backup, "mcp"));
84
+ fs.mkdirSync(path.join(backup, "pre-update-not-managed"));
85
+ fs.symlinkSync(good1, path.join(backup, "pre-update-2026-07-12T12-00-00Z-" + canonicalRootHash(root) + "-symlink"));
86
+ const inventory = inventoryManagedSnapshots(backup, root);
87
+ expect(inventory.candidates.map((x) => x.entry.name)).toEqual([path.basename(old)]);
88
+ expect(inventory.unmanaged.map((x) => x.name)).toEqual(expect.arrayContaining(["mcp", "pre-update-not-managed"]));
89
+ // A changed manifest invalidates the dry-run identity and cannot be removed.
90
+ fs.writeFileSync(path.join(old, "SNAPSHOT.json"), JSON.stringify({ ...entry("replacement", 2, "completed-good", root).manifest }));
91
+ const result = applyBackupPrune(backup, root, inventory);
92
+ expect(result.deleted).toEqual([]);
93
+ expect(fs.existsSync(old)).toBe(true);
94
+ expect(fs.existsSync(path.join(backup, "mcp"))).toBe(true);
95
+ expect(fs.existsSync(good2)).toBe(true);
96
+ } finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(backup, { recursive: true, force: true }); }
97
+ });
98
+ });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Safety-first inventory and retention policy for HQ rescue snapshots.
3
+ *
4
+ * This file deliberately separates policy from filesystem access. In
5
+ * particular, `planBackupPrune` cannot remove, create, or mutate anything.
6
+ */
7
+ import * as crypto from "crypto";
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+
11
+ export const SNAPSHOT_SCHEMA_VERSION = 1;
12
+ export const SNAPSHOT_FILE = "SNAPSHOT.json";
13
+ export const MANAGED_SNAPSHOT_RE = /^pre-update-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z-[a-f0-9]{16}-[A-Za-z0-9_-]+$/;
14
+ export type SnapshotStatus = "copying" | "ready" | "completed-good" | "failed-pre-mutation" | "recovery-required";
15
+
16
+ export interface SnapshotManifest {
17
+ schemaVersion: 1;
18
+ status: SnapshotStatus;
19
+ canonicalHqRoot: string;
20
+ rootHash: string;
21
+ runId: string;
22
+ creatorPid: number;
23
+ creatorStartedAt: string;
24
+ source: string;
25
+ ref: string;
26
+ sourceSha: string;
27
+ createdAt: string;
28
+ completedAt?: string;
29
+ includedRoots: string[];
30
+ fileCount: number;
31
+ byteCount: number;
32
+ }
33
+
34
+ export interface BackupEntry {
35
+ name: string;
36
+ manifest: SnapshotManifest;
37
+ createdMs: number;
38
+ completedMs: number;
39
+ bytes: number;
40
+ identity?: { dev: number; ino: number };
41
+ copyingOwnerLive?: boolean;
42
+ }
43
+ export interface ProtectedEntry { entry: BackupEntry; reason: string }
44
+ export interface DeletionCandidate { entry: BackupEntry; reason: string }
45
+ export interface BackupPrunePlan { candidates: DeletionCandidate[]; protected: ProtectedEntry[]; unmanaged: { name: string; reason: string }[] }
46
+ export interface RetentionConfig { keepGood: number; keepFailedPreMutation: number; maxAgeDays: number }
47
+
48
+ export const DEFAULT_RETENTION: RetentionConfig = { keepGood: 2, keepFailedPreMutation: 1, maxAgeDays: 7 };
49
+ const day = 24 * 60 * 60 * 1000;
50
+
51
+ export function canonicalRootHash(root: string): string {
52
+ return crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
53
+ }
54
+
55
+ export function safeRetention(input: Partial<RetentionConfig> = {}): RetentionConfig {
56
+ const integer = (value: unknown, fallback: number, minimum: number) =>
57
+ typeof value === "number" && Number.isInteger(value) && value >= minimum ? value : fallback;
58
+ return {
59
+ keepGood: integer(input.keepGood, DEFAULT_RETENTION.keepGood, 1),
60
+ keepFailedPreMutation: integer(input.keepFailedPreMutation, DEFAULT_RETENTION.keepFailedPreMutation, 1),
61
+ maxAgeDays: integer(input.maxAgeDays, DEFAULT_RETENTION.maxAgeDays, 0),
62
+ };
63
+ }
64
+
65
+ const newestFirst = (a: BackupEntry, b: BackupEntry) => b.completedMs - a.completedMs || b.createdMs - a.createdMs || b.name.localeCompare(a.name);
66
+
67
+ /** Every managed entry receives either a retention reason or a deletion reason. */
68
+ export function planBackupPrune(entries: BackupEntry[], nowMs: number, input: Partial<RetentionConfig> = {}): BackupPrunePlan {
69
+ const cfg = safeRetention(input);
70
+ const candidates: DeletionCandidate[] = [];
71
+ const protectedEntries: ProtectedEntry[] = [];
72
+ const byRoot = new Map<string, BackupEntry[]>();
73
+ for (const entry of entries) {
74
+ const group = byRoot.get(entry.manifest.canonicalHqRoot) ?? [];
75
+ group.push(entry);
76
+ byRoot.set(entry.manifest.canonicalHqRoot, group);
77
+ }
78
+ for (const group of byRoot.values()) {
79
+ const good = group.filter((e) => e.manifest.status === "completed-good").sort(newestFirst);
80
+ const readyOrGood = group.filter((e) => e.manifest.status === "ready" || e.manifest.status === "completed-good").sort(newestFirst);
81
+ const laterGood = (e: BackupEntry) => good.some((g) => g.completedMs > e.completedMs || (g.completedMs === e.completedMs && g.name > e.name));
82
+ const newestRecovery = group.filter((e) => e.manifest.status === "recovery-required").sort(newestFirst)[0];
83
+ for (const entry of group) {
84
+ const status = entry.manifest.status;
85
+ if (status === "copying") {
86
+ protectedEntries.push({ entry, reason: entry.copyingOwnerLive ? "copying-owner-live" : "copying-not-superseded" });
87
+ continue;
88
+ }
89
+ if (status === "completed-good") {
90
+ const rank = good.indexOf(entry);
91
+ if (rank < cfg.keepGood) {
92
+ protectedEntries.push({ entry, reason: rank === 0 ? "newest-good" : "keep-newest-good" });
93
+ } else if (nowMs - entry.completedMs >= cfg.maxAgeDays * day) {
94
+ candidates.push({ entry, reason: "completed-good-age-and-rank" });
95
+ } else {
96
+ candidates.push({ entry, reason: "completed-good-rank" });
97
+ }
98
+ continue;
99
+ }
100
+ if (status === "failed-pre-mutation") {
101
+ const failures = group.filter((e) => e.manifest.status === status).sort(newestFirst);
102
+ const rank = failures.indexOf(entry);
103
+ const superseded = readyOrGood.some((other) => newestFirst(other, entry) < 0);
104
+ if (rank < cfg.keepFailedPreMutation || !superseded) protectedEntries.push({ entry, reason: !superseded ? "failed-pre-mutation-not-superseded" : "keep-failed-pre-mutation" });
105
+ else candidates.push({ entry, reason: "failed-pre-mutation-superseded" });
106
+ continue;
107
+ }
108
+ // A recovery-required copy is recovery evidence until a later known-good run.
109
+ if (!laterGood(entry)) {
110
+ protectedEntries.push({ entry, reason: "recovery-required-no-later-good" });
111
+ } else if (entry === newestRecovery && nowMs - entry.completedMs < cfg.maxAgeDays * day) {
112
+ protectedEntries.push({ entry, reason: "newest-recovery-required-grace" });
113
+ } else if (nowMs - entry.completedMs >= cfg.maxAgeDays * day || entry !== newestRecovery) {
114
+ candidates.push({ entry, reason: "recovery-required-superseded" });
115
+ } else {
116
+ protectedEntries.push({ entry, reason: "recovery-required-grace" });
117
+ }
118
+ }
119
+ }
120
+ return { candidates, protected: protectedEntries, unmanaged: [] };
121
+ }
122
+
123
+ function validManifest(value: unknown, canonicalRoot: string): value is SnapshotManifest {
124
+ const x = value as Partial<SnapshotManifest>;
125
+ return !!x && x.schemaVersion === SNAPSHOT_SCHEMA_VERSION && typeof x.canonicalHqRoot === "string" &&
126
+ x.canonicalHqRoot === canonicalRoot && x.rootHash === canonicalRootHash(canonicalRoot) &&
127
+ typeof x.runId === "string" && /^[A-Za-z0-9_-]+$/.test(x.runId) &&
128
+ ["copying", "ready", "completed-good", "failed-pre-mutation", "recovery-required"].includes(x.status ?? "") &&
129
+ typeof x.createdAt === "string" && Array.isArray(x.includedRoots) && typeof x.fileCount === "number" && typeof x.byteCount === "number";
130
+ }
131
+
132
+ export function inventoryManagedSnapshots(backupRoot: string, canonicalRoot: string, isCopyingOwnerLive: (m: SnapshotManifest) => boolean = () => false, retention: Partial<RetentionConfig> = {}): BackupPrunePlan & { entries: BackupEntry[] } {
133
+ const entries: BackupEntry[] = [];
134
+ const unmanaged: { name: string; reason: string }[] = [];
135
+ let names: string[] = [];
136
+ try { names = fs.readdirSync(backupRoot); } catch { return { entries, candidates: [], protected: [], unmanaged: [{ name: backupRoot, reason: "backup-root-unreadable" }] }; }
137
+ for (const name of names.sort()) {
138
+ // Never descend into categories such as mcp/; only direct, exact names qualify.
139
+ if (!MANAGED_SNAPSHOT_RE.test(name)) { unmanaged.push({ name, reason: "not-managed-snapshot-name" }); continue; }
140
+ const abs = path.join(backupRoot, name);
141
+ let st: fs.Stats;
142
+ try { st = fs.lstatSync(abs); } catch { unmanaged.push({ name, reason: "lstat-failed" }); continue; }
143
+ if (!st.isDirectory() || st.isSymbolicLink()) { unmanaged.push({ name, reason: "not-real-directory" }); continue; }
144
+ let manifest: unknown;
145
+ try { manifest = JSON.parse(fs.readFileSync(path.join(abs, SNAPSHOT_FILE), "utf8")); } catch { unmanaged.push({ name, reason: "missing-or-invalid-metadata" }); continue; }
146
+ if (!validManifest(manifest, canonicalRoot)) { unmanaged.push({ name, reason: "metadata-does-not-match-canonical-root" }); continue; }
147
+ const m = manifest;
148
+ const createdMs = Date.parse(m.createdAt);
149
+ const completedMs = Date.parse(m.completedAt ?? m.createdAt);
150
+ if (!Number.isFinite(createdMs) || !Number.isFinite(completedMs)) { unmanaged.push({ name, reason: "invalid-metadata-timestamp" }); continue; }
151
+ entries.push({ name, manifest: m, createdMs, completedMs, bytes: m.byteCount, identity: { dev: st.dev, ino: st.ino }, copyingOwnerLive: m.status === "copying" && isCopyingOwnerLive(m) });
152
+ }
153
+ const plan = planBackupPrune(entries, Date.now(), retention);
154
+ return { entries, ...plan, unmanaged: [...unmanaged, ...plan.unmanaged] };
155
+ }
156
+
157
+ /** Revalidates strict identity then renames before recursively removing it. */
158
+ export function applyBackupPrune(backupRoot: string, canonicalRoot: string, plan: BackupPrunePlan, retention: Partial<RetentionConfig> = {}): { deleted: string[]; failures: string[] } {
159
+ const deleted: string[] = [], failures: string[] = [];
160
+ const lockPath = path.join(backupRoot, ".hq-backup-prune.lock");
161
+ let lockFd: number | undefined;
162
+ try { lockFd = fs.openSync(lockPath, "wx"); fs.writeSync(lockFd, `${process.pid}\n`); }
163
+ catch { return { deleted, failures: ["backup-root-lock-busy-or-unwritable"] }; }
164
+ try {
165
+ // This is deliberately after acquiring the backup-root lock and immediately
166
+ // before rename: a dry-run plan is never trusted across a filesystem race.
167
+ const fresh = inventoryManagedSnapshots(backupRoot, canonicalRoot, () => false, retention);
168
+ const freshByName = new Map(fresh.entries.map((e) => [e.name, e]));
169
+ const stillCandidates = new Set(fresh.candidates.map((e) => e.entry.name));
170
+ for (const candidate of plan.candidates) {
171
+ const current = freshByName.get(candidate.entry.name);
172
+ if (!current || !stillCandidates.has(candidate.entry.name) || current.manifest.runId !== candidate.entry.manifest.runId || current.identity?.dev !== candidate.entry.identity?.dev || current.identity?.ino !== candidate.entry.identity?.ino || current.manifest.status === "copying") { failures.push(`${candidate.entry.name}: changed-or-in-use`); continue; }
173
+ const from = path.join(backupRoot, current.name);
174
+ const tombstone = path.join(backupRoot, `.deleting-${current.manifest.runId}`);
175
+ try { fs.renameSync(from, tombstone); fs.rmSync(tombstone, { recursive: true, force: false }); deleted.push(current.name); }
176
+ catch (error) { failures.push(`${current.name}: ${error instanceof Error ? error.message : String(error)}`); }
177
+ }
178
+ } finally {
179
+ try { if (lockFd !== undefined) fs.closeSync(lockFd); fs.unlinkSync(lockPath); } catch { /* retained errors are safer than a delete race */ }
180
+ }
181
+ return { deleted, failures };
182
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /** Explicit, local-only retention command. Defaults to dry-run. */
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { applyBackupPrune, inventoryManagedSnapshots } from "../backup-prune.js";
6
+ import { acquireOperationLock, OperationLockedError } from "../operation-lock.js";
7
+
8
+ const args = process.argv.slice(2);
9
+ const value = (flag: string) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
10
+ const rootArg = value("--hq-root");
11
+ const backupArg = value("--backup-dir");
12
+ const mode = value("--mode") ?? "dry-run";
13
+ if (!rootArg || !["dry-run", "apply"].includes(mode)) {
14
+ process.stderr.write("usage: hq-backup-prune --hq-root DIR [--backup-dir DIR] [--mode dry-run|apply]\n");
15
+ process.exit(2);
16
+ }
17
+ const root = fs.realpathSync(rootArg);
18
+ const backupRoot = backupArg ? path.resolve(backupArg) : path.join(process.env.HOME ?? "", ".hq", "backups");
19
+ let lock;
20
+ try { lock = acquireOperationLock(root, "backup-prune", { timeoutSec: 0, onWaitStart: () => undefined }); }
21
+ catch (error) {
22
+ if (error instanceof OperationLockedError) { process.stderr.write("hq-backup-prune: skipped-in-use\n"); process.exit(0); }
23
+ throw error;
24
+ }
25
+ try {
26
+ const inventory = inventoryManagedSnapshots(backupRoot, root, (m) => { try { process.kill(m.creatorPid, 0); return true; } catch { return false; } });
27
+ const plan = { candidates: inventory.candidates, protected: inventory.protected, unmanaged: inventory.unmanaged };
28
+ process.stdout.write(JSON.stringify({ mode, candidates: plan.candidates.map((x) => ({ name: x.entry.name, rootHash: x.entry.manifest.rootHash, status: x.entry.manifest.status, bytes: x.entry.bytes, reason: x.reason })), protected: plan.protected.map((x) => ({ name: x.entry.name, reason: x.reason })), unmanaged: plan.unmanaged }) + "\n");
29
+ if (mode === "apply") {
30
+ const result = applyBackupPrune(backupRoot, root, plan);
31
+ if (result.failures.length) { process.stderr.write(result.failures.join("\n") + "\n"); process.exitCode = 1; }
32
+ }
33
+ } finally { lock.release(); }
@@ -1,4 +1,5 @@
1
1
  import * as path from "path";
2
+ import { inventoryManagedSnapshots } from "../backup-prune.js";
2
3
  import { VaultClient } from "../index.js";
3
4
  import { pickCanonicalPersonEntity } from "../vault-client.js";
4
5
  import {
@@ -98,6 +99,15 @@ export async function runWatchLoop(
98
99
  const eventPush = argv.includes("--event-push");
99
100
  const companiesMode = argv.includes("--companies");
100
101
  const hqRoot = parsed.hqRoot;
102
+ // Delayed and daily: this must never be attached to the 15s poll cadence.
103
+ // Stage 0 remains dry-run only; the standalone hq-backup-prune command is
104
+ // the explicitly-gated apply surface.
105
+ let nextBackupPruneAt = Date.now() + 60_000;
106
+ const runBackupPrune = deps.runBackupPrune ?? (async (root: string) => {
107
+ const backupRoot = path.join(process.env.HOME ?? "", ".hq", "backups");
108
+ const plan = inventoryManagedSnapshots(backupRoot, root, (m) => { try { process.kill(m.creatorPid, 0); return true; } catch { return false; } });
109
+ process.stderr.write(`hq-sync-runner: backup retention dry-run — ${plan.candidates.length} candidate(s), ${plan.protected.length} protected\n`);
110
+ });
101
111
 
102
112
  const passArgv = argv.filter((a, i) => {
103
113
  if (a === "--watch") return false;
@@ -510,6 +520,14 @@ export async function runWatchLoop(
510
520
  } else if (result !== 0) {
511
521
  return result;
512
522
  }
523
+ if (Date.now() >= nextBackupPruneAt) {
524
+ // Advance before invoking: a failed/unavailable backstop cannot become
525
+ // an every-tick retry loop. It is diagnostic only and never restarts
526
+ // the sync daemon.
527
+ nextBackupPruneAt = Date.now() + 24 * 60 * 60 * 1000;
528
+ try { await runBackupPrune(hqRoot); }
529
+ catch (error) { process.stderr.write(`hq-sync-runner: backup retention dry-run failed — ${error instanceof Error ? error.message : String(error)}\n`); }
530
+ }
513
531
  await Promise.race([sleep(pollMs), stoppedSignal]);
514
532
  }
515
533
  return 0;
@@ -1649,6 +1649,8 @@ export interface RunnerLoopDeps {
1649
1649
  * `deps` and forwards just the argv to `runRunner`.
1650
1650
  */
1651
1651
  runPass?: (passArgv: string[]) => Promise<number>;
1652
+ /** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
1653
+ runBackupPrune?: (hqRoot: string) => Promise<void>;
1652
1654
  /**
1653
1655
  * Clock seam for the event-push watcher's debounce window. Defaults to
1654
1656
  * {@link systemClock}; tests inject a `FakeClock` to advance the window
@@ -369,4 +369,125 @@ exec ${JSON.stringify(realGit)} "$@"
369
369
  expect(lexists(link)).toBe(true);
370
370
  expect(fs.existsSync(path.join(hqRoot, "core/a.md"))).toBe(true);
371
371
  });
372
+
373
+ it("fails with a typed state-change error when reindex replaces a classified symlink before apply", () => {
374
+ const hqRoot = makeHqRoot();
375
+ const target = path.join(hqRoot, "personal/skills/foreman/tests");
376
+ fs.mkdirSync(target, { recursive: true });
377
+ const link = path.join(hqRoot, ".claude/skills/personal:foreman/tests");
378
+ fs.mkdirSync(path.dirname(link), { recursive: true });
379
+ fs.symlinkSync("../../../personal/skills/foreman/tests", link);
380
+
381
+ const args = [
382
+ "--hq-root", hqRoot,
383
+ "--source", "test/repo",
384
+ "--ref", "main",
385
+ "--floor-sha", floorSha,
386
+ "--yes",
387
+ "--no-backup",
388
+ ];
389
+ const r = runRescueCapture(args, {
390
+ ...baseEnv,
391
+ HQ_RESCUE_FAULT_BEFORE_APPLY_REL: ".claude/skills/personal:foreman/tests",
392
+ HQ_RESCUE_FAULT_BEFORE_APPLY_KIND: "directory",
393
+ });
394
+ const out = `${r.stdout}\n${r.stderr}\n${String(r.threw ?? "")}`;
395
+
396
+ expect(r.threw, out).toBeInstanceOf(Error);
397
+ expect(out).toContain("changed after classification");
398
+ expect(out).toContain("expected symlink, found directory");
399
+ expect(out).not.toContain("EISDIR");
400
+ expect(fs.readFileSync(path.join(hqRoot, "core/a.md"), "utf-8")).toBe("v1\n");
401
+ });
402
+
403
+ it("keeps the full default wipe set out of worktrees and omits transient build state from the snapshot", () => {
404
+ const hqRoot = makeHqRoot();
405
+ const backupRoot = path.join(workDir, `backups-${caseSeq}`);
406
+ const writeTransient = (rel: string) => {
407
+ const dest = path.join(hqRoot, rel, "large.bin");
408
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
409
+ fs.writeFileSync(dest, "reproducible\n");
410
+ };
411
+ for (const rel of [
412
+ "worktrees/repo",
413
+ ".worktrees/repo",
414
+ ".sst-sandbox-home/runtime",
415
+ ".tmp-codex-asar/cache",
416
+ ".claude/worktrees/agent/repo",
417
+ ".claude/skills/demo/node_modules/pkg",
418
+ ".claude/skills/demo/.pnpm-store/v3",
419
+ ".claude/skills/demo/.sst/build",
420
+ ".claude/skills/demo/.next/cache",
421
+ ".claude/skills/demo/target/debug",
422
+ ]) {
423
+ writeTransient(rel);
424
+ }
425
+
426
+ const r = runRescueCapture(
427
+ [
428
+ "--hq-root", hqRoot,
429
+ "--source", "test/repo",
430
+ "--ref", "main",
431
+ "--floor-sha", floorSha,
432
+ "--backup-dir", backupRoot,
433
+ "--yes",
434
+ ],
435
+ baseEnv,
436
+ );
437
+ const out = `${r.stdout}\n${r.stderr}\n${String(r.threw ?? "")}`;
438
+
439
+ expect(r.threw, out).toBeUndefined();
440
+ expect(r.status, out).toBe(0);
441
+ for (const rel of ["worktrees", ".worktrees", ".sst-sandbox-home", ".tmp-codex-asar"]) {
442
+ expect(fs.existsSync(path.join(hqRoot, rel, "repo", "large.bin")) ||
443
+ fs.existsSync(path.join(hqRoot, rel, "runtime", "large.bin")) ||
444
+ fs.existsSync(path.join(hqRoot, rel, "cache", "large.bin")), rel).toBe(true);
445
+ }
446
+
447
+ const snapshots = fs.readdirSync(backupRoot).filter((name) => name.startsWith("pre-update-"));
448
+ expect(snapshots).toHaveLength(1);
449
+ const snapshot = path.join(backupRoot, snapshots[0]);
450
+ expect(fs.existsSync(path.join(snapshot, "core/a.md"))).toBe(true);
451
+ expect(fs.existsSync(path.join(snapshot, "RECOVERY.md"))).toBe(true);
452
+ for (const rel of [
453
+ "worktrees",
454
+ ".worktrees",
455
+ ".sst-sandbox-home",
456
+ ".tmp-codex-asar",
457
+ ".claude/worktrees",
458
+ ".claude/skills/demo/node_modules",
459
+ ".claude/skills/demo/.pnpm-store",
460
+ ".claude/skills/demo/.sst",
461
+ ".claude/skills/demo/.next",
462
+ ".claude/skills/demo/target",
463
+ ]) {
464
+ expect(lexists(path.join(snapshot, rel)), `snapshot unexpectedly contains ${rel}`).toBe(false);
465
+ }
466
+ });
467
+
468
+ it("dry-run never allocates or prunes snapshots", () => {
469
+ const hqRoot = makeHqRoot();
470
+ const backupRoot = path.join(workDir, `dry-backups-${caseSeq}`);
471
+ const existing = path.join(backupRoot, "pre-update-existing");
472
+ fs.mkdirSync(existing, { recursive: true });
473
+ fs.writeFileSync(path.join(existing, "keep.txt"), "do not prune\n");
474
+
475
+ const r = runRescueCapture(
476
+ [
477
+ "--hq-root", hqRoot,
478
+ "--source", "test/repo",
479
+ "--ref", "main",
480
+ "--floor-sha", floorSha,
481
+ "--backup-dir", backupRoot,
482
+ "--dry-run",
483
+ ],
484
+ baseEnv,
485
+ );
486
+ const out = `${r.stdout}\n${r.stderr}\n${String(r.threw ?? "")}`;
487
+
488
+ expect(r.threw, out).toBeUndefined();
489
+ expect(r.status, out).toBe(0);
490
+ expect(fs.readdirSync(backupRoot)).toEqual(["pre-update-existing"]);
491
+ expect(fs.readFileSync(path.join(existing, "keep.txt"), "utf-8")).toBe("do not prune\n");
492
+ });
372
493
  });