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,423 @@
1
+ //! Fault-isolated host for every collector task.
2
+ //!
3
+ //! # Why this exists at all
4
+ //!
5
+ //! `failproofai` fails CLOSED. Once a machine is daemon-configured, the CLI
6
+ //! turns an unreachable daemon into a *deny* rather than a fallback — see
7
+ //! `bin/failproofai.mjs`. So anything that can take the daemon process down,
8
+ //! or wedge it, blocks every tool call across every supported agent CLI on
9
+ //! that machine.
10
+ //!
11
+ //! Collection is strictly lower-value than enforcement: shipping a session log
12
+ //! late, or not at all, is a gap in a dashboard. Denying every tool call is a
13
+ //! developer who cannot work. This module exists to make that asymmetry
14
+ //! structural rather than aspirational, so a bug in a transform for some
15
+ //! agent's log format can never become an outage.
16
+ //!
17
+ //! # The three guarantees
18
+ //!
19
+ //! 1. **Own thread, own runtime.** The collector never runs on the socket
20
+ //! server's threads. `failproofaid`'s accept loop stays blocking `std`,
21
+ //! unchanged — converting it would mean rewriting the connection handling
22
+ //! whose non-blocking/BSD-`accept` subtleties are the reason macOS worked
23
+ //! at all, and re-earning trust in the exact code path that gates tool
24
+ //! calls. Not worth it to host a background uploader.
25
+ //! 2. **A panic is contained and logged, never propagated.** Every task body
26
+ //! runs inside `catch_unwind`. A panicking task is restarted with backoff;
27
+ //! it does not unwind into the runtime, does not abort the process, and
28
+ //! does not mark the daemon unhealthy.
29
+ //! 3. **Shutdown is bounded.** The supervisor observes the same shutdown flag
30
+ //! the socket server does, and `join_with_flush` waits only a fixed budget
31
+ //! for tasks to drain before giving up on them. A collector task that hangs
32
+ //! delays process exit by that budget and no more.
33
+ //!
34
+ //! # What this deliberately does NOT do
35
+ //!
36
+ //! It does not restart forever at full speed, and it does not treat a task
37
+ //! that exits cleanly as a failure. A task returning `Ok(())` has finished its
38
+ //! work; only an `Err` or a panic is a restart, and each consecutive one backs
39
+ //! off further so a task that is broken rather than merely unlucky cannot spin
40
+ //! the CPU behind a user's back.
41
+
42
+ use std::future::Future;
43
+ use std::panic::AssertUnwindSafe;
44
+ use std::pin::Pin;
45
+ use std::sync::Arc;
46
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
47
+ use std::thread::JoinHandle;
48
+ use std::time::Duration;
49
+
50
+ /// How long `join_with_flush` waits for the collector thread after asking it
51
+ /// to stop. Long enough for an in-flight spool write to finish; short enough
52
+ /// that a wedged task cannot hold up a `systemctl stop` past its own timeout.
53
+ pub const DEFAULT_FLUSH_BUDGET: Duration = Duration::from_secs(5);
54
+
55
+ /// First restart delay after a task fails. Doubles per consecutive failure.
56
+ const BACKOFF_START: Duration = Duration::from_secs(1);
57
+ /// Ceiling for the restart delay. A permanently broken task settles here
58
+ /// rather than retrying tightly forever.
59
+ const BACKOFF_MAX: Duration = Duration::from_secs(60);
60
+ /// How often the supervisor re-checks the shutdown flag while idle or backing
61
+ /// off. Shutdown latency is bounded by this, so it stays well under the flush
62
+ /// budget.
63
+ const SHUTDOWN_POLL: Duration = Duration::from_millis(100);
64
+
65
+ /// Worker threads for the collector runtime. Two is deliberate: the workload
66
+ /// is IO-bound (file reads, HTTP), and every thread here is one the OS
67
+ /// schedules against the socket server's handler threads.
68
+ const RUNTIME_WORKER_THREADS: usize = 2;
69
+
70
+ type TaskFuture = Pin<Box<dyn Future<Output = Result<(), TaskError>> + Send>>;
71
+ type TaskFactory = Box<dyn Fn(Shutdown) -> TaskFuture + Send + Sync>;
72
+
73
+ /// A task's view of "should I stop?".
74
+ ///
75
+ /// Every collector task is a poll loop, so the supervisor checking between
76
+ /// attempts is not enough on its own — a loop that never returns would only be
77
+ /// noticed once its flush budget expired, and it would be killed mid-iteration
78
+ /// rather than stopping at a safe point. Handing this to the task body lets it
79
+ /// exit at the top of a loop, after its cursor has been persisted.
80
+ ///
81
+ /// Cheap to clone; both flags are shared. `daemon` is the socket server's own
82
+ /// flag, so a SIGTERM is observed here without a second signal path.
83
+ #[derive(Clone)]
84
+ pub struct Shutdown {
85
+ daemon: Arc<AtomicBool>,
86
+ collector: Arc<AtomicBool>,
87
+ }
88
+
89
+ impl Shutdown {
90
+ /// Build one directly from a daemon flag, for tests and for callers that
91
+ /// drive a task outside the supervisor. Production code receives this from
92
+ /// the supervisor instead, which is why the fields stay private.
93
+ #[doc(hidden)]
94
+ pub fn for_test(daemon: Arc<AtomicBool>) -> Self {
95
+ Shutdown {
96
+ daemon,
97
+ collector: Arc::new(AtomicBool::new(false)),
98
+ }
99
+ }
100
+
101
+ /// True once either the daemon or the collector has been asked to stop.
102
+ pub fn is_set(&self) -> bool {
103
+ self.daemon.load(Ordering::Relaxed) || self.collector.load(Ordering::Relaxed)
104
+ }
105
+
106
+ /// Sleep for `dur`, returning early if shutdown is requested.
107
+ ///
108
+ /// Returns `true` if the full duration elapsed, `false` if it was cut
109
+ /// short. Poll loops should use this rather than `tokio::time::sleep` so a
110
+ /// 60-second poll interval does not become 60 seconds of shutdown latency.
111
+ pub async fn sleep(&self, dur: Duration) -> bool {
112
+ let deadline = tokio::time::Instant::now() + dur;
113
+ loop {
114
+ if self.is_set() {
115
+ return false;
116
+ }
117
+ let now = tokio::time::Instant::now();
118
+ if now >= deadline {
119
+ return true;
120
+ }
121
+ tokio::time::sleep(SHUTDOWN_POLL.min(deadline - now)).await;
122
+ }
123
+ }
124
+ }
125
+
126
+ /// Why a task stopped. Only used for logging and the restart decision — the
127
+ /// supervisor treats every failure the same way.
128
+ #[derive(Debug)]
129
+ pub struct TaskError(pub String);
130
+
131
+ impl std::fmt::Display for TaskError {
132
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133
+ write!(f, "{}", self.0)
134
+ }
135
+ }
136
+
137
+ impl std::error::Error for TaskError {}
138
+
139
+ impl From<String> for TaskError {
140
+ fn from(s: String) -> Self {
141
+ TaskError(s)
142
+ }
143
+ }
144
+
145
+ impl From<&str> for TaskError {
146
+ fn from(s: &str) -> Self {
147
+ TaskError(s.to_string())
148
+ }
149
+ }
150
+
151
+ /// One supervised unit of collector work.
152
+ ///
153
+ /// `factory` is called afresh for every attempt rather than the future being
154
+ /// built once, because a restarted task must start from a clean state — a
155
+ /// future that already failed cannot be polled again, and reusing one would
156
+ /// silently make "restart" mean "give up".
157
+ pub struct TaskSpec {
158
+ pub name: &'static str,
159
+ factory: TaskFactory,
160
+ }
161
+
162
+ impl TaskSpec {
163
+ pub fn new<F, Fut>(name: &'static str, factory: F) -> Self
164
+ where
165
+ F: Fn(Shutdown) -> Fut + Send + Sync + 'static,
166
+ Fut: Future<Output = Result<(), TaskError>> + Send + 'static,
167
+ {
168
+ TaskSpec {
169
+ name,
170
+ factory: Box::new(move |sd| Box::pin(factory(sd))),
171
+ }
172
+ }
173
+ }
174
+
175
+ /// Counters a caller (or a test) can read without stopping the supervisor.
176
+ /// Public because the daemon's health record reports them: a task that is
177
+ /// quietly restarting in a loop must be visible, not merely absent.
178
+ #[derive(Debug, Default)]
179
+ pub struct SupervisorMetrics {
180
+ /// Times a task returned `Err`.
181
+ pub failures: AtomicUsize,
182
+ /// Times a task panicked. Tracked separately from `failures` because a
183
+ /// panic is a bug in the collector, while an `Err` is usually the
184
+ /// environment (a vanished directory, a refused connection).
185
+ pub panics: AtomicUsize,
186
+ /// Times a task was started, including the first.
187
+ pub starts: AtomicUsize,
188
+ }
189
+
190
+ /// Handle to a running collector. Dropping it does NOT stop the collector —
191
+ /// call [`CollectorHandle::join_with_flush`], so shutdown stays explicit and
192
+ /// ordered relative to the socket server's own teardown.
193
+ pub struct CollectorHandle {
194
+ thread: Option<JoinHandle<()>>,
195
+ stop: Arc<AtomicBool>,
196
+ metrics: Arc<SupervisorMetrics>,
197
+ }
198
+
199
+ impl CollectorHandle {
200
+ pub fn metrics(&self) -> Arc<SupervisorMetrics> {
201
+ self.metrics.clone()
202
+ }
203
+
204
+ /// Ask every task to stop, then wait up to `budget` for the collector
205
+ /// thread to finish.
206
+ ///
207
+ /// Returns `true` if it drained in time. On `false` the thread is
208
+ /// abandoned deliberately rather than blocked on: the process is already
209
+ /// exiting, and the alternative — waiting indefinitely on a task that has
210
+ /// wedged — is the hang this whole module exists to prevent.
211
+ pub fn join_with_flush(mut self, budget: Duration) -> bool {
212
+ self.stop.store(true, Ordering::Relaxed);
213
+ let Some(thread) = self.thread.take() else {
214
+ return true;
215
+ };
216
+
217
+ // `JoinHandle` has no timed join in std, so poll `is_finished` and
218
+ // only then join (which is guaranteed not to block once finished).
219
+ let deadline = std::time::Instant::now() + budget;
220
+ while std::time::Instant::now() < deadline {
221
+ if thread.is_finished() {
222
+ let _ = thread.join();
223
+ return true;
224
+ }
225
+ std::thread::sleep(SHUTDOWN_POLL);
226
+ }
227
+ tracing::warn!(
228
+ budget_ms = budget.as_millis() as u64,
229
+ "collector did not drain within its flush budget; abandoning it so process exit is not blocked"
230
+ );
231
+ false
232
+ }
233
+ }
234
+
235
+ /// Start the collector on its own thread with its own Tokio runtime.
236
+ ///
237
+ /// `shutdown` is the daemon's existing flag, observed rather than owned, so a
238
+ /// SIGTERM stops the collector and the socket server through one signal path.
239
+ ///
240
+ /// Returns `None` when there is nothing to run. That is the normal state on a
241
+ /// machine that has not opted in to collection: no thread is spawned, no
242
+ /// runtime is created, and the daemon behaves exactly as it does today.
243
+ pub fn spawn_supervised(
244
+ tasks: Vec<TaskSpec>,
245
+ shutdown: Arc<AtomicBool>,
246
+ ) -> Option<CollectorHandle> {
247
+ if tasks.is_empty() {
248
+ tracing::debug!("collector has no enabled tasks; not starting a runtime");
249
+ return None;
250
+ }
251
+
252
+ let stop = Arc::new(AtomicBool::new(false));
253
+ let metrics = Arc::new(SupervisorMetrics::default());
254
+
255
+ let thread_stop = stop.clone();
256
+ let thread_metrics = metrics.clone();
257
+ let thread = std::thread::Builder::new()
258
+ .name("fpai-collect".to_string())
259
+ .spawn(move || {
260
+ // Building the runtime can fail (thread/fd exhaustion). That must
261
+ // degrade to "no collection" and never to a panic on a thread the
262
+ // daemon does not join.
263
+ let runtime = match tokio::runtime::Builder::new_multi_thread()
264
+ .worker_threads(RUNTIME_WORKER_THREADS)
265
+ .enable_all()
266
+ .thread_name("fpai-collect-rt")
267
+ .build()
268
+ {
269
+ Ok(rt) => rt,
270
+ Err(err) => {
271
+ tracing::error!(%err, "collector runtime failed to start; collection is off for this process");
272
+ return;
273
+ }
274
+ };
275
+
276
+ runtime.block_on(run_all(tasks, shutdown, thread_stop, thread_metrics));
277
+
278
+ // Do not let a task that ignored shutdown hold the thread here:
279
+ // the caller's flush budget is the only bound we promise, and
280
+ // `Runtime::drop` would otherwise wait for every blocking task.
281
+ runtime.shutdown_timeout(Duration::from_secs(1));
282
+ });
283
+
284
+ match thread {
285
+ Ok(thread) => Some(CollectorHandle {
286
+ thread: Some(thread),
287
+ stop,
288
+ metrics,
289
+ }),
290
+ Err(err) => {
291
+ tracing::error!(%err, "could not spawn the collector thread; collection is off for this process");
292
+ None
293
+ }
294
+ }
295
+ }
296
+
297
+ /// Supervise every task concurrently until either shutdown flag is set.
298
+ async fn run_all(
299
+ tasks: Vec<TaskSpec>,
300
+ shutdown: Arc<AtomicBool>,
301
+ stop: Arc<AtomicBool>,
302
+ metrics: Arc<SupervisorMetrics>,
303
+ ) {
304
+ let mut set = tokio::task::JoinSet::new();
305
+ let task_count = tasks.len();
306
+ for spec in tasks {
307
+ let shutdown = shutdown.clone();
308
+ let stop = stop.clone();
309
+ let metrics = metrics.clone();
310
+ set.spawn(async move { supervise_one(spec, shutdown, stop, metrics).await });
311
+ }
312
+ tracing::info!(tasks = task_count, "collector started");
313
+
314
+ // Every arm returns only on shutdown, so this drains rather than races.
315
+ while set.join_next().await.is_some() {}
316
+ tracing::info!("collector stopped");
317
+ }
318
+
319
+ /// Run one task, restarting it with backoff on failure, until shutdown.
320
+ async fn supervise_one(
321
+ spec: TaskSpec,
322
+ shutdown: Arc<AtomicBool>,
323
+ stop: Arc<AtomicBool>,
324
+ metrics: Arc<SupervisorMetrics>,
325
+ ) {
326
+ let mut backoff = BACKOFF_START;
327
+
328
+ loop {
329
+ if is_stopping(&shutdown, &stop) {
330
+ return;
331
+ }
332
+
333
+ metrics.starts.fetch_add(1, Ordering::Relaxed);
334
+
335
+ // `AssertUnwindSafe` is the honest choice rather than a papered-over
336
+ // one: a panic mid-task can leave whatever it was mutating in an
337
+ // arbitrary state. The factory builds a fresh future per attempt, so
338
+ // nothing observable survives the panic into the retry — anything
339
+ // durable lives on disk behind an atomic rename, and a torn in-memory
340
+ // buffer is discarded with the future that owned it.
341
+ let sd = Shutdown {
342
+ daemon: shutdown.clone(),
343
+ collector: stop.clone(),
344
+ };
345
+ let outcome = futures_catch_unwind(AssertUnwindSafe((spec.factory)(sd))).await;
346
+
347
+ match outcome {
348
+ Ok(Ok(())) => {
349
+ // Finished its work. Not a failure, so no backoff — but also
350
+ // nothing to restart, so stop supervising it.
351
+ tracing::debug!(task = spec.name, "collector task completed");
352
+ return;
353
+ }
354
+ Ok(Err(err)) => {
355
+ metrics.failures.fetch_add(1, Ordering::Relaxed);
356
+ tracing::warn!(
357
+ task = spec.name,
358
+ %err,
359
+ retry_in_ms = backoff.as_millis() as u64,
360
+ "collector task failed; restarting"
361
+ );
362
+ }
363
+ Err(panic_msg) => {
364
+ metrics.panics.fetch_add(1, Ordering::Relaxed);
365
+ // Logged at error, not warn: an Err is usually the
366
+ // environment, a panic is always a bug worth chasing.
367
+ tracing::error!(
368
+ task = spec.name,
369
+ panic = %panic_msg,
370
+ retry_in_ms = backoff.as_millis() as u64,
371
+ "collector task PANICKED; contained and restarting. Enforcement is unaffected"
372
+ );
373
+ }
374
+ }
375
+
376
+ let sd = Shutdown {
377
+ daemon: shutdown.clone(),
378
+ collector: stop.clone(),
379
+ };
380
+ if !sd.sleep(backoff).await {
381
+ return;
382
+ }
383
+ backoff = (backoff * 2).min(BACKOFF_MAX);
384
+ }
385
+ }
386
+
387
+ fn is_stopping(shutdown: &AtomicBool, stop: &AtomicBool) -> bool {
388
+ shutdown.load(Ordering::Relaxed) || stop.load(Ordering::Relaxed)
389
+ }
390
+
391
+ /// Await `fut`, converting a panic into an `Err` carrying its message.
392
+ ///
393
+ /// Hand-rolled rather than pulling in `futures` for one combinator: the
394
+ /// dependency budget for a binary that ships on four cross-compiled targets is
395
+ /// worth spending deliberately, and this is the only place the daemon needs it.
396
+ async fn futures_catch_unwind<F, T>(fut: AssertUnwindSafe<F>) -> Result<T, String>
397
+ where
398
+ F: Future<Output = T> + Send + 'static,
399
+ T: Send + 'static,
400
+ {
401
+ // A panic inside `poll` unwinds through `await`, so the guard has to wrap
402
+ // each poll rather than the whole future — which is exactly what awaiting
403
+ // inside a plain `catch_unwind` cannot do. Spawning gives us that
404
+ // per-poll boundary for free: Tokio already catches a panicking task and
405
+ // surfaces it as a `JoinError`. (`AssertUnwindSafe` implements `Future`
406
+ // itself, forwarding to the inner one, so it can be spawned directly.)
407
+ match tokio::task::spawn(fut).await {
408
+ Ok(v) => Ok(v),
409
+ Err(join_err) if join_err.is_panic() => {
410
+ let panic = join_err.into_panic();
411
+ let msg = if let Some(s) = panic.downcast_ref::<&str>() {
412
+ (*s).to_string()
413
+ } else if let Some(s) = panic.downcast_ref::<String>() {
414
+ s.clone()
415
+ } else {
416
+ "non-string panic payload".to_string()
417
+ };
418
+ Err(msg)
419
+ }
420
+ // Cancellation: the runtime is going away. Treat as a clean stop.
421
+ Err(_) => Err("task cancelled".to_string()),
422
+ }
423
+ }