@indigoai-us/hq-cloud 6.18.44-alpha.9 → 6.18.44

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 (1068) hide show
  1. package/dist/bin/sync-runner-planning.d.ts +12 -0
  2. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-planning.js +18 -0
  4. package/dist/bin/sync-runner-planning.js.map +1 -1
  5. package/dist/bin/sync-runner-watch-loop.d.ts +18 -0
  6. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  7. package/dist/bin/sync-runner-watch-loop.js +75 -6
  8. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  9. package/dist/bin/sync-runner.d.ts +22 -1
  10. package/dist/bin/sync-runner.d.ts.map +1 -1
  11. package/dist/bin/sync-runner.js +33 -4
  12. package/dist/bin/sync-runner.js.map +1 -1
  13. package/dist/cli/conflict.d.ts.map +1 -1
  14. package/dist/cli/conflict.js +2 -1
  15. package/dist/cli/conflict.js.map +1 -1
  16. package/dist/cli/rescue-core.d.ts +6 -0
  17. package/dist/cli/rescue-core.d.ts.map +1 -1
  18. package/dist/cli/rescue-core.js +10 -1
  19. package/dist/cli/rescue-core.js.map +1 -1
  20. package/dist/cli/rescue-windows-test-harness.d.ts +19 -0
  21. package/dist/cli/rescue-windows-test-harness.d.ts.map +1 -0
  22. package/dist/cli/rescue-windows-test-harness.js +443 -0
  23. package/dist/cli/rescue-windows-test-harness.js.map +1 -0
  24. package/dist/company-resolver.d.ts +22 -0
  25. package/dist/company-resolver.d.ts.map +1 -1
  26. package/dist/company-resolver.js +1 -1
  27. package/dist/company-resolver.js.map +1 -1
  28. package/dist/ignore.d.ts +4 -1
  29. package/dist/ignore.d.ts.map +1 -1
  30. package/dist/ignore.js +10 -12
  31. package/dist/ignore.js.map +1 -1
  32. package/dist/journal-snapshot-decoder.js +15 -2
  33. package/dist/journal-snapshot-decoder.js.map +1 -1
  34. package/dist/sync/durable-apply.d.ts +2 -0
  35. package/dist/sync/durable-apply.d.ts.map +1 -1
  36. package/dist/sync/durable-apply.js +9 -12
  37. package/dist/sync/durable-apply.js.map +1 -1
  38. package/dist/sync/fsync-compat.d.ts +13 -0
  39. package/dist/sync/fsync-compat.d.ts.map +1 -0
  40. package/dist/sync/fsync-compat.js +33 -0
  41. package/dist/sync/fsync-compat.js.map +1 -0
  42. package/dist/sync/multipart-uploader.d.ts.map +1 -1
  43. package/dist/sync/multipart-uploader.js +2 -1
  44. package/dist/sync/multipart-uploader.js.map +1 -1
  45. package/dist/sync/scope-inventory-client.d.ts.map +1 -1
  46. package/dist/sync/scope-inventory-client.js +7 -2
  47. package/dist/sync/scope-inventory-client.js.map +1 -1
  48. package/dist/telemetry.d.ts +31 -0
  49. package/dist/telemetry.d.ts.map +1 -1
  50. package/dist/telemetry.js +177 -17
  51. package/dist/telemetry.js.map +1 -1
  52. package/dist/usage-company-fallback.d.ts +104 -0
  53. package/dist/usage-company-fallback.d.ts.map +1 -0
  54. package/dist/usage-company-fallback.js +599 -0
  55. package/dist/usage-company-fallback.js.map +1 -0
  56. package/dist/watch-owner-lease.d.ts +3 -0
  57. package/dist/watch-owner-lease.d.ts.map +1 -1
  58. package/dist/watch-owner-lease.js +14 -3
  59. package/dist/watch-owner-lease.js.map +1 -1
  60. package/package.json +2 -2
  61. package/dist/active-company.test.d.ts +0 -2
  62. package/dist/active-company.test.d.ts.map +0 -1
  63. package/dist/active-company.test.js +0 -149
  64. package/dist/active-company.test.js.map +0 -1
  65. package/dist/agent-codex-instructions.test.d.ts +0 -2
  66. package/dist/agent-codex-instructions.test.d.ts.map +0 -1
  67. package/dist/agent-codex-instructions.test.js +0 -271
  68. package/dist/agent-codex-instructions.test.js.map +0 -1
  69. package/dist/backup-prune.test.d.ts +0 -2
  70. package/dist/backup-prune.test.d.ts.map +0 -1
  71. package/dist/backup-prune.test.js +0 -154
  72. package/dist/backup-prune.test.js.map +0 -1
  73. package/dist/bandwidth.test.d.ts +0 -2
  74. package/dist/bandwidth.test.d.ts.map +0 -1
  75. package/dist/bandwidth.test.js +0 -342
  76. package/dist/bandwidth.test.js.map +0 -1
  77. package/dist/best-effort-error.test.d.ts +0 -2
  78. package/dist/best-effort-error.test.d.ts.map +0 -1
  79. package/dist/best-effort-error.test.js +0 -54
  80. package/dist/best-effort-error.test.js.map +0 -1
  81. package/dist/bin/area-ledger-migration.test.d.ts +0 -2
  82. package/dist/bin/area-ledger-migration.test.d.ts.map +0 -1
  83. package/dist/bin/area-ledger-migration.test.js +0 -410
  84. package/dist/bin/area-ledger-migration.test.js.map +0 -1
  85. package/dist/bin/backup-prune-runner.test.d.ts +0 -2
  86. package/dist/bin/backup-prune-runner.test.d.ts.map +0 -1
  87. package/dist/bin/backup-prune-runner.test.js +0 -22
  88. package/dist/bin/backup-prune-runner.test.js.map +0 -1
  89. package/dist/bin/journal-repair.test.d.ts +0 -2
  90. package/dist/bin/journal-repair.test.d.ts.map +0 -1
  91. package/dist/bin/journal-repair.test.js +0 -53
  92. package/dist/bin/journal-repair.test.js.map +0 -1
  93. package/dist/bin/layout-compatibility.test.d.ts +0 -2
  94. package/dist/bin/layout-compatibility.test.d.ts.map +0 -1
  95. package/dist/bin/layout-compatibility.test.js +0 -87
  96. package/dist/bin/layout-compatibility.test.js.map +0 -1
  97. package/dist/bin/outpost-heartbeat.test.d.ts +0 -7
  98. package/dist/bin/outpost-heartbeat.test.d.ts.map +0 -1
  99. package/dist/bin/outpost-heartbeat.test.js +0 -84
  100. package/dist/bin/outpost-heartbeat.test.js.map +0 -1
  101. package/dist/bin/outpost-session-host.test.d.ts +0 -7
  102. package/dist/bin/outpost-session-host.test.d.ts.map +0 -1
  103. package/dist/bin/outpost-session-host.test.js +0 -52
  104. package/dist/bin/outpost-session-host.test.js.map +0 -1
  105. package/dist/bin/sync-mutation.test.d.ts +0 -2
  106. package/dist/bin/sync-mutation.test.d.ts.map +0 -1
  107. package/dist/bin/sync-mutation.test.js +0 -64
  108. package/dist/bin/sync-mutation.test.js.map +0 -1
  109. package/dist/bin/sync-runner-args.test.d.ts +0 -2
  110. package/dist/bin/sync-runner-args.test.d.ts.map +0 -1
  111. package/dist/bin/sync-runner-args.test.js +0 -82
  112. package/dist/bin/sync-runner-args.test.js.map +0 -1
  113. package/dist/bin/sync-runner-auth-wait.test.d.ts +0 -2
  114. package/dist/bin/sync-runner-auth-wait.test.d.ts.map +0 -1
  115. package/dist/bin/sync-runner-auth-wait.test.js +0 -406
  116. package/dist/bin/sync-runner-auth-wait.test.js.map +0 -1
  117. package/dist/bin/sync-runner-backoff.regression.test.d.ts +0 -29
  118. package/dist/bin/sync-runner-backoff.regression.test.d.ts.map +0 -1
  119. package/dist/bin/sync-runner-backoff.regression.test.js +0 -224
  120. package/dist/bin/sync-runner-backoff.regression.test.js.map +0 -1
  121. package/dist/bin/sync-runner-company.test.d.ts +0 -2
  122. package/dist/bin/sync-runner-company.test.d.ts.map +0 -1
  123. package/dist/bin/sync-runner-company.test.js +0 -2439
  124. package/dist/bin/sync-runner-company.test.js.map +0 -1
  125. package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.d.ts +0 -2
  126. package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.d.ts.map +0 -1
  127. package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.js +0 -648
  128. package/dist/bin/sync-runner-delete-snapshot-mixed.bench.test.js.map +0 -1
  129. package/dist/bin/sync-runner-delete-snapshot.bench.test.d.ts +0 -2
  130. package/dist/bin/sync-runner-delete-snapshot.bench.test.d.ts.map +0 -1
  131. package/dist/bin/sync-runner-delete-snapshot.bench.test.js +0 -329
  132. package/dist/bin/sync-runner-delete-snapshot.bench.test.js.map +0 -1
  133. package/dist/bin/sync-runner-events.test.d.ts +0 -2
  134. package/dist/bin/sync-runner-events.test.d.ts.map +0 -1
  135. package/dist/bin/sync-runner-events.test.js +0 -364
  136. package/dist/bin/sync-runner-events.test.js.map +0 -1
  137. package/dist/bin/sync-runner-heap-telemetry.test.d.ts +0 -2
  138. package/dist/bin/sync-runner-heap-telemetry.test.d.ts.map +0 -1
  139. package/dist/bin/sync-runner-heap-telemetry.test.js +0 -1966
  140. package/dist/bin/sync-runner-heap-telemetry.test.js.map +0 -1
  141. package/dist/bin/sync-runner-manifest.test.d.ts +0 -12
  142. package/dist/bin/sync-runner-manifest.test.d.ts.map +0 -1
  143. package/dist/bin/sync-runner-manifest.test.js +0 -921
  144. package/dist/bin/sync-runner-manifest.test.js.map +0 -1
  145. package/dist/bin/sync-runner-planning.test.d.ts +0 -2
  146. package/dist/bin/sync-runner-planning.test.d.ts.map +0 -1
  147. package/dist/bin/sync-runner-planning.test.js +0 -448
  148. package/dist/bin/sync-runner-planning.test.js.map +0 -1
  149. package/dist/bin/sync-runner-post-sync-flags.test.d.ts +0 -2
  150. package/dist/bin/sync-runner-post-sync-flags.test.d.ts.map +0 -1
  151. package/dist/bin/sync-runner-post-sync-flags.test.js +0 -64
  152. package/dist/bin/sync-runner-post-sync-flags.test.js.map +0 -1
  153. package/dist/bin/sync-runner-publish-expansion.test.d.ts +0 -2
  154. package/dist/bin/sync-runner-publish-expansion.test.d.ts.map +0 -1
  155. package/dist/bin/sync-runner-publish-expansion.test.js +0 -54
  156. package/dist/bin/sync-runner-publish-expansion.test.js.map +0 -1
  157. package/dist/bin/sync-runner-receiver-unresolved-company.test.d.ts +0 -2
  158. package/dist/bin/sync-runner-receiver-unresolved-company.test.d.ts.map +0 -1
  159. package/dist/bin/sync-runner-receiver-unresolved-company.test.js +0 -379
  160. package/dist/bin/sync-runner-receiver-unresolved-company.test.js.map +0 -1
  161. package/dist/bin/sync-runner-rollup.test.d.ts +0 -2
  162. package/dist/bin/sync-runner-rollup.test.d.ts.map +0 -1
  163. package/dist/bin/sync-runner-rollup.test.js +0 -24
  164. package/dist/bin/sync-runner-rollup.test.js.map +0 -1
  165. package/dist/bin/sync-runner-watch-loop.test.d.ts +0 -2
  166. package/dist/bin/sync-runner-watch-loop.test.d.ts.map +0 -1
  167. package/dist/bin/sync-runner-watch-loop.test.js +0 -734
  168. package/dist/bin/sync-runner-watch-loop.test.js.map +0 -1
  169. package/dist/bin/sync-runner-watch-routes.test.d.ts +0 -2
  170. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +0 -1
  171. package/dist/bin/sync-runner-watch-routes.test.js +0 -181
  172. package/dist/bin/sync-runner-watch-routes.test.js.map +0 -1
  173. package/dist/bin/sync-runner.test.d.ts +0 -10
  174. package/dist/bin/sync-runner.test.d.ts.map +0 -1
  175. package/dist/bin/sync-runner.test.js +0 -18984
  176. package/dist/bin/sync-runner.test.js.map +0 -1
  177. package/dist/cli/case-alias.test.d.ts +0 -2
  178. package/dist/cli/case-alias.test.d.ts.map +0 -1
  179. package/dist/cli/case-alias.test.js +0 -96
  180. package/dist/cli/case-alias.test.js.map +0 -1
  181. package/dist/cli/conflict-recovery.test.d.ts +0 -2
  182. package/dist/cli/conflict-recovery.test.d.ts.map +0 -1
  183. package/dist/cli/conflict-recovery.test.js +0 -201
  184. package/dist/cli/conflict-recovery.test.js.map +0 -1
  185. package/dist/cli/conflict-winner.test.d.ts +0 -7
  186. package/dist/cli/conflict-winner.test.d.ts.map +0 -1
  187. package/dist/cli/conflict-winner.test.js +0 -98
  188. package/dist/cli/conflict-winner.test.js.map +0 -1
  189. package/dist/cli/doctor.test.d.ts +0 -13
  190. package/dist/cli/doctor.test.d.ts.map +0 -1
  191. package/dist/cli/doctor.test.js +0 -607
  192. package/dist/cli/doctor.test.js.map +0 -1
  193. package/dist/cli/invite.test.d.ts +0 -5
  194. package/dist/cli/invite.test.d.ts.map +0 -1
  195. package/dist/cli/invite.test.js +0 -178
  196. package/dist/cli/invite.test.js.map +0 -1
  197. package/dist/cli/onepass-pull-planner-regressions.test.d.ts +0 -2
  198. package/dist/cli/onepass-pull-planner-regressions.test.d.ts.map +0 -1
  199. package/dist/cli/onepass-pull-planner-regressions.test.js +0 -300
  200. package/dist/cli/onepass-pull-planner-regressions.test.js.map +0 -1
  201. package/dist/cli/onepass-pull-planner.test.d.ts +0 -2
  202. package/dist/cli/onepass-pull-planner.test.d.ts.map +0 -1
  203. package/dist/cli/onepass-pull-planner.test.js +0 -266
  204. package/dist/cli/onepass-pull-planner.test.js.map +0 -1
  205. package/dist/cli/push-scan-exclusions.test.d.ts +0 -2
  206. package/dist/cli/push-scan-exclusions.test.d.ts.map +0 -1
  207. package/dist/cli/push-scan-exclusions.test.js +0 -99
  208. package/dist/cli/push-scan-exclusions.test.js.map +0 -1
  209. package/dist/cli/reindex-knowledge.test.d.ts +0 -12
  210. package/dist/cli/reindex-knowledge.test.d.ts.map +0 -1
  211. package/dist/cli/reindex-knowledge.test.js +0 -260
  212. package/dist/cli/reindex-knowledge.test.js.map +0 -1
  213. package/dist/cli/reindex.test.d.ts +0 -11
  214. package/dist/cli/reindex.test.d.ts.map +0 -1
  215. package/dist/cli/reindex.test.js +0 -811
  216. package/dist/cli/reindex.test.js.map +0 -1
  217. package/dist/cli/rescue-breaker.test.d.ts +0 -2
  218. package/dist/cli/rescue-breaker.test.d.ts.map +0 -1
  219. package/dist/cli/rescue-breaker.test.js +0 -812
  220. package/dist/cli/rescue-breaker.test.js.map +0 -1
  221. package/dist/cli/rescue-classify-ordering.test.d.ts +0 -2
  222. package/dist/cli/rescue-classify-ordering.test.d.ts.map +0 -1
  223. package/dist/cli/rescue-classify-ordering.test.js +0 -500
  224. package/dist/cli/rescue-classify-ordering.test.js.map +0 -1
  225. package/dist/cli/rescue-clone-diagnostics.test.d.ts +0 -2
  226. package/dist/cli/rescue-clone-diagnostics.test.d.ts.map +0 -1
  227. package/dist/cli/rescue-clone-diagnostics.test.js +0 -101
  228. package/dist/cli/rescue-clone-diagnostics.test.js.map +0 -1
  229. package/dist/cli/rescue-clone-network-retry.test.d.ts +0 -2
  230. package/dist/cli/rescue-clone-network-retry.test.d.ts.map +0 -1
  231. package/dist/cli/rescue-clone-network-retry.test.js +0 -415
  232. package/dist/cli/rescue-clone-network-retry.test.js.map +0 -1
  233. package/dist/cli/rescue-core-stamp.test.d.ts +0 -2
  234. package/dist/cli/rescue-core-stamp.test.d.ts.map +0 -1
  235. package/dist/cli/rescue-core-stamp.test.js +0 -364
  236. package/dist/cli/rescue-core-stamp.test.js.map +0 -1
  237. package/dist/cli/rescue-drift-reconcile.test.d.ts +0 -2
  238. package/dist/cli/rescue-drift-reconcile.test.d.ts.map +0 -1
  239. package/dist/cli/rescue-drift-reconcile.test.js +0 -162
  240. package/dist/cli/rescue-drift-reconcile.test.js.map +0 -1
  241. package/dist/cli/rescue-drop-dir-symlink.test.d.ts +0 -2
  242. package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +0 -1
  243. package/dist/cli/rescue-drop-dir-symlink.test.js +0 -255
  244. package/dist/cli/rescue-drop-dir-symlink.test.js.map +0 -1
  245. package/dist/cli/rescue-exec-bit-preserve.test.d.ts +0 -2
  246. package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +0 -1
  247. package/dist/cli/rescue-exec-bit-preserve.test.js +0 -177
  248. package/dist/cli/rescue-exec-bit-preserve.test.js.map +0 -1
  249. package/dist/cli/rescue-hq-root-guard.test.d.ts +0 -2
  250. package/dist/cli/rescue-hq-root-guard.test.d.ts.map +0 -1
  251. package/dist/cli/rescue-hq-root-guard.test.js +0 -210
  252. package/dist/cli/rescue-hq-root-guard.test.js.map +0 -1
  253. package/dist/cli/rescue-journal-reconcile.test.d.ts +0 -2
  254. package/dist/cli/rescue-journal-reconcile.test.d.ts.map +0 -1
  255. package/dist/cli/rescue-journal-reconcile.test.js +0 -186
  256. package/dist/cli/rescue-journal-reconcile.test.js.map +0 -1
  257. package/dist/cli/rescue-keep-local-ignore.test.d.ts +0 -2
  258. package/dist/cli/rescue-keep-local-ignore.test.d.ts.map +0 -1
  259. package/dist/cli/rescue-keep-local-ignore.test.js +0 -315
  260. package/dist/cli/rescue-keep-local-ignore.test.js.map +0 -1
  261. package/dist/cli/rescue-move.test.d.ts +0 -2
  262. package/dist/cli/rescue-move.test.d.ts.map +0 -1
  263. package/dist/cli/rescue-move.test.js +0 -166
  264. package/dist/cli/rescue-move.test.js.map +0 -1
  265. package/dist/cli/rescue-mtime-preserve.test.d.ts +0 -2
  266. package/dist/cli/rescue-mtime-preserve.test.d.ts.map +0 -1
  267. package/dist/cli/rescue-mtime-preserve.test.js +0 -186
  268. package/dist/cli/rescue-mtime-preserve.test.js.map +0 -1
  269. package/dist/cli/rescue-preserve-copy-symlink.test.d.ts +0 -2
  270. package/dist/cli/rescue-preserve-copy-symlink.test.d.ts.map +0 -1
  271. package/dist/cli/rescue-preserve-copy-symlink.test.js +0 -483
  272. package/dist/cli/rescue-preserve-copy-symlink.test.js.map +0 -1
  273. package/dist/cli/rescue-realpath.test.d.ts +0 -2
  274. package/dist/cli/rescue-realpath.test.d.ts.map +0 -1
  275. package/dist/cli/rescue-realpath.test.js +0 -109
  276. package/dist/cli/rescue-realpath.test.js.map +0 -1
  277. package/dist/cli/rescue-restore-flag.test.d.ts +0 -2
  278. package/dist/cli/rescue-restore-flag.test.d.ts.map +0 -1
  279. package/dist/cli/rescue-restore-flag.test.js +0 -82
  280. package/dist/cli/rescue-restore-flag.test.js.map +0 -1
  281. package/dist/cli/rescue-rsync-preflight.test.d.ts +0 -2
  282. package/dist/cli/rescue-rsync-preflight.test.d.ts.map +0 -1
  283. package/dist/cli/rescue-rsync-preflight.test.js +0 -579
  284. package/dist/cli/rescue-rsync-preflight.test.js.map +0 -1
  285. package/dist/cli/rescue-rsync-status.test.d.ts +0 -2
  286. package/dist/cli/rescue-rsync-status.test.d.ts.map +0 -1
  287. package/dist/cli/rescue-rsync-status.test.js +0 -406
  288. package/dist/cli/rescue-rsync-status.test.js.map +0 -1
  289. package/dist/cli/rescue-settings-reconcile.test.d.ts +0 -2
  290. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +0 -1
  291. package/dist/cli/rescue-settings-reconcile.test.js +0 -610
  292. package/dist/cli/rescue-settings-reconcile.test.js.map +0 -1
  293. package/dist/cli/rescue-snapshot-copy.test.d.ts +0 -2
  294. package/dist/cli/rescue-snapshot-copy.test.d.ts.map +0 -1
  295. package/dist/cli/rescue-snapshot-copy.test.js +0 -790
  296. package/dist/cli/rescue-snapshot-copy.test.js.map +0 -1
  297. package/dist/cli/rescue-snapshot-recovery.test.d.ts +0 -2
  298. package/dist/cli/rescue-snapshot-recovery.test.d.ts.map +0 -1
  299. package/dist/cli/rescue-snapshot-recovery.test.js +0 -265
  300. package/dist/cli/rescue-snapshot-recovery.test.js.map +0 -1
  301. package/dist/cli/rescue-snapshot.test.d.ts +0 -2
  302. package/dist/cli/rescue-snapshot.test.d.ts.map +0 -1
  303. package/dist/cli/rescue-snapshot.test.js +0 -46
  304. package/dist/cli/rescue-snapshot.test.js.map +0 -1
  305. package/dist/cli/rescue-source-switch-floor.test.d.ts +0 -2
  306. package/dist/cli/rescue-source-switch-floor.test.d.ts.map +0 -1
  307. package/dist/cli/rescue-source-switch-floor.test.js +0 -247
  308. package/dist/cli/rescue-source-switch-floor.test.js.map +0 -1
  309. package/dist/cli/rescue-unchanged-delete.test.d.ts +0 -2
  310. package/dist/cli/rescue-unchanged-delete.test.d.ts.map +0 -1
  311. package/dist/cli/rescue-unchanged-delete.test.js +0 -305
  312. package/dist/cli/rescue-unchanged-delete.test.js.map +0 -1
  313. package/dist/cli/rescue.reindex.test.d.ts +0 -2
  314. package/dist/cli/rescue.reindex.test.d.ts.map +0 -1
  315. package/dist/cli/rescue.reindex.test.js +0 -72
  316. package/dist/cli/rescue.reindex.test.js.map +0 -1
  317. package/dist/cli/rescue.test.d.ts +0 -2
  318. package/dist/cli/rescue.test.d.ts.map +0 -1
  319. package/dist/cli/rescue.test.js +0 -127
  320. package/dist/cli/rescue.test.js.map +0 -1
  321. package/dist/cli/share-plan-limit-presign.test.d.ts +0 -2
  322. package/dist/cli/share-plan-limit-presign.test.d.ts.map +0 -1
  323. package/dist/cli/share-plan-limit-presign.test.js +0 -271
  324. package/dist/cli/share-plan-limit-presign.test.js.map +0 -1
  325. package/dist/cli/share-presign-transport.test.d.ts +0 -22
  326. package/dist/cli/share-presign-transport.test.d.ts.map +0 -1
  327. package/dist/cli/share-presign-transport.test.js +0 -388
  328. package/dist/cli/share-presign-transport.test.js.map +0 -1
  329. package/dist/cli/share.test.d.ts +0 -5
  330. package/dist/cli/share.test.d.ts.map +0 -1
  331. package/dist/cli/share.test.js +0 -12335
  332. package/dist/cli/share.test.js.map +0 -1
  333. package/dist/cli/streaming-pull-planner.bench.test.d.ts +0 -15
  334. package/dist/cli/streaming-pull-planner.bench.test.d.ts.map +0 -1
  335. package/dist/cli/streaming-pull-planner.bench.test.js +0 -273
  336. package/dist/cli/streaming-pull-planner.bench.test.js.map +0 -1
  337. package/dist/cli/sync-scope.test.d.ts +0 -22
  338. package/dist/cli/sync-scope.test.d.ts.map +0 -1
  339. package/dist/cli/sync-scope.test.js +0 -903
  340. package/dist/cli/sync-scope.test.js.map +0 -1
  341. package/dist/cli/sync.test.d.ts +0 -5
  342. package/dist/cli/sync.test.d.ts.map +0 -1
  343. package/dist/cli/sync.test.js +0 -9028
  344. package/dist/cli/sync.test.js.map +0 -1
  345. package/dist/cli/tombstones.test.d.ts +0 -2
  346. package/dist/cli/tombstones.test.d.ts.map +0 -1
  347. package/dist/cli/tombstones.test.js +0 -120
  348. package/dist/cli/tombstones.test.js.map +0 -1
  349. package/dist/cli/watch-event-push-conflict.test.d.ts +0 -25
  350. package/dist/cli/watch-event-push-conflict.test.d.ts.map +0 -1
  351. package/dist/cli/watch-event-push-conflict.test.js +0 -230
  352. package/dist/cli/watch-event-push-conflict.test.js.map +0 -1
  353. package/dist/client-info.test.d.ts +0 -11
  354. package/dist/client-info.test.d.ts.map +0 -1
  355. package/dist/client-info.test.js +0 -168
  356. package/dist/client-info.test.js.map +0 -1
  357. package/dist/cognito-auth-lock-no-hard-links.test.d.ts +0 -11
  358. package/dist/cognito-auth-lock-no-hard-links.test.d.ts.map +0 -1
  359. package/dist/cognito-auth-lock-no-hard-links.test.js +0 -76
  360. package/dist/cognito-auth-lock-no-hard-links.test.js.map +0 -1
  361. package/dist/cognito-auth.test.d.ts +0 -9
  362. package/dist/cognito-auth.test.d.ts.map +0 -1
  363. package/dist/cognito-auth.test.js +0 -745
  364. package/dist/cognito-auth.test.js.map +0 -1
  365. package/dist/company-resolver.test.d.ts +0 -7
  366. package/dist/company-resolver.test.d.ts.map +0 -1
  367. package/dist/company-resolver.test.js +0 -533
  368. package/dist/company-resolver.test.js.map +0 -1
  369. package/dist/company-slug.test.d.ts +0 -7
  370. package/dist/company-slug.test.d.ts.map +0 -1
  371. package/dist/company-slug.test.js +0 -43
  372. package/dist/company-slug.test.js.map +0 -1
  373. package/dist/company-vault-transport.test.d.ts +0 -12
  374. package/dist/company-vault-transport.test.d.ts.map +0 -1
  375. package/dist/company-vault-transport.test.js +0 -97
  376. package/dist/company-vault-transport.test.js.map +0 -1
  377. package/dist/context.test.d.ts +0 -7
  378. package/dist/context.test.d.ts.map +0 -1
  379. package/dist/context.test.js +0 -646
  380. package/dist/context.test.js.map +0 -1
  381. package/dist/entity-resolver.test.d.ts +0 -10
  382. package/dist/entity-resolver.test.d.ts.map +0 -1
  383. package/dist/entity-resolver.test.js +0 -244
  384. package/dist/entity-resolver.test.js.map +0 -1
  385. package/dist/getindigo-dead-refs-contract.test.d.ts +0 -2
  386. package/dist/getindigo-dead-refs-contract.test.d.ts.map +0 -1
  387. package/dist/getindigo-dead-refs-contract.test.js +0 -58
  388. package/dist/getindigo-dead-refs-contract.test.js.map +0 -1
  389. package/dist/hermetic-test-env.test.d.ts +0 -2
  390. package/dist/hermetic-test-env.test.d.ts.map +0 -1
  391. package/dist/hermetic-test-env.test.js +0 -61
  392. package/dist/hermetic-test-env.test.js.map +0 -1
  393. package/dist/hq-root-recovery.test.d.ts +0 -2
  394. package/dist/hq-root-recovery.test.d.ts.map +0 -1
  395. package/dist/hq-root-recovery.test.js +0 -65
  396. package/dist/hq-root-recovery.test.js.map +0 -1
  397. package/dist/ignore.test.d.ts +0 -10
  398. package/dist/ignore.test.d.ts.map +0 -1
  399. package/dist/ignore.test.js +0 -926
  400. package/dist/ignore.test.js.map +0 -1
  401. package/dist/index.test.d.ts +0 -2
  402. package/dist/index.test.d.ts.map +0 -1
  403. package/dist/index.test.js +0 -26
  404. package/dist/index.test.js.map +0 -1
  405. package/dist/journal-area-ledger-read-boundary.test.d.ts +0 -2
  406. package/dist/journal-area-ledger-read-boundary.test.d.ts.map +0 -1
  407. package/dist/journal-area-ledger-read-boundary.test.js +0 -420
  408. package/dist/journal-area-ledger-read-boundary.test.js.map +0 -1
  409. package/dist/journal-baseline.test.d.ts +0 -2
  410. package/dist/journal-baseline.test.d.ts.map +0 -1
  411. package/dist/journal-baseline.test.js +0 -218
  412. package/dist/journal-baseline.test.js.map +0 -1
  413. package/dist/journal-decode-window.test.d.ts +0 -2
  414. package/dist/journal-decode-window.test.d.ts.map +0 -1
  415. package/dist/journal-decode-window.test.js +0 -99
  416. package/dist/journal-decode-window.test.js.map +0 -1
  417. package/dist/journal-delta-writes.test.d.ts +0 -2
  418. package/dist/journal-delta-writes.test.d.ts.map +0 -1
  419. package/dist/journal-delta-writes.test.js +0 -159
  420. package/dist/journal-delta-writes.test.js.map +0 -1
  421. package/dist/journal-ledger-index.test.d.ts +0 -15
  422. package/dist/journal-ledger-index.test.d.ts.map +0 -1
  423. package/dist/journal-ledger-index.test.js +0 -269
  424. package/dist/journal-ledger-index.test.js.map +0 -1
  425. package/dist/journal-public-boundary.test.d.ts +0 -2
  426. package/dist/journal-public-boundary.test.d.ts.map +0 -1
  427. package/dist/journal-public-boundary.test.js +0 -216
  428. package/dist/journal-public-boundary.test.js.map +0 -1
  429. package/dist/journal-row-store.test.d.ts +0 -2
  430. package/dist/journal-row-store.test.d.ts.map +0 -1
  431. package/dist/journal-row-store.test.js +0 -729
  432. package/dist/journal-row-store.test.js.map +0 -1
  433. package/dist/journal-snapshot-memory-helpers.d.ts +0 -33
  434. package/dist/journal-snapshot-memory-helpers.d.ts.map +0 -1
  435. package/dist/journal-snapshot-memory-helpers.js +0 -212
  436. package/dist/journal-snapshot-memory-helpers.js.map +0 -1
  437. package/dist/journal-snapshot-memory.test.d.ts +0 -2
  438. package/dist/journal-snapshot-memory.test.d.ts.map +0 -1
  439. package/dist/journal-snapshot-memory.test.js +0 -47
  440. package/dist/journal-snapshot-memory.test.js.map +0 -1
  441. package/dist/journal-snapshot-v4-memory.test.d.ts +0 -2
  442. package/dist/journal-snapshot-v4-memory.test.d.ts.map +0 -1
  443. package/dist/journal-snapshot-v4-memory.test.js +0 -24
  444. package/dist/journal-snapshot-v4-memory.test.js.map +0 -1
  445. package/dist/journal-snapshot-v4-writer-memory.test.d.ts +0 -2
  446. package/dist/journal-snapshot-v4-writer-memory.test.d.ts.map +0 -1
  447. package/dist/journal-snapshot-v4-writer-memory.test.js +0 -76
  448. package/dist/journal-snapshot-v4-writer-memory.test.js.map +0 -1
  449. package/dist/journal-snapshot-v4-writer.test.d.ts +0 -2
  450. package/dist/journal-snapshot-v4-writer.test.d.ts.map +0 -1
  451. package/dist/journal-snapshot-v4-writer.test.js +0 -282
  452. package/dist/journal-snapshot-v4-writer.test.js.map +0 -1
  453. package/dist/journal-snapshot-v4.test.d.ts +0 -2
  454. package/dist/journal-snapshot-v4.test.d.ts.map +0 -1
  455. package/dist/journal-snapshot-v4.test.js +0 -281
  456. package/dist/journal-snapshot-v4.test.js.map +0 -1
  457. package/dist/journal-store.test.d.ts +0 -2
  458. package/dist/journal-store.test.d.ts.map +0 -1
  459. package/dist/journal-store.test.js +0 -1227
  460. package/dist/journal-store.test.js.map +0 -1
  461. package/dist/journal-streaming.heap.test.d.ts +0 -2
  462. package/dist/journal-streaming.heap.test.d.ts.map +0 -1
  463. package/dist/journal-streaming.heap.test.js +0 -190
  464. package/dist/journal-streaming.heap.test.js.map +0 -1
  465. package/dist/journal.test.d.ts +0 -9
  466. package/dist/journal.test.d.ts.map +0 -1
  467. package/dist/journal.test.js +0 -1074
  468. package/dist/journal.test.js.map +0 -1
  469. package/dist/kms10-etag-safe-pull.test.d.ts +0 -2
  470. package/dist/kms10-etag-safe-pull.test.d.ts.map +0 -1
  471. package/dist/kms10-etag-safe-pull.test.js +0 -53
  472. package/dist/kms10-etag-safe-pull.test.js.map +0 -1
  473. package/dist/lib/cloud-authoritative.test.d.ts +0 -2
  474. package/dist/lib/cloud-authoritative.test.d.ts.map +0 -1
  475. package/dist/lib/cloud-authoritative.test.js +0 -42
  476. package/dist/lib/cloud-authoritative.test.js.map +0 -1
  477. package/dist/lib/conflict-reconcile.test.d.ts +0 -7
  478. package/dist/lib/conflict-reconcile.test.d.ts.map +0 -1
  479. package/dist/lib/conflict-reconcile.test.js +0 -246
  480. package/dist/lib/conflict-reconcile.test.js.map +0 -1
  481. package/dist/lib/conflict.test.d.ts +0 -9
  482. package/dist/lib/conflict.test.d.ts.map +0 -1
  483. package/dist/lib/conflict.test.js +0 -340
  484. package/dist/lib/conflict.test.js.map +0 -1
  485. package/dist/lib/describe-error.test.d.ts +0 -2
  486. package/dist/lib/describe-error.test.d.ts.map +0 -1
  487. package/dist/lib/describe-error.test.js +0 -148
  488. package/dist/lib/describe-error.test.js.map +0 -1
  489. package/dist/lib/machine-id.test.d.ts +0 -8
  490. package/dist/lib/machine-id.test.d.ts.map +0 -1
  491. package/dist/lib/machine-id.test.js +0 -228
  492. package/dist/lib/machine-id.test.js.map +0 -1
  493. package/dist/lib/net-errors.test.d.ts +0 -2
  494. package/dist/lib/net-errors.test.d.ts.map +0 -1
  495. package/dist/lib/net-errors.test.js +0 -111
  496. package/dist/lib/net-errors.test.js.map +0 -1
  497. package/dist/lib/ontology-entity-meta.test.d.ts +0 -2
  498. package/dist/lib/ontology-entity-meta.test.d.ts.map +0 -1
  499. package/dist/lib/ontology-entity-meta.test.js +0 -79
  500. package/dist/lib/ontology-entity-meta.test.js.map +0 -1
  501. package/dist/lib/ontology-entity-push.test.d.ts +0 -2
  502. package/dist/lib/ontology-entity-push.test.d.ts.map +0 -1
  503. package/dist/lib/ontology-entity-push.test.js +0 -156
  504. package/dist/lib/ontology-entity-push.test.js.map +0 -1
  505. package/dist/lib/personal-connector-settings-sync.test.d.ts +0 -2
  506. package/dist/lib/personal-connector-settings-sync.test.d.ts.map +0 -1
  507. package/dist/lib/personal-connector-settings-sync.test.js +0 -34
  508. package/dist/lib/personal-connector-settings-sync.test.js.map +0 -1
  509. package/dist/lib/readlink-safe.test.d.ts +0 -2
  510. package/dist/lib/readlink-safe.test.d.ts.map +0 -1
  511. package/dist/lib/readlink-safe.test.js +0 -34
  512. package/dist/lib/readlink-safe.test.js.map +0 -1
  513. package/dist/lib/s3-content-checksum.test.d.ts +0 -2
  514. package/dist/lib/s3-content-checksum.test.d.ts.map +0 -1
  515. package/dist/lib/s3-content-checksum.test.js +0 -26
  516. package/dist/lib/s3-content-checksum.test.js.map +0 -1
  517. package/dist/lib/skill-register-landed-bytes.test.d.ts +0 -2
  518. package/dist/lib/skill-register-landed-bytes.test.d.ts.map +0 -1
  519. package/dist/lib/skill-register-landed-bytes.test.js +0 -83
  520. package/dist/lib/skill-register-landed-bytes.test.js.map +0 -1
  521. package/dist/local-path-codec.test.d.ts +0 -14
  522. package/dist/local-path-codec.test.d.ts.map +0 -1
  523. package/dist/local-path-codec.test.js +0 -122
  524. package/dist/local-path-codec.test.js.map +0 -1
  525. package/dist/machine-auth.test.d.ts +0 -14
  526. package/dist/machine-auth.test.d.ts.map +0 -1
  527. package/dist/machine-auth.test.js +0 -1492
  528. package/dist/machine-auth.test.js.map +0 -1
  529. package/dist/machine-token-cache-isolation.test.d.ts +0 -24
  530. package/dist/machine-token-cache-isolation.test.d.ts.map +0 -1
  531. package/dist/machine-token-cache-isolation.test.js +0 -284
  532. package/dist/machine-token-cache-isolation.test.js.map +0 -1
  533. package/dist/manifest/US-002.acceptance.test.d.ts +0 -11
  534. package/dist/manifest/US-002.acceptance.test.d.ts.map +0 -1
  535. package/dist/manifest/US-002.acceptance.test.js +0 -160
  536. package/dist/manifest/US-002.acceptance.test.js.map +0 -1
  537. package/dist/manifest/US-004.acceptance.test.d.ts +0 -12
  538. package/dist/manifest/US-004.acceptance.test.d.ts.map +0 -1
  539. package/dist/manifest/US-004.acceptance.test.js +0 -173
  540. package/dist/manifest/US-004.acceptance.test.js.map +0 -1
  541. package/dist/manifest/build-manifest-resident-parity.test.d.ts +0 -2
  542. package/dist/manifest/build-manifest-resident-parity.test.d.ts.map +0 -1
  543. package/dist/manifest/build-manifest-resident-parity.test.js +0 -69
  544. package/dist/manifest/build-manifest-resident-parity.test.js.map +0 -1
  545. package/dist/manifest/build-manifest.bench.test.d.ts +0 -22
  546. package/dist/manifest/build-manifest.bench.test.d.ts.map +0 -1
  547. package/dist/manifest/build-manifest.bench.test.js +0 -930
  548. package/dist/manifest/build-manifest.bench.test.js.map +0 -1
  549. package/dist/manifest/build-manifest.test.d.ts +0 -10
  550. package/dist/manifest/build-manifest.test.d.ts.map +0 -1
  551. package/dist/manifest/build-manifest.test.js +0 -1217
  552. package/dist/manifest/build-manifest.test.js.map +0 -1
  553. package/dist/manifest/contract.test.d.ts +0 -17
  554. package/dist/manifest/contract.test.d.ts.map +0 -1
  555. package/dist/manifest/contract.test.js +0 -346
  556. package/dist/manifest/contract.test.js.map +0 -1
  557. package/dist/manifest/import-graph.test.d.ts +0 -25
  558. package/dist/manifest/import-graph.test.d.ts.map +0 -1
  559. package/dist/manifest/import-graph.test.js +0 -120
  560. package/dist/manifest/import-graph.test.js.map +0 -1
  561. package/dist/manifest/ledger-shard-isolation.test.d.ts +0 -18
  562. package/dist/manifest/ledger-shard-isolation.test.d.ts.map +0 -1
  563. package/dist/manifest/ledger-shard-isolation.test.js +0 -153
  564. package/dist/manifest/ledger-shard-isolation.test.js.map +0 -1
  565. package/dist/manifest/snapshot-store.test.d.ts +0 -11
  566. package/dist/manifest/snapshot-store.test.d.ts.map +0 -1
  567. package/dist/manifest/snapshot-store.test.js +0 -206
  568. package/dist/manifest/snapshot-store.test.js.map +0 -1
  569. package/dist/manifest/upload-manifest.test.d.ts +0 -10
  570. package/dist/manifest/upload-manifest.test.d.ts.map +0 -1
  571. package/dist/manifest/upload-manifest.test.js +0 -1335
  572. package/dist/manifest/upload-manifest.test.js.map +0 -1
  573. package/dist/manifest-reconcile.test.d.ts +0 -2
  574. package/dist/manifest-reconcile.test.d.ts.map +0 -1
  575. package/dist/manifest-reconcile.test.js +0 -1029
  576. package/dist/manifest-reconcile.test.js.map +0 -1
  577. package/dist/object-io.test.d.ts +0 -11
  578. package/dist/object-io.test.d.ts.map +0 -1
  579. package/dist/object-io.test.js +0 -2376
  580. package/dist/object-io.test.js.map +0 -1
  581. package/dist/operation-lock.test.d.ts +0 -5
  582. package/dist/operation-lock.test.d.ts.map +0 -1
  583. package/dist/operation-lock.test.js +0 -796
  584. package/dist/operation-lock.test.js.map +0 -1
  585. package/dist/outcome-telemetry.test.d.ts +0 -9
  586. package/dist/outcome-telemetry.test.d.ts.map +0 -1
  587. package/dist/outcome-telemetry.test.js +0 -663
  588. package/dist/outcome-telemetry.test.js.map +0 -1
  589. package/dist/outposts/client.test.d.ts +0 -9
  590. package/dist/outposts/client.test.d.ts.map +0 -1
  591. package/dist/outposts/client.test.js +0 -415
  592. package/dist/outposts/client.test.js.map +0 -1
  593. package/dist/outposts/command.test.d.ts +0 -18
  594. package/dist/outposts/command.test.d.ts.map +0 -1
  595. package/dist/outposts/command.test.js +0 -1230
  596. package/dist/outposts/command.test.js.map +0 -1
  597. package/dist/outposts/replica-sync.test.d.ts +0 -2
  598. package/dist/outposts/replica-sync.test.d.ts.map +0 -1
  599. package/dist/outposts/replica-sync.test.js +0 -224
  600. package/dist/outposts/replica-sync.test.js.map +0 -1
  601. package/dist/outposts/self-deploy.test.d.ts +0 -2
  602. package/dist/outposts/self-deploy.test.d.ts.map +0 -1
  603. package/dist/outposts/self-deploy.test.js +0 -218
  604. package/dist/outposts/self-deploy.test.js.map +0 -1
  605. package/dist/outposts/session-heartbeat-bounds.test.d.ts +0 -25
  606. package/dist/outposts/session-heartbeat-bounds.test.d.ts.map +0 -1
  607. package/dist/outposts/session-heartbeat-bounds.test.js +0 -156
  608. package/dist/outposts/session-heartbeat-bounds.test.js.map +0 -1
  609. package/dist/outposts/session-heartbeat-guard.test.d.ts +0 -16
  610. package/dist/outposts/session-heartbeat-guard.test.d.ts.map +0 -1
  611. package/dist/outposts/session-heartbeat-guard.test.js +0 -88
  612. package/dist/outposts/session-heartbeat-guard.test.js.map +0 -1
  613. package/dist/outposts/session-heartbeat-publisher.test.d.ts +0 -10
  614. package/dist/outposts/session-heartbeat-publisher.test.d.ts.map +0 -1
  615. package/dist/outposts/session-heartbeat-publisher.test.js +0 -153
  616. package/dist/outposts/session-heartbeat-publisher.test.js.map +0 -1
  617. package/dist/outposts/session-heartbeat-retain-guard.test.d.ts +0 -18
  618. package/dist/outposts/session-heartbeat-retain-guard.test.d.ts.map +0 -1
  619. package/dist/outposts/session-heartbeat-retain-guard.test.js +0 -106
  620. package/dist/outposts/session-heartbeat-retain-guard.test.js.map +0 -1
  621. package/dist/outposts/session-heartbeat-scan.test.d.ts +0 -20
  622. package/dist/outposts/session-heartbeat-scan.test.d.ts.map +0 -1
  623. package/dist/outposts/session-heartbeat-scan.test.js +0 -241
  624. package/dist/outposts/session-heartbeat-scan.test.js.map +0 -1
  625. package/dist/outposts/session-heartbeat.test.d.ts +0 -11
  626. package/dist/outposts/session-heartbeat.test.d.ts.map +0 -1
  627. package/dist/outposts/session-heartbeat.test.js +0 -370
  628. package/dist/outposts/session-heartbeat.test.js.map +0 -1
  629. package/dist/outposts/session-host-auth.test.d.ts +0 -16
  630. package/dist/outposts/session-host-auth.test.d.ts.map +0 -1
  631. package/dist/outposts/session-host-auth.test.js +0 -284
  632. package/dist/outposts/session-host-auth.test.js.map +0 -1
  633. package/dist/outposts/session-host-command.test.d.ts +0 -10
  634. package/dist/outposts/session-host-command.test.d.ts.map +0 -1
  635. package/dist/outposts/session-host-command.test.js +0 -180
  636. package/dist/outposts/session-host-command.test.js.map +0 -1
  637. package/dist/outposts/session-host-connection.test.d.ts +0 -18
  638. package/dist/outposts/session-host-connection.test.d.ts.map +0 -1
  639. package/dist/outposts/session-host-connection.test.js +0 -392
  640. package/dist/outposts/session-host-connection.test.js.map +0 -1
  641. package/dist/outposts/session-host-fakes.d.ts +0 -61
  642. package/dist/outposts/session-host-fakes.d.ts.map +0 -1
  643. package/dist/outposts/session-host-fakes.js +0 -115
  644. package/dist/outposts/session-host-fakes.js.map +0 -1
  645. package/dist/outposts/session-host-ndjson.test.d.ts +0 -11
  646. package/dist/outposts/session-host-ndjson.test.d.ts.map +0 -1
  647. package/dist/outposts/session-host-ndjson.test.js +0 -73
  648. package/dist/outposts/session-host-ndjson.test.js.map +0 -1
  649. package/dist/outposts/session-host-protocol.test.d.ts +0 -15
  650. package/dist/outposts/session-host-protocol.test.d.ts.map +0 -1
  651. package/dist/outposts/session-host-protocol.test.js +0 -0
  652. package/dist/outposts/session-host-protocol.test.js.map +0 -1
  653. package/dist/outposts/session-host-server.test.d.ts +0 -10
  654. package/dist/outposts/session-host-server.test.d.ts.map +0 -1
  655. package/dist/outposts/session-host-server.test.js +0 -118
  656. package/dist/outposts/session-host-server.test.js.map +0 -1
  657. package/dist/outposts/session-host-sessions.test.d.ts +0 -18
  658. package/dist/outposts/session-host-sessions.test.d.ts.map +0 -1
  659. package/dist/outposts/session-host-sessions.test.js +0 -439
  660. package/dist/outposts/session-host-sessions.test.js.map +0 -1
  661. package/dist/outposts/terminal.test.d.ts +0 -2
  662. package/dist/outposts/terminal.test.d.ts.map +0 -1
  663. package/dist/outposts/terminal.test.js +0 -146
  664. package/dist/outposts/terminal.test.js.map +0 -1
  665. package/dist/outposts/transport.test.d.ts +0 -10
  666. package/dist/outposts/transport.test.d.ts.map +0 -1
  667. package/dist/outposts/transport.test.js +0 -205
  668. package/dist/outposts/transport.test.js.map +0 -1
  669. package/dist/packed-journal-phase2.test.d.ts +0 -2
  670. package/dist/packed-journal-phase2.test.d.ts.map +0 -1
  671. package/dist/packed-journal-phase2.test.js +0 -273
  672. package/dist/packed-journal-phase2.test.js.map +0 -1
  673. package/dist/path-ancestors.test.d.ts +0 -2
  674. package/dist/path-ancestors.test.d.ts.map +0 -1
  675. package/dist/path-ancestors.test.js +0 -21
  676. package/dist/path-ancestors.test.js.map +0 -1
  677. package/dist/personal-vault-exclusions.test.d.ts +0 -22
  678. package/dist/personal-vault-exclusions.test.d.ts.map +0 -1
  679. package/dist/personal-vault-exclusions.test.js +0 -294
  680. package/dist/personal-vault-exclusions.test.js.map +0 -1
  681. package/dist/personal-vault.test.d.ts +0 -18
  682. package/dist/personal-vault.test.d.ts.map +0 -1
  683. package/dist/personal-vault.test.js +0 -1099
  684. package/dist/personal-vault.test.js.map +0 -1
  685. package/dist/plan-limit-pause.test.d.ts +0 -2
  686. package/dist/plan-limit-pause.test.d.ts.map +0 -1
  687. package/dist/plan-limit-pause.test.js +0 -150
  688. package/dist/plan-limit-pause.test.js.map +0 -1
  689. package/dist/plan-limit.fixtures.d.ts +0 -34
  690. package/dist/plan-limit.fixtures.d.ts.map +0 -1
  691. package/dist/plan-limit.fixtures.js +0 -96
  692. package/dist/plan-limit.fixtures.js.map +0 -1
  693. package/dist/plan-limit.test.d.ts +0 -2
  694. package/dist/plan-limit.test.d.ts.map +0 -1
  695. package/dist/plan-limit.test.js +0 -132
  696. package/dist/plan-limit.test.js.map +0 -1
  697. package/dist/prefix-coalesce.test.d.ts +0 -2
  698. package/dist/prefix-coalesce.test.d.ts.map +0 -1
  699. package/dist/prefix-coalesce.test.js +0 -260
  700. package/dist/prefix-coalesce.test.js.map +0 -1
  701. package/dist/private-folder-transport.test.d.ts +0 -2
  702. package/dist/private-folder-transport.test.d.ts.map +0 -1
  703. package/dist/private-folder-transport.test.js +0 -33
  704. package/dist/private-folder-transport.test.js.map +0 -1
  705. package/dist/public-surface.test.d.ts +0 -15
  706. package/dist/public-surface.test.d.ts.map +0 -1
  707. package/dist/public-surface.test.js +0 -110
  708. package/dist/public-surface.test.js.map +0 -1
  709. package/dist/qmd-reindex.test.d.ts +0 -14
  710. package/dist/qmd-reindex.test.d.ts.map +0 -1
  711. package/dist/qmd-reindex.test.js +0 -899
  712. package/dist/qmd-reindex.test.js.map +0 -1
  713. package/dist/read-only-state-dir.test.d.ts +0 -27
  714. package/dist/read-only-state-dir.test.d.ts.map +0 -1
  715. package/dist/read-only-state-dir.test.js +0 -167
  716. package/dist/read-only-state-dir.test.js.map +0 -1
  717. package/dist/remote-pull.test.d.ts +0 -2
  718. package/dist/remote-pull.test.d.ts.map +0 -1
  719. package/dist/remote-pull.test.js +0 -1147
  720. package/dist/remote-pull.test.js.map +0 -1
  721. package/dist/s3-download-stream-hash.test.d.ts +0 -2
  722. package/dist/s3-download-stream-hash.test.d.ts.map +0 -1
  723. package/dist/s3-download-stream-hash.test.js +0 -70
  724. package/dist/s3-download-stream-hash.test.js.map +0 -1
  725. package/dist/s3.list-cost.test.d.ts +0 -2
  726. package/dist/s3.list-cost.test.d.ts.map +0 -1
  727. package/dist/s3.list-cost.test.js +0 -51
  728. package/dist/s3.list-cost.test.js.map +0 -1
  729. package/dist/s3.rename-retry.test.d.ts +0 -2
  730. package/dist/s3.rename-retry.test.d.ts.map +0 -1
  731. package/dist/s3.rename-retry.test.js +0 -217
  732. package/dist/s3.rename-retry.test.js.map +0 -1
  733. package/dist/s3.symlink-materialize.test.d.ts +0 -18
  734. package/dist/s3.symlink-materialize.test.d.ts.map +0 -1
  735. package/dist/s3.symlink-materialize.test.js +0 -579
  736. package/dist/s3.symlink-materialize.test.js.map +0 -1
  737. package/dist/s3.test.d.ts +0 -9
  738. package/dist/s3.test.d.ts.map +0 -1
  739. package/dist/s3.test.js +0 -2047
  740. package/dist/s3.test.js.map +0 -1
  741. package/dist/s3.windows-portability.test.d.ts +0 -12
  742. package/dist/s3.windows-portability.test.d.ts.map +0 -1
  743. package/dist/s3.windows-portability.test.js +0 -223
  744. package/dist/s3.windows-portability.test.js.map +0 -1
  745. package/dist/schemas/signal-types.test.d.ts +0 -2
  746. package/dist/schemas/signal-types.test.d.ts.map +0 -1
  747. package/dist/schemas/signal-types.test.js +0 -65
  748. package/dist/schemas/signal-types.test.js.map +0 -1
  749. package/dist/schemas/source-channels.test.d.ts +0 -2
  750. package/dist/schemas/source-channels.test.d.ts.map +0 -1
  751. package/dist/schemas/source-channels.test.js +0 -65
  752. package/dist/schemas/source-channels.test.js.map +0 -1
  753. package/dist/scope-shrink.test.d.ts +0 -13
  754. package/dist/scope-shrink.test.d.ts.map +0 -1
  755. package/dist/scope-shrink.test.js +0 -558
  756. package/dist/scope-shrink.test.js.map +0 -1
  757. package/dist/session-log-capture.test.d.ts +0 -2
  758. package/dist/session-log-capture.test.d.ts.map +0 -1
  759. package/dist/session-log-capture.test.js +0 -533
  760. package/dist/session-log-capture.test.js.map +0 -1
  761. package/dist/session-log-retention.test.d.ts +0 -2
  762. package/dist/session-log-retention.test.d.ts.map +0 -1
  763. package/dist/session-log-retention.test.js +0 -383
  764. package/dist/session-log-retention.test.js.map +0 -1
  765. package/dist/signals/get.test.d.ts +0 -5
  766. package/dist/signals/get.test.d.ts.map +0 -1
  767. package/dist/signals/get.test.js +0 -254
  768. package/dist/signals/get.test.js.map +0 -1
  769. package/dist/signals/list.test.d.ts +0 -9
  770. package/dist/signals/list.test.d.ts.map +0 -1
  771. package/dist/signals/list.test.js +0 -330
  772. package/dist/signals/list.test.js.map +0 -1
  773. package/dist/skill-telemetry.test.d.ts +0 -2
  774. package/dist/skill-telemetry.test.d.ts.map +0 -1
  775. package/dist/skill-telemetry.test.js +0 -1484
  776. package/dist/skill-telemetry.test.js.map +0 -1
  777. package/dist/sources/get.test.d.ts +0 -5
  778. package/dist/sources/get.test.d.ts.map +0 -1
  779. package/dist/sources/get.test.js +0 -236
  780. package/dist/sources/get.test.js.map +0 -1
  781. package/dist/sources/list.test.d.ts +0 -8
  782. package/dist/sources/list.test.d.ts.map +0 -1
  783. package/dist/sources/list.test.js +0 -318
  784. package/dist/sources/list.test.js.map +0 -1
  785. package/dist/sts-credential-refresh.test.d.ts +0 -2
  786. package/dist/sts-credential-refresh.test.d.ts.map +0 -1
  787. package/dist/sts-credential-refresh.test.js +0 -181
  788. package/dist/sts-credential-refresh.test.js.map +0 -1
  789. package/dist/swallowed-error-sites.test.d.ts +0 -2
  790. package/dist/swallowed-error-sites.test.d.ts.map +0 -1
  791. package/dist/swallowed-error-sites.test.js +0 -199
  792. package/dist/swallowed-error-sites.test.js.map +0 -1
  793. package/dist/swallowed-error-w32.test.d.ts +0 -2
  794. package/dist/swallowed-error-w32.test.d.ts.map +0 -1
  795. package/dist/swallowed-error-w32.test.js +0 -171
  796. package/dist/swallowed-error-w32.test.js.map +0 -1
  797. package/dist/swallowed-error-w33.test.d.ts +0 -2
  798. package/dist/swallowed-error-w33.test.d.ts.map +0 -1
  799. package/dist/swallowed-error-w33.test.js +0 -125
  800. package/dist/swallowed-error-w33.test.js.map +0 -1
  801. package/dist/swallowed-error-w34.test.d.ts +0 -2
  802. package/dist/swallowed-error-w34.test.d.ts.map +0 -1
  803. package/dist/swallowed-error-w34.test.js +0 -110
  804. package/dist/swallowed-error-w34.test.js.map +0 -1
  805. package/dist/swallowed-error-w35.test.d.ts +0 -2
  806. package/dist/swallowed-error-w35.test.d.ts.map +0 -1
  807. package/dist/swallowed-error-w35.test.js +0 -223
  808. package/dist/swallowed-error-w35.test.js.map +0 -1
  809. package/dist/sync/area-ledger-auto-migration.test.d.ts +0 -2
  810. package/dist/sync/area-ledger-auto-migration.test.d.ts.map +0 -1
  811. package/dist/sync/area-ledger-auto-migration.test.js +0 -291
  812. package/dist/sync/area-ledger-auto-migration.test.js.map +0 -1
  813. package/dist/sync/area-ledger-migration.test.d.ts +0 -2
  814. package/dist/sync/area-ledger-migration.test.d.ts.map +0 -1
  815. package/dist/sync/area-ledger-migration.test.js +0 -554
  816. package/dist/sync/area-ledger-migration.test.js.map +0 -1
  817. package/dist/sync/area-ledger-recovery.bench.test.d.ts +0 -2
  818. package/dist/sync/area-ledger-recovery.bench.test.d.ts.map +0 -1
  819. package/dist/sync/area-ledger-recovery.bench.test.js +0 -60
  820. package/dist/sync/area-ledger-recovery.bench.test.js.map +0 -1
  821. package/dist/sync/area-ledger-writer-lock.bench.test.d.ts +0 -2
  822. package/dist/sync/area-ledger-writer-lock.bench.test.d.ts.map +0 -1
  823. package/dist/sync/area-ledger-writer-lock.bench.test.js +0 -57
  824. package/dist/sync/area-ledger-writer-lock.bench.test.js.map +0 -1
  825. package/dist/sync/area-ledger.test.d.ts +0 -2
  826. package/dist/sync/area-ledger.test.d.ts.map +0 -1
  827. package/dist/sync/area-ledger.test.js +0 -1401
  828. package/dist/sync/area-ledger.test.js.map +0 -1
  829. package/dist/sync/area-resolver.test.d.ts +0 -2
  830. package/dist/sync/area-resolver.test.d.ts.map +0 -1
  831. package/dist/sync/area-resolver.test.js +0 -234
  832. package/dist/sync/area-resolver.test.js.map +0 -1
  833. package/dist/sync/base-version-resolver.test.d.ts +0 -2
  834. package/dist/sync/base-version-resolver.test.d.ts.map +0 -1
  835. package/dist/sync/base-version-resolver.test.js +0 -24
  836. package/dist/sync/base-version-resolver.test.js.map +0 -1
  837. package/dist/sync/candidate-uploader.test.d.ts +0 -2
  838. package/dist/sync/candidate-uploader.test.d.ts.map +0 -1
  839. package/dist/sync/candidate-uploader.test.js +0 -132
  840. package/dist/sync/candidate-uploader.test.js.map +0 -1
  841. package/dist/sync/child-process-sync-worker-env.test.d.ts +0 -2
  842. package/dist/sync/child-process-sync-worker-env.test.d.ts.map +0 -1
  843. package/dist/sync/child-process-sync-worker-env.test.js +0 -38
  844. package/dist/sync/child-process-sync-worker-env.test.js.map +0 -1
  845. package/dist/sync/child-process-sync-worker-errors.test.d.ts +0 -2
  846. package/dist/sync/child-process-sync-worker-errors.test.d.ts.map +0 -1
  847. package/dist/sync/child-process-sync-worker-errors.test.js +0 -121
  848. package/dist/sync/child-process-sync-worker-errors.test.js.map +0 -1
  849. package/dist/sync/child-process-sync-worker.test.d.ts +0 -2
  850. package/dist/sync/child-process-sync-worker.test.d.ts.map +0 -1
  851. package/dist/sync/child-process-sync-worker.test.js +0 -262
  852. package/dist/sync/child-process-sync-worker.test.js.map +0 -1
  853. package/dist/sync/content-hash.test.d.ts +0 -2
  854. package/dist/sync/content-hash.test.d.ts.map +0 -1
  855. package/dist/sync/content-hash.test.js +0 -16
  856. package/dist/sync/content-hash.test.js.map +0 -1
  857. package/dist/sync/delta-client.test.d.ts +0 -2
  858. package/dist/sync/delta-client.test.d.ts.map +0 -1
  859. package/dist/sync/delta-client.test.js +0 -97
  860. package/dist/sync/delta-client.test.js.map +0 -1
  861. package/dist/sync/durable-apply.test.d.ts +0 -2
  862. package/dist/sync/durable-apply.test.d.ts.map +0 -1
  863. package/dist/sync/durable-apply.test.js +0 -180
  864. package/dist/sync/durable-apply.test.js.map +0 -1
  865. package/dist/sync/event-sync.test.d.ts +0 -14
  866. package/dist/sync/event-sync.test.d.ts.map +0 -1
  867. package/dist/sync/event-sync.test.js +0 -1714
  868. package/dist/sync/event-sync.test.js.map +0 -1
  869. package/dist/sync/feature-flags.test.d.ts +0 -24
  870. package/dist/sync/feature-flags.test.d.ts.map +0 -1
  871. package/dist/sync/feature-flags.test.js +0 -594
  872. package/dist/sync/feature-flags.test.js.map +0 -1
  873. package/dist/sync/full-pass-cap-governor.bench.test.d.ts +0 -2
  874. package/dist/sync/full-pass-cap-governor.bench.test.d.ts.map +0 -1
  875. package/dist/sync/full-pass-cap-governor.bench.test.js +0 -179
  876. package/dist/sync/full-pass-cap-governor.bench.test.js.map +0 -1
  877. package/dist/sync/full-pass-caps.test.d.ts +0 -2
  878. package/dist/sync/full-pass-caps.test.d.ts.map +0 -1
  879. package/dist/sync/full-pass-caps.test.js +0 -550
  880. package/dist/sync/full-pass-caps.test.js.map +0 -1
  881. package/dist/sync/idle-full-reconcile.test.d.ts +0 -2
  882. package/dist/sync/idle-full-reconcile.test.d.ts.map +0 -1
  883. package/dist/sync/idle-full-reconcile.test.js +0 -62
  884. package/dist/sync/idle-full-reconcile.test.js.map +0 -1
  885. package/dist/sync/inbound-event-admission.test.d.ts +0 -2
  886. package/dist/sync/inbound-event-admission.test.d.ts.map +0 -1
  887. package/dist/sync/inbound-event-admission.test.js +0 -125
  888. package/dist/sync/inbound-event-admission.test.js.map +0 -1
  889. package/dist/sync/journal-ledger-stream.test.d.ts +0 -12
  890. package/dist/sync/journal-ledger-stream.test.d.ts.map +0 -1
  891. package/dist/sync/journal-ledger-stream.test.js +0 -134
  892. package/dist/sync/journal-ledger-stream.test.js.map +0 -1
  893. package/dist/sync/lease-client.test.d.ts +0 -2
  894. package/dist/sync/lease-client.test.d.ts.map +0 -1
  895. package/dist/sync/lease-client.test.js +0 -119
  896. package/dist/sync/lease-client.test.js.map +0 -1
  897. package/dist/sync/logger.test.d.ts +0 -19
  898. package/dist/sync/logger.test.d.ts.map +0 -1
  899. package/dist/sync/logger.test.js +0 -204
  900. package/dist/sync/logger.test.js.map +0 -1
  901. package/dist/sync/metrics.test.d.ts +0 -19
  902. package/dist/sync/metrics.test.d.ts.map +0 -1
  903. package/dist/sync/metrics.test.js +0 -357
  904. package/dist/sync/metrics.test.js.map +0 -1
  905. package/dist/sync/multipart-uploader.test.d.ts +0 -2
  906. package/dist/sync/multipart-uploader.test.d.ts.map +0 -1
  907. package/dist/sync/multipart-uploader.test.js +0 -135
  908. package/dist/sync/multipart-uploader.test.js.map +0 -1
  909. package/dist/sync/mutation-client.test.d.ts +0 -2
  910. package/dist/sync/mutation-client.test.d.ts.map +0 -1
  911. package/dist/sync/mutation-client.test.js +0 -100
  912. package/dist/sync/mutation-client.test.js.map +0 -1
  913. package/dist/sync/mutation-session.test.d.ts +0 -2
  914. package/dist/sync/mutation-session.test.d.ts.map +0 -1
  915. package/dist/sync/mutation-session.test.js +0 -190
  916. package/dist/sync/mutation-session.test.js.map +0 -1
  917. package/dist/sync/parallel-sync-coordinator.test.d.ts +0 -2
  918. package/dist/sync/parallel-sync-coordinator.test.d.ts.map +0 -1
  919. package/dist/sync/parallel-sync-coordinator.test.js +0 -564
  920. package/dist/sync/parallel-sync-coordinator.test.js.map +0 -1
  921. package/dist/sync/path-leases.test.d.ts +0 -2
  922. package/dist/sync/path-leases.test.d.ts.map +0 -1
  923. package/dist/sync/path-leases.test.js +0 -51
  924. package/dist/sync/path-leases.test.js.map +0 -1
  925. package/dist/sync/per-area-maintenance.test.d.ts +0 -2
  926. package/dist/sync/per-area-maintenance.test.d.ts.map +0 -1
  927. package/dist/sync/per-area-maintenance.test.js +0 -100
  928. package/dist/sync/per-area-maintenance.test.js.map +0 -1
  929. package/dist/sync/push-event.test.d.ts +0 -15
  930. package/dist/sync/push-event.test.d.ts.map +0 -1
  931. package/dist/sync/push-event.test.js +0 -282
  932. package/dist/sync/push-event.test.js.map +0 -1
  933. package/dist/sync/push-receiver.test.d.ts +0 -25
  934. package/dist/sync/push-receiver.test.d.ts.map +0 -1
  935. package/dist/sync/push-receiver.test.js +0 -2851
  936. package/dist/sync/push-receiver.test.js.map +0 -1
  937. package/dist/sync/push-transport.test.d.ts +0 -2
  938. package/dist/sync/push-transport.test.d.ts.map +0 -1
  939. package/dist/sync/push-transport.test.js +0 -70
  940. package/dist/sync/push-transport.test.js.map +0 -1
  941. package/dist/sync/push-walk-matchers.bench.test.d.ts +0 -2
  942. package/dist/sync/push-walk-matchers.bench.test.d.ts.map +0 -1
  943. package/dist/sync/push-walk-matchers.bench.test.js +0 -230
  944. package/dist/sync/push-walk-matchers.bench.test.js.map +0 -1
  945. package/dist/sync/realtime-network-integration.test.d.ts +0 -2
  946. package/dist/sync/realtime-network-integration.test.d.ts.map +0 -1
  947. package/dist/sync/realtime-network-integration.test.js +0 -83
  948. package/dist/sync/realtime-network-integration.test.js.map +0 -1
  949. package/dist/sync/realtime-rollout.test.d.ts +0 -2
  950. package/dist/sync/realtime-rollout.test.d.ts.map +0 -1
  951. package/dist/sync/realtime-rollout.test.js +0 -94
  952. package/dist/sync/realtime-rollout.test.js.map +0 -1
  953. package/dist/sync/realtime-telemetry.test.d.ts +0 -2
  954. package/dist/sync/realtime-telemetry.test.d.ts.map +0 -1
  955. package/dist/sync/realtime-telemetry.test.js +0 -300
  956. package/dist/sync/realtime-telemetry.test.js.map +0 -1
  957. package/dist/sync/receiver-origin-config.test.d.ts +0 -2
  958. package/dist/sync/receiver-origin-config.test.d.ts.map +0 -1
  959. package/dist/sync/receiver-origin-config.test.js +0 -39
  960. package/dist/sync/receiver-origin-config.test.js.map +0 -1
  961. package/dist/sync/reconcile-cursor.test.d.ts +0 -2
  962. package/dist/sync/reconcile-cursor.test.d.ts.map +0 -1
  963. package/dist/sync/reconcile-cursor.test.js +0 -139
  964. package/dist/sync/reconcile-cursor.test.js.map +0 -1
  965. package/dist/sync/remote-file-spool.test.d.ts +0 -2
  966. package/dist/sync/remote-file-spool.test.d.ts.map +0 -1
  967. package/dist/sync/remote-file-spool.test.js +0 -77
  968. package/dist/sync/remote-file-spool.test.js.map +0 -1
  969. package/dist/sync/scope-inventory-client.test.d.ts +0 -2
  970. package/dist/sync/scope-inventory-client.test.d.ts.map +0 -1
  971. package/dist/sync/scope-inventory-client.test.js +0 -103
  972. package/dist/sync/scope-inventory-client.test.js.map +0 -1
  973. package/dist/sync/secret-path-policy.test.d.ts +0 -2
  974. package/dist/sync/secret-path-policy.test.d.ts.map +0 -1
  975. package/dist/sync/secret-path-policy.test.js +0 -183
  976. package/dist/sync/secret-path-policy.test.js.map +0 -1
  977. package/dist/sync/snapshot-client.test.d.ts +0 -2
  978. package/dist/sync/snapshot-client.test.d.ts.map +0 -1
  979. package/dist/sync/snapshot-client.test.js +0 -169
  980. package/dist/sync/snapshot-client.test.js.map +0 -1
  981. package/dist/sync/state-store.test.d.ts +0 -2
  982. package/dist/sync/state-store.test.d.ts.map +0 -1
  983. package/dist/sync/state-store.test.js +0 -1556
  984. package/dist/sync/state-store.test.js.map +0 -1
  985. package/dist/sync/sync-contract.test.d.ts +0 -2
  986. package/dist/sync/sync-contract.test.d.ts.map +0 -1
  987. package/dist/sync/sync-contract.test.js +0 -141
  988. package/dist/sync/sync-contract.test.js.map +0 -1
  989. package/dist/sync/targeted-pull-prefixes.test.d.ts +0 -2
  990. package/dist/sync/targeted-pull-prefixes.test.d.ts.map +0 -1
  991. package/dist/sync/targeted-pull-prefixes.test.js +0 -33
  992. package/dist/sync/targeted-pull-prefixes.test.js.map +0 -1
  993. package/dist/sync/targeted-upsert-exact-list.test.d.ts +0 -2
  994. package/dist/sync/targeted-upsert-exact-list.test.d.ts.map +0 -1
  995. package/dist/sync/targeted-upsert-exact-list.test.js +0 -247
  996. package/dist/sync/targeted-upsert-exact-list.test.js.map +0 -1
  997. package/dist/sync/unchanged-push-events.test.d.ts +0 -2
  998. package/dist/sync/unchanged-push-events.test.d.ts.map +0 -1
  999. package/dist/sync/unchanged-push-events.test.js +0 -76
  1000. package/dist/sync/unchanged-push-events.test.js.map +0 -1
  1001. package/dist/sync/uploader-finalization.test.d.ts +0 -2
  1002. package/dist/sync/uploader-finalization.test.d.ts.map +0 -1
  1003. package/dist/sync/uploader-finalization.test.js +0 -107
  1004. package/dist/sync/uploader-finalization.test.js.map +0 -1
  1005. package/dist/sync-core.test.d.ts +0 -2
  1006. package/dist/sync-core.test.d.ts.map +0 -1
  1007. package/dist/sync-core.test.js +0 -189
  1008. package/dist/sync-core.test.js.map +0 -1
  1009. package/dist/sync-progress.test.d.ts +0 -2
  1010. package/dist/sync-progress.test.d.ts.map +0 -1
  1011. package/dist/sync-progress.test.js +0 -277
  1012. package/dist/sync-progress.test.js.map +0 -1
  1013. package/dist/telemetry-events.test.d.ts +0 -2
  1014. package/dist/telemetry-events.test.d.ts.map +0 -1
  1015. package/dist/telemetry-events.test.js +0 -113
  1016. package/dist/telemetry-events.test.js.map +0 -1
  1017. package/dist/telemetry.test.d.ts +0 -11
  1018. package/dist/telemetry.test.d.ts.map +0 -1
  1019. package/dist/telemetry.test.js +0 -1603
  1020. package/dist/telemetry.test.js.map +0 -1
  1021. package/dist/vault-client.test.d.ts +0 -7
  1022. package/dist/vault-client.test.d.ts.map +0 -1
  1023. package/dist/vault-client.test.js +0 -1334
  1024. package/dist/vault-client.test.js.map +0 -1
  1025. package/dist/watch-coverage.test.d.ts +0 -2
  1026. package/dist/watch-coverage.test.d.ts.map +0 -1
  1027. package/dist/watch-coverage.test.js +0 -56
  1028. package/dist/watch-coverage.test.js.map +0 -1
  1029. package/dist/watch-owner-lease.test.d.ts +0 -2
  1030. package/dist/watch-owner-lease.test.d.ts.map +0 -1
  1031. package/dist/watch-owner-lease.test.js +0 -353
  1032. package/dist/watch-owner-lease.test.js.map +0 -1
  1033. package/dist/watch-parent.test.d.ts +0 -2
  1034. package/dist/watch-parent.test.d.ts.map +0 -1
  1035. package/dist/watch-parent.test.js +0 -131
  1036. package/dist/watch-parent.test.js.map +0 -1
  1037. package/dist/watch-roots.test.d.ts +0 -18
  1038. package/dist/watch-roots.test.d.ts.map +0 -1
  1039. package/dist/watch-roots.test.js +0 -395
  1040. package/dist/watch-roots.test.js.map +0 -1
  1041. package/dist/watcher-continuity-hot-path.test.d.ts +0 -2
  1042. package/dist/watcher-continuity-hot-path.test.d.ts.map +0 -1
  1043. package/dist/watcher-continuity-hot-path.test.js +0 -40
  1044. package/dist/watcher-continuity-hot-path.test.js.map +0 -1
  1045. package/dist/watcher-event-gate.test.d.ts +0 -16
  1046. package/dist/watcher-event-gate.test.d.ts.map +0 -1
  1047. package/dist/watcher-event-gate.test.js +0 -191
  1048. package/dist/watcher-event-gate.test.js.map +0 -1
  1049. package/dist/watcher-replan-sweep.test.d.ts +0 -2
  1050. package/dist/watcher-replan-sweep.test.d.ts.map +0 -1
  1051. package/dist/watcher-replan-sweep.test.js +0 -245
  1052. package/dist/watcher-replan-sweep.test.js.map +0 -1
  1053. package/dist/watcher-stdout.test.d.ts +0 -2
  1054. package/dist/watcher-stdout.test.d.ts.map +0 -1
  1055. package/dist/watcher-stdout.test.js +0 -86
  1056. package/dist/watcher-stdout.test.js.map +0 -1
  1057. package/dist/watcher.test.d.ts +0 -2
  1058. package/dist/watcher.test.d.ts.map +0 -1
  1059. package/dist/watcher.test.js +0 -4301
  1060. package/dist/watcher.test.js.map +0 -1
  1061. package/dist/watcher.warmup-cancel.test.d.ts +0 -2
  1062. package/dist/watcher.warmup-cancel.test.d.ts.map +0 -1
  1063. package/dist/watcher.warmup-cancel.test.js +0 -59
  1064. package/dist/watcher.warmup-cancel.test.js.map +0 -1
  1065. package/dist/watcher.warmup-responsive.test.d.ts +0 -2
  1066. package/dist/watcher.warmup-responsive.test.d.ts.map +0 -1
  1067. package/dist/watcher.warmup-responsive.test.js +0 -62
  1068. package/dist/watcher.warmup-responsive.test.js.map +0 -1
