@indigoai-us/hq-cloud 6.14.17 → 6.14.19

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 (124) hide show
  1. package/.github/workflows/ci.yml +35 -0
  2. package/dist/active-company.d.ts +43 -0
  3. package/dist/active-company.d.ts.map +1 -0
  4. package/dist/active-company.js +132 -0
  5. package/dist/active-company.js.map +1 -0
  6. package/dist/active-company.test.d.ts +2 -0
  7. package/dist/active-company.test.d.ts.map +1 -0
  8. package/dist/active-company.test.js +149 -0
  9. package/dist/active-company.test.js.map +1 -0
  10. package/dist/bin/sync-runner-company.d.ts +7 -0
  11. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  12. package/dist/bin/sync-runner-company.js +21 -1
  13. package/dist/bin/sync-runner-company.js.map +1 -1
  14. package/dist/bin/sync-runner-planning.d.ts +27 -1
  15. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  16. package/dist/bin/sync-runner-planning.js +33 -4
  17. package/dist/bin/sync-runner-planning.js.map +1 -1
  18. package/dist/bin/sync-runner-planning.test.d.ts +2 -0
  19. package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
  20. package/dist/bin/sync-runner-planning.test.js +115 -0
  21. package/dist/bin/sync-runner-planning.test.js.map +1 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
  23. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  24. package/dist/bin/sync-runner-watch-loop.js +201 -25
  25. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  26. package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
  27. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  28. package/dist/bin/sync-runner-watch-routes.js +14 -2
  29. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  30. package/dist/bin/sync-runner.d.ts +39 -9
  31. package/dist/bin/sync-runner.d.ts.map +1 -1
  32. package/dist/bin/sync-runner.js +114 -20
  33. package/dist/bin/sync-runner.js.map +1 -1
  34. package/dist/bin/sync-runner.test.js +852 -8
  35. package/dist/bin/sync-runner.test.js.map +1 -1
  36. package/dist/cli/rescue-core.js +27 -1
  37. package/dist/cli/rescue-core.js.map +1 -1
  38. package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
  39. package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
  40. package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
  41. package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
  42. package/dist/cli/share.d.ts +15 -0
  43. package/dist/cli/share.d.ts.map +1 -1
  44. package/dist/cli/share.js +100 -55
  45. package/dist/cli/share.js.map +1 -1
  46. package/dist/cli/share.test.js +119 -6
  47. package/dist/cli/share.test.js.map +1 -1
  48. package/dist/cli/sync.d.ts.map +1 -1
  49. package/dist/cli/sync.js +19 -25
  50. package/dist/cli/sync.js.map +1 -1
  51. package/dist/cli/sync.test.js +121 -0
  52. package/dist/cli/sync.test.js.map +1 -1
  53. package/dist/journal.d.ts +3 -1
  54. package/dist/journal.d.ts.map +1 -1
  55. package/dist/journal.js +13 -2
  56. package/dist/journal.js.map +1 -1
  57. package/dist/journal.test.js +26 -1
  58. package/dist/journal.test.js.map +1 -1
  59. package/dist/manifest-reconcile.d.ts +118 -5
  60. package/dist/manifest-reconcile.d.ts.map +1 -1
  61. package/dist/manifest-reconcile.js +319 -62
  62. package/dist/manifest-reconcile.js.map +1 -1
  63. package/dist/manifest-reconcile.test.js +824 -2
  64. package/dist/manifest-reconcile.test.js.map +1 -1
  65. package/dist/personal-vault.d.ts +12 -0
  66. package/dist/personal-vault.d.ts.map +1 -1
  67. package/dist/personal-vault.js +20 -0
  68. package/dist/personal-vault.js.map +1 -1
  69. package/dist/qmd-reindex.d.ts +88 -11
  70. package/dist/qmd-reindex.d.ts.map +1 -1
  71. package/dist/qmd-reindex.js +354 -65
  72. package/dist/qmd-reindex.js.map +1 -1
  73. package/dist/qmd-reindex.test.d.ts +4 -0
  74. package/dist/qmd-reindex.test.d.ts.map +1 -1
  75. package/dist/qmd-reindex.test.js +285 -3
  76. package/dist/qmd-reindex.test.js.map +1 -1
  77. package/dist/s3.d.ts +13 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +34 -1
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +60 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/telemetry.d.ts +22 -0
  84. package/dist/telemetry.d.ts.map +1 -1
  85. package/dist/telemetry.js +79 -0
  86. package/dist/telemetry.js.map +1 -1
  87. package/dist/telemetry.test.js +117 -1
  88. package/dist/telemetry.test.js.map +1 -1
  89. package/dist/watcher.d.ts +26 -1
  90. package/dist/watcher.d.ts.map +1 -1
  91. package/dist/watcher.js +75 -12
  92. package/dist/watcher.js.map +1 -1
  93. package/package.json +1 -1
  94. package/pnpm-workspace.yaml +1 -1
  95. package/src/active-company.test.ts +188 -0
  96. package/src/active-company.ts +168 -0
  97. package/src/bin/sync-runner-company.ts +29 -1
  98. package/src/bin/sync-runner-planning.test.ts +131 -0
  99. package/src/bin/sync-runner-planning.ts +60 -7
  100. package/src/bin/sync-runner-watch-loop.ts +322 -32
  101. package/src/bin/sync-runner-watch-routes.ts +14 -1
  102. package/src/bin/sync-runner.test.ts +1021 -16
  103. package/src/bin/sync-runner.ts +179 -28
  104. package/src/cli/rescue-core.ts +26 -1
  105. package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
  106. package/src/cli/share.test.ts +150 -6
  107. package/src/cli/share.ts +143 -48
  108. package/src/cli/sync.test.ts +150 -1
  109. package/src/cli/sync.ts +25 -29
  110. package/src/journal.test.ts +45 -0
  111. package/src/journal.ts +18 -1
  112. package/src/manifest-reconcile.test.ts +1019 -3
  113. package/src/manifest-reconcile.ts +424 -66
  114. package/src/personal-vault.ts +23 -0
  115. package/src/qmd-reindex.test.ts +324 -4
  116. package/src/qmd-reindex.ts +414 -76
  117. package/src/s3.test.ts +79 -0
  118. package/src/s3.ts +48 -1
  119. package/src/telemetry.test.ts +128 -0
  120. package/src/telemetry.ts +80 -0
  121. package/src/watcher.ts +111 -14
  122. package/test/e2e/watcher-real-chokidar.test.ts +62 -2
  123. package/test/e2e/watcher-recursive-backend.test.ts +67 -1
  124. package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
