@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,652 +0,0 @@
1
- /**
2
- * Unit tests for scope-aware download (US-005 wiring into `sync()`).
3
- *
4
- * Covers the contract added when `syncMode` / `prefixSet` were threaded
5
- * through `computePullPlan` + the scope-shrink pass:
6
- *
7
- * - `all` → no filtering (regression guard lives in sync.test.ts).
8
- * - `shared` → only keys covered by `prefixSet` download; the rest are
9
- * classified `skip-out-of-scope` (NOT downloaded).
10
- * - `custom` → same mechanism, driven by the explicit path list.
11
- * - Idempotency → a second `shared` pull downloads nothing and removes
12
- * nothing (the PullRecord makes scope-change a no-op).
13
- * - Scope shrink → narrowing `all → shared` prunes the now-out-of-scope
14
- * CLEAN local orphan; a DIRTY orphan aborts with
15
- * `ScopeShrinkBlockedError` unless `forceScopeShrink`.
16
- *
17
- * The security contract (this filter is footprint-only, never an authz
18
- * boundary) is asserted indirectly: out-of-scope keys are still LISTED and
19
- * accessible — the engine simply chooses not to materialize them.
20
- */
21
-
22
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
23
- import * as fs from "fs";
24
- import * as path from "path";
25
- import * as os from "os";
26
- import { clearContextCache } from "../context.js";
27
- import type { VaultServiceConfig } from "../types.js";
28
-
29
- // Mutable remote-file list so each test controls what the vault returns.
30
- const REMOTE: { current: Array<{ key: string; size: number; lastModified: Date; etag: string }> } = {
31
- current: [
32
- { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
33
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
34
- ],
35
- };
36
-
37
- vi.mock("../s3.js", async (importOriginal) => {
38
- const actual = await importOriginal<typeof import("../s3.js")>();
39
- const innerFs = await import("fs");
40
- const innerPath = await import("path");
41
- const { vi: innerVi } = await import("vitest");
42
- return {
43
- ...actual,
44
- toPosixKey: (key: string) => key.split("\\").join("/"),
45
- uploadFile: innerVi.fn().mockResolvedValue(undefined),
46
- downloadFile: innerVi
47
- .fn()
48
- .mockImplementation(async (_ctx: unknown, key: string, localPath: string) => {
49
- const dir = innerPath.dirname(localPath);
50
- if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
51
- // Deterministic per-key body so re-downloads produce a stable hash.
52
- innerFs.writeFileSync(localPath, `mock:${key}`);
53
- // Real GETs carry author metadata; surface a fixed uploader sub so
54
- // journal entries are stamped with a KNOWN author. With no `callerSub`
55
- // passed (the default for these tests), that author is "foreign", so
56
- // the scope-shrink prune/block paths behave exactly as pre-guard —
57
- // only the new own-author retention test passes a matching callerSub.
58
- return { metadata: { "created-by-sub": "uploader-sub" } };
59
- }),
60
- listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
61
- deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
62
- // HEAD returns metadata (object exists) for any key still in REMOTE,
63
- // null otherwise — mirrors the real bucket so the tombstone HEAD-verify
64
- // pass behaves correctly for out-of-scope (still-present) keys.
65
- headRemoteFile: innerVi.fn().mockImplementation(async (_ctx: unknown, key: string) => {
66
- const hit = REMOTE.current.find((r) => r.key === key);
67
- return hit ? { metadata: {}, size: hit.size, etag: hit.etag } : null;
68
- }),
69
- primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
70
- };
71
- });
72
-
73
- import { sync } from "./sync.js";
74
- import {
75
- ScopeShrinkBlockedError,
76
- ScopeShrinkLargePruneError,
77
- } from "../scope-shrink.js";
78
-
79
- const mockConfig: VaultServiceConfig = {
80
- apiUrl: "https://vault-api.test",
81
- authToken: "test-jwt-token",
82
- region: "us-east-1",
83
- };
84
-
85
- const mockEntity = {
86
- uid: "cmp_01ABCDEF",
87
- slug: "acme",
88
- bucketName: "hq-vault-acme-123",
89
- status: "active",
90
- };
91
-
92
- const mockVendResponse = {
93
- credentials: {
94
- accessKeyId: "ASIA_TEST_KEY",
95
- secretAccessKey: "test-secret",
96
- sessionToken: "test-session-token",
97
- expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
98
- },
99
- expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
100
- };
101
-
102
- function setupFetchMock() {
103
- const fetchMock = vi.fn().mockImplementation(async (url: string) => {
104
- const urlStr = String(url);
105
- if (urlStr.includes("/entity/check-slug/me")) {
106
- return {
107
- ok: true,
108
- status: 200,
109
- json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
110
- text: async () => "",
111
- };
112
- }
113
- if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
114
- return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
115
- }
116
- if (urlStr.includes("/sts/vend")) {
117
- return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
118
- }
119
- return { ok: false, status: 404, text: async () => "Not found" };
120
- });
121
- vi.stubGlobal("fetch", fetchMock);
122
- return fetchMock;
123
- }
124
-
125
- describe("sync — scope-aware download (US-005)", () => {
126
- let tmpDir: string;
127
- let stateDir: string;
128
- const companyRel = (p: string) => path.join(tmpDir, "companies", "acme", p);
129
-
130
- beforeEach(() => {
131
- clearContextCache();
132
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-scope-"));
133
- stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-scope-"));
134
- process.env.HQ_STATE_DIR = stateDir;
135
- // Reset the remote list every test (a prior test may have mutated it).
136
- REMOTE.current = [
137
- { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
138
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
139
- ];
140
- setupFetchMock();
141
- });
142
-
143
- afterEach(() => {
144
- vi.unstubAllGlobals();
145
- vi.clearAllMocks();
146
- fs.rmSync(tmpDir, { recursive: true, force: true });
147
- fs.rmSync(stateDir, { recursive: true, force: true });
148
- delete process.env.HQ_STATE_DIR;
149
- });
150
-
151
- it("shared mode downloads only keys covered by prefixSet; rest are skip-out-of-scope", async () => {
152
- const result = await sync({
153
- company: "acme",
154
- vaultConfig: mockConfig,
155
- hqRoot: tmpDir,
156
- syncMode: "shared",
157
- prefixSet: ["knowledge/"],
158
- });
159
-
160
- expect(result.filesDownloaded).toBe(1);
161
- expect(result.filesOutOfScope).toBe(1);
162
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
163
- // docs/handoff.md is out of scope — never materialized.
164
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
165
- });
166
-
167
- it("custom mode behaves like shared, driven by the explicit prefix list", async () => {
168
- const result = await sync({
169
- company: "acme",
170
- vaultConfig: mockConfig,
171
- hqRoot: tmpDir,
172
- syncMode: "custom",
173
- prefixSet: ["docs/"],
174
- });
175
-
176
- expect(result.filesDownloaded).toBe(1);
177
- expect(result.filesOutOfScope).toBe(1);
178
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
179
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
180
- });
181
-
182
- it("shared mode with empty prefixSet downloads nothing", async () => {
183
- const result = await sync({
184
- company: "acme",
185
- vaultConfig: mockConfig,
186
- hqRoot: tmpDir,
187
- syncMode: "shared",
188
- prefixSet: [],
189
- });
190
-
191
- expect(result.filesDownloaded).toBe(0);
192
- expect(result.filesOutOfScope).toBe(2);
193
- });
194
-
195
- // FILE_TOMBSTONE fetch is COMPANY-scoped (GET /v1/files/tombstones?company=…).
196
- // For the personal vault the target uid is a personUid (prs_…), which the
197
- // server correctly 403s ("No active membership for caller in company prs_…"),
198
- // spamming hq-pro's Sentry with a per-user no-membership cluster. The pull
199
- // must SKIP the fetch for the personal target (delete-resync was never a
200
- // committed personal-vault feature). Differential: company-mode fetches,
201
- // personal-mode does not.
202
- const hitTombstones = (m: ReturnType<typeof setupFetchMock>) =>
203
- m.mock.calls.some((c: unknown[]) =>
204
- String(c[0]).includes("/v1/files/tombstones"),
205
- );
206
-
207
- it("company-mode pull fetches FILE_TOMBSTONEs (baseline for the personal guard)", async () => {
208
- const fetchMock = setupFetchMock();
209
- await sync({
210
- company: "acme",
211
- vaultConfig: mockConfig,
212
- hqRoot: tmpDir,
213
- syncMode: "all",
214
- prefixSet: [],
215
- });
216
- expect(hitTombstones(fetchMock)).toBe(true);
217
- });
218
-
219
- it("personal-vault pull SKIPS the company-scoped tombstone fetch (no 403 → no Sentry no-membership spam)", async () => {
220
- const fetchMock = setupFetchMock();
221
- await sync({
222
- company: "acme",
223
- vaultConfig: mockConfig,
224
- hqRoot: tmpDir,
225
- syncMode: "all",
226
- prefixSet: [],
227
- personalMode: true,
228
- });
229
- expect(hitTombstones(fetchMock)).toBe(false);
230
- });
231
-
232
- it("is idempotent: a second shared pull downloads and removes nothing", async () => {
233
- const opts = {
234
- company: "acme",
235
- vaultConfig: mockConfig,
236
- hqRoot: tmpDir,
237
- syncMode: "shared" as const,
238
- prefixSet: ["knowledge/"],
239
- };
240
- const first = await sync(opts);
241
- expect(first.filesDownloaded).toBe(1);
242
-
243
- const second = await sync(opts);
244
- expect(second.filesDownloaded).toBe(0);
245
- expect(second.scopeOrphansRemoved).toBe(0);
246
- expect(second.scopeOrphansBlocked).toBe(0);
247
- // knowledge file still present; nothing churned.
248
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
249
- });
250
-
251
- it("scope shrink (all → shared) prunes the clean out-of-scope orphan", async () => {
252
- // First pull EVERYTHING under all-mode.
253
- const all = await sync({
254
- company: "acme",
255
- vaultConfig: mockConfig,
256
- hqRoot: tmpDir,
257
- syncMode: "all",
258
- });
259
- expect(all.filesDownloaded).toBe(2);
260
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
261
-
262
- // Narrow to shared/knowledge → docs/handoff.md is now a clean orphan.
263
- const shared = await sync({
264
- company: "acme",
265
- vaultConfig: mockConfig,
266
- hqRoot: tmpDir,
267
- syncMode: "shared",
268
- prefixSet: ["knowledge/"],
269
- });
270
- expect(shared.scopeOrphansRemoved).toBe(1);
271
- expect(shared.scopeOrphansBlocked).toBe(0);
272
- // The clean orphan was pruned; the in-scope file stays.
273
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
274
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
275
- });
276
-
277
- it("scope shrink (all → shared) RETAINS the owner's own out-of-scope file", async () => {
278
- // Corey's exact scenario: the caller authored everything (the mock stamps
279
- // `created-by-sub: "uploader-sub"`), so passing the matching `callerSub`
280
- // means narrowing to `shared` must NOT prune their own work — mode only
281
- // governs mirroring OTHER people's files.
282
- const all = await sync({
283
- company: "acme",
284
- vaultConfig: mockConfig,
285
- hqRoot: tmpDir,
286
- syncMode: "all",
287
- callerSub: "uploader-sub",
288
- });
289
- expect(all.filesDownloaded).toBe(2);
290
-
291
- const shared = await sync({
292
- company: "acme",
293
- vaultConfig: mockConfig,
294
- hqRoot: tmpDir,
295
- syncMode: "shared",
296
- prefixSet: ["knowledge/"],
297
- callerSub: "uploader-sub",
298
- });
299
- // Out of scope, but authored by the caller → retained, not pruned.
300
- expect(shared.scopeOrphansRemoved).toBe(0);
301
- expect(shared.scopeOrphansBlocked).toBe(0);
302
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
303
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
304
- });
305
-
306
- it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
307
- // Pull both files under all-mode, then narrow to a scope covering neither
308
- // → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
309
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
310
-
311
- // Narrow to a scope covering neither file → 2 clean orphans; cap at 1.
312
- process.env.HQ_SYNC_MAX_AUTO_PRUNE = "1";
313
- try {
314
- await expect(
315
- sync({
316
- company: "acme",
317
- vaultConfig: mockConfig,
318
- hqRoot: tmpDir,
319
- syncMode: "shared",
320
- prefixSet: ["nonexistent/"],
321
- }),
322
- ).rejects.toBeInstanceOf(ScopeShrinkLargePruneError);
323
- // Nothing deleted on the refused run.
324
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
325
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
326
-
327
- // Forced: the prune proceeds despite the cap.
328
- const forced = await sync({
329
- company: "acme",
330
- vaultConfig: mockConfig,
331
- hqRoot: tmpDir,
332
- syncMode: "shared",
333
- prefixSet: ["nonexistent/"],
334
- forceScopeShrink: true,
335
- });
336
- expect(forced.scopeOrphansRemoved).toBe(2);
337
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
338
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
339
- } finally {
340
- delete process.env.HQ_SYNC_MAX_AUTO_PRUNE;
341
- }
342
- });
343
-
344
- it("scope shrink aborts on a DIRTY orphan unless forceScopeShrink is set", async () => {
345
- await sync({
346
- company: "acme",
347
- vaultConfig: mockConfig,
348
- hqRoot: tmpDir,
349
- syncMode: "all",
350
- });
351
- // Locally modify the soon-to-be-orphan so it's dirty (hash mismatch).
352
- fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — do not delete");
353
-
354
- // Default: abort with the structured error; the dirty file is untouched.
355
- await expect(
356
- sync({
357
- company: "acme",
358
- vaultConfig: mockConfig,
359
- hqRoot: tmpDir,
360
- syncMode: "shared",
361
- prefixSet: ["knowledge/"],
362
- }),
363
- ).rejects.toBeInstanceOf(ScopeShrinkBlockedError);
364
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
365
- expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
366
- "LOCAL EDIT — do not delete",
367
- );
368
-
369
- // With force: the leg proceeds, the dirty file is LEFT ON DISK, only its
370
- // journal entry is tombstoned.
371
- const forced = await sync({
372
- company: "acme",
373
- vaultConfig: mockConfig,
374
- hqRoot: tmpDir,
375
- syncMode: "shared",
376
- prefixSet: ["knowledge/"],
377
- forceScopeShrink: true,
378
- });
379
- expect(forced.scopeOrphansBlocked).toBe(1);
380
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
381
- });
382
-
383
- // DEV-1768 fix #3: a scope shrink must QUARANTINE clean orphans (recoverable),
384
- // never silently delete them. The file leaves its working-tree path but is
385
- // recoverable under `.hq/scope-quarantine/<slug>/`.
386
- it("scope shrink QUARANTINES a clean orphan (recoverable), never silent-deletes", async () => {
387
- const quarantined = path.join(
388
- tmpDir,
389
- ".hq",
390
- "scope-quarantine",
391
- "acme",
392
- "docs",
393
- "handoff.md",
394
- );
395
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
396
- const original = companyRel("docs/handoff.md");
397
- const bodyBefore = fs.readFileSync(original, "utf-8");
398
-
399
- const shared = await sync({
400
- company: "acme",
401
- vaultConfig: mockConfig,
402
- hqRoot: tmpDir,
403
- syncMode: "shared",
404
- prefixSet: ["knowledge/"],
405
- });
406
-
407
- expect(shared.scopeOrphansRemoved).toBe(1);
408
- // Gone from the working tree...
409
- expect(fs.existsSync(original)).toBe(false);
410
- // ...but recoverable in quarantine, byte-for-byte.
411
- expect(fs.existsSync(quarantined)).toBe(true);
412
- expect(fs.readFileSync(quarantined, "utf-8")).toBe(bodyBefore);
413
- });
414
-
415
- // DEV-1768 fix #1 (recovery) + #2: the background runner path
416
- // (`scopeShrinkPolicy: "auto-recover"`) must NEVER throw on a scope shrink —
417
- // it self-heals. This reproduces the EXACT wedge: a buggy `all`-mode pull
418
- // seeds the journal (all-mode PullRecord + both files), one out-of-scope file
419
- // is edited locally (dirty), then the real `shared` pull arrives. Under the
420
- // old code this threw ScopeShrinkBlockedError(all→shared) → exit 2 forever.
421
- it("auto-recover policy clears an all→shared wedge: dirty kept on disk, clean quarantined, no throw", async () => {
422
- // 1. Buggy all-mode CLI seed: journals everything + stamps an all-mode record.
423
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
424
- // 2. User edits an out-of-scope file → it becomes a DIRTY orphan.
425
- fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — keep me");
426
- const quarantinedClean = path.join(
427
- tmpDir, ".hq", "scope-quarantine", "acme", "knowledge", "readme.md",
428
- );
429
-
430
- // 3. The menubar runner's real shared pull. prefixSet covers NEITHER file
431
- // (docs is dirty-orphan, knowledge becomes a clean orphan) so we exercise
432
- // both dispositions. Must resolve without throwing.
433
- const recovered = await sync({
434
- company: "acme",
435
- vaultConfig: mockConfig,
436
- hqRoot: tmpDir,
437
- syncMode: "shared",
438
- prefixSet: ["projects/"],
439
- scopeShrinkPolicy: "auto-recover",
440
- });
441
- expect(recovered.aborted).toBe(false);
442
- // Dirty file: KEPT on disk (un-tracked), counted as blocked-but-kept.
443
- expect(recovered.scopeOrphansBlocked).toBe(1);
444
- expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
445
- expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
446
- "LOCAL EDIT — keep me",
447
- );
448
- // Clean file: quarantined (recoverable), removed from the working tree.
449
- expect(recovered.scopeOrphansRemoved).toBe(1);
450
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
451
- expect(fs.existsSync(quarantinedClean)).toBe(true);
452
-
453
- // 4. Idempotent: the next auto-recover sync re-flags NOTHING (the orphans
454
- // were tombstoned, so the wedge does not recur).
455
- const second = await sync({
456
- company: "acme",
457
- vaultConfig: mockConfig,
458
- hqRoot: tmpDir,
459
- syncMode: "shared",
460
- prefixSet: ["projects/"],
461
- scopeShrinkPolicy: "auto-recover",
462
- });
463
- expect(second.scopeOrphansRemoved).toBe(0);
464
- expect(second.scopeOrphansBlocked).toBe(0);
465
- });
466
-
467
- // ── US-006: sessions prefix push-only pull-scope exclusion ─────────────────
468
- // The sessions prefix is PUSH-ONLY: pushed into the vault but never
469
- // auto-pulled onto grantee laptops in ANY mode (including `all`, which
470
- // otherwise pulls the whole bucket). The engine expresses this as
471
- // `excludePrefixes`, applied to the DOWNLOAD filter only — never to
472
- // scope-shrink, so an authored/pinned session is never orphaned.
473
- describe("sessions push-only exclusion (US-006)", () => {
474
- const withSession = () => {
475
- REMOTE.current = [
476
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
477
- { key: "sessions/prs_1/2026-07-11T10-00-00.json", size: 50, lastModified: new Date(), etag: '"sess1"' },
478
- ];
479
- };
480
- const sessionPath = "sessions/prs_1/2026-07-11T10-00-00.json";
481
-
482
- it("AC4: `all` mode subtracts excludePrefixes — a sessions key is skip-out-of-scope; non-session downloads", async () => {
483
- withSession();
484
- const result = await sync({
485
- company: "acme",
486
- vaultConfig: mockConfig,
487
- hqRoot: tmpDir,
488
- syncMode: "all",
489
- excludePrefixes: ["sessions/"],
490
- });
491
- // `all` normally pulls everything; the exclusion carves sessions out.
492
- expect(result.filesDownloaded).toBe(1);
493
- expect(result.filesOutOfScope).toBe(1);
494
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
495
- expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
496
- });
497
-
498
- it("AC4: excludePrefixes is NOT fed to scope-shrink — a previously-pulled session file is never orphaned", async () => {
499
- withSession();
500
- // 1. Full pull WITHOUT the exclusion: the session materializes and journals
501
- // as a pulled (direction:"down") entry under an `all`-mode PullRecord.
502
- const first = await sync({
503
- company: "acme",
504
- vaultConfig: mockConfig,
505
- hqRoot: tmpDir,
506
- syncMode: "all",
507
- });
508
- expect(first.filesDownloaded).toBe(2);
509
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
510
-
511
- // 2. Full pull WITH the sessions exclusion. The session is now
512
- // skip-out-of-scope (not re-pulled) but MUST NOT be pruned: the exclude
513
- // set never reaches scope-shrink, whose currentPrefixSet stays `[""]`
514
- // (covers everything). If it WERE fed in, the file would be orphaned.
515
- const second = await sync({
516
- company: "acme",
517
- vaultConfig: mockConfig,
518
- hqRoot: tmpDir,
519
- syncMode: "all",
520
- excludePrefixes: ["sessions/"],
521
- });
522
- expect(second.filesOutOfScope).toBe(1);
523
- expect(second.scopeOrphansRemoved).toBe(0);
524
- expect(second.scopeOrphansBlocked).toBe(0);
525
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
526
- });
527
-
528
- it("AC6: a pinned/in-scope session survives scope-shrink even though excludePrefixes skips its re-pull", async () => {
529
- withSession();
530
- // Materialize both under all-mode (knowledge sync + an on-demand session get).
531
- await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
532
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
533
-
534
- // A scoped shared pull whose inclusion prefixSet includes the pinned
535
- // session key (as resolvePullScope would after `hq files get` pins it),
536
- // plus the push-only sessions exclusion. The session is NOT re-pulled
537
- // (exclusion), but the pin keeps it in scope-shrink's currentPrefixSet, so
538
- // it is retained rather than orphaned.
539
- const scoped = await sync({
540
- company: "acme",
541
- vaultConfig: mockConfig,
542
- hqRoot: tmpDir,
543
- syncMode: "shared",
544
- prefixSet: ["knowledge/", sessionPath],
545
- excludePrefixes: ["sessions/"],
546
- });
547
- expect(scoped.scopeOrphansRemoved).toBe(0);
548
- expect(scoped.scopeOrphansBlocked).toBe(0);
549
- // Session is not re-downloaded (excluded) but survives on disk...
550
- expect(scoped.filesOutOfScope).toBe(1);
551
- expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
552
- // ...and the in-scope knowledge file is retained too.
553
- expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
554
- });
555
-
556
- // ── feedback_d2082110 follow-up ────────────────────────────────────────
557
- // `skip-out-of-scope` carries TWO causes with OPPOSITE remedies:
558
- // membership scope (fixed by raising the access level to `all`) and the
559
- // push-only sessions exclusion (NOT fixed by that — sessions are withheld
560
- // in every mode, `all` included, and materialize only via `hq files get`).
561
- // The `scope-materialization-gap` advisory names the shared→all lever, so
562
- // it must count and sample ONLY membership-withheld keys. Counting session
563
- // keys inflates the gap, can fill the sample list with keys the lever will
564
- // never deliver, and can fire the whole advisory when the membership scope
565
- // withheld nothing — sending the reporter back into exactly the
566
- // flip-everything-to-`all`-and-still-miss-files loop the event exists to end.
567
- describe("materialization gap excludes push-only keys (feedback_d2082110)", () => {
568
- const collectEvents = () => {
569
- const events: Array<Record<string, unknown>> = [];
570
- return {
571
- events,
572
- onEvent: (e: unknown) => events.push(e as Record<string, unknown>),
573
- };
574
- };
575
- const gapOf = (events: Array<Record<string, unknown>>) =>
576
- events.find((e) => e.type === "scope-materialization-gap");
577
-
578
- it("a leg withholding ONLY push-only session keys emits no gap — the shared→all lever would not materialize them", async () => {
579
- withSession();
580
- const { events, onEvent } = collectEvents();
581
- const result = await sync({
582
- company: "acme",
583
- vaultConfig: mockConfig,
584
- hqRoot: tmpDir,
585
- syncMode: "shared",
586
- prefixSet: ["knowledge/"],
587
- excludePrefixes: ["sessions/"],
588
- onEvent,
589
- });
590
-
591
- // The session key is still withheld and still counted on the
592
- // both-causes `filesOutOfScope` axis — that contract is unchanged.
593
- expect(result.filesDownloaded).toBe(1);
594
- expect(result.filesOutOfScope).toBe(1);
595
- expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
596
- // But the membership scope withheld nothing, so there is no
597
- // shared-vs-all gap to report and no `all` lever to advertise.
598
- expect(gapOf(events)).toBeUndefined();
599
- });
600
-
601
- it("counts and samples only membership-withheld keys when both causes fire on one leg", async () => {
602
- REMOTE.current = [
603
- { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
604
- { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
605
- { key: sessionPath, size: 50, lastModified: new Date(), etag: '"sess1"' },
606
- ];
607
- const { events, onEvent } = collectEvents();
608
- const result = await sync({
609
- company: "acme",
610
- vaultConfig: mockConfig,
611
- hqRoot: tmpDir,
612
- syncMode: "shared",
613
- prefixSet: ["knowledge/"],
614
- excludePrefixes: ["sessions/"],
615
- onEvent,
616
- });
617
-
618
- // Two keys withheld overall: one by the membership scope, one push-only.
619
- expect(result.filesDownloaded).toBe(1);
620
- expect(result.filesOutOfScope).toBe(2);
621
-
622
- // The advisory reports ONLY the one the `all` lever would deliver.
623
- const gap = gapOf(events);
624
- expect(gap).toBeDefined();
625
- expect(gap!.count).toBe(1);
626
- expect(gap!.syncMode).toBe("shared");
627
- expect(gap!.samplePaths).toEqual(["docs/handoff.md"]);
628
- });
629
-
630
- it("a pinned session key withheld on the push-only axis alone is not a membership gap", async () => {
631
- // Exercises the OTHER classification branch: the key PASSES the
632
- // inclusion check (it is pinned into prefixSet, as resolvePullScope
633
- // does after `hq files get`) and is withheld by the exclusion filter.
634
- withSession();
635
- const { events, onEvent } = collectEvents();
636
- const result = await sync({
637
- company: "acme",
638
- vaultConfig: mockConfig,
639
- hqRoot: tmpDir,
640
- syncMode: "shared",
641
- prefixSet: ["knowledge/", sessionPath],
642
- excludePrefixes: ["sessions/"],
643
- onEvent,
644
- });
645
-
646
- expect(result.filesDownloaded).toBe(1);
647
- expect(result.filesOutOfScope).toBe(1);
648
- expect(gapOf(events)).toBeUndefined();
649
- });
650
- });
651
- });
652
- });