@indigoai-us/hq-cloud 6.14.0 → 6.14.2

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 (167) hide show
  1. package/dist/bin/sync-runner-company.d.ts +4 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +50 -1
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +3 -2
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +21 -7
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner.d.ts +6 -2
  10. package/dist/bin/sync-runner.d.ts.map +1 -1
  11. package/dist/bin/sync-runner.js +162 -11
  12. package/dist/bin/sync-runner.js.map +1 -1
  13. package/dist/bin/sync-runner.test.js +155 -17
  14. package/dist/bin/sync-runner.test.js.map +1 -1
  15. package/dist/cli/accept.d.ts.map +1 -1
  16. package/dist/cli/accept.js +29 -0
  17. package/dist/cli/accept.js.map +1 -1
  18. package/dist/cli/invite.d.ts.map +1 -1
  19. package/dist/cli/invite.js +33 -0
  20. package/dist/cli/invite.js.map +1 -1
  21. package/dist/cli/promote.d.ts.map +1 -1
  22. package/dist/cli/promote.js +33 -0
  23. package/dist/cli/promote.js.map +1 -1
  24. package/dist/cli/reindex.d.ts +23 -11
  25. package/dist/cli/reindex.d.ts.map +1 -1
  26. package/dist/cli/reindex.js +39 -10
  27. package/dist/cli/reindex.js.map +1 -1
  28. package/dist/cli/reindex.test.js +40 -13
  29. package/dist/cli/reindex.test.js.map +1 -1
  30. package/dist/cli/share.js +47 -4
  31. package/dist/cli/share.js.map +1 -1
  32. package/dist/cli/share.test.js +123 -2
  33. package/dist/cli/share.test.js.map +1 -1
  34. package/dist/cli/sync-scope.test.js +84 -0
  35. package/dist/cli/sync-scope.test.js.map +1 -1
  36. package/dist/cli/sync.d.ts +44 -3
  37. package/dist/cli/sync.d.ts.map +1 -1
  38. package/dist/cli/sync.js +156 -7
  39. package/dist/cli/sync.js.map +1 -1
  40. package/dist/cli/sync.test.js +133 -0
  41. package/dist/cli/sync.test.js.map +1 -1
  42. package/dist/company-resolver.d.ts +21 -0
  43. package/dist/company-resolver.d.ts.map +1 -1
  44. package/dist/company-resolver.js +31 -6
  45. package/dist/company-resolver.js.map +1 -1
  46. package/dist/company-resolver.test.js +44 -1
  47. package/dist/company-resolver.test.js.map +1 -1
  48. package/dist/ignore.d.ts +10 -0
  49. package/dist/ignore.d.ts.map +1 -1
  50. package/dist/ignore.js +23 -2
  51. package/dist/ignore.js.map +1 -1
  52. package/dist/ignore.test.js +13 -1
  53. package/dist/ignore.test.js.map +1 -1
  54. package/dist/index.d.ts +4 -2
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +4 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/journal.d.ts +12 -0
  59. package/dist/journal.d.ts.map +1 -1
  60. package/dist/journal.js +12 -0
  61. package/dist/journal.js.map +1 -1
  62. package/dist/lib/exit-codes.d.ts +25 -0
  63. package/dist/lib/exit-codes.d.ts.map +1 -0
  64. package/dist/lib/exit-codes.js +25 -0
  65. package/dist/lib/exit-codes.js.map +1 -0
  66. package/dist/object-io.d.ts.map +1 -1
  67. package/dist/object-io.js +45 -14
  68. package/dist/object-io.js.map +1 -1
  69. package/dist/object-io.test.js +61 -1
  70. package/dist/object-io.test.js.map +1 -1
  71. package/dist/operation-lock.d.ts +36 -6
  72. package/dist/operation-lock.d.ts.map +1 -1
  73. package/dist/operation-lock.js +34 -11
  74. package/dist/operation-lock.js.map +1 -1
  75. package/dist/operation-lock.test.js +30 -0
  76. package/dist/operation-lock.test.js.map +1 -1
  77. package/dist/s3.d.ts +1 -0
  78. package/dist/s3.d.ts.map +1 -1
  79. package/dist/s3.js +75 -2
  80. package/dist/s3.js.map +1 -1
  81. package/dist/s3.test.js +56 -1
  82. package/dist/s3.test.js.map +1 -1
  83. package/dist/scope-shrink.d.ts +12 -0
  84. package/dist/scope-shrink.d.ts.map +1 -1
  85. package/dist/scope-shrink.js +12 -0
  86. package/dist/scope-shrink.js.map +1 -1
  87. package/dist/scope-shrink.test.js +31 -0
  88. package/dist/scope-shrink.test.js.map +1 -1
  89. package/dist/skill-telemetry.d.ts +41 -0
  90. package/dist/skill-telemetry.d.ts.map +1 -1
  91. package/dist/skill-telemetry.js +111 -7
  92. package/dist/skill-telemetry.js.map +1 -1
  93. package/dist/skill-telemetry.test.js +187 -5
  94. package/dist/skill-telemetry.test.js.map +1 -1
  95. package/dist/sync/event-sync.d.ts +3 -0
  96. package/dist/sync/event-sync.d.ts.map +1 -1
  97. package/dist/sync/event-sync.js +23 -0
  98. package/dist/sync/event-sync.js.map +1 -1
  99. package/dist/sync/pull-scope.d.ts +21 -0
  100. package/dist/sync/pull-scope.d.ts.map +1 -1
  101. package/dist/sync/pull-scope.js +46 -13
  102. package/dist/sync/pull-scope.js.map +1 -1
  103. package/dist/telemetry-events.d.ts +31 -0
  104. package/dist/telemetry-events.d.ts.map +1 -0
  105. package/dist/telemetry-events.js +140 -0
  106. package/dist/telemetry-events.js.map +1 -0
  107. package/dist/telemetry-events.test.d.ts +2 -0
  108. package/dist/telemetry-events.test.d.ts.map +1 -0
  109. package/dist/telemetry-events.test.js +73 -0
  110. package/dist/telemetry-events.test.js.map +1 -0
  111. package/dist/telemetry.js +1 -1
  112. package/dist/telemetry.js.map +1 -1
  113. package/dist/vault-client.d.ts +38 -5
  114. package/dist/vault-client.d.ts.map +1 -1
  115. package/dist/vault-client.js +69 -6
  116. package/dist/vault-client.js.map +1 -1
  117. package/dist/vault-client.test.js +31 -1
  118. package/dist/vault-client.test.js.map +1 -1
  119. package/dist/watcher.d.ts +7 -0
  120. package/dist/watcher.d.ts.map +1 -1
  121. package/dist/watcher.js +53 -1
  122. package/dist/watcher.js.map +1 -1
  123. package/dist/watcher.test.js +57 -0
  124. package/dist/watcher.test.js.map +1 -1
  125. package/package.json +1 -1
  126. package/pnpm-workspace.yaml +2 -0
  127. package/scripts/presign-transport-e2e.mjs +2 -2
  128. package/src/bin/sync-runner-company.ts +65 -1
  129. package/src/bin/sync-runner-watch-loop.ts +24 -8
  130. package/src/bin/sync-runner.test.ts +194 -16
  131. package/src/bin/sync-runner.ts +209 -11
  132. package/src/cli/accept.ts +27 -0
  133. package/src/cli/invite.ts +31 -0
  134. package/src/cli/promote.ts +31 -0
  135. package/src/cli/reindex.test.ts +44 -13
  136. package/src/cli/reindex.ts +74 -21
  137. package/src/cli/share.test.ts +139 -2
  138. package/src/cli/share.ts +48 -2
  139. package/src/cli/sync-scope.test.ts +90 -0
  140. package/src/cli/sync.test.ts +154 -0
  141. package/src/cli/sync.ts +224 -5
  142. package/src/company-resolver.test.ts +51 -0
  143. package/src/company-resolver.ts +40 -5
  144. package/src/ignore.test.ts +18 -1
  145. package/src/ignore.ts +24 -2
  146. package/src/index.ts +22 -1
  147. package/src/journal.ts +12 -0
  148. package/src/lib/exit-codes.ts +24 -0
  149. package/src/object-io.test.ts +66 -1
  150. package/src/object-io.ts +51 -14
  151. package/src/operation-lock.test.ts +33 -0
  152. package/src/operation-lock.ts +46 -11
  153. package/src/s3.test.ts +87 -0
  154. package/src/s3.ts +93 -2
  155. package/src/scope-shrink.test.ts +32 -0
  156. package/src/scope-shrink.ts +12 -0
  157. package/src/skill-telemetry.test.ts +247 -4
  158. package/src/skill-telemetry.ts +134 -6
  159. package/src/sync/event-sync.ts +37 -0
  160. package/src/sync/pull-scope.ts +62 -13
  161. package/src/telemetry-events.test.ts +88 -0
  162. package/src/telemetry-events.ts +205 -0
  163. package/src/telemetry.ts +1 -1
  164. package/src/vault-client.test.ts +39 -1
  165. package/src/vault-client.ts +115 -14
  166. package/src/watcher.test.ts +82 -0
  167. package/src/watcher.ts +69 -0
