failproofai 1.0.0-beta.2 → 1.0.0-beta.4

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 (234) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  56. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  57. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  71. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  72. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  89. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  91. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  116. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  117. package/.next/standalone/.next/server/pages/404.html +1 -1
  118. package/.next/standalone/.next/server/pages/500.html +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  121. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
  124. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
  128. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  129. package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
  130. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  132. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  135. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  137. package/.next/standalone/Cargo.lock +1992 -198
  138. package/.next/standalone/Cargo.toml +1 -1
  139. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  140. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  141. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  142. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  143. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  144. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  145. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  146. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  147. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  148. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  149. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  150. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  151. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  152. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  153. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  154. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  155. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  156. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  157. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  158. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  159. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  160. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  161. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  162. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  163. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  164. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  165. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  166. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  167. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  168. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  169. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  170. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  171. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  172. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  173. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  174. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  175. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  176. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  177. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  178. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  179. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  180. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  181. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  182. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  183. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  184. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  185. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  186. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  187. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  188. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  189. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  190. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  191. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  192. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  193. package/.next/standalone/package.json +15 -9
  194. package/.next/standalone/server.js +1 -1
  195. package/bin/failproofai.mjs +129 -0
  196. package/dist/cli.mjs +2255 -779
  197. package/dist/worker.mjs +365 -52
  198. package/package.json +15 -9
  199. package/scripts/build-daemon-packages.mjs +210 -0
  200. package/scripts/daemon-platforms.mjs +42 -0
  201. package/scripts/prune-standalone.mjs +6 -0
  202. package/scripts/publish-aliases.mjs +62 -41
  203. package/src/hooks/builtin-policies.ts +139 -0
  204. package/src/hooks/cloud-connection.ts +205 -0
  205. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  206. package/src/hooks/cloud-enrollment.ts +173 -0
  207. package/src/hooks/cloud-managed-policies.ts +138 -0
  208. package/src/hooks/collector-config.ts +226 -0
  209. package/src/hooks/configure-wizard.ts +221 -5
  210. package/src/hooks/custom-hooks-loader.ts +69 -25
  211. package/src/hooks/daemon-client.ts +18 -0
  212. package/src/hooks/daemon-download.ts +137 -27
  213. package/src/hooks/daemon-service.ts +23 -8
  214. package/src/hooks/handler.ts +144 -6
  215. package/src/hooks/hook-activity-store.ts +50 -0
  216. package/src/hooks/loader-utils.ts +6 -4
  217. package/src/hooks/policy-types.ts +35 -0
  218. package/src/hooks/session-pause-cli.ts +157 -0
  219. package/src/hooks/session-pause.ts +236 -0
  220. package/src/hooks/tui.ts +115 -0
  221. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  225. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  226. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  227. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  228. package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
  229. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  230. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  231. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  232. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
  233. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
  234. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
