@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,1235 +0,0 @@
1
- import * as os from "os";
2
- import * as path from "path";
3
- import * as fs from "fs";
4
- import { performance } from "node:perf_hooks";
5
- import { inventoryManagedSnapshots } from "../backup-prune.js";
6
- import { VaultClient } from "../index.js";
7
- import { pickCanonicalPersonEntity } from "../vault-client.js";
8
- import {
9
- acquireOperationLock,
10
- withOperationLock,
11
- OperationLockedError,
12
- OPERATION_LOCKED_EXIT,
13
- } from "../operation-lock.js";
14
- import { describeError } from "../lib/describe-error.js";
15
- import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
16
- import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
17
- import { getOrCreateMachineId } from "../lib/machine-id.js";
18
- import { localPathForVaultKey } from "../local-path-codec.js";
19
- import {
20
- TreeWatcher,
21
- systemClock,
22
- type LocalDeleteSnapshot,
23
- type TreeChange,
24
- type TreeChangeBatch,
25
- } from "../watcher.js";
26
- import {
27
- clearLocalDeleteIntent,
28
- markLocalDeleteIntent,
29
- PERSONAL_VAULT_JOURNAL_SLUG,
30
- readJournal,
31
- writeJournal,
32
- } from "../journal.js";
33
- import {
34
- NoopPushReceiver,
35
- type PushReceiver,
36
- type SyncEngineFn,
37
- } from "../sync/push-receiver.js";
38
- import {
39
- resolveEventSync,
40
- startEventSync as defaultStartEventSync,
41
- type EventSyncHandles,
42
- } from "../sync/event-sync.js";
43
- import type { TelemetryClaims } from "../telemetry-events.js";
44
- import {
45
- buildScopedDrainPullArgv,
46
- buildScopedPushArgv,
47
- buildTargetedPullArgv,
48
- isWatchRouteSelected,
49
- routeChangeToTarget,
50
- routeKey,
51
- type WatchRoute,
52
- } from "./sync-runner-watch-routes.js";
53
- import { resolveSkipCompanies } from "./sync-runner-planning.js";
54
- import type {
55
- Direction,
56
- RunnerLoopDeps,
57
- RunnerPassOutcome,
58
- WatcherSurface,
59
- } from "./sync-runner.js";
60
-
61
- const FULL_RECONCILE_EVERY_TICKS = 10;
62
-
63
- /**
64
- * Adaptive remote-poll backoff bounds (whole-box CPU aware). When
65
- * `--poll-remote-ms` is NOT passed, the watch loop scales its poll cadence to
66
- * the host's overall load instead of using a fixed interval: ~60s when the box
67
- * is idle, stretching toward ~10min under sustained full load, so the periodic
68
- * pull yields CPU/I-O to whatever else is running (e.g. the user's live agent
69
- * session). The poll is only a correctness backstop — event-push handles
70
- * real-time — so a stale interval under load is safe. An explicit
71
- * `--poll-remote-ms <n>` overrides this entirely and pins a fixed interval.
72
- */
73
- const ADAPTIVE_POLL_FLOOR_MS = 60_000;
74
- const ADAPTIVE_POLL_CEIL_MS = 600_000;
75
-
76
- /**
77
- * Map whole-box load to a poll interval in the [floor, ceil] range.
78
- *
79
- * `load1` is the 1-minute load average ({@link os.loadavg}[0]); dividing by the
80
- * CPU count yields per-core saturation. ratio 0 (idle) → floor; ratio ≥ 1
81
- * (fully saturated) → ceil; linear in between. Pure + injectable so the loop
82
- * can be unit-tested without a real host or a 10-minute wait.
83
- */
84
- export function adaptivePollMs(
85
- load1: number,
86
- cpuCount: number,
87
- floorMs: number = ADAPTIVE_POLL_FLOOR_MS,
88
- ceilMs: number = ADAPTIVE_POLL_CEIL_MS,
89
- ): number {
90
- const cores = cpuCount > 0 ? cpuCount : 1;
91
- const saturation = load1 > 0 ? load1 / cores : 0;
92
- const ratio = Math.max(0, Math.min(1, saturation));
93
- return Math.round(floorMs + ratio * (ceilMs - floorMs));
94
- }
95
-
96
- /**
97
- * Advance the cadence from its previous monotonic deadline, rather than from
98
- * the end of a pass. A slow pass is allowed one immediate catch-up pass; the
99
- * returned deadline is always the first future slot, so missed slots cannot
100
- * turn into an unbounded burst.
101
- */
102
- export function advanceMonotonicDeadline(
103
- previousDueAt: number,
104
- now: number,
105
- intervalMs: number,
106
- ): { nextDueAt: number; delayMs: number } {
107
- let nextDueAt = previousDueAt + intervalMs;
108
- if (nextDueAt > now) {
109
- return { nextDueAt, delayMs: nextDueAt - now };
110
- }
111
-
112
- // The pass crossed at least one deadline. Run exactly one catch-up now,
113
- // then retain only the first future cadence slot.
114
- do {
115
- nextDueAt += intervalMs;
116
- } while (nextDueAt <= now);
117
- return { nextDueAt, delayMs: 0 };
118
- }
119
-
120
- export interface ParsedLoopArgs {
121
- hqRoot: string;
122
- lockTimeoutSec?: number;
123
- /** Resolved parser flag; environment policy is applied by resolveSkipPersonal. */
124
- skipPersonal?: boolean;
125
- /**
126
- * The run's resolved sync direction, straight from `parseArgs` (the single
127
- * source of truth — same default as the parser). The drain reads this to
128
- * decide whether scoped pushes and/or the pull leg run; it must NOT re-parse
129
- * `--direction` from argv, which mis-defaults an omitted flag and disagrees
130
- * with the parser on duplicate flags.
131
- */
132
- direction?: Direction;
133
- }
134
-
135
- export interface WatchLoopRuntime {
136
- runPassWithOperationLockAlreadyHeld: (
137
- passArgv: string[],
138
- ) => Promise<number | RunnerPassOutcome>;
139
- authRequiredPassExit: number;
140
- defaultGetIdTokenClaims: () => ({ email?: string } & TelemetryClaims) | null;
141
- defaultGetAccessToken: () => Promise<string>;
142
- apiUrl: string;
143
- region: string;
144
- }
145
-
146
- type JournalFiles = ReturnType<typeof readJournal>["files"];
147
-
148
- /**
149
- * Select journal entries covered by one watcher delete. Exact file unlinks
150
- * deliberately avoid enumeration; only directory unlinks expand descendants.
151
- */
152
- export function journalEntriesForWatcherDelete(
153
- files: JournalFiles,
154
- key: string,
155
- kind: "unlink" | "unlinkDir",
156
- ): Array<[string, JournalFiles[string]]> {
157
- if (kind === "unlink") {
158
- const entry = files[key];
159
- return entry ? [[key, entry]] : [];
160
- }
161
- return Object.entries(files).filter(
162
- ([candidate]) =>
163
- key === "" || candidate === key || candidate.startsWith(`${key}/`),
164
- );
165
- }
166
-
167
- /**
168
- * Bulk-intent quarantine thresholds. Deliberately the same shape and values as
169
- * the engine's `BULK_ASYMMETRY_*` constants (`src/cli/share.ts`) so operators
170
- * learn one rule and one knob: a disappearance covering >= 10 journal entries
171
- * AND >= 10% of the shard is treated as suspect rather than deliberate.
172
- */
173
- const WATCHER_BULK_INTENT_MIN_ABS = 10;
174
- const WATCHER_BULK_INTENT_RATIO = 0.1;
175
-
176
- /**
177
- * Should a single `unlinkDir` expansion be quarantined instead of stamping a
178
- * version-bound delete intent on every descendant journal entry?
179
- *
180
- * One filesystem event can cover an arbitrary subtree, so the producer is
181
- * capable of minting thousands of individually-valid intents from a checkout
182
- * swap, an unmount, a partial restore or an `rm -rf` — the 2026-07-30 mass
183
- * deletion did exactly that. Quarantined entries are simply left intent-less,
184
- * which the engine already refuses (`missing-delete-intent`); nothing is
185
- * deleted locally or remotely and the next pull restores the tree.
186
- *
187
- * Pure so the threshold is unit-testable without a watcher or a journal.
188
- */
189
- export function shouldQuarantineWatcherDeleteIntents(
190
- expandedEntries: number,
191
- journalEntries: number,
192
- ): boolean {
193
- if (expandedEntries < WATCHER_BULK_INTENT_MIN_ABS) return false;
194
- if (journalEntries <= 0) return false;
195
- return expandedEntries / journalEntries >= WATCHER_BULK_INTENT_RATIO;
196
- }
197
-
198
- /**
199
- * Same operator escape hatch as the engine breaker — one knob for both layers.
200
- * Set when a genuinely intentional large folder removal must propagate.
201
- */
202
- function isBulkIntentOverride(): boolean {
203
- const v = (process.env.HQ_SYNC_DELETE_BULK_OVERRIDE ?? "").toLowerCase();
204
- return v === "1" || v === "true" || v === "yes";
205
- }
206
-
207
- function passExitCode(outcome: number | RunnerPassOutcome): number {
208
- return typeof outcome === "number" ? outcome : outcome.exitCode;
209
- }
210
-
211
- function companyPassFailures(
212
- outcome: number | RunnerPassOutcome,
213
- ): Array<{ company: string; message: string }> {
214
- return typeof outcome === "number"
215
- ? []
216
- : outcome.result?.companyFailures ?? [];
217
- }
218
-
219
- export async function runOneShotWithOperationLock(
220
- argv: string[],
221
- parsed: ParsedLoopArgs,
222
- deps: RunnerLoopDeps,
223
- runtime: WatchLoopRuntime,
224
- ): Promise<number> {
225
- const runOnce =
226
- deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
227
- try {
228
- return passExitCode(
229
- await withOperationLock(parsed.hqRoot, "sync", () => runOnce(argv), {
230
- timeoutSec: parsed.lockTimeoutSec,
231
- }),
232
- );
233
- } catch (err) {
234
- if (err instanceof OperationLockedError) {
235
- process.stderr.write(err.message + "\n");
236
- return OPERATION_LOCKED_EXIT;
237
- }
238
- throw err;
239
- }
240
- }
241
-
242
- export async function runWatchLoop(
243
- argv: string[],
244
- parsed: ParsedLoopArgs,
245
- deps: RunnerLoopDeps,
246
- runtime: WatchLoopRuntime,
247
- ): Promise<number> {
248
- const sleep =
249
- deps.sleep ??
250
- ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
251
- const monotonicNow = deps.monotonicNow ?? (() => performance.now());
252
- const runPass =
253
- deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
254
- const pollIdx = argv.indexOf("--poll-remote-ms");
255
- // An explicit --poll-remote-ms pins a fixed interval (menubar/CLI still set
256
- // one). When it is omitted, the interval is resolved fresh each cycle from
257
- // whole-box load via adaptivePollMs() — see the sleep at the loop's tail.
258
- const explicitPollMs =
259
- pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : undefined;
260
- const sampleLoadAvg = deps.sampleLoadAvg ?? (() => os.loadavg()[0]);
261
- const cpuCount = os.cpus().length;
262
- const eventPush = argv.includes("--event-push");
263
- const companiesMode = argv.includes("--companies");
264
- const companyMode = argv.includes("--company");
265
- const personalMode = argv.includes("--personal");
266
- // Keep this at the watcher ingress rather than importing the runner's
267
- // resolver: sync-runner imports this loop, and a reverse value import would
268
- // create a runtime cycle. These are the parser's documented truthy values.
269
- const skipPersonalEnv = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
270
- const watchRouteSelection = {
271
- companies: companiesMode,
272
- company: companyMode,
273
- personal: personalMode,
274
- skipPersonal:
275
- parsed.skipPersonal === true ||
276
- skipPersonalEnv === "1" ||
277
- skipPersonalEnv === "true" ||
278
- skipPersonalEnv === "yes",
279
- };
280
- const hqRoot = parsed.hqRoot;
281
- // Delayed and daily: this must never be attached to the 15s poll cadence.
282
- // Stage 0 remains dry-run only; the standalone hq-backup-prune command is
283
- // the explicitly-gated apply surface.
284
- let nextBackupPruneAt = Date.now() + 60_000;
285
- const runBackupPrune = deps.runBackupPrune ?? (async (root: string) => {
286
- const backupRoot = path.join(process.env.HOME ?? "", ".hq", "backups");
287
- const plan = inventoryManagedSnapshots(backupRoot, root, (m) => { try { process.kill(m.creatorPid, 0); return true; } catch { return false; } });
288
- process.stderr.write(`hq-sync-runner: backup retention dry-run — ${plan.candidates.length} candidate(s), ${plan.protected.length} protected\n`);
289
- });
290
-
291
- const passArgv = argv.filter((a, i) => {
292
- if (a === "--watch") return false;
293
- if (a === "--poll-remote-ms") return false;
294
- if (a === "--event-push") return false;
295
- if (a === "--scope-path") return false;
296
- if (a === "--delete-scope-path") return false;
297
- if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
298
- if (i > 0 && argv[i - 1] === "--scope-path") return false;
299
- if (i > 0 && argv[i - 1] === "--delete-scope-path") return false;
300
- return true;
301
- });
302
- // Authoritative direction from parseArgs — never re-derive from argv (that
303
- // mis-defaults an omitted --direction to "both" and picks the wrong
304
- // occurrence on duplicates). Fall back to the parser's own default ("pull").
305
- const originalDirection: Direction = parsed.direction ?? "pull";
306
- const drainRunsPush =
307
- originalDirection === "both" || originalDirection === "push";
308
- const drainRunsPull =
309
- originalDirection === "both" || originalDirection === "pull";
310
-
311
- if (parsed.lockTimeoutSec === 0) {
312
- try {
313
- const handle = acquireOperationLock(hqRoot, "sync", {
314
- timeoutSec: 0,
315
- onWaitStart: () => undefined,
316
- });
317
- handle.release();
318
- } catch (err) {
319
- if (err instanceof OperationLockedError) {
320
- process.stderr.write(err.message + "\n");
321
- return OPERATION_LOCKED_EXIT;
322
- }
323
- throw err;
324
- }
325
- }
326
-
327
- type PassPreparation = () => void;
328
- const runPassWithLock = async (
329
- passArgvForRun: string[],
330
- prepare?: PassPreparation,
331
- ): Promise<number | RunnerPassOutcome> => {
332
- try {
333
- const handle = acquireOperationLock(hqRoot, "sync", {
334
- timeoutSec: 0,
335
- onWaitStart: () => undefined,
336
- });
337
- try {
338
- prepare?.();
339
- return await runPass(passArgvForRun);
340
- } finally {
341
- handle.release();
342
- }
343
- } catch (err) {
344
- if (!(err instanceof OperationLockedError)) throw err;
345
- if (parsed.lockTimeoutSec === 0) {
346
- process.stderr.write(err.message + "\n");
347
- return OPERATION_LOCKED_EXIT;
348
- }
349
- }
350
-
351
- try {
352
- return await withOperationLock(
353
- hqRoot,
354
- "sync",
355
- () => {
356
- prepare?.();
357
- return runPass(passArgvForRun);
358
- },
359
- { timeoutSec: parsed.lockTimeoutSec },
360
- );
361
- } catch (err) {
362
- if (err instanceof OperationLockedError) {
363
- process.stderr.write(err.message + "\n");
364
- return OPERATION_LOCKED_EXIT;
365
- }
366
- throw err;
367
- }
368
- };
369
-
370
- let stopped = false;
371
- let resolveStopped: (() => void) | null = null;
372
- const stoppedSignal = new Promise<void>((resolve) => {
373
- resolveStopped = resolve;
374
- });
375
- // A signal can arrive while a guarded pass is running, before the next wait
376
- // has registered. Keep a monotonic generation so that event is retained and
377
- // consumed by the next scheduler wait instead of being lost.
378
- let interruptGeneration = 0;
379
- let observedInterruptGeneration = 0;
380
- let resolveInterruptWait: (() => void) | null = null;
381
- const interruptWait = (): void => {
382
- if (stopped) return;
383
- interruptGeneration += 1;
384
- const resolve = resolveInterruptWait;
385
- resolveInterruptWait = null;
386
- resolve?.();
387
- };
388
- const waitForNextPass = async (delayMs: number): Promise<void> => {
389
- if (interruptGeneration !== observedInterruptGeneration) {
390
- observedInterruptGeneration = interruptGeneration;
391
- return;
392
- }
393
-
394
- await Promise.race([
395
- sleep(delayMs),
396
- new Promise<void>((resolve) => {
397
- resolveInterruptWait = resolve;
398
- // Do not lose an event that arrived between the first generation
399
- // check and registration of this wait.
400
- if (interruptGeneration !== observedInterruptGeneration) {
401
- resolveInterruptWait = null;
402
- resolve();
403
- }
404
- }),
405
- stoppedSignal,
406
- ]);
407
- resolveInterruptWait = null;
408
- observedInterruptGeneration = interruptGeneration;
409
- };
410
- let activePass: Promise<number | RunnerPassOutcome> | null = null;
411
- type QueuedPass = {
412
- argv: string[];
413
- prepare?: PassPreparation;
414
- resolve: (outcome: number | RunnerPassOutcome) => void;
415
- reject: (err: unknown) => void;
416
- };
417
- const pendingPasses: QueuedPass[] = [];
418
- const resolveStoppedQueue = (): void => {
419
- while (pendingPasses.length > 0) {
420
- pendingPasses.shift()?.resolve(0);
421
- }
422
- };
423
- const drainQueuedPasses = (): void => {
424
- if (activePass !== null) return;
425
- if (stopped) {
426
- resolveStoppedQueue();
427
- return;
428
- }
429
- const next = pendingPasses.shift();
430
- if (!next) return;
431
- const current = startGuardedPass(next.argv, next.prepare);
432
- void current.then(next.resolve, next.reject);
433
- };
434
- const startGuardedPass = (
435
- passArgvForRun: string[],
436
- prepare?: PassPreparation,
437
- ): Promise<number | RunnerPassOutcome> => {
438
- const current = stopped
439
- ? Promise.resolve(0)
440
- : runPassWithLock(passArgvForRun, prepare);
441
- activePass = current;
442
- void current
443
- .finally(() => {
444
- if (activePass === current) {
445
- activePass = null;
446
- drainQueuedPasses();
447
- }
448
- })
449
- .catch(() => undefined);
450
- return current;
451
- };
452
- const runGuarded = (
453
- passArgvForRun: string[],
454
- prepare?: PassPreparation,
455
- ): Promise<number | RunnerPassOutcome> => {
456
- if (activePass === null && pendingPasses.length === 0) {
457
- return startGuardedPass(passArgvForRun, prepare);
458
- }
459
-
460
- return new Promise<number | RunnerPassOutcome>((resolve, reject) => {
461
- pendingPasses.push({ argv: passArgvForRun, prepare, resolve, reject });
462
- drainQueuedPasses();
463
- });
464
- };
465
-
466
- let watcher: WatcherSurface | null = null;
467
- let detachSignal: (() => void) | null = null;
468
- const pendingWatcherPaths = new Map<string, string>();
469
- const pendingWatcherChanges = new Map<string, TreeChange>();
470
- let pendingWatcherOriginalBatch: TreeChangeBatch | null = null;
471
- // Every coalesced watcher batch gets one durable in-process generation.
472
- // A wake can cause the scheduler to revisit a drained batch, but an event
473
- // announcement is an external side effect and must occur at most once for
474
- // that batch after its targeted push succeeds.
475
- let nextWatcherBatchGeneration = 0;
476
- let pendingWatcherBatchGeneration: number | null = null;
477
- const lastPublishedWatcherBatchGenerationByRoute = new Map<string, number>();
478
- let pendingWatcherBareChange = false;
479
- let pendingWatcherOverflowed = false;
480
- let pendingWatcherDroppedPaths = 0;
481
- let pendingWatcherDroppedBytes = 0;
482
- const addPendingWatcherChange = (
483
- changedRelPath?: string,
484
- batch?: TreeChangeBatch,
485
- ): boolean => {
486
- if (batch) {
487
- const paths = new Map<string, string>();
488
- const changes = new Map<string, TreeChange>();
489
- for (const [absolutePath, relativePath] of batch.paths.entries()) {
490
- const route = routeChangeToTarget(relativePath);
491
- if (!route || !isWatchRouteSelected(route, watchRouteSelection)) continue;
492
- paths.set(absolutePath, relativePath);
493
- const change = batch.changes?.get(absolutePath);
494
- if (change) changes.set(absolutePath, change);
495
- }
496
- if (paths.size === 0) return false;
497
- batch = {
498
- ...batch,
499
- paths,
500
- changes,
501
- };
502
- } else if (changedRelPath) {
503
- const route = routeChangeToTarget(changedRelPath);
504
- if (!route || !isWatchRouteSelected(route, watchRouteSelection)) {
505
- return false;
506
- }
507
- }
508
- pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
509
- if (batch) {
510
- pendingWatcherOriginalBatch =
511
- pendingWatcherPaths.size === 0 &&
512
- !pendingWatcherBareChange &&
513
- !pendingWatcherOverflowed &&
514
- !batch.overflowed
515
- ? batch
516
- : null;
517
- for (const [absolutePath, relativePath] of batch.paths.entries()) {
518
- pendingWatcherPaths.set(absolutePath, relativePath);
519
- const change = batch.changes?.get(absolutePath);
520
- if (change) pendingWatcherChanges.set(absolutePath, change);
521
- }
522
- if (batch.overflowed) {
523
- pendingWatcherOverflowed = true;
524
- pendingWatcherDroppedPaths += batch.droppedPaths ?? 0;
525
- pendingWatcherDroppedBytes += batch.droppedBytes ?? 0;
526
- }
527
- return true;
528
- }
529
- if (changedRelPath) {
530
- pendingWatcherOriginalBatch = null;
531
- // Codec boundary: `changedRelPath` is a canonical vault key (the
532
- // TreeWatcher decodes win32 farm names before emitting), so its local
533
- // counterpart must be re-derived through the codec — a raw join would
534
- // point at the impossible colon path on Windows.
535
- const absolutePath = localPathForVaultKey(hqRoot, changedRelPath);
536
- pendingWatcherPaths.set(absolutePath, changedRelPath);
537
- // A bare path signal carries no delete intent; drop any change record
538
- // a prior batch left for this path so the stale intent cannot leak
539
- // into the coalesced batch (#227).
540
- pendingWatcherChanges.delete(absolutePath);
541
- return true;
542
- }
543
- pendingWatcherOriginalBatch = null;
544
- pendingWatcherBareChange = true;
545
- return true;
546
- };
547
- const takePendingWatcherChange = (): {
548
- batch: TreeChangeBatch | null;
549
- bare: boolean;
550
- generation: number | null;
551
- } => {
552
- const batch =
553
- pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
554
- ? pendingWatcherOriginalBatch
555
- : pendingWatcherPaths.size > 0 || pendingWatcherOverflowed
556
- ? {
557
- paths: new Map(pendingWatcherPaths),
558
- ...(pendingWatcherChanges.size > 0
559
- ? { changes: new Map(pendingWatcherChanges) }
560
- : {}),
561
- ...(pendingWatcherOverflowed
562
- ? {
563
- overflowed: true,
564
- droppedPaths: pendingWatcherDroppedPaths,
565
- droppedBytes: pendingWatcherDroppedBytes,
566
- }
567
- : {}),
568
- }
569
- : null;
570
- const bare = pendingWatcherBareChange;
571
- const generation = pendingWatcherBatchGeneration;
572
- pendingWatcherPaths.clear();
573
- pendingWatcherChanges.clear();
574
- pendingWatcherOriginalBatch = null;
575
- pendingWatcherBareChange = false;
576
- pendingWatcherOverflowed = false;
577
- pendingWatcherDroppedPaths = 0;
578
- pendingWatcherDroppedBytes = 0;
579
- pendingWatcherBatchGeneration = null;
580
- return { batch, bare, generation };
581
- };
582
- const restorePendingWatcherPaths = (
583
- paths: Map<string, string>,
584
- generation: number | null,
585
- ): void => {
586
- pendingWatcherBatchGeneration ??=
587
- generation ?? ++nextWatcherBatchGeneration;
588
- pendingWatcherOriginalBatch = null;
589
- for (const [absolutePath, relativePath] of paths.entries()) {
590
- pendingWatcherPaths.set(absolutePath, relativePath);
591
- }
592
- };
593
-
594
- /**
595
- * Journal roots the bulk-intent quarantine has refused for the current
596
- * delete episode, keyed `slug\0journalKey`.
597
- *
598
- * Chokidar (the Linux backend) reports every removed child through `unlink`
599
- * BEFORE the parent `unlinkDir`, so by the time the directory expansion is
600
- * refused each descendant has already persisted its own version-bound intent
601
- * and handed a snapshot to the pending batch. Declining the expansion alone
602
- * leaves those intents standing and `prepareWatcherChanges` re-stamps them
603
- * from the snapshots, so a large `rm -rf` still propagates in full under
604
- * `HQ_SYNC_DELETE_POLICY=all` — the path this producer-side layer exists to
605
- * cover, because it bypasses the engine breaker entirely. The trip therefore
606
- * revokes the descendants' intents as well, and this set keeps the refusal
607
- * in force for the rest of the episode (the native macOS/Windows backends
608
- * make no child-before-parent guarantee, so descendants can also arrive
609
- * after the directory event).
610
- */
611
- const quarantinedDeleteRoots = new Set<string>();
612
- const QUARANTINE_KEY_SEPARATOR = "\u0000";
613
- const quarantineRootKey = (slug: string, journalKey: string): string =>
614
- `${slug}${QUARANTINE_KEY_SEPARATOR}${journalKey}`;
615
- const quarantineRootFor = (
616
- slug: string,
617
- journalKey: string,
618
- ): string | null => {
619
- for (const root of quarantinedDeleteRoots) {
620
- const separator = root.indexOf(QUARANTINE_KEY_SEPARATOR);
621
- if (root.slice(0, separator) !== slug) continue;
622
- const rootKey = root.slice(separator + 1);
623
- if (
624
- rootKey === "" ||
625
- journalKey === rootKey ||
626
- journalKey.startsWith(`${rootKey}/`)
627
- ) {
628
- return root;
629
- }
630
- }
631
- return null;
632
- };
633
-
634
- const journalCoordinates = (
635
- relativePath: string,
636
- ): { slug: string; key: string } | null => {
637
- const route = routeChangeToTarget(relativePath);
638
- if (!route || !isWatchRouteSelected(route, watchRouteSelection)) return null;
639
- if (route.kind === "personal") {
640
- return { slug: PERSONAL_VAULT_JOURNAL_SLUG, key: relativePath };
641
- }
642
- const prefix = `companies/${route.slug}`;
643
- return {
644
- slug: route.slug,
645
- key:
646
- relativePath === prefix
647
- ? ""
648
- : relativePath.slice(prefix.length + 1),
649
- };
650
- };
651
-
652
- const captureLocalDeleteSnapshots = (
653
- relativePath: string,
654
- kind: "unlink" | "unlinkDir",
655
- ): LocalDeleteSnapshot[] => {
656
- const coordinates = journalCoordinates(relativePath);
657
- const route = routeChangeToTarget(relativePath);
658
- if (!coordinates || !route) return [];
659
- const journal = readJournal(coordinates.slug);
660
- const snapshots: LocalDeleteSnapshot[] = [];
661
- const captureEntry = (
662
- key: string,
663
- entry: (typeof journal.files)[string] | undefined,
664
- ): void => {
665
- if (!entry?.remoteEtag || !entry.kind || entry.removedAt !== undefined) {
666
- return;
667
- }
668
- const snapshot: LocalDeleteSnapshot = {
669
- journalSlug: coordinates.slug,
670
- journalPath: key,
671
- deleteScopeRoot: coordinates.key,
672
- absolutePath:
673
- route.kind === "company"
674
- ? path.join(hqRoot, "companies", route.slug, key)
675
- : path.join(hqRoot, key),
676
- remoteEtag: entry.remoteEtag,
677
- localHash: entry.hash,
678
- localKind: entry.kind,
679
- };
680
- if (
681
- markLocalDeleteIntent(
682
- journal,
683
- key,
684
- snapshot.localHash,
685
- snapshot.localKind,
686
- snapshot.remoteEtag,
687
- )
688
- ) {
689
- snapshots.push(snapshot);
690
- }
691
- };
692
-
693
- // Chokidar emits one unlink per file during rm -rf. Avoid walking the
694
- // whole shard for every exact-key event; directory unlinks alone need
695
- // descendant expansion.
696
- const covered = journalEntriesForWatcherDelete(
697
- journal.files,
698
- coordinates.key,
699
- kind,
700
- );
701
-
702
- /**
703
- * Revoke every delete intent under this event, including ones a descendant
704
- * `unlink` already persisted before the parent `unlinkDir` arrived. An
705
- * intent-less disappearance is the safe state: the engine refuses it
706
- * (`missing-delete-intent`) under every delete policy, nothing is deleted
707
- * locally or remotely, and the next pull restores the subtree.
708
- */
709
- const revokeCoveredDeleteIntents = (): number => {
710
- let revoked = 0;
711
- for (const [key] of covered) {
712
- if (clearLocalDeleteIntent(journal, key)) revoked += 1;
713
- }
714
- if (revoked > 0) writeJournal(coordinates.slug, journal);
715
- return revoked;
716
- };
717
-
718
- // A refused directory removal covers its descendants for the rest of the
719
- // episode, whichever order the backend reports them in.
720
- if (quarantineRootFor(coordinates.slug, coordinates.key)) {
721
- revokeCoveredDeleteIntents();
722
- return [];
723
- }
724
-
725
- // Bulk-intent quarantine: one directory event must not be able to mint
726
- // intents across a large fraction of the shard. Leaving them intent-less
727
- // is the safe state — the engine refuses intent-less disappearances and
728
- // the next pull restores the subtree.
729
- const stampable = covered.filter(
730
- ([, entry]) =>
731
- entry?.remoteEtag && entry.kind && entry.removedAt === undefined,
732
- );
733
- if (
734
- kind === "unlinkDir" &&
735
- !isBulkIntentOverride() &&
736
- shouldQuarantineWatcherDeleteIntents(
737
- stampable.length,
738
- Object.keys(journal.files).length,
739
- )
740
- ) {
741
- quarantinedDeleteRoots.add(
742
- quarantineRootKey(coordinates.slug, coordinates.key),
743
- );
744
- const revoked = revokeCoveredDeleteIntents();
745
- process.stderr.write(
746
- `hq-sync-runner: refusing to record delete intent for ${stampable.length} ` +
747
- `of ${Object.keys(journal.files).length} journal entries under ` +
748
- `"${coordinates.key || "<root>"}" — a single directory removal that large ` +
749
- `is treated as a possible mirror loss. Nothing was deleted. ` +
750
- `Revoked ${revoked} intent(s) already recorded by descendant unlink ` +
751
- `events. Set HQ_SYNC_DELETE_BULK_OVERRIDE=1 to propagate it ` +
752
- `deliberately.\n`,
753
- );
754
- return [];
755
- }
756
-
757
- for (const [key, entry] of stampable) {
758
- captureEntry(key, entry);
759
- }
760
-
761
- // Persist at observation time, before the debounced batch can queue
762
- // behind an in-flight pull. The pull planner treats this current,
763
- // version-bound intent as authoritative and will not resurrect the path.
764
- if (snapshots.length > 0) {
765
- writeJournal(coordinates.slug, journal);
766
- }
767
- return snapshots;
768
- };
769
-
770
- const prepareWatcherChanges = (changes: Map<string, TreeChange>): void => {
771
- const journals = new Map<string, ReturnType<typeof readJournal>>();
772
- const dirty = new Set<string>();
773
- const journalFor = (slug: string) => {
774
- const loaded = journals.get(slug);
775
- if (loaded) return loaded;
776
- const journal = readJournal(slug);
777
- journals.set(slug, journal);
778
- return journal;
779
- };
780
-
781
- for (const [absolutePath, change] of changes.entries()) {
782
- if (change.kind === "unlink" || change.kind === "unlinkDir") {
783
- for (const snapshot of change.deleteSnapshots ?? []) {
784
- // Snapshots captured by descendant unlinks before the directory
785
- // expansion was refused are part of the same quarantined episode.
786
- // Re-stamping them here would hand the engine exactly the bulk
787
- // delete the quarantine declined.
788
- if (quarantineRootFor(snapshot.journalSlug, snapshot.journalPath)) {
789
- if (
790
- clearLocalDeleteIntent(
791
- journalFor(snapshot.journalSlug),
792
- snapshot.journalPath,
793
- )
794
- ) {
795
- dirty.add(snapshot.journalSlug);
796
- }
797
- continue;
798
- }
799
- let absent = false;
800
- try {
801
- fs.lstatSync(snapshot.absolutePath);
802
- } catch (err: unknown) {
803
- const code =
804
- err && typeof err === "object" && "code" in err
805
- ? (err as { code?: string }).code
806
- : undefined;
807
- if (code !== "ENOENT") continue;
808
- absent = true;
809
- }
810
- if (!absent) {
811
- if (
812
- clearLocalDeleteIntent(
813
- journalFor(snapshot.journalSlug),
814
- snapshot.journalPath,
815
- )
816
- ) {
817
- dirty.add(snapshot.journalSlug);
818
- }
819
- continue;
820
- }
821
- if (
822
- markLocalDeleteIntent(
823
- journalFor(snapshot.journalSlug),
824
- snapshot.journalPath,
825
- snapshot.localHash,
826
- snapshot.localKind,
827
- snapshot.remoteEtag,
828
- )
829
- ) {
830
- dirty.add(snapshot.journalSlug);
831
- }
832
- }
833
- continue;
834
- }
835
-
836
- const relativePath = path
837
- .relative(hqRoot, absolutePath)
838
- .split(path.sep)
839
- .join("/");
840
- const coordinates = journalCoordinates(relativePath);
841
- if (
842
- coordinates &&
843
- clearLocalDeleteIntent(
844
- journalFor(coordinates.slug),
845
- coordinates.key,
846
- )
847
- ) {
848
- dirty.add(coordinates.slug);
849
- }
850
- }
851
-
852
- for (const slug of dirty) {
853
- writeJournal(slug, journals.get(slug)!);
854
- }
855
- };
856
-
857
- const groupBatchByRoute = (
858
- batch: TreeChangeBatch,
859
- ): Map<
860
- string,
861
- {
862
- route: WatchRoute;
863
- paths: Map<string, string>;
864
- changes: Map<string, TreeChange>;
865
- relPaths: string[];
866
- deleteScopeRoots: string[];
867
- }
868
- > => {
869
- const grouped = new Map<
870
- string,
871
- {
872
- route: WatchRoute;
873
- paths: Map<string, string>;
874
- changes: Map<string, TreeChange>;
875
- relPaths: string[];
876
- deleteScopeRoots: string[];
877
- }
878
- >();
879
- for (const [absolutePath, relativePath] of batch.paths.entries()) {
880
- const route = routeChangeToTarget(relativePath);
881
- if (!route) continue;
882
- const key = routeKey(route);
883
- const existing =
884
- grouped.get(key) ??
885
- {
886
- route,
887
- paths: new Map<string, string>(),
888
- changes: new Map<string, TreeChange>(),
889
- relPaths: [],
890
- deleteScopeRoots: [],
891
- };
892
- existing.paths.set(absolutePath, relativePath);
893
- const change = batch.changes?.get(absolutePath);
894
- if (change) existing.changes.set(absolutePath, change);
895
- existing.relPaths = [...new Set([...existing.paths.values()])];
896
- existing.deleteScopeRoots = [
897
- ...new Set(
898
- [...existing.changes.values()].flatMap((item) =>
899
- (item.deleteSnapshots ?? []).flatMap((snapshot) =>
900
- snapshot.deleteScopeRoot === undefined
901
- ? []
902
- : [snapshot.deleteScopeRoot],
903
- ),
904
- ),
905
- ),
906
- ];
907
- grouped.set(key, existing);
908
- }
909
- return grouped;
910
- };
911
-
912
- const publishBatchIfAvailable = (paths: Map<string, string>): boolean => {
913
- if (!stopped && eventSync && paths.size > 0) {
914
- eventSync.publishBatch({ paths: new Map(paths) });
915
- return true;
916
- }
917
- return false;
918
- };
919
-
920
- const runScopedDrain = async (
921
- batch: TreeChangeBatch | null,
922
- watcherBatchGeneration: number | null,
923
- ): Promise<number> => {
924
- if (drainRunsPush && batch && batch.paths.size > 0) {
925
- const skipCompanies = resolveSkipCompanies();
926
- const grouped = groupBatchByRoute(batch);
927
- for (const group of grouped.values()) {
928
- // Paused workspaces: drop the scoped push without uploading or
929
- // publishing event-sync metadata (path/hash must not leak while Off).
930
- if (
931
- group.route.kind === "company" &&
932
- skipCompanies.has(group.route.slug.toLowerCase())
933
- ) {
934
- continue;
935
- }
936
- const scopedArgv = buildScopedPushArgv(
937
- group.route,
938
- group.relPaths,
939
- passArgv,
940
- group.deleteScopeRoots,
941
- );
942
- try {
943
- const result = await runGuarded(
944
- scopedArgv,
945
- group.changes.size > 0
946
- ? () => prepareWatcherChanges(group.changes)
947
- : undefined,
948
- );
949
- if (passExitCode(result) === 0) {
950
- const refusedPaths = new Set(
951
- typeof result === "number"
952
- ? []
953
- : (result.result?.pushPathResults ?? [])
954
- .filter((pathResult) => pathResult.status === "refused")
955
- .map((pathResult) => pathResult.relativePath),
956
- );
957
- const route = routeKey(group.route);
958
- const generation = watcherBatchGeneration ?? 0;
959
- if (
960
- lastPublishedWatcherBatchGenerationByRoute.get(route) !== generation
961
- ) {
962
- const published = publishBatchIfAvailable(
963
- new Map(
964
- [...group.paths.entries()].filter(
965
- ([absolutePath, relativePath]) => {
966
- if (refusedPaths.has(relativePath)) return false;
967
- if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
968
- return true;
969
- }
970
- return ![...refusedPaths].some((refusedPath) =>
971
- refusedPath.startsWith(`${relativePath}/`),
972
- );
973
- },
974
- ),
975
- ),
976
- );
977
- if (published) {
978
- lastPublishedWatcherBatchGenerationByRoute.set(route, generation);
979
- }
980
- }
981
- } else {
982
- restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
983
- }
984
- } catch (err) {
985
- restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
986
- process.stderr.write(
987
- `watch scoped push failed, will retry next tick: ${describeError(err)}\n`,
988
- );
989
- }
990
- }
991
- }
992
-
993
- if (!drainRunsPull) return 0;
994
- return passExitCode(
995
- await runGuarded(buildScopedDrainPullArgv(passArgv)),
996
- );
997
- };
998
-
999
- let receiver: PushReceiver | null = null;
1000
- let eventSync: EventSyncHandles | null = null;
1001
-
1002
- if (eventPush) {
1003
- const clock = deps.clock ?? systemClock;
1004
- const debounceMs = 2000;
1005
- // The watcher owns the structured degradation event; give it the normal
1006
- // runner telemetry client so a budget/ENOSPC fallback is observable even
1007
- // when event-sync itself is not enrolled.
1008
- const getClaims =
1009
- deps.getIdTokenClaims ?? runtime.defaultGetIdTokenClaims;
1010
- const claims = getClaims();
1011
- const getAccessToken =
1012
- deps.getAccessToken ?? runtime.defaultGetAccessToken;
1013
- const watcherTelemetryClient = new VaultClient({
1014
- apiUrl: runtime.apiUrl,
1015
- authToken: getAccessToken,
1016
- region: runtime.region,
1017
- });
1018
- const createWatcher =
1019
- deps.createWatcher ??
1020
- ((opts) =>
1021
- new TreeWatcher({
1022
- hqRoot: opts.hqRoot,
1023
- debounceMs: opts.debounceMs,
1024
- clock: opts.clock,
1025
- personalMode: !companiesMode,
1026
- captureLocalDeleteSnapshots: opts.captureLocalDeleteSnapshots,
1027
- telemetryClient: watcherTelemetryClient,
1028
- telemetryClaims: claims,
1029
- }));
1030
- watcher = createWatcher({
1031
- hqRoot,
1032
- debounceMs,
1033
- clock,
1034
- captureLocalDeleteSnapshots,
1035
- });
1036
-
1037
- watcher.onChange((changedRelPath, batch) => {
1038
- if (stopped) return;
1039
- if (addPendingWatcherChange(changedRelPath, batch)) interruptWait();
1040
- });
1041
- watcher.start();
1042
-
1043
- const receiverSyncFn: SyncEngineFn = async (ctx) => {
1044
- if (stopped) return;
1045
- // A received wake/high-water signal means there is bounded work now.
1046
- // It wakes the scheduler but is still funneled through runGuarded below,
1047
- // so it cannot overlap the pass that may currently own the operation
1048
- // lock.
1049
- interruptWait();
1050
- const route = routeChangeToTarget(ctx.event.relativePath);
1051
- if (!route) return;
1052
- const targetedArgv = buildTargetedPullArgv(route, passArgv);
1053
- const result = await runGuarded(targetedArgv);
1054
- if (passExitCode(result) !== 0) {
1055
- throw new Error(
1056
- `targeted pull failed with exit code ${passExitCode(result)}`,
1057
- );
1058
- }
1059
- };
1060
- const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
1061
- receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
1062
- void Promise.resolve(receiver.start()).catch(() => undefined);
1063
-
1064
- if (resolveEventSync(process.env.HQ_SYNC_EVENT_SYNC)) {
1065
- const startES = deps.startEventSync ?? defaultStartEventSync;
1066
- void (async () => {
1067
- const eventSyncClient = new VaultClient({
1068
- apiUrl: runtime.apiUrl,
1069
- authToken: getAccessToken,
1070
- region: runtime.region,
1071
- });
1072
- const handles = await startES({
1073
- hqRoot,
1074
- apiUrl: runtime.apiUrl,
1075
- authToken: getAccessToken,
1076
- deviceId: getOrCreateMachineId(hqRoot),
1077
- resolveTenantId: async () => {
1078
- const persons = await eventSyncClient.entity.listByType("person");
1079
- const pick = pickCanonicalPersonEntity(persons);
1080
- if (!pick?.uid) {
1081
- throw new Error("no canonical person entity for this account");
1082
- }
1083
- return pick.uid;
1084
- },
1085
- syncFn: receiverSyncFn,
1086
- log: (m) => process.stderr.write(`${m}\n`),
1087
- telemetryClient: eventSyncClient,
1088
- telemetryClaims: claims,
1089
- });
1090
- if (!handles) return;
1091
- if (stopped) {
1092
- void handles.dispose();
1093
- return;
1094
- }
1095
- eventSync = handles;
1096
- })().catch((err) => {
1097
- process.stderr.write(
1098
- `event-sync: wiring failed, continuing poll-only: ${describeError(err)}\n`,
1099
- );
1100
- });
1101
- }
1102
- }
1103
-
1104
- const shutdown = (): void => {
1105
- if (stopped) return;
1106
- stopped = true;
1107
- resolveStoppedQueue();
1108
- try {
1109
- void receiver?.dispose();
1110
- } catch {
1111
- /* ignore */
1112
- }
1113
- try {
1114
- void eventSync?.dispose();
1115
- } catch {
1116
- /* ignore */
1117
- }
1118
- eventSync = null;
1119
- try {
1120
- watcher?.dispose();
1121
- } catch {
1122
- /* ignore */
1123
- }
1124
- resolveStopped?.();
1125
- };
1126
- const onShutdownSignal =
1127
- deps.onShutdownSignal ??
1128
- ((handler: () => void) => {
1129
- const wrapped = () => handler();
1130
- process.on("SIGTERM", wrapped);
1131
- process.on("SIGINT", wrapped);
1132
- return () => {
1133
- process.off("SIGTERM", wrapped);
1134
- process.off("SIGINT", wrapped);
1135
- };
1136
- });
1137
- detachSignal = onShutdownSignal(shutdown);
1138
-
1139
- // The initial reconcile is due immediately. Later passes advance from this
1140
- // deadline, not from pass completion, so cadence stays monotonic.
1141
- let nextDueAt = monotonicNow();
1142
- let pollTick = 0;
1143
- try {
1144
- while (!stopped) {
1145
- pollTick++;
1146
- const pending = takePendingWatcherChange();
1147
- // The quarantine is scoped to the delete episode it refused, and only
1148
- // the roots already refused when this batch was taken belong to it.
1149
- // Snapshot them here rather than clearing the whole set after the pass:
1150
- // the pass is awaited, so a bulk removal observed while it runs adds its
1151
- // root afterwards and its snapshots ride the NEXT batch. Clearing
1152
- // unconditionally would drop that root before `prepareWatcherChanges`
1153
- // ever sees the batch, re-stamping the revoked descendant intents and
1154
- // handing the engine exactly the bulk delete the quarantine refused.
1155
- const drainedQuarantineRoots = new Set(quarantinedDeleteRoots);
1156
- const shouldRunFull =
1157
- pollTick === 1 ||
1158
- pending.bare ||
1159
- pending.batch?.overflowed === true ||
1160
- pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
1161
- const result = shouldRunFull
1162
- ? await runGuarded(passArgv)
1163
- : await runScopedDrain(pending.batch, pending.generation);
1164
- // This batch's episode has now been prepared and pushed, so a later
1165
- // removal under the same path is judged fresh — a root that never
1166
- // expired would silently refuse ordinary deletes from here on.
1167
- for (const root of drainedQuarantineRoots) {
1168
- quarantinedDeleteRoots.delete(root);
1169
- }
1170
- const exitCode = passExitCode(result);
1171
- if (exitCode === runtime.authRequiredPassExit) {
1172
- // The pass already emitted auth-error. Stop the unattended loop but
1173
- // keep the process exit successful so desktop/manual consumers treat
1174
- // this as a reauth state, not a crash.
1175
- return 0;
1176
- } else if (exitCode === TRANSIENT_NETWORK_EXIT) {
1177
- // The machine was briefly offline (the discovery fetch failed after
1178
- // retries). That is NOT a crash — stay alive and retry on the next
1179
- // poll. Returning it here would exit the watcher and the menubar would
1180
- // report "watcher exited unexpectedly" for every network blip
1181
- // (HQ-SYNC-1W). Log to stderr so it still shows up as a breadcrumb.
1182
- process.stderr.write(
1183
- "hq-sync-runner: watch pass skipped — transient network failure; retrying next poll\n",
1184
- );
1185
- } else if (exitCode === PARTIAL_SYNC_EXIT) {
1186
- // The pass completed but some per-file transfers threw. Also NOT a
1187
- // crash: the per-file errors were already emitted as ndjson `error`
1188
- // events (menubar breadcrumbs + journal), and exiting here just gets
1189
- // the watcher hot-respawned and Sentry-captured on every failing pass
1190
- // (the July 2026 code=Some(2) alert flood — see exit-codes.ts). Stay
1191
- // alive; everything else keeps syncing and the failing paths re-attempt
1192
- // on the next tick. A one-shot `hq sync` still exits 2.
1193
- const failures = companyPassFailures(result);
1194
- const failedCompanies = Array.from(
1195
- new Set(failures.map((failure) => failure.company)),
1196
- );
1197
- for (const failure of failures) {
1198
- process.stderr.write(
1199
- `hq-sync-runner: COMPANY PASS FAILED company=${failure.company} message=${failure.message} (downloads for this company did not run)\n`,
1200
- );
1201
- }
1202
- process.stderr.write(
1203
- "hq-sync-runner: watch pass completed with per-file errors" +
1204
- (failedCompanies.length > 0
1205
- ? `; failed-companies=${failedCompanies.join(",")}`
1206
- : "") +
1207
- "; staying alive, retrying next poll\n",
1208
- );
1209
- } else if (exitCode !== 0) {
1210
- return exitCode;
1211
- }
1212
- if (Date.now() >= nextBackupPruneAt) {
1213
- // Advance before invoking: a failed/unavailable backstop cannot become
1214
- // an every-tick retry loop. It is diagnostic only and never restarts
1215
- // the sync daemon.
1216
- nextBackupPruneAt = Date.now() + 24 * 60 * 60 * 1000;
1217
- try { await runBackupPrune(hqRoot); }
1218
- catch (error) { process.stderr.write(`hq-sync-runner: backup retention dry-run failed — ${error instanceof Error ? error.message : String(error)}\n`); }
1219
- }
1220
- const nextPollMs =
1221
- explicitPollMs ?? adaptivePollMs(sampleLoadAvg(), cpuCount);
1222
- const schedule = advanceMonotonicDeadline(
1223
- nextDueAt,
1224
- monotonicNow(),
1225
- nextPollMs,
1226
- );
1227
- nextDueAt = schedule.nextDueAt;
1228
- await waitForNextPass(schedule.delayMs);
1229
- }
1230
- return 0;
1231
- } finally {
1232
- shutdown();
1233
- detachSignal?.();
1234
- }
1235
- }