@indigoai-us/hq-cloud 6.14.50 → 6.15.1

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 (340) hide show
  1. package/dist/bin/sync-mutation.d.ts +14 -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 +64 -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/reindex-knowledge.d.ts +7 -0
  34. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  35. package/dist/cli/reindex-knowledge.js +389 -0
  36. package/dist/cli/reindex-knowledge.js.map +1 -0
  37. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  38. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  39. package/dist/cli/reindex-knowledge.test.js +260 -0
  40. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  41. package/dist/cli/reindex.d.ts.map +1 -1
  42. package/dist/cli/reindex.js +8 -0
  43. package/dist/cli/reindex.js.map +1 -1
  44. package/dist/cli/rescue-settings-reconcile.test.js +47 -0
  45. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -1
  46. package/dist/cli/sync.d.ts +27 -0
  47. package/dist/cli/sync.d.ts.map +1 -1
  48. package/dist/cli/sync.js +52 -0
  49. package/dist/cli/sync.js.map +1 -1
  50. package/dist/cli/sync.test.js +31 -1
  51. package/dist/cli/sync.test.js.map +1 -1
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/skill-telemetry.d.ts +6 -0
  57. package/dist/skill-telemetry.d.ts.map +1 -1
  58. package/dist/skill-telemetry.js +14 -2
  59. package/dist/skill-telemetry.js.map +1 -1
  60. package/dist/skill-telemetry.test.js +79 -0
  61. package/dist/skill-telemetry.test.js.map +1 -1
  62. package/dist/sync/candidate-uploader.d.ts +88 -0
  63. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  64. package/dist/sync/candidate-uploader.js +212 -0
  65. package/dist/sync/candidate-uploader.js.map +1 -0
  66. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  67. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  68. package/dist/sync/candidate-uploader.test.js +132 -0
  69. package/dist/sync/candidate-uploader.test.js.map +1 -0
  70. package/dist/sync/delta-client.d.ts +73 -0
  71. package/dist/sync/delta-client.d.ts.map +1 -0
  72. package/dist/sync/delta-client.js +201 -0
  73. package/dist/sync/delta-client.js.map +1 -0
  74. package/dist/sync/delta-client.test.d.ts +2 -0
  75. package/dist/sync/delta-client.test.d.ts.map +1 -0
  76. package/dist/sync/delta-client.test.js +97 -0
  77. package/dist/sync/delta-client.test.js.map +1 -0
  78. package/dist/sync/durable-apply.d.ts +76 -0
  79. package/dist/sync/durable-apply.d.ts.map +1 -0
  80. package/dist/sync/durable-apply.js +530 -0
  81. package/dist/sync/durable-apply.js.map +1 -0
  82. package/dist/sync/durable-apply.test.d.ts +2 -0
  83. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  84. package/dist/sync/durable-apply.test.js +180 -0
  85. package/dist/sync/durable-apply.test.js.map +1 -0
  86. package/dist/sync/event-sync.d.ts +33 -1
  87. package/dist/sync/event-sync.d.ts.map +1 -1
  88. package/dist/sync/event-sync.js +149 -1
  89. package/dist/sync/event-sync.js.map +1 -1
  90. package/dist/sync/event-sync.test.js +142 -1
  91. package/dist/sync/event-sync.test.js.map +1 -1
  92. package/dist/sync/index.d.ts +2 -0
  93. package/dist/sync/index.d.ts.map +1 -1
  94. package/dist/sync/index.js +1 -0
  95. package/dist/sync/index.js.map +1 -1
  96. package/dist/sync/multipart-uploader.d.ts +99 -0
  97. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  98. package/dist/sync/multipart-uploader.js +447 -0
  99. package/dist/sync/multipart-uploader.js.map +1 -0
  100. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  101. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  102. package/dist/sync/multipart-uploader.test.js +119 -0
  103. package/dist/sync/multipart-uploader.test.js.map +1 -0
  104. package/dist/sync/mutation-client.d.ts +82 -0
  105. package/dist/sync/mutation-client.d.ts.map +1 -0
  106. package/dist/sync/mutation-client.js +221 -0
  107. package/dist/sync/mutation-client.js.map +1 -0
  108. package/dist/sync/mutation-client.test.d.ts +2 -0
  109. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  110. package/dist/sync/mutation-client.test.js +51 -0
  111. package/dist/sync/mutation-client.test.js.map +1 -0
  112. package/dist/sync/push-receiver.d.ts +45 -0
  113. package/dist/sync/push-receiver.d.ts.map +1 -1
  114. package/dist/sync/push-receiver.js +101 -0
  115. package/dist/sync/push-receiver.js.map +1 -1
  116. package/dist/sync/push-receiver.test.js +54 -2
  117. package/dist/sync/push-receiver.test.js.map +1 -1
  118. package/dist/sync/scope-inventory-client.d.ts +69 -0
  119. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  120. package/dist/sync/scope-inventory-client.js +210 -0
  121. package/dist/sync/scope-inventory-client.js.map +1 -0
  122. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  123. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  124. package/dist/sync/scope-inventory-client.test.js +94 -0
  125. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  126. package/dist/sync/snapshot-client.d.ts +98 -0
  127. package/dist/sync/snapshot-client.d.ts.map +1 -0
  128. package/dist/sync/snapshot-client.js +402 -0
  129. package/dist/sync/snapshot-client.js.map +1 -0
  130. package/dist/sync/snapshot-client.test.d.ts +2 -0
  131. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  132. package/dist/sync/snapshot-client.test.js +169 -0
  133. package/dist/sync/snapshot-client.test.js.map +1 -0
  134. package/dist/sync/uploader-finalization.d.ts +97 -0
  135. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  136. package/dist/sync/uploader-finalization.js +273 -0
  137. package/dist/sync/uploader-finalization.js.map +1 -0
  138. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  139. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  140. package/dist/sync/uploader-finalization.test.js +92 -0
  141. package/dist/sync/uploader-finalization.test.js.map +1 -0
  142. package/dist/telemetry.d.ts +11 -1
  143. package/dist/telemetry.d.ts.map +1 -1
  144. package/dist/telemetry.js +21 -2
  145. package/dist/telemetry.js.map +1 -1
  146. package/dist/telemetry.test.js +80 -0
  147. package/dist/telemetry.test.js.map +1 -1
  148. package/package.json +6 -1
  149. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  150. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  151. package/.github/workflows/ci.yml +0 -84
  152. package/.github/workflows/publish.yml +0 -56
  153. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  154. package/eslint.config.js +0 -67
  155. package/pnpm-workspace.yaml +0 -2
  156. package/scripts/presign-transport-e2e.mjs +0 -250
  157. package/scripts/vault-rebaseline.sh +0 -323
  158. package/scripts/vault-rescue.sh +0 -332
  159. package/src/active-company.test.ts +0 -188
  160. package/src/active-company.ts +0 -168
  161. package/src/agent-codex-instructions.test.ts +0 -332
  162. package/src/agent-codex-instructions.ts +0 -309
  163. package/src/auth.ts +0 -146
  164. package/src/backup-prune.test.ts +0 -98
  165. package/src/backup-prune.ts +0 -182
  166. package/src/bin/backup-prune-runner.ts +0 -33
  167. package/src/bin/rescue-runner.ts +0 -25
  168. package/src/bin/sync-runner-company.ts +0 -695
  169. package/src/bin/sync-runner-events.test.ts +0 -143
  170. package/src/bin/sync-runner-events.ts +0 -55
  171. package/src/bin/sync-runner-planning.test.ts +0 -311
  172. package/src/bin/sync-runner-planning.ts +0 -258
  173. package/src/bin/sync-runner-rollup.test.ts +0 -37
  174. package/src/bin/sync-runner-rollup.ts +0 -97
  175. package/src/bin/sync-runner-telemetry.ts +0 -15
  176. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  177. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  178. package/src/bin/sync-runner-watch-routes.ts +0 -184
  179. package/src/bin/sync-runner.test.ts +0 -8767
  180. package/src/bin/sync-runner.ts +0 -2190
  181. package/src/cli/accept.ts +0 -124
  182. package/src/cli/conflict.ts +0 -119
  183. package/src/cli/doctor.test.ts +0 -581
  184. package/src/cli/doctor.ts +0 -642
  185. package/src/cli/index.ts +0 -49
  186. package/src/cli/invite.test.ts +0 -250
  187. package/src/cli/invite.ts +0 -214
  188. package/src/cli/promote.ts +0 -157
  189. package/src/cli/reindex.test.ts +0 -957
  190. package/src/cli/reindex.ts +0 -970
  191. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  192. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  193. package/src/cli/rescue-core.ts +0 -3011
  194. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  195. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  196. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  197. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  198. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  199. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  200. package/src/cli/rescue-settings-reconcile.test.ts +0 -583
  201. package/src/cli/rescue-snapshot.test.ts +0 -57
  202. package/src/cli/rescue-snapshot.ts +0 -51
  203. package/src/cli/rescue.reindex.test.ts +0 -63
  204. package/src/cli/rescue.test.ts +0 -131
  205. package/src/cli/rescue.ts +0 -182
  206. package/src/cli/share.test.ts +0 -7843
  207. package/src/cli/share.ts +0 -3663
  208. package/src/cli/sync-scope.test.ts +0 -652
  209. package/src/cli/sync.test.ts +0 -5207
  210. package/src/cli/sync.ts +0 -3470
  211. package/src/cli/tombstones.ts +0 -106
  212. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  213. package/src/client-info.test.ts +0 -214
  214. package/src/client-info.ts +0 -121
  215. package/src/cognito-auth.test.ts +0 -712
  216. package/src/cognito-auth.ts +0 -1422
  217. package/src/company-resolver.test.ts +0 -618
  218. package/src/company-resolver.ts +0 -521
  219. package/src/context.test.ts +0 -583
  220. package/src/context.ts +0 -378
  221. package/src/daemon-worker.ts +0 -26
  222. package/src/daemon.ts +0 -99
  223. package/src/entity-resolver.test.ts +0 -315
  224. package/src/entity-resolver.ts +0 -180
  225. package/src/ignore.test.ts +0 -466
  226. package/src/ignore.ts +0 -469
  227. package/src/index.ts +0 -439
  228. package/src/journal.test.ts +0 -968
  229. package/src/journal.ts +0 -765
  230. package/src/lib/cloud-authoritative.test.ts +0 -45
  231. package/src/lib/cloud-authoritative.ts +0 -59
  232. package/src/lib/conflict-file.ts +0 -86
  233. package/src/lib/conflict-index.ts +0 -289
  234. package/src/lib/conflict.test.ts +0 -348
  235. package/src/lib/describe-error.test.ts +0 -100
  236. package/src/lib/describe-error.ts +0 -58
  237. package/src/lib/exit-codes.ts +0 -24
  238. package/src/lib/machine-id.test.ts +0 -231
  239. package/src/lib/machine-id.ts +0 -175
  240. package/src/lib/net-errors.test.ts +0 -65
  241. package/src/lib/net-errors.ts +0 -86
  242. package/src/lib/readlink-safe.test.ts +0 -43
  243. package/src/lib/readlink-safe.ts +0 -29
  244. package/src/local-path-codec.test.ts +0 -138
  245. package/src/local-path-codec.ts +0 -161
  246. package/src/machine-auth.test.ts +0 -1323
  247. package/src/manifest-reconcile.test.ts +0 -1123
  248. package/src/manifest-reconcile.ts +0 -518
  249. package/src/object-io.test.ts +0 -1221
  250. package/src/object-io.ts +0 -1306
  251. package/src/operation-lock.test.ts +0 -484
  252. package/src/operation-lock.ts +0 -680
  253. package/src/outcome-telemetry.test.ts +0 -498
  254. package/src/outcome-telemetry.ts +0 -639
  255. package/src/personal-vault-exclusions.test.ts +0 -308
  256. package/src/personal-vault-exclusions.ts +0 -354
  257. package/src/personal-vault.test.ts +0 -756
  258. package/src/personal-vault.ts +0 -496
  259. package/src/prefix-coalesce.test.ts +0 -240
  260. package/src/prefix-coalesce.ts +0 -273
  261. package/src/public-surface.test.ts +0 -117
  262. package/src/qmd-reindex.test.ts +0 -877
  263. package/src/qmd-reindex.ts +0 -842
  264. package/src/read-only-state-dir.test.ts +0 -188
  265. package/src/remote-pull.test.ts +0 -1130
  266. package/src/remote-pull.ts +0 -618
  267. package/src/s3.symlink-materialize.test.ts +0 -492
  268. package/src/s3.test.ts +0 -1789
  269. package/src/s3.ts +0 -1532
  270. package/src/schemas/signal-types.test.ts +0 -82
  271. package/src/schemas/signal-types.ts +0 -38
  272. package/src/schemas/source-channels.test.ts +0 -82
  273. package/src/schemas/source-channels.ts +0 -53
  274. package/src/scope-shrink.test.ts +0 -633
  275. package/src/scope-shrink.ts +0 -481
  276. package/src/signals/get.test.ts +0 -310
  277. package/src/signals/get.ts +0 -75
  278. package/src/signals/internals.ts +0 -195
  279. package/src/signals/list.test.ts +0 -420
  280. package/src/signals/list.ts +0 -79
  281. package/src/signals/parse.ts +0 -8
  282. package/src/signals/types.ts +0 -91
  283. package/src/skill-telemetry.test.ts +0 -1825
  284. package/src/skill-telemetry.ts +0 -1439
  285. package/src/sources/get.test.ts +0 -293
  286. package/src/sources/get.ts +0 -66
  287. package/src/sources/internals.ts +0 -198
  288. package/src/sources/list.test.ts +0 -402
  289. package/src/sources/list.ts +0 -84
  290. package/src/sources/parse.ts +0 -43
  291. package/src/sources/types.ts +0 -84
  292. package/src/sync/event-sync.test.ts +0 -594
  293. package/src/sync/event-sync.ts +0 -545
  294. package/src/sync/feature-flags.test.ts +0 -378
  295. package/src/sync/feature-flags.ts +0 -62
  296. package/src/sync/index.ts +0 -76
  297. package/src/sync/lease-client.test.ts +0 -128
  298. package/src/sync/lease-client.ts +0 -207
  299. package/src/sync/logger.test.ts +0 -242
  300. package/src/sync/logger.ts +0 -79
  301. package/src/sync/metrics.test.ts +0 -462
  302. package/src/sync/metrics.ts +0 -213
  303. package/src/sync/pull-scope.ts +0 -265
  304. package/src/sync/push-event.test.ts +0 -266
  305. package/src/sync/push-event.ts +0 -224
  306. package/src/sync/push-receiver.test.ts +0 -566
  307. package/src/sync/push-receiver.ts +0 -1048
  308. package/src/sync/push-transport.ts +0 -231
  309. package/src/sync/realtime-rollout.test.ts +0 -86
  310. package/src/sync/realtime-rollout.ts +0 -262
  311. package/src/sync/state-store.test.ts +0 -194
  312. package/src/sync/state-store.ts +0 -727
  313. package/src/sync-core.ts +0 -58
  314. package/src/sync-progress.test.ts +0 -94
  315. package/src/sync-progress.ts +0 -140
  316. package/src/telemetry-events.test.ts +0 -88
  317. package/src/telemetry-events.ts +0 -205
  318. package/src/telemetry.test.ts +0 -1280
  319. package/src/telemetry.ts +0 -1109
  320. package/src/types.ts +0 -314
  321. package/src/vault-client.test.ts +0 -1380
  322. package/src/vault-client.ts +0 -1694
  323. package/src/version.ts +0 -24
  324. package/src/watch-roots.test.ts +0 -278
  325. package/src/watch-roots.ts +0 -162
  326. package/src/watcher-event-gate.test.ts +0 -212
  327. package/src/watcher.test.ts +0 -1079
  328. package/src/watcher.ts +0 -1741
  329. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  330. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  331. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  332. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  333. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  334. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  335. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  336. package/test/invite-flow.integration.test.ts +0 -244
  337. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  338. package/test/share-sync.integration.test.ts +0 -213
  339. package/tsconfig.json +0 -19
  340. package/vitest.config.ts +0 -22