@@ -21,6 +21,7 @@ import {
21
21
  buildTargetedPushArgv,
22
22
  resolvePullScope,
23
23
  readPinnedPrefixes,
24
+ SESSIONS_SCOPE_PREFIX,
24
25
  defaultCollectTelemetry,
25
26
  } from "./sync-runner.js";
26
27
  import { PresignObjectIO, S3SdkObjectIO } from "../object-io.js";
@@ -36,6 +37,7 @@ import { PERSONAL_VAULT_JOURNAL_SLUG } from "../journal.js";
36
37
  import { HQ_CLOUD_VERSION } from "../version.js";
37
38
  import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
38
39
  import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
40
+ import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
39
41
  import { isCoveredByAny } from "../prefix-coalesce.js";
40
42
  import type { SyncResult, SyncOptions } from "../cli/sync.js";
41
43
  import type { ShareResult, ShareOptions } from "../cli/share.js";
@@ -43,6 +45,7 @@ import type {
43
45
  Membership,
44
46
  EntityInfo,
45
47
  PendingInviteByEmail,
48
+ TelemetryEventsBatch,
46
49
  } from "../vault-client.js";
47
50
  import { VaultAuthError } from "../vault-client.js";
48
51
  import type { PushEvent } from "../sync/push-event.js";
