@indigoai-us/hq-cloud 6.15.0 → 6.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
@@ -1,518 +0,0 @@
1
- /**
2
- * Reconcile successfully pulled cloud companies into the local manifest.
3
- *
4
- * The personal-vault leg also carries `companies/manifest.yaml`, so this runs
5
- * only after the complete fanout has settled. That makes the locally
6
- * materialized cloud companies authoritative for their own manifest entries
7
- * without discarding entries the personal vault already had.
8
- *
9
- * Two layers live here on purpose:
10
- *
11
- * - `reconcileManifest(hqRoot, targets)` is the pure reconciler. It takes
12
- * already-resolved targets and does nothing but validate slugs, merge the
13
- * fields it owns, and (only when something would actually change) write the
14
- * manifest.
15
- * - `reconcileCompanyManifest(options)` is the sync-runner adapter. It decides
16
- * WHICH targets are eligible (personal mode, fanout completion, on-disk
17
- * materialization, and — only when an entity resolver is injected — live
18
- * entity resolution) and then delegates the mutation.
19
- *
20
- * The adapter keeps its name and options-object shape because `sync-runner`
21
- * injects it through `deps.reconcileManifest`. That injection seam is an
22
- * options-object callback with a different shape from the pure reconciler and
23
- * must not be conflated with it — renaming the adapter would break the seam.
24
- */
25
-
26
- import { randomUUID } from "node:crypto";
27
- import * as fs from "node:fs";
28
- import * as path from "node:path";
29
- import yaml from "js-yaml";
30
-
31
- import {
32
- VaultNotFoundError,
33
- VaultPermissionDeniedError,
34
- type EntityInfo,
35
- } from "./vault-client.js";
36
-
37
- export interface ManifestReconcileTarget {
38
- uid: string;
39
- slug: string;
40
- /**
41
- * Carried by the fanout plan, which already resolved the entity to build the
42
- * target. Present so the adapter can write a manifest entry with zero extra
43
- * API calls; still optional because a plan built from a degraded lookup keeps
44
- * the uid as its only identifier.
45
- */
46
- name?: string;
47
- bucketName?: string;
48
- /**
49
- * Entity type/status as captured AT PLAN TIME. These are the liveness
50
- * evidence for the no-`getEntity` path and are checked with exactly the same
51
- * predicate the `getEntity` path applies to a freshly fetched entity. Both
52
- * are optional in the type and REQUIRED in practice: a target that carries
53
- * neither is "unknown", and unknown is rejected.
54
- */
55
- entityType?: string;
56
- entityStatus?: string;
57
- personalMode?: boolean;
58
- }
59
-
60
- /**
61
- * Reported when the injected entity resolver fails in a way that is NOT an
62
- * ordinary "this entity is gone" lookup outcome. The target is skipped either
63
- * way; this exists so a systematic resolver failure cannot present as the
64
- * silent "joiners never get manifest entries" bug this module was written to
65
- * fix.
66
- */
67
- export interface ManifestReconcileDiagnostic {
68
- event: string;
69
- message: string;
70
- err: unknown;
71
- context: Record<string, unknown>;
72
- }
73
-
74
- export interface ManifestReconcileOptions {
75
- hqRoot: string;
76
- targets: readonly ManifestReconcileTarget[];
77
- completedCompanySlugs: ReadonlySet<string>;
78
- /**
79
- * OPTIONAL entity resolver.
80
- *
81
- * When supplied, every eligible target is re-verified against a freshly
82
- * fetched entity ({@link isLiveCompany}) before it may contribute an entry.
83
- *
84
- * When omitted — the sync-runner path — each eligible target's entry is
85
- * resolved from the plan-carried `uid`/`slug`/`name`/`bucketName`, costing
86
- * zero API calls. This does NOT discharge the liveness requirement: the same
87
- * type/status assertion still runs, against `entityType`/`entityStatus`
88
- * captured by `buildFanoutPlan` at the moment it fetched the entity (see
89
- * {@link isLiveTargetSnapshot}). The only thing given up is freshness — the
90
- * evidence is from earlier in the same run rather than from a second fetch.
91
- *
92
- * A target carrying no liveness fields is REJECTED. Absence is treated as
93
- * "unknown", never as "fine": the degraded lookup path in `buildFanoutPlan`
94
- * produces exactly that shape, and admitting it would let an unverified
95
- * entity into a vault-synced file.
96
- */
97
- getEntity?: (uid: string) => Promise<EntityInfo>;
98
- /**
99
- * Optional sink for non-fatal problems. Never affects control flow.
100
- */
101
- reportDiagnostic?: (diagnostic: ManifestReconcileDiagnostic) => void;
102
- }
103
-
104
- /**
105
- * A target that has already been resolved to the values the manifest should
106
- * carry. `name` and `bucketName` are optional: a company that lacks either is
107
- * still a real, routable company and must get its `cloud_uid` entry.
108
- */
109
- export interface ManifestReconcileEntryTarget {
110
- slug: string;
111
- uid: string;
112
- bucketName?: string;
113
- name?: string;
114
- }
115
-
116
- /**
117
- * `added` — slugs newly inserted into `companies:`.
118
- * `updated` — slugs whose pre-existing entry had a field changed.
119
- * `skipped` — slugs rejected outright (unsafe slug, non-record existing entry).
120
- * `written` — whether the manifest file was actually rewritten.
121
- */
122
- export interface ManifestReconcileResult {
123
- written: boolean;
124
- added: string[];
125
- updated: string[];
126
- skipped: string[];
127
- }
128
-
129
- interface ManifestCompany {
130
- name?: unknown;
131
- cloud_uid?: unknown;
132
- bucket_name?: unknown;
133
- [key: string]: unknown;
134
- }
135
-
136
- interface ManifestDocument {
137
- companies?: Record<string, ManifestCompany>;
138
- [key: string]: unknown;
139
- }
140
-
141
- /**
142
- * A PLAIN object, not merely "typeof object". js-yaml parses timestamp scalars
143
- * into `Date`, and a looser check let a `Date` masquerade as the company map (or
144
- * as an entry), which both defeated the non-record skip and silently destroyed
145
- * the value via object spread. Arrays, Date, RegExp, and Map are all rejected.
146
- */
147
- function isRecord(value: unknown): value is Record<string, unknown> {
148
- if (value === null || typeof value !== "object") return false;
149
- const proto = Object.getPrototypeOf(value) as unknown;
150
- return proto === Object.prototype || proto === null;
151
- }
152
-
153
- /**
154
- * Keys that would traverse or mutate the prototype chain rather than create an
155
- * own property. `__proto__` in particular resolves to `Object.prototype` on read
156
- * and invokes the prototype setter on write, so it never persists to YAML and
157
- * therefore reported drift on every single run — an unbounded rewrite loop on a
158
- * vault-synced file. None of these is ever a legitimate company directory name.
159
- */
160
- const UNSAFE_SLUG_KEYS = new Set(["__proto__", "constructor", "prototype"]);
161
-
162
- /** Own-property read; never falls through to the prototype chain. */
163
- function ownEntry(
164
- companies: Record<string, ManifestCompany>,
165
- slug: string,
166
- ): ManifestCompany | undefined {
167
- return Object.prototype.hasOwnProperty.call(companies, slug) ? companies[slug] : undefined;
168
- }
169
-
170
- /** Own-property write; never invokes an inherited setter. */
171
- function setEntry(
172
- companies: Record<string, ManifestCompany>,
173
- slug: string,
174
- value: ManifestCompany,
175
- ): void {
176
- Object.defineProperty(companies, slug, {
177
- value,
178
- enumerable: true,
179
- writable: true,
180
- configurable: true,
181
- });
182
- }
183
-
184
- /**
185
- * THE liveness rule. Both eligibility paths route through this single function
186
- * so they cannot drift: an entity qualifies for a manifest entry only if it is
187
- * an active company.
188
- *
189
- * Written to fail closed on `undefined`. That is the whole point — the
190
- * plan-carried path can legitimately have no idea what the entity was (the
191
- * degraded lookup branch in `buildFanoutPlan` records nothing), and "no idea"
192
- * must never read as "active company".
193
- */
194
- function isLiveCompanyDescriptor(
195
- type: string | undefined,
196
- status: string | undefined,
197
- ): boolean {
198
- return type === "company" && status === "active";
199
- }
200
-
201
- /**
202
- * A company is live when the entity still resolves to the uid we routed on and
203
- * is an active company. `name` and `bucketName` are deliberately NOT required:
204
- * demanding them dropped otherwise-valid companies from the manifest entirely,
205
- * which is exactly the joiner bug this module now guards against. Missing
206
- * fields are omitted from the written entry instead of disqualifying it.
207
- */
208
- function isLiveCompany(entity: EntityInfo, uid: string): boolean {
209
- return entity.uid === uid && isLiveCompanyDescriptor(entity.type, entity.status);
210
- }
211
-
212
- /**
213
- * The same rule applied to liveness captured at plan time instead of to a
214
- * freshly fetched entity. There is no uid re-check here because there is no
215
- * second observation to reconcile against — the snapshot came off the very
216
- * `entity.get(uid)` that produced this target.
217
- */
218
- function isLiveTargetSnapshot(target: ManifestReconcileTarget): boolean {
219
- return isLiveCompanyDescriptor(target.entityType, target.entityStatus);
220
- }
221
-
222
- /**
223
- * Is this entity-probe failure the routine kind?
224
- *
225
- * "The company is gone" (404) and "the caller can no longer see it" (403) are
226
- * the two ordinary reasons a target stops resolving, and both correctly mean
227
- * "skip, write nothing". Anything else — an auth failure, a 5xx, a network
228
- * fault, or a plain programming error like a TypeError — shares the same
229
- * control flow but is NOT routine, and swallowing it silently is how a broken
230
- * resolver disguises itself as "joiners never get manifest entries".
231
- *
232
- * The duck-typed `statusCode` arm matters: `buildFanoutPlan` already classifies
233
- * this same call's failures that way, so a bare `{ statusCode }` rejection is a
234
- * shape this codebase genuinely produces, not a hypothetical.
235
- */
236
- function isExpectedLookupFailure(err: unknown): boolean {
237
- if (err instanceof VaultNotFoundError) return true;
238
- if (err instanceof VaultPermissionDeniedError) return true;
239
- return (
240
- typeof err === "object" &&
241
- err !== null &&
242
- "statusCode" in err &&
243
- typeof (err as { statusCode?: unknown }).statusCode === "number"
244
- );
245
- }
246
-
247
- /** Non-empty string, or undefined. Keys with no value are never written. */
248
- function optionalField(value: string | undefined): string | undefined {
249
- return typeof value === "string" && value.length > 0 ? value : undefined;
250
- }
251
-
252
- /**
253
- * Pure path check — a slug must name a direct child of `companies/` and nothing
254
- * else. This deliberately does NOT require the directory to exist; the on-disk
255
- * stat check is an eligibility concern and lives in the adapter.
256
- */
257
- function isSafeCompanySlug(companiesDir: string, slug: string): boolean {
258
- if (slug.length === 0) return false;
259
- if (slug.includes("/") || slug.includes("\\")) return false;
260
- if (slug === "." || slug === "..") return false;
261
- if (UNSAFE_SLUG_KEYS.has(slug)) return false;
262
- const resolvedCompaniesDir = path.resolve(companiesDir);
263
- const companyDir = path.resolve(resolvedCompaniesDir, slug);
264
- return path.dirname(companyDir) === resolvedCompaniesDir;
265
- }
266
-
267
- function hasCompanyDirectory(companiesDir: string, slug: string): boolean {
268
- if (!isSafeCompanySlug(companiesDir, slug)) return false;
269
- try {
270
- return fs.statSync(path.resolve(companiesDir, slug)).isDirectory();
271
- } catch {
272
- return false;
273
- }
274
- }
275
-
276
- /**
277
- * Read the manifest, or return null when there is nothing safe to merge into.
278
- * A missing manifest is a no-op, NOT an invitation to create one: this module
279
- * only ever restores entries into a manifest that already exists.
280
- */
281
- function loadManifest(manifestPath: string): ManifestDocument | null {
282
- try {
283
- const parsed = yaml.load(fs.readFileSync(manifestPath, "utf8"));
284
- return isRecord(parsed) ? (parsed as ManifestDocument) : null;
285
- } catch {
286
- return null;
287
- }
288
- }
289
-
290
- function serializeManifest(document: ManifestDocument): string {
291
- return yaml.dump(document, { lineWidth: -1 });
292
- }
293
-
294
- /**
295
- * Temp-file + rename. The temp file is created in the SAME directory as the
296
- * target so the rename is a true atomic same-filesystem operation.
297
- *
298
- * The `finally` is not cosmetic: without it, a failed write or rename stranded a
299
- * uniquely-named `.manifest-*.yaml` inside `companies/`, which rides the personal
300
- * vault to every machine and accumulates one file per failed run.
301
- *
302
- * A write failure deliberately PROPAGATES rather than being swallowed into
303
- * `written: false`. The caller at `sync-runner.ts` already wraps this in a
304
- * try/catch that emits a `runner.manifest_reconcile.failed` diagnostic; reporting
305
- * a real I/O failure as a clean no-op would hide it. The AC's "never throws"
306
- * clause covers manifest READ problems (missing/unreadable/unparseable), all of
307
- * which are handled as no-ops in `loadManifest`.
308
- */
309
- function writeManifestAtomically(manifestPath: string, serialized: string): void {
310
- const temporaryPath = path.join(
311
- path.dirname(manifestPath),
312
- `.manifest-${process.pid}-${randomUUID()}.yaml`,
313
- );
314
- try {
315
- fs.writeFileSync(temporaryPath, serialized, "utf8");
316
- fs.renameSync(temporaryPath, manifestPath);
317
- } finally {
318
- // After a successful rename the temp path is already gone, so this is a
319
- // no-op on the happy path and a cleanup on every failure path.
320
- try {
321
- fs.rmSync(temporaryPath, { force: true });
322
- } catch {
323
- // Best effort — never mask the original error.
324
- }
325
- }
326
- }
327
-
328
- function emptyResult(skipped: string[] = []): ManifestReconcileResult {
329
- return { written: false, added: [], updated: [], skipped };
330
- }
331
-
332
- /**
333
- * Merge resolved targets into `companies/manifest.yaml` and report what moved.
334
- *
335
- * Only the three fields this module owns are considered: `cloud_uid` (always
336
- * written — it is the routing key), plus `name` and `bucket_name` when the
337
- * target carries them. Omitting a field means "do not set this key"; it never
338
- * means "delete it", so a manifest value already on disk survives a target that
339
- * has nothing to say about it.
340
- *
341
- * The skip-if-unchanged comparison is load-bearing rather than an optimization.
342
- * `yaml.dump` cannot round-trip comments, so an unconditional rewrite strips the
343
- * manifest header — and because `manifest.yaml` rides the personal vault, that
344
- * rewrite propagates to every machine and drives a recurring sync conflict loop.
345
- * When nothing would change, the file is left byte-for-byte identical and its
346
- * mtime untouched.
347
- */
348
- export function reconcileManifest(
349
- hqRoot: string,
350
- targets: readonly ManifestReconcileEntryTarget[],
351
- ): ManifestReconcileResult {
352
- const companiesDir = path.join(hqRoot, "companies");
353
-
354
- const skipped: string[] = [];
355
- // Dedupe by slug, last-wins. Two targets for one slug used to flip the change
356
- // flag on and then back off again, leaving it stuck true while the resulting
357
- // content was identical — a write (and an mtime bump) on every single run.
358
- const safeBySlug = new Map<string, ManifestReconcileEntryTarget>();
359
- for (const target of targets) {
360
- if (!isSafeCompanySlug(companiesDir, target.slug)) {
361
- skipped.push(target.slug);
362
- continue;
363
- }
364
- safeBySlug.set(target.slug, target);
365
- }
366
-
367
- if (safeBySlug.size === 0) return emptyResult(skipped);
368
-
369
- const manifestPath = path.join(companiesDir, "manifest.yaml");
370
- const document = loadManifest(manifestPath);
371
- if (!document) return emptyResult(skipped);
372
- // Pre-image, in the SAME serialization the writer uses. Comparing against the
373
- // raw file bytes would be wrong: raw text carries comments and formatting that
374
- // `yaml.dump` cannot reproduce, so every commented manifest would look
375
- // "changed" and rewrite on every single run.
376
- const before = serializeManifest(document);
377
-
378
- let companies: Record<string, ManifestCompany>;
379
- if (document.companies === undefined || document.companies === null) {
380
- // A missing `companies:` key and a bare `companies:` (which YAML parses as
381
- // null) mean the same thing — an empty map. Treating them differently would
382
- // silently deny a joiner their entry on one of the two shapes.
383
- companies = {};
384
- document.companies = companies;
385
- } else if (isRecord(document.companies)) {
386
- companies = document.companies as Record<string, ManifestCompany>;
387
- } else {
388
- // Any other shape (array, scalar, timestamp, string) is malformed user
389
- // state; refusing to replace it is safer than losing a manifest that cannot
390
- // be interpreted unambiguously.
391
- return emptyResult(skipped);
392
- }
393
-
394
- // Changes are STAGED rather than applied in place, so the pre-image can be
395
- // serialized for comparison and so a pure no-op costs no serialization at all.
396
- const pending: Array<{ slug: string; entry: ManifestCompany; isNew: boolean }> = [];
397
-
398
- for (const target of safeBySlug.values()) {
399
- const existing = ownEntry(companies, target.slug);
400
- if (existing !== undefined && !isRecord(existing)) {
401
- skipped.push(target.slug);
402
- continue;
403
- }
404
-
405
- const desired: ManifestCompany = { cloud_uid: target.uid };
406
- const name = optionalField(target.name);
407
- if (name !== undefined) desired.name = name;
408
- const bucketName = optionalField(target.bucketName);
409
- if (bucketName !== undefined) desired.bucket_name = bucketName;
410
-
411
- if (existing === undefined) {
412
- pending.push({ slug: target.slug, entry: desired, isNew: true });
413
- continue;
414
- }
415
-
416
- // Compare ONLY the fields we would write. Everything else on the entry is
417
- // someone else's state and never participates in the change decision.
418
- const drifted = Object.keys(desired).some((key) => existing[key] !== desired[key]);
419
- if (!drifted) continue;
420
-
421
- pending.push({ slug: target.slug, entry: { ...existing, ...desired }, isNew: false });
422
- }
423
-
424
- if (pending.length === 0) return emptyResult(skipped);
425
-
426
- // Final-state guard. The write decision rests on the document itself, not on
427
- // a flag the merge loop could have toggled: apply the staged changes, then
428
- // write only if the serialized result genuinely differs from the `before`
429
- // snapshot taken at load time. This is the backstop that stops any
430
- // order-dependent bug — duplicate targets cancelling out, a sticky flag — from
431
- // ever reaching a vault-synced file.
432
- for (const change of pending) setEntry(companies, change.slug, change.entry);
433
- const serialized = serializeManifest(document);
434
- if (serialized === before) return emptyResult(skipped);
435
-
436
- writeManifestAtomically(manifestPath, serialized);
437
- return {
438
- written: true,
439
- added: pending.filter((change) => change.isNew).map((change) => change.slug),
440
- updated: pending.filter((change) => !change.isNew).map((change) => change.slug),
441
- skipped,
442
- };
443
- }
444
-
445
- /**
446
- * Atomically merge live, successfully pulled company targets into
447
- * `companies/manifest.yaml`. Targets that are personal, incomplete, not
448
- * materialized on disk, or not a verifiably active company are ignored.
449
- *
450
- * Every one of those filters applies on BOTH paths. The paths differ only in
451
- * where the liveness evidence comes from: a fresh `getEntity` fetch when one is
452
- * injected, or the type/status the fanout plan captured when it is not.
453
- */
454
- export async function reconcileCompanyManifest(
455
- options: ManifestReconcileOptions,
456
- ): Promise<ManifestReconcileResult> {
457
- const companiesDir = path.join(options.hqRoot, "companies");
458
- const resolvedTargets: ManifestReconcileEntryTarget[] = [];
459
- const getEntity = options.getEntity;
460
-
461
- for (const target of options.targets) {
462
- if (
463
- target.personalMode === true ||
464
- !options.completedCompanySlugs.has(target.slug) ||
465
- !hasCompanyDirectory(companiesDir, target.slug)
466
- ) {
467
- continue;
468
- }
469
-
470
- if (getEntity === undefined) {
471
- // Plan-carried path. The liveness rule is NOT relaxed here — it is
472
- // applied to the type/status the plan captured off `entity.get`. A target
473
- // with no such evidence fails closed and never reaches the manifest.
474
- if (!isLiveTargetSnapshot(target)) continue;
475
- resolvedTargets.push({
476
- slug: target.slug,
477
- uid: target.uid,
478
- ...(target.name === undefined ? {} : { name: target.name }),
479
- ...(target.bucketName === undefined ? {} : { bucketName: target.bucketName }),
480
- });
481
- continue;
482
- }
483
-
484
- try {
485
- const entity = await getEntity(target.uid);
486
- if (isLiveCompany(entity, target.uid)) {
487
- resolvedTargets.push({
488
- slug: target.slug,
489
- uid: entity.uid,
490
- ...(entity.name === undefined ? {} : { name: entity.name }),
491
- ...(entity.bucketName === undefined ? {} : { bucketName: entity.bucketName }),
492
- });
493
- }
494
- } catch (err) {
495
- // The target is not presently a live, resolvable company. Never mint a
496
- // manifest entry from stale fanout data — so the target is skipped
497
- // regardless of WHY the probe failed.
498
- //
499
- // But "entity is gone" and "the resolver is broken" are different
500
- // stories with identical control flow, and only the first is routine.
501
- // Staying silent on the second is how a systematic resolver failure
502
- // disguises itself as "joiners never get manifest entries" — the exact
503
- // bug class this module exists to prevent.
504
- if (!isExpectedLookupFailure(err)) {
505
- options.reportDiagnostic?.({
506
- event: "runner.manifest_reconcile.entity_probe_failed",
507
- message: "unexpected error resolving company entity; target skipped",
508
- err,
509
- context: { slug: target.slug, uid: target.uid },
510
- });
511
- }
512
- }
513
- }
514
-
515
- if (resolvedTargets.length === 0) return emptyResult();
516
-
517
- return reconcileManifest(options.hqRoot, resolvedTargets);
518
- }