@indigoai-us/hq-cloud 6.15.0 → 6.15.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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
@@ -1,2190 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * hq-sync-runner — machine-targeted entrypoint for `@indigoai-us/hq-cloud`
4
- * (ADR-0001).
5
- *
6
- * The AppBar Sync menubar (Tauri + Rust) spawns this binary as a subprocess
7
- * and reads ndjson events from BOTH stdout and stderr (see "Channels"
8
- * below). The protocol is intentionally narrow and versioned-by-shape, not
9
- * by tooling — no chalk, no colors, no human prose. If you want to invoke
10
- * sync as a human, use `hq sync` in `@indigoai-us/hq-cli`.
11
- *
12
- * Flags:
13
- * --companies Fan out across every membership the caller has
14
- * --company <slug-or-uid> Sync a single company (alternative to --companies)
15
- * --on-conflict <strategy> abort | overwrite | keep (default: abort)
16
- * --hq-root <path> Local HQ directory (default: $HOME/hq)
17
- * --skip-personal Drop the personal target from the --companies
18
- * fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
19
- * (either truthy disables personal sync). No-op
20
- * outside --companies mode.
21
- * --json Ignored — ndjson on stdout is the default and
22
- * only output mode. Accepted for symmetry with the
23
- * AppBar's argv in case someone passes it.
24
- *
25
- * Channels (one JSON object per line):
26
- * stdout — protocol stream:
27
- * setup-needed caller signed in but has no person entity yet
28
- * fanout-plan list of companies we're about to sync
29
- * progress per-file download
30
- * complete per-company summary
31
- * all-complete aggregate summary after fanout
32
- * stderr — diagnostic stream:
33
- * error per-file or per-company error
34
- * auth-error no valid token available (interactive login disabled)
35
- *
36
- * Why the split: error-class events go to stderr so the menubar's Sentry
37
- * breadcrumb pipeline picks them up automatically (see hq-sync
38
- * src-tauri/src/commands/sync.rs `ProcessEvent::Stderr` handler). The
39
- * single Sentry capture at runner-exit then ships one #hq-alerts issue
40
- * with the full per-file → company → exit error trail attached, instead
41
- * of requiring per-event capture calls in the menubar.
42
- *
43
- * Exit code:
44
- * 0 — event stream describes the outcome. The runner finished its protocol
45
- * without any company throwing. Includes setup-needed, auth-error, and
46
- * runs where every company completed OR cleanly returned `aborted: true`
47
- * (a `--on-conflict abort` policy decision is not an error).
48
- * 1 — argv parse error or unrecoverable pre-sync failure.
49
- * 2 — at least one company threw a deterministic, non-transport failure
50
- * mid-stream (e.g. mid-fanout 401, denied access, S3 5xx after retries).
51
- * The all-complete event carries
52
- * `partial: true` and per-company partial counts captured from
53
- * `progress` events before the throw, so consumers parsing ndjson see
54
- * what actually transferred. This is distinct from exit 0 with
55
- * `partial: true` (clean conflict-aborts) — exit 2 is "something
56
- * unexpected happened", which the Tauri menubar converts to a Sentry
57
- * alert. Conflict-aborts intentionally do NOT alert.
58
- */
59
-
60
- import * as os from "os";
61
- import * as path from "path";
62
- import * as fs from "fs";
63
- import { randomUUID } from "node:crypto";
64
- import { fileURLToPath } from "url";
65
- import {
66
- CognitoRefreshError,
67
- getValidAccessToken,
68
- invalidateCachedTokensByFingerprint,
69
- loadCachedTokens,
70
- VaultClient,
71
- VaultAuthError,
72
- type CognitoAuthConfig,
73
- type CognitoTokens,
74
- type VaultServiceConfig,
75
- type Membership,
76
- type EntityInfo,
77
- type PendingInviteByEmail,
78
- type MembershipSyncConfig,
79
- type ExplicitGrant,
80
- } from "../index.js";
81
- import { pickCanonicalPersonEntity } from "../vault-client.js";
82
- import {
83
- resolvePullScope,
84
- readPinnedPrefixes,
85
- SESSIONS_SCOPE_PREFIX,
86
- type PullScope,
87
- } from "../sync/pull-scope.js";
88
- import {
89
- PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
90
- computePersonalVaultPaths,
91
- } from "../personal-vault.js";
92
- import { sync as defaultSync } from "../cli/sync.js";
93
- import type {
94
- SyncOptions,
95
- SyncResult,
96
- SyncProgressEvent,
97
- } from "../cli/sync.js";
98
- import { share as defaultShare } from "../cli/share.js";
99
- import type { ShareOptions, ShareResult } from "../cli/share.js";
100
- import type { ConflictStrategy } from "../cli/conflict.js";
101
- import type { UploadAuthor } from "../s3.js";
102
- import {
103
- setObjectIOFactory,
104
- presignObjectIOFactory,
105
- type ObjectIOFactory,
106
- type PresignTransportClient,
107
- } from "../object-io.js";
108
- import { HQ_CLOUD_VERSION } from "../version.js";
109
- import { collectAndSendTelemetry } from "../telemetry.js";
110
- import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
111
- import { collectAndSendOutcomeTelemetry } from "../outcome-telemetry.js";
112
- import { emitCloudTelemetry } from "../telemetry-events.js";
113
- import { reindexAfterSync } from "../qmd-reindex.js";
114
- import type { ReindexOptions as QmdReindexOptions } from "../qmd-reindex.js";
115
- import { reindex as defaultReindex } from "../cli/reindex.js";
116
- import type { ReindexOptions, ReindexResult } from "../cli/reindex.js";
117
- import type {
118
- TelemetryEventsBatch,
119
- TelemetryEventsIngestResult,
120
- } from "../vault-client.js";
121
- import { pruneConflictIndex } from "../lib/conflict-index.js";
122
- import { materializeCodexAgents } from "../agent-codex-instructions.js";
123
- import { getOrCreateMachineId } from "../lib/machine-id.js";
124
- import {
125
- isTransientNetworkError,
126
- TRANSIENT_NETWORK_EXIT,
127
- } from "../lib/net-errors.js";
128
- import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
129
- import { describeError } from "../lib/describe-error.js";
130
- import type {
131
- Clock,
132
- LocalDeleteSnapshot,
133
- TreeChangeBatch,
134
- } from "../watcher.js";
135
- import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
136
- import {
137
- type EventSyncHandles,
138
- type StartEventSyncOptions,
139
- } from "../sync/event-sync.js";
140
- import { migratePersonalVaultJournal } from "../journal.js";
141
- import {
142
- createRunnerEmitter,
143
- emitUncaughtRunnerError,
144
- } from "./sync-runner-events.js";
145
- import {
146
- buildFanoutPlan,
147
- emitFanoutPlan,
148
- resolveMembershipsForRun,
149
- resolveSkipCompanies,
150
- type RunnerTarget,
151
- type SetupNeededReason,
152
- } from "./sync-runner-planning.js";
153
-
154
- export { resolveSkipCompanies } from "./sync-runner-planning.js";
155
- import {
156
- seedActiveCompany,
157
- type SeedActiveCompanyOptions,
158
- type SeedActiveCompanyResult,
159
- } from "../active-company.js";
160
- import {
161
- reconcileCompanyManifest,
162
- type ManifestReconcileOptions,
163
- type ManifestReconcileResult,
164
- } from "../manifest-reconcile.js";
165
- import { executeCompanyFanout } from "./sync-runner-company.js";
166
- import { rollupAllComplete } from "./sync-runner-rollup.js";
167
- import { emitTelemetry } from "./sync-runner-telemetry.js";
168
- import {
169
- runOneShotWithOperationLock,
170
- runWatchLoop,
171
- } from "./sync-runner-watch-loop.js";
172
- export {
173
- buildTargetedPullArgv,
174
- buildTargetedPushArgv,
175
- routeChangeToTarget,
176
- } from "./sync-runner-watch-routes.js";
177
-
178
- /**
179
- * Sync direction for a run.
180
- *
181
- * - `pull`: download-only (legacy `hq sync` behaviour, and the default for
182
- * back-compat with pre-5.1.11 callers of the runner).
183
- * - `push`: upload-only. Walks the company folder and sends every file whose
184
- * local hash differs from the journal (skipUnchanged).
185
- * - `both`: push first, then pull. "Sync Now" in the menubar app targets this.
186
- * Push runs first so the subsequent pull doesn't redownload files we were
187
- * about to replace; if a company aborts on push conflict, pull is skipped
188
- * for that company but the fanout continues.
189
- */
190
- export type Direction = "pull" | "push" | "both";
191
-
192
- // ---------------------------------------------------------------------------
193
- // Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
194
- // imported) to avoid a circular dep between hq-cli and hq-cloud. If these
195
- // drift, the symptom is "runner talks to a different stage than hq sync"
196
- // — keep both files lined up.
197
- // ---------------------------------------------------------------------------
198
-
199
- const DEFAULT_COGNITO: CognitoAuthConfig = {
200
- region: process.env.AWS_REGION ?? "us-east-1",
201
- userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "vault-indigo-hq-prod",
202
- clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "7acei2c8v870enheptb1j5foln",
203
- port: process.env.HQ_COGNITO_CALLBACK_PORT
204
- ? Number(process.env.HQ_COGNITO_CALLBACK_PORT)
205
- : 8765,
206
- };
207
-
208
- const DEFAULT_VAULT_API_URL =
209
- process.env.HQ_VAULT_API_URL ?? "https://hqapi.hq.computer";
210
-
211
- const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
212
- const DEFAULT_TELEMETRY_TIMEOUT_MS = 120_000;
213
-
214
- function resolveTelemetryTimeoutMs(override?: number): number {
215
- if (override !== undefined && Number.isFinite(override) && override >= 0) {
216
- return Math.trunc(override);
217
- }
218
- const raw = process.env.HQ_TELEMETRY_TIMEOUT_MS;
219
- if (raw) {
220
- const parsed = Number.parseInt(raw, 10);
221
- if (Number.isFinite(parsed) && parsed >= 0) return parsed;
222
- }
223
- return DEFAULT_TELEMETRY_TIMEOUT_MS;
224
- }
225
-
226
- async function awaitTelemetryWithTimeout(
227
- telemetry: Promise<void>,
228
- timeoutMs: number,
229
- onTimeout: () => void,
230
- ): Promise<void> {
231
- let timeout: ReturnType<typeof setTimeout> | undefined;
232
- const timeoutPromise = new Promise<"timeout">((resolve) => {
233
- timeout = setTimeout(() => resolve("timeout"), timeoutMs);
234
- timeout.unref?.();
235
- });
236
- const result = await Promise.race([
237
- telemetry.then(() => "done" as const),
238
- timeoutPromise,
239
- ]);
240
- if (timeout) clearTimeout(timeout);
241
- if (result === "timeout") onTimeout();
242
- }
243
-
244
- /**
245
- * Delete-propagation policy honored by the push leg of bidirectional sync.
246
- *
247
- * Default `"currency-gated"` in 5.25 — flipped from `"owned-only"` after
248
- * one machine (Indigo / corey) ran the 5.24 code path through real syncs
249
- * for a week without surfacing surprise behavior. Currency-gated does a
250
- * per-file ETag HEAD before propagating any local-delete to S3: if the
251
- * remote object's current ETag no longer matches the journal's last-
252
- * recorded one, the delete is refused and the next pull leg re-pulls the
253
- * file via the standard 3-way merge path. This is strictly safer than
254
- * `owned-only` (which propagates any local-delete the journal can prove
255
- * came from this device) — the only delete-class that changes behavior
256
- * is "deleted locally + modified remotely by another device", which
257
- * previously destroyed remote work and now becomes a pull-and-conflict.
258
- *
259
- * Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
260
- * also the rollback knob — anyone surprised by 5.25's flip can revert
261
- * to `owned-only` without redeploying. `all` is the unsafe-mirror mode
262
- * previously used by the runner pre-5.20 — included only as an
263
- * emergency reconcile lever, not a recommended default.
264
- */
265
- export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
266
-
267
- export function resolveDeletePolicy(): DeletePropagationPolicy {
268
- const env = process.env.HQ_SYNC_DELETE_POLICY;
269
- if (env === "owned-only" || env === "all" || env === "currency-gated") {
270
- return env;
271
- }
272
- return "currency-gated";
273
- }
274
-
275
- /**
276
- * Resolve whether to skip the personal target in a `--companies` fanout.
277
- *
278
- * Two inputs combine: the `--skip-personal` CLI flag (parsed into
279
- * `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
280
- * being truthy skips the personal target — flag wins on conflict (CLI
281
- * flag is the explicit-for-this-invocation knob, env is the persistent
282
- * default usually set by the menubar in the spawned child process).
283
- *
284
- * Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
285
- * (including missing) is treated as falsy — same shape as classic
286
- * Unix opt-in env conventions; conservative to avoid surprising opt-outs.
287
- *
288
- * Use case: the menubar app exposes a "Sync personal vault" toggle in
289
- * Settings (default ON, matching the auto-provisioning UX). When the user
290
- * flips it off, the menubar spawns `hq sync` with this env set so the
291
- * fanout drops the personal target before walking the user's entire HQ
292
- * tree (a sync that would otherwise scan thousands of files, including
293
- * the new personal-vault default exclusions, just to do nothing useful).
294
- */
295
- export function resolveSkipPersonal(flag: boolean): boolean {
296
- if (flag) return true;
297
- const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
298
- return env === "1" || env === "true" || env === "yes";
299
- }
300
-
301
- /**
302
- * Resolve the object-transport factory for this sync session.
303
- *
304
- * Company vaults (`cmp_*`) ALWAYS use the presigned-URL transport: the client
305
- * holds no raw AWS credentials (it fetches short-lived signed URLs) and every
306
- * read/write is authorized server-side per-file, so it never hits the 2048-char
307
- * STS session-policy ceiling that produced the HQ-59 lockout. The STS-direct-S3
308
- * (`S3SdkObjectIO`) path for company vaults is RETIRED — there is no env
309
- * override or rollback lever that can route a company vault back to direct S3.
310
- * (The former `HQ_SYNC_PRESIGN_TRANSPORT` kill-switch is gone with this change.)
311
- *
312
- * Personal vaults (`prs_*`) KEEP the direct-S3/STS path: the membership-gated
313
- * `list`/`presign` endpoints 403 for the membership-less vend-self model, and a
314
- * single-owner personal vault has no ACL-scale problem. That cmp_/prs_ split
315
- * lives inside {@link presignObjectIOFactory}.
316
- *
317
- * Returns `null` only when the client predates the presign methods (never in a
318
- * shipped build); the caller then resets to the SDK default factory.
319
- */
320
- export function selectObjectIOFactory(
321
- client: Partial<PresignTransportClient>,
322
- ): ObjectIOFactory | null {
323
- if (
324
- typeof client.presign === "function" &&
325
- typeof client.listFiles === "function"
326
- ) {
327
- return presignObjectIOFactory(client as PresignTransportClient);
328
- }
329
- return null;
330
- }
331
-
332
- // Personal-vault scope (exclusion list + path computer) lives in
333
- // `../personal-vault.ts` so the `hq sync` CLI and this runner share the same
334
- // rules. Re-exported here for back-compat with any callers still importing
335
- // from sync-runner.js directly.
336
- export { PERSONAL_VAULT_EXCLUDED_TOP_LEVEL, computePersonalVaultPaths };
337
-
338
- // ---------------------------------------------------------------------------
339
- // Event protocol
340
- // ---------------------------------------------------------------------------
341
-
342
- /**
343
- * Every event the runner emits. Channel routing (stdout vs stderr) is
344
- * decided inside `runRunner`'s `emit` helper based on the event's `type`
345
- * — see the doc-block on the file header for the split.
346
- *
347
- * The `company` field is present on every event except `setup-needed` /
348
- * `auth-error` / `fanout-plan` / `all-complete` (which describe the whole
349
- * run) — consumers should treat its absence as "meta-event, not tied to a
350
- * specific company".
351
- */
352
- export type RunnerEvent =
353
- | {
354
- /**
355
- * The run cannot proceed. `reason` and `pendingInviteCount` are additive
356
- * and optional by design: a consumer that only reads `type` behaves
357
- * exactly as before, while /setup and /hq-sync can tell the user WHY they
358
- * look solo — most usefully, that an invite is still waiting.
359
- */
360
- type: "setup-needed";
361
- reason?: SetupNeededReason;
362
- pendingInviteCount?: number;
363
- }
364
- | { type: "auth-error"; message: string }
365
- | {
366
- /**
367
- * The resolved fanout plan. Carries whole `RunnerTarget`s: the runtime
368
- * payload has always been the plan entries verbatim, so the previous
369
- * `{uid, slug, name?}` declaration understated the wire contract (it
370
- * already omitted `bucketName`, `personalMode`, and `journalSlug`).
371
- * Consumers deserialize leniently and ignore fields they do not know.
372
- */
373
- type: "fanout-plan";
374
- companies: RunnerTarget[];
375
- }
376
- | ({
377
- /**
378
- * Stage-1 results for a single company's sync/share pass. Emitted once
379
- * before any `progress` events for that company arrive — once for the
380
- * pull phase (download counts) and once for the push phase (upload
381
- * counts) when `--direction both`. Consumers (the menubar) sum the
382
- * non-zero fields across all `plan` events seen for a fanout to render
383
- * an accurate "X of Y files" denominator before transfers begin.
384
- */
385
- type: "plan";
386
- company: string;
387
- } & Omit<Extract<SyncProgressEvent, { type: "plan" }>, "type">)
388
- | ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
389
- | ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
390
- | {
391
- /**
392
- * A per-company transport failure that is retryable, not a deterministic
393
- * sync error. This deliberately stays off the `error` channel: exit 2
394
- * means a real partial sync failure, while this pass returns EX_TEMPFAIL
395
- * (75) and is retried by the watch loop. Mirrors the non-error treatment
396
- * of deliberate skip events.
397
- */
398
- type: "transient-network";
399
- company: string;
400
- path: "(company)";
401
- message: string;
402
- }
403
- | ({ type: "conflict"; company: string } & Omit<Extract<SyncProgressEvent, { type: "conflict" }>, "type">)
404
- | { type: "new-files"; company: string; files: Array<{ path: string; bytes: number; addedBy: string | null }> }
405
- | { type: "scope-excluded"; company: string; count: number; samplePaths: string[] }
406
- | ({ type: "scope-materialization-gap"; company: string } & Omit<
407
- Extract<SyncProgressEvent, { type: "scope-materialization-gap" }>,
408
- "type"
409
- >)
410
- | { type: "ignore-excluded"; company: string; count: number; totalExcluded: number; samplePaths: string[] }
411
- | ({ type: "skip-invalid-scoped-key"; company: string } & Omit<
412
- Extract<SyncProgressEvent, { type: "skip-invalid-scoped-key" }>,
413
- "type"
414
- >)
415
- | ({ type: "skip-junk-key-spelling"; company: string } & Omit<
416
- Extract<SyncProgressEvent, { type: "skip-junk-key-spelling" }>,
417
- "type"
418
- >)
419
- | ({ type: "not-shipped"; company: string } & Omit<
420
- Extract<SyncProgressEvent, { type: "not-shipped" }>,
421
- "type"
422
- >)
423
- | ({
424
- type: "complete";
425
- company: string;
426
- /**
427
- * Upload counters. Always emitted (0 when the run was pull-only) so
428
- * downstream consumers don't need to conditionally read the field.
429
- */
430
- filesUploaded: number;
431
- bytesUploaded: number;
432
- /**
433
- * Push-side counters added in 5.25. Always emitted as numbers (0
434
- * when no push leg ran). Tauri's `SyncCompleteEvent` carries them
435
- * as Option<u32> for back-compat with <5.25 engines that don't
436
- * include them; structural-typing-wise, the union just adds
437
- * properties on top of `SyncResult`.
438
- */
439
- filesTombstoned: number;
440
- filesRefusedStale: number;
441
- /**
442
- * Paths corresponding to `filesRefusedStale`, capped at 50 (mirrors
443
- * `newFiles` cap). Surfaced on the `complete` event so operators
444
- * can triage the recurring `filesRefusedStale: 205` signal that
445
- * the 5.33.0 deep-test flagged as untriageable — the count alone
446
- * is impossible to investigate after the per-file
447
- * `delete-refused-stale-etag` events scroll off.
448
- */
449
- filesRefusedStalePaths: string[];
450
- filesExcludedByPolicy: number;
451
- } & SyncResult)
452
- | {
453
- type: "all-complete";
454
- companiesAttempted: number;
455
- filesDownloaded: number;
456
- bytesDownloaded: number;
457
- /** Always emitted; 0 when no push phase ran. */
458
- filesUploaded: number;
459
- bytesUploaded: number;
460
- /**
461
- * Conflict file paths aggregated across every company in the run.
462
- * Always emitted; empty array when no conflicts were detected. Lets
463
- * the menubar UI render a flat list without re-walking per-company
464
- * `complete` events.
465
- */
466
- conflictPaths: Array<{ company: string; path: string; direction: "pull" | "push" }>;
467
- errors: Array<{ company: string; message: string }>;
468
- /**
469
- * Retryable company-leg transport failures. Kept separate from `errors`
470
- * so consumers can surface an incomplete pass without calling it a
471
- * deterministic sync error. Always present for backward-compatible,
472
- * additive protocol evolution.
473
- */
474
- transient: Array<{ company: string; message: string }>;
475
- /**
476
- * True when at least one company in the fanout did not complete cleanly
477
- * — either it returned `aborted: true` (e.g. conflict-abort) or its sync
478
- * function threw mid-stream (e.g. mid-fanout 401). When `partial: true`,
479
- * the totals above include partial counts captured from `progress` events
480
- * before the abort, NOT just companies that emitted a clean `complete`.
481
- *
482
- * Automated monitors should check this field — `errors.length > 0` alone
483
- * isn't sufficient because a `aborted: true` return doesn't push to
484
- * `errors` (it's a clean conflict-abort, not an exception).
485
- */
486
- partial: boolean;
487
- /**
488
- * Per-company breakdown of the fanout. Always present, one entry per
489
- * planned company, in fanout order. Lets consumers reconcile per-company
490
- * partial counts with the aggregate without re-walking `complete` /
491
- * `error` event streams. The `status` field is the canonical signal:
492
- * - "complete" — sync returned cleanly, `aborted: false`
493
- * - "aborted" — sync returned cleanly with `aborted: true` (conflict-abort)
494
- * - "errored" — sync threw mid-stream; counts are sourced from progress
495
- * events seen before the throw
496
- * - "transient-network" — a retryable transport failure interrupted the
497
- * leg; see `transient` for its diagnostic
498
- */
499
- companies: Array<{
500
- company: string;
501
- status: "complete" | "aborted" | "errored" | "transient-network";
502
- filesDownloaded: number;
503
- bytesDownloaded: number;
504
- filesUploaded: number;
505
- bytesUploaded: number;
506
- }>;
507
- }
508
- | {
509
- /**
510
- * Emitted at most ONCE per fanout, AFTER `all-complete`, when the
511
- * post-sync conflict-ledger prune leaves one or more PRESERVED conflict
512
- * variants that a human still has to resolve (`kept > 0`). The prune
513
- * self-heals the ledger (drops orphaned + byte-identical false-positive
514
- * rows), but genuine divergences are conservatively KEPT — and pre-fix
515
- * that residual set was silent, so the reporter of feedback_d2082110
516
- * finished a full sync with "20 older preserved conflict entries" still
517
- * on disk and no signal they were there. This event reconciles and
518
- * surfaces the remaining count so the operator knows to run
519
- * `/resolve-conflicts`. Ledger-global (not per-company — the index lives
520
- * at the HQ root). Not emitted when the ledger is clean (`kept === 0`).
521
- *
522
- * `count` is the number of preserved conflict rows still awaiting
523
- * resolution; `samplePaths` carries up to 10 original (non-mirror) paths
524
- * for display.
525
- */
526
- type: "conflicts-remaining";
527
- count: number;
528
- samplePaths: string[];
529
- };
530
-
531
- /**
532
- * The narrow VaultClient surface the runner actually uses. Declared here (not
533
- * `Pick<VaultClient, ...>`) because `Pick` preserves the *entire* `entity`
534
- * accessor object — but the runner only needs `entity.get`, and forcing test
535
- * stubs to also implement `findBySlug`/`create` would be dishonest about the
536
- * real dependency. Keep this interface in sync with the real VaultClient
537
- * method signatures (both return types come straight from the SDK).
538
- */
539
- export interface VaultClientSurface {
540
- listMyMemberships: () => Promise<Membership[]>;
541
- listMyPendingInvitesByEmail: () => Promise<PendingInviteByEmail[]>;
542
- claimPendingInvitesByEmail: (personUid: string) => Promise<void>;
543
- ensureMyPersonEntity: (hints: {
544
- ownerSub: string;
545
- displayName: string;
546
- }) => Promise<EntityInfo>;
547
- entity: {
548
- get: (uid: string) => Promise<EntityInfo>;
549
- listByType: (type: string) => Promise<EntityInfo[]>;
550
- };
551
- // US-005 scope resolution. Optional so older test stubs (and any
552
- // VaultClientSurface impl that predates sync-config) still satisfy the
553
- // interface; when absent, `resolvePullScope` degrades to `all`.
554
- getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
555
- listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
556
- postTelemetryEvents?: (
557
- batch: TelemetryEventsBatch,
558
- options?: { timeoutMs?: number },
559
- ) => Promise<TelemetryEventsIngestResult>;
560
- }
561
-
562
- interface RunnerDiagnostic {
563
- component: string;
564
- event: string;
565
- message: string;
566
- err?: unknown;
567
- context?: Record<string, unknown>;
568
- }
569
-
570
- type RunnerDiagnosticReporter = (diagnostic: RunnerDiagnostic) => void;
571
-
572
- function errorDetails(err: unknown): Record<string, unknown> {
573
- const details: Record<string, unknown> = { message: describeError(err) };
574
- if (err instanceof Error) {
575
- if (err.name) details.name = err.name;
576
- const code = (err as NodeJS.ErrnoException).code;
577
- if (code) details.code = code;
578
- }
579
- return details;
580
- }
581
-
582
- function createRunnerDiagnosticReporter(
583
- stderr: { write: (chunk: string) => boolean | void },
584
- ): RunnerDiagnosticReporter {
585
- return (diagnostic) => {
586
- try {
587
- const payload: Record<string, unknown> = {
588
- type: "error",
589
- diagnostic: true,
590
- component: diagnostic.component,
591
- event: diagnostic.event,
592
- path: `(${diagnostic.component})`,
593
- message:
594
- diagnostic.err === undefined
595
- ? diagnostic.message
596
- : `${diagnostic.message}: ${describeError(diagnostic.err)}`,
597
- ...(diagnostic.context ?? {}),
598
- };
599
- if (diagnostic.err !== undefined) {
600
- payload.err = errorDetails(diagnostic.err);
601
- }
602
- stderr.write(`${JSON.stringify(payload)}\n`);
603
- } catch {
604
- /* diagnostics must never affect runner control flow */
605
- }
606
- };
607
- }
608
-
609
- // `resolvePullScope`, `readPinnedPrefixes`, and the `PullScope` type now live
610
- // in `../sync/pull-scope.ts` so the menubar runner and `hq sync pull|now`
611
- // (hq-cli) share ONE scope resolver — the drift between them was the root
612
- // cause of the all→shared scope-shrink wedge (DEV-1768). Re-exported here so
613
- // existing importers (and the runner test suite) keep their import path.
614
- export { resolvePullScope, readPinnedPrefixes, SESSIONS_SCOPE_PREFIX };
615
- export type { PullScope };
616
-
617
- /**
618
- * Backoff schedule (in ms) between attempts 2 and 3 of
619
- * `listMembershipsWithRetry`. Short on purpose — memberships is a single
620
- * API call gating the whole runner, and a transient blip (DNS hiccup,
621
- * idle ALB connection reset) usually clears in <50ms. If the network is
622
- * genuinely down, three attempts in <200ms total fail fast enough that
623
- * the tray can show its error banner before the user notices a delay.
624
- */
625
- const MEMBERSHIPS_RETRY_BACKOFFS_MS: readonly number[] = [50, 100];
626
-
627
- const REAUTH_MESSAGE =
628
- "Your HQ session needs a quick refresh. Sign in again to keep sync moving.";
629
-
630
- /**
631
- * Internal-only result used between a watch pass and its owning loop. The
632
- * outer process still exits 0 (auth-required is a handled UX state), while
633
- * the loop stops instead of retrying a known-unusable session forever.
634
- */
635
- export const AUTH_REQUIRED_PASS_EXIT = 18;
636
-
637
- /**
638
- * Call `listMyMemberships()` with up to 3 attempts and a small linear
639
- * backoff between them. The single network call that drives every cloud
640
- * company target plus the personal-vault slot — a one-off network blip
641
- * shouldn't kill the whole sync run.
642
- *
643
- * Auth failures (VaultAuthError) bypass retry entirely: re-vending creds
644
- * is the caller's job, not retryable in-process. Re-throwing immediately
645
- * preserves the existing auth-error event semantics in the outer
646
- * try/catch.
647
- */
648
- async function listMembershipsWithRetry(
649
- client: VaultClientSurface,
650
- ): Promise<Membership[]> {
651
- let lastErr: unknown;
652
- for (let attempt = 0; attempt < 3; attempt++) {
653
- if (attempt > 0) {
654
- const delayMs = MEMBERSHIPS_RETRY_BACKOFFS_MS[attempt - 1] ?? 100;
655
- await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
656
- }
657
- try {
658
- return await client.listMyMemberships();
659
- } catch (err) {
660
- if (err instanceof VaultAuthError) throw err;
661
- lastErr = err;
662
- }
663
- }
664
- throw lastErr;
665
- }
666
-
667
- /** Minimal shape of the claims we read off the Cognito idToken. */
668
- interface IdTokenClaims {
669
- sub?: string;
670
- email?: string;
671
- name?: string;
672
- given_name?: string;
673
- family_name?: string;
674
- /**
675
- * Entity-bound machine-principal claims. A headless agent or Outpost
676
- * authenticates with machine credentials; its idToken carries the machine's
677
- * own entity binding here. The runner preserves those authenticated claims
678
- * during planning. Agent claims additionally drive `--personal` target
679
- * resolution to the agent's OWN entity (`custom:entityUid`, `agt_*`) instead
680
- * of the person-only canonical pick.
681
- */
682
- "custom:entityType"?: string;
683
- "custom:entityUid"?: string;
684
- }
685
-
686
- export interface RunnerDeps {
687
- /** Where to write ndjson events. Defaults to `process.stdout`. */
688
- stdout?: { write: (chunk: string) => boolean | void };
689
- /** Where to write diagnostics. Defaults to `process.stderr`. */
690
- stderr?: { write: (chunk: string) => boolean | void };
691
- /** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
692
- getAccessToken?: () => Promise<string>;
693
- /** Invalidate only the rejected token generation. Injectable for tests. */
694
- clearSession?: (tokenFingerprint?: string) => void;
695
- /**
696
- * Read the caller's identity claims (sub/email/name) off the cached Cognito
697
- * idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
698
- * when no cached tokens exist — the runner will then skip the claim-dance
699
- * and fall through to the usual listMyMemberships path.
700
- */
701
- getIdTokenClaims?: () => IdTokenClaims | null;
702
- /**
703
- * Produce a VaultClient-like object. Defaults to `new VaultClient(config)`.
704
- * Tests inject a stub here — the runner only calls the methods listed in
705
- * `VaultClientSurface`.
706
- */
707
- createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
708
- /** Sync function. Defaults to `cli/sync.sync`. */
709
- sync?: (options: SyncOptions) => Promise<SyncResult>;
710
- /** Native post-fanout reindex function. Defaults to `cli/reindex.reindex`. */
711
- reindex?: (options: ReindexOptions) => ReindexResult;
712
- /** QMD post-sync reindex function. Defaults to `qmd-reindex.reindexAfterSync`. */
713
- qmdReindex?: (
714
- hqRoot: string,
715
- options: QmdReindexOptions,
716
- ) => ReturnType<typeof reindexAfterSync>;
717
- /**
718
- * Merge successfully pulled cloud companies into the local manifest after
719
- * the complete fanout. Injectable so runner tests do not touch a developer's
720
- * HQ root.
721
- */
722
- reconcileManifest?: (
723
- options: ManifestReconcileOptions,
724
- ) => Promise<ManifestReconcileResult>;
725
- /** Internal test seam for `.hq/config.json` activeCompany seeding. */
726
- seedActiveCompany?: (options: SeedActiveCompanyOptions) => SeedActiveCompanyResult;
727
- /** Internal: set when runRunner is invoked under the per-root operation lock. */
728
- operationLockAlreadyHeld?: boolean;
729
- /** Internal watch-loop result override; one-shot callers keep exit 0. */
730
- authRequiredExitCode?: number;
731
- /** Internal watch-loop callback carrying uncapped per-path push outcomes. */
732
- onPassResult?: (result: RunnerPassResult) => void;
733
- /** Share function (push phase). Defaults to `cli/share.share`. */
734
- share?: (options: ShareOptions) => Promise<ShareResult>;
735
- /**
736
- * Telemetry collector — runs just before the `all-complete` emit. Default
737
- * implementation calls `collectAndSendTelemetry` from `../telemetry.js`
738
- * using the real VaultClient; tests that inject `createVaultClient` are
739
- * implicitly opted out (the default skips when the client isn't a real
740
- * `VaultClient`). Tests that want to assert telemetry behavior should pass
741
- * an explicit stub here.
742
- */
743
- collectTelemetry?: () => Promise<void>;
744
- /** Maximum time to wait for pre-completion telemetry. Defaults to 120s. */
745
- telemetryTimeoutMs?: number;
746
- }
747
-
748
- export interface RunnerPassResult {
749
- pushPathResults: Array<{
750
- relativePath: string;
751
- status: "accepted" | "refused";
752
- operation: "delete" | "tombstone";
753
- reason?: string;
754
- }>;
755
- /** Company legs that threw before finishing their push/pull work. */
756
- companyFailures?: Array<{ company: string; message: string }>;
757
- }
758
-
759
- export interface RunnerPassOutcome {
760
- exitCode: number;
761
- result?: RunnerPassResult;
762
- }
763
-
764
- // ---------------------------------------------------------------------------
765
- // JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
766
- // We do NOT verify the signature here — Cognito already did that when it
767
- // issued the token, and we only read the public claims (sub/email/name) to
768
- // drive the claim-dance + create the person entity. If the token is tampered
769
- // with, the downstream vault-service call will reject it (signature-verified
770
- // there) long before any claimed value causes harm.
771
- // ---------------------------------------------------------------------------
772
-
773
- function decodeJwtClaims(jwt: string): IdTokenClaims | null {
774
- const parts = jwt.split(".");
775
- if (parts.length !== 3) return null;
776
- try {
777
- const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
778
- const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
779
- const json = Buffer.from(padded, "base64").toString("utf-8");
780
- return JSON.parse(json) as IdTokenClaims;
781
- } catch {
782
- return null;
783
- }
784
- }
785
-
786
- function defaultGetIdTokenClaims(): IdTokenClaims | null {
787
- const tokens: CognitoTokens | null = loadCachedTokens();
788
- if (!tokens?.idToken) return null;
789
- return decodeJwtClaims(tokens.idToken);
790
- }
791
-
792
- function isQmdIndexableContentPath(relPath: string): boolean {
793
- const normalized = relPath.split(path.sep).join("/");
794
- return (
795
- /^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized) ||
796
- /^companies\/[^/]+\/projects\/.+\.(?:md|json)$/i.test(normalized) ||
797
- /^personal\/knowledge\/.+\.md$/i.test(normalized)
798
- );
799
- }
800
-
801
- /**
802
- * Best-effort: claim any email-keyed pending invites that were sent before
803
- * this user had a person entity. Mirrors the installer's vault-handoff flow.
804
- *
805
- * Silent on the happy path — only logs to stderr on soft failures (so a
806
- * transient network blip doesn't block the sync). Never throws: a caller who
807
- * can't list memberships despite an unclaimed invite is no worse off than the
808
- * pre-claim-dance behavior (which was to emit setup-needed).
809
- */
810
- async function runClaimDance(
811
- client: VaultClientSurface,
812
- claims: IdTokenClaims,
813
- stderr: { write: (chunk: string) => boolean | void },
814
- reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
815
- ): Promise<number> {
816
- try {
817
- const pending = await client.listMyPendingInvitesByEmail();
818
- if (pending.length === 0) return 0;
819
-
820
- const displayName =
821
- claims.name ??
822
- [claims.given_name, claims.family_name].filter(Boolean).join(" ") ??
823
- claims.email ??
824
- "";
825
- const ownerSub = claims.sub ?? "";
826
- if (!ownerSub || !displayName) {
827
- reportDiagnostic({
828
- component: "claim-dance",
829
- event: "runner.claim_dance.skipped",
830
- message: "skipping claim-dance — idToken missing sub/name",
831
- context: { pendingInviteCount: pending.length },
832
- });
833
- // The invites are real and still pending; the caller must be able to say
834
- // so even though we could not claim them.
835
- return pending.length;
836
- }
837
-
838
- const person = await client.ensureMyPersonEntity({
839
- ownerSub,
840
- displayName,
841
- });
842
- await client.claimPendingInvitesByEmail(person.uid);
843
- return pending.length;
844
- } catch (err) {
845
- // Non-throwing by contract: a claim-dance failure must never take down a
846
- // sync. But it stops being invisible — a systematic failure here is exactly
847
- // what makes a joiner look permanently solo.
848
- reportDiagnostic({
849
- component: "claim-dance",
850
- event: "runner.claim_dance.skipped",
851
- message: "claim-dance skipped",
852
- err,
853
- context: {},
854
- });
855
- return 0;
856
- }
857
- }
858
-
859
- // ---------------------------------------------------------------------------
860
- // argv parser — intentionally minimal (no commander/yargs dep)
861
- // ---------------------------------------------------------------------------
862
-
863
- interface ParsedArgs {
864
- companies: boolean;
865
- company?: string;
866
- /**
867
- * Personal-vault-only mode. Mutually exclusive with `--companies` and
868
- * `--company`. Skips `listMyMemberships` (and therefore the claim-dance);
869
- * builds a fanout plan containing ONLY the personal target. Designed as
870
- * the runner-side entry point that replaces Rust's
871
- * `personal.rs::run_personal_first_push` first-push walker — so the
872
- * personal-vault scope (`computePersonalVaultPaths`) lives in exactly
873
- * one place (this TS engine) and not duplicated across engines.
874
- */
875
- personal: boolean;
876
- onConflict: ConflictStrategy;
877
- hqRoot: string;
878
- direction: Direction;
879
- /** Auto-sync (Beta): keep the runner alive after the first pass. */
880
- watch: boolean;
881
- /** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
882
- pollRemoteMs?: number;
883
- /**
884
- * Event-driven push (Phase 1). When set (and `--watch` is on), the runner
885
- * starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
886
- * company/subtree within the debounce window of a local edit — instead of
887
- * waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
888
- * menubar passes it only for `@getindigo.ai` identities (see PRD decision).
889
- * No-op without `--watch` (the one-shot path has nothing to keep alive).
890
- */
891
- eventPush: boolean;
892
- /**
893
- * Drop the personal target from the fanout. Combined with the
894
- * `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
895
- * truthy disables personal sync for this run. No-op outside `--companies`
896
- * mode (single-company runs never visit the personal target).
897
- */
898
- skipPersonal: boolean;
899
- /** Repeatable company/personal-relative path scope for push-only passes. */
900
- scopePaths: string[];
901
- /**
902
- * Internal watcher-only delete roots. Unlike `scopePaths`, these remain
903
- * meaningful after the corresponding local file or directory is absent.
904
- */
905
- deleteScopePaths: string[];
906
- /**
907
- * Bounded wait (seconds) for the per-root operation lock when another op is
908
- * already running. `0` → refuse immediately (pre-wait behavior); omitted →
909
- * inherit `HQ_OP_LOCK_TIMEOUT` / infinite wait. Only meaningful on the
910
- * one-shot path (the `--watch` runner is lock-exempt).
911
- */
912
- lockTimeoutSec?: number;
913
- }
914
-
915
- function parseArgs(argv: string[]): ParsedArgs | { error: string } {
916
- let companies = false;
917
- let company: string | undefined;
918
- let personal = false;
919
- let onConflict: ConflictStrategy = "abort";
920
- let hqRoot = DEFAULT_HQ_ROOT;
921
- let direction: Direction = "pull";
922
- let watch = false;
923
- let pollRemoteMs: number | undefined;
924
- let skipPersonal = false;
925
- const scopePaths: string[] = [];
926
- const deleteScopePaths: string[] = [];
927
- let eventPush = false;
928
- let lockTimeoutSec: number | undefined;
929
-
930
- for (let i = 0; i < argv.length; i++) {
931
- const arg = argv[i];
932
- switch (arg) {
933
- case "--companies":
934
- companies = true;
935
- break;
936
- case "--company":
937
- company = argv[++i];
938
- if (!company) return { error: "--company requires a value" };
939
- break;
940
- case "--personal":
941
- // Personal-vault-only mode. Skips listMyMemberships + claim-dance
942
- // entirely; builds a fanout plan containing only the personal target.
943
- // Replaces Rust's personal.rs::run_personal_first_push walker —
944
- // the personal-vault scope (computePersonalVaultPaths) is owned
945
- // by this engine, not duplicated across Rust + TS.
946
- personal = true;
947
- break;
948
- case "--on-conflict": {
949
- const val = argv[++i];
950
- if (val !== "abort" && val !== "overwrite" && val !== "keep") {
951
- return {
952
- error: `--on-conflict must be one of abort|overwrite|keep, got: ${val ?? "(missing)"}`,
953
- };
954
- }
955
- onConflict = val;
956
- break;
957
- }
958
- case "--direction": {
959
- const val = argv[++i];
960
- if (val !== "pull" && val !== "push" && val !== "both") {
961
- return {
962
- error: `--direction must be one of pull|push|both, got: ${val ?? "(missing)"}`,
963
- };
964
- }
965
- direction = val;
966
- break;
967
- }
968
- case "--hq-root":
969
- hqRoot = argv[++i];
970
- if (!hqRoot) return { error: "--hq-root requires a value" };
971
- break;
972
- case "--watch":
973
- watch = true;
974
- break;
975
- case "--poll-remote-ms": {
976
- const val = argv[++i];
977
- if (!val) return { error: "--poll-remote-ms requires a value" };
978
- const n = Number(val);
979
- if (!Number.isInteger(n) || n <= 0) {
980
- return {
981
- error: `--poll-remote-ms must be a positive integer (ms), got: ${val}`,
982
- };
983
- }
984
- pollRemoteMs = n;
985
- break;
986
- }
987
- case "--json":
988
- // Accepted but ignored — ndjson is the only output mode.
989
- break;
990
- case "--skip-personal":
991
- // Drop the personal target from the fanout. No-op outside
992
- // --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
993
- // resolveSkipPersonal().
994
- skipPersonal = true;
995
- break;
996
- case "--event-push":
997
- // Phase 1 event-driven push enable flag. Requires --watch (validated
998
- // below). Gated OFF by default; the menubar only passes it for
999
- // @getindigo.ai identities for the first release.
1000
- eventPush = true;
1001
- break;
1002
- case "--scope-path": {
1003
- const val = argv[++i];
1004
- if (!val) return { error: "--scope-path requires a value" };
1005
- scopePaths.push(val);
1006
- break;
1007
- }
1008
- case "--delete-scope-path": {
1009
- const val = argv[++i];
1010
- if (!val) return { error: "--delete-scope-path requires a value" };
1011
- deleteScopePaths.push(val);
1012
- break;
1013
- }
1014
- case "--lock-timeout": {
1015
- const val = argv[++i];
1016
- if (!val) return { error: "--lock-timeout requires a value (seconds)" };
1017
- const n = Number(val);
1018
- if (!Number.isInteger(n) || n < 0) {
1019
- return {
1020
- error: `--lock-timeout must be a non-negative integer (seconds), got: ${val}`,
1021
- };
1022
- }
1023
- lockTimeoutSec = n;
1024
- break;
1025
- }
1026
- default:
1027
- return { error: `Unknown argument: ${arg}` };
1028
- }
1029
- }
1030
-
1031
- if (companies && company) {
1032
- return { error: "Pass --companies OR --company <slug>, not both" };
1033
- }
1034
- if (personal && (companies || company)) {
1035
- return {
1036
- error: "--personal is mutually exclusive with --companies / --company",
1037
- };
1038
- }
1039
- if (personal && skipPersonal) {
1040
- return {
1041
- error: "--personal and --skip-personal are contradictory",
1042
- };
1043
- }
1044
- if (!companies && !company && !personal) {
1045
- return { error: "Pass --companies, --company <slug>, or --personal" };
1046
- }
1047
- if (pollRemoteMs !== undefined && !watch) {
1048
- return { error: "--poll-remote-ms requires --watch" };
1049
- }
1050
- if (eventPush && !watch) {
1051
- return { error: "--event-push requires --watch" };
1052
- }
1053
-
1054
- return {
1055
- companies,
1056
- company,
1057
- personal,
1058
- onConflict,
1059
- hqRoot,
1060
- direction,
1061
- watch,
1062
- pollRemoteMs,
1063
- skipPersonal,
1064
- scopePaths,
1065
- deleteScopePaths,
1066
- eventPush,
1067
- lockTimeoutSec,
1068
- };
1069
- }
1070
-
1071
- // ---------------------------------------------------------------------------
1072
- // Telemetry default — closes over the runner's vault client. Skipped when
1073
- // the caller injected a `createVaultClient` stub, because we have no
1074
- // guarantee the stub implements `getTelemetryOptIn` / `postUsage`. The real
1075
- // `VaultClient` from `../vault-client.js` always does. All errors are
1076
- // swallowed — telemetry must never abort or delay sync.
1077
- // ---------------------------------------------------------------------------
1078
-
1079
- export async function defaultCollectTelemetry(
1080
- client: VaultClientSurface,
1081
- clientIsStub: boolean,
1082
- hqRoot: string,
1083
- reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
1084
- fallbackCompany?: string,
1085
- ): Promise<void> {
1086
- if (clientIsStub) return;
1087
-
1088
- // Person-entity gate (the "onboarding gate" the telemetry call-site must
1089
- // sit behind). Both telemetry passes below resolve the caller's `personUid`
1090
- // SERVER-side from the JWT and reject with a 4xx when the caller has not yet
1091
- // been provisioned a person entity: `getTelemetryOptIn()` (the first call of
1092
- // each pass) hits `GET /v1/usage/opt-in`, which 404s `no-person-entity` for
1093
- // an unprovisioned caller, and the skill pass's `POST /v1/skill-invocations`
1094
- // does the same. hq-pro logs every such reject as a Sentry *warning*, so an
1095
- // unprovisioned-but-signed-in identity that runs telemetry on a loop — e.g.
1096
- // a machine/daemon/outpost identity, or a single-company sync that fabricates
1097
- // its membership and never runs the onboarding gate — emits a steady stream
1098
- // of benign `no-person-entity` warnings (Sentry HQ-4N). The reject itself is
1099
- // harmless (telemetry is best-effort and the response is swallowed), but the
1100
- // noise is not. Probe the caller's OWN person entity with the orphan-safe
1101
- // `entity.listByType("person")` — `GET /entity/by-type/person` returns `200`
1102
- // with an empty list for an unprovisioned caller (no reject, no warning),
1103
- // and a non-empty result is the EXACT predicate for "the server will resolve
1104
- // a personUid" (same caller-owned-person union the usage handler's
1105
- // `getEffectivePersonUid` uses). Skip both passes when there is none. The
1106
- // probe is the same canonical-person check the runner already uses to pick
1107
- // the personal-vault target. Fail OPEN: a thrown probe (transient/network)
1108
- // falls through to run telemetry as before, so a provisioned user's
1109
- // telemetry is never dropped by a flaky probe.
1110
- try {
1111
- const persons = await client.entity.listByType("person");
1112
- if (pickCanonicalPersonEntity(persons) === null) return;
1113
- } catch (err) {
1114
- reportDiagnostic({
1115
- component: "telemetry",
1116
- event: "runner.telemetry.person_probe_failed",
1117
- message: "telemetry person probe failed; continuing with telemetry",
1118
- err,
1119
- context: { hqRoot },
1120
- });
1121
- // Probe failed — preserve prior behavior and let telemetry run.
1122
- }
1123
-
1124
- // machineId: hq-cloud owns provisioning via `<hqRoot>/.hq/machine-id`
1125
- // (see `src/lib/machine-id.ts`). The resolver migrates forward from
1126
- // any legacy `~/.hq/menubar.json` value on first call, then becomes
1127
- // self-sufficient. On a clean Linux outpost (no menubar app), a fresh
1128
- // UUID is generated + persisted, so this row is attributable rather
1129
- // than collapsing onto the legacy `"unknown"` sentinel.
1130
- //
1131
- // installerVersion: callers (the Tauri menubar) set this when spawning
1132
- // the runner so the historical `installerVersion` dimension on CloudWatch
1133
- // keeps reporting the menubar version, not the runner's package version.
1134
- // CLI callers can leave it unset.
1135
- //
1136
- // Resolved once and shared by both telemetry passes below. If identity
1137
- // resolution itself throws, skip telemetry entirely.
1138
- let machineId: string;
1139
- let installerVersion: string;
1140
- try {
1141
- machineId = getOrCreateMachineId(hqRoot);
1142
- installerVersion = process.env.HQ_INSTALLER_VERSION ?? "hq-cloud";
1143
- } catch (err) {
1144
- reportDiagnostic({
1145
- component: "telemetry",
1146
- event: "runner.telemetry.identity_failed",
1147
- message: "telemetry identity resolution failed; skipping telemetry",
1148
- err,
1149
- context: { hqRoot },
1150
- });
1151
- return;
1152
- }
1153
-
1154
- try {
1155
- await collectAndSendTelemetry({
1156
- // The runtime guarantee here is that `clientIsStub === false` means
1157
- // `client` came from `new VaultClient(vaultConfig)` (see runRunner),
1158
- // which structurally satisfies `TelemetryClientSurface`.
1159
- client: client as unknown as Parameters<typeof collectAndSendTelemetry>[0]["client"],
1160
- machineId,
1161
- installerVersion,
1162
- // Resolve each event's cwd → owning company via <hqRoot>/companies/
1163
- // manifest.yaml and stamp companyUid (US-002). In explicit single-company
1164
- // runs, unmatched cwd values inherit that one scoped company; multi-company
1165
- // and personal runs keep them unattributed.
1166
- hqRoot,
1167
- fallbackCompany,
1168
- log: (message) =>
1169
- reportDiagnostic({
1170
- component: "telemetry",
1171
- event: "runner.telemetry.usage_nonfatal",
1172
- message,
1173
- context: { hqRoot },
1174
- }),
1175
- });
1176
- } catch (err) {
1177
- reportDiagnostic({
1178
- component: "telemetry",
1179
- event: "runner.telemetry.usage_failed",
1180
- message: "usage telemetry collector threw",
1181
- err,
1182
- context: { hqRoot },
1183
- });
1184
- // Fire-and-forget; nothing escapes the boundary.
1185
- }
1186
-
1187
- // Skill-invocation telemetry runs as an independent pass (own cursor, own
1188
- // endpoint) so a failure here can never affect token telemetry above.
1189
- try {
1190
- await collectAndSendSkillTelemetry({
1191
- client: client as unknown as Parameters<typeof collectAndSendSkillTelemetry>[0]["client"],
1192
- machineId,
1193
- installerVersion,
1194
- // Scope skill capture to the HQ project — only invocations whose cwd is
1195
- // the HQ root are emitted; skill usage in unrelated repos is excluded.
1196
- hqRoot,
1197
- log: (message) =>
1198
- reportDiagnostic({
1199
- component: "telemetry",
1200
- event: "runner.telemetry.skill_nonfatal",
1201
- message,
1202
- context: { hqRoot },
1203
- }),
1204
- });
1205
- } catch (err) {
1206
- reportDiagnostic({
1207
- component: "telemetry",
1208
- event: "runner.telemetry.skill_failed",
1209
- message: "skill telemetry collector threw",
1210
- err,
1211
- context: { hqRoot },
1212
- });
1213
- // Fire-and-forget; nothing escapes the boundary.
1214
- }
1215
-
1216
- // Outcome telemetry runs as an independent pass (own cursor, own endpoint,
1217
- // outcome-leaderboard US-004) so a failure here can never affect token or
1218
- // skill telemetry above. It diffs prd.json story `passes` + board.json project
1219
- // `status` transitions against a local cursor and POSTs story-completed /
1220
- // project-shipped events. No machineId/installerVersion — the /v1/outcome-events
1221
- // contract accepts only { events }.
1222
- try {
1223
- await collectAndSendOutcomeTelemetry({
1224
- client: client as unknown as Parameters<typeof collectAndSendOutcomeTelemetry>[0]["client"],
1225
- // Scan <hqRoot>/companies/* for prd.json/board.json and resolve each
1226
- // project's owning company via <hqRoot>/companies/manifest.yaml.
1227
- hqRoot,
1228
- log: (message) =>
1229
- reportDiagnostic({
1230
- component: "telemetry",
1231
- event: "runner.telemetry.outcome_nonfatal",
1232
- message,
1233
- context: { hqRoot },
1234
- }),
1235
- });
1236
- } catch (err) {
1237
- reportDiagnostic({
1238
- component: "telemetry",
1239
- event: "runner.telemetry.outcome_failed",
1240
- message: "outcome telemetry collector threw",
1241
- err,
1242
- context: { hqRoot },
1243
- });
1244
- // Fire-and-forget; nothing escapes the boundary.
1245
- }
1246
- }
1247
-
1248
- // ---------------------------------------------------------------------------
1249
- // runRunner — testable entrypoint
1250
- // ---------------------------------------------------------------------------
1251
-
1252
- export async function runRunner(
1253
- argv: string[],
1254
- deps: RunnerDeps = {},
1255
- ): Promise<number> {
1256
- const stdout = deps.stdout ?? process.stdout;
1257
- const stderr = deps.stderr ?? process.stderr;
1258
- const reportDiagnostic = createRunnerDiagnosticReporter(stderr);
1259
-
1260
- // ---- emit ---------------------------------------------------------------
1261
- // Error-class events go to stderr; everything else to stdout.
1262
- //
1263
- // Why split: the AppBar Sync menubar (Tauri + Rust) feeds runner stderr
1264
- // into Sentry as breadcrumbs and captures one Sentry event when the
1265
- // runner exits non-zero. Routing `error` / `auth-error` events through
1266
- // stderr makes them part of that breadcrumb trail automatically — the
1267
- // menubar doesn't need a per-event capture call, and operators get the
1268
- // full context (per-file errors → company error → exit) in a single
1269
- // Sentry issue alerted to #hq-alerts.
1270
- //
1271
- // Non-error events (progress, complete, transient-network, fanout-plan,
1272
- // all-complete, setup-needed) stay on stdout. They're the protocol stream the menubar
1273
- // parses for UI updates; mixing them with error events on the same
1274
- // channel was the original design (single ndjson stream, simpler to
1275
- // tee), but error context belongs in the diagnostic channel.
1276
- //
1277
- // Backward compat: older menubar releases (pre-PR-#34) parse only
1278
- // stdout for ndjson; with this change they will NOT receive error
1279
- // events. The menubar's `HQ_CLOUD_VERSION` pin gates which runner
1280
- // they spawn, so old menubars stay on the previous runner version
1281
- // even after this one is published.
1282
- const emit = createRunnerEmitter({ stdout, stderr });
1283
-
1284
- // ---- argv -------------------------------------------------------------
1285
- const parsed = parseArgs(argv);
1286
- if ("error" in parsed) {
1287
- stderr.write(`hq-sync-runner: ${parsed.error}\n`);
1288
- return 1;
1289
- }
1290
-
1291
- // ---- auth -------------------------------------------------------------
1292
- // Resolve the access token up-front to surface auth-error early (before any
1293
- // protocol events). Long-running multi-company syncs can outlast Cognito's
1294
- // 60-min access token TTL, so the vaultConfig captures a *getter* — every
1295
- // vault request resolves the latest token via getValidAccessToken, which
1296
- // re-reads `~/.hq/cognito-tokens.json` and refreshes on demand. Without
1297
- // this, a captured string goes stale mid-fanout (e.g. personal sync runs
1298
- // last → STS expires after 13 min → refreshEntityContext → fetchEntity →
1299
- // 401 against API Gateway's JWT authorizer because the captured token
1300
- // expired while the menubar was happily rotating the on-disk token).
1301
- const getAccessToken =
1302
- deps.getAccessToken ??
1303
- (() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
1304
- const clearSession =
1305
- deps.clearSession ?? invalidateCachedTokensByFingerprint;
1306
- let initialAuthToken: string;
1307
- try {
1308
- initialAuthToken = await getAccessToken();
1309
- } catch (err) {
1310
- if (
1311
- (err instanceof CognitoRefreshError && !err.requiresReauth) ||
1312
- isTransientNetworkError(err)
1313
- ) {
1314
- emit({
1315
- type: "error",
1316
- path: "(auth)",
1317
- message: err instanceof Error ? err.message : String(err),
1318
- });
1319
- return TRANSIENT_NETWORK_EXIT;
1320
- }
1321
- emit({
1322
- type: "auth-error",
1323
- message: REAUTH_MESSAGE,
1324
- });
1325
- return deps.authRequiredExitCode ?? 0;
1326
- }
1327
-
1328
- // ---- vault client -----------------------------------------------------
1329
- // Stamp clientInfo so every vault request (incl. /sts/vend) carries
1330
- // x-hq-client-name=hq-sync + x-hq-client-version=<hq-cloud version>. The
1331
- // company-vend min-version gate (HQ-59, hq-pro) uses this to recognize a
1332
- // compliant (>= 6.11.6) sync-runner and NOT force-upgrade it — the
1333
- // belt-and-suspenders companion to skipping the cmp_ vend on the presign
1334
- // path. Version is the hq-cloud package version (what the gate compares to
1335
- // its floor), not the desktop app version.
1336
- const vaultConfig: VaultServiceConfig = {
1337
- apiUrl: DEFAULT_VAULT_API_URL,
1338
- authToken: getAccessToken,
1339
- region: DEFAULT_COGNITO.region,
1340
- clientInfo: { name: "hq-sync", version: HQ_CLOUD_VERSION },
1341
- };
1342
- const client =
1343
- deps.createVaultClient?.(vaultConfig) ?? new VaultClient(vaultConfig);
1344
-
1345
- // ---- resolve identity claims -----------------------------------------
1346
- // Read the cached idToken claims once. Two consumers downstream:
1347
- // 1. The claim-dance (only fires in `--companies` mode for setup-needed
1348
- // invitees).
1349
- // 2. The S3 upload author (every share() call stamps `Metadata['created-by']`
1350
- // with `claims.email` so the hq-console vault UI's CREATED BY column
1351
- // attributes the file to the syncing user).
1352
- // Resolved here (not inside `parsed.companies`) so single-company runs also
1353
- // get author attribution. `null` is fine — share() simply omits the metadata.
1354
- const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
1355
- // A machine-principal token is minted immediately above and is an ID token.
1356
- // Make its entity binding authoritative for this run: a stale human token
1357
- // cache can otherwise make an authenticated machine look like a person
1358
- // during fanout planning. Human callers still retain the cache as their
1359
- // primary claims source because their bearer is normally an access token
1360
- // without the full ID-token profile.
1361
- const authenticatedClaims = decodeJwtClaims(initialAuthToken);
1362
- const authenticatedMachinePrincipalClaims =
1363
- authenticatedClaims &&
1364
- (authenticatedClaims["custom:entityType"] === "agent" ||
1365
- authenticatedClaims["custom:entityType"] === "outpost") &&
1366
- typeof authenticatedClaims["custom:entityUid"] === "string"
1367
- ? authenticatedClaims
1368
- : null;
1369
- const claims =
1370
- authenticatedMachinePrincipalClaims ?? getClaims() ?? authenticatedClaims;
1371
- const uploadAuthor: UploadAuthor | undefined =
1372
- claims?.sub && claims?.email
1373
- ? { userSub: claims.sub, email: claims.email }
1374
- : undefined;
1375
- const telemetrySessionId = `sync_${randomUUID()}`;
1376
- void emitCloudTelemetry(
1377
- client,
1378
- {
1379
- eventName: "sync_runner_session_started",
1380
- source: "sync-runner",
1381
- sessionId: telemetrySessionId,
1382
- properties: {
1383
- direction: parsed.direction,
1384
- targetMode: parsed.personal
1385
- ? "personal"
1386
- : parsed.company
1387
- ? "single_company"
1388
- : "companies",
1389
- watchState: parsed.watch ? "enabled" : "disabled",
1390
- eventPushState: parsed.eventPush ? "enabled" : "disabled",
1391
- scopePathCount: parsed.scopePaths?.length ?? 0,
1392
- },
1393
- },
1394
- {
1395
- claims,
1396
- log: (message) =>
1397
- reportDiagnostic({
1398
- component: "telemetry-events",
1399
- event: "runner.telemetry_events.nonfatal",
1400
- message,
1401
- context: { source: "sync-runner" },
1402
- }),
1403
- },
1404
- );
1405
-
1406
- const emitRunnerTerminalTelemetry = (properties: {
1407
- status: "completed" | "partial" | "failed";
1408
- attemptedCount?: number;
1409
- companyCount?: number;
1410
- errorCount?: number;
1411
- filesDownloaded?: number;
1412
- bytesDownloaded?: number;
1413
- filesUploaded?: number;
1414
- bytesUploaded?: number;
1415
- }): void => {
1416
- void emitCloudTelemetry(
1417
- client,
1418
- {
1419
- eventName: "sync_runner_completed",
1420
- source: "sync-runner",
1421
- sessionId: telemetrySessionId,
1422
- properties,
1423
- },
1424
- {
1425
- claims,
1426
- log: (message) =>
1427
- reportDiagnostic({
1428
- component: "telemetry-events",
1429
- event: "runner.telemetry_events.nonfatal",
1430
- message,
1431
- context: { source: "sync-runner" },
1432
- }),
1433
- },
1434
- );
1435
- };
1436
-
1437
- // ---- transport selection (presigned-URL for cmp_, direct-S3/STS for prs_) -
1438
- // Company vaults ALWAYS use the presigned-URL transport now (HQ-59): the
1439
- // STS-direct-S3 path for cmp_ is retired — no env override routes a company
1440
- // vault back to direct S3. Personal vaults keep direct-S3 inside the factory
1441
- // (the cmp_/prs_ split lives in presignObjectIOFactory). selectObjectIOFactory
1442
- // runs ONCE here — every s3.ts call in this session's fanout resolves through
1443
- // the installed factory. Setting it unconditionally (even to null, which the
1444
- // default S3 SDK factory backs for a pre-presign client) keeps the choice
1445
- // deterministic if a prior run mutated module state.
1446
- const presignCapable = client as Partial<PresignTransportClient>;
1447
- const objectIOFactory = selectObjectIOFactory(presignCapable);
1448
- setObjectIOFactory(objectIOFactory);
1449
- // HQ-59 vend-skip: when company vaults run over the presign transport (the
1450
- // shipped case — selectObjectIOFactory only returns null for a pre-presign
1451
- // client), tell resolveEntityContext to SKIP `POST /sts/vend` for cmp_
1452
- // contexts. Two reasons, both load-bearing: (1) the presign transport never
1453
- // reads STS creds, so the vend is dead weight; (2) a compliant sync-runner
1454
- // must not call the company vend route at all — its presence is the
1455
- // pre-6.11.6 signal the hq-pro min-version gate denies on. Gated on the SAME
1456
- // value that picks the transport, so the vend-skip and the transport can
1457
- // never diverge. If the factory is null (S3 SDK fallback), cmp_ keeps vending
1458
- // because S3SdkObjectIO needs the creds. Personal vaults always vend self.
1459
- vaultConfig.companyVaultUsesPresign = objectIOFactory !== null;
1460
-
1461
- // ---- resolve targets --------------------------------------------------
1462
- let memberships: Pick<Membership, "companyUid">[];
1463
- try {
1464
- const resolution = await resolveMembershipsForRun({
1465
- personal: parsed.personal,
1466
- companies: parsed.companies,
1467
- company: parsed.company,
1468
- client,
1469
- claims,
1470
- stderr,
1471
- runClaimDance: (client, claims, stderr) =>
1472
- runClaimDance(client, claims, stderr, reportDiagnostic),
1473
- listMemberships: listMembershipsWithRetry,
1474
- });
1475
- if (resolution.status === "setup-needed") {
1476
- emit({
1477
- type: "setup-needed",
1478
- reason: resolution.reason,
1479
- ...(resolution.pendingInviteCount === undefined
1480
- ? {}
1481
- : { pendingInviteCount: resolution.pendingInviteCount }),
1482
- });
1483
- return 0;
1484
- }
1485
- memberships = resolution.memberships;
1486
- } catch (err) {
1487
- if (err instanceof VaultAuthError) {
1488
- clearSession(err.tokenFingerprint);
1489
- emit({
1490
- type: "auth-error",
1491
- message: REAUTH_MESSAGE,
1492
- });
1493
- return deps.authRequiredExitCode ?? 0;
1494
- }
1495
- // Surface the failure as an error event for observability either way.
1496
- emit({
1497
- type: "error",
1498
- message: err instanceof Error ? err.message : String(err),
1499
- path: "(discovery)",
1500
- });
1501
- emitRunnerTerminalTelemetry({ status: "failed" });
1502
- // A transient network failure (offline, DNS blip, vault API briefly
1503
- // unreachable) is NOT a crash — it self-heals on the next poll. Return the
1504
- // retryable exit code so the watch loop stays alive instead of exiting and
1505
- // being reported as "watcher exited unexpectedly (code=Some(1))" for every
1506
- // blip (HQ-SYNC-1W). A one-shot run still exits non-zero. Any other failure
1507
- // is treated as a hard error (exit 1), as before.
1508
- return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1509
- }
1510
-
1511
- let targetPlan: Awaited<ReturnType<typeof buildFanoutPlan>>;
1512
- try {
1513
- targetPlan = await buildFanoutPlan({
1514
- memberships,
1515
- companies: parsed.companies,
1516
- personal: parsed.personal,
1517
- skipPersonal: parsed.skipPersonal,
1518
- client,
1519
- claims,
1520
- resolveSkipPersonal,
1521
- skipCompanies: resolveSkipCompanies(),
1522
- });
1523
- } catch (err) {
1524
- if (err instanceof VaultAuthError) {
1525
- clearSession(err.tokenFingerprint);
1526
- emit({ type: "auth-error", message: REAUTH_MESSAGE });
1527
- return deps.authRequiredExitCode ?? 0;
1528
- }
1529
- emit({
1530
- type: "error",
1531
- message: err instanceof Error ? err.message : String(err),
1532
- path: "(discovery)",
1533
- });
1534
- emitRunnerTerminalTelemetry({ status: "failed" });
1535
- return isTransientNetworkError(err) ? TRANSIENT_NETWORK_EXIT : 1;
1536
- }
1537
- if (targetPlan.status === "setup-needed") {
1538
- emit({ type: "setup-needed", reason: targetPlan.reason });
1539
- return 0;
1540
- }
1541
- const plan = targetPlan.plan;
1542
- const deferredUnresolved = targetPlan.deferredUnresolved;
1543
-
1544
- emitFanoutPlan(emit, plan);
1545
-
1546
- // One-time seed of the reserved personal-vault journal from the legacy
1547
- // "personal" journal, so switching the vault slot off the colliding slug
1548
- // does NOT trigger a mass re-upload of the HQ overlay on first run. No-op
1549
- // when the new journal already exists or the legacy one is absent, and
1550
- // harmless even when the personal-vault slot is not in the plan.
1551
- migratePersonalVaultJournal();
1552
-
1553
- // ---- fanout -----------------------------------------------------------
1554
- const syncFn = deps.sync ?? defaultSync;
1555
- const shareFn = deps.share ?? defaultShare;
1556
- let fanout: Awaited<ReturnType<typeof executeCompanyFanout>>;
1557
- try {
1558
- fanout = await executeCompanyFanout({
1559
- plan,
1560
- direction: parsed.direction,
1561
- hqRoot: parsed.hqRoot,
1562
- onConflict: parsed.onConflict,
1563
- client,
1564
- vaultConfig,
1565
- ...(uploadAuthor ? { uploadAuthor } : {}),
1566
- ...(deps.operationLockAlreadyHeld ? { operationLockAlreadyHeld: true } : {}),
1567
- syncFn,
1568
- shareFn,
1569
- resolveDeletePolicy,
1570
- emit,
1571
- scopePaths: parsed.scopePaths,
1572
- deleteScopePaths: parsed.deleteScopePaths,
1573
- telemetryClient: client,
1574
- telemetryClaims: claims,
1575
- telemetrySessionId,
1576
- });
1577
- } catch (err) {
1578
- if (err instanceof VaultAuthError) {
1579
- clearSession(err.tokenFingerprint);
1580
- emit({ type: "auth-error", message: REAUTH_MESSAGE });
1581
- return deps.authRequiredExitCode ?? 0;
1582
- }
1583
- throw err;
1584
- }
1585
- const { errors, transientCompanies, allConflicts, meaningfulOutcomeCount } = fanout;
1586
- deps.onPassResult?.({
1587
- pushPathResults: fanout.pushPathResults,
1588
- companyFailures: fanout.companyFailures,
1589
- });
1590
- const rollup = rollupAllComplete(plan, fanout.stateByCompany);
1591
-
1592
- // The personal-vault target runs last and can pull an older copy of the
1593
- // manifest. Reconcile only after the entire pull fanout has settled so the
1594
- // successfully materialized cloud directories restore their own entries.
1595
- //
1596
- // `--companies` only. A single-company run (`--company <slug>`) and a
1597
- // `--personal` run both see just one leg of the fanout, so neither has the
1598
- // full picture the manifest is reconciled against; treating a partial view as
1599
- // authoritative is how entries get lost. A push-only pass materializes
1600
- // nothing new locally, so it has nothing to reconcile either.
1601
- if (parsed.companies && parsed.direction !== "push") {
1602
- const completedCompanySlugs = new Set(
1603
- Array.from(fanout.stateByCompany.entries())
1604
- .filter(([, state]) => state.status === "complete")
1605
- .map(([slug]) => slug),
1606
- );
1607
- // The personal vault is never a manifest entry. The reconciler filters it
1608
- // too, but that is its own internal invariant — excluding it here means
1609
- // the personal target is never even handed across the seam.
1610
- const companyTargets = plan.filter(
1611
- (target) => target.personalMode !== true && target.slug !== "personal",
1612
- );
1613
- try {
1614
- const result = await (deps.reconcileManifest ?? reconcileCompanyManifest)({
1615
- hqRoot: parsed.hqRoot,
1616
- targets: companyTargets,
1617
- completedCompanySlugs,
1618
- reportDiagnostic: (diagnostic) =>
1619
- reportDiagnostic({ component: "manifest-reconcile", ...diagnostic }),
1620
- });
1621
- // Only on an actual write. A steady-state sync stays silent, so an
1622
- // unexpected diagnostic rate is itself the signal that skip-if-unchanged
1623
- // has regressed.
1624
- if (result.written) {
1625
- reportDiagnostic({
1626
- component: "manifest-reconcile",
1627
- event: "manifest-reconciled",
1628
- message: "restored company entries in companies/manifest.yaml",
1629
- context: { added: result.added, updated: result.updated },
1630
- });
1631
- }
1632
- } catch (err) {
1633
- reportDiagnostic({
1634
- component: "manifest-reconcile",
1635
- event: "runner.manifest_reconcile.failed",
1636
- message: "post-fanout manifest reconciliation failed",
1637
- err,
1638
- context: { hqRoot: parsed.hqRoot },
1639
- });
1640
- }
1641
-
1642
- // A joiner whose manifest entry was just restored still has nothing telling
1643
- // HQ which company is current. Seed it only when the answer cannot be wrong
1644
- // — deliberately its own try, so a manifest failure does not also cost the
1645
- // user their routing.
1646
- try {
1647
- const localCompanySlugs = companyTargets
1648
- .map((target) => target.slug)
1649
- .filter((slug) => completedCompanySlugs.has(slug));
1650
- const seeded = (deps.seedActiveCompany ?? seedActiveCompany)({
1651
- hqRoot: parsed.hqRoot,
1652
- companySlugs: localCompanySlugs,
1653
- reportDiagnostic: (diagnostic) =>
1654
- reportDiagnostic({ component: "active-company", ...diagnostic }),
1655
- });
1656
- if (seeded.written) {
1657
- reportDiagnostic({
1658
- component: "active-company",
1659
- event: "active-company-seeded",
1660
- message: "seeded activeCompany in .hq/config.json",
1661
- context: { activeCompany: seeded.activeCompany },
1662
- });
1663
- }
1664
- } catch (err) {
1665
- reportDiagnostic({
1666
- component: "active-company",
1667
- event: "runner.active_company.failed",
1668
- message: "seeding activeCompany failed",
1669
- err,
1670
- context: { hqRoot: parsed.hqRoot },
1671
- });
1672
- }
1673
- }
1674
-
1675
- if (rollup.needsReindex) {
1676
- try {
1677
- const reindexOptions = {
1678
- repoRoot: parsed.hqRoot,
1679
- ...(deps.operationLockAlreadyHeld === true ? { skipLock: true } : {}),
1680
- };
1681
- const reindexResult = (deps.reindex ?? defaultReindex)(reindexOptions);
1682
- void emitCloudTelemetry(
1683
- client,
1684
- {
1685
- eventName: "reindex_run",
1686
- source: "sync-runner",
1687
- sessionId: telemetrySessionId,
1688
- properties: {
1689
- result: reindexResult.status === 0 ? "completed" : "failed",
1690
- statusCode: reindexResult.status,
1691
- changedCount: rollup.changedPaths.length,
1692
- lockResult:
1693
- deps.operationLockAlreadyHeld === true ? "skipped" : "attempted",
1694
- },
1695
- },
1696
- {
1697
- claims,
1698
- log: (message) =>
1699
- reportDiagnostic({
1700
- component: "telemetry-events",
1701
- event: "runner.telemetry_events.nonfatal",
1702
- message,
1703
- context: { source: "sync-runner" },
1704
- }),
1705
- },
1706
- );
1707
- } catch (err) {
1708
- reportDiagnostic({
1709
- component: "native-reindex",
1710
- event: "runner.native_reindex.failed",
1711
- message: "post-fanout native reindex failed",
1712
- err,
1713
- context: { hqRoot: parsed.hqRoot },
1714
- });
1715
- void emitCloudTelemetry(
1716
- client,
1717
- {
1718
- eventName: "reindex_run",
1719
- source: "sync-runner",
1720
- sessionId: telemetrySessionId,
1721
- properties: {
1722
- result: "threw",
1723
- changedCount: rollup.changedPaths.length,
1724
- lockResult:
1725
- deps.operationLockAlreadyHeld === true ? "skipped" : "attempted",
1726
- },
1727
- },
1728
- {
1729
- claims,
1730
- log: (message) =>
1731
- reportDiagnostic({
1732
- component: "telemetry-events",
1733
- event: "runner.telemetry_events.nonfatal",
1734
- message,
1735
- context: { source: "sync-runner" },
1736
- }),
1737
- },
1738
- );
1739
- }
1740
- }
1741
-
1742
- // Fire telemetry collector before the all-complete emit so the cursor at
1743
- // `~/.hq/telemetry-cursor.json` is consistent with what the menubar sees.
1744
- await awaitTelemetryWithTimeout(
1745
- emitTelemetry({
1746
- collectTelemetry: deps.collectTelemetry,
1747
- defaultCollectTelemetry: () =>
1748
- defaultCollectTelemetry(
1749
- client,
1750
- deps.createVaultClient !== undefined,
1751
- parsed.hqRoot,
1752
- reportDiagnostic,
1753
- parsed.company,
1754
- ),
1755
- onError: (err) =>
1756
- reportDiagnostic({
1757
- component: "telemetry",
1758
- event: "runner.telemetry.collector_failed",
1759
- message: "telemetry collector failed",
1760
- err,
1761
- context: { hqRoot: parsed.hqRoot },
1762
- }),
1763
- }),
1764
- resolveTelemetryTimeoutMs(deps.telemetryTimeoutMs),
1765
- () =>
1766
- reportDiagnostic({
1767
- component: "telemetry",
1768
- event: "runner.telemetry.timeout",
1769
- message: "telemetry collector timed out; continuing sync completion",
1770
- context: { hqRoot: parsed.hqRoot },
1771
- }),
1772
- );
1773
-
1774
- emit({
1775
- type: "all-complete",
1776
- companiesAttempted: plan.length,
1777
- filesDownloaded: rollup.totalDownloaded,
1778
- bytesDownloaded: rollup.totalDownloadedBytes,
1779
- filesUploaded: rollup.totalUploaded,
1780
- bytesUploaded: rollup.totalUploadedBytes,
1781
- conflictPaths: allConflicts,
1782
- errors,
1783
- transient: transientCompanies,
1784
- partial: rollup.partial,
1785
- companies: rollup.companies,
1786
- });
1787
- if (
1788
- rollup.partial ||
1789
- errors.length > 0 ||
1790
- meaningfulOutcomeCount > 0
1791
- ) {
1792
- emitRunnerTerminalTelemetry({
1793
- status: rollup.partial || errors.length > 0 ? "partial" : "completed",
1794
- attemptedCount: plan.length,
1795
- companyCount: rollup.companies.length,
1796
- errorCount: errors.length,
1797
- filesDownloaded: rollup.totalDownloaded,
1798
- bytesDownloaded: rollup.totalDownloadedBytes,
1799
- filesUploaded: rollup.totalUploaded,
1800
- bytesUploaded: rollup.totalUploadedBytes,
1801
- });
1802
- }
1803
-
1804
- // Post-sync qmd reindex — runs AFTER `all-complete` is emitted so the
1805
- // menubar/CLI already shows the sync as done; this is a best-effort tail
1806
- // step that never affects the exit code. Only when files were actually
1807
- // pulled in (nothing to reindex otherwise) and not explicitly disabled.
1808
- // Self-contained: shells out to the global `qmd` binary, no dependency on
1809
- // any (possibly stale) script inside the synced HQ tree. See qmd-reindex.ts.
1810
- const qmdChangedPaths = rollup.changedPaths.filter(isQmdIndexableContentPath);
1811
- if (qmdChangedPaths.length > 0 && process.env.HQ_QMD_REINDEX_ON_SYNC !== "0") {
1812
- try {
1813
- const qmdResult = (deps.qmdReindex ?? reindexAfterSync)(parsed.hqRoot, {
1814
- changedPaths: qmdChangedPaths,
1815
- forceCollectionRefresh: true,
1816
- log: ({ event, message, err, context }) =>
1817
- reportDiagnostic({
1818
- component: "qmd-reindex",
1819
- event,
1820
- message,
1821
- err,
1822
- context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1823
- }),
1824
- });
1825
- void emitCloudTelemetry(
1826
- client,
1827
- {
1828
- eventName: "qmd_reindex_run",
1829
- source: "sync-runner",
1830
- sessionId: telemetrySessionId,
1831
- properties: {
1832
- result: "completed",
1833
- changedCount: qmdChangedPaths.length,
1834
- qmdState: qmdResult.qmdAvailable ? "available" : "unavailable",
1835
- collectionsAddedCount: qmdResult.collectionsAdded.length,
1836
- pathDriftDetectedCount: qmdResult.pathDriftDetected?.length ?? 0,
1837
- collectionsRepairedCount: qmdResult.collectionsRepaired?.length ?? 0,
1838
- updateState: qmdResult.updated ? "updated" : "unchanged",
1839
- embedState: qmdResult.embedded ? "embedded" : "not_embedded",
1840
- dirtyState: qmdResult.pendingDirty ? "pending_dirty" : "clean",
1841
- lockBusy: qmdResult.lockBusy === true ? 1 : 0,
1842
- timedOut: qmdResult.timedOut === true ? 1 : 0,
1843
- corruptionQuarantined: qmdResult.corruptionQuarantined === true ? 1 : 0,
1844
- corruptionQuarantineFailed: qmdResult.corruptionQuarantineFailed === true ? 1 : 0,
1845
- },
1846
- },
1847
- {
1848
- claims,
1849
- log: (message) =>
1850
- reportDiagnostic({
1851
- component: "telemetry-events",
1852
- event: "runner.telemetry_events.nonfatal",
1853
- message,
1854
- context: { source: "sync-runner" },
1855
- }),
1856
- },
1857
- );
1858
- } catch (err) {
1859
- reportDiagnostic({
1860
- component: "qmd-reindex",
1861
- event: "runner.qmd_reindex.failed",
1862
- message: "post-sync qmd reindex threw",
1863
- err,
1864
- context: { hqRoot: parsed.hqRoot },
1865
- });
1866
- // Defensive: reindexAfterSync already swallows internally.
1867
- void emitCloudTelemetry(
1868
- client,
1869
- {
1870
- eventName: "qmd_reindex_run",
1871
- source: "sync-runner",
1872
- sessionId: telemetrySessionId,
1873
- properties: {
1874
- result: "threw",
1875
- changedCount: qmdChangedPaths.length,
1876
- },
1877
- },
1878
- {
1879
- claims,
1880
- log: (message) =>
1881
- reportDiagnostic({
1882
- component: "telemetry-events",
1883
- event: "runner.telemetry_events.nonfatal",
1884
- message,
1885
- context: { source: "sync-runner" },
1886
- }),
1887
- },
1888
- );
1889
- }
1890
- }
1891
-
1892
- // Post-sync conflict-ledger prune — also a best-effort tail step (runs AFTER
1893
- // `all-complete`, never touches the exit code). The producers only append a
1894
- // conflict row after proving real divergence, but the ledger has no other
1895
- // self-healing path: rows for byte-identical false positives (legacy or
1896
- // since-reverted) and orphaned `.conflict-*` mirrors otherwise linger
1897
- // forever and make `.hq-conflicts/index.json` over-report. One GC pass per
1898
- // run keeps it converged. Idempotent and cheap on a clean ledger (a no-row
1899
- // index returns immediately and never rewrites the file). Opt-out via
1900
- // HQ_CONFLICT_PRUNE_ON_SYNC=0.
1901
- if (process.env.HQ_CONFLICT_PRUNE_ON_SYNC !== "0") {
1902
- try {
1903
- const pruneResult = pruneConflictIndex(parsed.hqRoot);
1904
- // Reconcile + surface the residual preserved conflict variants a human
1905
- // still has to resolve. Pre-fix this set was silent — the reporter of
1906
- // feedback_d2082110 finished a full sync with 20 preserved conflict
1907
- // entries still on disk and no signal. Emitting after the prune means the
1908
- // count reflects the self-healed (orphan/false-positive-dropped) ledger,
1909
- // never the inflated raw one. Ledger-global; silent on a clean ledger.
1910
- if (pruneResult.kept > 0) {
1911
- emit({
1912
- type: "conflicts-remaining",
1913
- count: pruneResult.kept,
1914
- samplePaths: pruneResult.keptSamplePaths,
1915
- });
1916
- }
1917
- } catch (err) {
1918
- reportDiagnostic({
1919
- component: "conflict-index",
1920
- event: "runner.conflict_index.prune_failed",
1921
- message: "post-sync conflict index prune failed",
1922
- err,
1923
- context: { hqRoot: parsed.hqRoot },
1924
- });
1925
- // Defensive: a prune failure must never affect the sync outcome.
1926
- }
1927
- }
1928
-
1929
- // Post-sync agent codex-instructions materialization — best-effort tail step
1930
- // (runs AFTER `all-complete`, never affects the exit code). For an AGENT box
1931
- // ONLY (`custom:entityType === "agent"`), project the synced personal/ agent
1932
- // overlay (agents-profile.md + agent-capabilities/*.md) into the codex GLOBAL
1933
- // instruction file ~/.codex/AGENTS.md, which `codex exec` natively merges on
1934
- // every run. This is the codex mirror of Claude's ~/.claude/CLAUDE.md:
1935
- // DURABLE (outside the hq-root, so `hq rescue` never touches it) and
1936
- // fleet-SCALABLE (every box runs @latest each cycle). A human running
1937
- // `hq sync` (person identity) never triggers it. Opt-out: set
1938
- // HQ_AGENT_CODEX_MATERIALIZE=0. The write is idempotent and never destructive
1939
- // (skips when the overlay is empty), and never throws (mirrors the qmd /
1940
- // conflict-prune tail steps above).
1941
- if (
1942
- claims?.["custom:entityType"] === "agent" &&
1943
- process.env.HQ_AGENT_CODEX_MATERIALIZE !== "0"
1944
- ) {
1945
- try {
1946
- materializeCodexAgents({
1947
- hqRoot: parsed.hqRoot,
1948
- log: ({ event, message, err, context }) =>
1949
- reportDiagnostic({
1950
- component: "agent-codex-instructions",
1951
- event,
1952
- message,
1953
- err,
1954
- context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1955
- }),
1956
- });
1957
- } catch (err) {
1958
- reportDiagnostic({
1959
- component: "agent-codex-instructions",
1960
- event: "runner.agent_codex_instructions.failed",
1961
- message: "post-sync agent codex-instructions materialization threw",
1962
- err,
1963
- context: { hqRoot: parsed.hqRoot },
1964
- });
1965
- // Defensive: materializeCodexAgents already swallows internally.
1966
- }
1967
- }
1968
-
1969
- // Exit PARTIAL_SYNC_EXIT (2) only when something actually threw
1970
- // (`errors.length > 0`). A clean conflict-abort sets `partial: true` in the
1971
- // JSON but exits 0 — the Tauri menubar's non-zero-exit Sentry capture would
1972
- // otherwise fire for normal user-policy outcomes. Consumers that want to
1973
- // flag any non-clean outcome (clean-abort + thrown-error) read `partial`
1974
- // from the JSON. The watch loop stays alive on this code (see exit-codes.ts).
1975
- //
1976
- // deferredUnresolved: a slug denylist was active but entity discovery blipped
1977
- // for one or more memberships we could not prove were paused. Fail closed on
1978
- // the plan (they were omitted) and return TRANSIENT so watch scoped drain
1979
- // does not publish without a successful upload pass.
1980
- if (errors.length > 0) return PARTIAL_SYNC_EXIT;
1981
- if (transientCompanies.length > 0 || deferredUnresolved > 0) {
1982
- return TRANSIENT_NETWORK_EXIT;
1983
- }
1984
- return 0;
1985
- }
1986
-
1987
- // ---------------------------------------------------------------------------
1988
- // Entrypoint — only runs when invoked directly, not when imported for tests
1989
- // ---------------------------------------------------------------------------
1990
-
1991
- // Detect whether this module is the entry point. The obvious check
1992
- // (`import.meta.url === file://${argv[1]}`) breaks for every real-world
1993
- // install shape: npm-link'd binaries, global installs via Homebrew, and
1994
- // pnpm's `node_modules/.bin` shims all leave `process.argv[1]` pointing
1995
- // at a symlink named `hq-sync-runner` (no `.js` suffix) while
1996
- // `import.meta.url` always resolves to the underlying `sync-runner.js`.
1997
- //
1998
- // Resolve both sides through realpath before comparing — that's the only
1999
- // way to handle all symlink layouts without false negatives. If realpath
2000
- // fails (argv[1] gone, permissions), fall through to `false` so we
2001
- // don't run twice when imported as a library.
2002
- const isDirectInvocation = (() => {
2003
- if (!process.argv[1]) return false;
2004
- try {
2005
- const modulePath = fs.realpathSync(fileURLToPath(import.meta.url));
2006
- const argvPath = fs.realpathSync(process.argv[1]);
2007
- return modulePath === argvPath;
2008
- } catch {
2009
- return false;
2010
- }
2011
- })();
2012
-
2013
- /**
2014
- * Test/event-driven seam (US-001).
2015
- *
2016
- * `runRunnerWithLoop` performs an unbounded, monotonic-cadence loop in
2017
- * production. The initial pass is immediate; subsequent deadlines advance
2018
- * from their prior deadline rather than pass completion. Watcher and receiver
2019
- * signals interrupt the wait but still use the same guarded pass queue. To
2020
- * test it deterministically, both waiting and monotonic time are injectable.
2021
- *
2022
- * A test (or US-003's wiring) injects a fake sleep that resolves immediately
2023
- * and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
2024
- * so the loop can be exercised without a real 10-minute wait.
2025
- */
2026
- export interface RunnerLoopDeps {
2027
- /** Sleep `ms` between passes. Default: host setTimeout. */
2028
- sleep?: (ms: number) => Promise<void>;
2029
- /**
2030
- * Monotonic milliseconds source for cadence deadlines. Defaults to
2031
- * `performance.now()`; tests inject it to prove missed-deadline behavior
2032
- * without depending on wall-clock time.
2033
- */
2034
- monotonicNow?: () => number;
2035
- /**
2036
- * 1-minute whole-box load average source for the adaptive remote-poll
2037
- * backoff (used only when `--poll-remote-ms` is omitted). Defaults to
2038
- * `os.loadavg()[0]`. Tests inject a fixed sample to assert the interval
2039
- * scales with load without depending on the host's real load.
2040
- */
2041
- sampleLoadAvg?: () => number;
2042
- /**
2043
- * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
2044
- * (and the event-push wiring) so the poll loop and the watcher-triggered
2045
- * targeted push share one seam and one in-flight guard. The default ignores
2046
- * `deps` and forwards just the argv to `runRunner`.
2047
- */
2048
- runPass?: (passArgv: string[]) => Promise<number | RunnerPassOutcome>;
2049
- /** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
2050
- runBackupPrune?: (hqRoot: string) => Promise<void>;
2051
- /**
2052
- * Clock seam for the event-push watcher's debounce window. Defaults to
2053
- * {@link systemClock}; tests inject a `FakeClock` to advance the window
2054
- * deterministically. Only consulted when `--event-push` is on.
2055
- */
2056
- clock?: Clock;
2057
- /**
2058
- * Factory for the file watcher used in event-push mode. Defaults to a real
2059
- * {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
2060
- * `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
2061
- */
2062
- createWatcher?: (opts: {
2063
- hqRoot: string;
2064
- debounceMs: number;
2065
- clock: Clock;
2066
- captureLocalDeleteSnapshots: (
2067
- relativePath: string,
2068
- kind: "unlink" | "unlinkDir",
2069
- ) => LocalDeleteSnapshot[];
2070
- }) => WatcherSurface;
2071
- /**
2072
- * Register a one-shot shutdown signal handler. Defaults to listening for
2073
- * SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
2074
- * clean teardown without sending real signals. The returned fn detaches the
2075
- * handler (called during teardown so tests don't leak listeners).
2076
- */
2077
- onShutdownSignal?: (handler: () => void) => () => void;
2078
- /**
2079
- * Factory for the Phase 2 pull-on-event receiver (US-009). Defaults to a
2080
- * {@link NoopPushReceiver} — the daemon ships the receiver SEAM wired into
2081
- * the lifecycle (start after the watcher, dispose before exit) but stays
2082
- * DORMANT by default: the per-client SQS queue is provisioned server-side
2083
- * (an unbuilt follow-up) and the receiver is feature-flag gated. A future
2084
- * menubar/CLI release injects an {@link SqsPushReceiver} here once a queue
2085
- * URL is available. Only consulted when `--event-push` is on.
2086
- *
2087
- * The factory is handed a {@link SyncEngineFn} that bridges a received
2088
- * PushEvent to a TARGETED pull pass (`--company <slug> --direction pull`,
2089
- * or a personal `--companies --direction pull`) routed by the event's
2090
- * `relativePath`, funneled through the same in-flight guard as the poll
2091
- * loop and the watcher push so a pull-on-event never overlaps an in-flight
2092
- * pass.
2093
- */
2094
- createReceiver?: (opts: {
2095
- syncFn: SyncEngineFn;
2096
- hqRoot: string;
2097
- }) => PushReceiver;
2098
- /**
2099
- * Phase 3 (US-017/US-018/US-019): factory for the event-driven publish +
2100
- * pull wiring, consulted only when `--event-push` is on AND the rollout
2101
- * gate ({@link resolveEventSync}) passes for the signed-in account.
2102
- * Defaults to the real {@link defaultStartEventSync}. Tests inject a stub
2103
- * to assert gate behavior without network/AWS.
2104
- */
2105
- startEventSync?: (
2106
- opts: StartEventSyncOptions,
2107
- ) => Promise<EventSyncHandles | null>;
2108
- /**
2109
- * Identity-claims source for the Phase 3 rollout gate (the loop has no
2110
- * RunnerDeps; mirror of RunnerDeps.getIdTokenClaims). Defaults to reading
2111
- * the cached Cognito idToken.
2112
- */
2113
- getIdTokenClaims?: () => IdTokenClaims | null;
2114
- /**
2115
- * Access-token source for the Phase 3 vault API calls (publish transport +
2116
- * subscribe). Defaults to {@link getValidAccessToken} non-interactive.
2117
- */
2118
- getAccessToken?: () => Promise<string>;
2119
- }
2120
-
2121
- /**
2122
- * The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
2123
- * it; tests inject a lighter stub. Kept narrow so the loop never reaches past
2124
- * the lifecycle + change-subscription contract.
2125
- *
2126
- * `onChange`'s listener receives an OPTIONAL changed relative path. The real
2127
- * {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
2128
- * the loop routes the targeted push to the personal vault. A path-aware
2129
- * watcher (or a test stub) can pass the changed `companies/<slug>/...`
2130
- * relative path so the loop targets just that company.
2131
- */
2132
- export interface WatcherSurface {
2133
- onChange(
2134
- listener: (changedRelPath?: string, batch?: TreeChangeBatch) => void,
2135
- ): () => void;
2136
- start(): void;
2137
- stop(): void;
2138
- dispose(): void;
2139
- }
2140
-
2141
- export async function runRunnerWithLoop(
2142
- argv: string[],
2143
- deps: RunnerLoopDeps = {},
2144
- ): Promise<number> {
2145
- const parsed = parseArgs(argv);
2146
- const runtime = {
2147
- runPassWithOperationLockAlreadyHeld: async (passArgv: string[]) => {
2148
- let result: RunnerPassResult | undefined;
2149
- const exitCode = await runRunner(passArgv, {
2150
- operationLockAlreadyHeld: true,
2151
- authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
2152
- onPassResult: (passResult) => {
2153
- result = passResult;
2154
- },
2155
- });
2156
- return { exitCode, ...(result ? { result } : {}) };
2157
- },
2158
- authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
2159
- defaultGetIdTokenClaims,
2160
- defaultGetAccessToken: () =>
2161
- getValidAccessToken(DEFAULT_COGNITO, { interactive: false }),
2162
- apiUrl: DEFAULT_VAULT_API_URL,
2163
- region: DEFAULT_COGNITO.region,
2164
- };
2165
-
2166
- if (!argv.includes("--watch")) {
2167
- if ("error" in parsed) return runRunner(argv);
2168
- return runOneShotWithOperationLock(argv, parsed, deps, runtime);
2169
- }
2170
-
2171
- if ("error" in parsed) return runRunner(argv);
2172
- return runWatchLoop(argv, parsed, deps, runtime);
2173
- }
2174
-
2175
- if (isDirectInvocation) {
2176
- runRunnerWithLoop(process.argv.slice(2))
2177
- .then((code) => process.exit(code))
2178
- .catch((err) => {
2179
- // A top-level rejection means the runner died before (or instead of)
2180
- // emitting its protocol stream. Emit a CLASSIFIED `error` event first so
2181
- // the menubar runs it through `is_alertable_error` and suppresses the
2182
- // benign cases, instead of treating every uncaught exit as an unexplained
2183
- // "silent death" crash (HQ-SYNC-WEB-6).
2184
- emitUncaughtRunnerError(err, {
2185
- stdout: process.stdout,
2186
- stderr: process.stderr,
2187
- });
2188
- process.exit(1);
2189
- });
2190
- }