@indigoai-us/hq-cloud 6.14.17 → 6.14.18

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 (39) hide show
  1. package/dist/active-company.d.ts +43 -0
  2. package/dist/active-company.d.ts.map +1 -0
  3. package/dist/active-company.js +132 -0
  4. package/dist/active-company.js.map +1 -0
  5. package/dist/active-company.test.d.ts +2 -0
  6. package/dist/active-company.test.d.ts.map +1 -0
  7. package/dist/active-company.test.js +149 -0
  8. package/dist/active-company.test.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +27 -1
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-planning.js +33 -4
  12. package/dist/bin/sync-runner-planning.js.map +1 -1
  13. package/dist/bin/sync-runner-planning.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-planning.test.js +115 -0
  16. package/dist/bin/sync-runner-planning.test.js.map +1 -0
  17. package/dist/bin/sync-runner.d.ts +22 -7
  18. package/dist/bin/sync-runner.d.ts.map +1 -1
  19. package/dist/bin/sync-runner.js +92 -16
  20. package/dist/bin/sync-runner.js.map +1 -1
  21. package/dist/bin/sync-runner.test.js +357 -5
  22. package/dist/bin/sync-runner.test.js.map +1 -1
  23. package/dist/manifest-reconcile.d.ts +118 -5
  24. package/dist/manifest-reconcile.d.ts.map +1 -1
  25. package/dist/manifest-reconcile.js +319 -62
  26. package/dist/manifest-reconcile.js.map +1 -1
  27. package/dist/manifest-reconcile.test.js +824 -2
  28. package/dist/manifest-reconcile.test.js.map +1 -1
  29. package/package.json +1 -1
  30. package/pnpm-workspace.yaml +1 -1
  31. package/src/active-company.test.ts +188 -0
  32. package/src/active-company.ts +168 -0
  33. package/src/bin/sync-runner-planning.test.ts +131 -0
  34. package/src/bin/sync-runner-planning.ts +60 -7
  35. package/src/bin/sync-runner.test.ts +430 -10
  36. package/src/bin/sync-runner.ts +129 -23
  37. package/src/manifest-reconcile.test.ts +1019 -3
  38. package/src/manifest-reconcile.ts +424 -66
  39. package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
@@ -140,10 +140,18 @@ import {
140
140
  buildFanoutPlan,
141
141
  emitFanoutPlan,
142
142
  resolveMembershipsForRun,
143
+ type RunnerTarget,
144
+ type SetupNeededReason,
143
145
  } from "./sync-runner-planning.js";
146
+ import {
147
+ seedActiveCompany,
148
+ type SeedActiveCompanyOptions,
149
+ type SeedActiveCompanyResult,
150
+ } from "../active-company.js";
144
151
  import {
145
152
  reconcileCompanyManifest,
146
153
  type ManifestReconcileOptions,
154
+ type ManifestReconcileResult,
147
155
  } from "../manifest-reconcile.js";
148
156
  import { executeCompanyFanout } from "./sync-runner-company.js";
149
157
  import { rollupAllComplete } from "./sync-runner-rollup.js";
@@ -333,11 +341,28 @@ export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
333
341
  * specific company".
334
342
  */
335
343
  export type RunnerEvent =
336
- | { type: "setup-needed" }
344
+ | {
345
+ /**
346
+ * The run cannot proceed. `reason` and `pendingInviteCount` are additive
347
+ * and optional by design: a consumer that only reads `type` behaves
348
+ * exactly as before, while /setup and /hq-sync can tell the user WHY they
349
+ * look solo — most usefully, that an invite is still waiting.
350
+ */
351
+ type: "setup-needed";
352
+ reason?: SetupNeededReason;
353
+ pendingInviteCount?: number;
354
+ }
337
355
  | { type: "auth-error"; message: string }
338
356
  | {
357
+ /**
358
+ * The resolved fanout plan. Carries whole `RunnerTarget`s: the runtime
359
+ * payload has always been the plan entries verbatim, so the previous
360
+ * `{uid, slug, name?}` declaration understated the wire contract (it
361
+ * already omitted `bucketName`, `personalMode`, and `journalSlug`).
362
+ * Consumers deserialize leniently and ignore fields they do not know.
363
+ */
339
364
  type: "fanout-plan";
340
- companies: Array<{ uid: string; slug: string; name?: string }>;
365
+ companies: RunnerTarget[];
341
366
  }
