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,600 @@
1
+ //! Ships spooled batches to the ingest endpoint.
2
+ //!
3
+ //! Four properties here are load-bearing. Each one exists because its absence
4
+ //! loses data rather than merely slowing things down.
5
+ //!
6
+ //! **The timeout is per-read, not per-request.** A whole-request timeout also
7
+ //! bounds streaming the body, and the body is re-sent in full on every retry —
8
+ //! so a large batch on an ordinary uplink can never finish, and burns its
9
+ //! entire retry budget failing the same way each time. A per-read timeout
10
+ //! bounds progress instead of total size. On its own it bounds nothing,
11
+ //! though: a server trickling one byte inside every window would hold the
12
+ //! request open forever, so a deliberately generous total cap stays as a
13
+ //! backstop. It is a large multiple of the read timeout so it only ever
14
+ //! catches a genuinely stuck request, never a merely slow one.
15
+ //!
16
+ //! **A 2xx is not automatically a success.** Ingest answers
17
+ //! `{"accepted":N,"skipped":M}` and silently skips any line it cannot parse —
18
+ //! a missing `session_id`, an unparseable timestamp, a comma in
19
+ //! `environment`. Without reading that body, a batch the server discarded
20
+ //! entirely is indistinguishable from a perfect upload.
21
+ //!
22
+ //! **`failed/` is a retry queue, not a graveyard.** A batch there is data the
23
+ //! server does not have, and it is the last copy. Batches are retried in place
24
+ //! with the attempt count encoded in the filename, and only ever parked as
25
+ //! `.poison` — never deleted.
26
+ //!
27
+ //! **Oversized batches are split in memory.** Writing the chunks to disk
28
+ //! beside the original would create files the watcher had never seen, so it
29
+ //! would pick them up and post them concurrently with this function — the same
30
+ //! payload delivered twice.
31
+
32
+ use std::path::{Path, PathBuf};
33
+ use std::sync::Arc;
34
+ use std::sync::atomic::{AtomicU64, Ordering};
35
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
36
+
37
+ use serde::Deserialize;
38
+
39
+ /// Suffix marking a batch that exhausted its retry budget. Deliberately NOT
40
+ /// `.jsonl`, so every directory scan and the watcher skip it for free rather
41
+ /// than each needing its own filter.
42
+ pub const POISON_SUFFIX: &str = ".poison";
43
+
44
+ /// Largest body sent in one request. Bigger spool files are split.
45
+ pub const DEFAULT_MAX_UPLOAD_BYTES: u64 = 8 * 1024 * 1024;
46
+ /// Total attempts per batch, including the first.
47
+ pub const DEFAULT_MAX_RETRIES: u32 = 5;
48
+ /// Base for the exponential backoff.
49
+ pub const DEFAULT_RETRY_BASE: Duration = Duration::from_millis(1000);
50
+ /// Per-read timeout. Bounds progress, not total transfer size.
51
+ pub const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(120);
52
+ /// Attempts a parked batch gets before it is marked poison.
53
+ pub const DEFAULT_FAILED_RETRIES_MAX: u32 = 3;
54
+ /// Ceiling applied to a server-supplied `Retry-After`, so a misconfigured
55
+ /// header cannot park the uploader for hours.
56
+ const MAX_RETRY_AFTER: Duration = Duration::from_secs(300);
57
+
58
+ #[derive(Debug)]
59
+ pub enum UploadError {
60
+ /// A definitive client error. Not retried — the cause is a rotated key or
61
+ /// a wrong URL, and retrying burns the budget of every other batch.
62
+ Client {
63
+ status: u16,
64
+ },
65
+ Server {
66
+ status: u16,
67
+ attempts: u32,
68
+ },
69
+ Network {
70
+ attempts: u32,
71
+ detail: String,
72
+ },
73
+ Io(std::io::Error),
74
+ }
75
+
76
+ impl std::fmt::Display for UploadError {
77
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78
+ match self {
79
+ UploadError::Client { status } => write!(f, "client error {status}: not retried"),
80
+ UploadError::Server { status, attempts } => {
81
+ write!(f, "server error {status} after {attempts} attempt(s)")
82
+ }
83
+ UploadError::Network { attempts, detail } => {
84
+ write!(f, "network error after {attempts} attempt(s): {detail}")
85
+ }
86
+ UploadError::Io(e) => write!(f, "io error: {e}"),
87
+ }
88
+ }
89
+ }
90
+
91
+ impl std::error::Error for UploadError {}
92
+
93
+ impl From<std::io::Error> for UploadError {
94
+ fn from(e: std::io::Error) -> Self {
95
+ UploadError::Io(e)
96
+ }
97
+ }
98
+
99
+ /// What ingest reports it did with a batch. `skipped` counts lines it refused
100
+ /// to store; those are gone, and nothing retries them.
101
+ #[derive(Debug, Default, Deserialize)]
102
+ struct IngestAck {
103
+ // REQUIRED — no serde default. This is what proves the 2xx came from the
104
+ // ingest endpoint and not a login page, a proxy or a static host that
105
+ // happens to answer 200. An adversarial red-team pointed a live daemon at a
106
+ // dashboard-lookalike (POST /events -> 307 -> /login -> 200 text/html); the
107
+ // old `resp.json().unwrap_or_default()` turned that HTML into a default ack,
108
+ // reported the batch delivered, and DELETED the spool file — silent, total
109
+ // data loss. A body without a numeric `accepted` now fails to parse and the
110
+ // batch is parked instead.
111
+ accepted: u64,
112
+ #[serde(default)]
113
+ skipped: u64,
114
+ }
115
+
116
+ /// Delivery counters. Live on the long-lived `Uploader` so a supervised task
117
+ /// restart never resets them to zero — a counter that silently rewinds is
118
+ /// worse than no counter, because it reads as "nothing has gone wrong".
119
+ #[derive(Debug, Default)]
120
+ pub struct UploadMetrics {
121
+ pub accepted_total: AtomicU64,
122
+ pub skipped_total: AtomicU64,
123
+ /// Batches the server answered 200 for while storing NONE of their events.
124
+ pub batches_fully_skipped: AtomicU64,
125
+ /// Unix seconds of the last upload the server accepted.
126
+ pub last_ok_ts: AtomicU64,
127
+ }
128
+
129
+ pub struct Uploader {
130
+ client: reqwest::Client,
131
+ url: String,
132
+ key: String,
133
+ failed_dir: PathBuf,
134
+ max_upload_bytes: u64,
135
+ max_retries: u32,
136
+ retry_base: Duration,
137
+ failed_retries_max: u32,
138
+ metrics: Arc<UploadMetrics>,
139
+ }
140
+
141
+ impl Uploader {
142
+ pub fn new(url: String, key: String, failed_dir: PathBuf) -> Result<Self, String> {
143
+ let client = reqwest::Client::builder()
144
+ .connect_timeout(Duration::from_secs(10))
145
+ .read_timeout(DEFAULT_READ_TIMEOUT)
146
+ // Backstop only — see the module docs. Large multiple of the read
147
+ // timeout so it catches a stuck request, never a slow one.
148
+ .timeout(DEFAULT_READ_TIMEOUT.saturating_mul(10))
149
+ // NEVER follow a redirect. reqwest follows by default, and a
150
+ // misconfigured or hostile ingest URL that 307s to another host
151
+ // then re-POSTs the batch there — verified live, an attacker server
152
+ // received full event payloads (prompts, command text) while the
153
+ // spool file was deleted as "delivered". With no following, a 3xx
154
+ // surfaces as a status that post_batch parks as a client error.
155
+ // reqwest already strips the bearer token across hosts; this stops
156
+ // the body from leaving too.
157
+ .redirect(reqwest::redirect::Policy::none())
158
+ .build()
159
+ .map_err(|e| format!("could not build the HTTP client: {e}"))?;
160
+
161
+ Ok(Uploader {
162
+ client,
163
+ url,
164
+ key,
165
+ failed_dir,
166
+ max_upload_bytes: DEFAULT_MAX_UPLOAD_BYTES,
167
+ max_retries: DEFAULT_MAX_RETRIES,
168
+ retry_base: DEFAULT_RETRY_BASE,
169
+ failed_retries_max: DEFAULT_FAILED_RETRIES_MAX,
170
+ metrics: Arc::new(UploadMetrics::default()),
171
+ })
172
+ }
173
+
174
+ /// Shorten every delay. Tests only — without it each retry test would wait
175
+ /// out a real multi-second backoff.
176
+ #[doc(hidden)]
177
+ pub fn with_retry_base(mut self, base: Duration) -> Self {
178
+ self.retry_base = base;
179
+ self
180
+ }
181
+
182
+ #[doc(hidden)]
183
+ pub fn with_max_upload_bytes(mut self, bytes: u64) -> Self {
184
+ self.max_upload_bytes = bytes;
185
+ self
186
+ }
187
+
188
+ pub fn metrics(&self) -> Arc<UploadMetrics> {
189
+ self.metrics.clone()
190
+ }
191
+
192
+ /// Upload one batch file, deleting it once every chunk has landed.
193
+ ///
194
+ /// A `NotFound` on read is `Ok`: another task already handled this file.
195
+ /// The delete happens only after all chunks succeed, so a crash part-way
196
+ /// leaves the original intact and the worst case is re-sending chunks the
197
+ /// server already has — byte-identical, so it dedups them.
198
+ pub async fn upload_file(&self, path: &Path) -> Result<(), UploadError> {
199
+ let bytes = match tokio::fs::read(path).await {
200
+ Ok(b) => b,
201
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
202
+ Err(e) => return Err(UploadError::Io(e)),
203
+ };
204
+
205
+ for chunk in split_lines(&bytes, self.max_upload_bytes) {
206
+ self.post_batch(path, chunk).await?;
207
+ }
208
+
209
+ match tokio::fs::remove_file(path).await {
210
+ Ok(()) => Ok(()),
211
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
212
+ Err(e) => Err(UploadError::Io(e)),
213
+ }
214
+ }
215
+
216
+ /// POST one body, retrying per policy. `path` is used only for parking and
217
+ /// log context — this never deletes it.
218
+ async fn post_batch(&self, path: &Path, bytes: Vec<u8>) -> Result<(), UploadError> {
219
+ let mut attempt: u32 = 0;
220
+
221
+ loop {
222
+ let result = self
223
+ .client
224
+ .post(&self.url)
225
+ .header("Authorization", format!("Bearer {}", self.key))
226
+ .header("Content-Type", "application/x-ndjson")
227
+ // A HEADER, not a payload field, so it stays OUT of the
228
+ // server's content-hash dedup key — otherwise every upgrade
229
+ // would make previously-shipped events look new.
230
+ .header("X-Failproofai-Collector-Version", env!("CARGO_PKG_VERSION"))
231
+ .body(bytes.clone())
232
+ .send()
233
+ .await;
234
+
235
+ attempt += 1;
236
+
237
+ match result {
238
+ Ok(resp) => {
239
+ let status = resp.status();
240
+ if status.is_success() {
241
+ // A 2xx is necessary but NOT sufficient. Require the body
242
+ // to actually be an ingest ack (a numeric `accepted`);
243
+ // otherwise this "success" is a login page or a proxy,
244
+ // and returning Ok here deletes the spool file behind it.
245
+ // Park instead, non-retryable — it will parse-fail
246
+ // identically until the URL is fixed.
247
+ match resp.json::<IngestAck>().await {
248
+ Ok(ack) => {
249
+ self.record_ack(path, &ack);
250
+ return Ok(());
251
+ }
252
+ Err(_) => {
253
+ let code = status.as_u16();
254
+ self.park(path, Some(code), attempt).await;
255
+ return Err(UploadError::Client { status: code });
256
+ }
257
+ }
258
+ }
259
+
260
+ let code = status.as_u16();
261
+ // 408 and 429 are the two 4xx that mean "try again" rather
262
+ // than "you are wrong". Treating them as definitive would
263
+ // park a batch the server explicitly asked us to resend.
264
+ let retryable = status.is_server_error() || code == 408 || code == 429;
265
+ if !retryable {
266
+ self.park(path, Some(code), attempt).await;
267
+ return Err(UploadError::Client { status: code });
268
+ }
269
+ if attempt >= self.max_retries {
270
+ self.park(path, None, attempt).await;
271
+ return Err(UploadError::Server {
272
+ status: code,
273
+ attempts: attempt,
274
+ });
275
+ }
276
+ let wait = retry_after(&resp).unwrap_or_else(|| self.backoff(attempt));
277
+ tokio::time::sleep(wait).await;
278
+ }
279
+ Err(err) => {
280
+ if attempt >= self.max_retries {
281
+ self.park(path, None, attempt).await;
282
+ return Err(UploadError::Network {
283
+ attempts: attempt,
284
+ detail: err.to_string(),
285
+ });
286
+ }
287
+ tokio::time::sleep(self.backoff(attempt)).await;
288
+ }
289
+ }
290
+ }
291
+ }
292
+
293
+ /// Interpret the ack body. A 200 that stored nothing is an error, not a
294
+ /// success — it is the shape a systematically malformed transform takes,
295
+ /// and without this it looks identical to a healthy upload.
296
+ fn record_ack(&self, path: &Path, ack: &IngestAck) {
297
+ self.metrics
298
+ .accepted_total
299
+ .fetch_add(ack.accepted, Ordering::Relaxed);
300
+ self.metrics
301
+ .skipped_total
302
+ .fetch_add(ack.skipped, Ordering::Relaxed);
303
+ self.metrics
304
+ .last_ok_ts
305
+ .store(unix_now_secs(), Ordering::Relaxed);
306
+
307
+ if ack.accepted == 0 && ack.skipped > 0 {
308
+ self.metrics
309
+ .batches_fully_skipped
310
+ .fetch_add(1, Ordering::Relaxed);
311
+ tracing::error!(
312
+ file = %path.display(),
313
+ skipped = ack.skipped,
314
+ "the server accepted the request but stored NONE of its events; \
315
+ every line was rejected as malformed"
316
+ );
317
+ } else if ack.skipped > 0 {
318
+ tracing::warn!(
319
+ file = %path.display(),
320
+ accepted = ack.accepted,
321
+ skipped = ack.skipped,
322
+ "the server skipped some events in this batch"
323
+ );
324
+ }
325
+ }
326
+
327
+ /// `base * 2^(attempt-1)` plus jitter.
328
+ ///
329
+ /// The jitter matters more than the curve: without it every collector that
330
+ /// went down during the same outage retries in lockstep afterwards and
331
+ /// re-creates the outage. Derived from the clock rather than a PRNG so this
332
+ /// crate needs no `rand` dependency — jitter needs to be *spread*, not
333
+ /// unpredictable.
334
+ fn backoff(&self, attempt: u32) -> Duration {
335
+ let base = self.retry_base.as_millis() as u64;
336
+ let exp = base.saturating_mul(1u64 << attempt.saturating_sub(1).min(16));
337
+ let jitter = if base == 0 {
338
+ 0
339
+ } else {
340
+ (nanos_now() % base as u128) as u64
341
+ };
342
+ Duration::from_millis(exp.saturating_add(jitter))
343
+ }
344
+
345
+ /// Move a batch into `failed/`, encoding its retry state in the filename.
346
+ ///
347
+ /// Never deletes and never overwrites: a collision gets a numeric suffix,
348
+ /// because the file being moved is the only copy of data the server does
349
+ /// not have.
350
+ async fn park(&self, path: &Path, client_status: Option<u16>, _attempt: u32) {
351
+ if let Err(err) = self.park_inner(path, client_status).await {
352
+ tracing::error!(
353
+ file = %path.display(),
354
+ %err,
355
+ "could not park a failed batch; it stays in the spool and will be retried"
356
+ );
357
+ }
358
+ }
359
+
360
+ async fn park_inner(
361
+ &self,
362
+ path: &Path,
363
+ client_status: Option<u16>,
364
+ ) -> Result<(), std::io::Error> {
365
+ tokio::fs::create_dir_all(&self.failed_dir).await?;
366
+ let name = path
367
+ .file_name()
368
+ .and_then(|n| n.to_str())
369
+ .unwrap_or("batch.jsonl");
370
+
371
+ let mut parked = ParkedName::parse(name);
372
+ parked.attempt += 1;
373
+ if client_status.is_some() {
374
+ parked.client_status = client_status;
375
+ }
376
+ parked.poison = parked.attempt >= self.failed_retries_max.max(1);
377
+
378
+ let dest = unique_destination(&self.failed_dir, &parked.render()).await;
379
+ tracing::warn!(
380
+ file = %path.display(),
381
+ dest = %dest.display(),
382
+ attempt = parked.attempt,
383
+ poison = parked.poison,
384
+ "parking an undelivered batch"
385
+ );
386
+ tokio::fs::rename(path, &dest).await
387
+ }
388
+ }
389
+
390
+ /// Retry state carried in a parked batch's filename:
391
+ /// `<base>.a<N>[.c<STATUS>].jsonl[.poison]`.
392
+ ///
393
+ /// The filename IS the database. A sidecar would need its own atomicity story
394
+ /// and could desynchronise from the batch it describes; a rename cannot.
395
+ #[derive(Debug, PartialEq, Eq)]
396
+ pub struct ParkedName {
397
+ pub base: String,
398
+ pub attempt: u32,
399
+ pub client_status: Option<u16>,
400
+ pub poison: bool,
401
+ }
402
+
403
+ impl ParkedName {
404
+ pub fn parse(name: &str) -> Self {
405
+ let (name, poison) = match name.strip_suffix(POISON_SUFFIX) {
406
+ Some(rest) => (rest, true),
407
+ None => (name, false),
408
+ };
409
+ let stem = name.strip_suffix(".jsonl").unwrap_or(name);
410
+
411
+ let mut base = stem.to_string();
412
+ let mut attempt = 0;
413
+ let mut client_status = None;
414
+
415
+ // Suffixes are stripped right to left: `.cNNN` then `.aN`. Parsing
416
+ // rather than merely matching the prefix is what keeps a real spool
417
+ // name safe — `claude-...-0` has no `.c`/`.a` to find, and anything
418
+ // that does but is not numeric is left as part of the base.
419
+ if let Some(idx) = base.rfind(".c")
420
+ && let Ok(code) = base[idx + 2..].parse::<u16>()
421
+ {
422
+ client_status = Some(code);
423
+ base.truncate(idx);
424
+ }
425
+ if let Some(idx) = base.rfind(".a")
426
+ && let Ok(n) = base[idx + 2..].parse::<u32>()
427
+ {
428
+ attempt = n;
429
+ base.truncate(idx);
430
+ }
431
+
432
+ ParkedName {
433
+ base,
434
+ attempt,
435
+ client_status,
436
+ poison,
437
+ }
438
+ }
439
+
440
+ pub fn render(&self) -> String {
441
+ let mut s = format!("{}.a{}", self.base, self.attempt);
442
+ if let Some(code) = self.client_status {
443
+ s.push_str(&format!(".c{code}"));
444
+ }
445
+ s.push_str(".jsonl");
446
+ if self.poison {
447
+ s.push_str(POISON_SUFFIX);
448
+ }
449
+ s
450
+ }
451
+
452
+ /// Whether an automatic retry pass should pick this up. A batch the server
453
+ /// definitively rejected will fail identically until the cause is fixed,
454
+ /// so retrying it just burns the budget of batches that could succeed.
455
+ pub fn is_auto_retryable(&self) -> bool {
456
+ !self.poison && self.client_status.is_none()
457
+ }
458
+ }
459
+
460
+ /// Split a body on line boundaries so no chunk exceeds `max`.
461
+ ///
462
+ /// A single line longer than `max` is emitted alone rather than dropped: the
463
+ /// spool writer already guarantees no such line exists, so reaching this means
464
+ /// the cap was lowered after the batch was written, and the server refusing it
465
+ /// is more useful than this function silently discarding it.
466
+ fn split_lines(bytes: &[u8], max: u64) -> Vec<Vec<u8>> {
467
+ if bytes.len() as u64 <= max {
468
+ return vec![bytes.to_vec()];
469
+ }
470
+ let mut out = Vec::new();
471
+ let mut cur: Vec<u8> = Vec::new();
472
+ for line in bytes.split_inclusive(|b| *b == b'\n') {
473
+ if !cur.is_empty() && (cur.len() + line.len()) as u64 > max {
474
+ out.push(std::mem::take(&mut cur));
475
+ }
476
+ cur.extend_from_slice(line);
477
+ }
478
+ if !cur.is_empty() {
479
+ out.push(cur);
480
+ }
481
+ out
482
+ }
483
+
484
+ /// A `Retry-After` in seconds, capped so a bad header cannot stall delivery.
485
+ fn retry_after(resp: &reqwest::Response) -> Option<Duration> {
486
+ let raw = resp.headers().get(reqwest::header::RETRY_AFTER)?;
487
+ let secs: u64 = raw.to_str().ok()?.trim().parse().ok()?;
488
+ Some(Duration::from_secs(secs).min(MAX_RETRY_AFTER))
489
+ }
490
+
491
+ /// `<dir>/<name>`, or `<name>.1`, `.2`, … if taken. A collision must never
492
+ /// destroy an undelivered batch.
493
+ async fn unique_destination(dir: &Path, name: &str) -> PathBuf {
494
+ let first = dir.join(name);
495
+ if tokio::fs::metadata(&first).await.is_err() {
496
+ return first;
497
+ }
498
+ for n in 1..1000 {
499
+ let candidate = dir.join(format!("{name}.{n}"));
500
+ if tokio::fs::metadata(&candidate).await.is_err() {
501
+ return candidate;
502
+ }
503
+ }
504
+ dir.join(format!("{name}.{}", nanos_now()))
505
+ }
506
+
507
+ fn unix_now_secs() -> u64 {
508
+ SystemTime::now()
509
+ .duration_since(UNIX_EPOCH)
510
+ .map(|d| d.as_secs())
511
+ .unwrap_or(0)
512
+ }
513
+
514
+ fn nanos_now() -> u128 {
515
+ SystemTime::now()
516
+ .duration_since(UNIX_EPOCH)
517
+ .map(|d| d.as_nanos())
518
+ .unwrap_or(0)
519
+ }
520
+
521
+ #[cfg(test)]
522
+ mod tests {
523
+ use super::*;
524
+
525
+ #[test]
526
+ fn parked_names_round_trip() {
527
+ let p = ParkedName {
528
+ base: "hooks-s1-123-0".into(),
529
+ attempt: 2,
530
+ client_status: Some(404),
531
+ poison: false,
532
+ };
533
+ assert_eq!(p.render(), "hooks-s1-123-0.a2.c404.jsonl");
534
+ assert_eq!(ParkedName::parse(&p.render()), p);
535
+ }
536
+
537
+ #[test]
538
+ fn a_poison_name_round_trips_and_is_not_jsonl() {
539
+ let p = ParkedName {
540
+ base: "claude-s-1-0".into(),
541
+ attempt: 3,
542
+ client_status: None,
543
+ poison: true,
544
+ };
545
+ let rendered = p.render();
546
+ assert_eq!(rendered, "claude-s-1-0.a3.jsonl.poison");
547
+ // The whole point of the suffix ordering: a poison file is not a
548
+ // `.jsonl`, so every scan skips it without a dedicated filter.
549
+ assert!(!crate::spool::is_batch_file(Path::new(&rendered)));
550
+ assert_eq!(ParkedName::parse(&rendered), p);
551
+ }
552
+
553
+ #[test]
554
+ fn a_fresh_batch_carries_no_retry_state() {
555
+ // Spool filenames are `<prefix>-<tag>-<run>-<seq>.jsonl` and the tag is
556
+ // sanitised to alphanumerics and `-`, so a real name can never parse as
557
+ // one that has already been retried.
558
+ let p = ParkedName::parse("claude-3ee9c788-1785741108180149712-0.jsonl");
559
+ assert_eq!(p.attempt, 0);
560
+ assert_eq!(p.client_status, None);
561
+ assert!(!p.poison);
562
+ assert_eq!(p.base, "claude-3ee9c788-1785741108180149712-0");
563
+ }
564
+
565
+ #[test]
566
+ fn only_batches_with_no_definitive_rejection_are_auto_retried() {
567
+ let park = |n: &str| ParkedName::parse(n);
568
+ assert!(park("a.a1.jsonl").is_auto_retryable());
569
+ // A 4xx will fail identically until the key or URL is fixed.
570
+ assert!(!park("a.a1.c401.jsonl").is_auto_retryable());
571
+ assert!(!park("a.a3.jsonl.poison").is_auto_retryable());
572
+ }
573
+
574
+ #[test]
575
+ fn splitting_respects_line_boundaries_and_the_cap() {
576
+ let body = b"aaaa\nbbbb\ncccc\ndddd\n";
577
+ let chunks = split_lines(body, 10);
578
+ assert!(chunks.len() > 1);
579
+ for c in &chunks {
580
+ assert!(c.len() <= 10, "chunk of {} bytes exceeds cap", c.len());
581
+ assert!(c.ends_with(b"\n"), "a chunk must not split a line");
582
+ }
583
+ let rejoined: Vec<u8> = chunks.concat();
584
+ assert_eq!(rejoined, body, "splitting must lose nothing");
585
+ }
586
+
587
+ #[test]
588
+ fn a_body_under_the_cap_is_one_chunk() {
589
+ let body = b"one\ntwo\n";
590
+ assert_eq!(split_lines(body, 1024).len(), 1);
591
+ }
592
+
593
+ #[test]
594
+ fn an_oversized_single_line_is_emitted_rather_than_dropped() {
595
+ let body = b"aaaaaaaaaaaaaaaaaaaaaaaa\n";
596
+ let chunks = split_lines(body, 4);
597
+ assert_eq!(chunks.len(), 1);
598
+ assert_eq!(chunks[0], body);
599
+ }
600
+ }