@@ -125,7 +125,11 @@ import {
125
125
  } from "../lib/net-errors.js";
126
126
  import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
127
127
  import { describeError } from "../lib/describe-error.js";
128
- import type { Clock, TreeChangeBatch } from "../watcher.js";
128
+ import type {
129
+ Clock,
130
+ LocalDeleteSnapshot,
131
+ TreeChangeBatch,
132
+ } from "../watcher.js";
129
133
  import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
130
134
  import {
131
135
  type EventSyncHandles,
@@ -140,10 +144,18 @@ import {
140
144
  buildFanoutPlan,
141
145
  emitFanoutPlan,
142
146
  resolveMembershipsForRun,
147
+ type RunnerTarget,
148
+ type SetupNeededReason,
143
149
  } from "./sync-runner-planning.js";
150
+ import {
151
+ seedActiveCompany,
152
+ type SeedActiveCompanyOptions,
153
+ type SeedActiveCompanyResult,
154
+ } from "../active-company.js";
144
155
  import {
145
156
  reconcileCompanyManifest,
146
157
  type ManifestReconcileOptions,
158
+ type ManifestReconcileResult,
147
159
  } from "../manifest-reconcile.js";
148
160
  import { executeCompanyFanout } from "./sync-runner-company.js";
149
161
  import { rollupAllComplete } from "./sync-runner-rollup.js";
@@ -333,11 +345,28 @@ export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
333
345
  * specific company".
334
346
  */
335
347
  export type RunnerEvent =
336
- | { type: "setup-needed" }
348
+ | {
349
+ /**
350
+ * The run cannot proceed. `reason` and `pendingInviteCount` are additive
351
+ * and optional by design: a consumer that only reads `type` behaves
352
+ * exactly as before, while /setup and /hq-sync can tell the user WHY they
353
+ * look solo — most usefully, that an invite is still waiting.
354
+ */
355
+ type: "setup-needed";
356
+ reason?: SetupNeededReason;
357
+ pendingInviteCount?: number;
358
+ }
337
359
  | { type: "auth-error"; message: string }
338
360
  | {
361
+ /**
362
+ * The resolved fanout plan. Carries whole `RunnerTarget`s: the runtime
363
+ * payload has always been the plan entries verbatim, so the previous
364
+ * `{uid, slug, name?}` declaration understated the wire contract (it
365
+ * already omitted `bucketName`, `personalMode`, and `journalSlug`).
366
+ * Consumers deserialize leniently and ignore fields they do not know.
367
+ */
339
368
  type: "fanout-plan";
340
- companies: Array<{ uid: string; slug: string; name?: string }>;
369
+ companies: RunnerTarget[];
341
370
  }
342
371
  | ({
343
372
  /**
@@ -632,11 +661,15 @@ export interface RunnerDeps {
632
661
  */
633
662
  reconcileManifest?: (
634
663
  options: ManifestReconcileOptions,
635
- ) => Promise<void>;
664
+ ) => Promise<ManifestReconcileResult>;
665
+ /** Internal test seam for `.hq/config.json` activeCompany seeding. */
666
+ seedActiveCompany?: (options: SeedActiveCompanyOptions) => SeedActiveCompanyResult;
636
667
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
637
668
  operationLockAlreadyHeld?: boolean;
638
669
  /** Internal watch-loop result override; one-shot callers keep exit 0. */
639
670
  authRequiredExitCode?: number;
671
+ /** Internal watch-loop callback carrying uncapped per-path push outcomes. */
672
+ onPassResult?: (result: RunnerPassResult) => void;
640
673
  /** Share function (push phase). Defaults to `cli/share.share`. */
641
674
  share?: (options: ShareOptions) => Promise<ShareResult>;
642
675
  /**
@@ -652,6 +685,20 @@ export interface RunnerDeps {
652
685
  telemetryTimeoutMs?: number;
653
686
  }
654
687
 
688
+ export interface RunnerPassResult {
689
+ pushPathResults: Array<{
690
+ relativePath: string;
691
+ status: "accepted" | "refused";
692
+ operation: "delete" | "tombstone";
693
+ reason?: string;
694
+ }>;
695
+ }
696
+
697
+ export interface RunnerPassOutcome {
698
+ exitCode: number;
699
+ result?: RunnerPassResult;
700
+ }
701
+
655
702
  // ---------------------------------------------------------------------------
656
703
  // JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
657
704
  // We do NOT verify the signature here — Cognito already did that when it
@@ -698,10 +745,11 @@ async function runClaimDance(
698
745
  client: VaultClientSurface,
699
746
  claims: IdTokenClaims,
700
747
  stderr: { write: (chunk: string) => boolean | void },
701
- ): Promise<void> {
748
+ reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
749
+ ): Promise<number> {
702
750
  try {
703
751
  const pending = await client.listMyPendingInvitesByEmail();
704
- if (pending.length === 0) return;
752
+ if (pending.length === 0) return 0;
705
753
 
706
754
  const displayName =
707
755
  claims.name ??
@@ -710,10 +758,15 @@ async function runClaimDance(
710
758
  "";
711
759
  const ownerSub = claims.sub ?? "";
712
760
  if (!ownerSub || !displayName) {
713
- stderr.write(
714
- "hq-sync-runner: skipping claim-dance — idToken missing sub/name\n",
715
- );
716
- return;
761
+ reportDiagnostic({
762
+ component: "claim-dance",
763
+ event: "runner.claim_dance.skipped",
764
+ message: "skipping claim-dance — idToken missing sub/name",
765
+ context: { pendingInviteCount: pending.length },
766
+ });
767
+ // The invites are real and still pending; the caller must be able to say
768
+ // so even though we could not claim them.
769
+ return pending.length;
717
770
  }
718
771
 
719
772
  const person = await client.ensureMyPersonEntity({
@@ -721,9 +774,19 @@ async function runClaimDance(
721
774
  displayName,
722
775
  });
723
776
  await client.claimPendingInvitesByEmail(person.uid);
777
+ return pending.length;
724
778
  } catch (err) {
725
- const msg = err instanceof Error ? err.message : String(err);
726
- stderr.write(`hq-sync-runner: claim-dance skipped — ${msg}\n`);
779
+ // Non-throwing by contract: a claim-dance failure must never take down a
780
+ // sync. But it stops being invisible — a systematic failure here is exactly
781
+ // what makes a joiner look permanently solo.
782
+ reportDiagnostic({
783
+ component: "claim-dance",
784
+ event: "runner.claim_dance.skipped",
785
+ message: "claim-dance skipped",
786
+ err,
787
+ context: {},
788
+ });
789
+ return 0;
727
790
  }
728
791
  }
729
792
 
@@ -769,6 +832,11 @@ interface ParsedArgs {
769
832
  skipPersonal: boolean;
770
833
  /** Repeatable company/personal-relative path scope for push-only passes. */
771
834
  scopePaths: string[];
835
+ /**
836
+ * Internal watcher-only delete roots. Unlike `scopePaths`, these remain
837
+ * meaningful after the corresponding local file or directory is absent.
838
+ */
839
+ deleteScopePaths: string[];
772
840
  /**
773
841
  * Bounded wait (seconds) for the per-root operation lock when another op is
774
842
  * already running. `0` → refuse immediately (pre-wait behavior); omitted →
@@ -789,6 +857,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
789
857
  let pollRemoteMs: number | undefined;
790
858
  let skipPersonal = false;
791
859
  const scopePaths: string[] = [];
860
+ const deleteScopePaths: string[] = [];
792
861
  let eventPush = false;
793
862
  let lockTimeoutSec: number | undefined;
794
863
 
@@ -870,6 +939,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
870
939
  scopePaths.push(val);
871
940
  break;
872
941
  }
942
+ case "--delete-scope-path": {
943
+ const val = argv[++i];
944
+ if (!val) return { error: "--delete-scope-path requires a value" };
945
+ deleteScopePaths.push(val);
946
+ break;
947
+ }
873
948
  case "--lock-timeout": {
874
949
  const val = argv[++i];
875
950
  if (!val) return { error: "--lock-timeout requires a value (seconds)" };
@@ -921,6 +996,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
921
996
  pollRemoteMs,
922
997
  skipPersonal,
923
998
  scopePaths,
999
+ deleteScopePaths,
924
1000
  eventPush,
925
1001
  lockTimeoutSec,
926
1002
  };
@@ -1276,11 +1352,18 @@ export async function runRunner(
1276
1352
  client,
1277
1353
  claims,
1278
1354
  stderr,
1279
- runClaimDance,
1355
+ runClaimDance: (client, claims, stderr) =>
1356
+ runClaimDance(client, claims, stderr, reportDiagnostic),
1280
1357
  listMemberships: listMembershipsWithRetry,
1281
1358
  });
1282
1359
  if (resolution.status === "setup-needed") {
1283
- emit({ type: "setup-needed" });
1360
+ emit({
1361
+ type: "setup-needed",
1362
+ reason: resolution.reason,
1363
+ ...(resolution.pendingInviteCount === undefined
1364
+ ? {}
1365
+ : { pendingInviteCount: resolution.pendingInviteCount }),
1366
+ });
1284
1367
  return 0;
1285
1368
  }
1286
1369
  memberships = resolution.memberships;
@@ -1335,7 +1418,7 @@ export async function runRunner(
1335
1418
  return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1336
1419
  }
1337
1420
  if (targetPlan.status === "setup-needed") {
1338
- emit({ type: "setup-needed" });
1421
+ emit({ type: "setup-needed", reason: targetPlan.reason });
1339
1422
  return 0;
1340
1423
  }
1341
1424
  const plan = targetPlan.plan;
@@ -1368,6 +1451,7 @@ export async function runRunner(
1368
1451
  resolveDeletePolicy,
1369
1452
  emit,
1370
1453
  scopePaths: parsed.scopePaths,
1454
+ deleteScopePaths: parsed.deleteScopePaths,
1371
1455
  telemetryClient: client,
1372
1456
  telemetryClaims: claims,
1373
1457
  telemetrySessionId,
@@ -1381,24 +1465,49 @@ export async function runRunner(
1381
1465
  throw err;
1382
1466
  }
1383
1467
  const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
1468
+ deps.onPassResult?.({ pushPathResults: fanout.pushPathResults });
1384
1469
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1385
1470
 
1386
1471
  // The personal-vault target runs last and can pull an older copy of the
1387
1472
  // manifest. Reconcile only after the entire pull fanout has settled so the
1388
1473
  // successfully materialized cloud directories restore their own entries.
1389
- if (parsed.direction !== "push") {
1474
+ //
1475
+ // `--companies` only. A single-company run (`--company <slug>`) and a
1476
+ // `--personal` run both see just one leg of the fanout, so neither has the
1477
+ // full picture the manifest is reconciled against; treating a partial view as
1478
+ // authoritative is how entries get lost. A push-only pass materializes
1479
+ // nothing new locally, so it has nothing to reconcile either.
1480
+ if (parsed.companies && parsed.direction !== "push") {
1481
+ const completedCompanySlugs = new Set(
1482
+ Array.from(fanout.stateByCompany.entries())
1483
+ .filter(([, state]) => state.status === "complete")
1484
+ .map(([slug]) => slug),
1485
+ );
1486
+ // The personal vault is never a manifest entry. The reconciler filters it
1487
+ // too, but that is its own internal invariant — excluding it here means
1488
+ // the personal target is never even handed across the seam.
1489
+ const companyTargets = plan.filter(
1490
+ (target) => target.personalMode !== true && target.slug !== "personal",
1491
+ );
1390
1492
  try {
1391
- const completedCompanySlugs = new Set(
1392
- Array.from(fanout.stateByCompany.entries())
1393
- .filter(([, state]) => state.status === "complete")
1394
- .map(([slug]) => slug),
1395
- );
1396
- await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1493
+ const result = await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1397
1494
  hqRoot: parsed.hqRoot,
1398
- targets: plan,
1495
+ targets: companyTargets,
1399
1496
  completedCompanySlugs,
1400
- getEntity: client.entity.get,
1497
+ reportDiagnostic: (diagnostic) =>
1498
+ reportDiagnostic({ component: "manifest-reconcile", ...diagnostic }),
1401
1499
  });
1500
+ // Only on an actual write. A steady-state sync stays silent, so an
1501
+ // unexpected diagnostic rate is itself the signal that skip-if-unchanged
1502
+ // has regressed.
1503
+ if (result.written) {
1504
+ reportDiagnostic({
1505
+ component: "manifest-reconcile",
1506
+ event: "manifest-reconciled",
1507
+ message: "restored company entries in companies/manifest.yaml",
1508
+ context: { added: result.added, updated: result.updated },
1509
+ });
1510
+ }
1402
1511
  } catch (err) {
1403
1512
  reportDiagnostic({
1404
1513
  component: "manifest-reconcile",
@@ -1408,6 +1517,38 @@ export async function runRunner(
1408
1517
  context: { hqRoot: parsed.hqRoot },
1409
1518
  });
1410
1519
  }
1520
+
1521
+ // A joiner whose manifest entry was just restored still has nothing telling
1522
+ // HQ which company is current. Seed it only when the answer cannot be wrong
1523
+ // — deliberately its own try, so a manifest failure does not also cost the
1524
+ // user their routing.
1525
+ try {
1526
+ const localCompanySlugs = companyTargets
1527
+ .map((target) => target.slug)
1528
+ .filter((slug) => completedCompanySlugs.has(slug));
1529
+ const seeded = (deps.seedActiveCompany ?? seedActiveCompany)({
1530
+ hqRoot: parsed.hqRoot,
1531
+ companySlugs: localCompanySlugs,
1532
+ reportDiagnostic: (diagnostic) =>
1533
+ reportDiagnostic({ component: "active-company", ...diagnostic }),
1534
+ });
1535
+ if (seeded.written) {
1536
+ reportDiagnostic({
1537
+ component: "active-company",
1538
+ event: "active-company-seeded",
1539
+ message: "seeded activeCompany in .hq/config.json",
1540
+ context: { activeCompany: seeded.activeCompany },
1541
+ });
1542
+ }
1543
+ } catch (err) {
1544
+ reportDiagnostic({
1545
+ component: "active-company",
1546
+ event: "runner.active_company.failed",
1547
+ message: "seeding activeCompany failed",
1548
+ err,
1549
+ context: { hqRoot: parsed.hqRoot },
1550
+ });
1551
+ }
1411
1552
  }
1412
1553
 
1413
1554
  if (rollup.needsReindex) {
@@ -1754,7 +1895,7 @@ export interface RunnerLoopDeps {
1754
1895
  * targeted push share one seam and one in-flight guard. The default ignores
1755
1896
  * `deps` and forwards just the argv to `runRunner`.
1756
1897
  */
1757
- runPass?: (passArgv: string[]) => Promise<number>;
1898
+ runPass?: (passArgv: string[]) => Promise<number | RunnerPassOutcome>;
1758
1899
  /** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
1759
1900
  runBackupPrune?: (hqRoot: string) => Promise<void>;
1760
1901
  /**
@@ -1772,6 +1913,10 @@ export interface RunnerLoopDeps {
1772
1913
  hqRoot: string;
1773
1914
  debounceMs: number;
1774
1915
  clock: Clock;
1916
+ captureLocalDeleteSnapshots: (
1917
+ relativePath: string,
1918
+ kind: "unlink" | "unlinkDir",
1919
+ ) => LocalDeleteSnapshot[];
1775
1920
  }) => WatcherSurface;
1776
1921
  /**
1777
1922
  * Register a one-shot shutdown signal handler. Defaults to listening for
@@ -1849,11 +1994,17 @@ export async function runRunnerWithLoop(
1849
1994
  ): Promise<number> {
1850
1995
  const parsed = parseArgs(argv);
1851
1996
  const runtime = {
1852
- runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
1853
- runRunner(passArgv, {
1997
+ runPassWithOperationLockAlreadyHeld: async (passArgv: string[]) => {
1998
+ let result: RunnerPassResult | undefined;
1999
+ const exitCode = await runRunner(passArgv, {
1854
2000
  operationLockAlreadyHeld: true,
1855
2001
  authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
1856
- }),
2002
+ onPassResult: (passResult) => {
2003
+ result = passResult;
2004
+ },
2005
+ });
2006
+ return { exitCode, ...(result ? { result } : {}) };
2007
+ },
1857
2008
  authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
1858
2009
  defaultGetIdTokenClaims,
1859
2010
  defaultGetAccessToken: () =>
@@ -1515,6 +1515,31 @@ function masterSyncSymlinkTarget(localPath: string): string | null {
1515
1515
  return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
1516
1516
  }
1517
1517
 
1518
+ /**
1519
+ * Drop a reindex symlink with lstat + unlink semantics: treat the path as a
1520
+ * link and remove the link itself, never following or recursing into its
1521
+ * target.
1522
+ *
1523
+ * `fs.rmSync(p, { force: true })` stat-follows on some platforms (notably
1524
+ * macOS): a symlink whose target is a directory is seen as a directory, and
1525
+ * with `recursive` unset it throws `EISDIR` ("Path is a directory"). During a
1526
+ * rescue apply that abort rolls back the whole update — the exact failure this
1527
+ * guards against (a directory-target overlay symlink such as
1528
+ * core/knowledge/<name> -> personal/knowledge/<name>). `unlink(2)` removes the
1529
+ * link regardless of what it points at, so file- and directory-target reindex
1530
+ * symlinks are dropped identically. reindex.sh re-lays the link afterward.
1531
+ *
1532
+ * A missing path is a no-op (force-equivalent); a non-symlink is left untouched
1533
+ * so we can never recurse into or delete a real directory (the classifier only
1534
+ * ever routes real symlinks here — this stays defensive).
1535
+ */
1536
+ function dropReindexSymlink(localPath: string): void {
1537
+ const st = lstatOrNull(localPath);
1538
+ if (!st) return; // already gone — nothing to drop
1539
+ if (!st.isSymbolicLink()) return; // never follow/recurse into a real target
1540
+ fs.unlinkSync(localPath);
1541
+ }
1542
+
1518
1543
  function isUnderPreserve(cfg: Config, rel: string): boolean {
1519
1544
  for (const sp of cfg.preserveSubpaths) {
1520
1545
  if (rel === sp || rel.startsWith(sp + "/")) return true;
@@ -1972,7 +1997,7 @@ function buildRescueActions(ctx: WalkCtx): RescueAction[] {
1972
1997
  break;
1973
1998
  case "drop-reindex-symlink":
1974
1999
  add(`drop reindex symlink: ${decision.rel}`, [decision.rel], () => {
1975
- fs.rmSync(localPath, { force: true });
2000
+ dropReindexSymlink(localPath);
1976
2001
  ctx.appendLog(`symlink-dropped\t${decision.rel}\t(reindex regenerable)\n`);
1977
2002
  });
1978
2003
  break;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Regression for the rescue "drop reindex symlink" fix in
3
+ * src/cli/rescue-core.ts (dropReindexSymlink).
4
+ *
5
+ * The bug: the apply step removed a reindex overlay symlink with
6
+ * `fs.rmSync(localPath, { force: true })`. `rmSync` stat-FOLLOWS on some
7
+ * platforms (notably macOS), so a symlink whose target is a directory — e.g.
8
+ * core/knowledge/ad-creative-engine -> personal/knowledge/ad-creative-engine —
9
+ * is seen as a directory and, with `recursive` unset, throws `EISDIR`
10
+ * ("Path is a directory"). During a rescue apply that abort rolls back the
11
+ * whole update, so /update-hq fails on any install carrying a directory-target
12
+ * overlay symlink. The fix removes the link with lstat + unlink semantics
13
+ * (`fs.unlinkSync`), which drops the link itself regardless of what it points
14
+ * at and never recurses into the target.
15
+ *
16
+ * The macOS `rmSync` follow-behavior is not reproducible with a plain path on
17
+ * Linux CI, so we reproduce it deterministically: `fs` is mocked so `rmSync`
18
+ * throws `EISDIR` on exactly a directory-target symlink (every other call is
19
+ * delegated to the real implementation). Under that fault the OLD code aborts
20
+ * the apply and rolls the whole update back; the fixed code drops the link with
21
+ * `unlinkSync` and the rescue still succeeds.
22
+ *
23
+ * Mirrors rescue-mtime-preserve.test.ts: shim `git clone` to a local fixture
24
+ * and run the real rescue (non-dry-run, --no-backup) so the overlay lays files
25
+ * down and the symlink-drop action really runs.
26
+ */
27
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
28
+ import { execFileSync } from "child_process";
29
+ import * as fs from "fs";
30
+ import * as os from "os";
31
+ import * as path from "path";
32
+ import { runRescue } from "./rescue-core.js";
33
+
34
+ // Simulate the reporter's platform (macOS): fs.rmSync stat-follows a
35
+ // symlink-to-directory and throws EISDIR ("Path is a directory"). Only the
36
+ // dir-target symlink trips this; every other rmSync call — and every other fs
37
+ // function — is the real implementation, so clone/overlay/backup all work.
38
+ vi.mock("fs", async (importOriginal) => {
39
+ const real = await importOriginal<typeof import("fs")>();
40
+ const patchedRmSync: typeof real.rmSync = ((p: fs.PathLike, opts?: fs.RmOptions) => {
41
+ try {
42
+ const lst = real.lstatSync(p as fs.PathLike);
43
+ if (lst.isSymbolicLink() && real.statSync(p as fs.PathLike).isDirectory()) {
44
+ const err = new Error(
45
+ `EISDIR: illegal operation on a directory, Path is a directory: ${String(p)}`,
46
+ ) as NodeJS.ErrnoException;
47
+ err.code = "ERR_FS_EISDIR";
48
+ throw err;
49
+ }
50
+ } catch (e) {
51
+ if ((e as NodeJS.ErrnoException).code === "ERR_FS_EISDIR") throw e;
52
+ // lstat/stat failed (missing path / broken link) — fall through to real rm.
53
+ }
54
+ return real.rmSync(p, opts);
55
+ }) as typeof real.rmSync;
56
+ return { ...real, rmSync: patchedRmSync };
57
+ });
58
+
59
+ function has(bin: string, ...args: string[]): boolean {
60
+ try {
61
+ execFileSync(bin, args, { stdio: "ignore" });
62
+ return true;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+ const toolsAvailable = has("git", "--version") && has("rsync", "--version");
68
+
69
+ const EPOCH = 1609459200; // 2021-01-01T00:00:00Z
70
+
71
+ /** Run the rescue in-process, capturing its stdout/stderr. */
72
+ function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
73
+ let stdout = "";
74
+ let stderr = "";
75
+ const origOut = process.stdout.write.bind(process.stdout);
76
+ const origErr = process.stderr.write.bind(process.stderr);
77
+ process.stdout.write = ((chunk: unknown) => {
78
+ stdout += String(chunk);
79
+ return true;
80
+ }) as typeof process.stdout.write;
81
+ process.stderr.write = ((chunk: unknown) => {
82
+ stderr += String(chunk);
83
+ return true;
84
+ }) as typeof process.stderr.write;
85
+ let status: number;
86
+ try {
87
+ status = runRescue(argv, { env }).status;
88
+ } finally {
89
+ process.stdout.write = origOut;
90
+ process.stderr.write = origErr;
91
+ }
92
+ return { status, stdout, stderr };
93
+ }
94
+
95
+ describe.skipIf(!toolsAvailable)("rescue drops directory-target reindex symlinks", () => {
96
+ let workDir: string;
97
+ let upstream: string;
98
+ let shimDir: string;
99
+ let floorSha: string;
100
+ let env: NodeJS.ProcessEnv;
101
+
102
+ const gitAt = (cwd: string, ...args: string[]) =>
103
+ execFileSync("git", args, {
104
+ cwd,
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ env: {
107
+ ...process.env,
108
+ GIT_AUTHOR_NAME: "t",
109
+ GIT_AUTHOR_EMAIL: "t@t",
110
+ GIT_COMMITTER_NAME: "t",
111
+ GIT_COMMITTER_EMAIL: "t@t",
112
+ GIT_AUTHOR_DATE: `${EPOCH} +0000`,
113
+ GIT_COMMITTER_DATE: `${EPOCH} +0000`,
114
+ },
115
+ })
116
+ .toString()
117
+ .trim();
118
+
119
+ beforeAll(() => {
120
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-dirsymlink-"));
121
+
122
+ // --- "upstream" repo: a single core/ file so the wipe set includes core/ --
123
+ upstream = path.join(workDir, "upstream");
124
+ fs.mkdirSync(path.join(upstream, "core/knowledge"), { recursive: true });
125
+ gitAt(workDir, "init", "-b", "main", "upstream");
126
+ fs.writeFileSync(path.join(upstream, "core/knowledge/README.md"), "shipped\n");
127
+ gitAt(upstream, "add", "-A");
128
+ gitAt(upstream, "commit", "-m", "floor");
129
+ floorSha = gitAt(upstream, "rev-parse", "HEAD");
130
+
131
+ // --- git shim: redirect `git clone <github-url>` to the local fixture -----
132
+ const realGit =
133
+ execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
134
+ shimDir = path.join(workDir, "shim");
135
+ fs.mkdirSync(shimDir, { recursive: true });
136
+ const shim = `#!/usr/bin/env bash
137
+ if [ "$1" = "clone" ]; then
138
+ args=()
139
+ for a in "$@"; do
140
+ case "$a" in
141
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
142
+ esac
143
+ args+=("$a")
144
+ done
145
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
146
+ fi
147
+ exec ${JSON.stringify(realGit)} "$@"
148
+ `;
149
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
150
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
151
+ });
152
+
153
+ afterAll(() => {
154
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
155
+ });
156
+
157
+ /**
158
+ * Build a fresh local HQ root carrying a directory-target reindex symlink
159
+ * (core/knowledge/ad-creative-engine -> <hqRoot>/personal/knowledge/ad-creative-engine),
160
+ * whose target directory holds a sentinel file that must survive the drop.
161
+ */
162
+ function makeHqRoot(): { hqRoot: string; link: string; targetDir: string; sentinel: string } {
163
+ const hqRoot = fs.mkdtempSync(path.join(workDir, "hq-"));
164
+ // HQ-root shape check: companies/ + one of .claude/|core/|personal/.
165
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
166
+ fs.mkdirSync(path.join(hqRoot, "core/knowledge"), { recursive: true });
167
+ // Unchanged vs upstream so the overlay is a no-op for it.
168
+ fs.writeFileSync(path.join(hqRoot, "core/knowledge/README.md"), "shipped\n");
169
+
170
+ const targetDir = path.join(hqRoot, "personal/knowledge/ad-creative-engine");
171
+ fs.mkdirSync(targetDir, { recursive: true });
172
+ const sentinel = path.join(targetDir, "brief.md");
173
+ fs.writeFileSync(sentinel, "authored locally\n");
174
+
175
+ const link = path.join(hqRoot, "core/knowledge/ad-creative-engine");
176
+ // Absolute target that includes "/personal/" — matches the overlay
177
+ // convention masterSyncSymlinkTarget() recognizes.
178
+ fs.symlinkSync(targetDir, link);
179
+
180
+ return { hqRoot, link, targetDir, sentinel };
181
+ }
182
+
183
+ function rescueArgs(hqRoot: string): string[] {
184
+ return [
185
+ "--hq-root", hqRoot,
186
+ "--source", "test/repo",
187
+ "--ref", "main",
188
+ "--floor-sha", floorSha,
189
+ "--yes",
190
+ "--no-backup",
191
+ ];
192
+ }
193
+
194
+ it("sanity: the mocked rmSync reproduces the macOS EISDIR on a dir-target symlink", () => {
195
+ const { link, targetDir, sentinel } = makeHqRoot();
196
+ // The old apply path used exactly this call — it must throw under the fault.
197
+ expect(() => fs.rmSync(link, { force: true })).toThrow(/Path is a directory/);
198
+ // ...and the fixed path (unlink) removes the link while sparing the target.
199
+ fs.unlinkSync(link);
200
+ expect(fs.existsSync(link)).toBe(false);
201
+ expect(fs.existsSync(targetDir)).toBe(true);
202
+ expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
203
+ });
204
+
205
+ it("does not abort the apply on a directory-target symlink (drops it, spares the target)", () => {
206
+ const { hqRoot, link, targetDir, sentinel } = makeHqRoot();
207
+
208
+ const r = runRescueCapture(rescueArgs(hqRoot), env);
209
+
210
+ // The apply must survive the simulated-macOS rmSync fault: the fix removes
211
+ // the link with unlink semantics, so the whole update no longer rolls back.
212
+ // (The old rmSync-based drop throws EISDIR here → non-zero + rollback.)
213
+ if (r.status !== 0) {
214
+ throw new Error(`rescue aborted on the dir-target symlink (${r.status}):\n${r.stdout}\n${r.stderr}`);
215
+ }
216
+
217
+ // The link itself is gone (reindex.sh re-lays it later)...
218
+ expect(fs.existsSync(link)).toBe(false);
219
+ // ...but the target directory and its contents are untouched — the drop
220
+ // must never follow/recurse into the target.
221
+ expect(fs.existsSync(targetDir)).toBe(true);
222
+ expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
223
+ });
224
+ });