@@ -1,4301 +0,0 @@
1
- import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
2
- import { EventEmitter } from "node:events";
3
- import * as fs from "fs";
4
- import * as os from "os";
5
- import * as path from "path";
6
- import { createHash } from "node:crypto";
7
- import { monitorEventLoopDelay } from "node:perf_hooks";
8
- import { watch } from "chokidar";
9
- import { FakeClock, WatchPushDriver, TreeWatcher, ChokidarWatchBudget, createWatchPathFilter, toChokidarIgnored, PushEventEmitter, PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES, PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP, resolveEventDebounceConfig, summarizePublishBatchOutcome, DEFAULT_EVENT_DEBOUNCE_MS, DEFAULT_EVENT_MAX_WAIT_MS, DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS, DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS, DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES, DEFAULT_PUBLISH_MIN_INTERVAL_MS, EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV, EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV, EVENT_SMALL_BATCH_MAX_PATHS_ENV, EVENT_SMALL_BATCH_MAX_BYTES_ENV, DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS, MAX_TREE_WATCHER_MAX_WATCHED_PATHS, TREE_WATCHER_MAX_WATCHED_PATHS_ENV, resolveMaxWatchedPaths, PUBLISH_MIN_INTERVAL_MS_ENV, resolvePublishMinIntervalMs, DurablePublishedContentHashes, DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS, replayDurablePublishedContentHashRecordsForTest, startScopedRecursiveWatch, startNativeRootWatch, startTreeWatch, selectTreeWatchBackend } from "./watcher.js";
10
- import { StateStore, StateStoreLockError } from "./sync/state-store.js";
11
- import { HttpPushTransport, PushScopeForbiddenError, RealtimeUnavailableError, } from "./sync/push-transport.js";
12
- import { computePersonalVaultPaths } from "./personal-vault.js";
13
- /**
14
- * US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
15
- *
16
- * These tests drive the debounce/coalesce core ({@link WatchPushDriver}) with an
17
- * injected {@link FakeClock} and an injected spy push fn — no real chokidar
18
- * watcher, no real S3, no real 10-minute sleep. US-002/US-003 build the real
19
- * watcher + targeted-push fn on top of this same seam, so the tests here also
20
- * serve as regression guards for the contract those stories depend on.
21
- */
22
- const DEBOUNCE = 2000;
23
- describe("scoped watcher resource errors", () => {
24
- let root;
25
- beforeEach(() => {
26
- root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-resource-error-")));
27
- });
28
- afterEach(() => {
29
- fs.rmSync(root, { recursive: true, force: true });
30
- });
31
- it("does not reopen a directory watch after EMFILE", () => {
32
- const docs = path.join(root, "docs");
33
- fs.mkdirSync(docs);
34
- class FakeWatcher extends EventEmitter {
35
- close() {
36
- this.emit("close");
37
- return this;
38
- }
39
- }
40
- const handles = new Map();
41
- const watchedPaths = [];
42
- const backend = startScopedRecursiveWatch(root, createWatchPathFilter(root, false), () => { }, () => { }, undefined, {
43
- recursiveWatches: false,
44
- listChildDirs: (dir) => dir === root ? [docs] : [],
45
- watchDirectory: (dir) => {
46
- const watchPath = dir.toString();
47
- watchedPaths.push(watchPath);
48
- const handle = new FakeWatcher();
49
- handles.set(watchPath, handle);
50
- return handle;
51
- },
52
- });
53
- try {
54
- expect(watchedPaths.filter((dir) => dir === docs)).toHaveLength(1);
55
- handles.get(docs).emit("error", Object.assign(new Error("too many open files"), { code: "EMFILE" }));
56
- expect(watchedPaths.filter((dir) => dir === docs)).toHaveLength(1);
57
- }
58
- finally {
59
- backend.close();
60
- }
61
- });
62
- });
63
- function publishedHashRecords(count) {
64
- const maxPaths = Math.max(1, Math.floor(count / 2));
65
- let seed = 0x14c0ffee;
66
- const next = () => {
67
- seed = (seed * 1_664_525 + 1_013_904_223) >>> 0;
68
- return seed;
69
- };
70
- return Array.from({ length: count }, (_, index) => {
71
- const relativePath = `path-${next() % maxPaths}`;
72
- return next() % 5 === 0
73
- ? { sequence: index + 1, type: 0x5032, flags: 0, payload: { relativePath } }
74
- : {
75
- sequence: index + 1,
76
- type: 0x5031,
77
- flags: 0,
78
- payload: { relativePath, contentHash: `sha256:${next()}`, maxPaths },
79
- };
80
- });
81
- }
82
- /**
83
- * Build a driver wired to a FakeClock and a spy push fn. Returns everything a
84
- * test needs to drive and assert against the seam. This helper is the reusable
85
- * "test harness" the story asks for.
86
- */
87
- function makeHarness(opts) {
88
- const clock = new FakeClock();
89
- const push = vi.fn(() => { });
90
- const driver = new WatchPushDriver({
91
- debounceMs: opts?.debounceMs ?? DEBOUNCE,
92
- clock,
93
- push,
94
- });
95
- return {
96
- clock,
97
- push,
98
- driver,
99
- /** Emit a synthetic file-change event into the driver. */
100
- emitChange: () => driver.notifyChange(),
101
- /** Advance virtual time. */
102
- advance: (ms) => clock.advance(ms),
103
- };
104
- }
105
- async function flushImmediate() {
106
- await new Promise((resolve) => setImmediate(resolve));
107
- }
108
- describe("FakeClock", () => {
109
- it("fires a timer exactly when its deadline is reached", () => {
110
- const clock = new FakeClock();
111
- const fn = vi.fn();
112
- clock.setTimeout(fn, 100);
113
- clock.advance(99);
114
- expect(fn).not.toHaveBeenCalled();
115
- clock.advance(1);
116
- expect(fn).toHaveBeenCalledTimes(1);
117
- });
118
- it("does not fire a cleared timer", () => {
119
- const clock = new FakeClock();
120
- const fn = vi.fn();
121
- const handle = clock.setTimeout(fn, 100);
122
- clock.clearTimeout(handle);
123
- clock.advance(1000);
124
- expect(fn).not.toHaveBeenCalled();
125
- expect(clock.pendingTimerCount()).toBe(0);
126
- });
127
- it("reports pending timer count for leak checks", () => {
128
- const clock = new FakeClock();
129
- clock.setTimeout(() => { }, 100);
130
- clock.setTimeout(() => { }, 200);
131
- expect(clock.pendingTimerCount()).toBe(2);
132
- clock.advance(100);
133
- expect(clock.pendingTimerCount()).toBe(1);
134
- });
135
- });
136
- describe("TreeWatcher adaptive watch budget", () => {
137
- it("uses half of Linux max_user_watches, bounded by the floor and ceiling", () => {
138
- const readFileSync = vi.fn(() => "1048576\n");
139
- expect(resolveMaxWatchedPaths(undefined, {
140
- platform: "linux",
141
- env: {},
142
- readFileSync,
143
- })).toBe(MAX_TREE_WATCHER_MAX_WATCHED_PATHS);
144
- expect(readFileSync).toHaveBeenCalledWith("/proc/sys/fs/inotify/max_user_watches", "utf8");
145
- expect(resolveMaxWatchedPaths(undefined, {
146
- platform: "linux",
147
- env: {},
148
- readFileSync: () => "40000\n",
149
- })).toBe(DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS);
150
- expect(resolveMaxWatchedPaths(undefined, {
151
- platform: "linux",
152
- env: {},
153
- readFileSync: () => "8192\n",
154
- })).toBe(4096);
155
- });
156
- it("keeps an explicit environment override authoritative", () => {
157
- expect(resolveMaxWatchedPaths(undefined, {
158
- platform: "linux",
159
- env: { [TREE_WATCHER_MAX_WATCHED_PATHS_ENV]: "500000" },
160
- readFileSync: () => {
161
- throw new Error("must not read when overridden");
162
- },
163
- })).toBe(500_000);
164
- });
165
- it("falls back to the established constant off Linux or when proc is unreadable", () => {
166
- expect(resolveMaxWatchedPaths(undefined, {
167
- platform: "darwin",
168
- env: {},
169
- readFileSync: () => "1048576\n",
170
- })).toBe(DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS);
171
- expect(resolveMaxWatchedPaths(undefined, {
172
- platform: "linux",
173
- env: {},
174
- readFileSync: () => {
175
- throw new Error("proc unavailable");
176
- },
177
- })).toBe(DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS);
178
- });
179
- });
180
- describe("TreeWatcher pending backlog caps", () => {
181
- it("collects only warm-up mutations plus journal-backed deletions in one local scan", () => {
182
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-"));
183
- try {
184
- for (let i = 0; i < 12; i++)
185
- fs.writeFileSync(path.join(dir, `unchanged-${i}.md`), "old");
186
- const settle = Date.now() + 50;
187
- while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
188
- const cutoff = Date.now();
189
- while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
190
- const fresh = path.join(dir, "created-during-warmup.md");
191
- fs.writeFileSync(fresh, "new");
192
- const capture = vi.fn(() => []);
193
- const watcher = new TreeWatcher({
194
- hqRoot: dir,
195
- pathFilter: () => true,
196
- captureLocalDeleteSnapshots: capture,
197
- });
198
- const result = watcher.collectWarmupCatchup(cutoff, ["gone-during-warmup.md"]);
199
- expect([...result.batch.paths.values()]).toEqual([
200
- "created-during-warmup.md",
201
- "gone-during-warmup.md",
202
- ]);
203
- expect(result.batch.changes?.get(path.join(dir, "gone-during-warmup.md")))
204
- .toMatchObject({ kind: "unlink" });
205
- expect(capture).toHaveBeenCalledWith("gone-during-warmup.md", "unlink");
206
- expect(result.scannedPaths).toBe(13);
207
- }
208
- finally {
209
- fs.rmSync(dir, { recursive: true, force: true });
210
- }
211
- });
212
- it("yields through a 50k-row warm-up journal catch-up without losing coverage", async () => {
213
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-yield-"));
214
- try {
215
- const watcher = new TreeWatcher({ hqRoot: dir, pathFilter: () => true });
216
- const journalRows = Array.from({ length: 50_000 }, (_, index) => `personal/missing-${index}.md`);
217
- const histogram = monitorEventLoopDelay({ resolution: 1 });
218
- let eventLoopTicks = 0;
219
- let keepTicking = true;
220
- const tick = () => {
221
- if (!keepTicking)
222
- return;
223
- eventLoopTicks += 1;
224
- setImmediate(tick);
225
- };
226
- histogram.enable();
227
- setImmediate(tick);
228
- const result = await watcher.collectWarmupCatchupAsync(Date.now(), journalRows);
229
- keepTicking = false;
230
- await flushImmediate();
231
- histogram.disable();
232
- expect(result.scannedPaths).toBe(0);
233
- expect(result.batch.paths.size).toBe(50_000);
234
- expect(eventLoopTicks).toBeGreaterThan(1);
235
- expect(Number(histogram.max) / 1_000_000).toBeLessThan(50);
236
- }
237
- finally {
238
- fs.rmSync(dir, { recursive: true, force: true });
239
- }
240
- });
241
- it("uses async filesystem calls and prunes excluded trees during warm-up", async () => {
242
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-async-fs-"));
243
- const allowed = path.join(dir, "core", "knowledge", "public");
244
- const nodeModules = path.join(dir, "core", "workers", "demo", "node_modules", "pkg");
245
- const target = path.join(dir, "core", "packages", "demo", "target", "debug");
246
- fs.mkdirSync(allowed, { recursive: true });
247
- fs.mkdirSync(nodeModules, { recursive: true });
248
- fs.mkdirSync(target, { recursive: true });
249
- fs.writeFileSync(path.join(allowed, "keep.md"), "keep");
250
- fs.writeFileSync(path.join(nodeModules, "skip.js"), "skip");
251
- fs.writeFileSync(path.join(target, "skip.o"), "skip");
252
- try {
253
- const watcher = new TreeWatcher({
254
- hqRoot: dir,
255
- pathFilter: createWatchPathFilter(dir, false),
256
- });
257
- const result = await watcher.collectWarmupCatchupAsync(Date.now() - 10_000);
258
- expect(result.scannedPaths).toBeGreaterThan(0);
259
- expect([...result.batch.paths.values()]).toEqual([
260
- "core/knowledge/public/keep.md",
261
- ]);
262
- }
263
- finally {
264
- fs.rmSync(dir, { recursive: true, force: true });
265
- }
266
- });
267
- it("stops a yielding warm-up catch-up without scheduling the remaining chunks", async () => {
268
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-stop-"));
269
- const originalSetImmediate = globalThis.setImmediate;
270
- let releaseFirstYield;
271
- let firstYieldReached;
272
- const firstYield = new Promise((resolve) => {
273
- firstYieldReached = resolve;
274
- });
275
- const setImmediateSpy = vi.spyOn(globalThis, "setImmediate").mockImplementation(((callback, ...args) => {
276
- if (!releaseFirstYield) {
277
- releaseFirstYield = () => callback(...args);
278
- firstYieldReached?.();
279
- return undefined;
280
- }
281
- return originalSetImmediate(callback, ...args);
282
- }));
283
- try {
284
- const watcher = new TreeWatcher({ hqRoot: dir, pathFilter: () => true });
285
- const journalRows = Array.from({ length: 256 }, (_, index) => `personal/missing-${index}.md`);
286
- const catchup = watcher.collectWarmupCatchupAsync(Date.now(), journalRows);
287
- await firstYield;
288
- watcher.stop();
289
- releaseFirstYield?.();
290
- const result = await catchup;
291
- expect(result.cancelled).toBe(true);
292
- expect(result.batch.paths.size).toBeLessThan(journalRows.length);
293
- expect(setImmediateSpy).toHaveBeenCalledTimes(1);
294
- }
295
- finally {
296
- setImmediateSpy.mockRestore();
297
- fs.rmSync(dir, { recursive: true, force: true });
298
- }
299
- });
300
- it("keeps a warm-up epoch only for a queued deletion capture, and only while it runs", async () => {
301
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-epochs-"));
302
- try {
303
- for (let i = 0; i < 6; i++)
304
- fs.writeFileSync(path.join(dir, `unchanged-${i}.md`), "old");
305
- const settle = Date.now() + 50;
306
- while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
307
- const cutoff = Date.now();
308
- while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
309
- for (let i = 0; i < 4; i++)
310
- fs.writeFileSync(path.join(dir, `fresh-${i}.md`), "new");
311
- let resolveCapture;
312
- const watcher = new TreeWatcher({
313
- hqRoot: dir,
314
- pathFilter: () => true,
315
- queueLocalDeleteSnapshots: async () => {
316
- await new Promise((resolve) => { resolveCapture = resolve; });
317
- return [];
318
- },
319
- });
320
- const result = watcher.collectWarmupCatchup(cutoff, ["gone-during-warmup.md"]);
321
- expect([...result.batch.paths.values()].sort()).toEqual([
322
- "fresh-0.md", "fresh-1.md", "fresh-2.md", "fresh-3.md", "gone-during-warmup.md",
323
- ]);
324
- // Four live changes were recorded, but only the deletion has a capture
325
- // in flight, so only the deletion holds an epoch.
326
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
327
- await new Promise((resolve) => setImmediate(resolve)); // the queued job starts
328
- resolveCapture();
329
- await new Promise((resolve) => setImmediate(resolve));
330
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
331
- watcher.dispose();
332
- }
333
- finally {
334
- fs.rmSync(dir, { recursive: true, force: true });
335
- }
336
- });
337
- it("does not report a journal row as deleted when its file exists behind a symlinked directory", () => {
338
- // The warm-up walk never descends through a symlinked directory (a Dirent
339
- // symlink is not a directory), so rows journaled under such a path were
340
- // never `observed` and every one of them came back as an `unlink` even
341
- // though the file was right there. On one HQ, `.codex/claude -> ../.claude`
342
- // made 11.4k present state files look deleted on every runner start: 11.4k
343
- // delete roots, 11.4k queued delete-intent writes, and a delete plan that
344
- // then refused each one. A journal row the walk missed must be stat'ed like
345
- // any walked file: absent -> unlink, present and unchanged -> nothing,
346
- // present and touched since the cutoff -> change.
347
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-symlink-dir-"));
348
- try {
349
- fs.mkdirSync(path.join(dir, ".claude", "state"), { recursive: true });
350
- fs.writeFileSync(path.join(dir, ".claude", "state", "stable.txt"), "old");
351
- fs.mkdirSync(path.join(dir, ".codex"));
352
- fs.symlinkSync(path.join("..", ".claude"), path.join(dir, ".codex", "claude"), "dir");
353
- const settle = Date.now() + 50;
354
- while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
355
- const cutoff = Date.now();
356
- while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
357
- fs.writeFileSync(path.join(dir, ".claude", "state", "fresh.txt"), "new");
358
- const capture = vi.fn(() => []);
359
- const watcher = new TreeWatcher({
360
- hqRoot: dir,
361
- pathFilter: () => true,
362
- captureLocalDeleteSnapshots: capture,
363
- });
364
- const result = watcher.collectWarmupCatchup(cutoff, [
365
- ".codex/claude/state/stable.txt",
366
- ".codex/claude/state/fresh.txt",
367
- ".codex/claude/state/gone.txt",
368
- ]);
369
- const kinds = Object.fromEntries([...result.batch.paths].map(([absolute, relative]) => [relative, result.batch.changes?.get(absolute)?.kind]));
370
- expect(kinds).toEqual({
371
- ".claude/state/fresh.txt": "change",
372
- ".codex/claude/state/fresh.txt": "change",
373
- ".codex/claude/state/gone.txt": "unlink",
374
- });
375
- expect(capture).toHaveBeenCalledTimes(1);
376
- expect(capture).toHaveBeenCalledWith(".codex/claude/state/gone.txt", "unlink");
377
- }
378
- finally {
379
- fs.rmSync(dir, { recursive: true, force: true });
380
- }
381
- });
382
- it("canonicalizes a legacy journal key before adding its warm-up deletion to the batch", () => {
383
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-posix-key-"));
384
- try {
385
- const capture = vi.fn(() => []);
386
- const watcher = new TreeWatcher({
387
- hqRoot: dir,
388
- pathFilter: () => true,
389
- captureLocalDeleteSnapshots: capture,
390
- });
391
- const result = watcher.collectWarmupCatchup(Date.now(), [
392
- "projects\\forecast\\x.csv",
393
- ]);
394
- const absolute = path.join(dir, "projects", "forecast", "x.csv");
395
- expect([...result.batch.paths.entries()]).toEqual([
396
- [absolute, "projects/forecast/x.csv"],
397
- ]);
398
- expect(capture).toHaveBeenCalledWith("projects/forecast/x.csv", "unlink");
399
- }
400
- finally {
401
- fs.rmSync(dir, { recursive: true, force: true });
402
- }
403
- });
404
- it("recovers a warm-up path even when the event backlog dropped it", () => {
405
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-warmup-overflow-"));
406
- try {
407
- const retained = path.join(dir, "retained.md");
408
- fs.writeFileSync(retained, "old");
409
- const settle = Date.now() + 50;
410
- while (Date.now() < settle) { /* let old ctime settle below the cutoff */ }
411
- const cutoff = Date.now();
412
- while (Date.now() < cutoff + 50) { /* filesystems may round ctime down */ }
413
- const dropped = path.join(dir, "dropped-during-warmup.md");
414
- fs.writeFileSync(dropped, "new");
415
- const watcher = new TreeWatcher({
416
- hqRoot: dir,
417
- pathFilter: () => true,
418
- maxPendingPaths: 1,
419
- maxPendingBytes: 100_000,
420
- });
421
- watcher.handleEvent(retained);
422
- watcher.handleEvent(dropped);
423
- const result = watcher.collectWarmupCatchup(cutoff);
424
- expect([...result.batch.paths.values()]).toEqual(["dropped-during-warmup.md"]);
425
- }
426
- finally {
427
- fs.rmSync(dir, { recursive: true, force: true });
428
- }
429
- });
430
- it("honors the pending-path environment cap in the production constructor", () => {
431
- vi.stubEnv("HQ_SYNC_MAX_PENDING_PATHS", "3");
432
- vi.stubEnv("HQ_SYNC_MAX_PENDING_BYTES", "100000");
433
- try {
434
- const clock = new FakeClock();
435
- const changed = vi.fn();
436
- const watcher = new TreeWatcher({
437
- hqRoot: ROOT,
438
- clock,
439
- debounceMs: DEBOUNCE,
440
- pathFilter: () => true,
441
- });
442
- watcher.onChange(changed);
443
- for (let i = 0; i < 4; i++) {
444
- watcher.handleEvent(path.join(ROOT, `env-cap-${i}.md`));
445
- }
446
- clock.advance(DEBOUNCE);
447
- expect(changed.mock.calls[0][1].paths.size).toBe(3);
448
- expect(changed.mock.calls[0][1].overflowed).toBe(true);
449
- }
450
- finally {
451
- vi.unstubAllEnvs();
452
- }
453
- });
454
- it("honors the pending-byte environment cap in the production constructor", () => {
455
- vi.stubEnv("HQ_SYNC_MAX_PENDING_PATHS", "100");
456
- vi.stubEnv("HQ_SYNC_MAX_PENDING_BYTES", "1");
457
- try {
458
- const clock = new FakeClock();
459
- const changed = vi.fn();
460
- const watcher = new TreeWatcher({
461
- hqRoot: ROOT,
462
- clock,
463
- debounceMs: DEBOUNCE,
464
- pathFilter: () => true,
465
- });
466
- watcher.onChange(changed);
467
- watcher.handleEvent(path.join(ROOT, "byte-cap.md"));
468
- clock.advance(DEBOUNCE);
469
- expect(changed.mock.calls[0][1].paths.size).toBe(0);
470
- expect(changed.mock.calls[0][1].overflowed).toBe(true);
471
- }
472
- finally {
473
- vi.unstubAllEnvs();
474
- }
475
- });
476
- it.each([
477
- ["HQ_SYNC_MAX_PENDING_PATHS", "0"],
478
- ["HQ_SYNC_MAX_PENDING_PATHS", "0.5"],
479
- ["HQ_SYNC_MAX_PENDING_PATHS", "-1"],
480
- ["HQ_SYNC_MAX_PENDING_PATHS", "not-a-number"],
481
- ["HQ_SYNC_MAX_PENDING_BYTES", "0"],
482
- ["HQ_SYNC_MAX_PENDING_BYTES", "0.5"],
483
- ["HQ_SYNC_MAX_PENDING_BYTES", "-1"],
484
- ["HQ_SYNC_MAX_PENDING_BYTES", "not-a-number"],
485
- ])("warns once and falls back for invalid %s=%s", (name, value) => {
486
- vi.stubEnv(name, value);
487
- const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
488
- try {
489
- new TreeWatcher({ hqRoot: ROOT });
490
- expect(warn).toHaveBeenCalledOnce();
491
- expect(warn).toHaveBeenCalledWith(expect.stringContaining(name));
492
- }
493
- finally {
494
- warn.mockRestore();
495
- vi.unstubAllEnvs();
496
- }
497
- });
498
- it("keeps a burst at the configured cap without marking it overflowed", () => {
499
- vi.stubEnv("HQ_SYNC_MAX_PENDING_PATHS", "4");
500
- vi.stubEnv("HQ_SYNC_MAX_PENDING_BYTES", "100000");
501
- try {
502
- const clock = new FakeClock();
503
- const changed = vi.fn();
504
- const overflow = vi.fn();
505
- const watcher = new TreeWatcher({
506
- hqRoot: ROOT,
507
- clock,
508
- debounceMs: DEBOUNCE,
509
- pathFilter: () => true,
510
- onBacklogOverflow: overflow,
511
- });
512
- watcher.onChange(changed);
513
- for (let i = 0; i < 4; i++) {
514
- watcher.handleEvent(path.join(ROOT, `below-cap-${i}.md`));
515
- }
516
- clock.advance(DEBOUNCE);
517
- expect(overflow).not.toHaveBeenCalled();
518
- expect(changed.mock.calls[0][1]).not.toHaveProperty("overflowed");
519
- }
520
- finally {
521
- vi.unstubAllEnvs();
522
- }
523
- });
524
- it("reports the hit cap and cumulative dropped count in every overflow episode", () => {
525
- const clock = new FakeClock();
526
- const overflow = vi.fn();
527
- const watcher = new TreeWatcher({
528
- hqRoot: ROOT,
529
- clock,
530
- debounceMs: DEBOUNCE,
531
- pathFilter: () => true,
532
- maxPendingPaths: 1,
533
- maxPendingBytes: 100_000,
534
- onBacklogOverflow: overflow,
535
- });
536
- watcher.handleEvent(path.join(ROOT, "kept-first.md"));
537
- watcher.handleEvent(path.join(ROOT, "dropped-first.md"));
538
- clock.advance(DEBOUNCE);
539
- watcher.handleEvent(path.join(ROOT, "kept-second.md"));
540
- clock.advance(10);
541
- watcher.handleEvent(path.join(ROOT, "dropped-second.md"));
542
- expect(overflow).toHaveBeenCalledTimes(2);
543
- expect(overflow.mock.calls[0][0]).toMatchObject({
544
- capHit: "paths",
545
- totalDroppedPaths: 1,
546
- maxPendingPaths: 1,
547
- maxPendingBytes: 100_000,
548
- pendingPaths: 1,
549
- inFlightPassMs: 0,
550
- });
551
- expect(overflow.mock.calls[1][0]).toMatchObject({
552
- capHit: "paths",
553
- totalDroppedPaths: 2,
554
- inFlightPassMs: 10,
555
- });
556
- });
557
- it("logs every default overflow record with the cumulative total and hit cap", () => {
558
- const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
559
- try {
560
- const watcher = new TreeWatcher({
561
- hqRoot: ROOT,
562
- pathFilter: () => true,
563
- maxPendingPaths: 1,
564
- maxPendingBytes: 100_000,
565
- });
566
- watcher.handleEvent(path.join(ROOT, "kept.md"));
567
- watcher.handleEvent(path.join(ROOT, "dropped-one.md"));
568
- watcher.handleEvent(path.join(ROOT, "dropped-two.md"));
569
- expect(warn).toHaveBeenCalledTimes(2);
570
- expect(warn).toHaveBeenLastCalledWith(expect.stringContaining("backlog cap exceeded"), expect.objectContaining({
571
- capHit: "paths",
572
- totalDroppedPaths: 2,
573
- pendingPaths: 1,
574
- maxPendingPaths: 1,
575
- maxPendingBytes: 100_000,
576
- inFlightPassMs: expect.any(Number),
577
- }));
578
- }
579
- finally {
580
- warn.mockRestore();
581
- }
582
- });
583
- it("omits route hints when an overflow exceeds the route-evidence cap", () => {
584
- const clock = new FakeClock();
585
- const changed = vi.fn();
586
- const watcher = new TreeWatcher({
587
- hqRoot: ROOT,
588
- clock,
589
- debounceMs: DEBOUNCE,
590
- pathFilter: () => true,
591
- maxPendingPaths: 1,
592
- maxPendingBytes: 100_000,
593
- onBacklogOverflow: () => { },
594
- });
595
- watcher.onChange(changed);
596
- watcher.handleEvent(path.join(ROOT, "companies", "kept", "a.md"));
597
- for (let i = 0; i < 33; i++) {
598
- watcher.handleEvent(path.join(ROOT, "companies", `dropped-${i}`, "a.md"));
599
- }
600
- clock.advance(DEBOUNCE);
601
- expect(changed.mock.calls[0][1]).toMatchObject({ overflowed: true });
602
- expect(changed.mock.calls[0][1]).not.toHaveProperty("droppedRouteHints");
603
- });
604
- });
605
- describe("US-001: WatchPushDriver — debounced push seam", () => {
606
- it("calls the injected push fn exactly once within debounce+grace after a synthetic change", async () => {
607
- const h = makeHarness();
608
- h.emitChange();
609
- // Before the window elapses, no push.
610
- h.advance(DEBOUNCE - 1);
611
- expect(h.push).not.toHaveBeenCalled();
612
- // Crossing the debounce boundary fires the push exactly once.
613
- h.advance(1);
614
- await Promise.resolve();
615
- expect(h.push).toHaveBeenCalledTimes(1);
616
- });
617
- it("coalesces N rapid changes within the window to exactly 1 push", async () => {
618
- const h = makeHarness();
619
- // 5 synthetic changes spread across less than one debounce window.
620
- for (let i = 0; i < 5; i++) {
621
- h.emitChange();
622
- h.advance(100); // total 500ms << 2000ms debounce
623
- }
624
- // Window has not fully elapsed since the LAST change yet.
625
- expect(h.push).not.toHaveBeenCalled();
626
- // Let the quiet window after the final change elapse.
627
- h.advance(DEBOUNCE);
628
- await Promise.resolve();
629
- expect(h.push).toHaveBeenCalledTimes(1);
630
- });
631
- it("treats two separate bursts as two pushes", async () => {
632
- const h = makeHarness();
633
- h.emitChange();
634
- h.advance(DEBOUNCE);
635
- await Promise.resolve();
636
- expect(h.push).toHaveBeenCalledTimes(1);
637
- h.emitChange();
638
- h.advance(DEBOUNCE);
639
- await Promise.resolve();
640
- expect(h.push).toHaveBeenCalledTimes(2);
641
- });
642
- it("never overlaps an in-flight push; a mid-push change re-triggers one follow-up pass", async () => {
643
- const clock = new FakeClock();
644
- let release;
645
- const inFlight = new Promise((r) => {
646
- release = r;
647
- });
648
- let calls = 0;
649
- const push = vi.fn(() => {
650
- calls += 1;
651
- // First push hangs until released; later pushes resolve immediately.
652
- return calls === 1 ? inFlight : Promise.resolve();
653
- });
654
- const driver = new WatchPushDriver({ debounceMs: DEBOUNCE, clock, push });
655
- // First burst -> first push starts and hangs.
656
- driver.notifyChange();
657
- clock.advance(DEBOUNCE);
658
- await Promise.resolve();
659
- expect(push).toHaveBeenCalledTimes(1);
660
- expect(driver.isPushing()).toBe(true);
661
- // A change arrives while the first push is in flight: must NOT start a
662
- // concurrent push.
663
- driver.notifyChange();
664
- clock.advance(DEBOUNCE);
665
- await Promise.resolve();
666
- expect(push).toHaveBeenCalledTimes(1);
667
- // Release the first push; the collapsed mid-push change re-arms.
668
- release();
669
- await inFlight;
670
- await Promise.resolve();
671
- // Re-armed window must elapse before the follow-up push fires.
672
- clock.advance(DEBOUNCE);
673
- await Promise.resolve();
674
- expect(push).toHaveBeenCalledTimes(2);
675
- driver.dispose();
676
- });
677
- it("F17: rejected watch push is caught instead of surfacing as an unhandled rejection", async () => {
678
- const clock = new FakeClock();
679
- const rejection = new Error("push failed");
680
- const push = vi.fn(async () => {
681
- throw rejection;
682
- });
683
- const driver = new WatchPushDriver({ debounceMs: DEBOUNCE, clock, push });
684
- const unhandled = [];
685
- const onUnhandled = (reason) => {
686
- unhandled.push(reason);
687
- };
688
- process.prependListener("unhandledRejection", onUnhandled);
689
- try {
690
- driver.notifyChange();
691
- clock.advance(DEBOUNCE);
692
- await Promise.resolve();
693
- await flushImmediate();
694
- expect(push).toHaveBeenCalledTimes(1);
695
- expect(driver.isPushing()).toBe(false);
696
- expect(unhandled).toEqual([]);
697
- }
698
- finally {
699
- process.removeListener("unhandledRejection", onUnhandled);
700
- driver.dispose();
701
- }
702
- });
703
- it("respects a custom debounce window", async () => {
704
- const h = makeHarness({ debounceMs: 500 });
705
- h.emitChange();
706
- h.advance(499);
707
- expect(h.push).not.toHaveBeenCalled();
708
- h.advance(1);
709
- await Promise.resolve();
710
- expect(h.push).toHaveBeenCalledTimes(1);
711
- });
712
- });
713
- describe("US-001: WatchPushDriver — teardown leaves no leaked timers", () => {
714
- it("dispose() cancels a pending debounce timer", () => {
715
- const h = makeHarness();
716
- h.emitChange();
717
- expect(h.clock.pendingTimerCount()).toBe(1);
718
- h.driver.dispose();
719
- expect(h.clock.pendingTimerCount()).toBe(0);
720
- // A push must not fire after dispose, even if time advances.
721
- h.advance(DEBOUNCE * 10);
722
- expect(h.push).not.toHaveBeenCalled();
723
- });
724
- it("notifyChange() after dispose is a no-op and schedules nothing", () => {
725
- const h = makeHarness();
726
- h.driver.dispose();
727
- h.emitChange();
728
- expect(h.clock.pendingTimerCount()).toBe(0);
729
- h.advance(DEBOUNCE * 10);
730
- expect(h.push).not.toHaveBeenCalled();
731
- });
732
- it("dispose() is idempotent", () => {
733
- const h = makeHarness();
734
- h.emitChange();
735
- h.driver.dispose();
736
- h.driver.dispose();
737
- expect(h.clock.pendingTimerCount()).toBe(0);
738
- });
739
- it("a fully drained burst leaves zero pending timers", async () => {
740
- const h = makeHarness();
741
- for (let i = 0; i < 5; i++)
742
- h.emitChange();
743
- h.advance(DEBOUNCE);
744
- await Promise.resolve();
745
- expect(h.push).toHaveBeenCalledTimes(1);
746
- expect(h.clock.pendingTimerCount()).toBe(0);
747
- });
748
- });
749
- /**
750
- * US-002 — file-watcher module (debounced, ignore-aware, exclusion-aware).
751
- *
752
- * `createWatchPathFilter` is pure (no chokidar) so the exclusion logic is
753
- * tested directly against absolute paths. `TreeWatcher` is then exercised via
754
- * its `handleEvent` seam + FakeClock so debounce/coalesce/lifecycle assert
755
- * deterministically without spinning a real chokidar instance.
756
- */
757
- const ROOT = "/tmp/hq-root";
758
- describe("US-002: createWatchPathFilter — ignore-list matching", () => {
759
- const filter = createWatchPathFilter(ROOT, false);
760
- it("emits for an ordinary tracked file", () => {
761
- expect(filter(path.join(ROOT, "personal/notes.md"))).toBe(true);
762
- });
763
- it("does NOT emit for a .env file (DEFAULT_IGNORES secret)", () => {
764
- expect(filter(path.join(ROOT, ".env"))).toBe(false);
765
- expect(filter(path.join(ROOT, "personal/.env.local"))).toBe(false);
766
- });
767
- it("does NOT emit for AppleDouble files", () => {
768
- expect(filter(path.join(ROOT, "._README.md"))).toBe(false);
769
- expect(filter(path.join(ROOT, "core/connectors/._README.md"))).toBe(false);
770
- });
771
- it("does NOT emit for restore staging paths", () => {
772
- expect(filter(path.join(ROOT, "core/packages.hq-restore-9980"), true)).toBe(false);
773
- expect(filter(path.join(ROOT, "core/packages.hq-restore-9980/connectors/README.md"))).toBe(false);
774
- expect(filter(path.join(ROOT, "core/README.md.hq-restore-probe-9980-m6bx"))).toBe(false);
775
- });
776
- it("does NOT emit for node_modules / dist build artifacts", () => {
777
- expect(filter(path.join(ROOT, "node_modules/foo/index.js"))).toBe(false);
778
- expect(filter(path.join(ROOT, "dist/bundle.js"))).toBe(false);
779
- });
780
- it("does NOT emit for the watched root itself or paths outside it", () => {
781
- expect(filter(ROOT)).toBe(false);
782
- expect(filter("/some/other/place/file.txt")).toBe(false);
783
- });
784
- });
785
- describe("US-002: createWatchPathFilter — personal-vault exclusions", () => {
786
- const personal = createWatchPathFilter(ROOT, true);
787
- const nonPersonal = createWatchPathFilter(ROOT, false);
788
- it("does NOT emit for PERSONAL_VAULT_DEFAULT_EXCLUSIONS in personalMode (output/, .beads/)", () => {
789
- expect(personal(path.join(ROOT, "personal/output/x.txt"))).toBe(false);
790
- expect(personal(path.join(ROOT, "personal/.beads/issues.db"))).toBe(false);
791
- });
792
- it("does NOT emit for PERSONAL_VAULT_EXCLUDED_TOP_LEVEL in personalMode (.git/companies/repos/workspace)", () => {
793
- expect(personal(path.join(ROOT, "companies/indigo/board.json"))).toBe(false);
794
- expect(personal(path.join(ROOT, "workspace/threads/x.json"))).toBe(false);
795
- expect(personal(path.join(ROOT, "repos/private/foo/file.ts"))).toBe(false);
796
- });
797
- // ── DEV-1778 continuity-pointer carve-out ───────────────────────────────
798
- it("DOES emit for workspace/threads/handoff.json (the continuity pointer)", () => {
799
- expect(personal(path.join(ROOT, "workspace/threads/handoff.json"))).toBe(true);
800
- });
801
- it("emits for the pointer's active continuity thread file", () => {
802
- const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-active-thread-")));
803
- const thread = path.join(root, "workspace", "threads", "x.json");
804
- try {
805
- fs.mkdirSync(path.dirname(thread), { recursive: true });
806
- fs.writeFileSync(thread, "{}");
807
- fs.writeFileSync(path.join(root, "workspace", "threads", "handoff.json"), JSON.stringify({ thread_path: "workspace/threads/x.json" }));
808
- const scoped = createWatchPathFilter(root, true);
809
- expect(scoped(thread)).toBe(true);
810
- }
811
- finally {
812
- fs.rmSync(root, { recursive: true, force: true });
813
- }
814
- });
815
- it("covers every present personal-vault root, while preserving .hqignore exclusions", () => {
816
- const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-scope-invariant-")));
817
- const activeThread = path.join(root, "workspace", "threads", "x.json");
818
- try {
819
- fs.mkdirSync(path.join(root, "personal"), { recursive: true });
820
- fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
821
- fs.mkdirSync(path.join(root, "companies", "local"), { recursive: true });
822
- fs.mkdirSync(path.join(root, "workspace", "agency", "team"), { recursive: true });
823
- fs.mkdirSync(path.join(root, "workspace", ".session-logs"), { recursive: true });
824
- fs.mkdirSync(path.dirname(activeThread), { recursive: true });
825
- fs.writeFileSync(path.join(root, "personal", "note.md"), "note");
826
- fs.writeFileSync(path.join(root, ".codex", "settings.json"), "{}");
827
- fs.writeFileSync(path.join(root, "companies", "manifest.yaml"), "companies: {}\n");
828
- fs.writeFileSync(path.join(root, "companies", "local", "note.md"), "local");
829
- fs.writeFileSync(path.join(root, "workspace", "agency", "team", "state.json"), "{}");
830
- fs.writeFileSync(path.join(root, "workspace", ".session-logs", "session.jsonl"), "{}\n");
831
- fs.writeFileSync(activeThread, "{}");
832
- fs.writeFileSync(path.join(root, "workspace", "threads", "handoff.json"), JSON.stringify({ thread_path: "workspace/threads/x.json" }));
833
- fs.writeFileSync(path.join(root, ".hqignore"), "workspace/agency/ignored/\n");
834
- const filter = createWatchPathFilter(root, true);
835
- const uncovered = computePersonalVaultPaths(root).filter((scopeRoot) => !filter(scopeRoot, fs.lstatSync(scopeRoot).isDirectory()));
836
- // Scope roots must be watched, unless a future change introduces an
837
- // explicit, reasoned exclusion. There are deliberately no such
838
- // exclusions in this representative personal scope.
839
- expect(uncovered).toEqual([]);
840
- expect(filter(path.join(root, "workspace", "agency", "ignored"), true)).toBe(false);
841
- }
842
- finally {
843
- fs.rmSync(root, { recursive: true, force: true });
844
- }
845
- });
846
- it("still does NOT emit for other workspace/ files (carve-out is pointer-only)", () => {
847
- // The pointer is the ONLY re-include — sibling threads, the journal, and
848
- // unrelated workspace litter stay machine-local.
849
- expect(personal(path.join(ROOT, "workspace/threads/T-old.json"))).toBe(false);
850
- expect(personal(path.join(ROOT, "workspace/threads/INDEX.md"))).toBe(false);
851
- expect(personal(path.join(ROOT, "workspace/locks/x.lock"))).toBe(false);
852
- // The pointer queried as a directory is not an emit target either.
853
- expect(personal(path.join(ROOT, "workspace/threads/handoff.json"), true)).toBe(false);
854
- });
855
- it("allows chokidar to DESCEND through the pointer's ancestor dirs (dir probe)", () => {
856
- // Without this the Linux chokidar backend prunes `workspace/` before ever
857
- // reaching the leaf. Ancestor dirs return true ONLY when queried as dirs.
858
- expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
859
- expect(personal(path.join(ROOT, "workspace/threads"), true)).toBe(true);
860
- // A non-ancestor dir under workspace/ is still pruned.
861
- expect(personal(path.join(ROOT, "workspace/locks"), true)).toBe(false);
862
- // Ancestor dirs queried as FILES are not emit targets.
863
- expect(personal(path.join(ROOT, "workspace"), false)).toBe(false);
864
- });
865
- // ── workspace/agency subtree carve-out (personal mode) ──────────────────
866
- // The personal push uploads workspace/agency/** (computeAgencySyncPaths), so
867
- // the personal watcher must emit those changes event-driven, not leave them
868
- // for the 60s poll. Mirrors the continuity-pointer carve-out, for a subtree.
869
- it("DOES emit for workspace/agency files in personalMode (synced subtree)", () => {
870
- expect(personal(path.join(ROOT, "workspace/agency/indigo/nick/chat.jsonl"))).toBe(true);
871
- expect(personal(path.join(ROOT, "workspace/agency/indigo/nick/status.json"))).toBe(true);
872
- // The agency root itself, as a file event, also emits.
873
- expect(personal(path.join(ROOT, "workspace/agency"))).toBe(true);
874
- });
875
- it("allows chokidar to DESCEND to workspace/agency in personalMode (dir probes)", () => {
876
- // workspace/ and workspace/agency queried as dirs must return true so the
877
- // chokidar backend descends instead of pruning workspace/ at the top.
878
- expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
879
- expect(personal(path.join(ROOT, "workspace/agency"), true)).toBe(true);
880
- expect(personal(path.join(ROOT, "workspace/agency/indigo"), true)).toBe(true);
881
- });
882
- it("agency carve-out is exact-segment (does NOT leak to workspace/agencyX)", () => {
883
- // Only workspace/agency (and its subtree) is re-included; a sibling whose
884
- // name merely starts with 'agency' stays excluded like other workspace litter.
885
- expect(personal(path.join(ROOT, "workspace/agencyX/foo.txt"))).toBe(false);
886
- expect(personal(path.join(ROOT, "workspace/agency-notes.md"))).toBe(false);
887
- });
888
- it("agency carve-out does not re-include other workspace/ litter", () => {
889
- // Regression: the new carve-out must not widen the workspace exclusion for
890
- // anything other than agency + the continuity pointer.
891
- expect(personal(path.join(ROOT, "workspace/locks/x.lock"))).toBe(false);
892
- expect(personal(path.join(ROOT, "workspace/threads/T-old.json"))).toBe(false);
893
- expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
894
- });
895
- // ── workspace/.session-logs subtree carve-out (personal mode) ────────────
896
- // The personal push uploads workspace/.session-logs/** (reindex-captured
897
- // Claude Code transcripts, computeSessionLogsSyncPaths), so the personal
898
- // watcher must emit those changes event-driven. Mirrors the agency carve-out.
899
- it("DOES emit for workspace/.session-logs files in personalMode (synced subtree)", () => {
900
- expect(personal(path.join(ROOT, "workspace/.session-logs/session.jsonl"))).toBe(true);
901
- expect(personal(path.join(ROOT, "workspace/.session-logs/tool-results/r.txt"))).toBe(true);
902
- // The subtree root itself, as a file event, also emits.
903
- expect(personal(path.join(ROOT, "workspace/.session-logs"))).toBe(true);
904
- });
905
- it("allows chokidar to DESCEND to workspace/.session-logs in personalMode (dir probes)", () => {
906
- expect(personal(path.join(ROOT, "workspace"), true)).toBe(true);
907
- expect(personal(path.join(ROOT, "workspace/.session-logs"), true)).toBe(true);
908
- expect(personal(path.join(ROOT, "workspace/.session-logs/tool-results"), true)).toBe(true);
909
- });
910
- it("session-logs carve-out is exact-segment (does NOT leak to a similarly-named sibling)", () => {
911
- expect(personal(path.join(ROOT, "workspace/.session-logs-old/foo.txt"))).toBe(false);
912
- expect(personal(path.join(ROOT, "workspace/.session-logsX.md"))).toBe(false);
913
- });
914
- it("does not apply the carve-out in non-personal mode", () => {
915
- // In non-personal mode the excluded-top-level layer never runs, so the
916
- // carve-out is irrelevant; handoff.json passes via the ordinary ignore
917
- // stack like any other tracked file (no special-casing needed).
918
- expect(nonPersonal(path.join(ROOT, "workspace/threads/handoff.json"))).toBe(true);
919
- });
920
- it("DOES emit for an included top-level personal path in personalMode", () => {
921
- expect(personal(path.join(ROOT, "personal/notes.md"))).toBe(true);
922
- expect(personal(path.join(ROOT, "core/policies/x.md"))).toBe(true);
923
- });
924
- it("non-personal mode does NOT apply the excluded-top-level buckets", () => {
925
- // companies/ is only excluded by the personal-vault layer; in non-personal
926
- // mode the ignore stack alone governs and lets it through.
927
- expect(nonPersonal(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
928
- });
929
- // ── Never-synced heavy buckets pruned in BOTH modes ─────────────────────
930
- // In --companies mode personalMode=false, so the excluded-top-level layer
931
- // never ran and the watcher descended into the local git worktrees
932
- // (workspace/worktrees/, ~200k transient files from agent coding runs) and
933
- // the on-box repos checkout (repos/). Neither ever vault-syncs in any mode,
934
- // so watching them only burns inotify watches (near the OS limit) + CPU on
935
- // build churn it discards, and can trip the overflow→full-reconcile fallback.
936
- it("non-personal mode EXCLUDES never-synced heavy buckets (workspace/worktrees, repos)", () => {
937
- expect(nonPersonal(path.join(ROOT, "workspace/worktrees/hq-console/src/a.ts"))).toBe(false);
938
- // The bucket dir itself is pruned so chokidar never descends into it.
939
- expect(nonPersonal(path.join(ROOT, "workspace/worktrees"), true)).toBe(false);
940
- expect(nonPersonal(path.join(ROOT, "repos/private/hq-cloud/src/x.ts"))).toBe(false);
941
- expect(nonPersonal(path.join(ROOT, "repos"), true)).toBe(false);
942
- });
943
- it("non-personal mode STILL watches synced content (companies/, workspace/agency, continuity)", () => {
944
- expect(nonPersonal(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
945
- expect(nonPersonal(path.join(ROOT, "workspace/agency/indigo/nick/status.json"))).toBe(true);
946
- expect(nonPersonal(path.join(ROOT, "workspace/threads/handoff.json"))).toBe(true);
947
- // chokidar must still descend into workspace/ to reach agency + continuity.
948
- expect(nonPersonal(path.join(ROOT, "workspace"), true)).toBe(true);
949
- // A worktrees SIBLING under workspace/ is unaffected (only worktrees pruned).
950
- expect(nonPersonal(path.join(ROOT, "workspace/agency"), true)).toBe(true);
951
- });
952
- it("combined personal + company mode retains company trees without admitting unrelated workspace churn", () => {
953
- const combined = createWatchPathFilter(ROOT, true, {
954
- includeCompanyPaths: true,
955
- });
956
- expect(combined(path.join(ROOT, "personal/rt-hour.md"))).toBe(true);
957
- expect(combined(path.join(ROOT, "companies/indigo/board.json"))).toBe(true);
958
- expect(combined(path.join(ROOT, "workspace/locks/sync.lock"))).toBe(false);
959
- expect(combined(path.join(ROOT, "workspace/agency/indigo/chat.jsonl"))).toBe(true);
960
- });
961
- it("personal mode still excludes worktrees + repos (regression, unchanged)", () => {
962
- expect(personal(path.join(ROOT, "workspace/worktrees/x/a.ts"))).toBe(false);
963
- expect(personal(path.join(ROOT, "repos/private/x/a.ts"))).toBe(false);
964
- });
965
- });
966
- describe("US-002: chokidar descent scope", () => {
967
- let dir;
968
- beforeEach(() => {
969
- dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-scope-")));
970
- // This is the real HQ allowlist shape. The synthetic heavy directories
971
- // deliberately sit both under an in-scope company and under the two
972
- // never-synced top-level buckets from #156.
973
- fs.writeFileSync(path.join(dir, ".hqinclude"), "companies/*/knowledge/\n");
974
- fs.mkdirSync(path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy"), { recursive: true });
975
- fs.writeFileSync(path.join(dir, "companies", "acme", "knowledge", "node_modules", "heavy", "index.js"), "module.exports = 1;\n");
976
- fs.mkdirSync(path.join(dir, "repos", "checkout", "heavy"), { recursive: true });
977
- fs.mkdirSync(path.join(dir, "workspace", "worktrees", "repo", "node_modules", "heavy"), { recursive: true });
978
- });
979
- afterEach(() => {
980
- fs.rmSync(dir, { recursive: true, force: true });
981
- });
982
- it("defers a file-shaped pre-stat candidate to the stat-aware directory verdict", () => {
983
- const filter = createWatchPathFilter(dir, false);
984
- const nodeModules = path.join(dir, "companies", "acme", "knowledge", "node_modules");
985
- // `node_modules/` is directory-only. A no-stat chokidar probe cannot know
986
- // whether this is a new file or a directory, so it must survive long
987
- // enough to receive the stat-aware verdict below.
988
- expect(filter(nodeModules, false)).toBe(true);
989
- expect(filter(nodeModules, true)).toBe(false);
990
- const ignored = toChokidarIgnored(filter, dir);
991
- expect(ignored(nodeModules)).toBe(false);
992
- expect(ignored(nodeModules, fs.lstatSync(nodeModules))).toBe(true);
993
- // The allowlist's exact ancestor matcher remains the only carve-out: the
994
- // watcher can still reach companies/<slug>/knowledge without opening every
995
- // otherwise-file-shaped ignored directory below it.
996
- expect(ignored(path.join(dir, "companies"))).toBe(false);
997
- expect(ignored(path.join(dir, "companies", "acme"))).toBe(false);
998
- });
999
- it("prunes ignored directories during chokidar's pre-stat descent probe", async () => {
1000
- const filter = createWatchPathFilter(dir, false);
1001
- const chokidarWatcher = watch(dir, {
1002
- ignored: toChokidarIgnored(filter, dir),
1003
- ignoreInitial: true,
1004
- persistent: false,
1005
- });
1006
- try {
1007
- await new Promise((resolve, reject) => {
1008
- chokidarWatcher.once("ready", resolve);
1009
- chokidarWatcher.once("error", reject);
1010
- });
1011
- const descended = Object.keys(chokidarWatcher.getWatched())
1012
- .map((watchedPath) => path.resolve(watchedPath))
1013
- .sort();
1014
- expect(descended).toContain(path.join(dir, "companies", "acme", "knowledge"));
1015
- expect(descended).not.toContain(path.join(dir, "companies", "acme", "knowledge", "node_modules"));
1016
- expect(descended).not.toContain(path.join(dir, "repos"));
1017
- expect(descended).not.toContain(path.join(dir, "workspace", "worktrees"));
1018
- }
1019
- finally {
1020
- await chokidarWatcher.close();
1021
- }
1022
- });
1023
- });
1024
- describe("chokidar polling symlink boundaries", () => {
1025
- async function waitFor(check) {
1026
- const deadline = Date.now() + 6000;
1027
- while (Date.now() < deadline) {
1028
- if (check())
1029
- return;
1030
- await new Promise((resolve) => setTimeout(resolve, 25));
1031
- }
1032
- expect(check()).toBe(true);
1033
- }
1034
- async function waitReady(backend) {
1035
- await new Promise((resolve, reject) => {
1036
- if (!backend.onReady)
1037
- return resolve();
1038
- const timer = setTimeout(() => reject(new Error("watcher did not become ready")), 6000);
1039
- backend.onReady(() => {
1040
- clearTimeout(timer);
1041
- resolve();
1042
- });
1043
- });
1044
- }
1045
- it("polls ordinary paths without descending or budgeting symlink targets, while reporting the link entry", async () => {
1046
- const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-link-root-")));
1047
- const external = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-link-target-")));
1048
- const link = path.join(root, ".agents");
1049
- const events = [];
1050
- const budgetExceeded = vi.fn();
1051
- const errors = [];
1052
- try {
1053
- fs.mkdirSync(path.join(external, "many", "nested", "directories", "under", "the", "alias"), { recursive: true });
1054
- fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
1055
- fs.writeFileSync(path.join(root, ".codex", "config.toml"), "model = 'test'\n");
1056
- fs.mkdirSync(path.join(root, "workspace", "agency", "scratch"), { recursive: true });
1057
- fs.writeFileSync(path.join(root, "workspace", "agency", "scratch", "notes.md"), "notes\n");
1058
- fs.symlinkSync(external, link, "dir");
1059
- const filter = createWatchPathFilter(root, true);
1060
- expect(filter(path.join(root, ".codex", "config.toml"))).toBe(true);
1061
- expect(filter(path.join(root, "workspace", "agency", "scratch", "notes.md"))).toBe(true);
1062
- const backend = startTreeWatch(root, filter, (absolutePath, kind) => events.push({ path: absolutePath, kind }), (error) => errors.push(error), 7, budgetExceeded, undefined, true);
1063
- try {
1064
- await waitReady(backend);
1065
- expect(backend.mode).toBe("polling");
1066
- // Root + .codex + workspace/agency/scratch; the link target costs no watches.
1067
- expect(backend.watchedPathCount()).toBe(5);
1068
- expect(budgetExceeded).not.toHaveBeenCalled();
1069
- expect(errors).toEqual([]);
1070
- fs.unlinkSync(link);
1071
- await waitFor(() => events.some((event) => event.path === link && event.kind === "unlink"));
1072
- fs.symlinkSync(external, link, "dir");
1073
- await waitFor(() => events.some((event) => event.path === link && event.kind === "add"));
1074
- expect(backend.watchedPathCount()).toBe(5);
1075
- }
1076
- finally {
1077
- backend.close();
1078
- }
1079
- }
1080
- finally {
1081
- fs.rmSync(root, { recursive: true, force: true });
1082
- fs.rmSync(external, { recursive: true, force: true });
1083
- }
1084
- });
1085
- it("continues watching when the supplied HQ root is itself a symlink", async () => {
1086
- const realRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-real-root-")));
1087
- const parent = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-poll-root-link-")));
1088
- const linkedRoot = path.join(parent, "hq");
1089
- const events = [];
1090
- const errors = [];
1091
- fs.mkdirSync(path.join(realRoot, "workspace", "agency", "scratch"), { recursive: true });
1092
- fs.symlinkSync(realRoot, linkedRoot, "dir");
1093
- const filter = createWatchPathFilter(linkedRoot, true);
1094
- const backend = startTreeWatch(linkedRoot, filter, (absolutePath) => events.push(absolutePath), (error) => errors.push(error), 20, () => { }, undefined, true);
1095
- try {
1096
- await waitReady(backend);
1097
- const file = path.join(linkedRoot, "workspace", "agency", "scratch", "after-ready.md");
1098
- fs.writeFileSync(file, "observed\n");
1099
- await waitFor(() => events.includes(file));
1100
- expect(errors).toEqual([]);
1101
- }
1102
- finally {
1103
- backend.close();
1104
- fs.rmSync(linkedRoot, { recursive: true, force: true });
1105
- fs.rmSync(parent, { recursive: true, force: true });
1106
- fs.rmSync(realRoot, { recursive: true, force: true });
1107
- }
1108
- });
1109
- });
1110
- describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
1111
- function makeWatcher(opts) {
1112
- const clock = new FakeClock();
1113
- const changed = vi.fn();
1114
- const watcher = new TreeWatcher({
1115
- hqRoot: ROOT,
1116
- debounceMs: opts?.debounceMs ?? DEBOUNCE,
1117
- personalMode: opts?.personalMode ?? false,
1118
- clock,
1119
- });
1120
- watcher.onChange(changed);
1121
- return { clock, changed, watcher };
1122
- }
1123
- it("coalesces 5 rapid changes within the window into exactly 1 changed signal", () => {
1124
- const { clock, changed, watcher } = makeWatcher();
1125
- for (let i = 0; i < 5; i++) {
1126
- watcher.handleEvent(path.join(ROOT, `personal/file-${i}.md`));
1127
- clock.advance(20); // 100ms total << 2000ms debounce
1128
- }
1129
- expect(changed).not.toHaveBeenCalled();
1130
- clock.advance(DEBOUNCE);
1131
- expect(changed).toHaveBeenCalledTimes(1);
1132
- expect(watcher.pendingTimerCount()).toBe(0);
1133
- });
1134
- it("fires once after the quiet window for a single change", () => {
1135
- const { clock, changed, watcher } = makeWatcher();
1136
- watcher.handleEvent(path.join(ROOT, "personal/a.md"));
1137
- clock.advance(DEBOUNCE - 1);
1138
- expect(changed).not.toHaveBeenCalled();
1139
- clock.advance(1);
1140
- expect(changed).toHaveBeenCalledTimes(1);
1141
- });
1142
- it("flushes a quiet, known-small batch through the 60s debounce window", () => {
1143
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-debounce-"));
1144
- try {
1145
- const small = path.join(dir, "small.md");
1146
- fs.writeFileSync(small, "sixteen-byte-doc");
1147
- const clock = new FakeClock();
1148
- const changed = vi.fn();
1149
- const watcher = new TreeWatcher({
1150
- hqRoot: dir,
1151
- debounceMs: 60_000,
1152
- smallBatchDebounceMs: 60_000,
1153
- smallBatchMaxPaths: 8,
1154
- smallBatchMaxBytes: 1024 * 1024,
1155
- clock,
1156
- pathFilter: () => true,
1157
- });
1158
- watcher.onChange(changed);
1159
- watcher.handleEvent(small);
1160
- // The old 2.5s small-batch fast path must no longer fire.
1161
- clock.advance(2_500);
1162
- expect(changed).not.toHaveBeenCalled();
1163
- clock.advance(57_499);
1164
- expect(changed).not.toHaveBeenCalled();
1165
- clock.advance(1);
1166
- expect(changed).toHaveBeenCalledTimes(1);
1167
- expect(changed.mock.calls[0][1].paths.get(small)).toBe("small.md");
1168
- watcher.dispose();
1169
- }
1170
- finally {
1171
- fs.rmSync(dir, { recursive: true, force: true });
1172
- }
1173
- });
1174
- it("keeps a burst above the small-path cap on the 60s debounce", () => {
1175
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-burst-debounce-"));
1176
- try {
1177
- const clock = new FakeClock();
1178
- const changed = vi.fn();
1179
- const watcher = new TreeWatcher({
1180
- hqRoot: dir,
1181
- debounceMs: 60_000,
1182
- smallBatchDebounceMs: 60_000,
1183
- smallBatchMaxPaths: 8,
1184
- smallBatchMaxBytes: 1024 * 1024,
1185
- clock,
1186
- pathFilter: () => true,
1187
- });
1188
- watcher.onChange(changed);
1189
- for (let index = 0; index < 9; index += 1) {
1190
- const file = path.join(dir, `burst-${index}.md`);
1191
- fs.writeFileSync(file, "small");
1192
- watcher.handleEvent(file);
1193
- clock.advance(100);
1194
- }
1195
- // The final event landed at t=800ms; the loop's final 100ms advance is
1196
- // outside the quiet window, so stop one millisecond before t=60_800ms.
1197
- clock.advance(59_899);
1198
- expect(changed).not.toHaveBeenCalled();
1199
- clock.advance(1);
1200
- expect(changed).toHaveBeenCalledTimes(1);
1201
- expect(changed.mock.calls[0][1].paths.size).toBe(9);
1202
- watcher.dispose();
1203
- }
1204
- finally {
1205
- fs.rmSync(dir, { recursive: true, force: true });
1206
- }
1207
- });
1208
- it("preserves max-wait for a continuous small batch that never becomes quiet", () => {
1209
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-max-wait-"));
1210
- try {
1211
- const file = path.join(dir, "continuous.md");
1212
- fs.writeFileSync(file, "small");
1213
- const clock = new FakeClock();
1214
- const changed = vi.fn();
1215
- const watcher = new TreeWatcher({
1216
- hqRoot: dir,
1217
- debounceMs: 60_000,
1218
- maxWaitMs: 120_000,
1219
- smallBatchDebounceMs: 60_000,
1220
- smallBatchMaxPaths: 8,
1221
- smallBatchMaxBytes: 1024 * 1024,
1222
- clock,
1223
- pathFilter: () => true,
1224
- });
1225
- watcher.onChange(changed);
1226
- for (let elapsed = 0; elapsed < 120_000; elapsed += 2_000) {
1227
- watcher.handleEvent(file);
1228
- if (elapsed < 118_000)
1229
- clock.advance(2_000);
1230
- }
1231
- expect(changed).not.toHaveBeenCalled();
1232
- clock.advance(2_000);
1233
- expect(changed).toHaveBeenCalledTimes(1);
1234
- watcher.dispose();
1235
- }
1236
- finally {
1237
- fs.rmSync(dir, { recursive: true, force: true });
1238
- }
1239
- });
1240
- it("keeps a duplicate directory delete out of the short debounce lane", () => {
1241
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-delete-debounce-"));
1242
- try {
1243
- const deleted = path.join(dir, "deleted-directory");
1244
- const clock = new FakeClock();
1245
- const changed = vi.fn();
1246
- const watcher = new TreeWatcher({
1247
- hqRoot: dir,
1248
- debounceMs: 60_000,
1249
- smallBatchDebounceMs: 60_000,
1250
- smallBatchMaxPaths: 8,
1251
- smallBatchMaxBytes: 1024 * 1024,
1252
- clock,
1253
- pathFilter: () => true,
1254
- });
1255
- watcher.onChange(changed);
1256
- watcher.handleEvent(deleted, "unlinkDir");
1257
- watcher.handleEvent(deleted, "unlink");
1258
- clock.advance(59_999);
1259
- expect(changed).not.toHaveBeenCalled();
1260
- clock.advance(1);
1261
- expect(changed).toHaveBeenCalledTimes(1);
1262
- expect(changed.mock.calls[0][1].changes.get(deleted)).toMatchObject({
1263
- kind: "unlinkDir",
1264
- });
1265
- watcher.dispose();
1266
- }
1267
- finally {
1268
- fs.rmSync(dir, { recursive: true, force: true });
1269
- }
1270
- });
1271
- it("keeps an overflowed batch out of the short debounce lane", () => {
1272
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-overflow-debounce-"));
1273
- try {
1274
- const first = path.join(dir, "first.md");
1275
- const dropped = path.join(dir, "dropped.md");
1276
- fs.writeFileSync(first, "small");
1277
- fs.writeFileSync(dropped, "small");
1278
- const clock = new FakeClock();
1279
- const changed = vi.fn();
1280
- const watcher = new TreeWatcher({
1281
- hqRoot: dir,
1282
- debounceMs: 60_000,
1283
- smallBatchDebounceMs: 60_000,
1284
- smallBatchMaxPaths: 8,
1285
- smallBatchMaxBytes: 1024 * 1024,
1286
- maxPendingPaths: 1,
1287
- maxPendingBytes: 1024 * 1024,
1288
- clock,
1289
- pathFilter: () => true,
1290
- onBacklogOverflow: () => undefined,
1291
- });
1292
- watcher.onChange(changed);
1293
- watcher.handleEvent(first);
1294
- watcher.handleEvent(dropped);
1295
- clock.advance(59_999);
1296
- expect(changed).not.toHaveBeenCalled();
1297
- clock.advance(1);
1298
- expect(changed).toHaveBeenCalledTimes(1);
1299
- expect(changed.mock.calls[0][1]).toMatchObject({ overflowed: true });
1300
- watcher.dispose();
1301
- }
1302
- finally {
1303
- fs.rmSync(dir, { recursive: true, force: true });
1304
- }
1305
- });
1306
- it("does NOT emit for an ignored / excluded path", () => {
1307
- const { clock, changed, watcher } = makeWatcher({ personalMode: true });
1308
- watcher.handleEvent(path.join(ROOT, ".env"));
1309
- watcher.handleEvent(path.join(ROOT, "personal/output/big.bin"));
1310
- watcher.handleEvent(path.join(ROOT, "companies/indigo/board.json"));
1311
- clock.advance(DEBOUNCE * 2);
1312
- expect(changed).not.toHaveBeenCalled();
1313
- expect(watcher.pendingTimerCount()).toBe(0);
1314
- });
1315
- it("max-wait ceiling: a continuous event stream still emits one coalesced batch", () => {
1316
- const clock = new FakeClock();
1317
- const changed = vi.fn();
1318
- const watcher = new TreeWatcher({
1319
- hqRoot: ROOT,
1320
- debounceMs: 60_000,
1321
- maxWaitMs: 120_000,
1322
- personalMode: false,
1323
- clock,
1324
- });
1325
- watcher.onChange(changed);
1326
- // Events every 10s — the quiet window (60s) never elapses, so only the
1327
- // max-wait ceiling can fire. Events land at t=0s..110s.
1328
- for (let i = 0; i < 12; i++) {
1329
- watcher.handleEvent(path.join(ROOT, `personal/file-${i % 3}.md`));
1330
- if (i < 11)
1331
- clock.advance(10_000);
1332
- }
1333
- expect(changed).not.toHaveBeenCalled();
1334
- clock.advance(10_000); // reaches t=120s — the ceiling since the FIRST event
1335
- expect(changed).toHaveBeenCalledTimes(1);
1336
- // One coalesced batch carrying every distinct path of the stream.
1337
- const batch = changed.mock.calls[0][1];
1338
- expect([...batch.paths.values()].sort()).toEqual([
1339
- "personal/file-0.md",
1340
- "personal/file-1.md",
1341
- "personal/file-2.md",
1342
- ]);
1343
- // The ceiling resets between windows: a fresh burst gets a fresh window
1344
- // and settles on the ordinary quiet period.
1345
- watcher.handleEvent(path.join(ROOT, "personal/next.md"));
1346
- clock.advance(60_000);
1347
- expect(changed).toHaveBeenCalledTimes(2);
1348
- expect(watcher.pendingTimerCount()).toBe(0);
1349
- watcher.dispose();
1350
- });
1351
- it("max-wait ceiling never fires before one quiet window (floored at debounceMs)", () => {
1352
- const clock = new FakeClock();
1353
- const changed = vi.fn();
1354
- const watcher = new TreeWatcher({
1355
- hqRoot: ROOT,
1356
- debounceMs: 60_000,
1357
- maxWaitMs: 1, // absurdly low — must be floored to the quiet window
1358
- personalMode: false,
1359
- clock,
1360
- });
1361
- watcher.onChange(changed);
1362
- watcher.handleEvent(path.join(ROOT, "personal/a.md"));
1363
- clock.advance(59_999);
1364
- expect(changed).not.toHaveBeenCalled();
1365
- clock.advance(1);
1366
- expect(changed).toHaveBeenCalledTimes(1);
1367
- watcher.dispose();
1368
- });
1369
- it("without maxWaitMs the legacy pure quiet-window behavior is preserved", () => {
1370
- const { clock, changed, watcher } = makeWatcher();
1371
- // Keep resetting the quiet window far past any would-be ceiling.
1372
- for (let i = 0; i < 200; i++) {
1373
- watcher.handleEvent(path.join(ROOT, "personal/a.md"));
1374
- clock.advance(DEBOUNCE - 1);
1375
- }
1376
- expect(changed).not.toHaveBeenCalled();
1377
- clock.advance(DEBOUNCE);
1378
- expect(changed).toHaveBeenCalledTimes(1);
1379
- });
1380
- it("treats two separate bursts as two changed signals", () => {
1381
- const { clock, changed, watcher } = makeWatcher();
1382
- watcher.handleEvent(path.join(ROOT, "personal/a.md"));
1383
- clock.advance(DEBOUNCE);
1384
- expect(changed).toHaveBeenCalledTimes(1);
1385
- watcher.handleEvent(path.join(ROOT, "personal/b.md"));
1386
- clock.advance(DEBOUNCE);
1387
- expect(changed).toHaveBeenCalledTimes(2);
1388
- });
1389
- it("R-F13: caps watcher backlog and keeps EMFILE polling fallback removed", () => {
1390
- const clock = new FakeClock();
1391
- const changed = vi.fn();
1392
- const overflow = vi.fn();
1393
- const watcher = new TreeWatcher({
1394
- hqRoot: ROOT,
1395
- debounceMs: DEBOUNCE,
1396
- clock,
1397
- pathFilter: () => true,
1398
- maxPendingPaths: 3,
1399
- maxPendingBytes: 10_000,
1400
- onBacklogOverflow: overflow,
1401
- });
1402
- watcher.onChange(changed);
1403
- for (let i = 0; i < 10; i++) {
1404
- watcher.handleEvent(path.join(ROOT, `bulk-${i}.md`));
1405
- }
1406
- expect(overflow).toHaveBeenCalledTimes(1);
1407
- clock.advance(DEBOUNCE);
1408
- expect(changed).toHaveBeenCalledTimes(1);
1409
- const batch = changed.mock.calls[0][1];
1410
- expect(batch.paths.size).toBe(3);
1411
- expect(batch.overflowed).toBe(true);
1412
- expect(batch.droppedPaths).toBe(7);
1413
- const source = fs.readFileSync(path.join(process.cwd(), "src/watcher.ts"), "utf8");
1414
- expect(source).not.toContain("startPollingTreeWatch");
1415
- expect(source).not.toContain("snapshotWatchTree");
1416
- });
1417
- it("overflow records one dropped-route hint per touched route so the drain can stay targeted", () => {
1418
- const clock = new FakeClock();
1419
- const changed = vi.fn();
1420
- const watcher = new TreeWatcher({
1421
- hqRoot: ROOT,
1422
- debounceMs: DEBOUNCE,
1423
- clock,
1424
- pathFilter: () => true,
1425
- maxPendingPaths: 2,
1426
- maxPendingBytes: 10_000,
1427
- onBacklogOverflow: () => { },
1428
- });
1429
- watcher.onChange(changed);
1430
- // Two retained, then drops spanning two company routes + personal.
1431
- watcher.handleEvent(path.join(ROOT, "companies", "indigo", "kept-a.md"));
1432
- watcher.handleEvent(path.join(ROOT, "companies", "indigo", "kept-b.md"));
1433
- watcher.handleEvent(path.join(ROOT, "companies", "indigo", "dropped-1.md"));
1434
- watcher.handleEvent(path.join(ROOT, "companies", "acme", "dropped-2.md"));
1435
- watcher.handleEvent(path.join(ROOT, "notes", "dropped-3.md"));
1436
- clock.advance(DEBOUNCE);
1437
- const batch = changed.mock.calls[0][1];
1438
- expect(batch.overflowed).toBe(true);
1439
- expect(batch.droppedPaths).toBe(3);
1440
- // One representative relative path per route (companies/indigo,
1441
- // companies/acme, personal) — enough to reconstruct the touched-route
1442
- // set without holding the dropped paths themselves.
1443
- expect(batch.droppedRouteHints?.sort()).toEqual([
1444
- "companies/acme/dropped-2.md",
1445
- "companies/indigo/dropped-1.md",
1446
- "notes/dropped-3.md",
1447
- ]);
1448
- // Next window starts clean: a non-overflow batch carries no hints.
1449
- changed.mockClear();
1450
- watcher.handleEvent(path.join(ROOT, "companies", "indigo", "later.md"));
1451
- clock.advance(DEBOUNCE);
1452
- const nextBatch = changed.mock.calls[0][1];
1453
- expect(nextBatch.overflowed).toBeUndefined();
1454
- expect(nextBatch.droppedRouteHints).toBeUndefined();
1455
- });
1456
- it("stops descending at the hard watch budget, reports, and degrades to polling", () => {
1457
- const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "watch-cap-")));
1458
- fs.mkdirSync(path.join(dir, "personal", "a", "deep"), { recursive: true });
1459
- fs.mkdirSync(path.join(dir, "personal", "b", "deep"), { recursive: true });
1460
- const clock = new FakeClock();
1461
- const degraded = vi.fn();
1462
- const changed = vi.fn();
1463
- const descended = [];
1464
- let backendClosed = false;
1465
- const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
1466
- try {
1467
- const watcher = new TreeWatcher({
1468
- hqRoot: dir,
1469
- clock,
1470
- debounceMs: DEBOUNCE,
1471
- maxWatchedPaths: 3,
1472
- onDegraded: degraded,
1473
- // This faked backend drives chokidar's real ignored predicate through
1474
- // its pre-stat and post-stat calls, without needing an inotify slot on
1475
- // a host already exhausted by another process.
1476
- backendFactory: ({ hqRoot, shouldEmit, maxWatchedPaths, onWatchBudgetExceeded, }) => {
1477
- const budget = new ChokidarWatchBudget(maxWatchedPaths, onWatchBudgetExceeded);
1478
- const ignored = toChokidarIgnored(shouldEmit, hqRoot, budget);
1479
- const visit = (candidate) => {
1480
- if (ignored(candidate))
1481
- return;
1482
- const stats = fs.lstatSync(candidate);
1483
- if (ignored(candidate, stats))
1484
- return;
1485
- if (!stats.isDirectory())
1486
- return;
1487
- descended.push(path.resolve(candidate));
1488
- for (const entry of fs.readdirSync(candidate)) {
1489
- visit(path.join(candidate, entry));
1490
- }
1491
- };
1492
- visit(hqRoot);
1493
- return {
1494
- close: () => {
1495
- backendClosed = true;
1496
- },
1497
- needsKnownKinds: false,
1498
- watchedPathCount: () => descended.length,
1499
- };
1500
- },
1501
- });
1502
- watcher.onChange(changed);
1503
- watcher.start();
1504
- expect(descended).toHaveLength(3);
1505
- expect(descended).not.toContain(path.join(dir, "personal", "a", "deep"));
1506
- expect(degraded).toHaveBeenCalledWith(expect.objectContaining({
1507
- reason: "watch_budget_exhausted",
1508
- maxWatchedPaths: 3,
1509
- watchedPaths: 3,
1510
- offendingPath: expect.stringContaining(path.join("personal", "a", "deep")),
1511
- }));
1512
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('"type":"watch-scope-excluded"'));
1513
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('"excludedRoot"'));
1514
- expect(backendClosed).toBe(true);
1515
- expect(watcher.isWatching()).toBe(false);
1516
- clock.advance(DEBOUNCE);
1517
- expect(changed).toHaveBeenCalledWith(undefined, expect.objectContaining({ overflowed: true }));
1518
- }
1519
- finally {
1520
- warn.mockRestore();
1521
- fs.rmSync(dir, { recursive: true, force: true });
1522
- }
1523
- });
1524
- it.each(["ENOSPC", "EMFILE", "ENFILE"])("keeps event watching through polling when a backend emits runtime %s", (errorCode) => {
1525
- const clock = new FakeClock();
1526
- const degraded = vi.fn();
1527
- const changed = vi.fn();
1528
- const telemetry = [];
1529
- const telemetryClient = {
1530
- postTelemetryEvents: vi.fn(async (batch) => {
1531
- telemetry.push(batch);
1532
- return { ok: true, written: batch.events.length, skipped: [] };
1533
- }),
1534
- };
1535
- let closeCalls = 0;
1536
- const watcher = new TreeWatcher({
1537
- hqRoot: ROOT,
1538
- clock,
1539
- debounceMs: DEBOUNCE,
1540
- onDegraded: degraded,
1541
- telemetryClient,
1542
- backendFactory: ({ forcePolling, onError }) => {
1543
- const backend = {
1544
- close: () => {
1545
- closeCalls += 1;
1546
- },
1547
- needsKnownKinds: false,
1548
- mode: forcePolling ? "polling" : "chokidar",
1549
- watchedPathCount: () => forcePolling ? 200 : 0,
1550
- };
1551
- if (!forcePolling) {
1552
- queueMicrotask(() => onError(Object.assign(new Error("watch resource limit"), {
1553
- code: errorCode,
1554
- path: path.join(ROOT, "companies", "acme", "knowledge"),
1555
- })));
1556
- }
1557
- return backend;
1558
- },
1559
- });
1560
- watcher.onChange(changed);
1561
- watcher.start();
1562
- return new Promise((resolve) => queueMicrotask(resolve)).then(() => {
1563
- expect(closeCalls).toBe(1);
1564
- expect(watcher.isWatching()).toBe(true);
1565
- expect(watcher.watchMode()).toBe("polling");
1566
- expect(watcher.watchedPathCount()).toBe(200);
1567
- const reason = errorCode === "ENOSPC" ? "inotify_enospc" : "watch_resource_exhausted";
1568
- expect(degraded).toHaveBeenCalledWith(expect.objectContaining({
1569
- reason,
1570
- errorCode,
1571
- offendingPath: path.join(ROOT, "companies", "acme", "knowledge"),
1572
- }));
1573
- expect(telemetry).toEqual([
1574
- expect.objectContaining({
1575
- events: [
1576
- expect.objectContaining({
1577
- eventName: "watcher_degraded",
1578
- source: "watcher",
1579
- properties: expect.objectContaining({
1580
- reason,
1581
- offendingTopLevel: "companies",
1582
- }),
1583
- }),
1584
- ],
1585
- }),
1586
- ]);
1587
- clock.advance(DEBOUNCE);
1588
- expect(changed).toHaveBeenCalledWith(undefined, expect.objectContaining({ overflowed: true }));
1589
- });
1590
- });
1591
- it("keeps a large synthetic watch set alive across multiple debounced passes", () => {
1592
- const clock = new FakeClock();
1593
- const changed = vi.fn();
1594
- const handles = new Set(Array.from({ length: 20_000 }, (_, i) => `dir-${i}`));
1595
- const watcher = new TreeWatcher({
1596
- hqRoot: ROOT,
1597
- clock,
1598
- debounceMs: DEBOUNCE,
1599
- backendFactory: () => ({
1600
- close: () => handles.clear(),
1601
- mode: "chokidar",
1602
- needsKnownKinds: false,
1603
- watchedPathCount: () => handles.size,
1604
- }),
1605
- });
1606
- watcher.onChange(changed);
1607
- watcher.start();
1608
- for (let pass = 0; pass < 3; pass += 1) {
1609
- watcher.handleEvent(path.join(ROOT, "personal", `change-${pass}.md`));
1610
- clock.advance(DEBOUNCE);
1611
- expect(watcher.isWatching()).toBe(true);
1612
- expect(watcher.watchedPathCount()).toBe(20_000);
1613
- }
1614
- expect(changed).toHaveBeenCalledTimes(3);
1615
- watcher.dispose();
1616
- });
1617
- it("continues initial discovery while the watched-path count is still growing", () => {
1618
- const clock = new FakeClock();
1619
- const degraded = vi.fn();
1620
- let watchedPaths = 0;
1621
- const watcher = new TreeWatcher({
1622
- hqRoot: ROOT,
1623
- clock,
1624
- discoveryTimeoutMs: 100,
1625
- onDegraded: degraded,
1626
- backendFactory: () => ({
1627
- close: () => { },
1628
- mode: "chokidar",
1629
- needsKnownKinds: false,
1630
- watchedPathCount: () => watchedPaths,
1631
- // Deliberately never ready: this models a large, still-discovering tree.
1632
- onReady: () => () => { },
1633
- }),
1634
- });
1635
- watcher.start();
1636
- watchedPaths = 9_220;
1637
- clock.advance(100);
1638
- watchedPaths = 14_588;
1639
- clock.advance(100);
1640
- expect(watcher.isWatching()).toBe(true);
1641
- expect(watcher.watchMode()).toBe("chokidar");
1642
- expect(degraded).not.toHaveBeenCalled();
1643
- watcher.dispose();
1644
- });
1645
- it("stands down loudly when polling discovery also times out", () => {
1646
- const clock = new FakeClock();
1647
- const degraded = vi.fn();
1648
- let closeCalls = 0;
1649
- const watcher = new TreeWatcher({
1650
- hqRoot: ROOT,
1651
- clock,
1652
- discoveryTimeoutMs: 100,
1653
- onDegraded: degraded,
1654
- backendFactory: ({ forcePolling }) => ({
1655
- close: () => { closeCalls += 1; },
1656
- mode: forcePolling ? "polling" : "chokidar",
1657
- needsKnownKinds: false,
1658
- watchedPathCount: () => forcePolling ? 20 : 10,
1659
- // Deliberately never invokes its listener: this simulates discovery
1660
- // that hangs before chokidar can report ready.
1661
- onReady: () => () => { },
1662
- }),
1663
- });
1664
- watcher.start();
1665
- clock.advance(100);
1666
- expect(watcher.isWatching()).toBe(true);
1667
- expect(watcher.watchMode()).toBe("polling");
1668
- clock.advance(100);
1669
- expect(watcher.isWatching()).toBe(false);
1670
- expect(closeCalls).toBe(2);
1671
- expect(degraded).toHaveBeenLastCalledWith(expect.objectContaining({
1672
- reason: "initial_discovery_timeout",
1673
- action: "stopped",
1674
- watchedPaths: 20,
1675
- }));
1676
- });
1677
- it("does not retain one rename-kind entry for every changed file", () => {
1678
- const clock = new FakeClock();
1679
- const watcher = new TreeWatcher({
1680
- hqRoot: ROOT,
1681
- clock,
1682
- pathFilter: () => true,
1683
- maxPendingPaths: 20_000,
1684
- maxPendingBytes: 20_000_000,
1685
- });
1686
- for (let i = 0; i < 10_000; i++) {
1687
- watcher.handleEvent(path.join(ROOT, "personal", `file-${i}.md`));
1688
- }
1689
- expect(watcher.knownDirectoryCount()).toBe(0);
1690
- watcher.stop();
1691
- });
1692
- });
1693
- describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", () => {
1694
- let dir;
1695
- beforeEach(() => {
1696
- // realpathSync resolves macOS's /var -> /private/var symlink so chokidar's
1697
- // canonicalized event paths stay inside hqRoot (path.relative would
1698
- // otherwise yield a `..`-prefixed path and the filter would reject them).
1699
- dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "treewatcher-")));
1700
- });
1701
- afterEach(() => {
1702
- fs.rmSync(dir, { recursive: true, force: true });
1703
- });
1704
- it("start() is idempotent — a second start does not open a second watcher", () => {
1705
- const w = new TreeWatcher({ hqRoot: dir });
1706
- w.start();
1707
- expect(w.isWatching()).toBe(true);
1708
- w.start(); // no throw, no second instance
1709
- expect(w.isWatching()).toBe(true);
1710
- w.stop();
1711
- expect(w.isWatching()).toBe(false);
1712
- });
1713
- it("stop() closes the watcher and clears pending timers; dispose() is permanent", () => {
1714
- const clock = new FakeClock();
1715
- const w = new TreeWatcher({ hqRoot: dir, clock });
1716
- w.start();
1717
- w.handleEvent(path.join(dir, "x.md"));
1718
- expect(w.pendingTimerCount()).toBe(1);
1719
- w.stop();
1720
- expect(w.pendingTimerCount()).toBe(0);
1721
- expect(w.isWatching()).toBe(false);
1722
- // After dispose, events are inert and start is a no-op.
1723
- w.dispose();
1724
- w.handleEvent(path.join(dir, "y.md"));
1725
- expect(w.pendingTimerCount()).toBe(0);
1726
- w.start();
1727
- expect(w.isWatching()).toBe(false);
1728
- });
1729
- it("emits a debounced changed signal for a real file write", async () => {
1730
- const w = new TreeWatcher({ hqRoot: dir, debounceMs: 50 });
1731
- const changed = vi.fn();
1732
- w.onChange(changed);
1733
- w.start();
1734
- // Give chokidar a beat to set up its watch before writing.
1735
- await new Promise((r) => setTimeout(r, 200));
1736
- fs.writeFileSync(path.join(dir, "hello.md"), "hi");
1737
- await new Promise((r) => setTimeout(r, 1500));
1738
- expect(changed).toHaveBeenCalled();
1739
- w.dispose();
1740
- }, 10_000);
1741
- });
1742
- describe("PushEventEmitter — directory and delete tombstone handling", () => {
1743
- let dir;
1744
- beforeEach(() => {
1745
- dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pushemit-")));
1746
- });
1747
- afterEach(() => {
1748
- fs.rmSync(dir, { recursive: true, force: true });
1749
- });
1750
- it("replays randomized hash records with the same LRU state as the legacy reducer", () => {
1751
- const records = publishedHashRecords(4_096);
1752
- expect(replayDurablePublishedContentHashRecordsForTest(records, true))
1753
- .toEqual(replayDurablePublishedContentHashRecordsForTest(records, false));
1754
- });
1755
- it("replays legacy records followed by a batch with the exact single-record LRU state", () => {
1756
- const batchEntries = [
1757
- { kind: "delete", relativePath: "alpha" },
1758
- { kind: "set", relativePath: "gamma", contentHash: "sha256:gamma", maxPaths: 2 },
1759
- { kind: "set", relativePath: "beta", contentHash: "sha256:beta-new", maxPaths: 2 },
1760
- ];
1761
- const legacyThenBatch = [
1762
- { sequence: 1, type: 0x5031, flags: 0, payload: { relativePath: "alpha", contentHash: "sha256:alpha", maxPaths: 2 } },
1763
- { sequence: 2, type: 0x5031, flags: 0, payload: { relativePath: "beta", contentHash: "sha256:beta", maxPaths: 2 } },
1764
- { sequence: 3, type: 0x5033, flags: 0, payload: { entries: batchEntries } },
1765
- ];
1766
- const equivalentSingles = [
1767
- ...legacyThenBatch.slice(0, 2),
1768
- ...batchEntries.map((entry, index) => ({
1769
- sequence: index + 3,
1770
- type: entry.kind === "set" ? 0x5031 : 0x5032,
1771
- flags: 0,
1772
- payload: entry.kind === "set"
1773
- ? { relativePath: entry.relativePath, contentHash: entry.contentHash, maxPaths: entry.maxPaths }
1774
- : { relativePath: entry.relativePath },
1775
- })),
1776
- ];
1777
- const batched = replayDurablePublishedContentHashRecordsForTest(legacyThenBatch);
1778
- expect(JSON.stringify(batched)).toBe(JSON.stringify(replayDurablePublishedContentHashRecordsForTest(equivalentSingles)));
1779
- expect(batched).toEqual(replayDurablePublishedContentHashRecordsForTest(legacyThenBatch, false));
1780
- });
1781
- it("coalesces pull marks into one append and persists only the final mutation for a path", async () => {
1782
- const clock = new FakeClock();
1783
- const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync");
1784
- const stateDir = path.join(dir, "coalesced-state");
1785
- const emitter = makeEmitter({ clock, lastPublishedContentHashStateDir: stateDir });
1786
- const finalContent = "final revision\n";
1787
- const finalHash = `sha256:${createHash("sha256").update(finalContent).digest("hex")}`;
1788
- try {
1789
- for (let index = 0; index < 128; index++) {
1790
- emitter.markContentPublished(`path-${index}.md`, `sha256:${index}`);
1791
- }
1792
- emitter.markContentPublished("same.md", "sha256:obsolete");
1793
- emitter.markContentPublished("same.md", finalHash);
1794
- clock.advance(DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS);
1795
- await flushImmediate();
1796
- expect(appendGenerated).toHaveBeenCalledTimes(1);
1797
- await emitter.dispose();
1798
- const samePath = path.join(dir, "same.md");
1799
- fs.writeFileSync(samePath, finalContent);
1800
- const published = [];
1801
- await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch({
1802
- paths: new Map([[samePath, "same.md"]]),
1803
- });
1804
- expect(published).toEqual([]);
1805
- }
1806
- finally {
1807
- appendGenerated.mockRestore();
1808
- }
1809
- });
1810
- it("drops a stale guarded mutation at flush time without dropping the rest of its batch", async () => {
1811
- const clock = new FakeClock();
1812
- const hashes = new DurablePublishedContentHashes(path.join(dir, "predicate-state"), "test", 4, {}, clock);
1813
- let shouldPersist = true;
1814
- const stale = hashes.setIfCurrent("stale.md", "sha256:stale", 4, () => shouldPersist);
1815
- const kept = hashes.set("kept.md", "sha256:kept", 4, false);
1816
- shouldPersist = false;
1817
- await hashes.dispose();
1818
- await expect(stale).resolves.toBe(false);
1819
- await expect(kept).resolves.toBeUndefined();
1820
- expect(hashes.get("stale.md")).toBeUndefined();
1821
- expect(hashes.get("kept.md")).toBe("sha256:kept");
1822
- });
1823
- it("flushes pending durable pull marks while disposing", async () => {
1824
- const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync");
1825
- const emitter = makeEmitter({ lastPublishedContentHashStateDir: path.join(dir, "dispose-state") });
1826
- try {
1827
- emitter.markContentPublished("pending.md", "sha256:pending");
1828
- await emitter.dispose();
1829
- expect(appendGenerated).toHaveBeenCalledTimes(1);
1830
- }
1831
- finally {
1832
- appendGenerated.mockRestore();
1833
- }
1834
- });
1835
- it("reports and retries a failed durable hash append as one physical batch", async () => {
1836
- const clock = new FakeClock();
1837
- const onError = vi.fn();
1838
- const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
1839
- .mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
1840
- const emitter = makeEmitter({
1841
- clock,
1842
- onError,
1843
- lastPublishedContentHashStateDir: path.join(dir, "batch-lock-timeout-state"),
1844
- });
1845
- try {
1846
- for (let index = 0; index < 128; index += 1) {
1847
- emitter.markContentPublished(`batch-${index}.md`, `sha256:${index}`);
1848
- }
1849
- clock.advance(DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS);
1850
- await flushImmediate();
1851
- expect(onError).toHaveBeenCalledTimes(1);
1852
- expect(onError.mock.calls[0]?.[0]).toMatchObject({ name: "StateStoreLockError" });
1853
- expect((onError.mock.calls[0]?.[0]).message).toContain("128 mutations");
1854
- expect(clock.pendingTimerCount()).toBe(1);
1855
- }
1856
- finally {
1857
- await emitter.dispose();
1858
- appendGenerated.mockRestore();
1859
- }
1860
- });
1861
- it("does not revive a superseded mutation while its failed batch is retrying", async () => {
1862
- const clock = new FakeClock();
1863
- let releaseRetry;
1864
- const retryGate = new Promise((resolve) => { releaseRetry = resolve; });
1865
- let retryEntered;
1866
- const retryStarted = new Promise((resolve) => { retryEntered = resolve; });
1867
- const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
1868
- const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
1869
- .mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"))
1870
- .mockImplementationOnce(async function (type, preparePayload, flags) {
1871
- retryEntered();
1872
- await retryGate;
1873
- return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
1874
- });
1875
- const hashes = new DurablePublishedContentHashes(path.join(dir, "superseded-batch-retry-state"), "test", 4, {}, clock);
1876
- try {
1877
- const stale = hashes.setIfCurrent("same.md", "sha256:stale", 4, () => true);
1878
- await expect(stale).resolves.toBe(false);
1879
- expect(clock.pendingTimerCount()).toBe(1);
1880
- clock.advance(250);
1881
- await retryStarted;
1882
- const newer = hashes.set("same.md", "sha256:newer", 4, false);
1883
- releaseRetry();
1884
- await flushImmediate();
1885
- clock.advance(DURABLE_PUBLISHED_HASH_BATCH_FLUSH_MS);
1886
- await expect(newer).resolves.toBeUndefined();
1887
- expect(hashes.get("same.md")).toBe("sha256:newer");
1888
- }
1889
- finally {
1890
- await hashes.dispose();
1891
- appendGenerated.mockRestore();
1892
- }
1893
- });
1894
- it("rechecks a guarded mutation when its failed batch retries", async () => {
1895
- const clock = new FakeClock();
1896
- let shouldPersist = true;
1897
- const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
1898
- .mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
1899
- const hashes = new DurablePublishedContentHashes(path.join(dir, "guarded-batch-retry-state"), "test", 4, {}, clock);
1900
- try {
1901
- const stale = hashes.setIfCurrent("stale.md", "sha256:stale", 4, () => shouldPersist);
1902
- await expect(stale).resolves.toBe(false);
1903
- shouldPersist = false;
1904
- clock.advance(250);
1905
- await flushImmediate();
1906
- expect(hashes.get("stale.md")).toBeUndefined();
1907
- expect(appendGenerated).toHaveBeenCalledTimes(2);
1908
- }
1909
- finally {
1910
- await hashes.dispose();
1911
- appendGenerated.mockRestore();
1912
- }
1913
- });
1914
- it("drains an active publish into the durable memo before disposing", async () => {
1915
- let releasePublish;
1916
- const publishGate = new Promise((resolve) => { releasePublish = resolve; });
1917
- let publishEntered;
1918
- const publishStarted = new Promise((resolve) => { publishEntered = resolve; });
1919
- const published = [];
1920
- const stateDir = path.join(dir, "dispose-publish-drain-state");
1921
- const changed = path.join(dir, "dispose-publish-drain.md");
1922
- fs.writeFileSync(changed, "drain me\n");
1923
- const emitter = makeEmitter({
1924
- lastPublishedContentHashStateDir: stateDir,
1925
- transport: {
1926
- start: async () => { },
1927
- dispose: async () => { },
1928
- connected: true,
1929
- publish: async (event) => {
1930
- publishEntered();
1931
- await publishGate;
1932
- published.push(event);
1933
- },
1934
- },
1935
- });
1936
- const publishing = emitter.emitForBatch({ paths: new Map([[changed, "dispose-publish-drain.md"]]) });
1937
- await publishStarted;
1938
- let disposed = false;
1939
- const disposing = emitter.dispose().then(() => { disposed = true; });
1940
- await Promise.resolve();
1941
- expect(disposed).toBe(false);
1942
- releasePublish();
1943
- await Promise.all([publishing, disposing]);
1944
- const afterRestart = [];
1945
- await makeEmitter({
1946
- published: afterRestart,
1947
- lastPublishedContentHashStateDir: stateDir,
1948
- }).emitForBatch({ paths: new Map([[changed, "dispose-publish-drain.md"]]) });
1949
- expect(published).toHaveLength(1);
1950
- expect(afterRestart).toEqual([]);
1951
- });
1952
- it("leaves the durable memo open when a publish outlives the shutdown drain", async () => {
1953
- const clock = new FakeClock();
1954
- const onError = vi.fn();
1955
- let releasePublish;
1956
- const publishGate = new Promise((resolve) => { releasePublish = resolve; });
1957
- let publishEntered;
1958
- const publishStarted = new Promise((resolve) => { publishEntered = resolve; });
1959
- const stateDir = path.join(dir, "dispose-timeout-state");
1960
- const changed = path.join(dir, "dispose-timeout.md");
1961
- fs.writeFileSync(changed, "late publish\n");
1962
- const emitter = makeEmitter({
1963
- clock,
1964
- onError,
1965
- lastPublishedContentHashStateDir: stateDir,
1966
- transport: {
1967
- start: async () => { },
1968
- dispose: async () => { },
1969
- connected: true,
1970
- publish: async () => {
1971
- publishEntered();
1972
- await publishGate;
1973
- },
1974
- },
1975
- });
1976
- const publishing = emitter.emitForBatch({ paths: new Map([[changed, "dispose-timeout.md"]]) });
1977
- await publishStarted;
1978
- const disposing = emitter.dispose();
1979
- clock.advance(60_000);
1980
- await disposing;
1981
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("timed out draining active publish") }), {});
1982
- releasePublish();
1983
- await publishing;
1984
- await flushImmediate();
1985
- const afterRestart = [];
1986
- await makeEmitter({
1987
- published: afterRestart,
1988
- lastPublishedContentHashStateDir: stateDir,
1989
- }).emitForBatch({ paths: new Map([[changed, "dispose-timeout.md"]]) });
1990
- expect(afterRestart).toEqual([]);
1991
- });
1992
- it("returns one disposal barrier until the final durable batch is appended", async () => {
1993
- let releaseAppend;
1994
- const appendGate = new Promise((resolve) => { releaseAppend = resolve; });
1995
- let appendEntered;
1996
- const appendStarted = new Promise((resolve) => { appendEntered = resolve; });
1997
- const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
1998
- const appendGenerated = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
1999
- .mockImplementationOnce(async function (type, preparePayload, flags) {
2000
- appendEntered();
2001
- await appendGate;
2002
- return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
2003
- });
2004
- const emitter = makeEmitter({ lastPublishedContentHashStateDir: path.join(dir, "concurrent-dispose-state") });
2005
- try {
2006
- emitter.markContentPublished("pending.md", "sha256:pending");
2007
- const firstDispose = emitter.dispose();
2008
- await appendStarted;
2009
- let secondDisposed = false;
2010
- const secondDispose = emitter.dispose();
2011
- expect(secondDispose).toBe(firstDispose);
2012
- void secondDispose.then(() => { secondDisposed = true; });
2013
- await Promise.resolve();
2014
- expect(secondDisposed).toBe(false);
2015
- releaseAppend();
2016
- await Promise.all([firstDispose, secondDispose]);
2017
- expect(secondDisposed).toBe(true);
2018
- expect(appendGenerated).toHaveBeenCalledTimes(1);
2019
- }
2020
- finally {
2021
- appendGenerated.mockRestore();
2022
- }
2023
- });
2024
- it("replays __proto__ hash records with the same state as the legacy reducer", async () => {
2025
- const records = [
2026
- { sequence: 1, type: 0x5031, flags: 0, payload: { relativePath: "__proto__", contentHash: "sha256:proto", maxPaths: 4 } },
2027
- { sequence: 2, type: 0x5031, flags: 0, payload: { relativePath: "ordinary", contentHash: "sha256:ordinary", maxPaths: 4 } },
2028
- ];
2029
- const keyed = replayDurablePublishedContentHashRecordsForTest(records, true);
2030
- expect(keyed).toEqual(replayDurablePublishedContentHashRecordsForTest(records, false));
2031
- expect(keyed.hashes["__proto__"]).toBe("sha256:proto");
2032
- records.push({ sequence: 3, type: 0x5032, flags: 0, payload: { relativePath: "__proto__" } });
2033
- records.push({ sequence: 4, type: 0x5032, flags: 0, payload: { relativePath: "ordinary" } });
2034
- expect(replayDurablePublishedContentHashRecordsForTest(records, true))
2035
- .toEqual(replayDurablePublishedContentHashRecordsForTest(records, false));
2036
- // This restart uses DurablePublishedContentHashes.get("__proto__") to
2037
- // decide whether a successfully acknowledged hash may be suppressed.
2038
- const filePath = path.join(dir, "proto-sentinel.md");
2039
- const stateDir = path.join(dir, "proto-state");
2040
- const batch = { paths: new Map([[filePath, "__proto__"]]) };
2041
- fs.writeFileSync(filePath, "proto content\n");
2042
- await makeEmitter({ lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
2043
- const afterRestart = [];
2044
- await makeEmitter({ published: afterRestart, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
2045
- expect(afterRestart).toEqual([]);
2046
- });
2047
- it("replays fifty thousand hash records with sub-quadratic scaling", () => {
2048
- const replayFor = (count) => {
2049
- const started = performance.now();
2050
- replayDurablePublishedContentHashRecordsForTest(publishedHashRecords(count));
2051
- return performance.now() - started;
2052
- };
2053
- const small = replayFor(5_000);
2054
- const large = replayFor(50_000);
2055
- // The keyed reducer does a constant number of Map/object operations per
2056
- // record. Keep ample room for slow CI while rejecting a quadratic replay.
2057
- expect(large).toBeLessThan(small * 20 + 100);
2058
- });
2059
- function makeEmitter(opts) {
2060
- const published = opts?.published ?? [];
2061
- const transport = opts?.transport ?? {
2062
- start: async () => { },
2063
- dispose: async () => { },
2064
- connected: true,
2065
- publish: async (event) => {
2066
- published.push(event);
2067
- },
2068
- };
2069
- return new PushEventEmitter({
2070
- originTenantId: "tenant-indigo",
2071
- originDeviceId: "device-a",
2072
- transport,
2073
- flagProvider: { isEnabled: () => true },
2074
- now: () => new Date("2026-06-18T12:00:00.000Z"),
2075
- clock: opts?.clock,
2076
- getSequenceNumber: opts?.getSequenceNumber,
2077
- onError: opts?.onError,
2078
- lastPublishedContentHashMaxPaths: opts?.lastPublishedContentHashMaxPaths,
2079
- publishMinIntervalMs: opts?.publishMinIntervalMs ?? 0,
2080
- ...(opts?.lastPublishedContentHashStateDir
2081
- ? { lastPublishedContentHashStateDir: opts.lastPublishedContentHashStateDir }
2082
- : {}),
2083
- ...(opts?.initialPublishedContentHashes
2084
- ? { initialPublishedContentHashes: opts.initialPublishedContentHashes }
2085
- : {}),
2086
- ...(opts?.publishedContentHashLookup
2087
- ? { publishedContentHashLookup: opts.publishedContentHashLookup }
2088
- : {}),
2089
- ...(opts?.caseFoldingVolume !== undefined
2090
- ? { caseFoldingVolume: opts.caseFoldingVolume }
2091
- : {}),
2092
- });
2093
- }
2094
- it("lazily rechecks the journal baseline on a durable-cache miss without pinning a full snapshot", async () => {
2095
- const published = [];
2096
- const relativePath = "companies/indigo/seeded-lazily.md";
2097
- const filePath = path.join(dir, relativePath);
2098
- const content = "already uploaded\n";
2099
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
2100
- fs.writeFileSync(filePath, content);
2101
- const hash = `sha256:${createHash("sha256").update(content).digest("hex")}`;
2102
- const lookup = vi.fn((path) => path === relativePath ? hash : undefined);
2103
- const emitter = makeEmitter({
2104
- published,
2105
- lastPublishedContentHashStateDir: path.join(dir, "lazy-baseline-state"),
2106
- publishedContentHashLookup: lookup,
2107
- });
2108
- await emitter.emitForBatch({ paths: new Map([[filePath, relativePath]]) });
2109
- expect(lookup).toHaveBeenCalledWith(relativePath);
2110
- expect(published).toEqual([]);
2111
- // A failed/unknown lazy lookup must never suppress a genuine local edit.
2112
- const changedPath = path.join(dir, "companies/indigo/unknown-baseline.md");
2113
- fs.writeFileSync(changedPath, "local edit\n");
2114
- await emitter.emitForBatch({
2115
- paths: new Map([[changedPath, "companies/indigo/unknown-baseline.md"]]),
2116
- });
2117
- expect(published).toHaveLength(1);
2118
- });
2119
- it("F13: watcher batch event publishing applies bounded backpressure", async () => {
2120
- const paths = new Map();
2121
- for (let i = 0; i < 32; i++) {
2122
- const rel = `bulk-${i}.md`;
2123
- const abs = path.join(dir, rel);
2124
- fs.writeFileSync(abs, `file ${i}`);
2125
- paths.set(abs, rel);
2126
- }
2127
- let inFlight = 0;
2128
- let maxInFlight = 0;
2129
- let releaseGate;
2130
- const gate = new Promise((resolve) => {
2131
- releaseGate = resolve;
2132
- });
2133
- const transport = {
2134
- start: async () => { },
2135
- dispose: async () => { },
2136
- connected: true,
2137
- publish: async () => {
2138
- inFlight += 1;
2139
- maxInFlight = Math.max(maxInFlight, inFlight);
2140
- await gate;
2141
- inFlight -= 1;
2142
- },
2143
- };
2144
- const emitter = new PushEventEmitter({
2145
- originTenantId: "tenant-indigo",
2146
- originDeviceId: "device-a",
2147
- transport,
2148
- flagProvider: { isEnabled: () => true },
2149
- now: () => new Date("2026-06-18T12:00:00.000Z"),
2150
- });
2151
- const run = emitter.emitForBatch({ paths });
2152
- try {
2153
- for (let i = 0; i < 200 && maxInFlight <= 16; i++) {
2154
- await flushImmediate();
2155
- }
2156
- releaseGate();
2157
- await run;
2158
- expect(maxInFlight).toBeLessThanOrEqual(16);
2159
- }
2160
- finally {
2161
- releaseGate();
2162
- await run.catch(() => { });
2163
- }
2164
- });
2165
- it("skips directory changes silently without publishing or reporting an error", async () => {
2166
- const published = [];
2167
- const onError = vi.fn();
2168
- const subdir = path.join(dir, "folder");
2169
- fs.mkdirSync(subdir);
2170
- const emitter = makeEmitter({ published, onError });
2171
- await emitter.emitForBatch({ paths: new Map([[subdir, "folder"]]) });
2172
- expect(published).toEqual([]);
2173
- expect(onError).not.toHaveBeenCalled();
2174
- });
2175
- it("emits and publishes a delete tombstone when the changed file is gone", async () => {
2176
- const published = [];
2177
- const onError = vi.fn();
2178
- const emitter = makeEmitter({
2179
- published,
2180
- onError,
2181
- getSequenceNumber: () => 99,
2182
- caseFoldingVolume: true,
2183
- });
2184
- const missing = path.join(dir, "deleted.md");
2185
- await emitter.emitForBatch({ paths: new Map([[missing, "deleted.md"]]) });
2186
- expect(onError).not.toHaveBeenCalled();
2187
- expect(published).toEqual([
2188
- {
2189
- kind: "delete",
2190
- relativePath: "deleted.md",
2191
- originDeviceId: "device-a",
2192
- originTenantId: "tenant-indigo",
2193
- sequenceNumber: 99,
2194
- eventTimestamp: "2026-06-18T12:00:00.000Z",
2195
- },
2196
- ]);
2197
- expect(published[0]).not.toHaveProperty("contentHash");
2198
- expect(published[0]).not.toHaveProperty("mtime");
2199
- expect(published[0]).not.toHaveProperty("sizeBytes");
2200
- });
2201
- it("clears a pull-applied delete marker after a re-upload without a realtime emit", async () => {
2202
- const published = [];
2203
- const emitter = makeEmitter({ published });
2204
- const recreatedPath = path.join(dir, "recreated.md");
2205
- fs.writeFileSync(recreatedPath, "recreated content");
2206
- emitter.markContentDeleted("recreated.md");
2207
- // A successful scoped upload clears the marker even when publishing was
2208
- // unavailable, so the next genuine unlink cannot be swallowed.
2209
- emitter.clearContentDeleted("recreated.md");
2210
- fs.unlinkSync(recreatedPath);
2211
- await emitter.emitForBatch({
2212
- paths: new Map([[recreatedPath, "recreated.md"]]),
2213
- });
2214
- expect(published.map(({ kind, relativePath }) => ({ kind, relativePath })))
2215
- .toEqual([{ kind: "delete", relativePath: "recreated.md" }]);
2216
- await emitter.dispose();
2217
- });
2218
- it("keeps a case-only directory rename on the journal's existing remote spelling", async () => {
2219
- const contentByPath = new Map([
2220
- ["a.md", "a content"],
2221
- ["b.md", "b content"],
2222
- ]);
2223
- const renamedDir = path.join(dir, "dir");
2224
- fs.mkdirSync(renamedDir, { recursive: true });
2225
- const paths = new Map();
2226
- const knownHashes = new Map();
2227
- const remote = new Map();
2228
- for (const [name, content] of contentByPath) {
2229
- const localPath = path.join(renamedDir, name);
2230
- fs.writeFileSync(localPath, content);
2231
- const oldKey = `Dir/${name}`;
2232
- const newKey = `dir/${name}`;
2233
- knownHashes.set(oldKey, `sha256:${createHash("sha256").update(content).digest("hex")}`);
2234
- remote.set(oldKey, content);
2235
- paths.set(path.join(dir, "Dir", name), oldKey);
2236
- paths.set(localPath, newKey);
2237
- }
2238
- // APFS and NTFS already resolve `Dir` to this folder; a case-sensitive
2239
- // host gets the same lookup through a symlink.
2240
- if (!fs.existsSync(path.join(dir, "Dir")))
2241
- fs.symlinkSync("dir", path.join(dir, "Dir"), "dir");
2242
- const published = [];
2243
- const emitter = makeEmitter({
2244
- published,
2245
- publishedContentHashLookup: (relativePath) => knownHashes.get(relativePath),
2246
- caseFoldingVolume: true,
2247
- });
2248
- await emitter.emitForBatch({ paths });
2249
- for (const event of published) {
2250
- if (event.kind === "delete")
2251
- remote.delete(event.relativePath);
2252
- else
2253
- remote.set(event.relativePath, contentByPath.get(path.basename(event.relativePath)));
2254
- }
2255
- expect(published.some((event) => event.kind === "delete")).toBe(false);
2256
- expect([...remote.entries()].sort(([a], [b]) => a.localeCompare(b))).toEqual([
2257
- ["Dir/a.md", "a content"],
2258
- ["Dir/b.md", "b content"],
2259
- ]);
2260
- });
2261
- it("canonicalizes native-separator event keys and leaves POSIX keys byte-identical", async () => {
2262
- const published = [];
2263
- const onError = vi.fn();
2264
- const legacyPath = path.join(dir, "legacy.csv");
2265
- const posixPath = path.join(dir, "already-posix.csv");
2266
- fs.writeFileSync(legacyPath, "legacy\n");
2267
- fs.writeFileSync(posixPath, "posix\n");
2268
- const emitter = makeEmitter({ published, onError });
2269
- const windowsKey = "projects\\forecast\\x.csv";
2270
- await emitter.emitForBatch({
2271
- paths: new Map([[legacyPath, windowsKey]]),
2272
- });
2273
- await emitter.emitForBatch({
2274
- paths: new Map([[posixPath, "projects/forecast/already-posix.csv"]]),
2275
- });
2276
- if (path.sep === "\\") {
2277
- expect(published.map((event) => event.relativePath)).toEqual([
2278
- "projects/forecast/x.csv",
2279
- "projects/forecast/already-posix.csv",
2280
- ]);
2281
- expect(onError).not.toHaveBeenCalled();
2282
- }
2283
- else {
2284
- // POSIX: `\\` is a filename character. Do not remap onto a nested key.
2285
- expect(published.map((event) => event.relativePath)).toEqual([
2286
- "projects/forecast/already-posix.csv",
2287
- ]);
2288
- expect(onError).toHaveBeenCalled();
2289
- }
2290
- });
2291
- it("canonicalizes a native-separator tombstone only when `\\` is path.sep", async () => {
2292
- const published = [];
2293
- const onError = vi.fn();
2294
- const missing = path.join(dir, "deleted.csv");
2295
- const emitter = makeEmitter({ published, onError });
2296
- await emitter.emitForBatch({
2297
- paths: new Map([[missing, "projects\\forecast\\deleted.csv"]]),
2298
- });
2299
- if (path.sep === "\\") {
2300
- expect(published).toEqual([
2301
- expect.objectContaining({
2302
- kind: "delete",
2303
- relativePath: "projects/forecast/deleted.csv",
2304
- }),
2305
- ]);
2306
- expect(onError).not.toHaveBeenCalled();
2307
- }
2308
- else {
2309
- expect(published).toEqual([]);
2310
- expect(onError).toHaveBeenCalled();
2311
- }
2312
- });
2313
- it("publishes N unchanged watcher announcements exactly once", async () => {
2314
- const published = [];
2315
- const unchanged = path.join(dir, "unchanged-session-log.jsonl");
2316
- fs.writeFileSync(unchanged, '{"tool":"read"}\n');
2317
- const emitter = makeEmitter({ published });
2318
- const batch = { paths: new Map([[unchanged, "workspace/.session-logs/unchanged-session-log.jsonl"]]) };
2319
- for (let i = 0; i < 5; i += 1) {
2320
- await emitter.emitForBatch(batch);
2321
- }
2322
- expect(published).toHaveLength(1);
2323
- expect(published[0]).toMatchObject({
2324
- kind: "upsert",
2325
- relativePath: "workspace/.session-logs/unchanged-session-log.jsonl",
2326
- });
2327
- });
2328
- it("publishes again when content changes", async () => {
2329
- const published = [];
2330
- const changed = path.join(dir, "changed-session-log.jsonl");
2331
- fs.writeFileSync(changed, "A\n");
2332
- const emitter = makeEmitter({ published });
2333
- const batch = { paths: new Map([[changed, "workspace/.session-logs/changed-session-log.jsonl"]]) };
2334
- await emitter.emitForBatch(batch);
2335
- fs.writeFileSync(changed, "B\n");
2336
- await emitter.emitForBatch(batch);
2337
- expect(published).toHaveLength(2);
2338
- expect(published.map((event) => event.contentHash)).toEqual([
2339
- expect.stringMatching(/^sha256:/),
2340
- expect.stringMatching(/^sha256:/),
2341
- ]);
2342
- expect(published[0].contentHash).not.toBe(published[1].contentHash);
2343
- });
2344
- it("does not echo a pull-applied upsert but publishes a later local edit", async () => {
2345
- const published = [];
2346
- const applied = path.join(dir, "companies", "indigo", "remote-note.md");
2347
- const relativePath = "companies/indigo/remote-note.md";
2348
- const remoteContent = "from remote\n";
2349
- fs.mkdirSync(path.dirname(applied), { recursive: true });
2350
- fs.writeFileSync(applied, remoteContent);
2351
- const emitter = makeEmitter({ published });
2352
- // The pull completed its file write and journal update, then the runner
2353
- // bridged that upsert into the live emitter before the watcher settled.
2354
- emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
2355
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2356
- expect(published).toEqual([]);
2357
- fs.writeFileSync(applied, "local follow-up\n");
2358
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2359
- expect(published).toHaveLength(1);
2360
- expect(published[0]).toMatchObject({
2361
- kind: "upsert",
2362
- relativePath,
2363
- contentHash: `sha256:${createHash("sha256")
2364
- .update("local follow-up\n")
2365
- .digest("hex")}`,
2366
- });
2367
- });
2368
- it("publishes a local edit immediately after a pull mark despite the publish interval", async () => {
2369
- const clock = new FakeClock();
2370
- const published = [];
2371
- const applied = path.join(dir, "companies", "indigo", "remote-interval-note.md");
2372
- const relativePath = "companies/indigo/remote-interval-note.md";
2373
- const remoteContent = "from remote\n";
2374
- fs.mkdirSync(path.dirname(applied), { recursive: true });
2375
- fs.writeFileSync(applied, remoteContent);
2376
- const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
2377
- emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
2378
- fs.writeFileSync(applied, "local follow-up\n");
2379
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2380
- expect(published).toHaveLength(1);
2381
- expect(published[0]).toMatchObject({
2382
- kind: "upsert",
2383
- relativePath,
2384
- contentHash: `sha256:${createHash("sha256")
2385
- .update("local follow-up\n")
2386
- .digest("hex")}`,
2387
- });
2388
- });
2389
- it("keeps a newer pull mark when an older local publish completes", async () => {
2390
- const published = [];
2391
- const applied = path.join(dir, "companies", "indigo", "publish-race.md");
2392
- const relativePath = "companies/indigo/publish-race.md";
2393
- const oldContent = "local old bytes\n";
2394
- const remoteContent = "remote newer bytes\n";
2395
- fs.mkdirSync(path.dirname(applied), { recursive: true });
2396
- fs.writeFileSync(applied, oldContent);
2397
- let releasePublish;
2398
- let publishStartedResolve;
2399
- const publishStarted = new Promise((resolve) => {
2400
- publishStartedResolve = resolve;
2401
- });
2402
- const publishGate = new Promise((resolve) => {
2403
- releasePublish = resolve;
2404
- });
2405
- const emitter = makeEmitter({
2406
- published,
2407
- transport: {
2408
- start: async () => { },
2409
- dispose: async () => { },
2410
- connected: true,
2411
- publish: async (event) => {
2412
- published.push(event);
2413
- publishStartedResolve?.();
2414
- await publishGate;
2415
- },
2416
- },
2417
- });
2418
- const oldPublish = emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2419
- await publishStarted;
2420
- emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
2421
- releasePublish?.();
2422
- await oldPublish;
2423
- fs.writeFileSync(applied, remoteContent);
2424
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2425
- // The remote bytes remain suppressed: old publish completion could not
2426
- // overwrite the newer pull mark in the per-path publication ledger.
2427
- expect(published).toHaveLength(1);
2428
- expect(published[0]).toMatchObject({
2429
- kind: "upsert",
2430
- relativePath,
2431
- contentHash: `sha256:${createHash("sha256").update(oldContent).digest("hex")}`,
2432
- });
2433
- });
2434
- it("bounds publishedContentHashGenerations to the published-hash cap (was an unbounded Map)", () => {
2435
- const maxPaths = 8;
2436
- const K = 12;
2437
- const emitter = makeEmitter({ lastPublishedContentHashMaxPaths: maxPaths });
2438
- for (let i = 0; i < maxPaths + K; i++) {
2439
- emitter.markContentPublished(`companies/indigo/gen-${i}.md`, `sha256:h${i}`);
2440
- }
2441
- const sizes = emitter.censusSizes();
2442
- // The generations map used to be a plain Map with no eviction, so it grew to
2443
- // maxPaths + K and stayed there for the whole watch session — the unbounded
2444
- // retention this fix removes. It must now hold no more than its bounded
2445
- // sibling's cap.
2446
- expect(sizes.publishedContentHashGenerations).toBeLessThanOrEqual(maxPaths);
2447
- expect(sizes.lastPublishedContentHashes).toBeLessThanOrEqual(maxPaths);
2448
- // Sanity: we really did drive more distinct paths than the cap.
2449
- expect(maxPaths + K).toBeGreaterThan(maxPaths);
2450
- });
2451
- it("bounds the generations map without breaking pull-apply echo suppression for a hot path", async () => {
2452
- const published = [];
2453
- const maxPaths = 4;
2454
- const emitter = makeEmitter({ published, lastPublishedContentHashMaxPaths: maxPaths });
2455
- // Churn several cold paths first: each takes a fresh monotonic generation and
2456
- // evicts older ones from the now-bounded map.
2457
- for (let i = 0; i < maxPaths * 4; i++) {
2458
- emitter.markContentPublished(`companies/indigo/cold-${i}.md`, `sha256:c${i}`);
2459
- }
2460
- // The pulled path is marked LAST, so it is the hot, retained entry.
2461
- const applied = path.join(dir, "companies", "indigo", "hot-note.md");
2462
- const relativePath = "companies/indigo/hot-note.md";
2463
- const remoteContent = "from remote\n";
2464
- fs.mkdirSync(path.dirname(applied), { recursive: true });
2465
- fs.writeFileSync(applied, remoteContent);
2466
- emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
2467
- // The identical local write is still suppressed as an echo (contract #456).
2468
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2469
- expect(published).toEqual([]);
2470
- // A genuinely new local edit still publishes.
2471
- fs.writeFileSync(applied, "local follow-up\n");
2472
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2473
- expect(published).toHaveLength(1);
2474
- });
2475
- it("keeps generation currency fail-closed under the bounded map: a newer pull mark still wins the publish race", async () => {
2476
- const published = [];
2477
- const maxPaths = 4;
2478
- const applied = path.join(dir, "companies", "indigo", "fail-closed-race.md");
2479
- const relativePath = "companies/indigo/fail-closed-race.md";
2480
- const oldContent = "local old bytes\n";
2481
- const remoteContent = "remote newer bytes\n";
2482
- fs.mkdirSync(path.dirname(applied), { recursive: true });
2483
- fs.writeFileSync(applied, oldContent);
2484
- let releasePublish;
2485
- let publishStartedResolve;
2486
- const publishStarted = new Promise((resolve) => {
2487
- publishStartedResolve = resolve;
2488
- });
2489
- const publishGate = new Promise((resolve) => {
2490
- releasePublish = resolve;
2491
- });
2492
- const emitter = makeEmitter({
2493
- published,
2494
- lastPublishedContentHashMaxPaths: maxPaths,
2495
- transport: {
2496
- start: async () => { },
2497
- dispose: async () => { },
2498
- connected: true,
2499
- publish: async (event) => {
2500
- published.push(event);
2501
- publishStartedResolve?.();
2502
- await publishGate;
2503
- },
2504
- },
2505
- });
2506
- // Mark the path with an UNRELATED hash before the publish starts, so the
2507
- // in-flight publish captures a NON-ZERO generation yet is NOT echo-suppressed
2508
- // (the file's bytes differ from the marked hash).
2509
- emitter.markContentPublished(relativePath, "sha256:00decoy00");
2510
- const oldPublish = emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2511
- await publishStarted;
2512
- // A pull applies newer bytes while the old publish is in flight, taking a
2513
- // fresh monotonic generation the captured value can never match.
2514
- emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
2515
- releasePublish?.();
2516
- await oldPublish;
2517
- fs.writeFileSync(applied, remoteContent);
2518
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2519
- // The stale in-flight publish could not resurrect its captured generation to
2520
- // overwrite the newer pull mark, so the remote bytes stay suppressed.
2521
- expect(published.filter((event) => event.relativePath === relativePath)).toHaveLength(1);
2522
- expect(published[published.length - 1]).toMatchObject({
2523
- kind: "upsert",
2524
- relativePath,
2525
- contentHash: `sha256:${createHash("sha256").update(oldContent).digest("hex")}`,
2526
- });
2527
- });
2528
- it("does not resurrect an evicted generation to record a stale publish (ABA at the eviction boundary)", async () => {
2529
- const published = [];
2530
- const maxPaths = 2;
2531
- const applied = path.join(dir, "companies", "indigo", "aba-race.md");
2532
- const relativePath = "companies/indigo/aba-race.md";
2533
- const oldContent = "local old bytes\n";
2534
- const remoteContent = "remote newer bytes\n";
2535
- const oldHash = `sha256:${createHash("sha256").update(oldContent).digest("hex")}`;
2536
- fs.mkdirSync(path.dirname(applied), { recursive: true });
2537
- fs.writeFileSync(applied, oldContent);
2538
- let releasePublish;
2539
- let firstStartedResolve;
2540
- const firstStarted = new Promise((resolve) => {
2541
- firstStartedResolve = resolve;
2542
- });
2543
- const publishGate = new Promise((resolve) => {
2544
- releasePublish = resolve;
2545
- });
2546
- let publishCount = 0;
2547
- const emitter = makeEmitter({
2548
- published,
2549
- lastPublishedContentHashMaxPaths: maxPaths,
2550
- transport: {
2551
- start: async () => { },
2552
- dispose: async () => { },
2553
- connected: true,
2554
- publish: async (event) => {
2555
- published.push(event);
2556
- publishCount += 1;
2557
- // Gate only the FIRST publish so it is still in flight while the pull
2558
- // mark and eviction happen underneath it.
2559
- if (publishCount === 1) {
2560
- firstStartedResolve?.();
2561
- await publishGate;
2562
- }
2563
- },
2564
- },
2565
- });
2566
- // The path has NEVER been marked, so the in-flight publish captures the
2567
- // never-marked sentinel — the exact ABA precondition.
2568
- const firstPublish = emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2569
- await firstStarted;
2570
- // A pull applies newer bytes for this path while the publish is in flight...
2571
- emitter.markContentPublished(relativePath, `sha256:${createHash("sha256").update(remoteContent).digest("hex")}`);
2572
- // ...then enough other paths are marked to evict this path's generation entry,
2573
- // collapsing it back to the absent sentinel.
2574
- for (let i = 0; i < maxPaths * 4; i += 1) {
2575
- emitter.markContentPublished(`companies/indigo/aba-churn-${i}.md`, `sha256:z${i}`);
2576
- }
2577
- releasePublish?.();
2578
- await firstPublish;
2579
- // The stale completion must NOT have recorded oldHash for this path. If it
2580
- // had (the ABA bug), the genuine local revert to oldContent below would be
2581
- // wrongly suppressed as an echo and never published — silent data loss.
2582
- await emitter.emitForBatch({ paths: new Map([[applied, relativePath]]) });
2583
- const forPath = published.filter((event) => event.relativePath === relativePath);
2584
- expect(forPath).toHaveLength(2);
2585
- expect(forPath[1]).toMatchObject({ kind: "upsert", relativePath, contentHash: oldHash });
2586
- });
2587
- it("stamps an upsert with the byte length of the content it hashes", async () => {
2588
- const published = [];
2589
- const file = path.join(dir, "sized-content.bin");
2590
- const content = Buffer.from([0, 1, 2, 3, 4, 5, 6]);
2591
- fs.writeFileSync(file, content);
2592
- await makeEmitter({ published }).emitForBatch({
2593
- paths: new Map([[file, "sized-content.bin"]]),
2594
- });
2595
- expect(published).toHaveLength(1);
2596
- expect(published[0]).toMatchObject({
2597
- kind: "upsert",
2598
- sizeBytes: content.byteLength,
2599
- contentHash: `sha256:${createHash("sha256").update(content).digest("hex")}`,
2600
- });
2601
- });
2602
- it("coalesces ten rewrites of one path into an immediate and trailing latest publish", async () => {
2603
- const clock = new FakeClock();
2604
- const published = [];
2605
- const rewritten = path.join(dir, "high-churn-prd.json");
2606
- const relativePath = "personal/projects/churn/prd.json";
2607
- const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
2608
- const batch = { paths: new Map([[rewritten, relativePath]]) };
2609
- for (let version = 0; version < 10; version += 1) {
2610
- fs.writeFileSync(rewritten, `{"version":${version}}\n`);
2611
- await emitter.emitForBatch(batch);
2612
- if (version < 9)
2613
- clock.advance(5_000);
2614
- }
2615
- expect(published).toHaveLength(1);
2616
- clock.advance(15_000);
2617
- await emitter.emitForBatch({ paths: new Map() });
2618
- expect(published).toHaveLength(2);
2619
- expect(clock.pendingTimerCount()).toBe(0);
2620
- expect(published[1]).toMatchObject({ kind: "upsert", relativePath });
2621
- expect(published[1].contentHash).toBe(`sha256:${createHash("sha256").update('{"version":9}\n').digest("hex")}`);
2622
- });
2623
- it("suppresses a deferred continuation queued behind a realtime-unavailable denial", async () => {
2624
- const clock = new FakeClock();
2625
- const published = [];
2626
- const deferred = path.join(dir, "deferred-after-denial.json");
2627
- const active = path.join(dir, "active-denial.json");
2628
- const deferredRelativePath = "personal/projects/deferred-after-denial.json";
2629
- let releaseDenial;
2630
- let activePublishStarted;
2631
- const denialGate = new Promise((resolve) => {
2632
- releaseDenial = resolve;
2633
- });
2634
- const activeStarted = new Promise((resolve) => {
2635
- activePublishStarted = resolve;
2636
- });
2637
- const emitter = makeEmitter({
2638
- published,
2639
- clock,
2640
- publishMinIntervalMs: 60_000,
2641
- transport: {
2642
- start: async () => { },
2643
- dispose: async () => { },
2644
- connected: true,
2645
- publish: async (event) => {
2646
- published.push(event);
2647
- if (event.relativePath === "personal/projects/active-denial.json") {
2648
- activePublishStarted?.();
2649
- await denialGate;
2650
- throw new RealtimeUnavailableError({ status: 403, operation: "test push" });
2651
- }
2652
- },
2653
- },
2654
- });
2655
- fs.writeFileSync(deferred, '{"revision":1}\n');
2656
- await emitter.emitForBatch({ paths: new Map([[deferred, deferredRelativePath]]) });
2657
- fs.writeFileSync(deferred, '{"revision":2}\n');
2658
- await emitter.emitForBatch({ paths: new Map([[deferred, deferredRelativePath]]) });
2659
- fs.writeFileSync(active, '{"active":true}\n');
2660
- const activeRun = emitter.emitForBatch({
2661
- paths: new Map([[active, "personal/projects/active-denial.json"]]),
2662
- });
2663
- await activeStarted;
2664
- clock.advance(60_000);
2665
- releaseDenial?.();
2666
- await activeRun;
2667
- for (let i = 0; i < 6; i++)
2668
- await Promise.resolve();
2669
- expect(emitter.realtimeUnavailable).toBe(true);
2670
- expect(published.map((event) => event.relativePath)).toEqual([
2671
- deferredRelativePath,
2672
- "personal/projects/active-denial.json",
2673
- ]);
2674
- });
2675
- it("keeps publishing other companies after a company membership denial", async () => {
2676
- const deniedRelativePath = "companies/liverecover/projects/denied.md";
2677
- const allowedRelativePath = "companies/indigo/projects/allowed.md";
2678
- const denied = path.join(dir, deniedRelativePath);
2679
- const allowed = path.join(dir, allowedRelativePath);
2680
- fs.mkdirSync(path.dirname(denied), { recursive: true });
2681
- fs.mkdirSync(path.dirname(allowed), { recursive: true });
2682
- fs.writeFileSync(denied, "denied\n");
2683
- fs.writeFileSync(allowed, "allowed\n");
2684
- const observedPaths = [];
2685
- const fetch = vi.fn(async (_url, init) => {
2686
- const relativePath = JSON.parse(init?.body ?? "{}").relativePath;
2687
- observedPaths.push(relativePath);
2688
- return relativePath === deniedRelativePath
2689
- ? {
2690
- ok: false,
2691
- status: 403,
2692
- text: async () => JSON.stringify({
2693
- error: "cross-tenant",
2694
- code: "cross-tenant-push-rejected",
2695
- }),
2696
- }
2697
- : { ok: true, status: 200, text: async () => "" };
2698
- });
2699
- const onError = vi.fn();
2700
- const emitter = makeEmitter({
2701
- onError,
2702
- transport: new HttpPushTransport({
2703
- apiUrl: "https://api.example.com",
2704
- authToken: "token",
2705
- fetchImpl: fetch,
2706
- }),
2707
- });
2708
- await emitter.emitForBatch({ paths: new Map([[denied, deniedRelativePath]]) });
2709
- await emitter.emitForBatch({ paths: new Map([[allowed, allowedRelativePath]]) });
2710
- expect(fetch).toHaveBeenCalledTimes(2);
2711
- expect(observedPaths).toEqual([deniedRelativePath, allowedRelativePath]);
2712
- expect(emitter.realtimeUnavailable).toBe(false);
2713
- expect(onError).toHaveBeenCalledTimes(1);
2714
- expect(onError).toHaveBeenCalledWith(expect.any(PushScopeForbiddenError), { relativePath: deniedRelativePath });
2715
- });
2716
- it("suppresses repeated membership-denied publishes for one company scope", async () => {
2717
- const relativePath = "companies/liverecover/projects/retry-loop.md";
2718
- const file = path.join(dir, relativePath);
2719
- fs.mkdirSync(path.dirname(file), { recursive: true });
2720
- fs.writeFileSync(file, "still local\n");
2721
- const fetch = vi.fn(async () => ({
2722
- ok: false,
2723
- status: 403,
2724
- text: async () => JSON.stringify({
2725
- error: "cross-tenant",
2726
- code: "cross-tenant-push-rejected",
2727
- }),
2728
- }));
2729
- const onError = vi.fn();
2730
- const emitter = makeEmitter({
2731
- onError,
2732
- transport: new HttpPushTransport({
2733
- apiUrl: "https://api.example.com",
2734
- authToken: "token",
2735
- fetchImpl: fetch,
2736
- }),
2737
- });
2738
- const batch = { paths: new Map([[file, relativePath]]) };
2739
- await emitter.emitForBatch(batch);
2740
- await emitter.emitForBatch(batch);
2741
- await emitter.emitForBatch(batch);
2742
- expect(fetch).toHaveBeenCalledTimes(1);
2743
- expect(onError).toHaveBeenCalledTimes(1);
2744
- expect(onError).toHaveBeenCalledWith(expect.any(PushScopeForbiddenError), { relativePath });
2745
- });
2746
- it("publishes the uploaded deferred revision when the local file changes before flush", async () => {
2747
- const clock = new FakeClock();
2748
- const published = [];
2749
- const rewritten = path.join(dir, "captured-deferred-revision.json");
2750
- const relativePath = "personal/projects/captured/prd.json";
2751
- const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
2752
- const batch = { paths: new Map([[rewritten, relativePath]]) };
2753
- fs.writeFileSync(rewritten, '{"revision":"uploaded"}\n');
2754
- await emitter.emitForBatch(batch);
2755
- fs.writeFileSync(rewritten, '{"revision":"deferred-uploaded"}\n');
2756
- await emitter.emitForBatch(batch);
2757
- const expectedHash = `sha256:${createHash("sha256")
2758
- .update('{"revision":"deferred-uploaded"}\n')
2759
- .digest("hex")}`;
2760
- // This local revision has not completed its scoped push yet. The trailing
2761
- // announcement must remain bound to the previously uploaded bytes.
2762
- fs.writeFileSync(rewritten, '{"revision":"newer-local"}\n');
2763
- clock.advance(60_000);
2764
- await emitter.emitForBatch({ paths: new Map() });
2765
- expect(published).toHaveLength(2);
2766
- expect(published[1]).toMatchObject({
2767
- kind: "upsert",
2768
- relativePath,
2769
- contentHash: expectedHash,
2770
- });
2771
- expect(published[1].contentHash).not.toBe(`sha256:${createHash("sha256")
2772
- .update('{"revision":"newer-local"}\n')
2773
- .digest("hex")}`);
2774
- });
2775
- it("skips the trailing publish when the final rewrite restores the last published hash", async () => {
2776
- const clock = new FakeClock();
2777
- const published = [];
2778
- const rewritten = path.join(dir, "reverted-prd.json");
2779
- const relativePath = "personal/projects/reverted/prd.json";
2780
- const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
2781
- const batch = { paths: new Map([[rewritten, relativePath]]) };
2782
- fs.writeFileSync(rewritten, "A\n");
2783
- await emitter.emitForBatch(batch);
2784
- fs.writeFileSync(rewritten, "B\n");
2785
- await emitter.emitForBatch(batch);
2786
- fs.writeFileSync(rewritten, "A\n");
2787
- await emitter.emitForBatch(batch);
2788
- clock.advance(60_000);
2789
- await emitter.emitForBatch({ paths: new Map() });
2790
- expect(published).toHaveLength(1);
2791
- expect(clock.pendingTimerCount()).toBe(0);
2792
- expect(published[0]).toMatchObject({ kind: "upsert", relativePath });
2793
- });
2794
- it("publishes a delete immediately while an upsert is deferred", async () => {
2795
- const clock = new FakeClock();
2796
- const published = [];
2797
- const deleted = path.join(dir, "delete-during-quiet.jsonl");
2798
- const relativePath = "workspace/.session-logs/delete-during-quiet.jsonl";
2799
- const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
2800
- const batch = { paths: new Map([[deleted, relativePath]]) };
2801
- fs.writeFileSync(deleted, "A\n");
2802
- await emitter.emitForBatch(batch);
2803
- fs.writeFileSync(deleted, "B\n");
2804
- await emitter.emitForBatch(batch);
2805
- fs.rmSync(deleted);
2806
- await emitter.emitForBatch(batch);
2807
- expect(published.map((event) => event.kind)).toEqual(["upsert", "delete"]);
2808
- clock.advance(60_000);
2809
- await emitter.emitForBatch({ paths: new Map() });
2810
- expect(published).toHaveLength(2);
2811
- });
2812
- it("does not make a second path wait behind another path's quiet interval", async () => {
2813
- const published = [];
2814
- const first = path.join(dir, "first-high-churn.jsonl");
2815
- const second = path.join(dir, "independent.jsonl");
2816
- const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000 });
2817
- fs.writeFileSync(first, "A\n");
2818
- fs.writeFileSync(second, "B\n");
2819
- await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
2820
- fs.writeFileSync(first, "A2\n");
2821
- await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
2822
- await emitter.emitForBatch({ paths: new Map([[second, "workspace/.session-logs/second.jsonl"]]) });
2823
- expect(published.map((event) => event.relativePath)).toEqual([
2824
- "workspace/.session-logs/first.jsonl",
2825
- "workspace/.session-logs/second.jsonl",
2826
- ]);
2827
- });
2828
- it("publishes a content revert because it compares only with the last publication", async () => {
2829
- const published = [];
2830
- const reverted = path.join(dir, "reverted-session-log.jsonl");
2831
- fs.writeFileSync(reverted, "A\n");
2832
- const emitter = makeEmitter({ published });
2833
- const batch = { paths: new Map([[reverted, "workspace/.session-logs/reverted-session-log.jsonl"]]) };
2834
- await emitter.emitForBatch(batch);
2835
- fs.writeFileSync(reverted, "B\n");
2836
- await emitter.emitForBatch(batch);
2837
- fs.writeFileSync(reverted, "A\n");
2838
- await emitter.emitForBatch(batch);
2839
- expect(published).toHaveLength(3);
2840
- expect(published[0].contentHash).toBe(published[2].contentHash);
2841
- expect(published[0].contentHash).not.toBe(published[1].contentHash);
2842
- });
2843
- it("re-announces a revert after an intervening changed publication fails", async () => {
2844
- const published = [];
2845
- let fail = false;
2846
- const changed = path.join(dir, "failed-revert-session-log.jsonl");
2847
- fs.writeFileSync(changed, "A\n");
2848
- const emitter = new PushEventEmitter({
2849
- originTenantId: "tenant-indigo",
2850
- originDeviceId: "device-a",
2851
- flagProvider: { isEnabled: () => true },
2852
- publishMinIntervalMs: 0,
2853
- transport: {
2854
- start: async () => { },
2855
- dispose: async () => { },
2856
- connected: true,
2857
- publish: async (event) => {
2858
- if (fail)
2859
- throw new Error("push unavailable");
2860
- published.push(event);
2861
- },
2862
- },
2863
- onError: vi.fn(),
2864
- });
2865
- const batch = { paths: new Map([[changed, "workspace/.session-logs/failed-revert-session-log.jsonl"]]) };
2866
- await emitter.emitForBatch(batch);
2867
- fs.writeFileSync(changed, "B\n");
2868
- fail = true;
2869
- await emitter.emitForBatch(batch);
2870
- fs.writeFileSync(changed, "A\n");
2871
- fail = false;
2872
- await emitter.emitForBatch(batch);
2873
- expect(published).toHaveLength(2);
2874
- expect(published[0].contentHash).toBe(published[1].contentHash);
2875
- });
2876
- it("always publishes deletes after an unchanged touch and clears the path state", async () => {
2877
- const published = [];
2878
- const deleted = path.join(dir, "deleted-after-touch.jsonl");
2879
- fs.writeFileSync(deleted, "A\n");
2880
- const emitter = makeEmitter({ published });
2881
- const batch = { paths: new Map([[deleted, "workspace/.session-logs/deleted-after-touch.jsonl"]]) };
2882
- await emitter.emitForBatch(batch);
2883
- await emitter.emitForBatch(batch);
2884
- fs.rmSync(deleted);
2885
- await emitter.emitForBatch(batch);
2886
- fs.writeFileSync(deleted, "A\n");
2887
- await emitter.emitForBatch(batch);
2888
- expect(published).toHaveLength(3);
2889
- expect(published.map((event) => event.kind)).toEqual(["upsert", "delete", "upsert"]);
2890
- });
2891
- it("re-announces a same-content recreation after a failed delete", async () => {
2892
- const published = [];
2893
- let fail = false;
2894
- const deleted = path.join(dir, "failed-delete-session-log.jsonl");
2895
- fs.writeFileSync(deleted, "A\n");
2896
- const emitter = new PushEventEmitter({
2897
- originTenantId: "tenant-indigo",
2898
- originDeviceId: "device-a",
2899
- flagProvider: { isEnabled: () => true },
2900
- transport: {
2901
- start: async () => { },
2902
- dispose: async () => { },
2903
- connected: true,
2904
- publish: async (event) => {
2905
- if (fail)
2906
- throw new Error("push unavailable");
2907
- published.push(event);
2908
- },
2909
- },
2910
- onError: vi.fn(),
2911
- });
2912
- const batch = { paths: new Map([[deleted, "workspace/.session-logs/failed-delete-session-log.jsonl"]]) };
2913
- await emitter.emitForBatch(batch);
2914
- fs.rmSync(deleted);
2915
- fail = true;
2916
- await emitter.emitForBatch(batch);
2917
- fs.writeFileSync(deleted, "A\n");
2918
- fail = false;
2919
- await emitter.emitForBatch(batch);
2920
- expect(published.map((event) => event.kind)).toEqual(["upsert", "upsert"]);
2921
- });
2922
- it("bounds last-published content state and only re-publishes an evicted path", async () => {
2923
- const published = [];
2924
- const emitter = makeEmitter({ published, lastPublishedContentHashMaxPaths: 2 });
2925
- const paths = ["first.jsonl", "second.jsonl", "third.jsonl"].map((name) => {
2926
- const absolute = path.join(dir, name);
2927
- fs.writeFileSync(absolute, `${name}\n`);
2928
- return [absolute, `workspace/.session-logs/${name}`];
2929
- });
2930
- for (const entry of paths)
2931
- await emitter.emitForBatch({ paths: new Map([entry]) });
2932
- await emitter.emitForBatch({ paths: new Map([paths[0]]) });
2933
- await emitter.emitForBatch({ paths: new Map([paths[2]]) });
2934
- expect(published.map((event) => event.relativePath)).toEqual([
2935
- "workspace/.session-logs/first.jsonl",
2936
- "workspace/.session-logs/second.jsonl",
2937
- "workspace/.session-logs/third.jsonl",
2938
- "workspace/.session-logs/first.jsonl",
2939
- ]);
2940
- });
2941
- it("restart simulation: preserves published hashes so an unchanged first pass emits zero upserts", async () => {
2942
- const published = [];
2943
- const journaled = path.join(dir, "pre-populated-journal.jsonl");
2944
- fs.writeFileSync(journaled, "A\n");
2945
- const batch = { paths: new Map([[journaled, "workspace/.session-logs/pre-populated-journal.jsonl"]]) };
2946
- const stateDir = path.join(dir, "state");
2947
- await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
2948
- await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
2949
- expect(published).toHaveLength(1);
2950
- fs.writeFileSync(journaled, "B\n");
2951
- await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
2952
- expect(published).toHaveLength(2);
2953
- expect(published[1].contentHash).not.toBe(published[0].contentHash);
2954
- });
2955
- it("restart simulation: publishes a tombstone for a delete during downtime", async () => {
2956
- const published = [];
2957
- const deleted = path.join(dir, "deleted-during-downtime.md");
2958
- const batch = { paths: new Map([[deleted, "deleted-during-downtime.md"]]) };
2959
- const stateDir = path.join(dir, "state");
2960
- fs.writeFileSync(deleted, "A\n");
2961
- await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
2962
- fs.rmSync(deleted);
2963
- await makeEmitter({ published, lastPublishedContentHashStateDir: stateDir }).emitForBatch(batch);
2964
- expect(published.map((event) => event.kind)).toEqual(["upsert", "delete"]);
2965
- });
2966
- it("journal baseline suppresses unchanged files when durable state is first created", async () => {
2967
- const published = [];
2968
- const existing = path.join(dir, "journaled-before-upgrade.md");
2969
- const relativePath = "companies/indigo/journaled-before-upgrade.md";
2970
- fs.writeFileSync(existing, "already synced\n");
2971
- const contentHash = `sha256:${createHash("sha256").update("already synced\n").digest("hex")}`;
2972
- await makeEmitter({
2973
- published,
2974
- lastPublishedContentHashStateDir: path.join(dir, "baseline-state"),
2975
- initialPublishedContentHashes: { [relativePath]: contentHash },
2976
- }).emitForBatch({ paths: new Map([[existing, relativePath]]) });
2977
- expect(published).toEqual([]);
2978
- });
2979
- it("does not seed suppression for a journaled local divergence", async () => {
2980
- const published = [];
2981
- const changed = path.join(dir, "changed-while-down.md");
2982
- const relativePath = "companies/indigo/changed-while-down.md";
2983
- fs.writeFileSync(changed, "changed while down\n");
2984
- const contentHash = `sha256:${createHash("sha256").update("changed while down\n").digest("hex")}`;
2985
- await makeEmitter({
2986
- published,
2987
- lastPublishedContentHashStateDir: path.join(dir, "divergent-state"),
2988
- }).emitForBatch({ paths: new Map([[changed, relativePath]]) });
2989
- expect(published).toHaveLength(1);
2990
- expect(published[0]).toMatchObject({ kind: "upsert", contentHash });
2991
- });
2992
- it("fresh durable state publishes the first uploaded file", async () => {
2993
- const published = [];
2994
- const fresh = path.join(dir, "fresh-device.md");
2995
- fs.writeFileSync(fresh, "first upload\n");
2996
- await makeEmitter({
2997
- published,
2998
- lastPublishedContentHashStateDir: path.join(dir, "fresh-state"),
2999
- }).emitForBatch({ paths: new Map([[fresh, "fresh-device.md"]]) });
3000
- expect(published).toHaveLength(1);
3001
- expect(published[0]).toMatchObject({ kind: "upsert", relativePath: "fresh-device.md" });
3002
- });
3003
- it("retries a durable publish-state lock timeout without rejecting the watcher announce", async () => {
3004
- const clock = new FakeClock();
3005
- const onError = vi.fn();
3006
- const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
3007
- const appendGeneratedIfAsync = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
3008
- .mockImplementationOnce(function (type, preparePayload, flags) {
3009
- return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
3010
- })
3011
- .mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
3012
- const published = [];
3013
- const changed = path.join(dir, "lock-timeout.md");
3014
- fs.writeFileSync(changed, "lock timeout\n");
3015
- try {
3016
- await expect(makeEmitter({
3017
- clock,
3018
- onError,
3019
- published,
3020
- lastPublishedContentHashStateDir: path.join(dir, "lock-timeout-state"),
3021
- }).emitForBatch({ paths: new Map([[changed, "lock-timeout.md"]]) })).resolves.toBeUndefined();
3022
- expect(published).toHaveLength(1);
3023
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ name: "StateStoreLockError" }), { relativePath: "lock-timeout.md" });
3024
- expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(2); // invalidate before the guarded durable set
3025
- const appendsBeforeRetry = appendGeneratedIfAsync.mock.calls.length;
3026
- clock.advance(250);
3027
- await Promise.resolve();
3028
- await Promise.resolve();
3029
- expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(appendsBeforeRetry + 1);
3030
- }
3031
- finally {
3032
- appendGeneratedIfAsync.mockRestore();
3033
- }
3034
- });
3035
- it("cancels a stale durable-state retry after persisting a newer hash", async () => {
3036
- const clock = new FakeClock();
3037
- const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
3038
- const appendGeneratedIfAsync = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
3039
- .mockImplementationOnce(function (type, preparePayload, flags) {
3040
- return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
3041
- })
3042
- .mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
3043
- const stateDir = path.join(dir, "superseded-retry-state");
3044
- const changed = path.join(dir, "superseded-retry.md");
3045
- const published = [];
3046
- fs.writeFileSync(changed, "A\n");
3047
- try {
3048
- const emitter = makeEmitter({ clock, published, lastPublishedContentHashStateDir: stateDir });
3049
- await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
3050
- expect(clock.pendingTimerCount()).toBe(1);
3051
- fs.writeFileSync(changed, "B\n");
3052
- await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
3053
- expect(clock.pendingTimerCount()).toBe(0);
3054
- const appendsAfterNewerSuccess = appendGeneratedIfAsync.mock.calls.length;
3055
- clock.advance(10_000);
3056
- await Promise.resolve();
3057
- expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(appendsAfterNewerSuccess);
3058
- const afterRestart = [];
3059
- await makeEmitter({
3060
- published: afterRestart,
3061
- lastPublishedContentHashStateDir: stateDir,
3062
- }).emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
3063
- expect(afterRestart).toEqual([]);
3064
- }
3065
- finally {
3066
- appendGeneratedIfAsync.mockRestore();
3067
- }
3068
- });
3069
- it("drops permanent durable-state persistence errors without scheduling retries", async () => {
3070
- const clock = new FakeClock();
3071
- const onError = vi.fn();
3072
- const originalAppendGeneratedIfAsync = StateStore.prototype.appendGeneratedIfAsync;
3073
- const appendGeneratedIfAsync = vi.spyOn(StateStore.prototype, "appendGeneratedIfAsync")
3074
- .mockImplementationOnce(function (type, preparePayload, flags) {
3075
- return originalAppendGeneratedIfAsync.call(this, type, preparePayload, flags);
3076
- })
3077
- .mockRejectedValue(new Error("EACCES: state directory is unwritable"));
3078
- const changed = path.join(dir, "permanent-state-error.md");
3079
- fs.writeFileSync(changed, "cannot persist\n");
3080
- try {
3081
- await makeEmitter({
3082
- clock,
3083
- onError,
3084
- lastPublishedContentHashStateDir: path.join(dir, "permanent-state-error"),
3085
- }).emitForBatch({ paths: new Map([[changed, "permanent-state-error.md"]]) });
3086
- expect(onError).toHaveBeenCalledTimes(1);
3087
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("EACCES") }), { relativePath: "permanent-state-error.md" });
3088
- expect(clock.pendingTimerCount()).toBe(0);
3089
- const appendsAfterFailure = appendGeneratedIfAsync.mock.calls.length;
3090
- clock.advance(60_000);
3091
- await Promise.resolve();
3092
- expect(appendGeneratedIfAsync).toHaveBeenCalledTimes(appendsAfterFailure);
3093
- expect(onError).toHaveBeenCalledTimes(1);
3094
- }
3095
- finally {
3096
- appendGeneratedIfAsync.mockRestore();
3097
- }
3098
- });
3099
- it("emits push telemetry only when publication fails", async () => {
3100
- const posts = [];
3101
- const telemetryClient = {
3102
- postTelemetryEvents: vi.fn(async (batch) => {
3103
- posts.push(batch);
3104
- return { ok: true, written: batch.events.length, skipped: [] };
3105
- }),
3106
- };
3107
- const changed = path.join(dir, "changed.md");
3108
- fs.writeFileSync(changed, "changed");
3109
- const success = new PushEventEmitter({
3110
- originTenantId: "tenant-indigo",
3111
- originDeviceId: "device-a",
3112
- transport: {
3113
- start: async () => { },
3114
- dispose: async () => { },
3115
- connected: true,
3116
- publish: async () => { },
3117
- },
3118
- flagProvider: { isEnabled: () => true },
3119
- telemetryClient,
3120
- });
3121
- await success.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
3122
- await flushImmediate();
3123
- expect(posts).toEqual([]);
3124
- const failed = new PushEventEmitter({
3125
- originTenantId: "tenant-indigo",
3126
- originDeviceId: "device-a",
3127
- transport: {
3128
- start: async () => { },
3129
- dispose: async () => { },
3130
- connected: true,
3131
- publish: async () => {
3132
- throw new Error("network down");
3133
- },
3134
- },
3135
- flagProvider: { isEnabled: () => true },
3136
- telemetryClient,
3137
- onError: vi.fn(),
3138
- });
3139
- await failed.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
3140
- await flushImmediate();
3141
- expect(posts.flatMap((post) => post.events)).toEqual([
3142
- expect.objectContaining({
3143
- eventName: "push_event_failed",
3144
- properties: {
3145
- kind: "upsert",
3146
- count: 1,
3147
- status: "failure",
3148
- stage: "publish",
3149
- },
3150
- }),
3151
- ]);
3152
- });
3153
- it("emits one outcome record with the correct mixed-batch attempt denominator", async () => {
3154
- const first = path.join(dir, "first.md");
3155
- const second = path.join(dir, "second.md");
3156
- fs.writeFileSync(first, "first");
3157
- fs.writeFileSync(second, "second");
3158
- const outcomes = [];
3159
- const emitter = new PushEventEmitter({
3160
- originTenantId: "tenant-indigo",
3161
- originDeviceId: "device-a",
3162
- transport: {
3163
- start: async () => { },
3164
- dispose: async () => { },
3165
- connected: true,
3166
- publish: async (event) => {
3167
- if (event.relativePath === "second.md") {
3168
- throw new Error("second publish failed");
3169
- }
3170
- },
3171
- },
3172
- flagProvider: { isEnabled: () => true },
3173
- getSequenceNumber: (() => {
3174
- let next = 41;
3175
- return () => next++;
3176
- })(),
3177
- onError: vi.fn(),
3178
- onPublishOutcome: (outcome) => outcomes.push(outcome),
3179
- });
3180
- await emitter.emitForBatch({
3181
- paths: new Map([
3182
- [first, "first.md"],
3183
- [second, "second.md"],
3184
- ]),
3185
- });
3186
- expect(outcomes).toHaveLength(1);
3187
- expect(outcomes[0]).toMatchObject({
3188
- attempted: 2,
3189
- succeeded: 1,
3190
- failed: 1,
3191
- });
3192
- expect(outcomes[0].omitted).toBe(0);
3193
- expect(outcomes[0].attempts).toContainEqual(expect.objectContaining({
3194
- error: expect.objectContaining({ message: "second publish failed" }),
3195
- }));
3196
- });
3197
- it("caps successful attempt evidence while retaining every failure and full-set latency statistics", () => {
3198
- const successes = Array.from({ length: 1_000 }, (_, index) => ({
3199
- sequenceNumber: index,
3200
- elapsedMs: index + 1,
3201
- abortedAtIssue: false,
3202
- }));
3203
- const failures = Array.from({ length: 16 }, (_, index) => ({
3204
- sequenceNumber: 10_000 + index,
3205
- elapsedMs: 60_000 + index,
3206
- abortedAtIssue: false,
3207
- error: {
3208
- name: "AbortError",
3209
- message: `publish timeout ${index}`,
3210
- stack: "at Timeout._onTimeout (push-transport.js:195)",
3211
- },
3212
- }));
3213
- const outcome = summarizePublishBatchOutcome([...successes, ...failures]);
3214
- const serialized = JSON.stringify(outcome);
3215
- const retainedFailures = outcome.attempts.filter((attempt) => attempt.error !== undefined);
3216
- const retainedSuccesses = outcome.attempts.filter((attempt) => attempt.error === undefined);
3217
- expect(outcome).toMatchObject({
3218
- attempted: 1_016,
3219
- succeeded: 1_000,
3220
- failed: 16,
3221
- omitted: 1_000 - PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP,
3222
- successfulElapsedMs: { min: 1, median: 500.5, max: 1_000 },
3223
- });
3224
- expect(retainedSuccesses).toEqual(successes.slice(0, 3));
3225
- expect(retainedFailures).toEqual(failures);
3226
- for (const aggregate of ["attempted", "succeeded", "failed"]) {
3227
- expect(serialized.indexOf(`"${aggregate}"`)).toBeLessThan(serialized.indexOf('"attempts"'));
3228
- }
3229
- expect(Buffer.byteLength(serialized)).toBeLessThan(PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES);
3230
- });
3231
- });
3232
- describe("TreeWatcher wire-key codec boundary (US-002)", () => {
3233
- it("emits the canonical vault key for an encoded win32 farm name (verbatim local name on POSIX)", () => {
3234
- const hqRoot = path.join(os.tmpdir(), "hq-watch-codec-test-root");
3235
- const clock = new FakeClock();
3236
- const watcher = new TreeWatcher({
3237
- hqRoot,
3238
- clock,
3239
- debounceMs: 100,
3240
- pathFilter: () => true,
3241
- });
3242
- const rels = [];
3243
- watcher.onChange((_first, batch) => {
3244
- if (batch)
3245
- rels.push(...batch.paths.values());
3246
- });
3247
- watcher.handleEvent(path.join(hqRoot, ".claude", "skills", "indigo%3Acall-prep-brief"));
3248
- clock.advance(200);
3249
- // The batch rel is a WIRE identifier (PushEvent.relativePath / targeted
3250
- // push route). On win32 the encoded local farm name must decode back to
3251
- // the canonical key or event-driven push re-mints the junk key family;
3252
- // on POSIX the local name IS the key verbatim (codec is identity there).
3253
- const expectedRel = process.platform === "win32"
3254
- ? ".claude/skills/indigo:call-prep-brief"
3255
- : ".claude/skills/indigo%3Acall-prep-brief";
3256
- expect(rels).toEqual([expectedRel]);
3257
- watcher.dispose();
3258
- });
3259
- });
3260
- describe("TreeWatcher deferred delete snapshots", () => {
3261
- it("does not let a thrown delete-capture callback kill later watcher delivery", () => {
3262
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-capture-error-"));
3263
- const clock = new FakeClock();
3264
- const watcher = new TreeWatcher({
3265
- hqRoot,
3266
- clock,
3267
- debounceMs: 25,
3268
- pathFilter: () => true,
3269
- queueLocalDeleteSnapshots: () => {
3270
- throw new Error("capture failed");
3271
- },
3272
- });
3273
- const batches = [];
3274
- watcher.onChange((_first, batch) => batches.push([...(batch?.paths.values() ?? [])]));
3275
- expect(() => watcher.handleEvent(path.join(hqRoot, "deleted.md"), "unlink")).not.toThrow();
3276
- expect(() => watcher.handleEvent(path.join(hqRoot, "later.md"), "add")).not.toThrow();
3277
- clock.advance(25);
3278
- expect(batches).toEqual([["deleted.md", "later.md"]]);
3279
- watcher.dispose();
3280
- fs.rmSync(hqRoot, { recursive: true, force: true });
3281
- });
3282
- it("marks a queued unlink stale when a newer revision arrives", async () => {
3283
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-capture-stale-"));
3284
- const clock = new FakeClock();
3285
- let resolveCapture;
3286
- let observedCurrent = true;
3287
- const watcher = new TreeWatcher({
3288
- hqRoot,
3289
- clock,
3290
- debounceMs: 25,
3291
- pathFilter: () => true,
3292
- queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
3293
- await new Promise((resolve) => { resolveCapture = resolve; });
3294
- observedCurrent = isCurrent();
3295
- return [];
3296
- },
3297
- });
3298
- watcher.handleEvent(path.join(hqRoot, "recreated.md"), "unlink");
3299
- await Promise.resolve();
3300
- watcher.handleEvent(path.join(hqRoot, "recreated.md"), "add");
3301
- resolveCapture();
3302
- await Promise.resolve();
3303
- expect(observedCurrent).toBe(false);
3304
- watcher.dispose();
3305
- fs.rmSync(hqRoot, { recursive: true, force: true });
3306
- });
3307
- it("batches one watcher turn and keeps per-path current checks", async () => {
3308
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-capture-batch-"));
3309
- const clock = new FakeClock();
3310
- const observed = [];
3311
- const queueBatch = async (captures) => {
3312
- const results = captures.map((capture) => {
3313
- const current = capture.isCurrent();
3314
- observed.push({ relativePath: capture.relativePath, current });
3315
- return current ? [{
3316
- journalSlug: "indigo",
3317
- journalPath: capture.relativePath,
3318
- deleteScopeRoot: "",
3319
- absolutePath: path.join(hqRoot, capture.relativePath),
3320
- remoteEtag: "etag",
3321
- localHash: "hash",
3322
- localKind: "file",
3323
- }] : [];
3324
- });
3325
- return results;
3326
- };
3327
- const watcher = new TreeWatcher({
3328
- hqRoot,
3329
- clock,
3330
- debounceMs: 25,
3331
- pathFilter: () => true,
3332
- queueLocalDeleteSnapshotsBatch: queueBatch,
3333
- });
3334
- try {
3335
- const recreated = path.join(hqRoot, "recreated.md");
3336
- const deleted = path.join(hqRoot, "deleted.md");
3337
- watcher.handleEvent(recreated, "unlink");
3338
- watcher.handleEvent(recreated, "add");
3339
- watcher.handleEvent(deleted, "unlink");
3340
- await settle();
3341
- expect(observed).toEqual([
3342
- { relativePath: "recreated.md", current: false },
3343
- { relativePath: "deleted.md", current: true },
3344
- ]);
3345
- await settle();
3346
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
3347
- }
3348
- finally {
3349
- watcher.dispose();
3350
- fs.rmSync(hqRoot, { recursive: true, force: true });
3351
- }
3352
- });
3353
- // `deleteSnapshotEpochs` exists to let a newer revision of a path
3354
- // invalidate a delete-snapshot capture that is still queued or running. A
3355
- // long-lived runner touches hundreds of thousands of distinct paths, so a
3356
- // key may live in that map only while such a capture is in flight; any
3357
- // other write into it is a leak keyed by path.
3358
- it("batches warmup missing-file snapshots through the queued capture path", async () => {
3359
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-warmup-capture-batch-"));
3360
- const observed = [];
3361
- const queueBatch = async (captures) => {
3362
- observed.push(captures.map((capture) => capture.relativePath));
3363
- return captures.map(() => []);
3364
- };
3365
- const watcher = new TreeWatcher({
3366
- hqRoot,
3367
- pathFilter: () => true,
3368
- queueLocalDeleteSnapshotsBatch: queueBatch,
3369
- });
3370
- try {
3371
- const catchup = await watcher.collectWarmupCatchupAsync(Date.now(), [
3372
- "missing-one.md",
3373
- "missing-two.md",
3374
- ]);
3375
- await Promise.all([...(catchup.batch.changes?.values() ?? [])].map((change) => change.deleteSnapshotCapture ?? Promise.resolve([])));
3376
- await settle();
3377
- expect(observed).toEqual([["missing-one.md", "missing-two.md"]]);
3378
- }
3379
- finally {
3380
- watcher.dispose();
3381
- fs.rmSync(hqRoot, { recursive: true, force: true });
3382
- }
3383
- });
3384
- const settle = () => new Promise((resolve) => setImmediate(resolve));
3385
- it("retires a superseded capture's epoch when that capture completes", async () => {
3386
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-retire-"));
3387
- const clock = new FakeClock();
3388
- let resolveCapture;
3389
- let observedCurrent = true;
3390
- const watcher = new TreeWatcher({
3391
- hqRoot,
3392
- clock,
3393
- debounceMs: 25,
3394
- pathFilter: () => true,
3395
- queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
3396
- await new Promise((resolve) => { resolveCapture = resolve; });
3397
- observedCurrent = isCurrent();
3398
- return [];
3399
- },
3400
- });
3401
- try {
3402
- const target = path.join(hqRoot, "recreated.md");
3403
- watcher.handleEvent(target, "unlink");
3404
- await Promise.resolve();
3405
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
3406
- // The newer revision invalidates the running capture but must keep the
3407
- // marker until that capture has actually observed it.
3408
- watcher.handleEvent(target, "add");
3409
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
3410
- resolveCapture();
3411
- await settle();
3412
- expect(observedCurrent).toBe(false);
3413
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
3414
- }
3415
- finally {
3416
- watcher.dispose();
3417
- fs.rmSync(hqRoot, { recursive: true, force: true });
3418
- }
3419
- });
3420
- it("never creates an epoch for a live event or for a synchronous capture", () => {
3421
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-live-"));
3422
- const clock = new FakeClock();
3423
- const capture = vi.fn(() => []);
3424
- const watcher = new TreeWatcher({
3425
- hqRoot,
3426
- clock,
3427
- debounceMs: 25,
3428
- pathFilter: () => true,
3429
- captureLocalDeleteSnapshots: capture,
3430
- });
3431
- try {
3432
- watcher.handleEvent(path.join(hqRoot, "added.md"), "add");
3433
- watcher.handleEvent(path.join(hqRoot, "edited.md"), "change");
3434
- watcher.handleEvent(path.join(hqRoot, "made"), "addDir");
3435
- watcher.handleEvent(path.join(hqRoot, "gone.md"), "unlink");
3436
- watcher.handleEvent(path.join(hqRoot, "gone-dir"), "unlinkDir");
3437
- clock.advance(25);
3438
- expect(capture).toHaveBeenCalledTimes(2);
3439
- // A synchronous capture has already run by the time the event returns:
3440
- // there is nothing left to invalidate, so there is nothing to remember.
3441
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
3442
- }
3443
- finally {
3444
- watcher.dispose();
3445
- fs.rmSync(hqRoot, { recursive: true, force: true });
3446
- }
3447
- });
3448
- it("shares one epoch across overlapping captures of a path and retires it with the last", async () => {
3449
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-overlap-"));
3450
- const clock = new FakeClock();
3451
- const resolvers = [];
3452
- const observed = [];
3453
- const watcher = new TreeWatcher({
3454
- hqRoot,
3455
- clock,
3456
- debounceMs: 25,
3457
- pathFilter: () => true,
3458
- queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
3459
- await new Promise((resolve) => { resolvers.push(resolve); });
3460
- observed.push(isCurrent());
3461
- return [];
3462
- },
3463
- });
3464
- try {
3465
- const target = path.join(hqRoot, "twice.md");
3466
- watcher.handleEvent(target, "unlink");
3467
- await Promise.resolve();
3468
- watcher.handleEvent(target, "unlink"); // second delete in the window queues behind the first
3469
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
3470
- resolvers.shift()();
3471
- await settle();
3472
- // The first capture was superseded by the second; the second is still
3473
- // queued, so the path keeps its marker.
3474
- expect(observed).toEqual([false]);
3475
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
3476
- resolvers.shift()();
3477
- await settle();
3478
- expect(observed).toEqual([false, true]);
3479
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
3480
- }
3481
- finally {
3482
- watcher.dispose();
3483
- fs.rmSync(hqRoot, { recursive: true, force: true });
3484
- }
3485
- });
3486
- it("leaves no epoch for a path whose capture the full queue rejected", async () => {
3487
- const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-epoch-full-"));
3488
- const clock = new FakeClock();
3489
- const resolvers = [];
3490
- const observed = [];
3491
- const watcher = new TreeWatcher({
3492
- hqRoot,
3493
- clock,
3494
- debounceMs: 25,
3495
- pathFilter: () => true,
3496
- maxPendingPaths: 2,
3497
- queueLocalDeleteSnapshots: async (relative, _kind, isCurrent) => {
3498
- await new Promise((resolve) => { resolvers.push(resolve); });
3499
- observed.push(`${relative}:${isCurrent()}`);
3500
- return [];
3501
- },
3502
- });
3503
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
3504
- try {
3505
- const a = path.join(hqRoot, "a.md");
3506
- const b = path.join(hqRoot, "b.md");
3507
- watcher.handleEvent(a, "unlink");
3508
- await Promise.resolve();
3509
- resolvers.shift()();
3510
- await settle(); // a's capture is done; a is still pending in the window
3511
- expect(observed).toEqual(["a.md:true"]);
3512
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
3513
- watcher.handleEvent(b, "unlink");
3514
- watcher.handleEvent(b, "unlink"); // two captures queued for b: the queue is full
3515
- // A third delete of `a` finds the queue full. Nothing is in flight for
3516
- // `a`, so nothing may be recorded for it either.
3517
- watcher.handleEvent(a, "unlink");
3518
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(1);
3519
- // A full queue must still supersede a capture that IS in flight for the
3520
- // path, exactly as it did before, and that path's marker still retires.
3521
- watcher.handleEvent(b, "unlink");
3522
- await settle(); // the first queued capture for b starts
3523
- resolvers.shift()();
3524
- await settle(); // ...and the second, queued behind it
3525
- resolvers.shift()();
3526
- await settle();
3527
- expect(observed).toEqual(["a.md:true", "b.md:false", "b.md:false"]);
3528
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
3529
- }
3530
- finally {
3531
- consoleError.mockRestore();
3532
- watcher.dispose();
3533
- fs.rmSync(hqRoot, { recursive: true, force: true });
3534
- }
3535
- });
3536
- });
3537
- describe("TreeWatcher untracked delete drop", () => {
3538
- // Atomic writers (`.name.<pid>.<random>.tmp` renamed into place, editor
3539
- // swap files) create and remove a path within one debounce window. Such a
3540
- // path has no journal row, so its unlink has nothing to delete remotely and
3541
- // no revision to bind: queueing it only cost a delete-snapshot capture, a
3542
- // pending-map slot, and a targeted push that found nothing on disk. On the
3543
- // controller these were 370 of 380 agency watcher events per minute.
3544
- function makeRoot() {
3545
- return fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-untracked-"));
3546
- }
3547
- it("drops a file unlink that has no journal row before it reaches a batch", () => {
3548
- const hqRoot = makeRoot();
3549
- const clock = new FakeClock();
3550
- const capture = vi.fn(() => []);
3551
- const watcher = new TreeWatcher({
3552
- hqRoot,
3553
- clock,
3554
- debounceMs: 25,
3555
- pathFilter: () => true,
3556
- captureLocalDeleteSnapshots: capture,
3557
- isUntrackedDelete: (relativePath) => relativePath.endsWith(".tmp"),
3558
- });
3559
- const batches = [];
3560
- watcher.onChange((_first, batch) => batches.push([...(batch?.paths.values() ?? [])]));
3561
- try {
3562
- watcher.handleEvent(path.join(hqRoot, "notes", ".draft.md.123.abc.tmp"), "unlink");
3563
- watcher.handleEvent(path.join(hqRoot, "notes", "kept.md"), "unlink");
3564
- clock.advance(25);
3565
- expect(batches).toEqual([["notes/kept.md"]]);
3566
- // The dropped path never paid for a delete-snapshot capture.
3567
- expect(capture).toHaveBeenCalledTimes(1);
3568
- expect(capture).toHaveBeenCalledWith("notes/kept.md", "unlink");
3569
- expect(watcher.untrackedDeletesDropped()).toBe(1);
3570
- // Neither path leaves an epoch behind: the kept unlink was captured
3571
- // synchronously (nothing in flight to invalidate) and the dropped one
3572
- // never had a capture at all.
3573
- expect(watcher.censusSizes().deleteSnapshotEpochs).toBe(0);
3574
- }
3575
- finally {
3576
- watcher.dispose();
3577
- fs.rmSync(hqRoot, { recursive: true, force: true });
3578
- }
3579
- });
3580
- it("leaves no delete-snapshot epoch behind for an atomic write cancelled inside its own window", () => {
3581
- // Every agency writer saves through `.<name>.<pid>.<random>.tmp` and
3582
- // renames it into place, so the watcher sees an `add` and then an
3583
- // `unlink` of a name it will never see again. 370 of those a minute on
3584
- // the controller: if each one left a key in a per-path map, the map
3585
- // would grow by half a million randomised paths a day.
3586
- const hqRoot = makeRoot();
3587
- const clock = new FakeClock();
3588
- const queue = vi.fn(async () => []);
3589
- const watcher = new TreeWatcher({
3590
- hqRoot,
3591
- clock,
3592
- debounceMs: 25,
3593
- pathFilter: () => true,
3594
- queueLocalDeleteSnapshots: queue,
3595
- isUntrackedDelete: (relativePath) => relativePath.endsWith(".tmp"),
3596
- });
3597
- const changed = vi.fn();
3598
- watcher.onChange(changed);
3599
- try {
3600
- for (let i = 0; i < 250; i++) {
3601
- const temp = path.join(hqRoot, "workspace", "agency", `.ledger.json.${i}.${(i * 7919).toString(36)}.tmp`);
3602
- watcher.handleEvent(temp, "add");
3603
- watcher.handleEvent(temp, "unlink");
3604
- }
3605
- clock.advance(1_000);
3606
- expect(queue).not.toHaveBeenCalled();
3607
- expect(changed).not.toHaveBeenCalled();
3608
- expect(watcher.untrackedDeletesDropped()).toBe(250);
3609
- expect(watcher.pendingTimerCount()).toBe(0);
3610
- expect(watcher.censusSizes()).toMatchObject({ deleteSnapshotEpochs: 0, knownKinds: 0 });
3611
- }
3612
- finally {
3613
- watcher.dispose();
3614
- fs.rmSync(hqRoot, { recursive: true, force: true });
3615
- }
3616
- });
3617
- it("resolves a vanished rename to an unlink, purges the path's earlier queued add, and leaves no timer armed", () => {
3618
- const hqRoot = makeRoot();
3619
- const clock = new FakeClock();
3620
- const seam = vi.fn((relativePath) => relativePath.endsWith(".tmp"));
3621
- const watcher = new TreeWatcher({
3622
- hqRoot,
3623
- clock,
3624
- debounceMs: 25,
3625
- pathFilter: () => true,
3626
- isUntrackedDelete: seam,
3627
- });
3628
- const changed = vi.fn();
3629
- watcher.onChange(changed);
3630
- try {
3631
- const temp = path.join(hqRoot, "state.json.tmp");
3632
- fs.writeFileSync(temp, "{}");
3633
- watcher.handleEvent(temp, "rename"); // exists → add
3634
- expect(watcher.pendingTimerCount()).toBe(1);
3635
- fs.rmSync(temp);
3636
- watcher.handleEvent(temp, "rename"); // ENOENT → unlink → untracked → dropped
3637
- // The create and the delete cancelled out inside one window. Nothing is
3638
- // left to emit, so the window closes without waking the push at all.
3639
- expect(seam).toHaveBeenCalledWith("state.json.tmp");
3640
- expect(watcher.pendingTimerCount()).toBe(0);
3641
- clock.advance(1_000);
3642
- expect(changed).not.toHaveBeenCalled();
3643
- expect(watcher.untrackedDeletesDropped()).toBe(1);
3644
- }
3645
- finally {
3646
- watcher.dispose();
3647
- fs.rmSync(hqRoot, { recursive: true, force: true });
3648
- }
3649
- });
3650
- it("keeps journaled unlinks and never consults the seam for live files or directory deletes", () => {
3651
- const hqRoot = makeRoot();
3652
- const clock = new FakeClock();
3653
- const seam = vi.fn(() => false);
3654
- const capture = vi.fn(() => []);
3655
- const watcher = new TreeWatcher({
3656
- hqRoot,
3657
- clock,
3658
- debounceMs: 25,
3659
- pathFilter: () => true,
3660
- captureLocalDeleteSnapshots: capture,
3661
- isUntrackedDelete: seam,
3662
- });
3663
- const batches = [];
3664
- watcher.onChange((_first, batch) => batches.push(batch?.paths ?? new Map()));
3665
- try {
3666
- const live = path.join(hqRoot, "live.md");
3667
- fs.writeFileSync(live, "x");
3668
- watcher.handleEvent(live, "add");
3669
- // A directory with no row of its own may still cover journaled
3670
- // descendants; only the capture's prefix scan can tell, so directory
3671
- // deletes always go through.
3672
- watcher.handleEvent(path.join(hqRoot, "gone-dir"), "unlinkDir");
3673
- watcher.handleEvent(path.join(hqRoot, "journaled.md"), "unlink");
3674
- clock.advance(25);
3675
- expect(seam).toHaveBeenCalledTimes(1);
3676
- expect(seam).toHaveBeenCalledWith("journaled.md");
3677
- expect([...(batches[0]?.values() ?? [])].sort()).toEqual(["gone-dir", "journaled.md", "live.md"]);
3678
- expect(capture).toHaveBeenCalledWith("gone-dir", "unlinkDir");
3679
- expect(capture).toHaveBeenCalledWith("journaled.md", "unlink");
3680
- expect(watcher.untrackedDeletesDropped()).toBe(0);
3681
- }
3682
- finally {
3683
- watcher.dispose();
3684
- fs.rmSync(hqRoot, { recursive: true, force: true });
3685
- }
3686
- });
3687
- it("keeps the unlink when the seam throws", () => {
3688
- const hqRoot = makeRoot();
3689
- const clock = new FakeClock();
3690
- const watcher = new TreeWatcher({
3691
- hqRoot,
3692
- clock,
3693
- debounceMs: 25,
3694
- pathFilter: () => true,
3695
- isUntrackedDelete: () => {
3696
- throw new Error("journal unavailable");
3697
- },
3698
- });
3699
- const batches = [];
3700
- watcher.onChange((_first, batch) => batches.push([...(batch?.paths.values() ?? [])]));
3701
- const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
3702
- try {
3703
- expect(() => watcher.handleEvent(path.join(hqRoot, "maybe.md"), "unlink")).not.toThrow();
3704
- clock.advance(25);
3705
- // Dropping is an optimisation; keeping the event is the safe default.
3706
- expect(batches).toEqual([["maybe.md"]]);
3707
- expect(watcher.untrackedDeletesDropped()).toBe(0);
3708
- }
3709
- finally {
3710
- consoleError.mockRestore();
3711
- watcher.dispose();
3712
- fs.rmSync(hqRoot, { recursive: true, force: true });
3713
- }
3714
- });
3715
- it("a dropped unlink supersedes an in-flight queued capture for the same path", async () => {
3716
- const hqRoot = makeRoot();
3717
- const clock = new FakeClock();
3718
- let untracked = false;
3719
- let resolveCapture;
3720
- let observedCurrent;
3721
- const watcher = new TreeWatcher({
3722
- hqRoot,
3723
- clock,
3724
- debounceMs: 25,
3725
- pathFilter: () => true,
3726
- isUntrackedDelete: () => untracked,
3727
- queueLocalDeleteSnapshots: async (_relative, _kind, isCurrent) => {
3728
- await new Promise((resolve) => { resolveCapture = resolve; });
3729
- observedCurrent = isCurrent();
3730
- return [];
3731
- },
3732
- });
3733
- try {
3734
- const target = path.join(hqRoot, "flip.md");
3735
- watcher.handleEvent(target, "unlink"); // journal view cold: kept, capture queued
3736
- await Promise.resolve();
3737
- untracked = true;
3738
- watcher.handleEvent(target, "unlink"); // journal view warm: no row → dropped
3739
- resolveCapture();
3740
- await Promise.resolve();
3741
- // The queued capture must not bind a revision for a path the watcher
3742
- // has since discarded.
3743
- expect(observedCurrent).toBe(false);
3744
- expect(watcher.pendingTimerCount()).toBe(0);
3745
- expect(watcher.untrackedDeletesDropped()).toBe(1);
3746
- }
3747
- finally {
3748
- watcher.dispose();
3749
- fs.rmSync(hqRoot, { recursive: true, force: true });
3750
- }
3751
- });
3752
- });
3753
- // ---------------------------------------------------------------------------
3754
- // Event-pass debounce configuration (HQ_SYNC_EVENT_DEBOUNCE_MS / _MAX_WAIT_MS)
3755
- // ---------------------------------------------------------------------------
3756
- describe("resolveEventDebounceConfig", () => {
3757
- const ENV_KEYS = [
3758
- EVENT_DEBOUNCE_MS_ENV,
3759
- EVENT_MAX_WAIT_MS_ENV,
3760
- EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV,
3761
- EVENT_SMALL_BATCH_MAX_PATHS_ENV,
3762
- EVENT_SMALL_BATCH_MAX_BYTES_ENV,
3763
- ];
3764
- let saved;
3765
- beforeEach(() => {
3766
- saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
3767
- for (const k of ENV_KEYS)
3768
- delete process.env[k];
3769
- });
3770
- afterEach(() => {
3771
- for (const k of ENV_KEYS) {
3772
- if (saved[k] === undefined)
3773
- delete process.env[k];
3774
- else
3775
- process.env[k] = saved[k];
3776
- }
3777
- });
3778
- it("defaults to a 60s quiet window, a 60s small-batch window, and a 120s max-wait ceiling", () => {
3779
- expect(DEFAULT_EVENT_DEBOUNCE_MS).toBe(60_000);
3780
- expect(DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS).toBe(60_000);
3781
- expect(DEFAULT_EVENT_MAX_WAIT_MS).toBe(120_000);
3782
- expect(resolveEventDebounceConfig({})).toEqual({
3783
- debounceMs: 60_000,
3784
- maxWaitMs: 120_000,
3785
- smallBatchDebounceMs: 60_000,
3786
- smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
3787
- smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
3788
- });
3789
- expect(resolveEventDebounceConfig()).toEqual({
3790
- debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
3791
- maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
3792
- smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
3793
- smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
3794
- smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
3795
- });
3796
- });
3797
- it("honors env overrides", () => {
3798
- process.env[EVENT_DEBOUNCE_MS_ENV] = "5000";
3799
- process.env[EVENT_MAX_WAIT_MS_ENV] = "30000";
3800
- process.env[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV] = "2000";
3801
- process.env[EVENT_SMALL_BATCH_MAX_PATHS_ENV] = "6";
3802
- process.env[EVENT_SMALL_BATCH_MAX_BYTES_ENV] = "4096";
3803
- expect(resolveEventDebounceConfig()).toEqual({
3804
- debounceMs: 5000,
3805
- maxWaitMs: 30_000,
3806
- smallBatchDebounceMs: 2000,
3807
- smallBatchMaxPaths: 6,
3808
- smallBatchMaxBytes: 4096,
3809
- });
3810
- });
3811
- it("lets HQ_SYNC_EVENT_DEBOUNCE_MS override the quiet window independently", () => {
3812
- expect(resolveEventDebounceConfig({
3813
- [EVENT_DEBOUNCE_MS_ENV]: "90000",
3814
- })).toEqual({
3815
- debounceMs: 90_000,
3816
- maxWaitMs: 120_000,
3817
- smallBatchDebounceMs: 60_000,
3818
- smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
3819
- smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
3820
- });
3821
- });
3822
- it("lets HQ_SYNC_EVENT_SMALL_BATCH_DEBOUNCE_MS override the small-batch window independently", () => {
3823
- expect(resolveEventDebounceConfig({
3824
- [EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "10000",
3825
- })).toEqual({
3826
- debounceMs: 60_000,
3827
- maxWaitMs: 120_000,
3828
- smallBatchDebounceMs: 10_000,
3829
- smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
3830
- smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
3831
- });
3832
- });
3833
- it("clamps a small-batch override above the quiet window down to the quiet window", () => {
3834
- expect(resolveEventDebounceConfig({
3835
- [EVENT_DEBOUNCE_MS_ENV]: "30000",
3836
- [EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "50000",
3837
- })).toEqual({
3838
- debounceMs: 30_000,
3839
- maxWaitMs: 120_000,
3840
- smallBatchDebounceMs: 30_000,
3841
- smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
3842
- smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
3843
- });
3844
- });
3845
- it("treats 0, negative, and non-numeric values as unset (defaults)", () => {
3846
- for (const bad of ["0", "-5", "banana", ""]) {
3847
- process.env[EVENT_DEBOUNCE_MS_ENV] = bad;
3848
- process.env[EVENT_MAX_WAIT_MS_ENV] = bad;
3849
- expect(resolveEventDebounceConfig()).toEqual({
3850
- debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
3851
- maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
3852
- smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
3853
- smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
3854
- smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
3855
- });
3856
- }
3857
- });
3858
- it("floors the max-wait ceiling at the quiet window", () => {
3859
- process.env[EVENT_DEBOUNCE_MS_ENV] = "60000";
3860
- process.env[EVENT_MAX_WAIT_MS_ENV] = "10000";
3861
- expect(resolveEventDebounceConfig()).toEqual({
3862
- debounceMs: 60_000,
3863
- maxWaitMs: 60_000,
3864
- smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
3865
- smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
3866
- smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
3867
- });
3868
- });
3869
- it("accepts an injected env record without touching process.env", () => {
3870
- expect(resolveEventDebounceConfig({
3871
- [EVENT_DEBOUNCE_MS_ENV]: "1000",
3872
- [EVENT_MAX_WAIT_MS_ENV]: "2000",
3873
- [EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "750",
3874
- [EVENT_SMALL_BATCH_MAX_PATHS_ENV]: "4",
3875
- [EVENT_SMALL_BATCH_MAX_BYTES_ENV]: "1024",
3876
- })).toEqual({
3877
- debounceMs: 1000,
3878
- maxWaitMs: 2000,
3879
- smallBatchDebounceMs: 750,
3880
- smallBatchMaxPaths: 4,
3881
- smallBatchMaxBytes: 1024,
3882
- });
3883
- });
3884
- });
3885
- describe("resolvePublishMinIntervalMs", () => {
3886
- it("defaults to 60 seconds, accepts zero to disable coalescing, and rejects invalid values", () => {
3887
- expect(resolvePublishMinIntervalMs({})).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
3888
- expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "0" })).toBe(0);
3889
- expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "invalid" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
3890
- expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "-1" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
3891
- });
3892
- it("uses the environment override in milliseconds", () => {
3893
- expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "1234.9" })).toBe(1234);
3894
- });
3895
- });
3896
- /**
3897
- * Linux folder-only backend.
3898
- *
3899
- * On Linux there is no recursive `fs.watch`, and chokidar's answer was one
3900
- * inotify watch per directory PLUS one per file. On a real HQ root that was
3901
- * ~74k directories and ~878k files — ~950k watches, ~16 KB each — while the
3902
- * safety cap counted only the directories and so never fired. A directory
3903
- * watch already reports content changes to the files directly inside it, so
3904
- * the per-file watches bought nothing. These tests pin the replacement: the
3905
- * scoped native backend driven with `recursiveWatches: false`, taking exactly
3906
- * one shallow watch per in-scope directory and none per file.
3907
- */
3908
- describe("Linux folder-only native backend (startScopedRecursiveWatch, recursiveWatches: false)", () => {
3909
- let root;
3910
- const isLinux = process.platform === "linux";
3911
- beforeEach(() => {
3912
- root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-")));
3913
- });
3914
- afterEach(() => {
3915
- fs.rmSync(root, { recursive: true, force: true });
3916
- });
3917
- /** In-scope tree: root, docs, docs/deep, notes — 4 dirs, 8 files, plus an excluded node_modules. */
3918
- function seedTree() {
3919
- const dirs = [
3920
- path.join(root, "docs"),
3921
- path.join(root, "docs", "deep"),
3922
- path.join(root, "notes"),
3923
- ];
3924
- for (const d of dirs)
3925
- fs.mkdirSync(d, { recursive: true });
3926
- const files = [];
3927
- for (const d of [root, ...dirs]) {
3928
- for (const n of ["one.md", "two.md"]) {
3929
- const f = path.join(d, n);
3930
- fs.writeFileSync(f, "x");
3931
- files.push(f);
3932
- }
3933
- }
3934
- const nm = path.join(root, "docs", "node_modules", "pkg");
3935
- fs.mkdirSync(nm, { recursive: true });
3936
- fs.writeFileSync(path.join(nm, "index.js"), "1");
3937
- return { inScopeDirs: [root, ...dirs], files };
3938
- }
3939
- /** Count this process's live inotify watch descriptors (Linux only). */
3940
- function inotifyWatchDescriptors() {
3941
- let total = 0;
3942
- for (const fd of fs.readdirSync("/proc/self/fdinfo")) {
3943
- try {
3944
- const info = fs.readFileSync(path.join("/proc/self/fdinfo", fd), "utf8");
3945
- total += (info.match(/^inotify wd:/gm) ?? []).length;
3946
- }
3947
- catch {
3948
- /* fd closed between readdir and read */
3949
- }
3950
- }
3951
- return total;
3952
- }
3953
- async function waitFor(check, ms = 4000) {
3954
- const deadline = Date.now() + ms;
3955
- while (Date.now() < deadline) {
3956
- if (check())
3957
- return;
3958
- await new Promise((r) => setTimeout(r, 25));
3959
- }
3960
- expect(check()).toBe(true);
3961
- }
3962
- function waitForReady(backend) {
3963
- return new Promise((resolve) => {
3964
- if (backend.onReady)
3965
- backend.onReady(resolve);
3966
- else
3967
- resolve();
3968
- });
3969
- }
3970
- function startFolderOnly(opts = {}) {
3971
- const shouldEmit = opts.includeAll
3972
- ? () => true
3973
- : createWatchPathFilter(root, false);
3974
- return startScopedRecursiveWatch(root, shouldEmit, opts.onEvent ?? (() => { }), opts.onError ?? ((err) => { throw err; }), undefined, {
3975
- recursiveWatches: false,
3976
- maxWatchedPaths: opts.maxWatchedPaths,
3977
- onWatchBudgetExceeded: opts.onWatchBudgetExceeded,
3978
- listChildDirs: opts.listChildDirs,
3979
- watchDirectory: opts.watchDirectory,
3980
- directoryIdentity: opts.directoryIdentity,
3981
- });
3982
- }
3983
- it("takes exactly one watch per in-scope directory and none per file", async () => {
3984
- const { inScopeDirs } = seedTree();
3985
- const before = isLinux ? inotifyWatchDescriptors() : 0;
3986
- const backend = startFolderOnly();
3987
- try {
3988
- await waitForReady(backend);
3989
- expect(backend.mode).toBe("native");
3990
- expect(backend.attachFailures).toBe(0);
3991
- // 4 in-scope directories; 8 in-scope files and an excluded subtree add nothing.
3992
- expect(backend.watchedPathCount()).toBe(inScopeDirs.length);
3993
- if (isLinux) {
3994
- // Measured immediately, before any drain window: a whole-tree
3995
- // bootstrap watch would show up here as extra descriptors.
3996
- expect(inotifyWatchDescriptors() - before).toBe(inScopeDirs.length);
3997
- }
3998
- }
3999
- finally {
4000
- backend.close();
4001
- }
4002
- if (isLinux)
4003
- expect(inotifyWatchDescriptors()).toBe(before);
4004
- });
4005
- it("reports a content change to a file inside a watched folder without a file watch", async () => {
4006
- const { files } = seedTree();
4007
- const seen = [];
4008
- const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
4009
- try {
4010
- await new Promise((r) => setTimeout(r, 150));
4011
- const target = files.find((f) => f.includes(path.join("docs", "deep")));
4012
- fs.appendFileSync(target, "more");
4013
- await waitFor(() => seen.includes(target));
4014
- }
4015
- finally {
4016
- backend.close();
4017
- }
4018
- });
4019
- it("attaches a folder created after start, then reports files written inside it", async () => {
4020
- seedTree();
4021
- const seen = [];
4022
- const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
4023
- try {
4024
- await new Promise((r) => setTimeout(r, 150));
4025
- const before = backend.watchedPathCount();
4026
- const created = path.join(root, "notes", "later");
4027
- fs.mkdirSync(created);
4028
- await waitFor(() => backend.watchedPathCount() === before + 1);
4029
- const file = path.join(created, "fresh.md");
4030
- fs.writeFileSync(file, "hello");
4031
- await waitFor(() => seen.includes(file));
4032
- }
4033
- finally {
4034
- backend.close();
4035
- }
4036
- });
4037
- it("scans a folder that already holds files when it attaches, so nothing arrives silently", async () => {
4038
- seedTree();
4039
- const seen = [];
4040
- const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
4041
- try {
4042
- await new Promise((r) => setTimeout(r, 150));
4043
- // Build the subtree OUTSIDE the watched root, then move it in with one
4044
- // rename: the folder watch only ever sees the directory appear, never
4045
- // the files inside it. Chokidar walked such a directory on addDir;
4046
- // the folder-only backend must do the same.
4047
- const staging = fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-stage-"));
4048
- fs.mkdirSync(path.join(staging, "inner"));
4049
- fs.writeFileSync(path.join(staging, "a.md"), "a");
4050
- fs.writeFileSync(path.join(staging, "inner", "b.md"), "b");
4051
- const dest = path.join(root, "docs", "moved");
4052
- fs.renameSync(staging, dest);
4053
- await waitFor(() => seen.includes(path.join(dest, "a.md")) &&
4054
- seen.includes(path.join(dest, "inner", "b.md")));
4055
- expect(backend.watchedPathCount()).toBe(4 + 2);
4056
- }
4057
- finally {
4058
- backend.close();
4059
- }
4060
- });
4061
- it("re-attaches when a watched folder is swapped for a new one at the same path (Linux inode change)", async () => {
4062
- seedTree();
4063
- const seen = [];
4064
- const backend = startFolderOnly({ onEvent: (p) => seen.push(p) });
4065
- try {
4066
- await new Promise((r) => setTimeout(r, 150));
4067
- expect(backend.watchedPathCount()).toBe(4);
4068
- // Atomic swap: the old `docs` moves out of the tree and a replacement
4069
- // (built outside, so its creation is never observed) lands at the same
4070
- // pathname — both renames complete before any watch callback runs. On
4071
- // Linux the old handle follows the moved-away INODE, so without an
4072
- // identity check the backend would keep it, consider `docs` covered,
4073
- // and never hear another event from the replacement subtree.
4074
- const staging = fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-swap-"));
4075
- fs.mkdirSync(path.join(staging, "deep"));
4076
- fs.writeFileSync(path.join(staging, "a.md"), "a");
4077
- fs.writeFileSync(path.join(staging, "deep", "b.md"), "b");
4078
- const parked = fs.mkdtempSync(path.join(os.tmpdir(), "folder-watch-parked-"));
4079
- const docs = path.join(root, "docs");
4080
- fs.renameSync(docs, path.join(parked, "docs"));
4081
- fs.renameSync(staging, docs);
4082
- // The replacement's existing files are announced, exactly like a
4083
- // populated folder moved in from outside.
4084
- await waitFor(() => seen.includes(path.join(docs, "a.md")) &&
4085
- seen.includes(path.join(docs, "deep", "b.md")));
4086
- // A later write inside the replacement is reported: the new inode is
4087
- // watched, and the stale handles (docs + docs/deep) are gone — still
4088
- // root, docs, docs/deep, notes.
4089
- const after = path.join(docs, "deep", "after.md");
4090
- fs.writeFileSync(after, "after");
4091
- await waitFor(() => seen.includes(after));
4092
- expect(backend.watchedPathCount()).toBe(4);
4093
- // The moved-away tree must not keep reporting under its OLD path.
4094
- seen.length = 0;
4095
- fs.writeFileSync(path.join(parked, "docs", "stale.md"), "stale");
4096
- await new Promise((r) => setTimeout(r, 300));
4097
- expect(seen.filter((p) => p.startsWith(docs + path.sep))).toEqual([]);
4098
- fs.rmSync(parked, { recursive: true, force: true });
4099
- }
4100
- finally {
4101
- backend.close();
4102
- }
4103
- });
4104
- it("probeCoverage reports root-missing when the HQ root is gone", () => {
4105
- seedTree();
4106
- const backend = startFolderOnly();
4107
- try {
4108
- expect(backend.probeCoverage()).toEqual({ ok: true });
4109
- fs.rmSync(root, { recursive: true, force: true });
4110
- const probe = backend.probeCoverage();
4111
- expect(probe.ok).toBe(false);
4112
- if (!probe.ok)
4113
- expect(probe.reason).toBe("root-missing");
4114
- }
4115
- finally {
4116
- backend.close();
4117
- }
4118
- });
4119
- it("covers a folder created after its parent was listed but before the plan attached (walk/attach race)", async () => {
4120
- seedTree();
4121
- const seen = [];
4122
- const late = path.join(root, "notes", "late");
4123
- const watchEvents = new Map();
4124
- // The plan walk lists `notes`, then — before any watch existed on the
4125
- // old code path — `notes/late` appears. The snapshot plan never holds
4126
- // it, and a shallow watch on `notes` attached only after the whole walk
4127
- // would never have seen it appear. On a real HQ root the walk takes
4128
- // seconds; agents create directories the whole time.
4129
- const listChildDirs = (dir) => {
4130
- const children = fs
4131
- .readdirSync(dir, { withFileTypes: true })
4132
- .filter((e) => e.isDirectory())
4133
- .map((e) => path.join(dir, e.name));
4134
- if (dir === path.join(root, "notes"))
4135
- fs.mkdirSync(late);
4136
- return children;
4137
- };
4138
- const backend = startFolderOnly({
4139
- onEvent: (p) => seen.push(p),
4140
- listChildDirs,
4141
- watchDirectory: ((dir, _options, listener) => {
4142
- watchEvents.set(path.resolve(String(dir)), listener);
4143
- const handle = new EventEmitter();
4144
- handle.close = () => { };
4145
- return handle;
4146
- }),
4147
- });
4148
- try {
4149
- await waitForReady(backend);
4150
- watchEvents.get(path.join(root, "notes"))?.("rename", "late");
4151
- // root, docs, docs/deep, notes — plus `late`, picked up by the live
4152
- // watch on `notes` rather than by the plan.
4153
- await waitFor(() => backend.watchedPathCount() === 5);
4154
- const file = path.join(late, "inside.md");
4155
- fs.writeFileSync(file, "x");
4156
- watchEvents.get(late)?.("rename", "inside.md");
4157
- await waitFor(() => seen.includes(file));
4158
- }
4159
- finally {
4160
- backend.close();
4161
- }
4162
- });
4163
- it("releases every watch beneath a deleted folder", async () => {
4164
- seedTree();
4165
- const backend = startFolderOnly();
4166
- try {
4167
- await new Promise((r) => setTimeout(r, 150));
4168
- expect(backend.watchedPathCount()).toBe(4);
4169
- fs.rmSync(path.join(root, "docs"), { recursive: true, force: true });
4170
- // docs and docs/deep both go; root and notes remain.
4171
- await waitFor(() => backend.watchedPathCount() === 2);
4172
- if (isLinux) {
4173
- await waitFor(() => inotifyWatchDescriptors() <= 2);
4174
- }
4175
- }
4176
- finally {
4177
- backend.close();
4178
- }
4179
- });
4180
- it("stops attaching at the budget and reports the exhaustion exactly once, counting real watches", async () => {
4181
- seedTree();
4182
- const exceeded = vi.fn();
4183
- const backend = startFolderOnly({
4184
- maxWatchedPaths: 2,
4185
- onWatchBudgetExceeded: exceeded,
4186
- });
4187
- try {
4188
- await waitForReady(backend);
4189
- expect(backend.watchedPathCount()).toBe(2);
4190
- expect(exceeded).toHaveBeenCalledTimes(1);
4191
- expect(exceeded.mock.calls[0][0]).toMatchObject({
4192
- watchedPaths: 2,
4193
- maxWatchedPaths: 2,
4194
- });
4195
- expect(typeof exceeded.mock.calls[0][0].offendingPath).toBe("string");
4196
- }
4197
- finally {
4198
- backend.close();
4199
- }
4200
- });
4201
- it("selects the single-root backend on darwin and leaves linux and windows unchanged", () => {
4202
- expect(selectTreeWatchBackend("linux", false)).toBe("native-shallow");
4203
- expect(selectTreeWatchBackend("darwin", false)).toBe("native-root");
4204
- expect(selectTreeWatchBackend("win32", false)).toBe("native-recursive");
4205
- expect(selectTreeWatchBackend("freebsd", false)).toBe("chokidar");
4206
- expect(selectTreeWatchBackend("linux", true)).toBe("polling");
4207
- expect(selectTreeWatchBackend("darwin", true)).toBe("polling");
4208
- });
4209
- it("filters native-root callbacks before forwarding them to onEvent", () => {
4210
- const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "native-root-filter-")));
4211
- const excludedDir = path.join(dir, "node_modules");
4212
- fs.mkdirSync(path.join(excludedDir, "pkg"), { recursive: true });
4213
- fs.writeFileSync(path.join(excludedDir, "pkg", "index.js"), "x");
4214
- const callback = vi.fn();
4215
- const handle = new EventEmitter();
4216
- handle.close = vi.fn();
4217
- let listener;
4218
- const backend = startNativeRootWatch(dir, createWatchPathFilter(dir, false), callback, vi.fn(), {
4219
- watchDirectory: ((root, options, onChange) => {
4220
- expect(root).toBe(dir);
4221
- expect(options).toEqual({ recursive: true, persistent: true });
4222
- listener = onChange;
4223
- return handle;
4224
- }),
4225
- });
4226
- try {
4227
- expect(backend.mode).toBe("native-root");
4228
- expect(backend.watchedPathCount()).toBe(1);
4229
- listener?.("rename", "companies/indigo/knowledge/note.md");
4230
- listener?.("rename", "node_modules");
4231
- listener?.("change", "workspace/node_modules/pkg/index.js");
4232
- expect(callback).toHaveBeenCalledTimes(1);
4233
- expect(callback).toHaveBeenCalledWith(path.join(dir, "companies/indigo/knowledge/note.md"), "rename");
4234
- }
4235
- finally {
4236
- backend.close();
4237
- fs.rmSync(dir, { recursive: true, force: true });
4238
- }
4239
- });
4240
- it("switches a native-root watcher to polling after a systemic watch error", async () => {
4241
- const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "native-root-fallback-")));
4242
- const closed = [];
4243
- const backend = new TreeWatcher({
4244
- hqRoot: dir,
4245
- debounceMs: 50,
4246
- backendFactory: ({ forcePolling, onError }) => {
4247
- if (forcePolling) {
4248
- return {
4249
- close: () => closed.push("polling"),
4250
- mode: "polling",
4251
- needsKnownKinds: false,
4252
- watchedPathCount: () => 1,
4253
- };
4254
- }
4255
- const handle = new EventEmitter();
4256
- handle.close = vi.fn(() => closed.push("native-root"));
4257
- const native = startNativeRootWatch(dir, () => true, vi.fn(), onError, {
4258
- watchDirectory: (() => {
4259
- queueMicrotask(() => handle.emit("error", Object.assign(new Error("too many open files"), { code: "EMFILE" })));
4260
- return handle;
4261
- }),
4262
- });
4263
- return native;
4264
- },
4265
- });
4266
- const log = vi.spyOn(console, "error").mockImplementation(() => { });
4267
- backend.start();
4268
- try {
4269
- await new Promise((resolve) => setTimeout(resolve, 0));
4270
- expect(backend.watchMode()).toBe("polling");
4271
- expect(backend.watchedPathCount()).toBe(1);
4272
- expect(closed).toContain("native-root");
4273
- }
4274
- finally {
4275
- backend.dispose();
4276
- log.mockRestore();
4277
- fs.rmSync(dir, { recursive: true, force: true });
4278
- }
4279
- });
4280
- it("TreeWatcher on linux starts natively with one watch per directory (no chokidar, no file watches)", async () => {
4281
- if (!isLinux)
4282
- return;
4283
- const { inScopeDirs } = seedTree();
4284
- const before = inotifyWatchDescriptors();
4285
- const w = new TreeWatcher({ hqRoot: root, debounceMs: 50 });
4286
- const changed = vi.fn();
4287
- w.onChange(changed);
4288
- w.start();
4289
- try {
4290
- await new Promise((resolve) => w.onReady(resolve));
4291
- expect(w.watchMode()).toBe("native");
4292
- expect(inotifyWatchDescriptors() - before).toBe(inScopeDirs.length);
4293
- fs.writeFileSync(path.join(root, "notes", "new.md"), "hi");
4294
- await waitFor(() => changed.mock.calls.length > 0);
4295
- }
4296
- finally {
4297
- w.dispose();
4298
- }
4299
- });
4300
- });
4301
- //# sourceMappingURL=watcher.test.js.map