@@ -1601,6 +1604,60 @@ describe("all-complete aggregate", () => {
1601
1604
  expect(deps.stderr.raw()).toContain("runner.telemetry.timeout");
1602
1605
  });
1603
1606
 
1607
+ it("emits action telemetry for completion and telemetry transport failure does not block completion", async () => {
1608
+ const posts: TelemetryEventsBatch[] = [];
1609
+ const client: VaultClientSurface = {
1610
+ ...makeVaultStub({
1611
+ memberships: [{ companyUid: "cmp_a" }],
1612
+ entityGet: (uid: string) =>
1613
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1614
+ }),
1615
+ postTelemetryEvents: vi.fn(async (batch) => {
1616
+ posts.push(batch);
1617
+ throw new Error("telemetry transport down");
1618
+ }),
1619
+ };
1620
+ const deps = makeDeps({
1621
+ createVaultClient: () => client,
1622
+ collectTelemetry: vi.fn().mockResolvedValue(undefined),
1623
+ sync: vi
1624
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
1625
+ .mockResolvedValue(
1626
+ defaultSyncResult({
1627
+ filesDownloaded: 2,
1628
+ bytesDownloaded: 300,
1629
+ }),
1630
+ ),
1631
+ });
1632
+
1633
+ const code = await runRunner(["--companies"], deps);
1634
+ await new Promise((resolve) => setTimeout(resolve, 0));
1635
+
1636
+ expect(code).toBe(0);
1637
+ expect(deps.stdout.events().some((e) => e.type === "all-complete")).toBe(
1638
+ true,
1639
+ );
1640
+ const completed = posts
1641
+ .flatMap((post) => post.events)
1642
+ .find((event) => event.eventName === "sync_runner_completed");
1643
+ expect(completed).toMatchObject({
1644
+ app: "hq-cloud",
1645
+ source: "sync-runner",
1646
+ schemaVersion: 1,
1647
+ properties: {
1648
+ result: "completed",
1649
+ attemptedCount: 1,
1650
+ companyCount: 1,
1651
+ errorCount: 0,
1652
+ filesDownloaded: 2,
1653
+ bytesDownloaded: 300,
1654
+ filesUploaded: 0,
1655
+ bytesUploaded: 0,
1656
+ },
1657
+ });
1658
+ expect(deps.stderr.raw()).toContain("runner.telemetry_events.nonfatal");
1659
+ });
1660
+
1604
1661
  it("sums filesDownloaded and bytesDownloaded across all companies", async () => {
1605
1662
  const slugs: Record<string, string> = { cmp_a: "acme", cmp_b: "beta" };
1606
1663
  const deps = makeDeps({
@@ -2407,6 +2464,13 @@ describe("personal slot fanout", () => {
2407
2464
  const keys = Object.keys(args);
2408
2465
  expect(keys).not.toContain("personalMode");
2409
2466
  expect(keys).not.toContain("journalSlug");
2467
+ // Positive assertion on the runner→sync() glue (sync-runner-company.ts:278):
2468
+ // the push-only sessions exclusion IS threaded into the company pull leg.
2469
+ // The ABSENT-key checks above would still pass if a regression dropped the
2470
+ // `...(excludePrefixes.length > 0 ? { excludePrefixes } : {})` spread or
2471
+ // flipped its guard — silently re-enabling full session pulls. Pin the
2472
+ // exact leak against the exported constant, not a string literal.
2473
+ expect(args.excludePrefixes).toEqual([SESSIONS_SCOPE_PREFIX]);
2410
2474
  }
2411
2475
  });
2412
2476
 
@@ -3504,9 +3568,9 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3504
3568
  expect(clock.pendingTimerCount()).toBe(0);
3505
3569
  });
3506
3570
 
3507
- it("a non-zero poll pass exit returns immediately (hard error surfaces)", async () => {
3571
+ it("a hard-error (exit 1) poll pass returns immediately", async () => {
3508
3572
  const watcher = makeWatcherStub();
3509
- const runPass = vi.fn().mockResolvedValue(2);
3573
+ const runPass = vi.fn().mockResolvedValue(1);
3510
3574
 
3511
3575
  const code = await runRunnerWithLoop(
3512
3576
  ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
@@ -3518,7 +3582,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3518
3582
  onShutdownSignal: () => () => {},
3519
3583
  },
3520
3584
  );
3521
- expect(code).toBe(2);
3585
+ expect(code).toBe(1);
3522
3586
  expect(watcher.disposed).toBe(true);
3523
3587
  });
3524
3588
 
@@ -3533,7 +3597,7 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3533
3597
  n++;
3534
3598
  // First pass: machine is offline (transient). Second pass: a real hard
3535
3599
  // error, used here only to end the otherwise-infinite loop deterministically.
3536
- return n === 1 ? TRANSIENT_NETWORK_EXIT : 2;
3600
+ return n === 1 ? TRANSIENT_NETWORK_EXIT : 1;
3537
3601
  });
3538
3602
 