342
367
  | ({
343
368
  /**
@@ -632,7 +657,9 @@ export interface RunnerDeps {
632
657
  */
633
658
  reconcileManifest?: (
634
659
  options: ManifestReconcileOptions,
635
- ) => Promise<void>;
660
+ ) => Promise<ManifestReconcileResult>;
661
+ /** Internal test seam for `.hq/config.json` activeCompany seeding. */
662
+ seedActiveCompany?: (options: SeedActiveCompanyOptions) => SeedActiveCompanyResult;
636
663
  /** Internal: set when runRunner is invoked under the per-root operation lock. */
637
664
  operationLockAlreadyHeld?: boolean;
638
665
  /** Internal watch-loop result override; one-shot callers keep exit 0. */
@@ -698,10 +725,11 @@ async function runClaimDance(
698
725
  client: VaultClientSurface,
699
726
  claims: IdTokenClaims,
700
727
  stderr: { write: (chunk: string) => boolean | void },
701
- ): Promise<void> {
728
+ reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
729
+ ): Promise<number> {
702
730
  try {
703
731
  const pending = await client.listMyPendingInvitesByEmail();
704
- if (pending.length === 0) return;
732
+ if (pending.length === 0) return 0;
705
733
 
706
734
  const displayName =
707
735
  claims.name ??
@@ -710,10 +738,15 @@ async function runClaimDance(
710
738
  "";
711
739
  const ownerSub = claims.sub ?? "";
712
740
  if (!ownerSub || !displayName) {
713
- stderr.write(
714
- "hq-sync-runner: skipping claim-dance — idToken missing sub/name\n",
715
- );
716
- return;
741
+ reportDiagnostic({
742
+ component: "claim-dance",
743
+ event: "runner.claim_dance.skipped",
744
+ message: "skipping claim-dance — idToken missing sub/name",
745
+ context: { pendingInviteCount: pending.length },
746
+ });
747
+ // The invites are real and still pending; the caller must be able to say
748
+ // so even though we could not claim them.
749
+ return pending.length;
717
750
  }
718
751
 
719
752
  const person = await client.ensureMyPersonEntity({
@@ -721,9 +754,19 @@ async function runClaimDance(
721
754
  displayName,
722
755
  });
723
756
  await client.claimPendingInvitesByEmail(person.uid);
757
+ return pending.length;
724
758
  } catch (err) {
725
- const msg = err instanceof Error ? err.message : String(err);
726
- stderr.write(`hq-sync-runner: claim-dance skipped${msg}\n`);
759
+ // Non-throwing by contract: a claim-dance failure must never take down a
760
+ // sync. But it stops being invisible a systematic failure here is exactly
761
+ // what makes a joiner look permanently solo.
762
+ reportDiagnostic({
763
+ component: "claim-dance",
764
+ event: "runner.claim_dance.skipped",
765
+ message: "claim-dance skipped",
766
+ err,
767
+ context: {},
768
+ });
769
+ return 0;
727
770
  }
728
771
  }
729
772
 
@@ -1276,11 +1319,18 @@ export async function runRunner(
1276
1319
  client,
1277
1320
  claims,
1278
1321
  stderr,
1279
- runClaimDance,
1322
+ runClaimDance: (client, claims, stderr) =>
1323
+ runClaimDance(client, claims, stderr, reportDiagnostic),
1280
1324
  listMemberships: listMembershipsWithRetry,
1281
1325
  });
1282
1326
  if (resolution.status === "setup-needed") {
1283
- emit({ type: "setup-needed" });
1327
+ emit({
1328
+ type: "setup-needed",
1329
+ reason: resolution.reason,
1330
+ ...(resolution.pendingInviteCount === undefined
1331
+ ? {}
1332
+ : { pendingInviteCount: resolution.pendingInviteCount }),
1333
+ });
1284
1334
  return 0;
1285
1335
  }
1286
1336
  memberships = resolution.memberships;
@@ -1335,7 +1385,7 @@ export async function runRunner(
1335
1385
  return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1336
1386
  }
1337
1387
  if (targetPlan.status === "setup-needed") {
1338
- emit({ type: "setup-needed" });
1388
+ emit({ type: "setup-needed", reason: targetPlan.reason });
1339
1389
  return 0;
1340
1390
  }
1341
1391
  const plan = targetPlan.plan;
@@ -1386,19 +1436,43 @@ export async function runRunner(
1386
1436
  // The personal-vault target runs last and can pull an older copy of the
1387
1437
  // manifest. Reconcile only after the entire pull fanout has settled so the
1388
1438
  // successfully materialized cloud directories restore their own entries.
1389
- if (parsed.direction !== "push") {
1439
+ //
1440
+ // `--companies` only. A single-company run (`--company <slug>`) and a
1441
+ // `--personal` run both see just one leg of the fanout, so neither has the
1442
+ // full picture the manifest is reconciled against; treating a partial view as
1443
+ // authoritative is how entries get lost. A push-only pass materializes
1444
+ // nothing new locally, so it has nothing to reconcile either.
1445
+ if (parsed.companies && parsed.direction !== "push") {
1446
+ const completedCompanySlugs = new Set(
1447
+ Array.from(fanout.stateByCompany.entries())
1448
+ .filter(([, state]) => state.status === "complete")
1449
+ .map(([slug]) => slug),
1450
+ );
1451
+ // The personal vault is never a manifest entry. The reconciler filters it
1452
+ // too, but that is its own internal invariant — excluding it here means
1453
+ // the personal target is never even handed across the seam.
1454
+ const companyTargets = plan.filter(
1455
+ (target) => target.personalMode !== true && target.slug !== "personal",
1456
+ );
1390
1457
  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)({
1458
+ const result = await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1397
1459
  hqRoot: parsed.hqRoot,
1398
- targets: plan,
1460
+ targets: companyTargets,
1399
1461
  completedCompanySlugs,
1400
- getEntity: client.entity.get,
1462
+ reportDiagnostic: (diagnostic) =>
1463
+ reportDiagnostic({ component: "manifest-reconcile", ...diagnostic }),
1401
1464
  });
1465
+ // Only on an actual write. A steady-state sync stays silent, so an
1466
+ // unexpected diagnostic rate is itself the signal that skip-if-unchanged
1467
+ // has regressed.
1468
+ if (result.written) {
1469
+ reportDiagnostic({
1470
+ component: "manifest-reconcile",
1471
+ event: "manifest-reconciled",
1472
+ message: "restored company entries in companies/manifest.yaml",
1473
+ context: { added: result.added, updated: result.updated },
1474
+ });
1475
+ }
1402
1476
  } catch (err) {
1403
1477
  reportDiagnostic({
1404
1478
  component: "manifest-reconcile",
@@ -1408,6 +1482,38 @@ export async function runRunner(
1408
1482
  context: { hqRoot: parsed.hqRoot },
1409
1483
  });
1410
1484
  }
1485
+
1486
+ // A joiner whose manifest entry was just restored still has nothing telling
1487
+ // HQ which company is current. Seed it only when the answer cannot be wrong
1488
+ // — deliberately its own try, so a manifest failure does not also cost the
1489
+ // user their routing.
1490
+ try {
1491
+ const localCompanySlugs = companyTargets
1492
+ .map((target) => target.slug)
1493
+ .filter((slug) => completedCompanySlugs.has(slug));
1494
+ const seeded = (deps.seedActiveCompany ?? seedActiveCompany)({
1495
+ hqRoot: parsed.hqRoot,
1496
+ companySlugs: localCompanySlugs,
1497
+ reportDiagnostic: (diagnostic) =>
1498
+ reportDiagnostic({ component: "active-company", ...diagnostic }),
1499
+ });
1500
+ if (seeded.written) {
1501
+ reportDiagnostic({
1502
+ component: "active-company",
1503
+ event: "active-company-seeded",
1504
+ message: "seeded activeCompany in .hq/config.json",
1505
+ context: { activeCompany: seeded.activeCompany },
1506
+ });
1507
+ }
1508
+ } catch (err) {
1509
+ reportDiagnostic({
1510
+ component: "active-company",
1511
+ event: "runner.active_company.failed",
1512
+ message: "seeding activeCompany failed",
1513
+ err,
1514
+ context: { hqRoot: parsed.hqRoot },
1515
+ });
1516
+ }
1411
1517
  }
1412
1518
 
1413
1519
  if (rollup.needsReindex) {