@@ -1,618 +0,0 @@
1
- /**
2
- * Auto-sync (Beta) remote-pull decisions.
3
- *
4
- * Pure helper that decides which remote keys to download, which local files
5
- * to delete (tombstones from another machine), and which to skip — given a
6
- * remote listing, the journal, and a set of paths currently flagged as
7
- * conflicts. Isolating the decision keeps the watch-mode poll loop in
8
- * sync-runner.ts trivial: list S3 → call `decideRemotePulls` → drive S3 +
9
- * filesystem from the result.
10
- *
11
- * Pairs with the TreeWatcher push path — together they implement the
12
- * bidirectional auto-sync the Settings toggle exposes.
13
- */
14
- import type { RemoteFile } from "./s3.js";
15
- import { listRemoteFiles } from "./s3.js";
16
- import {
17
- appendPullRecord,
18
- gcTombstones,
19
- generatePullId,
20
- lastPullRecord,
21
- normalizeEtag,
22
- } from "./journal.js";
23
- import type {
24
- EntityContext,
25
- PullRecord,
26
- SyncJournal,
27
- } from "./types.js";
28
- import type {
29
- ExplicitGrant,
30
- MembershipSyncConfig,
31
- } from "./vault-client.js";
32
- import {
33
- coalescePrefixes,
34
- isCoveredByAny,
35
- pathToScopePrefix,
36
- toScopePrefixEntries,
37
- type ScopePrefixInput,
38
- } from "./prefix-coalesce.js";
39
- import {
40
- applyScopeShrink,
41
- buildScopeShrinkPlan,
42
- ScopeShrinkBlockedError,
43
- type ApplyScopeShrinkResult,
44
- type ScopeShrinkPlan,
45
- } from "./scope-shrink.js";
46
-
47
- /** Minimal shape every entry in `skip` has — `key` is the only field
48
- * guaranteed to be populated. Remote-listing skips carry the full RemoteFile;
49
- * conflict-tombstone skips (no remote counterpart) carry only the path. */
50
- export interface SkippedKey {
51
- key: string;
52
- }
53
-
54
- export interface RemotePullDecision {
55
- /** Remote files to download to disk. */
56
- download: RemoteFile[];
57
- /**
58
- * Relative paths of local files whose remote counterpart has been deleted
59
- * since the last sync. The watcher should remove them locally and drop
60
- * the journal entry.
61
- */
62
- deleteLocal: string[];
63
- /**
64
- * Entries left untouched this pass — either because the local journal
65
- * already matches the remote ETag (idempotent), the path is currently
66
- * flagged in the conflict store (auto-pull never clobbers conflicts), or
67
- * a remote tombstone arrived for a conflicting file (auto-pull never
68
- * deletes a file the user is mid-resolving).
69
- */
70
- skip: SkippedKey[];
71
- }
72
-
73
- export interface DecideRemotePullsInput {
74
- remoteFiles: RemoteFile[];
75
- journal: SyncJournal;
76
- /**
77
- * Relative paths currently in the conflict store. Auto-sync skips these
78
- * entirely — neither downloads nor deletes — so the user's in-progress
79
- * conflict resolution can't be silently overwritten.
80
- */
81
- conflictKeys: Set<string>;
82
- /**
83
- * Journal keys intentionally retained by scope-shrink authorship guards
84
- * even though they are outside the current remote listing scope.
85
- */
86
- protectedMissingKeys?: Set<string>;
87
- }
88
-
89
- export function decideRemotePulls({
90
- remoteFiles,
91
- journal,
92
- conflictKeys,
93
- protectedMissingKeys = new Set<string>(),
94
- }: DecideRemotePullsInput): RemotePullDecision {
95
- const download: RemoteFile[] = [];
96
- const skip: SkippedKey[] = [];
97
- const deleteLocal: string[] = [];
98
-
99
- const seenRemote = new Set<string>();
100
-
101
- for (const file of remoteFiles) {
102
- seenRemote.add(file.key);
103
-
104
- if (conflictKeys.has(file.key)) {
105
- skip.push(file);
106
- continue;
107
- }
108
-
109
- const entry = journal.files[file.key];
110
- if (!entry || !entry.remoteEtag) {
111
- // New to us, or pre-ETag legacy entry — pull to get into a known state.
112
- download.push(file);
113
- continue;
114
- }
115
-
116
- if (normalizeEtag(file.etag) === entry.remoteEtag) {
117
- skip.push(file);
118
- } else {
119
- download.push(file);
120
- }
121
- }
122
-
123
- // Tombstone pass: anything in the journal that's no longer remote.
124
- for (const relativePath of Object.keys(journal.files)) {
125
- if (seenRemote.has(relativePath)) continue;
126
- if (journal.files[relativePath]?.removedAt) continue;
127
- if (protectedMissingKeys.has(relativePath)) {
128
- skip.push({ key: relativePath });
129
- continue;
130
- }
131
- if (conflictKeys.has(relativePath)) {
132
- // Remote tombstone for a file the user is conflict-resolving locally.
133
- // Skip — record so callers can log/report, but do NOT delete.
134
- skip.push({ key: relativePath });
135
- continue;
136
- }
137
- deleteLocal.push(relativePath);
138
- }
139
-
140
- return { download, deleteLocal, skip };
141
- }
142
-
143
- // ───────────────────────────────────────────────────────────────────────────
144
- // US-005: ACL-aware narrowing — engine layer
145
- // ───────────────────────────────────────────────────────────────────────────
146
-
147
- /**
148
- * Hard cap on coalesced prefixes per STS vend (US-001-D). The vault-service
149
- * `validateVendRequest` rejects `paths.length > 10`, so the engine MUST
150
- * either shard into multiple vends + ListObjectsV2 calls when the coalesced
151
- * grant set exceeds this OR fall back to a broad list + post-filter.
152
- */
153
- export const VEND_PATH_CAP = 10;
154
-
155
- /**
156
- * Threshold above which the engine prefers a single broad ListObjectsV2 +
157
- * client-side post-filter instead of fanning out N vends. Tuned for the
158
- * US-001-B p99 finding (TBD live) — N coalesced prefixes <= 50 is cheaper as
159
- * vend-fanout (~5 STS calls); > 50 is cheaper as one broad list.
160
- */
161
- export const POST_FILTER_THRESHOLD = 50;
162
-
163
- /** Bounded parallelism for vend fan-out (5 concurrent vends/list paginators). */
164
- export const VEND_FANOUT_CONCURRENCY = 5;
165
-
166
- /**
167
- * Effective per-company sync scope, resolved from the membership's sync-config
168
- * + (if `shared`) the caller's explicit grants. Returned by
169
- * `resolveCompanyScope` and consumed by `pullCompany`.
170
- *
171
- * `strategy: "vend-fanout"` issues 1..N narrowed STS+ListObjectsV2 calls,
172
- * union'd. `strategy: "broad-postfilter"` issues one wide list and filters
173
- * client-side. `strategy: "all"` is the legacy syncMode='all' path.
174
- */
175
- export interface CompanyScope {
176
- companyUid: string;
177
- syncMode: MembershipSyncConfig["syncMode"];
178
- /** Coalesced prefix set. For `all`, this is the single company prefix. */
179
- prefixSet: string[];
180
- /**
181
- * Strategy chosen by `resolveCompanyScope` based on coalesced count vs
182
- * `VEND_PATH_CAP` and `POST_FILTER_THRESHOLD`.
183
- */
184
- strategy: "all" | "vend-fanout" | "broad-postfilter";
185
- }
186
-
187
- export interface ResolveCompanyScopeInput {
188
- companyUid: string;
189
- companyPrefix: string; // e.g. "companies/indigo/"
190
- syncConfig: MembershipSyncConfig;
191
- /** Required when `syncConfig.syncMode === 'shared'`. */
192
- explicitGrants?: ExplicitGrant[];
193
- }
194
-
195
- /**
196
- * Resolve the effective sync scope for one per-company leg.
197
- *
198
- * Decision table:
199
- * - `syncMode === 'all'` → strategy `all`, prefixSet [companyPrefix]
200
- * - `syncMode === 'shared'` → coalesce explicit grants. If count
201
- * ≤ VEND_PATH_CAP → `vend-fanout`.
202
- * If ≤ POST_FILTER_THRESHOLD → still
203
- * `vend-fanout` (sharded). Else
204
- * `broad-postfilter`.
205
- * - `syncMode === 'custom'` → coalesce customPaths, same decision
206
- * table as `shared`.
207
- *
208
- * Pure function. No network, no journal mutation.
209
- */
210
- export function resolveCompanyScope(
211
- input: ResolveCompanyScopeInput,
212
- ): CompanyScope {
213
- const { companyUid, companyPrefix, syncConfig, explicitGrants } = input;
214
-
215
- if (syncConfig.syncMode === "all") {
216
- return {
217
- companyUid,
218
- syncMode: "all",
219
- prefixSet: [companyPrefix],
220
- strategy: "all",
221
- };
222
- }
223
-
224
- let raw: ScopePrefixInput[];
225
- if (syncConfig.syncMode === "custom") {
226
- raw = (syncConfig.customPaths ?? []).map(pathToScopePrefix);
227
- } else {
228
- // 'shared'
229
- raw = (explicitGrants ?? []).map((g) => pathToScopePrefix(g.path));
230
- }
231
- const prefixSet = coalescePrefixes(raw);
232
-
233
- // Empty grant set in `shared` mode means the caller has no explicit grants
234
- // for this company. Returning an empty prefixSet here lets `pullCompany`
235
- // short-circuit — issuing zero ListObjectsV2 calls and downloading
236
- // nothing, the correct "I have no shared access" outcome.
237
- const strategy: CompanyScope["strategy"] =
238
- prefixSet.length > POST_FILTER_THRESHOLD
239
- ? "broad-postfilter"
240
- : "vend-fanout";
241
-
242
- return {
243
- companyUid,
244
- syncMode: syncConfig.syncMode,
245
- prefixSet,
246
- strategy,
247
- };
248
- }
249
-
250
- /**
251
- * Split a coalesced prefix set into batches of at most `VEND_PATH_CAP`
252
- * prefixes each. Each batch maps to a single STS vend + ListObjectsV2 call.
253
- */
254
- export function batchPrefixesForVend(
255
- prefixes: string[],
256
- cap: number = VEND_PATH_CAP,
257
- ): string[][] {
258
- if (cap <= 0) throw new Error(`batchPrefixesForVend: cap must be > 0`);
259
- const vendPrefixes = toScopePrefixEntries(prefixes).map((entry) => entry.prefix);
260
- const batches: string[][] = [];
261
- for (let i = 0; i < vendPrefixes.length; i += cap) {
262
- batches.push(vendPrefixes.slice(i, i + cap));
263
- }
264
- return batches;
265
- }
266
-
267
- /**
268
- * Bounded-parallel mapper. Awaits up to `concurrency` promises at once.
269
- * Used to fan out per-batch ListObjectsV2 calls without exhausting the
270
- * AWS SDK or hitting STS throttles.
271
- */
272
- async function mapWithConcurrency<T, R>(
273
- items: T[],
274
- concurrency: number,
275
- fn: (item: T, index: number) => Promise<R>,
276
- ): Promise<R[]> {
277
- const results: R[] = new Array(items.length);
278
- let cursor = 0;
279
- async function worker(): Promise<void> {
280
- while (true) {
281
- const i = cursor++;
282
- if (i >= items.length) return;
283
- results[i] = await fn(items[i]!, i);
284
- }
285
- }
286
- const workers: Promise<void>[] = [];
287
- for (let i = 0; i < Math.min(concurrency, items.length); i++) {
288
- workers.push(worker());
289
- }
290
- await Promise.all(workers);
291
- return results;
292
- }
293
-
294
- function createConcurrencyLimiter(
295
- concurrency: number,
296
- ): <R>(fn: () => Promise<R>) => Promise<R> {
297
- const limit = Math.max(1, concurrency);
298
- let active = 0;
299
- const waiters: Array<() => void> = [];
300
-
301
- return async function runLimited<R>(fn: () => Promise<R>): Promise<R> {
302
- if (active >= limit) {
303
- await new Promise<void>((resolve) => waiters.push(resolve));
304
- }
305
- active += 1;
306
- try {
307
- return await fn();
308
- } finally {
309
- active -= 1;
310
- waiters.shift()?.();
311
- }
312
- };
313
- }
314
-
315
- export interface ListRemoteForScopeInput {
316
- ctx: EntityContext;
317
- scope: CompanyScope;
318
- /**
319
- * Override for tests / alternative S3 surfaces. Defaults to the package's
320
- * own `listRemoteFiles`. Signature matches `(ctx, prefix?) => RemoteFile[]`.
321
- */
322
- listFn?: (
323
- ctx: EntityContext,
324
- prefix?: string,
325
- ) => Promise<RemoteFile[]>;
326
- /**
327
- * Override for tests to vend a per-batch narrowed EntityContext. Default:
328
- * reuse `ctx` (which the orchestrator is expected to have already vended
329
- * appropriately for the scope). The full per-batch STS vend wiring will
330
- * land in US-006 along with the CLI.
331
- */
332
- vendForBatchFn?: (
333
- ctx: EntityContext,
334
- paths: string[],
335
- ) => Promise<EntityContext>;
336
- }
337
-
338
- /**
339
- * List remote objects in scope, applying the chosen strategy:
340
- * - `all` — one broad ListObjectsV2 under the company prefix.
341
- * - `vend-fanout` — one ListObjectsV2 per coalesced batch (≤ VEND_PATH_CAP),
342
- * bounded parallel, results union'd. The caller is
343
- * responsible for vending narrowed credentials when
344
- * this path is taken (`vendForBatchFn`).
345
- * - `broad-postfilter`— one broad ListObjectsV2 + client-side filter
346
- * against `scope.prefixSet`.
347
- *
348
- * Dedup by key so multi-batch overlaps don't double-download.
349
- */
350
- export async function listRemoteForScope(
351
- input: ListRemoteForScopeInput,
352
- ): Promise<RemoteFile[]> {
353
- const list = input.listFn ?? listRemoteFiles;
354
- const { ctx, scope } = input;
355
-
356
- if (scope.strategy === "all") {
357
- return list(ctx, scope.prefixSet[0]);
358
- }
359
-
360
- if (scope.strategy === "broad-postfilter") {
361
- const all = await list(ctx);
362
- const scopeEntries = toScopePrefixEntries(scope.prefixSet);
363
- return all.filter((f) => isCoveredByAny(f.key, scopeEntries));
364
- }
365
-
366
- // vend-fanout
367
- if (scope.prefixSet.length === 0) return [];
368
- const scopeEntries = toScopePrefixEntries(scope.prefixSet);
369
- const listPrefixes = scopeEntries.map((entry) => entry.prefix);
370
- const batches = batchPrefixesForVend(listPrefixes);
371
- const listWithLimit = createConcurrencyLimiter(VEND_FANOUT_CONCURRENCY);
372
- const perBatch = await mapWithConcurrency(
373
- batches,
374
- VEND_FANOUT_CONCURRENCY,
375
- async (paths) => {
376
- const batchCtx = input.vendForBatchFn
377
- ? await input.vendForBatchFn(ctx, paths)
378
- : ctx;
379
- // For a coalesced batch we issue one ListObjectsV2 per prefix in the
380
- // batch. We can't issue one ListObjectsV2 across N prefixes (the API
381
- // takes a single Prefix); the per-batch grouping exists for the STS
382
- // session policy ceiling, not the list call itself. The shared limiter
383
- // keeps the total active prefix paginators bounded across all batches.
384
- const lists = await Promise.all(
385
- paths.map((p) => listWithLimit(() => list(batchCtx, p))),
386
- );
387
- return lists.flat();
388
- },
389
- );
390
- return dedupByKey(
391
- perBatch.flat().filter((f) => isCoveredByAny(f.key, scopeEntries)),
392
- );
393
- }
394
-
395
- function dedupByKey(files: RemoteFile[]): RemoteFile[] {
396
- const seen = new Set<string>();
397
- const out: RemoteFile[] = [];
398
- for (const f of files) {
399
- if (seen.has(f.key)) continue;
400
- seen.add(f.key);
401
- out.push(f);
402
- }
403
- return out;
404
- }
405
-
406
- // ── Per-company orchestration: scope-shrink + listing + PullRecord ──────────
407
-
408
- export interface PullCompanyInput {
409
- ctx: EntityContext;
410
- journal: SyncJournal;
411
- hqRoot: string;
412
- scope: CompanyScope;
413
- /** Set of conflict-store keys to forward to `decideRemotePulls`. */
414
- conflictKeys?: Set<string>;
415
- /** Honor the operator override on dirty orphans (US-005 contract). */
416
- forceScopeShrink?: boolean;
417
- /**
418
- * The caller's own Cognito `sub` for the scope-shrink authorship guard — a
419
- * scope shrink never prunes content the caller authored. Defaults to the
420
- * cached session sub (`resolveCallerSubFromCache()`); pass explicitly when
421
- * the runner already decoded its idToken claims.
422
- */
423
- callerSub?: string;
424
- /** Listing override hook — see `ListRemoteForScopeInput.listFn`. */
425
- listFn?: ListRemoteForScopeInput["listFn"];
426
- vendForBatchFn?: ListRemoteForScopeInput["vendForBatchFn"];
427
- /** Time injector for tests; defaults to real wall clock. */
428
- now?: () => Date;
429
- }
430
-
431
- export interface PullCompanyResult {
432
- /** Effective scope used. */
433
- scope: CompanyScope;
434
- /** Remote files listed under the scope (post-dedup, post-filter). */
435
- remoteFiles: RemoteFile[];
436
- /** Pure download/delete/skip decision from `decideRemotePulls`. */
437
- decision: RemotePullDecision;
438
- /** Scope-shrink plan computed before listing. */
439
- scopeShrinkPlan: ScopeShrinkPlan;
440
- /** Applied scope-shrink action (counts). `null` when no shrink was needed. */
441
- scopeShrinkApplied: ApplyScopeShrinkResult | null;
442
- /** Pull record appended to `journal.pulls`. */
443
- pullRecord: PullRecord;
444
- /** Tombstones GC'd at the start of this leg. */
445
- tombstonesGcd: number;
446
- }
447
-
448
- /**
449
- * Per-company sync leg — the engine half of `pullAll` for ONE company.
450
- *
451
- * Flow:
452
- * 1. GC expired tombstones (cheap; bounds journal growth).
453
- * 2. Resolve last-pull scope (or `["companyPrefix"]` if no record exists).
454
- * 3. Build scope-shrink plan + abort on dirty orphans (unless force).
455
- * 4. Apply scope-shrink (delete clean orphans, tombstone entries).
456
- * 5. List remote under current scope (vend-fanout / broad-postfilter / all).
457
- * 6. Compute download/delete/skip via `decideRemotePulls`.
458
- * 7. Append a `PullRecord` capturing the actual `syncMode` + `prefixSet`.
459
- *
460
- * Step 6 returns the decision plan — the actual S3 GETs and FS writes
461
- * remain in the CLI layer (`hq-cli/src/commands/cloud.ts`'s `pullAll`),
462
- * which threads conflict detection + remoteEtag stamping on completion.
463
- * US-006 wires this orchestrator into the CLI.
464
- */
465
- export async function pullCompany(
466
- input: PullCompanyInput,
467
- ): Promise<PullCompanyResult> {
468
- const now = input.now ?? (() => new Date());
469
- const startedAt = now().toISOString();
470
- const conflictKeys = input.conflictKeys ?? new Set<string>();
471
-
472
- const tombstonesGcd = gcTombstones(input.journal, now().getTime());
473
-
474
- const last = lastPullRecord(input.journal, input.scope.companyUid);
475
- const lastPrefixSet =
476
- last?.prefixSet && last.prefixSet.length > 0
477
- ? last.prefixSet
478
- : // No record OR a v1-migrated record with empty prefixSet — treat
479
- // the last scope as "everything under the company prefix". For the
480
- // `all` -> `shared` flip this correctly flags shared-mode orphans.
481
- [companyPrefixOf(input.scope, last)];
482
-
483
- const currentPrefixSet = input.scope.prefixSet;
484
- const scopeShrinkPlan = buildScopeShrinkPlan({
485
- journal: input.journal,
486
- hqRoot: input.hqRoot,
487
- lastPrefixSet,
488
- currentPrefixSet,
489
- callerSub: input.callerSub,
490
- // Background runner pull: protect the caller's own work and don't make a
491
- // destructive guess about unknown-author (legacy) orphans. The explicit
492
- // `hq sync narrow` ritual is the confirmed path that opts out of this.
493
- protectUnknownAuthors: true,
494
- });
495
-
496
- let scopeShrinkApplied: ApplyScopeShrinkResult | null = null;
497
- if (scopeShrinkPlan.scopeChangeDetected) {
498
- if (
499
- scopeShrinkPlan.dirty.length > 0 &&
500
- !input.forceScopeShrink
501
- ) {
502
- throw new ScopeShrinkBlockedError(
503
- input.scope.companyUid,
504
- last?.syncMode ?? "unknown",
505
- input.scope.syncMode,
506
- scopeShrinkPlan.dirty,
507
- scopeShrinkPlan.clean,
508
- );
509
- }
510
- scopeShrinkApplied = applyScopeShrink({
511
- journal: input.journal,
512
- plan: scopeShrinkPlan,
513
- hqRoot: input.hqRoot,
514
- forceScopeShrink: input.forceScopeShrink ?? false,
515
- });
516
- }
517
-
518
- const remoteFiles = await listRemoteForScope({
519
- ctx: input.ctx,
520
- scope: input.scope,
521
- listFn: input.listFn,
522
- vendForBatchFn: input.vendForBatchFn,
523
- });
524
-
525
- const decision = decideRemotePulls({
526
- remoteFiles,
527
- journal: input.journal,
528
- conflictKeys,
529
- protectedMissingKeys: collectScopeProtectedMissingKeys({
530
- journal: input.journal,
531
- lastPrefixSet,
532
- currentPrefixSet,
533
- callerSub: input.callerSub,
534
- protectUnknownAuthors: true,
535
- }),
536
- });
537
-
538
- const completedAt = now().toISOString();
539
- const pullRecord: PullRecord = {
540
- pullId: generatePullId(now().getTime()),
541
- companyUid: input.scope.companyUid,
542
- startedAt,
543
- completedAt,
544
- syncMode: input.scope.syncMode,
545
- prefixSet: [...input.scope.prefixSet],
546
- scopeChangeDetected: scopeShrinkPlan.scopeChangeDetected,
547
- orphansRemoved: scopeShrinkApplied?.cleanRemoved ?? 0,
548
- orphansBlocked:
549
- scopeShrinkApplied && input.forceScopeShrink
550
- ? scopeShrinkApplied.dirtyTombstoned
551
- : scopeShrinkPlan.dirty.length,
552
- };
553
- appendPullRecord(input.journal, pullRecord);
554
-
555
- return {
556
- scope: input.scope,
557
- remoteFiles,
558
- decision,
559
- scopeShrinkPlan,
560
- scopeShrinkApplied,
561
- pullRecord,
562
- tombstonesGcd,
563
- };
564
- }
565
-
566
- function collectScopeProtectedMissingKeys(input: {
567
- journal: SyncJournal;
568
- lastPrefixSet: readonly ScopePrefixInput[];
569
- currentPrefixSet: readonly ScopePrefixInput[];
570
- callerSub?: string;
571
- protectUnknownAuthors: boolean;
572
- }): Set<string> {
573
- const protectedKeys = new Set<string>();
574
- for (const [relPath, entry] of Object.entries(input.journal.files)) {
575
- if (entry.removedAt) continue;
576
- if (entry.direction !== "down") continue;
577
- if (isCoveredByAny(relPath, input.currentPrefixSet)) {
578
- if (entry.outOfScopeProtected) delete entry.outOfScopeProtected;
579
- continue;
580
- }
581
- if (entry.outOfScopeProtected) {
582
- protectedKeys.add(relPath);
583
- continue;
584
- }
585
- if (!isCoveredByAny(relPath, input.lastPrefixSet)) continue;
586
- if (input.callerSub && entry.createdBySub === input.callerSub) {
587
- entry.outOfScopeProtected = true;
588
- protectedKeys.add(relPath);
589
- continue;
590
- }
591
- if (input.protectUnknownAuthors && entry.createdBySub === undefined) {
592
- entry.outOfScopeProtected = true;
593
- protectedKeys.add(relPath);
594
- }
595
- }
596
- return protectedKeys;
597
- }
598
-
599
- /**
600
- * Recover the "company prefix" for a v1-migrated record with no recorded
601
- * `prefixSet`. We derive it from the current scope's first prefix's parent
602
- * (best-effort) — the only consumer of this fallback is the v1 → v2
603
- * migration window. After one pull lands a v2 record, this branch never
604
- * runs again for that company.
605
- */
606
- function companyPrefixOf(
607
- scope: CompanyScope,
608
- _last: PullRecord | undefined,
609
- ): string {
610
- // For `all` mode, scope.prefixSet[0] IS the company prefix.
611
- const firstEntry = toScopePrefixEntries(scope.prefixSet)[0];
612
- if (scope.strategy === "all" && firstEntry) return firstEntry.prefix;
613
- // Otherwise, derive `companies/{slug}/` from the first prefix. ACL grant
614
- // paths always start with `companies/{slug}/...`.
615
- const first = firstEntry?.prefix ?? "";
616
- const m = first.match(/^(companies\/[^/]+\/)/);
617
- return m ? m[1]! : first;
618
- }