3539
3603
  const code = await runRunnerWithLoop(
@@ -3550,11 +3614,47 @@ describe("runRunnerWithLoop — event-push wiring", () => {
3550
3614
  // The transient pass did NOT end the loop — it ran again…
3551
3615
  expect(runPass).toHaveBeenCalledTimes(2);
3552
3616
  // …and the transient code was never surfaced as the watcher's exit code.
3553
- expect(code).toBe(2);
3617
+ expect(code).toBe(1);
3554
3618
  expect(code).not.toBe(TRANSIENT_NETWORK_EXIT);
3555
3619
  expect(watcher.disposed).toBe(true);
3556
3620
  });
3557
3621
 
3622
+ it("a PARTIAL_SYNC_EXIT poll pass does NOT surface as a crash — the loop retries", async () => {
3623
+ // A partial pass (some per-file transfers threw: oversized object on an
3624
+ // old runner, presign denial, expired token, corrupt vault key) must keep
3625
+ // the watcher alive. Exiting on it got the watcher hot-respawned by the
3626
+ // menubar supervisor and Sentry-captured on EVERY failing pass — the
3627
+ // July 2026 "watcher exited unexpectedly (code=Some(2))" alert flood. The
3628
+ // per-file errors still surface via ndjson `error` events; the loop logs
3629
+ // and retries next tick. A one-shot `hq sync` still exits 2.
3630
+ const watcher = makeWatcherStub();
3631
+ let n = 0;
3632
+ const runPass = vi.fn().mockImplementation(async () => {
3633
+ n++;
3634
+ // Two partial passes prove a DETERMINISTIC per-file failure doesn't kill
3635
+ // the loop either. A hard error then ends the otherwise-infinite loop.
3636
+ return n <= 2 ? PARTIAL_SYNC_EXIT : 1;
3637
+ });
3638
+
3639
+ const code = await runRunnerWithLoop(
3640
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
3641
+ {
3642
+ runPass,
3643
+ clock: new FakeClock(),
3644
+ createWatcher: () => watcher,
3645
+ sleep: () => Promise.resolve(),
3646
+ onShutdownSignal: () => () => {},
3647
+ },
3648
+ );
3649
+
3650
+ // Both partial passes stayed alive — the loop ran a third time…
3651
+ expect(runPass).toHaveBeenCalledTimes(3);
3652
+ // …and the partial code was never surfaced as the watcher's exit code.
3653
+ expect(code).toBe(1);
3654
+ expect(code).not.toBe(PARTIAL_SYNC_EXIT);
3655
+ expect(watcher.disposed).toBe(true);
3656
+ });
3657
+
3558
3658
  it("without --event-push, no watcher is created (poll-only safety net)", async () => {
3559
3659
  let triggerShutdown = () => {};
3560
3660
  const createWatcher = vi.fn(() => makeWatcherStub());
@@ -4208,7 +4308,85 @@ describe("resolvePullScope", () => {
4208
4308
 
4209
4309
  it("returns all when the client has no getMembershipSyncConfig", async () => {
4210
4310
  const scope = await resolvePullScope(stubClient({}), "cmp_a", "acme");
4211
- expect(scope).toEqual({ syncMode: "all" });
4311
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4312
+ });
4313
+
4314
+ // US-006 AC4: the sessions prefix is push-only, so `resolvePullScope` stamps
4315
+ // `excludePrefixes: ["sessions/"]` on EVERY return — including `all`
4316
+ // (full-bucket), owner→all, agent→all, shared, and custom. A single missed
4317
+ // return site would silently regress to a full session pull; this test pins
4318
+ // the invariant across every mode the resolver can return.
4319
+ it("stamps the push-only sessions exclusion on EVERY resolved mode (AC4)", async () => {
4320
+ expect(SESSIONS_SCOPE_PREFIX).toBe("sessions/");
4321
+
4322
+ const modes = [
4323
+ // no-client early return → all
4324
+ await resolvePullScope(stubClient({}), "cmp_a", "acme"),
4325
+ // owner role-bypass → all
4326
+ await resolvePullScope(
4327
+ stubClient({
4328
+ listMyMemberships: async () => [ownerMembership("cmp_a", "mk_owner")],
4329
+ }),
4330
+ "cmp_a",
4331
+ "acme",
4332
+ ),
4333
+ // agent membership → all (short-circuit before the always-403 endpoint)
4334
+ await resolvePullScope(
4335
+ stubClient({
4336
+ listMyMemberships: async () => [membership("cmp_a", "agt_9#cmp_a")],
4337
+ }),
4338
+ "cmp_a",
4339
+ "acme",
4340
+ ),
4341
+ // config-all → all
4342
+ await resolvePullScope(
4343
+ stubClient({
4344
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
4345
+ getMembershipSyncConfig: async () => ({
4346
+ membershipId: "mk_a",
4347
+ syncMode: "all",
4348
+ isDefault: true,
4349
+ }),
4350
+ }),
4351
+ "cmp_a",
4352
+ "acme",
4353
+ ),
4354
+ // shared
4355
+ await resolvePullScope(
4356
+ stubClient({
4357
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
4358
+ getMembershipSyncConfig: async () => ({
4359
+ membershipId: "mk_a",
4360
+ syncMode: "shared",
4361
+ isDefault: false,
4362
+ }),
4363
+ listMyExplicitGrants: async () =>
4364
+ [
4365
+ { companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" },
4366
+ ] as never,
4367
+ }),
4368
+ "cmp_a",
4369
+ "acme",
4370
+ ),
4371
+ // custom
4372
+ await resolvePullScope(
4373
+ stubClient({
4374
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
4375
+ getMembershipSyncConfig: async () => ({
4376
+ membershipId: "mk_a",
4377
+ syncMode: "custom",
4378
+ customPaths: ["projects/x/"],
4379
+ isDefault: false,
4380
+ }),
4381
+ }),
4382
+ "cmp_a",
4383
+ "acme",
4384
+ ),
4385
+ ];
4386
+
4387
+ for (const scope of modes) {
4388
+ expect(scope.excludePrefixes).toEqual([SESSIONS_SCOPE_PREFIX]);
4389
+ }
4212
4390
  });
4213
4391
 
4214
4392
  // OWNER role-bypass regression (feedback_67bdb8a4 / feedback_a46c3b37).
@@ -4238,7 +4416,7 @@ describe("resolvePullScope", () => {
4238
4416
  "cmp_a",
4239
4417
  "acme",
4240
4418
  );
4241
- expect(scope).toEqual({ syncMode: "all" });
4419
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4242
4420
  // The short-circuit must fire BEFORE any grant resolution.
4243
4421
  expect(grantsCalled).toBe(false);
4244
4422
  });
@@ -4264,7 +4442,7 @@ describe("resolvePullScope", () => {
4264
4442
  // Even a `custom` row cannot scope an owner below their role on the sync
4265
4443
  // path (the server would 403 their own writes), so `all` wins and the
4266
4444
  // config fetch is never needed.
4267
- expect(scope).toEqual({ syncMode: "all" });
4445
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4268
4446
  expect(configCalled).toBe(false);
4269
4447
  });
4270
4448
 
@@ -4285,7 +4463,7 @@ describe("resolvePullScope", () => {
4285
4463
  "cmp_a",
4286
4464
  "acme",
4287
4465
  );
4288
- expect(scope).toEqual({ syncMode: "shared", prefixSet: ["knowledge/"] });
4466
+ expect(scope).toEqual({ syncMode: "shared", prefixSet: ["knowledge/"], excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4289
4467
  });
4290
4468
 
4291
4469
  it("returns all for an all-mode membership (no prefixSet)", async () => {
@@ -4301,7 +4479,7 @@ describe("resolvePullScope", () => {
4301
4479
  "cmp_a",
4302
4480
  "acme",
4303
4481
  );
4304
- expect(scope).toEqual({ syncMode: "all" });
4482
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4305
4483
  });
4306
4484
 
4307
4485
  it("returns all for an AGENT membership WITHOUT calling sync-config (HQ-1R: agents own no person entity → server 403s the person-gate)", async () => {
@@ -4325,7 +4503,7 @@ describe("resolvePullScope", () => {
4325
4503
  "acme",
4326
4504
  );
4327
4505
  expect(called).toBe(false); // never hit the always-403 endpoint for an agent
4328
- expect(scope).toEqual({ syncMode: "all" });
4506
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4329
4507
  });
4330
4508
 
4331
4509
  it("coalesces explicit grants for a shared-mode membership", async () => {
@@ -4445,7 +4623,7 @@ describe("resolvePullScope", () => {
4445
4623
  // A `*` grant = everything. Because coalescePrefixes drops the empty
4446
4624
  // prefix (which would otherwise collapse to "nothing" and prune the
4447
4625
  // tree), an everything-scope resolves to full-access `all`.
4448
- expect(scope).toEqual({ syncMode: "all" });
4626
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4449
4627
  });
4450
4628
 
4451
4629
  it("uses customPaths for a custom-mode membership", async () => {
@@ -4480,7 +4658,7 @@ describe("resolvePullScope", () => {
4480
4658
  "cmp_a",
4481
4659
  "acme",
4482
4660
  );
4483
- expect(scope).toEqual({ syncMode: "all" });
4661
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4484
4662
  });
4485
4663
 
4486
4664
  it("allows a genuinely-empty shared scope (grants method present, returns [])", async () => {
@@ -4497,7 +4675,7 @@ describe("resolvePullScope", () => {
4497
4675
  "cmp_a",
4498
4676
  "acme",
4499
4677
  );
4500
- expect(scope).toEqual({ syncMode: "shared", prefixSet: [] });
4678
+ expect(scope).toEqual({ syncMode: "shared", prefixSet: [], excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4501
4679
  });
4502
4680
 
4503
4681
  it("degrades to all when the membership is not found", async () => {
@@ -4511,7 +4689,7 @@ describe("resolvePullScope", () => {
4511
4689
  "cmp_a",
4512
4690
  "acme",
4513
4691
  );
4514
- expect(scope).toEqual({ syncMode: "all" });
4692
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4515
4693
  });
4516
4694
 
4517
4695
  it("degrades to all when sync-config resolution throws (never prune on error)", async () => {
@@ -4525,7 +4703,7 @@ describe("resolvePullScope", () => {
4525
4703
  "cmp_a",
4526
4704
  "acme",
4527
4705
  );
4528
- expect(scope).toEqual({ syncMode: "all" });
4706
+ expect(scope).toEqual({ syncMode: "all", excludePrefixes: [SESSIONS_SCOPE_PREFIX] });
4529
4707
  });
4530
4708
 
4531
4709
  // ── pin union (Phase C: hq files get) ──────────────────────────────────────
@@ -59,6 +59,7 @@
59
59
  import * as os from "os";
60
60
  import * as path from "path";
61
61
  import * as fs from "fs";
62
+ import { randomUUID } from "node:crypto";
62
63
  import { fileURLToPath } from "url";
63
64
  import {
64
65
  getValidAccessToken,
@@ -78,6 +79,7 @@ import { pickCanonicalPersonEntity } from "../vault-client.js";
78
79
  import {
79
80
  resolvePullScope,
80
81
  readPinnedPrefixes,
82
+ SESSIONS_SCOPE_PREFIX,
81
83
  type PullScope,
82
84
  } from "../sync/pull-scope.js";
83
85
  import {
@@ -103,10 +105,15 @@ import {
103
105
  import { HQ_CLOUD_VERSION } from "../version.js";
104
106
  import { collectAndSendTelemetry } from "../telemetry.js";
105
107
  import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
108
+ import { emitCloudTelemetry } from "../telemetry-events.js";
106
109
  import { reindexAfterSync } from "../qmd-reindex.js";
107
110
  import type { ReindexOptions as QmdReindexOptions } from "../qmd-reindex.js";
108
111
  import { reindex as defaultReindex } from "../cli/reindex.js";
109
112
  import type { ReindexOptions, ReindexResult } from "../cli/reindex.js";
113
+ import type {
114
+ TelemetryEventsBatch,
115
+ TelemetryEventsIngestResult,
116
+ } from "../vault-client.js";
110
117
  import { pruneConflictIndex } from "../lib/conflict-index.js";
111
118
  import { materializeCodexAgents } from "../agent-codex-instructions.js";
112
119
  import { getOrCreateMachineId } from "../lib/machine-id.js";
@@ -114,6 +121,7 @@ import {
114
121
  isTransientNetworkError,
115
122
  TRANSIENT_NETWORK_EXIT,
116
123
  } from "../lib/net-errors.js";
124
+ import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
117
125
  import { describeError } from "../lib/describe-error.js";
118
126
  import type { Clock, TreeChangeBatch } from "../watcher.js";
119
127
  import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
@@ -175,7 +183,7 @@ const DEFAULT_COGNITO: CognitoAuthConfig = {
175
183
  };
176
184
 
177
185
  const DEFAULT_VAULT_API_URL =
178
- process.env.HQ_VAULT_API_URL ?? "https://hqapi.getindigo.ai";
186
+ process.env.HQ_VAULT_API_URL ?? "https://hqapi.hq.computer";
179
187
 
180
188
  const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
181
189
  const DEFAULT_TELEMETRY_TIMEOUT_MS = 120_000;
@@ -445,6 +453,10 @@ export interface VaultClientSurface {
445
453
  // interface; when absent, `resolvePullScope` degrades to `all`.
446
454
  getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
447
455
  listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
456
+ postTelemetryEvents?: (
457
+ batch: TelemetryEventsBatch,
458
+ options?: { timeoutMs?: number },
459
+ ) => Promise<TelemetryEventsIngestResult>;
448
460
  }
449
461
 
450
462
  interface RunnerDiagnostic {
@@ -499,7 +511,7 @@ function createRunnerDiagnosticReporter(
499
511
  // (hq-cli) share ONE scope resolver — the drift between them was the root
500
512
  // cause of the all→shared scope-shrink wedge (DEV-1768). Re-exported here so
501
513
  // existing importers (and the runner test suite) keep their import path.
502
- export { resolvePullScope, readPinnedPrefixes };
514
+ export { resolvePullScope, readPinnedPrefixes, SESSIONS_SCOPE_PREFIX };
503
515
  export type { PullScope };
504
516
 
505
517
  /**
@@ -1123,6 +1135,36 @@ export async function runRunner(
1123
1135
  claims?.sub && claims?.email
1124
1136
  ? { userSub: claims.sub, email: claims.email }
1125
1137
  : undefined;
1138
+ const telemetrySessionId = `sync_${randomUUID()}`;
1139
+ void emitCloudTelemetry(
1140
+ client,
1141
+ {
1142
+ eventName: "sync_runner_session_started",
1143
+ source: "sync-runner",
1144
+ sessionId: telemetrySessionId,
1145
+ properties: {
1146
+ direction: parsed.direction,
1147
+ targetMode: parsed.personal
1148
+ ? "personal"
1149
+ : parsed.company
1150
+ ? "single_company"
1151
+ : "companies",
1152
+ watchState: parsed.watch ? "enabled" : "disabled",
1153
+ eventPushState: parsed.eventPush ? "enabled" : "disabled",
1154
+ scopePathCount: parsed.scopePaths?.length ?? 0,
1155
+ },
1156
+ },
1157
+ {
1158
+ claims,
1159
+ log: (message) =>
1160
+ reportDiagnostic({
1161
+ component: "telemetry-events",
1162
+ event: "runner.telemetry_events.nonfatal",
1163
+ message,
1164
+ context: { source: "sync-runner" },
1165
+ }),
1166
+ },
1167
+ );
1126
1168
 
1127
1169
  // ---- transport selection (presigned-URL for cmp_, direct-S3/STS for prs_) -
1128
1170
  // Company vaults ALWAYS use the presigned-URL transport now (HQ-59): the
@@ -1204,6 +1246,31 @@ export async function runRunner(
1204
1246
  }
1205
1247
  const plan = targetPlan.plan;
1206
1248
 
1249
+ void emitCloudTelemetry(
1250
+ client,
1251
+ {
1252
+ eventName: "sync_runner_plan_built",
1253
+ source: "sync-runner",
1254
+ sessionId: telemetrySessionId,
1255
+ properties: {
1256
+ direction: parsed.direction,
1257
+ targetCount: plan.length,
1258
+ companyCount: plan.filter((target) => target.personalMode !== true).length,
1259
+ personalCount: plan.filter((target) => target.personalMode === true).length,
1260
+ },
1261
+ },
1262
+ {
1263
+ claims,
1264
+ log: (message) =>
1265
+ reportDiagnostic({
1266
+ component: "telemetry-events",
1267
+ event: "runner.telemetry_events.nonfatal",
1268
+ message,
1269
+ context: { source: "sync-runner" },
1270
+ }),
1271
+ },
1272
+ );
1273
+
1207
1274
  emitFanoutPlan(emit, plan);
1208
1275
 
1209
1276
  // One-time seed of the reserved personal-vault journal from the legacy
@@ -1230,16 +1297,45 @@ export async function runRunner(
1230
1297
  resolveDeletePolicy,
1231
1298
  emit,
1232
1299
  scopePaths: parsed.scopePaths,
1300
+ telemetryClient: client,
1301
+ telemetryClaims: claims,
1302
+ telemetrySessionId,
1233
1303
  });
1234
1304
  const { errors, allConflicts } = fanout;
1235
1305
  const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1236
1306
 
1237
1307
  if (rollup.needsReindex) {
1238
1308
  try {
1239
- (deps.reindex ?? defaultReindex)({
1309
+ const reindexOptions = {
1240
1310
  repoRoot: parsed.hqRoot,
1241
1311
  ...(deps.operationLockAlreadyHeld === true ? { skipLock: true } : {}),
1242
- });
1312
+ };
1313
+ const reindexResult = (deps.reindex ?? defaultReindex)(reindexOptions);
1314
+ void emitCloudTelemetry(
1315
+ client,
1316
+ {
1317
+ eventName: "reindex_run",
1318
+ source: "sync-runner",
1319
+ sessionId: telemetrySessionId,
1320
+ properties: {
1321
+ result: reindexResult.status === 0 ? "completed" : "failed",
1322
+ statusCode: reindexResult.status,
1323
+ changedCount: rollup.changedPaths.length,
1324
+ lockResult:
1325
+ deps.operationLockAlreadyHeld === true ? "skipped" : "attempted",
1326
+ },
1327
+ },
1328
+ {
1329
+ claims,
1330
+ log: (message) =>
1331
+ reportDiagnostic({
1332
+ component: "telemetry-events",
1333
+ event: "runner.telemetry_events.nonfatal",
1334
+ message,
1335
+ context: { source: "sync-runner" },
1336
+ }),
1337
+ },
1338
+ );
1243
1339
  } catch (err) {
1244
1340
  reportDiagnostic({
1245
1341
  component: "native-reindex",
@@ -1248,6 +1344,30 @@ export async function runRunner(
1248
1344
  err,
1249
1345
  context: { hqRoot: parsed.hqRoot },
1250
1346
  });
1347
+ void emitCloudTelemetry(
1348
+ client,
1349
+ {
1350
+ eventName: "reindex_run",
1351
+ source: "sync-runner",
1352
+ sessionId: telemetrySessionId,
1353
+ properties: {
1354
+ result: "threw",
1355
+ changedCount: rollup.changedPaths.length,
1356
+ lockResult:
1357
+ deps.operationLockAlreadyHeld === true ? "skipped" : "attempted",
1358
+ },
1359
+ },
1360
+ {
1361
+ claims,
1362
+ log: (message) =>
1363
+ reportDiagnostic({
1364
+ component: "telemetry-events",
1365
+ event: "runner.telemetry_events.nonfatal",
1366
+ message,
1367
+ context: { source: "sync-runner" },
1368
+ }),
1369
+ },
1370
+ );
1251
1371
  }
1252
1372
  }
1253
1373
 
@@ -1294,6 +1414,34 @@ export async function runRunner(
1294
1414
  partial: rollup.partial,
1295
1415
  companies: rollup.companies,
1296
1416
  });
1417
+ void emitCloudTelemetry(
1418
+ client,
1419
+ {
1420
+ eventName: "sync_runner_completed",
1421
+ source: "sync-runner",
1422
+ sessionId: telemetrySessionId,
1423
+ properties: {
1424
+ result: rollup.partial ? "partial" : "completed",
1425
+ attemptedCount: plan.length,
1426
+ companyCount: rollup.companies.length,
1427
+ errorCount: errors.length,
1428
+ filesDownloaded: rollup.totalDownloaded,
1429
+ bytesDownloaded: rollup.totalDownloadedBytes,
1430
+ filesUploaded: rollup.totalUploaded,
1431
+ bytesUploaded: rollup.totalUploadedBytes,
1432
+ },
1433
+ },
1434
+ {
1435
+ claims,
1436
+ log: (message) =>
1437
+ reportDiagnostic({
1438
+ component: "telemetry-events",
1439
+ event: "runner.telemetry_events.nonfatal",
1440
+ message,
1441
+ context: { source: "sync-runner" },
1442
+ }),
1443
+ },
1444
+ );
1297
1445
 
1298
1446
  // Post-sync qmd reindex — runs AFTER `all-complete` is emitted so the
1299
1447
  // menubar/CLI already shows the sync as done; this is a best-effort tail
@@ -1304,7 +1452,7 @@ export async function runRunner(
1304
1452
  const qmdChangedPaths = rollup.changedPaths.filter(isKnowledgeMarkdownPath);
1305
1453
  if (qmdChangedPaths.length > 0 && process.env.HQ_QMD_REINDEX_ON_SYNC !== "0") {
1306
1454
  try {
1307
- (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
1455
+ const qmdResult = (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
1308
1456
  changedPaths: qmdChangedPaths,
1309
1457
  forceCollectionRefresh: true,
1310
1458
  log: ({ event, message, err, context }) =>
@@ -1316,6 +1464,33 @@ export async function runRunner(
1316
1464
  context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1317
1465
  }),
1318
1466
  });
1467
+ void emitCloudTelemetry(
1468
+ client,
1469
+ {
1470
+ eventName: "qmd_reindex_run",
1471
+ source: "sync-runner",
1472
+ sessionId: telemetrySessionId,
1473
+ properties: {
1474
+ result: "completed",
1475
+ changedCount: qmdChangedPaths.length,
1476
+ qmdState: qmdResult.qmdAvailable ? "available" : "unavailable",
1477
+ collectionsAddedCount: qmdResult.collectionsAdded.length,
1478
+ updateState: qmdResult.updated ? "updated" : "unchanged",
1479
+ embedState: qmdResult.embedded ? "embedded" : "not_embedded",
1480
+ dirtyState: qmdResult.pendingDirty ? "pending_dirty" : "clean",
1481
+ },
1482
+ },
1483
+ {
1484
+ claims,
1485
+ log: (message) =>
1486
+ reportDiagnostic({
1487
+ component: "telemetry-events",
1488
+ event: "runner.telemetry_events.nonfatal",
1489
+ message,
1490
+ context: { source: "sync-runner" },
1491
+ }),
1492
+ },
1493
+ );
1319
1494
  } catch (err) {
1320
1495
  reportDiagnostic({
1321
1496
  component: "qmd-reindex",
@@ -1325,6 +1500,28 @@ export async function runRunner(
1325
1500
  context: { hqRoot: parsed.hqRoot },
1326
1501
  });
1327
1502
  // Defensive: reindexAfterSync already swallows internally.
1503
+ void emitCloudTelemetry(
1504
+ client,
1505
+ {
1506
+ eventName: "qmd_reindex_run",
1507
+ source: "sync-runner",
1508
+ sessionId: telemetrySessionId,
1509
+ properties: {
1510
+ result: "threw",
1511
+ changedCount: qmdChangedPaths.length,
1512
+ },
1513
+ },
1514
+ {
1515
+ claims,
1516
+ log: (message) =>
1517
+ reportDiagnostic({
1518
+ component: "telemetry-events",
1519
+ event: "runner.telemetry_events.nonfatal",
1520
+ message,
1521
+ context: { source: "sync-runner" },
1522
+ }),
1523
+ },
1524
+ );
1328
1525
  }
1329
1526
  }
1330
1527
 
@@ -1392,12 +1589,13 @@ export async function runRunner(
1392
1589
  }
1393
1590
  }
1394
1591
 
1395
- // Exit 2 only when something actually threw (`errors.length > 0`). A clean
1396
- // conflict-abort sets `partial: true` in the JSON but exits 0 — the Tauri
1397
- // menubar's non-zero-exit Sentry capture would otherwise fire for normal
1398
- // user-policy outcomes. Consumers that want to flag any non-clean outcome
1399
- // (clean-abort + thrown-error) read `partial` from the JSON.
1400
- return errors.length > 0 ? 2 : 0;
1592
+ // Exit PARTIAL_SYNC_EXIT (2) only when something actually threw
1593
+ // (`errors.length > 0`). A clean conflict-abort sets `partial: true` in the
1594
+ // JSON but exits 0 — the Tauri menubar's non-zero-exit Sentry capture would
1595
+ // otherwise fire for normal user-policy outcomes. Consumers that want to
1596
+ // flag any non-clean outcome (clean-abort + thrown-error) read `partial`
1597
+ // from the JSON. The watch loop stays alive on this code (see exit-codes.ts).
1598
+ return errors.length > 0 ? PARTIAL_SYNC_EXIT : 0;
1401
1599
  }
1402
1600
 
1403
1601
  // ---------------------------------------------------------------------------