@indigoai-us/hq-cloud 6.14.7 → 6.14.9

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 (104) 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-company.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-company.js +8 -0
  15. package/dist/bin/sync-runner-company.js.map +1 -1
  16. package/dist/bin/sync-runner-planning.d.ts +1 -1
  17. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-planning.js +15 -1
  19. package/dist/bin/sync-runner-planning.js.map +1 -1
  20. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  21. package/dist/bin/sync-runner-watch-loop.js +28 -0
  22. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  23. package/dist/bin/sync-runner.d.ts +14 -0
  24. package/dist/bin/sync-runner.d.ts.map +1 -1
  25. package/dist/bin/sync-runner.js +26 -0
  26. package/dist/bin/sync-runner.js.map +1 -1
  27. package/dist/bin/sync-runner.test.js +68 -1
  28. package/dist/bin/sync-runner.test.js.map +1 -1
  29. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  30. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  31. package/dist/cli/rescue-core.d.ts +1 -0
  32. package/dist/cli/rescue-core.d.ts.map +1 -1
  33. package/dist/cli/rescue-core.js +478 -300
  34. package/dist/cli/rescue-core.js.map +1 -1
  35. package/dist/cli/rescue-snapshot.d.ts +14 -0
  36. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  37. package/dist/cli/rescue-snapshot.js +39 -0
  38. package/dist/cli/rescue-snapshot.js.map +1 -0
  39. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  40. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  41. package/dist/cli/rescue-snapshot.test.js +46 -0
  42. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  43. package/dist/cli/sync-scope.test.js +3 -1
  44. package/dist/cli/sync-scope.test.js.map +1 -1
  45. package/dist/cli/sync.d.ts +6 -6
  46. package/dist/cli/sync.d.ts.map +1 -1
  47. package/dist/cli/sync.js +51 -41
  48. package/dist/cli/sync.js.map +1 -1
  49. package/dist/cli/sync.test.js +102 -2
  50. package/dist/cli/sync.test.js.map +1 -1
  51. package/dist/manifest-reconcile.d.ts +27 -0
  52. package/dist/manifest-reconcile.d.ts.map +1 -0
  53. package/dist/manifest-reconcile.js +120 -0
  54. package/dist/manifest-reconcile.js.map +1 -0
  55. package/dist/manifest-reconcile.test.d.ts +2 -0
  56. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  57. package/dist/manifest-reconcile.test.js +97 -0
  58. package/dist/manifest-reconcile.test.js.map +1 -0
  59. package/dist/object-io.d.ts +19 -2
  60. package/dist/object-io.d.ts.map +1 -1
  61. package/dist/object-io.js +168 -32
  62. package/dist/object-io.js.map +1 -1
  63. package/dist/object-io.test.js +157 -1
  64. package/dist/object-io.test.js.map +1 -1
  65. package/dist/s3.d.ts +12 -1
  66. package/dist/s3.d.ts.map +1 -1
  67. package/dist/s3.js +51 -18
  68. package/dist/s3.js.map +1 -1
  69. package/dist/s3.test.js +12 -1
  70. package/dist/s3.test.js.map +1 -1
  71. package/dist/signals/get.test.js +21 -1
  72. package/dist/signals/get.test.js.map +1 -1
  73. package/dist/signals/list.test.js +21 -1
  74. package/dist/signals/list.test.js.map +1 -1
  75. package/dist/sources/get.test.js +21 -1
  76. package/dist/sources/get.test.js.map +1 -1
  77. package/dist/sources/list.test.js +21 -1
  78. package/dist/sources/list.test.js.map +1 -1
  79. package/package.json +3 -2
  80. package/src/backup-prune.test.ts +98 -0
  81. package/src/backup-prune.ts +182 -0
  82. package/src/bin/backup-prune-runner.ts +33 -0
  83. package/src/bin/sync-runner-company.ts +7 -0
  84. package/src/bin/sync-runner-planning.ts +16 -2
  85. package/src/bin/sync-runner-watch-loop.ts +18 -0
  86. package/src/bin/sync-runner.test.ts +82 -1
  87. package/src/bin/sync-runner.ts +45 -0
  88. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  89. package/src/cli/rescue-core.ts +261 -86
  90. package/src/cli/rescue-snapshot.test.ts +57 -0
  91. package/src/cli/rescue-snapshot.ts +51 -0
  92. package/src/cli/sync-scope.test.ts +3 -1
  93. package/src/cli/sync.test.ts +121 -2
  94. package/src/cli/sync.ts +71 -53
  95. package/src/manifest-reconcile.test.ts +107 -0
  96. package/src/manifest-reconcile.ts +160 -0
  97. package/src/object-io.test.ts +175 -0
  98. package/src/object-io.ts +213 -32
  99. package/src/s3.test.ts +18 -0
  100. package/src/s3.ts +64 -30
  101. package/src/signals/get.test.ts +26 -2
  102. package/src/signals/list.test.ts +26 -2
  103. package/src/sources/get.test.ts +26 -2
  104. package/src/sources/list.test.ts +26 -2