@@ -0,0 +1,472 @@
1
+ //! Getting spooled batches to the uploader: a watcher for latency and a
2
+ //! sweeper for guarantees.
3
+ //!
4
+ //! # Why both
5
+ //!
6
+ //! The **watcher** exists for latency. It registers inotify/FSEvents on each
7
+ //! spool directory so a batch is delivered within milliseconds of the rename
8
+ //! that publishes it.
9
+ //!
10
+ //! The **sweeper** exists for correctness, and it is the one that actually
11
+ //! guarantees delivery. Filesystem events are lost in every way that matters:
12
+ //! the daemon was not running when the file appeared, the watch failed to
13
+ //! register, the event queue overflowed, the file was written on a filesystem
14
+ //! that does not report events at all. A periodic scan has none of those
15
+ //! failure modes. If the watcher were deleted tomorrow nothing would be lost —
16
+ //! it would just arrive up to a sweep interval later.
17
+ //!
18
+ //! That asymmetry is why a watcher registration failure is logged and shrugged
19
+ //! off rather than being fatal.
20
+ //!
21
+ //! # Shared state, and why it is shared
22
+ //!
23
+ //! Both paths hand work to the same [`Delivery`], which owns one semaphore and
24
+ //! one in-flight set. They must be shared rather than per-task: with separate
25
+ //! sets, a file the watcher is uploading right now is invisible to a
26
+ //! concurrent sweep, and both would POST it. With separate semaphores the
27
+ //! process could issue twice the intended concurrency at exactly the moment a
28
+ //! backlog is being worked through.
29
+
30
+ use std::collections::HashSet;
31
+ use std::path::{Path, PathBuf};
32
+ use std::sync::{Arc, Mutex};
33
+ use std::time::{Duration, SystemTime};
34
+
35
+ use tokio::sync::Semaphore;
36
+
37
+ use crate::spool::is_batch_file;
38
+ use crate::supervisor::{Shutdown, TaskError};
39
+ use crate::uploader::{ParkedName, Uploader};
40
+
41
+ /// Concurrent uploads across the watcher and sweeper combined.
42
+ ///
43
+ /// Deliberately far below the standalone collector's 64: this runs inside the
44
+ /// process that answers the enforcement socket, and 64 simultaneous TLS
45
+ /// handshakes is a lot of CPU to put behind a hook call that must return in
46
+ /// milliseconds. A backlog drains slightly slower; tool calls stay fast.
47
+ const MAX_CONCURRENT_UPLOADS: usize = 8;
48
+
49
+ /// How often the sweeper scans the spool directories.
50
+ const SWEEP_INTERVAL: Duration = Duration::from_secs(60);
51
+ /// How old a batch must be before the sweeper claims it.
52
+ ///
53
+ /// Not politeness — it is what keeps the two paths from racing on a file the
54
+ /// watcher has already been handed but has not yet locked in its in-flight
55
+ /// set.
56
+ const SWEEP_MIN_AGE: Duration = Duration::from_secs(120);
57
+ /// Batches per sweep. Bounds the work a single pass can queue so a large
58
+ /// backlog is drained steadily instead of all at once.
59
+ const SWEEP_MAX_FILES: usize = 64;
60
+
61
+ /// How often parked batches are retried. Deliberately far slower than the
62
+ /// spool sweep: a backlog in `failed/` must never starve fresh events of
63
+ /// upload permits.
64
+ const FAILED_RETRY_INTERVAL: Duration = Duration::from_secs(3600);
65
+ /// How old a parked batch must be before an automatic retry.
66
+ const FAILED_RETRY_MIN_AGE: Duration = Duration::from_secs(300);
67
+ /// Parked batches per retry pass.
68
+ const FAILED_RETRY_MAX_FILES: usize = 16;
69
+
70
+ /// Bound on the filesystem-event channel. Overflow is survivable precisely
71
+ /// because the sweeper is the guarantee — a dropped event costs latency, not
72
+ /// data.
73
+ const EVENT_CHANNEL_CAP: usize = 256;
74
+
75
+ /// Shared upload gate: one semaphore and one in-flight set for every path that
76
+ /// delivers a batch.
77
+ pub struct Delivery {
78
+ uploader: Arc<Uploader>,
79
+ permits: Arc<Semaphore>,
80
+ in_flight: Arc<Mutex<HashSet<PathBuf>>>,
81
+ }
82
+
83
+ /// Removes a path from the in-flight set on scope exit, including during an
84
+ /// unwind. A leaked entry would make that batch permanently invisible to both
85
+ /// the watcher and the sweeper — undelivered, on disk, and never retried.
86
+ struct InFlightGuard {
87
+ set: Arc<Mutex<HashSet<PathBuf>>>,
88
+ path: PathBuf,
89
+ }
90
+
91
+ impl Drop for InFlightGuard {
92
+ fn drop(&mut self) {
93
+ if let Ok(mut set) = self.set.lock() {
94
+ set.remove(&self.path);
95
+ }
96
+ }
97
+ }
98
+
99
+ impl Delivery {
100
+ pub fn new(uploader: Arc<Uploader>) -> Self {
101
+ Delivery {
102
+ uploader,
103
+ permits: Arc::new(Semaphore::new(MAX_CONCURRENT_UPLOADS)),
104
+ in_flight: Arc::new(Mutex::new(HashSet::new())),
105
+ }
106
+ }
107
+
108
+ /// Claim `path` for delivery, or `None` if another task already has it.
109
+ ///
110
+ /// Claiming and releasing are a guard rather than a pair of calls so an
111
+ /// early return or a panic cannot leave the claim behind.
112
+ fn claim(&self, path: &Path) -> Option<InFlightGuard> {
113
+ let mut set = self.in_flight.lock().ok()?;
114
+ if !set.insert(path.to_path_buf()) {
115
+ return None;
116
+ }
117
+ Some(InFlightGuard {
118
+ set: self.in_flight.clone(),
119
+ path: path.to_path_buf(),
120
+ })
121
+ }
122
+
123
+ /// Upload one batch, if nothing else is already doing so.
124
+ ///
125
+ /// Errors are logged, never propagated: the uploader has already parked
126
+ /// anything undeliverable, so there is nothing for a caller to do about it
127
+ /// and returning an error would restart the whole task over one bad batch.
128
+ pub async fn deliver(&self, path: PathBuf) {
129
+ let Some(_guard) = self.claim(&path) else {
130
+ return;
131
+ };
132
+ let Ok(_permit) = self.permits.clone().acquire_owned().await else {
133
+ return; // semaphore closed — shutting down
134
+ };
135
+
136
+ match self.uploader.upload_file(&path).await {
137
+ Ok(()) => tracing::debug!(file = %path.display(), "uploaded"),
138
+ Err(err) => tracing::warn!(file = %path.display(), %err, "upload failed"),
139
+ }
140
+ }
141
+ }
142
+
143
+ /// Watch every spool directory and deliver batches as they are published.
144
+ ///
145
+ /// Registration failures are logged and skipped rather than returned: losing
146
+ /// the watch costs latency, and returning an error would restart the task in a
147
+ /// loop on a filesystem that will never support events.
148
+ pub async fn watch(
149
+ delivery: Arc<Delivery>,
150
+ dirs: Vec<PathBuf>,
151
+ sd: Shutdown,
152
+ ) -> Result<(), TaskError> {
153
+ use notify::{
154
+ EventKind, RecursiveMode, Watcher, event::CreateKind, event::ModifyKind, event::RenameMode,
155
+ };
156
+
157
+ let (tx, mut rx) = tokio::sync::mpsc::channel::<PathBuf>(EVENT_CHANNEL_CAP);
158
+
159
+ let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
160
+ let Ok(event) = res else { return };
161
+ // The atomic publish is a rename INTO the directory, which Linux
162
+ // reports as IN_MOVED_TO and macOS as a create. Watching only
163
+ // `Create(File)` would therefore miss every batch on Linux — the exact
164
+ // shape the spool writer produces.
165
+ let interesting = matches!(
166
+ event.kind,
167
+ EventKind::Create(CreateKind::File)
168
+ | EventKind::Create(CreateKind::Any)
169
+ | EventKind::Modify(ModifyKind::Name(RenameMode::To))
170
+ );
171
+ if !interesting {
172
+ return;
173
+ }
174
+ for path in event.paths {
175
+ if is_batch_file(&path) {
176
+ // Sync callback; a full channel means we are behind, and the
177
+ // sweeper will collect what we drop.
178
+ let _ = tx.try_send(path);
179
+ }
180
+ }
181
+ })
182
+ .map_err(|e| TaskError::from(format!("could not create a filesystem watcher: {e}")))?;
183
+
184
+ let mut watched = 0usize;
185
+ for dir in &dirs {
186
+ if let Err(err) = tokio::fs::create_dir_all(dir).await {
187
+ tracing::warn!(dir = %dir.display(), %err, "could not create a spool directory");
188
+ continue;
189
+ }
190
+ match watcher.watch(dir, RecursiveMode::NonRecursive) {
191
+ Ok(()) => watched += 1,
192
+ Err(err) => tracing::warn!(
193
+ dir = %dir.display(),
194
+ %err,
195
+ "could not watch this spool directory; the sweeper still delivers from it, just later"
196
+ ),
197
+ }
198
+ }
199
+ tracing::info!(watched, of = dirs.len(), "spool watcher started");
200
+
201
+ loop {
202
+ tokio::select! {
203
+ biased;
204
+ _ = poll_shutdown(&sd) => {
205
+ drop(watcher);
206
+ return Ok(());
207
+ }
208
+ Some(path) = rx.recv() => {
209
+ delivery.deliver(path).await;
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ /// Periodically scan every spool directory, and retry parked batches on a much
216
+ /// slower cadence.
217
+ pub async fn sweep(
218
+ delivery: Arc<Delivery>,
219
+ dirs: Vec<PathBuf>,
220
+ failed_dir: PathBuf,
221
+ sd: Shutdown,
222
+ ) -> Result<(), TaskError> {
223
+ // Run one pass immediately. A cold start must not wait a full interval to
224
+ // deliver what accumulated while the daemon was stopped, and a filesystem
225
+ // event for those files is never coming — they were published while
226
+ // nothing was listening.
227
+ let mut next_failed_pass = SystemTime::now();
228
+
229
+ loop {
230
+ for dir in &dirs {
231
+ for path in stale_batches(dir, SWEEP_MIN_AGE, SWEEP_MAX_FILES).await {
232
+ if sd.is_set() {
233
+ return Ok(());
234
+ }
235
+ delivery.deliver(path).await;
236
+ }
237
+ }
238
+
239
+ if SystemTime::now() >= next_failed_pass {
240
+ retry_parked(&delivery, &failed_dir, &sd).await;
241
+ next_failed_pass = SystemTime::now() + FAILED_RETRY_INTERVAL;
242
+ }
243
+
244
+ if !sd.sleep(SWEEP_INTERVAL).await {
245
+ return Ok(());
246
+ }
247
+ }
248
+ }
249
+
250
+ /// Batches in `dir` older than `min_age`, newest first, capped at `max`.
251
+ ///
252
+ /// Newest-first is deliberate: when a backlog cannot be cleared in one pass,
253
+ /// the events a user is most likely to be looking at right now should arrive
254
+ /// first. (`failed/` is drained oldest-first instead — see [`retry_parked`].)
255
+ async fn stale_batches(dir: &Path, min_age: Duration, max: usize) -> Vec<PathBuf> {
256
+ let Ok(mut rd) = tokio::fs::read_dir(dir).await else {
257
+ return Vec::new();
258
+ };
259
+ let now = SystemTime::now();
260
+ let mut found: Vec<(SystemTime, PathBuf)> = Vec::new();
261
+
262
+ while let Ok(Some(entry)) = rd.next_entry().await {
263
+ let path = entry.path();
264
+ if !is_batch_file(&path) {
265
+ continue;
266
+ }
267
+ let Ok(meta) = entry.metadata().await else {
268
+ continue;
269
+ };
270
+ let Ok(modified) = meta.modified() else {
271
+ continue;
272
+ };
273
+ // A file younger than min_age may still be the watcher's to deliver.
274
+ if now.duration_since(modified).unwrap_or_default() < min_age {
275
+ continue;
276
+ }
277
+ found.push((modified, path));
278
+ }
279
+
280
+ // Newest first: `sort_by_key` cannot express a reverse key without
281
+ // cloning, and Reverse on a SystemTime tuple reads worse than this.
282
+ found.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
283
+ found.into_iter().take(max).map(|(_, p)| p).collect()
284
+ }
285
+
286
+ /// Retry batches parked in `failed/`, oldest first.
287
+ ///
288
+ /// Oldest-first is the opposite of the spool sweep, and deliberately so: a
289
+ /// parked batch is data the server does not have and this is the last copy, so
290
+ /// the one that has been waiting longest is the one most at risk.
291
+ ///
292
+ /// Skips anything poison or carrying a definitive client status — those fail
293
+ /// identically until a human fixes the key or the URL, and retrying them burns
294
+ /// permits that batches which could succeed are waiting for.
295
+ async fn retry_parked(delivery: &Delivery, failed_dir: &Path, sd: &Shutdown) {
296
+ let Ok(mut rd) = tokio::fs::read_dir(failed_dir).await else {
297
+ return;
298
+ };
299
+ let now = SystemTime::now();
300
+ let mut found: Vec<(SystemTime, PathBuf)> = Vec::new();
301
+
302
+ while let Ok(Some(entry)) = rd.next_entry().await {
303
+ let path = entry.path();
304
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
305
+ continue;
306
+ };
307
+ if !ParkedName::parse(name).is_auto_retryable() {
308
+ continue;
309
+ }
310
+ let Ok(meta) = entry.metadata().await else {
311
+ continue;
312
+ };
313
+ let Ok(modified) = meta.modified() else {
314
+ continue;
315
+ };
316
+ if now.duration_since(modified).unwrap_or_default() < FAILED_RETRY_MIN_AGE {
317
+ continue;
318
+ }
319
+ found.push((modified, path));
320
+ }
321
+
322
+ if found.is_empty() {
323
+ return;
324
+ }
325
+ found.sort_by_key(|(t, _)| *t);
326
+ tracing::info!(
327
+ count = found.len().min(FAILED_RETRY_MAX_FILES),
328
+ "retrying parked batches"
329
+ );
330
+
331
+ for (_, path) in found.into_iter().take(FAILED_RETRY_MAX_FILES) {
332
+ if sd.is_set() {
333
+ return;
334
+ }
335
+ delivery.deliver(path).await;
336
+ }
337
+ }
338
+
339
+ /// Resolves once shutdown is requested. A `select!` arm needs a future, and
340
+ /// `Shutdown` exposes a flag rather than a notification.
341
+ async fn poll_shutdown(sd: &Shutdown) {
342
+ while !sd.is_set() {
343
+ tokio::time::sleep(Duration::from_millis(100)).await;
344
+ }
345
+ }
346
+
347
+ #[cfg(test)]
348
+ mod tests {
349
+ use super::*;
350
+ use std::time::UNIX_EPOCH;
351
+
352
+ fn tmpdir(name: &str) -> PathBuf {
353
+ let d = std::env::temp_dir().join(format!(
354
+ "fpai-deliv-{}-{}-{}",
355
+ name,
356
+ std::process::id(),
357
+ SystemTime::now()
358
+ .duration_since(UNIX_EPOCH)
359
+ .unwrap()
360
+ .as_nanos()
361
+ ));
362
+ std::fs::create_dir_all(&d).unwrap();
363
+ d
364
+ }
365
+
366
+ #[tokio::test]
367
+ async fn the_sweeper_ignores_batches_younger_than_the_min_age() {
368
+ // A fresh file is very likely already on its way via the watcher.
369
+ // Claiming it here is how the same batch gets POSTed twice.
370
+ let dir = tmpdir("young");
371
+ std::fs::write(dir.join("hooks-a-1-0.jsonl"), "{}\n").unwrap();
372
+
373
+ let found = stale_batches(&dir, Duration::from_secs(120), 64).await;
374
+ assert!(
375
+ found.is_empty(),
376
+ "a just-written batch must be left to the watcher"
377
+ );
378
+
379
+ // With no age requirement the same file is picked up.
380
+ let found = stale_batches(&dir, Duration::ZERO, 64).await;
381
+ assert_eq!(found.len(), 1);
382
+ std::fs::remove_dir_all(&dir).ok();
383
+ }
384
+
385
+ #[tokio::test]
386
+ async fn the_sweeper_only_claims_batch_files() {
387
+ let dir = tmpdir("filter");
388
+ std::fs::write(dir.join("hooks-a-1-0.jsonl"), "{}\n").unwrap();
389
+ // A partially-written batch. Delivering this would ship a truncated
390
+ // body, which is the whole reason the spool writes tmp-then-renames.
391
+ std::fs::write(dir.join("hooks-a-1-1.tmp"), "{}\n").unwrap();
392
+ std::fs::write(dir.join("notes.txt"), "x").unwrap();
393
+ std::fs::write(dir.join("hooks-a-1-2.jsonl.poison"), "{}\n").unwrap();
394
+
395
+ let found = stale_batches(&dir, Duration::ZERO, 64).await;
396
+ let names: Vec<String> = found
397
+ .iter()
398
+ .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
399
+ .collect();
400
+ assert_eq!(names, vec!["hooks-a-1-0.jsonl"], "got {names:?}");
401
+ std::fs::remove_dir_all(&dir).ok();
402
+ }
403
+
404
+ #[tokio::test]
405
+ async fn the_sweeper_caps_how_much_one_pass_queues() {
406
+ let dir = tmpdir("cap");
407
+ for i in 0..20 {
408
+ std::fs::write(dir.join(format!("hooks-a-1-{i}.jsonl")), "{}\n").unwrap();
409
+ }
410
+ let found = stale_batches(&dir, Duration::ZERO, 5).await;
411
+ assert_eq!(found.len(), 5);
412
+ std::fs::remove_dir_all(&dir).ok();
413
+ }
414
+
415
+ #[tokio::test]
416
+ async fn a_missing_spool_directory_is_not_an_error() {
417
+ // Normal on a fresh install, and on a machine where the SDK has never
418
+ // run so ~/.agenteye/events does not exist.
419
+ let found = stale_batches(Path::new("/nonexistent/fpai/spool"), Duration::ZERO, 8).await;
420
+ assert!(found.is_empty());
421
+ }
422
+
423
+ #[test]
424
+ fn an_in_flight_claim_is_exclusive_and_released_on_drop() {
425
+ let up = Arc::new(
426
+ Uploader::new(
427
+ "http://127.0.0.1:1/events".into(),
428
+ "k".into(),
429
+ tmpdir("claim"),
430
+ )
431
+ .unwrap(),
432
+ );
433
+ let d = Delivery::new(up);
434
+ let p = Path::new("/s/hooks-a-1-0.jsonl");
435
+
436
+ let first = d.claim(p).expect("first claim should succeed");
437
+ assert!(d.claim(p).is_none(), "a second claim must be refused");
438
+ drop(first);
439
+ assert!(
440
+ d.claim(p).is_some(),
441
+ "the claim must be released on drop, or the batch is never retried"
442
+ );
443
+ }
444
+
445
+ #[test]
446
+ fn a_claim_is_released_even_when_the_holder_panics() {
447
+ // A leaked claim makes that batch permanently invisible to both the
448
+ // watcher and the sweeper: undelivered, on disk, never retried.
449
+ let up = Arc::new(
450
+ Uploader::new(
451
+ "http://127.0.0.1:1/events".into(),
452
+ "k".into(),
453
+ tmpdir("panic"),
454
+ )
455
+ .unwrap(),
456
+ );
457
+ let d = Arc::new(Delivery::new(up));
458
+ let p = PathBuf::from("/s/hooks-a-1-0.jsonl");
459
+
460
+ let d2 = d.clone();
461
+ let p2 = p.clone();
462
+ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
463
+ let _guard = d2.claim(&p2).unwrap();
464
+ panic!("boom");
465
+ }));
466
+
467
+ assert!(
468
+ d.claim(&p).is_some(),
469
+ "the guard must release during an unwind"
470
+ );
471
+ }
472
+ }