@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,1123 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import * as fs from "node:fs";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import yaml from "js-yaml";
6
-
7
- import {
8
- reconcileCompanyManifest,
9
- reconcileManifest,
10
- type ManifestReconcileDiagnostic,
11
- } from "./manifest-reconcile.js";
12
- import {
13
- VaultNotFoundError,
14
- VaultPermissionDeniedError,
15
- type EntityInfo,
16
- } from "./vault-client.js";
17
-
18
- let hqRoot: string | undefined;
19
-
20
- afterEach(() => {
21
- if (hqRoot) {
22
- fs.rmSync(hqRoot, { recursive: true, force: true });
23
- hqRoot = undefined;
24
- }
25
- });
26
-
27
- /** The stock manifest shipped to a fresh HQ root: header comment, no companies. */
28
- const EMPTY_MANIFEST_TEMPLATE = `# HQ company manifest.
29
- # Source of truth for which company slugs this HQ root routes.
30
- # Edited by humans; reconciled by hq-sync. Keep this header.
31
- companies: {}
32
- `;
33
-
34
- /** Far enough in the past that any rewrite is unmistakable in the mtime. */
35
- const FROZEN_MTIME = new Date("2020-01-01T00:00:00.000Z");
36
-
37
- function makeHqRoot(): string {
38
- const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-manifest-reconcile-"));
39
- fs.mkdirSync(path.join(root, "companies"), { recursive: true });
40
- return root;
41
- }
42
-
43
- function manifestPathFor(root: string): string {
44
- return path.join(root, "companies", "manifest.yaml");
45
- }
46
-
47
- function writeManifest(root: string, contents: string): string {
48
- const manifestPath = manifestPathFor(root);
49
- fs.writeFileSync(manifestPath, contents, "utf8");
50
- fs.utimesSync(manifestPath, FROZEN_MTIME, FROZEN_MTIME);
51
- return manifestPath;
52
- }
53
-
54
- function readCompanies(manifestPath: string): Record<string, Record<string, unknown>> {
55
- const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8")) as {
56
- companies?: Record<string, Record<string, unknown>>;
57
- };
58
- return parsed.companies ?? {};
59
- }
60
-
61
- /** Asserts the file was not touched at all — contents AND mtime. */
62
- function expectUntouched(manifestPath: string, before: string): void {
63
- expect(fs.readFileSync(manifestPath, "utf8")).toBe(before);
64
- expect(fs.statSync(manifestPath).mtimeMs).toBe(FROZEN_MTIME.getTime());
65
- }
66
-
67
- describe("reconcileManifest", () => {
68
- it("inserts a company into the stock empty manifest template", () => {
69
- hqRoot = makeHqRoot();
70
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
71
-
72
- const result = reconcileManifest(hqRoot, [
73
- {
74
- slug: "acme",
75
- uid: "cmp_acme",
76
- name: "Acme Inc",
77
- bucketName: "hq-vault-cmp-acme",
78
- },
79
- ]);
80
-
81
- expect(result).toEqual({
82
- written: true,
83
- added: ["acme"],
84
- updated: [],
85
- skipped: [],
86
- });
87
- expect(readCompanies(manifestPath)).toEqual({
88
- acme: {
89
- cloud_uid: "cmp_acme",
90
- name: "Acme Inc",
91
- bucket_name: "hq-vault-cmp-acme",
92
- },
93
- });
94
- });
95
-
96
- it("inserts alongside an existing company without disturbing it", () => {
97
- hqRoot = makeHqRoot();
98
- const manifestPath = writeManifest(
99
- hqRoot,
100
- `version: 3\ncompanies:\n incumbent:\n name: Incumbent\n cloud_uid: cmp_incumbent\n bucket_name: hq-vault-cmp-incumbent\n`,
101
- );
102
-
103
- const result = reconcileManifest(hqRoot, [
104
- { slug: "joiner", uid: "cmp_joiner", name: "Joiner", bucketName: "hq-vault-cmp-joiner" },
105
- ]);
106
-
107
- expect(result.written).toBe(true);
108
- expect(result.added).toEqual(["joiner"]);
109
- expect(result.updated).toEqual([]);
110
- expect(readCompanies(manifestPath)).toEqual({
111
- incumbent: {
112
- name: "Incumbent",
113
- cloud_uid: "cmp_incumbent",
114
- bucket_name: "hq-vault-cmp-incumbent",
115
- },
116
- joiner: {
117
- cloud_uid: "cmp_joiner",
118
- name: "Joiner",
119
- bucket_name: "hq-vault-cmp-joiner",
120
- },
121
- });
122
- // Unrelated top-level state survives the merge.
123
- expect(
124
- (yaml.load(fs.readFileSync(manifestPath, "utf8")) as { version?: unknown }).version,
125
- ).toBe(3);
126
- });
127
-
128
- it("performs no write at all when every target is already current", () => {
129
- hqRoot = makeHqRoot();
130
- // The header comment is the canary: js-yaml cannot round-trip comments, so
131
- // any rewrite would silently drop it and churn the personal-vault copy.
132
- const contents = `# Keep this header comment.\ncompanies:\n acme:\n name: Acme Inc\n cloud_uid: cmp_acme\n bucket_name: hq-vault-cmp-acme\n`;
133
- const manifestPath = writeManifest(hqRoot, contents);
134
-
135
- const result = reconcileManifest(hqRoot, [
136
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
137
- ]);
138
-
139
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: [] });
140
- expectUntouched(manifestPath, contents);
141
- expect(fs.readFileSync(manifestPath, "utf8")).toContain("# Keep this header comment.");
142
- });
143
-
144
- it("reports a partial drift as an update and leaves untouched fields alone", () => {
145
- hqRoot = makeHqRoot();
146
- const manifestPath = writeManifest(
147
- hqRoot,
148
- `companies:\n acme:\n name: Acme Inc\n cloud_uid: cmp_acme\n bucket_name: hq-vault-stale\n`,
149
- );
150
-
151
- const result = reconcileManifest(hqRoot, [
152
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
153
- ]);
154
-
155
- expect(result).toEqual({
156
- written: true,
157
- added: [],
158
- updated: ["acme"],
159
- skipped: [],
160
- });
161
- expect(readCompanies(manifestPath).acme).toEqual({
162
- name: "Acme Inc",
163
- cloud_uid: "cmp_acme",
164
- bucket_name: "hq-vault-cmp-acme",
165
- });
166
- });
167
-
168
- it("preserves unknown keys on both the untouched and the updated company", () => {
169
- hqRoot = makeHqRoot();
170
- const manifestPath = writeManifest(
171
- hqRoot,
172
- [
173
- "companies:",
174
- " bystander:",
175
- " name: Bystander",
176
- " cloud_uid: cmp_bystander",
177
- " repos:",
178
- " - repos/private/bystander",
179
- " workers:",
180
- " - designer",
181
- " qmd_collections:",
182
- " - bystander-docs",
183
- " acme:",
184
- " name: Acme Inc",
185
- " cloud_uid: cmp_acme",
186
- " bucket_name: hq-vault-stale",
187
- " repos:",
188
- " - repos/private/acme",
189
- " workers:",
190
- " - backend-dev",
191
- " qmd_collections:",
192
- " - acme-docs",
193
- "",
194
- ].join("\n"),
195
- );
196
-
197
- const result = reconcileManifest(hqRoot, [
198
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
199
- ]);
200
-
201
- expect(result.updated).toEqual(["acme"]);
202
- const companies = readCompanies(manifestPath);
203
- expect(companies.bystander).toEqual({
204
- name: "Bystander",
205
- cloud_uid: "cmp_bystander",
206
- repos: ["repos/private/bystander"],
207
- workers: ["designer"],
208
- qmd_collections: ["bystander-docs"],
209
- });
210
- expect(companies.acme).toEqual({
211
- name: "Acme Inc",
212
- cloud_uid: "cmp_acme",
213
- bucket_name: "hq-vault-cmp-acme",
214
- repos: ["repos/private/acme"],
215
- workers: ["backend-dev"],
216
- qmd_collections: ["acme-docs"],
217
- });
218
- });
219
-
220
- it("no-ops without throwing when the manifest cannot be parsed", () => {
221
- hqRoot = makeHqRoot();
222
- const contents = "companies:\n acme:\n - this: is\n not: valid yaml\n\t\tbad indent\n";
223
- const manifestPath = writeManifest(hqRoot, contents);
224
-
225
- let result;
226
- expect(() => {
227
- result = reconcileManifest(hqRoot as string, [
228
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
229
- ]);
230
- }).not.toThrow();
231
-
232
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: [] });
233
- expectUntouched(manifestPath, contents);
234
- });
235
-
236
- it("no-ops when the manifest is missing and never creates one", () => {
237
- hqRoot = makeHqRoot();
238
- const manifestPath = manifestPathFor(hqRoot);
239
- expect(fs.existsSync(manifestPath)).toBe(false);
240
-
241
- const result = reconcileManifest(hqRoot, [
242
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
243
- ]);
244
-
245
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: [] });
246
- expect(fs.existsSync(manifestPath)).toBe(false);
247
- });
248
-
249
- it("no-ops when `companies` is present but is not a mapping", () => {
250
- hqRoot = makeHqRoot();
251
- const contents = "companies:\n - acme\n - beta\n";
252
- const manifestPath = writeManifest(hqRoot, contents);
253
-
254
- const result = reconcileManifest(hqRoot, [
255
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
256
- ]);
257
-
258
- expect(result.written).toBe(false);
259
- expectUntouched(manifestPath, contents);
260
- });
261
-
262
- it("skips a slug whose existing entry is not a mapping", () => {
263
- hqRoot = makeHqRoot();
264
- const contents = "companies:\n acme: not-a-mapping\n";
265
- const manifestPath = writeManifest(hqRoot, contents);
266
-
267
- const result = reconcileManifest(hqRoot, [
268
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
269
- ]);
270
-
271
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: ["acme"] });
272
- expectUntouched(manifestPath, contents);
273
- });
274
-
275
- describe("path traversal guard", () => {
276
- const unsafeSlugs: Array<[label: string, slug: string]> = [
277
- ["a forward-slash path", "nested/evil"],
278
- ["a backslash path", "nested\\evil"],
279
- ["the current directory", "."],
280
- ["the parent directory", ".."],
281
- ["a slug resolving outside the companies dir", "../escape"],
282
- ["an empty slug", ""],
283
- ["the __proto__ prototype key", "__proto__"],
284
- ["the constructor prototype key", "constructor"],
285
- ["the prototype prototype key", "prototype"],
286
- ];
287
-
288
- for (const [label, slug] of unsafeSlugs) {
289
- it(`rejects ${label} without writing`, () => {
290
- hqRoot = makeHqRoot();
291
- const contents = EMPTY_MANIFEST_TEMPLATE;
292
- const manifestPath = writeManifest(hqRoot, contents);
293
-
294
- const result = reconcileManifest(hqRoot, [
295
- { slug, uid: "cmp_evil", name: "Evil", bucketName: "hq-vault-cmp-evil" },
296
- ]);
297
-
298
- expect(result.skipped).toEqual([slug]);
299
- expect(result.written).toBe(false);
300
- expect(result.added).toEqual([]);
301
- expect(result.updated).toEqual([]);
302
- expectUntouched(manifestPath, contents);
303
- expect(readCompanies(manifestPath)).toEqual({});
304
- });
305
- }
306
- });
307
-
308
- it("does not fall into a write loop on a __proto__ slug", () => {
309
- // Regression: `__proto__` read back as Object.prototype, so it passed the
310
- // non-mapping skip, always reported drift, and never persisted through
311
- // yaml.dump — a rewrite on every run of a vault-synced file, forever.
312
- hqRoot = makeHqRoot();
313
- const contents = EMPTY_MANIFEST_TEMPLATE;
314
- const manifestPath = writeManifest(hqRoot, contents);
315
- const targets = [{ slug: "__proto__", uid: "cmp_evil", name: "Evil" }];
316
-
317
- for (let run = 0; run < 3; run += 1) {
318
- const result = reconcileManifest(hqRoot, targets);
319
- expect(result.written).toBe(false);
320
- expect(result.skipped).toEqual(["__proto__"]);
321
- expectUntouched(manifestPath, contents);
322
- }
323
-
324
- // The prototype itself must be clean — no pollution leaked out.
325
- expect(({} as Record<string, unknown>).cloud_uid).toBeUndefined();
326
- expect(readCompanies(manifestPath)).toEqual({});
327
- });
328
-
329
- it("no-ops when `companies` is a timestamp scalar rather than a mapping", () => {
330
- // js-yaml parses this into a Date, which a `typeof value === "object"`
331
- // check would have accepted as the company map.
332
- hqRoot = makeHqRoot();
333
- const contents = "companies: 2026-01-01\n";
334
- const manifestPath = writeManifest(hqRoot, contents);
335
-
336
- const result = reconcileManifest(hqRoot, [
337
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
338
- ]);
339
-
340
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: [] });
341
- expectUntouched(manifestPath, contents);
342
- });
343
-
344
- it("no-ops when `companies` is a plain string scalar", () => {
345
- hqRoot = makeHqRoot();
346
- const contents = "companies: nonsense\n";
347
- const manifestPath = writeManifest(hqRoot, contents);
348
-
349
- expect(reconcileManifest(hqRoot, [{ slug: "acme", uid: "cmp_acme" }]).written).toBe(false);
350
- expectUntouched(manifestPath, contents);
351
- });
352
-
353
- it("skips a timestamp entry without destroying its value", () => {
354
- // A Date entry used to slip past the non-mapping skip and then be silently
355
- // flattened to {} by the object spread.
356
- hqRoot = makeHqRoot();
357
- const contents = "companies:\n acme: 2026-01-01\n";
358
- const manifestPath = writeManifest(hqRoot, contents);
359
-
360
- const result = reconcileManifest(hqRoot, [
361
- { slug: "acme", uid: "cmp_acme", name: "Acme Inc", bucketName: "hq-vault-cmp-acme" },
362
- ]);
363
-
364
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: ["acme"] });
365
- expectUntouched(manifestPath, contents);
366
- });
367
-
368
- it("treats a bare `companies:` key as an empty map and inserts into it", () => {
369
- hqRoot = makeHqRoot();
370
- const manifestPath = writeManifest(hqRoot, "version: 3\ncompanies:\n");
371
-
372
- const result = reconcileManifest(hqRoot, [{ slug: "acme", uid: "cmp_acme" }]);
373
-
374
- expect(result.written).toBe(true);
375
- expect(result.added).toEqual(["acme"]);
376
- expect(readCompanies(manifestPath)).toEqual({ acme: { cloud_uid: "cmp_acme" } });
377
- });
378
-
379
- it("does not write when duplicate slugs produce no net change", () => {
380
- // Regression: a sticky change flag was flipped on by the first target and
381
- // back off by the second, leaving it true while content was identical.
382
- hqRoot = makeHqRoot();
383
- const contents = "companies:\n acme:\n cloud_uid: cmp_b\n";
384
- const manifestPath = writeManifest(hqRoot, contents);
385
-
386
- const result = reconcileManifest(hqRoot, [
387
- { slug: "acme", uid: "cmp_a" },
388
- { slug: "acme", uid: "cmp_b" },
389
- ]);
390
-
391
- expect(result.written).toBe(false);
392
- expect(result.updated).toEqual([]);
393
- expectUntouched(manifestPath, contents);
394
- });
395
-
396
- it("applies last-wins for duplicate slugs that do change the file", () => {
397
- hqRoot = makeHqRoot();
398
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
399
-
400
- const result = reconcileManifest(hqRoot, [
401
- { slug: "acme", uid: "cmp_a" },
402
- { slug: "acme", uid: "cmp_b" },
403
- ]);
404
-
405
- expect(result.written).toBe(true);
406
- expect(result.added).toEqual(["acme"]);
407
- expect(readCompanies(manifestPath)).toEqual({ acme: { cloud_uid: "cmp_b" } });
408
- });
409
-
410
- it("leaves no temp file behind when the write fails", () => {
411
- hqRoot = makeHqRoot();
412
- const companiesDir = path.join(hqRoot, "companies");
413
- writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
414
- // Read-only directory: the temp write itself fails, and on platforms where
415
- // it does not, the rename over the target does.
416
- fs.chmodSync(companiesDir, 0o500);
417
-
418
- try {
419
- expect(() => reconcileManifest(hqRoot as string, [{ slug: "acme", uid: "cmp_acme" }])).toThrow();
420
- const strays = fs.readdirSync(companiesDir).filter((n) => n.startsWith(".manifest-"));
421
- expect(strays).toEqual([]);
422
- } finally {
423
- fs.chmodSync(companiesDir, 0o700);
424
- }
425
- });
426
-
427
- it("documents that the first genuine write drops the manifest header comment", () => {
428
- // yaml.dump cannot round-trip comments. This loss is accepted and is exactly
429
- // why skip-if-unchanged must prevent every needless rewrite.
430
- hqRoot = makeHqRoot();
431
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
432
- expect(fs.readFileSync(manifestPath, "utf8")).toContain("# HQ company manifest.");
433
-
434
- expect(reconcileManifest(hqRoot, [{ slug: "acme", uid: "cmp_acme" }]).written).toBe(true);
435
-
436
- const after = fs.readFileSync(manifestPath, "utf8");
437
- expect(after).not.toContain("# HQ company manifest.");
438
- // ...and the very next run must not touch it again.
439
- const replay = reconcileManifest(hqRoot, [{ slug: "acme", uid: "cmp_acme" }]);
440
- expect(replay.written).toBe(false);
441
- expect(fs.readFileSync(manifestPath, "utf8")).toBe(after);
442
- });
443
-
444
- it("omits bucket_name entirely when the target has no bucket", () => {
445
- hqRoot = makeHqRoot();
446
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
447
-
448
- const result = reconcileManifest(hqRoot, [
449
- { slug: "joiner", uid: "cmp_joiner", name: "Joiner Inc" },
450
- ]);
451
-
452
- expect(result.added).toEqual(["joiner"]);
453
- const entry = readCompanies(manifestPath).joiner;
454
- expect("bucket_name" in entry).toBe(false);
455
- expect(entry).toEqual({ cloud_uid: "cmp_joiner", name: "Joiner Inc" });
456
- expect(fs.readFileSync(manifestPath, "utf8")).not.toContain("bucket_name");
457
- });
458
-
459
- it("omits name entirely when the target has no name", () => {
460
- hqRoot = makeHqRoot();
461
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
462
-
463
- const result = reconcileManifest(hqRoot, [
464
- { slug: "joiner", uid: "cmp_joiner", bucketName: "hq-vault-cmp-joiner" },
465
- ]);
466
-
467
- expect(result.added).toEqual(["joiner"]);
468
- const entry = readCompanies(manifestPath).joiner;
469
- expect("name" in entry).toBe(false);
470
- expect(entry).toEqual({ cloud_uid: "cmp_joiner", bucket_name: "hq-vault-cmp-joiner" });
471
- // No `name:` key line at all (`bucket_name:` must not satisfy this).
472
- expect(fs.readFileSync(manifestPath, "utf8")).not.toMatch(/^\s*name:/m);
473
- });
474
-
475
- it("treats empty-string name and bucket as absent rather than writing blanks", () => {
476
- hqRoot = makeHqRoot();
477
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
478
-
479
- const result = reconcileManifest(hqRoot, [
480
- { slug: "joiner", uid: "cmp_joiner", name: "", bucketName: "" },
481
- ]);
482
-
483
- expect(result).toEqual({
484
- written: true,
485
- added: ["joiner"],
486
- updated: [],
487
- skipped: [],
488
- });
489
- const entry = readCompanies(manifestPath).joiner;
490
- expect("name" in entry).toBe(false);
491
- expect("bucket_name" in entry).toBe(false);
492
- expect(entry).toEqual({ cloud_uid: "cmp_joiner" });
493
-
494
- // An empty-string field must not read as drift on the replay, or the
495
- // manifest would be rewritten on every run.
496
- fs.utimesSync(manifestPath, FROZEN_MTIME, FROZEN_MTIME);
497
- const replay = reconcileManifest(hqRoot, [
498
- { slug: "joiner", uid: "cmp_joiner", name: "", bucketName: "" },
499
- ]);
500
- expect(replay.written).toBe(false);
501
- expect(fs.statSync(manifestPath).mtimeMs).toBe(FROZEN_MTIME.getTime());
502
- });
503
-
504
- it("still writes cloud_uid for a company that has neither name nor bucket", () => {
505
- hqRoot = makeHqRoot();
506
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
507
-
508
- const result = reconcileManifest(hqRoot, [{ slug: "joiner", uid: "cmp_joiner" }]);
509
-
510
- expect(result.written).toBe(true);
511
- expect(result.added).toEqual(["joiner"]);
512
- expect(readCompanies(manifestPath).joiner).toEqual({ cloud_uid: "cmp_joiner" });
513
- });
514
-
515
- it("never deletes an existing key just because the target omits it", () => {
516
- hqRoot = makeHqRoot();
517
- const manifestPath = writeManifest(
518
- hqRoot,
519
- `companies:\n acme:\n name: Acme Inc\n cloud_uid: cmp_old\n bucket_name: hq-vault-cmp-acme\n`,
520
- );
521
-
522
- const result = reconcileManifest(hqRoot, [{ slug: "acme", uid: "cmp_acme" }]);
523
-
524
- expect(result.updated).toEqual(["acme"]);
525
- expect(readCompanies(manifestPath).acme).toEqual({
526
- name: "Acme Inc",
527
- cloud_uid: "cmp_acme",
528
- bucket_name: "hq-vault-cmp-acme",
529
- });
530
- });
531
-
532
- it("reports adds, updates and skips together in one pass", () => {
533
- hqRoot = makeHqRoot();
534
- const manifestPath = writeManifest(
535
- hqRoot,
536
- [
537
- "companies:",
538
- " current:",
539
- " cloud_uid: cmp_current",
540
- " drifted:",
541
- " cloud_uid: cmp_stale",
542
- " broken: not-a-mapping",
543
- "",
544
- ].join("\n"),
545
- );
546
-
547
- const result = reconcileManifest(hqRoot, [
548
- { slug: "fresh", uid: "cmp_fresh" },
549
- { slug: "drifted", uid: "cmp_drifted" },
550
- { slug: "current", uid: "cmp_current" },
551
- { slug: "broken", uid: "cmp_broken" },
552
- { slug: "../escape", uid: "cmp_escape" },
553
- ]);
554
-
555
- expect(result).toEqual({
556
- written: true,
557
- added: ["fresh"],
558
- updated: ["drifted"],
559
- skipped: ["../escape", "broken"],
560
- });
561
- const companies = readCompanies(manifestPath);
562
- expect(companies.current).toEqual({ cloud_uid: "cmp_current" });
563
- expect(companies.drifted).toEqual({ cloud_uid: "cmp_drifted" });
564
- expect(companies.fresh).toEqual({ cloud_uid: "cmp_fresh" });
565
- expect(companies.broken).toBe("not-a-mapping");
566
- });
567
- });
568
-
569
- describe("reconcileCompanyManifest", () => {
570
- it("atomically restores one successful cloud entry after the personal leg without replacing existing manifest state", async () => {
571
- hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-manifest-reconcile-"));
572
- const companiesDir = path.join(hqRoot, "companies");
573
- fs.mkdirSync(path.join(companiesDir, "acme"), { recursive: true });
574
- fs.mkdirSync(path.join(companiesDir, "failed"), { recursive: true });
575
- fs.mkdirSync(path.join(companiesDir, "deleted"), { recursive: true });
576
-
577
- // This is the older manifest the personal-vault leg pulled after the
578
- // cloud-company legs completed. The final merge must retain this state and
579
- // add only the live, successful cloud target.
580
- const manifestPath = path.join(companiesDir, "manifest.yaml");
581
- fs.writeFileSync(
582
- manifestPath,
583
- `version: 3\nsettings:\n keep: true\ncompanies:\n local-only:\n name: Local Only\n repos:\n - repos/local\n preserved:\n name: Keep Me\n cloud_uid: cmp_preserved\n`,
584
- );
585
-
586
- const entities: Record<string, EntityInfo> = {
587
- cmp_acme: {
588
- uid: "cmp_acme",
589
- slug: "acme",
590
- type: "company",
591
- status: "active",
592
- name: "Acme Inc",
593
- bucketName: "hq-vault-cmp-acme",
594
- createdAt: "2026-01-01T00:00:00Z",
595
- },
596
- cmp_failed: {
597
- uid: "cmp_failed",
598
- slug: "failed",
599
- type: "company",
600
- status: "active",
601
- name: "Failed Corp",
602
- bucketName: "hq-vault-cmp-failed",
603
- createdAt: "2026-01-01T00:00:00Z",
604
- },
605
- cmp_deleted: {
606
- uid: "cmp_deleted",
607
- slug: "deleted",
608
- type: "company",
609
- status: "deleted",
610
- name: "Deleted Corp",
611
- bucketName: "hq-vault-cmp-deleted",
612
- createdAt: "2026-01-01T00:00:00Z",
613
- },
614
- };
615
- const options = {
616
- hqRoot,
617
- targets: [
618
- { uid: "cmp_acme", slug: "acme" },
619
- { uid: "cmp_failed", slug: "failed" },
620
- { uid: "cmp_deleted", slug: "deleted" },
621
- { uid: "cmp_missing-directory", slug: "missing-directory" },
622
- { uid: "prs_me", slug: "personal", personalMode: true },
623
- ],
624
- completedCompanySlugs: new Set(["acme", "deleted", "missing-directory", "personal"]),
625
- getEntity: async (uid: string) => {
626
- const entity = entities[uid];
627
- if (!entity) throw new Error("entity not found");
628
- return entity;
629
- },
630
- };
631
-
632
- await reconcileCompanyManifest(options);
633
- const firstWrite = fs.readFileSync(manifestPath, "utf8");
634
- // Freeze the mtime so the replay cannot pass by coincidence: identical bytes
635
- // written a second time would still bump mtime, and mtime is what the sync
636
- // layer keys on, so a byte comparison alone would miss a real conflict loop.
637
- fs.utimesSync(manifestPath, FROZEN_MTIME, FROZEN_MTIME);
638
- const replay = await reconcileCompanyManifest(options);
639
-
640
- expect(replay.written).toBe(false);
641
- expect(fs.readFileSync(manifestPath, "utf8")).toBe(firstWrite);
642
- expect(fs.statSync(manifestPath).mtimeMs).toBe(FROZEN_MTIME.getTime());
643
- expect(yaml.load(firstWrite)).toEqual({
644
- version: 3,
645
- settings: { keep: true },
646
- companies: {
647
- "local-only": {
648
- name: "Local Only",
649
- repos: ["repos/local"],
650
- },
651
- preserved: {
652
- name: "Keep Me",
653
- cloud_uid: "cmp_preserved",
654
- },
655
- acme: {
656
- name: "Acme Inc",
657
- cloud_uid: "cmp_acme",
658
- bucket_name: "hq-vault-cmp-acme",
659
- },
660
- },
661
- });
662
- });
663
-
664
- it("returns the reconcile result on the insert path and again on the no-op replay", async () => {
665
- hqRoot = makeHqRoot();
666
- fs.mkdirSync(path.join(hqRoot, "companies", "acme"), { recursive: true });
667
- writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
668
-
669
- const options = {
670
- hqRoot,
671
- targets: [{ uid: "cmp_acme", slug: "acme" }],
672
- completedCompanySlugs: new Set(["acme"]),
673
- getEntity: async (): Promise<EntityInfo> => ({
674
- uid: "cmp_acme",
675
- slug: "acme",
676
- type: "company",
677
- status: "active",
678
- name: "Acme Inc",
679
- bucketName: "hq-vault-cmp-acme",
680
- createdAt: "2026-01-01T00:00:00Z",
681
- }),
682
- };
683
-
684
- expect(await reconcileCompanyManifest(options)).toEqual({
685
- written: true,
686
- added: ["acme"],
687
- updated: [],
688
- skipped: [],
689
- });
690
- expect(await reconcileCompanyManifest(options)).toEqual({
691
- written: false,
692
- added: [],
693
- updated: [],
694
- skipped: [],
695
- });
696
- });
697
-
698
- it("returns an empty result when no target is eligible", async () => {
699
- hqRoot = makeHqRoot();
700
- writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
701
-
702
- expect(
703
- await reconcileCompanyManifest({
704
- hqRoot,
705
- targets: [{ uid: "prs_me", slug: "personal", personalMode: true }],
706
- completedCompanySlugs: new Set(["personal"]),
707
- getEntity: async () => {
708
- throw new Error("should not be called");
709
- },
710
- }),
711
- ).toEqual({ written: false, added: [], updated: [], skipped: [] });
712
- });
713
-
714
- it("keeps a company that has no name or bucket instead of dropping it", async () => {
715
- hqRoot = makeHqRoot();
716
- fs.mkdirSync(path.join(hqRoot, "companies", "joiner"), { recursive: true });
717
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
718
-
719
- const options = {
720
- hqRoot,
721
- targets: [{ uid: "cmp_joiner", slug: "joiner" }],
722
- completedCompanySlugs: new Set(["joiner"]),
723
- getEntity: async (): Promise<EntityInfo> => ({
724
- uid: "cmp_joiner",
725
- slug: "joiner",
726
- type: "company",
727
- status: "active",
728
- createdAt: "2026-01-01T00:00:00Z",
729
- }),
730
- };
731
- const result = await reconcileCompanyManifest(options);
732
-
733
- expect(result.written).toBe(true);
734
- expect(result.added).toEqual(["joiner"]);
735
- const entry = readCompanies(manifestPath).joiner;
736
- expect(entry).toEqual({ cloud_uid: "cmp_joiner" });
737
- expect("name" in entry).toBe(false);
738
- expect("bucket_name" in entry).toBe(false);
739
-
740
- // The highest-risk idempotency property: a target missing both optional
741
- // fields must not re-report drift forever.
742
- const after = fs.readFileSync(manifestPath, "utf8");
743
- const mtime = fs.statSync(manifestPath).mtimeMs;
744
- expect((await reconcileCompanyManifest(options)).written).toBe(false);
745
- expect((await reconcileCompanyManifest(options)).written).toBe(false);
746
- expect(fs.readFileSync(manifestPath, "utf8")).toBe(after);
747
- expect(fs.statSync(manifestPath).mtimeMs).toBe(mtime);
748
- });
749
-
750
- // -------------------------------------------------------------------------
751
- // US-002 — plan-carried path (no `getEntity`). This is the shape the
752
- // sync-runner uses: `buildFanoutPlan` already resolved the entity, so the
753
- // reconciler makes zero API calls and reads name/bucketName off the target.
754
- // -------------------------------------------------------------------------
755
-
756
- it("without getEntity: writes entries straight from the plan-carried target fields", async () => {
757
- hqRoot = makeHqRoot();
758
- fs.mkdirSync(path.join(hqRoot, "companies", "acme"), { recursive: true });
759
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
760
-
761
- const options = {
762
- hqRoot,
763
- targets: [
764
- {
765
- uid: "cmp_acme",
766
- slug: "acme",
767
- name: "Acme Inc",
768
- bucketName: "hq-vault-cmp-acme",
769
- entityType: "company",
770
- entityStatus: "active",
771
- },
772
- ],
773
- completedCompanySlugs: new Set(["acme"]),
774
- };
775
-
776
- expect(await reconcileCompanyManifest(options)).toEqual({
777
- written: true,
778
- added: ["acme"],
779
- updated: [],
780
- skipped: [],
781
- });
782
- expect(readCompanies(manifestPath).acme).toEqual({
783
- cloud_uid: "cmp_acme",
784
- name: "Acme Inc",
785
- bucket_name: "hq-vault-cmp-acme",
786
- });
787
-
788
- // Same idempotency contract as the getEntity path.
789
- const after = fs.readFileSync(manifestPath, "utf8");
790
- fs.utimesSync(manifestPath, FROZEN_MTIME, FROZEN_MTIME);
791
- expect((await reconcileCompanyManifest(options)).written).toBe(false);
792
- expectUntouched(manifestPath, after);
793
- });
794
-
795
- it("without getEntity: still writes a target that carries neither name nor bucketName", async () => {
796
- hqRoot = makeHqRoot();
797
- fs.mkdirSync(path.join(hqRoot, "companies", "joiner"), { recursive: true });
798
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
799
-
800
- const result = await reconcileCompanyManifest({
801
- hqRoot,
802
- targets: [
803
- {
804
- uid: "cmp_joiner",
805
- slug: "joiner",
806
- entityType: "company",
807
- entityStatus: "active",
808
- },
809
- ],
810
- completedCompanySlugs: new Set(["joiner"]),
811
- });
812
-
813
- expect(result.written).toBe(true);
814
- expect(readCompanies(manifestPath).joiner).toEqual({ cloud_uid: "cmp_joiner" });
815
- });
816
-
817
- it("without getEntity: keeps every eligibility filter — personal, incomplete, and unmaterialized targets are skipped", async () => {
818
- hqRoot = makeHqRoot();
819
- fs.mkdirSync(path.join(hqRoot, "companies", "acme"), { recursive: true });
820
- fs.mkdirSync(path.join(hqRoot, "companies", "incomplete"), { recursive: true });
821
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
822
-
823
- const result = await reconcileCompanyManifest({
824
- hqRoot,
825
- targets: [
826
- {
827
- uid: "cmp_acme",
828
- slug: "acme",
829
- bucketName: "hq-vault-cmp-acme",
830
- entityType: "company",
831
- entityStatus: "active",
832
- },
833
- // Completed and live, but never materialized a directory on disk.
834
- {
835
- uid: "cmp_ghost",
836
- slug: "ghost",
837
- bucketName: "hq-vault-cmp-ghost",
838
- entityType: "company",
839
- entityStatus: "active",
840
- },
841
- // On disk and live, but its fanout leg did not complete.
842
- {
843
- uid: "cmp_incomplete",
844
- slug: "incomplete",
845
- entityType: "company",
846
- entityStatus: "active",
847
- },
848
- // Personal vault — never a manifest entry, even if it completed.
849
- { uid: "prs_me", slug: "personal", personalMode: true },
850
- ],
851
- completedCompanySlugs: new Set(["acme", "ghost", "personal"]),
852
- });
853
-
854
- expect(result).toEqual({
855
- written: true,
856
- added: ["acme"],
857
- updated: [],
858
- skipped: [],
859
- });
860
- expect(Object.keys(readCompanies(manifestPath))).toEqual(["acme"]);
861
- });
862
-
863
- // Removing the per-target `entity.get` round trip (US-002) must not remove the
864
- // liveness GUARANTEE it provided. Without an entity resolver the only evidence
865
- // left is what the plan captured, so a target that cannot prove it was an
866
- // active company at plan time must fail closed.
867
- // Entity-probe failures all skip the target, but they are not all equally
868
- // routine. "The company is gone" is expected; "the resolver is broken" is the
869
- // failure mode that would otherwise masquerade as "joiners never get manifest
870
- // entries" — the exact bug this module exists to prevent.
871
- it("stays silent when a probe fails for the ordinary reasons (404 / 403)", async () => {
872
- hqRoot = makeHqRoot();
873
- fs.mkdirSync(path.join(hqRoot, "companies", "gone"), { recursive: true });
874
- fs.mkdirSync(path.join(hqRoot, "companies", "forbidden"), { recursive: true });
875
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
876
- const diagnostics: ManifestReconcileDiagnostic[] = [];
877
-
878
- const result = await reconcileCompanyManifest({
879
- hqRoot,
880
- targets: [
881
- { uid: "cmp_gone", slug: "gone" },
882
- { uid: "cmp_forbidden", slug: "forbidden" },
883
- ],
884
- completedCompanySlugs: new Set(["gone", "forbidden"]),
885
- getEntity: (uid: string): Promise<EntityInfo> =>
886
- uid === "cmp_gone"
887
- ? Promise.reject(new VaultNotFoundError("company deleted"))
888
- : Promise.reject(new VaultPermissionDeniedError("no longer a member")),
889
- reportDiagnostic: (d) => diagnostics.push(d),
890
- });
891
-
892
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: [] });
893
- expect(diagnostics).toEqual([]);
894
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
895
- });
896
-
897
- it("reports an unexpected probe failure instead of silently skipping the target", async () => {
898
- hqRoot = makeHqRoot();
899
- fs.mkdirSync(path.join(hqRoot, "companies", "acme"), { recursive: true });
900
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
901
- const diagnostics: ManifestReconcileDiagnostic[] = [];
902
-
903
- const result = await reconcileCompanyManifest({
904
- hqRoot,
905
- targets: [{ uid: "cmp_acme", slug: "acme" }],
906
- completedCompanySlugs: new Set(["acme"]),
907
- // A programming error, not a lookup answer.
908
- getEntity: () => Promise.reject(new TypeError("resolver is not a function")),
909
- reportDiagnostic: (d) => diagnostics.push(d),
910
- });
911
-
912
- // The target is still skipped — a broken probe never mints an entry.
913
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: [] });
914
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
915
-
916
- // ...but it is no longer invisible.
917
- expect(diagnostics).toHaveLength(1);
918
- expect(diagnostics[0].event).toBe("runner.manifest_reconcile.entity_probe_failed");
919
- expect(diagnostics[0].err).toBeInstanceOf(TypeError);
920
- expect(diagnostics[0].context).toMatchObject({ slug: "acme", uid: "cmp_acme" });
921
- });
922
-
923
- it("without getEntity: fails closed when the plan carried no liveness evidence at all", async () => {
924
- hqRoot = makeHqRoot();
925
- fs.mkdirSync(path.join(hqRoot, "companies", "gone"), { recursive: true });
926
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
927
-
928
- expect(
929
- await reconcileCompanyManifest({
930
- hqRoot,
931
- // Completed, non-personal, directory on disk — every OTHER filter passes.
932
- targets: [{ uid: "cmp_deleted", slug: "gone" }],
933
- completedCompanySlugs: new Set(["gone"]),
934
- }),
935
- ).toEqual({ written: false, added: [], updated: [], skipped: [] });
936
- expect(readCompanies(manifestPath).gone).toBeUndefined();
937
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
938
- });
939
-
940
- it("without getEntity: refuses a target the plan saw as non-active or non-company", async () => {
941
- hqRoot = makeHqRoot();
942
- for (const slug of ["suspended", "deleted", "notacompany"]) {
943
- fs.mkdirSync(path.join(hqRoot, "companies", slug), { recursive: true });
944
- }
945
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
946
-
947
- expect(
948
- await reconcileCompanyManifest({
949
- hqRoot,
950
- targets: [
951
- {
952
- uid: "cmp_suspended",
953
- slug: "suspended",
954
- entityType: "company",
955
- entityStatus: "suspended",
956
- },
957
- {
958
- uid: "cmp_deleted",
959
- slug: "deleted",
960
- entityType: "company",
961
- entityStatus: "deleted",
962
- },
963
- {
964
- uid: "prs_person",
965
- slug: "notacompany",
966
- entityType: "person",
967
- entityStatus: "active",
968
- },
969
- ],
970
- completedCompanySlugs: new Set(["suspended", "deleted", "notacompany"]),
971
- }),
972
- ).toEqual({ written: false, added: [], updated: [], skipped: [] });
973
- expect(Object.keys(readCompanies(manifestPath))).toEqual([]);
974
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
975
- });
976
-
977
- it("without getEntity: no eligible target means no write at all", async () => {
978
- hqRoot = makeHqRoot();
979
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
980
-
981
- expect(
982
- await reconcileCompanyManifest({
983
- hqRoot,
984
- targets: [{ uid: "prs_me", slug: "personal", personalMode: true }],
985
- completedCompanySlugs: new Set(["personal"]),
986
- }),
987
- ).toEqual({ written: false, added: [], updated: [], skipped: [] });
988
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
989
- });
990
-
991
- // -------------------------------------------------------------------------
992
- // US-002 — entity-probe failures on the getEntity path.
993
- //
994
- // Every one of these SKIPS the target; that control flow is deliberate and
995
- // unchanged. What differs is visibility: a routine "it's gone" answer stays
996
- // quiet, while a broken resolver is reported. Without that split, a
997
- // systematic resolver failure looks exactly like "joiners silently never get
998
- // manifest entries" — the bug class this module exists to prevent.
999
- // -------------------------------------------------------------------------
1000
-
1001
- interface CapturedDiagnostic {
1002
- event: string;
1003
- message: string;
1004
- err: unknown;
1005
- context: Record<string, unknown>;
1006
- }
1007
-
1008
- async function reconcileWithProbeError(
1009
- err: unknown,
1010
- ): Promise<{ diagnostics: CapturedDiagnostic[]; manifestPath: string }> {
1011
- hqRoot = makeHqRoot();
1012
- fs.mkdirSync(path.join(hqRoot, "companies", "acme"), { recursive: true });
1013
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
1014
-
1015
- const diagnostics: CapturedDiagnostic[] = [];
1016
- const result = await reconcileCompanyManifest({
1017
- hqRoot,
1018
- targets: [{ uid: "cmp_acme", slug: "acme" }],
1019
- completedCompanySlugs: new Set(["acme"]),
1020
- getEntity: () => Promise.reject(err),
1021
- reportDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
1022
- });
1023
-
1024
- // Invariant across every case: skipped, nothing written, file untouched.
1025
- expect(result).toEqual({ written: false, added: [], updated: [], skipped: [] });
1026
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
1027
- return { diagnostics, manifestPath };
1028
- }
1029
-
1030
- it("with getEntity: a broken resolver (TypeError) is skipped AND reported", async () => {
1031
- const { diagnostics } = await reconcileWithProbeError(
1032
- new TypeError("client.entity.get is not a function"),
1033
- );
1034
-
1035
- expect(diagnostics).toHaveLength(1);
1036
- expect(diagnostics[0]!.event).toBe("runner.manifest_reconcile.entity_probe_failed");
1037
- expect(diagnostics[0]!.context).toMatchObject({ slug: "acme", uid: "cmp_acme" });
1038
- expect(diagnostics[0]!.err).toBeInstanceOf(TypeError);
1039
- });
1040
-
1041
- it("with getEntity: an ordinary not-found is skipped SILENTLY", async () => {
1042
- const { diagnostics } = await reconcileWithProbeError(
1043
- new VaultNotFoundError("company deleted"),
1044
- );
1045
- expect(diagnostics).toEqual([]);
1046
- });
1047
-
1048
- it("with getEntity: a permission denial is skipped SILENTLY", async () => {
1049
- const { diagnostics } = await reconcileWithProbeError(
1050
- new VaultPermissionDeniedError(),
1051
- );
1052
- expect(diagnostics).toEqual([]);
1053
- });
1054
-
1055
- it("with getEntity: a duck-typed { statusCode } rejection is treated as a lookup answer, not a bug", async () => {
1056
- // buildFanoutPlan classifies this same call's failures by duck-typed
1057
- // statusCode, so this shape is one the codebase genuinely produces.
1058
- const { diagnostics } = await reconcileWithProbeError({ statusCode: 404 });
1059
- expect(diagnostics).toEqual([]);
1060
- });
1061
-
1062
- it("with getEntity: reporting is optional — omitting the sink never throws", async () => {
1063
- hqRoot = makeHqRoot();
1064
- fs.mkdirSync(path.join(hqRoot, "companies", "acme"), { recursive: true });
1065
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
1066
-
1067
- await expect(
1068
- reconcileCompanyManifest({
1069
- hqRoot,
1070
- targets: [{ uid: "cmp_acme", slug: "acme" }],
1071
- completedCompanySlugs: new Set(["acme"]),
1072
- getEntity: () => Promise.reject(new TypeError("boom")),
1073
- }),
1074
- ).resolves.toEqual({ written: false, added: [], updated: [], skipped: [] });
1075
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
1076
- });
1077
-
1078
- it("with getEntity: a suspended company is rejected — the A/B twin of the plan-carried case", async () => {
1079
- hqRoot = makeHqRoot();
1080
- fs.mkdirSync(path.join(hqRoot, "companies", "susp"), { recursive: true });
1081
- const manifestPath = writeManifest(hqRoot, EMPTY_MANIFEST_TEMPLATE);
1082
-
1083
- // Both paths must reach the SAME verdict on the same company. This is the
1084
- // pair that was empirically divergent before the guard was relocated.
1085
- expect(
1086
- await reconcileCompanyManifest({
1087
- hqRoot,
1088
- targets: [{ uid: "cmp_susp", slug: "susp" }],
1089
- completedCompanySlugs: new Set(["susp"]),
1090
- getEntity: (): Promise<EntityInfo> =>
1091
- Promise.resolve({
1092
- uid: "cmp_susp",
1093
- slug: "susp",
1094
- type: "company",
1095
- status: "suspended",
1096
- name: "Suspended Corp",
1097
- bucketName: "hq-vault-cmp-susp",
1098
- createdAt: "2026-01-01T00:00:00Z",
1099
- }),
1100
- }),
1101
- ).toEqual({ written: false, added: [], updated: [], skipped: [] });
1102
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
1103
-
1104
- // ...and the plan-carried path agrees, with no resolver at all.
1105
- expect(
1106
- await reconcileCompanyManifest({
1107
- hqRoot,
1108
- targets: [
1109
- {
1110
- uid: "cmp_susp",
1111
- slug: "susp",
1112
- name: "Suspended Corp",
1113
- bucketName: "hq-vault-cmp-susp",
1114
- entityType: "company",
1115
- entityStatus: "suspended",
1116
- },
1117
- ],
1118
- completedCompanySlugs: new Set(["susp"]),
1119
- }),
1120
- ).toEqual({ written: false, added: [], updated: [], skipped: [] });
1121
- expectUntouched(manifestPath, EMPTY_MANIFEST_TEMPLATE);
1122
- });
1123
- });