@@ -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;
@@ -47,7 +47,7 @@ import type {
47
47
  PendingInviteByEmail,
48
48
  TelemetryEventsBatch,
49
49
  } from "../vault-client.js";
50
- import { VaultAuthError } from "../vault-client.js";
50
+ import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
51
51
  import type { PushEvent } from "../sync/push-event.js";
52
52
 
53
53
  // ---------------------------------------------------------------------------
@@ -219,6 +219,7 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
219
219
  embedded: false,
220
220
  pendingDirty: false,
221
221
  })),
222
+ reconcileManifest: vi.fn().mockResolvedValue(undefined),
222
223
  ...overrides,
223
224
  stdout,
224
225
  stderr,
@@ -926,6 +927,86 @@ describe("fanout-plan", () => {
926
927
  expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
927
928
  });
928
929
 
930
+ it("skips a ghost membership whose entity is gone so it cannot force exit 2", async () => {
931
+ const sync = vi.fn().mockResolvedValue(defaultSyncResult());
932
+ const deps = makeDeps({
933
+ createVaultClient: () =>
934
+ makeVaultStub({
935
+ memberships: [
936
+ { companyUid: "cmp_live" },
937
+ { companyUid: "cmp_ghost" },
938
+ ],
939
+ entityGet: (uid: string) => {
940
+ if (uid === "cmp_ghost") {
941
+ return Promise.reject(new VaultNotFoundError("company deleted"));
942
+ }
943
+ return Promise.resolve({
944
+ uid,
945
+ slug: "live",
946
+ type: "company",
947
+ status: "active",
948
+ createdAt: "2026-01-01T00:00:00Z",
949
+ });
950
+ },
951
+ }),
952
+ sync,
953
+ });
954
+
955
+ const code = await runRunner(["--companies"], deps);
956
+
957
+ expect(code).toBe(0);
958
+ expect(sync).toHaveBeenCalledTimes(1);
959
+ expect(sync).toHaveBeenCalledWith(
960
+ expect.objectContaining({ company: "cmp_live" }),
961
+ );
962
+ const plan = deps.stdout
963
+ .events()
964
+ .find((e) => e.type === "fanout-plan") as Extract<
965
+ RunnerEvent,
966
+ { type: "fanout-plan" }
967
+ >;
968
+ expect(plan.companies).toEqual([{ uid: "cmp_live", slug: "live" }]);
969
+ });
970
+
971
+ it("reconciles after the personal leg completes", async () => {
972
+ const order: string[] = [];
973
+ const deps = makeDeps({
974
+ createVaultClient: () =>
975
+ makeVaultStub({
976
+ memberships: [{ companyUid: "cmp_acme" }],
977
+ entityGet: (uid: string) =>
978
+ Promise.resolve({
979
+ uid,
980
+ slug: "acme",
981
+ type: "company",
982
+ status: "active",
983
+ createdAt: "2026-01-01T00:00:00Z",
984
+ }),
985
+ listPersons: () =>
986
+ Promise.resolve([
987
+ {
988
+ uid: "prs_me",
989
+ slug: "me",
990
+ type: "person",
991
+ status: "active",
992
+ bucketName: "hq-vault-prs-me",
993
+ createdAt: "2026-01-01T00:00:00Z",
994
+ } as EntityInfo,
995
+ ]),
996
+ }),
997
+ sync: vi.fn().mockImplementation(async (options: SyncOptions) => {
998
+ order.push(options.personalMode === true ? "personal" : "company");
999
+ return defaultSyncResult();
1000
+ }),
1001
+ reconcileManifest: vi.fn().mockImplementation(async () => {
1002
+ order.push("reconcile");
1003
+ }),
1004
+ });
1005
+
1006
+ expect(await runRunner(["--companies"], deps)).toBe(0);
1007
+ expect(order).toEqual(["company", "personal", "reconcile"]);
1008
+ });
1009
+
929
1010
  it("includes entity.name on plan entries when available", async () => {
930
1011
  const deps = makeDeps({
931
1012
  createVaultClient: () =>
@@ -139,6 +139,10 @@ import {
139
139
  emitFanoutPlan,
140
140
  resolveMembershipsForRun,
141
141
  } from "./sync-runner-planning.js";
142
+ import {
143
+ reconcileCompanyManifest,
144
+ type ManifestReconcileOptions,
145
+ } from "../manifest-reconcile.js";
142
146
  import { executeCompanyFanout } from "./sync-runner-company.js";
143
147
  import { rollupAllComplete } from "./sync-runner-rollup.js";
144
148
  import { emitTelemetry } from "./sync-runner-telemetry.js";
@@ -351,6 +355,10 @@ export type RunnerEvent =
351
355
  | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
352
356
  | { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
353
357
  | { type: "ignore-excluded"; company: string; count: number; totalExcluded: number; samplePaths: string[] }
358
+ | ({ type: "skip-invalid-scoped-key"; company: string } & Omit<
359
+ Extract<SyncProgressEvent, { type: "skip-invalid-scoped-key" }>,
360
+ "type"
361
+ >)
354
362
  | ({
355
363
  type: "complete";
356
364
  company: string;
@@ -603,6 +611,14 @@ export interface RunnerDeps {
603
611
  hqRoot: string,
604
612
  options: QmdReindexOptions,
605
613
  ) => ReturnType<typeof reindexAfterSync>;
614
+ /**
615
+ * Merge successfully pulled cloud companies into the local manifest after
616
+ * the complete fanout. Injectable so runner tests do not touch a developer's
617
+ * HQ root.
618
+ */
619
+ reconcileManifest?: (
620
+ options: ManifestReconcileOptions,
621
+ ) => Promise<void>;
606
622
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
607
623
  operationLockAlreadyHeld?: boolean;
608
624
  /** Share function (push phase). Defaults to `cli/share.share`. */
@@ -1311,6 +1327,33 @@ export async function runRunner(
1311
1327
  const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1312
1328
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1313
1329
 
1330
+ // The personal-vault target runs last and can pull an older copy of the
1331
+ // manifest. Reconcile only after the entire pull fanout has settled so the
1332
+ // successfully materialized cloud directories restore their own entries.
1333
+ if (parsed.direction !== "push") {
1334
+ try {
1335
+ const completedCompanySlugs = new Set(
1336
+ Array.from(fanout.stateByCompany.entries())
1337
+ .filter(([, state]) => state.status === "complete")
1338
+ .map(([slug]) => slug),
1339
+ );
1340
+ await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1341
+ hqRoot: parsed.hqRoot,
1342
+ targets: plan,
1343
+ completedCompanySlugs,
1344
+ getEntity: client.entity.get,
1345
+ });
1346
+ } catch (err) {
1347
+ reportDiagnostic({
1348
+ component: "manifest-reconcile",
1349
+ event: "runner.manifest_reconcile.failed",
1350
+ message: "post-fanout manifest reconciliation failed",
1351
+ err,
1352
+ context: { hqRoot: parsed.hqRoot },
1353
+ });
1354
+ }
1355
+ }
1356
+
1314
1357
  if (rollup.needsReindex) {
1315
1358
  try {
1316
1359
  const reindexOptions = {
@@ -1649,6 +1692,8 @@ export interface RunnerLoopDeps {
1649
1692
  * `deps` and forwards just the argv to `runRunner`.
1650
1693
  */
1651
1694
  runPass?: (passArgv: string[]) => Promise<number>;
1695
+ /** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
1696
+ runBackupPrune?: (hqRoot: string) => Promise<void>;
1652
1697
  /**
1653
1698
  * Clock seam for the event-push watcher's debounce window. Defaults to
1654
1699
  * {@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
  });