failproofai 1.0.1 → 1.0.2-beta.0

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 (389) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +6 -6
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
  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 +6 -6
  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/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  24. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  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/status/route.js +4 -4
  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.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/page.js +2 -2
  48. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +66 -22
  51. package/.next/standalone/.next/server/app/policies/page.js +5 -4
  52. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  65. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  68. package/.next/standalone/.next/server/app/settings/page.js +1 -1
  69. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0e32-7c._.js} +1 -1
  72. package/.next/standalone/.next/server/chunks/[externals]__1s462yl._.js +3 -0
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -6
  84. package/.next/standalone/.next/server/chunks/lib_telemetry-id_ts_1k8gj9o._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +12 -0
  89. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +4 -0
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +4 -0
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +4 -0
  93. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0spkm68._.js → [root-of-the-server]__07gm3zl._.js} +21 -20
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +4 -0
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +4 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +4 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +6 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07tbbf3._.js → [root-of-the-server]__0zig5sv._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +5 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +4 -0
  102. package/.next/standalone/.next/server/chunks/ssr/{_0lvyuv4._.js → _00e9tnf._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{_0u5zauv._.js → _12l4a_k._.js} +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +13 -13
  108. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_08o7bqo._.js +10 -0
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  114. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  115. package/.next/standalone/.next/server/middleware.js +1 -1
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +71 -27
  120. package/.next/standalone/.next/static/chunks/{37fcpc36x9gpe.js → 0eqky-7n7dgdm.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{1ide1wl2f7zi1.js → 0sa9v5mhc8861.js} +13 -13
  122. package/.next/standalone/.next/static/chunks/1mfzsja6ftim5.css +2 -0
  123. package/.next/standalone/.next/static/chunks/{3k7mxbp9hiom0.js → 1nuqytbta44af.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +6 -0
  125. package/.next/standalone/.next/static/chunks/{2_2lhuojq2xop.js → 2aawsjld9ied_.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/2gn7ut4svofm2.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{31s1smp-g7gks.js → 2hs7ycvsav7v2.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{0k5050ezjvv49.js → 2i2yt3hzjredm.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{3c808qrfcq0im.js → 35xg6retpe6rp.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{2zpp1we2jv6tb.js → 3y54nw0i7dsjl.js} +1 -1
  131. package/.next/standalone/SECURITY.md +5 -4
  132. package/.next/standalone/app/actions/get-hooks-config.ts +108 -17
  133. package/.next/standalone/app/actions/pack-actions.ts +153 -0
  134. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  135. package/.next/standalone/app/audit/_components/share-templates.ts +13 -13
  136. package/.next/standalone/app/policies/hooks-client.tsx +320 -14
  137. package/.next/standalone/components/navbar.tsx +2 -28
  138. package/.next/standalone/components/reach-developers.tsx +8 -1
  139. package/.next/standalone/docs-old/agenteye/python-sdk-skill.mdx +1 -1
  140. package/.next/standalone/docs-old/agenteye/python-sdk.mdx +59 -35
  141. package/.next/standalone/docs-old/i18n/README.ar.md +1 -0
  142. package/.next/standalone/docs-old/i18n/README.de.md +1 -0
  143. package/.next/standalone/docs-old/i18n/README.es.md +1 -0
  144. package/.next/standalone/docs-old/i18n/README.fr.md +1 -0
  145. package/.next/standalone/docs-old/i18n/README.he.md +1 -0
  146. package/.next/standalone/docs-old/i18n/README.hi.md +1 -0
  147. package/.next/standalone/docs-old/i18n/README.it.md +1 -0
  148. package/.next/standalone/docs-old/i18n/README.ja.md +1 -0
  149. package/.next/standalone/docs-old/i18n/README.ko.md +1 -0
  150. package/.next/standalone/docs-old/i18n/README.pt-br.md +1 -0
  151. package/.next/standalone/docs-old/i18n/README.ru.md +1 -0
  152. package/.next/standalone/docs-old/i18n/README.tr.md +1 -0
  153. package/.next/standalone/docs-old/i18n/README.vi.md +1 -0
  154. package/.next/standalone/docs-old/i18n/README.zh.md +1 -0
  155. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +431 -0
  156. package/.next/standalone/fp-cloud-cli/LICENSE +42 -0
  157. package/.next/standalone/fp-cloud-cli/MANIFEST.in +10 -0
  158. package/.next/standalone/fp-cloud-cli/README.md +259 -0
  159. package/.next/standalone/fp-cloud-cli/fp_cli/__init__.py +10 -0
  160. package/.next/standalone/fp-cloud-cli/fp_cli/__main__.py +4 -0
  161. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +64 -0
  162. package/.next/standalone/fp-cloud-cli/fp_cli/_context.py +332 -0
  163. package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -0
  164. package/.next/standalone/fp-cloud-cli/fp_cli/analytics.py +432 -0
  165. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_config.py +77 -0
  166. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_registry.py +83 -0
  167. package/.next/standalone/fp-cloud-cli/fp_cli/app.py +492 -0
  168. package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +160 -0
  169. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1694 -0
  170. package/.next/standalone/fp-cloud-cli/fp_cli/commands/__init__.py +0 -0
  171. package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +214 -0
  172. package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +407 -0
  173. package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +445 -0
  174. package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +1054 -0
  175. package/.next/standalone/fp-cloud-cli/fp_cli/commands/auth_cmds.py +512 -0
  176. package/.next/standalone/fp-cloud-cli/fp_cli/commands/errors_cmds.py +190 -0
  177. package/.next/standalone/fp-cloud-cli/fp_cli/commands/evals_cmds.py +161 -0
  178. package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +159 -0
  179. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +416 -0
  180. package/.next/standalone/fp-cloud-cli/fp_cli/commands/guardrails_cmds.py +148 -0
  181. package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +472 -0
  182. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +407 -0
  183. package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +63 -0
  184. package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +319 -0
  185. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +499 -0
  186. package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +378 -0
  187. package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +151 -0
  188. package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +150 -0
  189. package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +35 -0
  190. package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +404 -0
  191. package/.next/standalone/fp-cloud-cli/fp_cli/config.py +330 -0
  192. package/.next/standalone/fp-cloud-cli/fp_cli/dates.py +78 -0
  193. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +345 -0
  194. package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +98 -0
  195. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +891 -0
  196. package/.next/standalone/fp-cloud-cli/fp_cli/orgs.py +30 -0
  197. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +6593 -0
  198. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +208 -0
  199. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +290 -0
  200. package/.next/standalone/fp-cloud-cli/fp_cli/py.typed +0 -0
  201. package/.next/standalone/fp-cloud-cli/fp_cli/select.py +322 -0
  202. package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +53 -0
  203. package/.next/standalone/fp-cloud-cli/pyproject.toml +106 -0
  204. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +302 -0
  205. package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +8 -0
  206. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +254 -0
  207. package/.next/standalone/fp-cloud-cli/tests/__init__.py +0 -0
  208. package/.next/standalone/fp-cloud-cli/tests/conftest.py +92 -0
  209. package/.next/standalone/fp-cloud-cli/tests/test_alerting.py +644 -0
  210. package/.next/standalone/fp-cloud-cli/tests/test_analytics.py +385 -0
  211. package/.next/standalone/fp-cloud-cli/tests/test_audits.py +950 -0
  212. package/.next/standalone/fp-cloud-cli/tests/test_auth.py +98 -0
  213. package/.next/standalone/fp-cloud-cli/tests/test_auth_mode.py +272 -0
  214. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +118 -0
  215. package/.next/standalone/fp-cloud-cli/tests/test_client.py +299 -0
  216. package/.next/standalone/fp-cloud-cli/tests/test_commands.py +814 -0
  217. package/.next/standalone/fp-cloud-cli/tests/test_config.py +99 -0
  218. package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +340 -0
  219. package/.next/standalone/fp-cloud-cli/tests/test_dates.py +76 -0
  220. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +481 -0
  221. package/.next/standalone/fp-cloud-cli/tests/test_facets.py +170 -0
  222. package/.next/standalone/fp-cloud-cli/tests/test_failproofai_home.py +722 -0
  223. package/.next/standalone/fp-cloud-cli/tests/test_fp_home_contract.py +198 -0
  224. package/.next/standalone/fp-cloud-cli/tests/test_hardening.py +186 -0
  225. package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +127 -0
  226. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +496 -0
  227. package/.next/standalone/fp-cloud-cli/tests/test_list.py +59 -0
  228. package/.next/standalone/fp-cloud-cli/tests/test_multivalue.py +183 -0
  229. package/.next/standalone/fp-cloud-cli/tests/test_no_customer_identifiers.py +168 -0
  230. package/.next/standalone/fp-cloud-cli/tests/test_operator.py +492 -0
  231. package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +692 -0
  232. package/.next/standalone/fp-cloud-cli/tests/test_output.py +1732 -0
  233. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +191 -0
  234. package/.next/standalone/fp-cloud-cli/tests/test_readme_matches_reality.py +125 -0
  235. package/.next/standalone/fp-cloud-cli/tests/test_review_fixes.py +161 -0
  236. package/.next/standalone/fp-cloud-cli/tests/test_telemetry_completeness.py +90 -0
  237. package/.next/standalone/fp-cloud-cli/tests/test_usage.py +115 -0
  238. package/.next/standalone/fp-cloud-cli/tests/test_v1_origin_diagnostic.py +70 -0
  239. package/.next/standalone/fp-cloud-cli/tests/test_v1_routing.py +214 -0
  240. package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +104 -0
  241. package/.next/standalone/fp-cloud-cli/uv.lock +552 -0
  242. package/.next/standalone/package.json +29 -22
  243. package/.next/standalone/sdk/python/CHANGELOG.md +70 -0
  244. package/.next/standalone/sdk/python/LICENSE +42 -0
  245. package/.next/standalone/sdk/python/MANIFEST.in +21 -0
  246. package/.next/standalone/sdk/python/README.md +466 -0
  247. package/.next/standalone/sdk/python/docs/README.md +249 -0
  248. package/.next/standalone/sdk/python/docs/_shared/README.md +43 -0
  249. package/.next/standalone/sdk/python/docs/_shared/__init__.py +195 -0
  250. package/.next/standalone/sdk/python/docs/crewai/README.md +328 -0
  251. package/.next/standalone/sdk/python/docs/crewai/examples/quickstart.py +61 -0
  252. package/.next/standalone/sdk/python/docs/crewai/examples/research_crew.py +92 -0
  253. package/.next/standalone/sdk/python/docs/langgraph/README.md +481 -0
  254. package/.next/standalone/sdk/python/docs/langgraph/examples/quickstart.py +51 -0
  255. package/.next/standalone/sdk/python/docs/langgraph/examples/research_agent.py +86 -0
  256. package/.next/standalone/sdk/python/docs/langgraph/examples/supervisor_handoff.py +83 -0
  257. package/.next/standalone/sdk/python/docs/llama_index/README.md +332 -0
  258. package/.next/standalone/sdk/python/docs/llama_index/examples/quickstart.py +62 -0
  259. package/.next/standalone/sdk/python/docs/llama_index/examples/research_agent.py +93 -0
  260. package/.next/standalone/sdk/python/docs/manual/README.md +491 -0
  261. package/.next/standalone/sdk/python/docs/manual/examples/quickstart.py +57 -0
  262. package/.next/standalone/sdk/python/docs/manual/examples/research_agent.py +133 -0
  263. package/.next/standalone/sdk/python/docs/pydantic_ai/README.md +323 -0
  264. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/quickstart.py +48 -0
  265. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/research_agent.py +94 -0
  266. package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +131 -0
  267. package/.next/standalone/sdk/python/failproofai_sdk/_context.py +218 -0
  268. package/.next/standalone/sdk/python/failproofai_sdk/_environment.py +78 -0
  269. package/.next/standalone/sdk/python/failproofai_sdk/_events.py +752 -0
  270. package/.next/standalone/sdk/python/failproofai_sdk/_resolver.py +118 -0
  271. package/.next/standalone/sdk/python/failproofai_sdk/_runtime.py +21 -0
  272. package/.next/standalone/sdk/python/failproofai_sdk/_schema.py +317 -0
  273. package/.next/standalone/sdk/python/failproofai_sdk/_scopes.py +422 -0
  274. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -0
  275. package/.next/standalone/sdk/python/failproofai_sdk/_writer.py +750 -0
  276. package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +274 -0
  277. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_compat.py +247 -0
  278. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_core.py +1076 -0
  279. package/.next/standalone/sdk/python/failproofai_sdk/integrations/crewai.py +1669 -0
  280. package/.next/standalone/sdk/python/failproofai_sdk/integrations/langchain.py +2151 -0
  281. package/.next/standalone/sdk/python/failproofai_sdk/integrations/llama_index.py +1588 -0
  282. package/.next/standalone/sdk/python/failproofai_sdk/integrations/pydantic_ai.py +977 -0
  283. package/.next/standalone/sdk/python/failproofai_sdk/py.typed +0 -0
  284. package/.next/standalone/sdk/python/pyproject.toml +92 -0
  285. package/.next/standalone/sdk/python/skill/SKILL.md +399 -0
  286. package/.next/standalone/sdk/python/skill/agents/openai.yaml +8 -0
  287. package/.next/standalone/sdk/python/skill/references/events.md +234 -0
  288. package/.next/standalone/sdk/python/skill/references/frameworks.md +314 -0
  289. package/.next/standalone/sdk/python/skill/references/install.md +100 -0
  290. package/.next/standalone/sdk/python/skill/references/integration.md +352 -0
  291. package/.next/standalone/sdk/python/tests/__init__.py +0 -0
  292. package/.next/standalone/sdk/python/tests/conftest.py +169 -0
  293. package/.next/standalone/sdk/python/tests/integrations/__init__.py +6 -0
  294. package/.next/standalone/sdk/python/tests/integrations/test_crewai.py +1299 -0
  295. package/.next/standalone/sdk/python/tests/integrations/test_langchain.py +2338 -0
  296. package/.next/standalone/sdk/python/tests/integrations/test_llama_index.py +1576 -0
  297. package/.next/standalone/sdk/python/tests/integrations/test_pydantic_ai.py +1102 -0
  298. package/.next/standalone/sdk/python/tests/test_context.py +410 -0
  299. package/.next/standalone/sdk/python/tests/test_docs.py +411 -0
  300. package/.next/standalone/sdk/python/tests/test_durability.py +1352 -0
  301. package/.next/standalone/sdk/python/tests/test_encoding.py +571 -0
  302. package/.next/standalone/sdk/python/tests/test_integrations.py +1127 -0
  303. package/.next/standalone/sdk/python/tests/test_no_customer_identifiers.py +214 -0
  304. package/.next/standalone/sdk/python/tests/test_packaging.py +185 -0
  305. package/.next/standalone/sdk/python/tests/test_resolver_umbrella.py +178 -0
  306. package/.next/standalone/sdk/python/tests/test_scopes.py +487 -0
  307. package/.next/standalone/sdk/python/tests/test_sdk.py +923 -0
  308. package/.next/standalone/sdk/python/tests/test_server_contract.py +650 -0
  309. package/.next/standalone/sdk/python/tests/test_site_docs.py +371 -0
  310. package/.next/standalone/sdk/python/tests/test_skill_snippets.py +165 -0
  311. package/.next/standalone/sdk/python/tests/test_spool_contract.py +287 -0
  312. package/.next/standalone/sdk/python/tests/test_spool_creation.py +360 -0
  313. package/.next/standalone/sdk/python/tests/test_wire_format.py +279 -0
  314. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +378 -0
  315. package/.next/standalone/sdk/python/uv.lock +6005 -0
  316. package/.next/standalone/server.js +1 -1
  317. package/README.md +77 -21
  318. package/bin/failproofai.mjs +145 -11
  319. package/dist/cli.mjs +4482 -2205
  320. package/dist/index.js +5 -5
  321. package/dist/worker.mjs +2399 -1978
  322. package/package.json +29 -22
  323. package/policy-pack/SHA256SUMS +2 -0
  324. package/policy-pack/failproofai-pack.json +732 -0
  325. package/policy-pack/failproofai-pack.mjs +2017 -0
  326. package/scripts/build-policy-pack.mjs +121 -0
  327. package/scripts/changelog-section.py +77 -0
  328. package/scripts/prune-standalone.mjs +3 -0
  329. package/scripts/python-version.py +232 -0
  330. package/scripts/translate-docs/cli.ts +21 -0
  331. package/scripts/translate-docs/mintlify-nav.ts +85 -13
  332. package/scripts/translate-docs/translator.ts +66 -11
  333. package/src/audit/cache.ts +40 -3
  334. package/src/audit/cli.ts +1 -1
  335. package/src/audit/index.ts +1 -1
  336. package/src/audit/replay.ts +89 -3
  337. package/src/audit/report.ts +5 -5
  338. package/src/audit/schedule-cli.ts +49 -53
  339. package/src/hooks/builtin-policies.ts +217 -579
  340. package/src/hooks/cloud-enrollment-cli.ts +66 -27
  341. package/src/hooks/cloud-managed-policies.ts +6 -1
  342. package/src/hooks/configure-wizard.ts +1 -1
  343. package/src/hooks/custom-hooks-loader.ts +89 -6
  344. package/src/hooks/fp-home.ts +92 -4
  345. package/src/hooks/fp-reset.ts +26 -2
  346. package/src/hooks/handler.ts +181 -11
  347. package/src/hooks/harness-cli.ts +79 -25
  348. package/src/hooks/hook-activity-store.ts +13 -3
  349. package/src/hooks/install-prompt.ts +25 -3
  350. package/src/hooks/manager.ts +418 -172
  351. package/src/hooks/migrations.ts +2 -2
  352. package/src/hooks/pack-cli.ts +505 -0
  353. package/src/hooks/pack-failclosed.ts +189 -0
  354. package/src/hooks/pack-manifest.ts +344 -0
  355. package/src/hooks/pack-store.ts +930 -0
  356. package/src/hooks/policy-catalog.ts +535 -0
  357. package/src/hooks/policy-evaluator.ts +24 -16
  358. package/src/hooks/policy-presets.ts +4 -2
  359. package/src/hooks/policy-registry.ts +8 -2
  360. package/src/hooks/policy-types.ts +26 -0
  361. package/src/hooks/session-pause-cli.ts +33 -9
  362. package/src/hooks/tui.ts +570 -0
  363. package/src/hooks/types.ts +15 -1
  364. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
  365. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
  366. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +0 -3
  367. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +0 -3
  368. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
  369. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
  370. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +0 -3
  371. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
  372. package/.next/standalone/.next/server/chunks/_1ger0a2._.js +0 -3
  373. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
  374. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_1iiwm5e._.js +0 -3
  375. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kc8_v5._.js +0 -4
  376. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +0 -4
  377. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mhqk5a._.js +0 -4
  378. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pltpn8._.js +0 -4
  379. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qkcyf-._.js +0 -4
  380. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w0x_ok._.js +0 -4
  381. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +0 -4
  382. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bfex62._.js +0 -5
  383. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +0 -6
  384. package/.next/standalone/.next/static/chunks/36fw6yk1s6x_f.js +0 -1
  385. package/.next/standalone/.next/static/chunks/3tzq0260n17ae.js +0 -6
  386. package/.next/standalone/.next/static/chunks/3wq9x0c-7ld5d.css +0 -2
  387. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_buildManifest.js +0 -0
  388. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_clientMiddlewareManifest.js +0 -0
  389. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_ssgManifest.js +0 -0
@@ -0,0 +1,1352 @@
1
+ """Nothing this SDK accepts may be lost without saying so.
2
+
3
+ The writer buffers in memory and publishes from a background daemon thread, so
4
+ every failure here is asynchronous and out of the caller's sight: `event.*()`
5
+ returned None a long time ago and the application moved on. There is no return
6
+ code to check, no exception to catch, and — because an unread spool looks
7
+ exactly like an idle one — no symptom until someone notices a dashboard is
8
+ emptier than it should be.
9
+
10
+ The tests below are the ones that would have caught a real loss. Several of them
11
+ are regression tests for a bug found while writing them: batches were named from
12
+ a millisecond timestamp alone, so two written in the same millisecond collided
13
+ and `os.replace` silently overwrote the first. It fired on the atexit flush
14
+ racing the flush thread, on `flush_now()` from two threads, and — worst — across
15
+ processes sharing one spool root, which is the ordinary deployment.
16
+ """
17
+ import json
18
+ import logging
19
+ import os
20
+ import stat
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import time
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+
30
+ from failproofai_sdk import _resolver
31
+ from failproofai_sdk._events import _PENDING_CAP, EventNamespace, _tool_key
32
+ from failproofai_sdk._writer import _QUEUE_CAP, EventWriter
33
+
34
+
35
+ @pytest.fixture
36
+ def spool(tmp_path, monkeypatch):
37
+ """An isolated spool root, restored afterwards."""
38
+ _resolver.set_base_dir(tmp_path)
39
+ yield tmp_path
40
+ _resolver.set_base_dir(None)
41
+
42
+
43
+ def read_all(spool_dir: Path) -> list[dict]:
44
+ """Every event in every published batch. Fails loudly on a torn file."""
45
+ events = []
46
+ for path in sorted((spool_dir / "events").glob("*.jsonl")):
47
+ for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
48
+ try:
49
+ events.append(json.loads(line))
50
+ except json.JSONDecodeError as exc: # pragma: no cover - failure path
51
+ pytest.fail(f"{path.name}:{lineno} is not valid JSON ({exc}): {line!r}")
52
+ return events
53
+
54
+
55
+ # ─────────────────────────────────────────────────────────────────────────────
56
+ # Batch naming — the silent-overwrite class
57
+ # ─────────────────────────────────────────────────────────────────────────────
58
+
59
+
60
+ @pytest.fixture
61
+ def frozen_clock(monkeypatch):
62
+ """Pin `_writer`'s clock so "the same millisecond" is guaranteed, not likely.
63
+
64
+ Without this the collision tests below only reproduce the bug when both
65
+ writes happen to land in one millisecond. They usually do on a fast machine
66
+ — which is the problem: on a loaded CI runner the clock advances between
67
+ them, a timestamp-only stem produces two different names, and the test
68
+ passes against the very implementation it exists to reject.
69
+ """
70
+ # NOT `from failproofai_sdk import _writer` — `__init__` binds that name to
71
+ # the EventWriter SINGLETON, which shadows the submodule of the same name on
72
+ # the package. Reaching for the module has to go through sys.modules.
73
+ writer_module = sys.modules["failproofai_sdk._writer"]
74
+
75
+ fixed = datetime(2026, 1, 2, 3, 4, 5, 678_000, tzinfo=timezone.utc)
76
+
77
+ class _FrozenDatetime(datetime):
78
+ @classmethod
79
+ def now(cls, tz=None):
80
+ return fixed
81
+
82
+ monkeypatch.setattr(writer_module, "datetime", _FrozenDatetime)
83
+ return fixed
84
+
85
+
86
+ def test_two_batches_in_the_same_millisecond_do_not_overwrite_each_other(spool, frozen_clock):
87
+ """Regression: the timestamp-only stem lost whichever batch wrote first."""
88
+ writer = EventWriter(flush_interval=3600)
89
+ writer._write_batch([{"id": "first"}])
90
+ writer._write_batch([{"id": "second"}])
91
+
92
+ # Same instant for both, so a timestamp-only name could not have differed.
93
+ stems = sorted(p.name for p in (spool / "events").glob("*.jsonl"))
94
+ assert len(stems) == 2, f"the two batches collided onto one file: {stems}"
95
+ assert all(frozen_clock.strftime("%Y-%m-%dT%H-%M-%S") in s for s in stems)
96
+
97
+ recovered = {e["id"] for e in read_all(spool)}
98
+ assert recovered == {"first", "second"}
99
+
100
+
101
+ # The DeprecationWarning about fork() in a multi-threaded process is the
102
+ # hazard under test, not a problem with the test.
103
+ @pytest.mark.filterwarnings("ignore:.*fork.*:DeprecationWarning")
104
+ def test_batch_filenames_are_unique_across_processes(spool, frozen_clock):
105
+ """Several agents share one spool root. Their batches must not collide.
106
+
107
+ Nothing in a timestamp-only stem identified the writer, so two processes
108
+ flushing in the same millisecond overwrote each other — and because each one
109
+ saw its own `os.replace` succeed, both would report having written.
110
+
111
+ The frozen clock is inherited across `fork()`, so every child computes the
112
+ same timestamp and the collision is forced rather than hoped for.
113
+ """
114
+ if not hasattr(os, "fork"):
115
+ pytest.skip("requires fork")
116
+
117
+ pids = []
118
+ for _ in range(4):
119
+ pid = os.fork()
120
+ if pid == 0: # child
121
+ try:
122
+ writer = EventWriter(flush_interval=3600)
123
+ writer._write_batch([{"id": f"pid-{os.getpid()}"}])
124
+ finally:
125
+ os._exit(0)
126
+ pids.append(pid)
127
+ for pid in pids:
128
+ _, status = os.waitpid(pid, 0)
129
+ assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0
130
+
131
+ assert len({e["id"] for e in read_all(spool)}) == 4
132
+
133
+
134
+ def test_published_batches_never_end_in_tmp(spool):
135
+ """`.tmp` is how the daemons tell "still being written" from "ready"."""
136
+ writer = EventWriter(flush_interval=3600)
137
+ for i in range(20):
138
+ writer._write_batch([{"id": i}])
139
+
140
+ names = [p.name for p in (spool / "events").iterdir()]
141
+ assert names, "nothing was published"
142
+ assert all(n.endswith(".jsonl") for n in names)
143
+ assert not any(n.endswith(".tmp") for n in names), (
144
+ f"a .tmp file survived publication: {[n for n in names if n.endswith('.tmp')]}"
145
+ )
146
+
147
+
148
+ # ─────────────────────────────────────────────────────────────────────────────
149
+ # Concurrency
150
+ # ─────────────────────────────────────────────────────────────────────────────
151
+
152
+
153
+ def test_sixteen_threads_emitting_concurrently_lose_and_duplicate_nothing(spool):
154
+ """The queue is written by many threads and drained by one. Prove it holds."""
155
+ threads, per_thread = 16, 500
156
+ writer = EventWriter(flush_interval=3600)
157
+ namespace = EventNamespace(writer)
158
+ barrier = threading.Barrier(threads)
159
+
160
+ def emit(thread_id: int):
161
+ barrier.wait()
162
+ for n in range(per_thread):
163
+ namespace.agent_start(
164
+ session_id=f"s{thread_id}", agent_id="a", goal=f"{thread_id}:{n}"
165
+ )
166
+
167
+ workers = [threading.Thread(target=emit, args=(i,)) for i in range(threads)]
168
+ for worker in workers:
169
+ worker.start()
170
+ for worker in workers:
171
+ worker.join()
172
+ writer.flush_now()
173
+
174
+ goals = [e["goal"] for e in read_all(spool)]
175
+ assert len(goals) == threads * per_thread, "events were lost"
176
+ assert len(set(goals)) == len(goals), "events were duplicated"
177
+
178
+
179
+ def test_concurrent_flushes_publish_every_batch(spool):
180
+ """`flush_now()` from several threads at once must not drop a batch."""
181
+ writer = EventWriter(flush_interval=3600)
182
+ total = 400
183
+ for i in range(total):
184
+ writer.submit({"id": i})
185
+
186
+ barrier = threading.Barrier(8)
187
+
188
+ def flush():
189
+ barrier.wait()
190
+ writer.flush_now()
191
+
192
+ workers = [threading.Thread(target=flush) for _ in range(8)]
193
+ for worker in workers:
194
+ worker.start()
195
+ for worker in workers:
196
+ worker.join()
197
+
198
+ assert sorted(e["id"] for e in read_all(spool)) == list(range(total))
199
+
200
+
201
+ def test_a_reader_never_observes_a_partially_written_batch(spool):
202
+ """The `.tmp` -> `.jsonl` rename is what makes a visible file a complete one.
203
+
204
+ A daemon polls this directory. If it can ever open a `.jsonl` mid-write it
205
+ reads truncated JSON, ingest counts the bad lines as `skipped`, and returns
206
+ 200 — silent partial loss with a successful-looking upload.
207
+ """
208
+ writer = EventWriter(flush_interval=3600)
209
+ events_dir = spool / "events"
210
+ events_dir.mkdir(parents=True, exist_ok=True)
211
+ stop = threading.Event()
212
+ torn: list[str] = []
213
+
214
+ def poll():
215
+ while not stop.is_set():
216
+ for path in list(events_dir.glob("*.jsonl")):
217
+ try:
218
+ text = path.read_text(encoding="utf-8")
219
+ except (FileNotFoundError, PermissionError):
220
+ continue
221
+ if text and not text.endswith("\n"):
222
+ torn.append(f"{path.name}: no trailing newline")
223
+ continue
224
+ for line in text.splitlines():
225
+ try:
226
+ json.loads(line)
227
+ except json.JSONDecodeError:
228
+ torn.append(f"{path.name}: {line[:60]!r}")
229
+
230
+ reader = threading.Thread(target=poll, daemon=True)
231
+ reader.start()
232
+ try:
233
+ # Batches big enough that a non-atomic write would be caught mid-flight.
234
+ for batch in range(60):
235
+ writer._write_batch([{"id": f"{batch}-{i}", "pad": "x" * 512} for i in range(200)])
236
+ finally:
237
+ stop.set()
238
+ reader.join(timeout=5)
239
+
240
+ assert not torn, f"reader saw incomplete batches: {torn[:5]}"
241
+
242
+
243
+ # ─────────────────────────────────────────────────────────────────────────────
244
+ # Failure and retry
245
+ # ─────────────────────────────────────────────────────────────────────────────
246
+
247
+
248
+ def test_a_failed_write_requeues_the_batch_and_the_next_flush_recovers_it(spool, monkeypatch):
249
+ """A transient filesystem error must delay events, never discard them."""
250
+ writer = EventWriter(flush_interval=3600)
251
+ for i in range(50):
252
+ writer.submit({"id": i})
253
+
254
+ real_replace = os.replace
255
+ monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError(28, "No space left on device")))
256
+
257
+ with pytest.raises(OSError):
258
+ writer.flush_now()
259
+ assert read_all(spool) == [], "a failed write must publish nothing"
260
+ assert len(writer._queue) == 50, "the batch was dropped instead of requeued"
261
+
262
+ monkeypatch.setattr(os, "replace", real_replace)
263
+ writer.flush_now()
264
+ assert sorted(e["id"] for e in read_all(spool)) == list(range(50))
265
+
266
+
267
+ def test_a_requeued_batch_keeps_its_original_order(spool, monkeypatch):
268
+ """Requeueing at the front must not reverse the batch."""
269
+ writer = EventWriter(flush_interval=3600)
270
+ for i in range(20):
271
+ writer.submit({"id": i})
272
+
273
+ monkeypatch.setattr(os, "replace", lambda *a, **k: (_ for _ in ()).throw(OSError("boom")))
274
+ with pytest.raises(OSError):
275
+ writer.flush_now()
276
+ monkeypatch.undo()
277
+
278
+ writer.flush_now()
279
+ assert [e["id"] for e in read_all(spool)] == list(range(20))
280
+
281
+
282
+ def test_the_flush_thread_survives_a_write_failure_and_retries(spool, monkeypatch):
283
+ """One bad flush must not permanently kill the background writer.
284
+
285
+ If the loop thread dies, every subsequent event in the process is buffered
286
+ forever and lost at exit — from the caller's side, indistinguishable from
287
+ working.
288
+ """
289
+ failures = {"n": 0}
290
+ real_replace = os.replace
291
+
292
+ def flaky(src, dst):
293
+ if failures["n"] < 2:
294
+ failures["n"] += 1
295
+ raise OSError("transient")
296
+ return real_replace(src, dst)
297
+
298
+ monkeypatch.setattr(os, "replace", flaky)
299
+ writer = EventWriter(flush_interval=0.01)
300
+ writer.submit({"id": "eventually"})
301
+
302
+ deadline = time.monotonic() + 5
303
+ while time.monotonic() < deadline and not read_all(spool):
304
+ time.sleep(0.02)
305
+
306
+ assert failures["n"] == 2, "the failure injection never fired"
307
+ assert [e["id"] for e in read_all(spool)] == ["eventually"]
308
+ assert writer._thread.is_alive()
309
+
310
+
311
+ @pytest.mark.skipif(os.name != "posix", reason="POSIX permission semantics")
312
+ @pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="root ignores mode bits")
313
+ def test_an_unwritable_spool_retains_events_rather_than_dropping_them(spool):
314
+ """A wrong-permissions spool is a config error, not a reason to lose data."""
315
+ writer = EventWriter(flush_interval=3600)
316
+ events_dir = spool / "events"
317
+ events_dir.mkdir(parents=True)
318
+ events_dir.chmod(0o500) # r-x: cannot create files
319
+ try:
320
+ writer.submit({"id": "kept"})
321
+ with pytest.raises(OSError):
322
+ writer.flush_now()
323
+ assert len(writer._queue) == 1
324
+ finally:
325
+ events_dir.chmod(0o700)
326
+
327
+ writer.flush_now()
328
+ assert [e["id"] for e in read_all(spool)] == ["kept"]
329
+
330
+
331
+ # ─────────────────────────────────────────────────────────────────────────────
332
+ # Process lifecycle
333
+ # ─────────────────────────────────────────────────────────────────────────────
334
+
335
+
336
+ def _run_script(body: str, spool_dir: Path) -> subprocess.CompletedProcess:
337
+ script = f"""
338
+ import failproofai_sdk
339
+ failproofai_sdk.configure(base_dir={str(spool_dir)!r}, flush_interval=3600)
340
+ {body}
341
+ """
342
+ return subprocess.run(
343
+ [sys.executable, "-c", script],
344
+ capture_output=True,
345
+ text=True,
346
+ timeout=60,
347
+ cwd=str(Path(__file__).resolve().parents[1]),
348
+ )
349
+
350
+
351
+ def test_events_are_flushed_at_normal_interpreter_exit(tmp_path):
352
+ """`flush_interval=3600` means atexit is the only thing that can publish."""
353
+ result = _run_script(
354
+ "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='at-exit')",
355
+ tmp_path,
356
+ )
357
+ assert result.returncode == 0, result.stderr
358
+ assert [e["goal"] for e in read_all(tmp_path)] == ["at-exit"]
359
+
360
+
361
+ def test_events_are_flushed_when_the_process_exits_via_sys_exit(tmp_path):
362
+ result = _run_script(
363
+ "import sys\n"
364
+ "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='sys-exit')\n"
365
+ "sys.exit(3)",
366
+ tmp_path,
367
+ )
368
+ assert result.returncode == 3
369
+ assert [e["goal"] for e in read_all(tmp_path)] == ["sys-exit"]
370
+
371
+
372
+ def test_events_are_flushed_when_the_process_dies_of_an_uncaught_exception(tmp_path):
373
+ """The run that crashed is the run whose telemetry matters most."""
374
+ result = _run_script(
375
+ "failproofai_sdk.event.error(session_id='s', agent_id='a', "
376
+ "error_type='RuntimeError', message='boom')\n"
377
+ "raise RuntimeError('boom')",
378
+ tmp_path,
379
+ )
380
+ assert result.returncode == 1
381
+ assert [e["type"] for e in read_all(tmp_path)] == ["error"]
382
+
383
+
384
+ def test_os_exit_skips_the_flush_and_that_is_documented_not_fixed(tmp_path):
385
+ """`os._exit` bypasses atexit by definition. Pinned so nobody assumes otherwise.
386
+
387
+ There is no way to make this safe from inside the SDK — the point of
388
+ `os._exit` is to skip cleanup. The honest answer is a documented loss window
389
+ and a `flush_now()` for callers who use it, not a fix that cannot exist.
390
+ """
391
+ result = _run_script(
392
+ "import os\n"
393
+ "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='lost')\n"
394
+ "os._exit(0)",
395
+ tmp_path,
396
+ )
397
+ assert result.returncode == 0
398
+ assert read_all(tmp_path) == []
399
+
400
+
401
+ def test_flush_now_makes_os_exit_safe(tmp_path):
402
+ """The documented escape hatch for the case above."""
403
+ result = _run_script(
404
+ "import os\n"
405
+ "failproofai_sdk.event.agent_start(session_id='s', agent_id='a', goal='kept')\n"
406
+ "failproofai_sdk._writer.flush_now()\n"
407
+ "os._exit(0)",
408
+ tmp_path,
409
+ )
410
+ assert result.returncode == 0
411
+ assert [e["goal"] for e in read_all(tmp_path)] == ["kept"]
412
+
413
+
414
+ @pytest.mark.skipif(not hasattr(os, "fork"), reason="requires fork")
415
+ # The DeprecationWarning about fork() in a multi-threaded process is the
416
+ # hazard under test, not a problem with the test.
417
+ @pytest.mark.filterwarnings("ignore:.*fork.*:DeprecationWarning")
418
+ def test_a_forked_child_can_still_emit_and_publish(spool):
419
+ """The flush thread does not survive fork; the child must not hang or lose.
420
+
421
+ A child that inherits a queue with no thread to drain it, and then blocks
422
+ forever at exit, turns telemetry into a liveness bug in the host process.
423
+ """
424
+ writer = EventWriter(flush_interval=3600)
425
+ namespace = EventNamespace(writer)
426
+ namespace.agent_start(session_id="parent", agent_id="a", goal="before-fork")
427
+
428
+ pid = os.fork()
429
+ if pid == 0: # child
430
+ try:
431
+ child_writer = EventWriter(flush_interval=3600)
432
+ EventNamespace(child_writer).agent_start(
433
+ session_id="child", agent_id="a", goal="in-child"
434
+ )
435
+ child_writer.flush_now()
436
+ os._exit(0)
437
+ except BaseException:
438
+ os._exit(70)
439
+
440
+ _, status = os.waitpid(pid, 0)
441
+ assert os.WIFEXITED(status), "child did not exit cleanly"
442
+ assert os.WEXITSTATUS(status) == 0, f"child exited {os.WEXITSTATUS(status)}"
443
+
444
+ writer.flush_now()
445
+ goals = {e["goal"] for e in read_all(spool)}
446
+ assert goals == {"before-fork", "in-child"}
447
+
448
+
449
+ # ─────────────────────────────────────────────────────────────────────────────
450
+ # Correlation state
451
+ # ─────────────────────────────────────────────────────────────────────────────
452
+
453
+
454
+ def test_pending_map_is_capped_and_evicts_oldest_first():
455
+ """An agent that never closes its tool calls must not exhaust memory."""
456
+ namespace = EventNamespace(_NullWriter())
457
+ for i in range(_PENDING_CAP + 100):
458
+ namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id=f"c{i}")
459
+
460
+ assert len(namespace._pending) == _PENDING_CAP
461
+ assert _tool_key("s", "c0") not in namespace._pending, "eviction is not FIFO"
462
+ assert _tool_key("s", f"c{_PENDING_CAP + 99}") in namespace._pending
463
+
464
+
465
+ def test_an_evicted_pair_completes_without_duration_instead_of_raising():
466
+ """Losing a duration is acceptable. Raising inside the caller's agent is not."""
467
+ writer = _NullWriter()
468
+ namespace = EventNamespace(writer)
469
+ namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id="evicted")
470
+ for i in range(_PENDING_CAP):
471
+ namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id=f"c{i}")
472
+
473
+ writer.entries.clear()
474
+ namespace.tool_result(session_id="s", agent_id="a", tool_name="t", tool_call_id="evicted")
475
+
476
+ assert "duration_ms" not in writer.entries[0]
477
+
478
+
479
+ def test_a_tool_and_a_hook_sharing_an_id_do_not_cross_correlate():
480
+ """Regression: they used to share one flat keyspace in `_pending`.
481
+
482
+ An id collision between a tool call and a hook is not exotic — both are
483
+ routinely the harness's own step id. When the keys were bare, the
484
+ `hook_completed` consumed the `tool_use` timestamp and reported the interval
485
+ between two unrelated events, and the real `tool_result` that followed got no
486
+ duration at all. Two plausible numbers, no error, nothing downstream able to
487
+ tell.
488
+ """
489
+ writer = _NullWriter()
490
+ namespace = EventNamespace(writer)
491
+
492
+ namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id="shared")
493
+
494
+ # The hook never started, so its completion must not borrow the tool's start.
495
+ writer.entries.clear()
496
+ namespace.hook_completed(session_id="s", agent_id="a", hook_name="h", hook_id="shared")
497
+ assert "duration_ms" not in writer.entries[0], (
498
+ "hook_completed consumed the tool_use timestamp — the keyspaces are flat again"
499
+ )
500
+
501
+ # And the tool's own pairing is untouched, so its result still gets a duration.
502
+ writer.entries.clear()
503
+ namespace.tool_result(session_id="s", agent_id="a", tool_name="t", tool_call_id="shared")
504
+ assert "duration_ms" in writer.entries[0], (
505
+ "the tool's pending entry was consumed by the unrelated hook"
506
+ )
507
+
508
+
509
+ def test_every_pairing_is_namespaced_by_what_it_pairs():
510
+ """The keyspaces are separate in both directions, for all four pair types."""
511
+ writer = _NullWriter()
512
+ namespace = EventNamespace(writer)
513
+ ids = dict(session_id="s", agent_id="a")
514
+
515
+ namespace.tool_use(**ids, tool_name="t", tool_call_id="x")
516
+ namespace.hook_triggered(**ids, hook_name="h", hook_id="x")
517
+ namespace.human_wait(**ids, input_id="x")
518
+ namespace.agent_pause(**ids, pause_id="x")
519
+
520
+ # Four starts, one shared id, four distinct pending keys.
521
+ assert len(namespace._pending) == 4, sorted(namespace._pending)
522
+
523
+ # Each end event finds its own start and no other.
524
+ for call in (
525
+ lambda: namespace.tool_result(**ids, tool_name="t", tool_call_id="x"),
526
+ lambda: namespace.hook_completed(**ids, hook_name="h", hook_id="x"),
527
+ lambda: namespace.human_input(**ids, input_id="x"),
528
+ lambda: namespace.agent_resume(**ids, pause_id="x"),
529
+ ):
530
+ writer.entries.clear()
531
+ call()
532
+ assert "duration_ms" in writer.entries[0], writer.entries[0]["type"]
533
+
534
+ assert namespace._pending == {}, "an end event left its start behind"
535
+
536
+
537
+ def test_human_and_pause_pairs_are_namespaced_by_session_and_agent():
538
+ """The same input id in two sessions must not cross-correlate."""
539
+ writer = _NullWriter()
540
+ namespace = EventNamespace(writer)
541
+
542
+ namespace.human_wait(session_id="s1", agent_id="a", input_id="same")
543
+ writer.entries.clear()
544
+ namespace.human_input(session_id="s2", agent_id="a", input_id="same")
545
+ assert "duration_ms" not in writer.entries[0]
546
+
547
+ writer.entries.clear()
548
+ namespace.human_input(session_id="s1", agent_id="a", input_id="same")
549
+ assert "duration_ms" in writer.entries[0]
550
+
551
+
552
+ class _NullWriter:
553
+ def __init__(self):
554
+ self.entries = []
555
+
556
+ def submit(self, entry):
557
+ self.entries.append(entry)
558
+
559
+
560
+ # ─────────────────────────────────────────────────────────────────────────────
561
+ # Configuration
562
+ # ─────────────────────────────────────────────────────────────────────────────
563
+
564
+
565
+ @pytest.mark.parametrize(
566
+ "bad", [-1, -0.001, 0, 0.0, float("nan"), float("inf"), float("-inf")]
567
+ )
568
+ def test_an_unusable_flush_interval_is_rejected_at_the_boundary(bad):
569
+ """`time.sleep` runs OUTSIDE the loop's try, so a bad value kills the thread.
570
+
571
+ And a dead writer thread is the worst state this class has: `submit()` keeps
572
+ accepting events, the queue keeps growing, nothing is ever written, and the
573
+ caller learns none of it. Negative, NaN and infinite intervals all raise from
574
+ `sleep`; zero does not raise but busy-loops, pinning a core and rewriting the
575
+ spool as fast as the disk allows. All of them are refused up front instead.
576
+ """
577
+ with pytest.raises(ValueError, match="finite number greater than zero"):
578
+ EventWriter(flush_interval=bad)
579
+
580
+
581
+ @pytest.mark.parametrize("bad", [-1, 0, float("nan"), float("inf")])
582
+ def test_set_flush_interval_rejects_without_changing_the_live_interval(bad):
583
+ """A refused value must leave the writer on the interval it already had."""
584
+ writer = EventWriter(flush_interval=3600)
585
+ with pytest.raises(ValueError):
586
+ writer.set_flush_interval(bad)
587
+ assert writer._flush_interval == 3600
588
+ assert writer._thread.is_alive()
589
+
590
+
591
+ @pytest.mark.parametrize("bad", [-1, 0, float("nan"), float("inf")])
592
+ def test_configure_rejects_a_bad_interval_before_applying_anything(bad, spool, tmp_path):
593
+ """Validation comes first, so a rejected call is not a half-applied one."""
594
+ import failproofai_sdk
595
+
596
+ failproofai_sdk.configure(base_dir=spool, flush_interval=3600)
597
+
598
+ with pytest.raises(ValueError, match="finite number greater than zero"):
599
+ failproofai_sdk.configure(base_dir=tmp_path / "elsewhere", flush_interval=bad)
600
+
601
+ # base_dir is set BEFORE the interval in configure(), so validating inside
602
+ # set_flush_interval alone would have left this pointing at "elsewhere".
603
+ assert _resolver.get_base_dir() == spool
604
+ assert failproofai_sdk._writer._flush_interval == 3600
605
+
606
+
607
+ def test_a_valid_interval_still_applies():
608
+ writer = EventWriter(flush_interval=3600)
609
+ writer.set_flush_interval(0.25)
610
+ assert writer._flush_interval == 0.25
611
+ # Ints are accepted and normalised, so `_flush_loop` always sleeps on a float.
612
+ writer.set_flush_interval(2)
613
+ assert writer._flush_interval == 2.0
614
+ assert isinstance(writer._flush_interval, float)
615
+
616
+
617
+ def test_configure_can_be_called_after_events_have_already_been_emitted(spool, tmp_path):
618
+ """Late configuration must redirect the spool, not strand what is buffered."""
619
+ import failproofai_sdk
620
+
621
+ failproofai_sdk.configure(base_dir=spool, flush_interval=3600)
622
+ failproofai_sdk.event.agent_start(session_id="s", agent_id="a", goal="before")
623
+
624
+ later = tmp_path / "later"
625
+ failproofai_sdk.configure(base_dir=later, flush_interval=3600)
626
+ failproofai_sdk.event.agent_start(session_id="s", agent_id="a", goal="after")
627
+ failproofai_sdk._writer.flush_now()
628
+
629
+ # Both were still in the queue, so both land under the newest base dir.
630
+ assert {e["goal"] for e in read_all(later)} == {"before", "after"}
631
+ assert read_all(spool) == []
632
+
633
+
634
+ def test_configure_is_safe_to_call_from_several_threads(spool, tmp_path):
635
+ """Racing configure() calls must not corrupt state or lose queued events."""
636
+ import failproofai_sdk
637
+
638
+ # This is the one test that asserts an EXACT count on the process-wide
639
+ # singleton, so it must not inherit anything another test left queued. Drain
640
+ # to a throwaway directory first — otherwise the assertion below depends on
641
+ # test execution order, and an order-dependent test fails for a reason that
642
+ # has nothing to do with what it checks.
643
+ failproofai_sdk.configure(base_dir=tmp_path / "drain", flush_interval=3600)
644
+ failproofai_sdk._writer.flush_now()
645
+
646
+ failproofai_sdk.configure(base_dir=spool, flush_interval=3600)
647
+ barrier = threading.Barrier(8)
648
+ errors: list[BaseException] = []
649
+
650
+ def churn(n: int):
651
+ try:
652
+ barrier.wait()
653
+ for _ in range(50):
654
+ failproofai_sdk.configure(base_dir=spool, flush_interval=3600)
655
+ failproofai_sdk.event.agent_start(session_id=f"s{n}", agent_id="a")
656
+ except BaseException as exc: # pragma: no cover - failure path
657
+ errors.append(exc)
658
+
659
+ workers = [threading.Thread(target=churn, args=(i,)) for i in range(8)]
660
+ for worker in workers:
661
+ worker.start()
662
+ for worker in workers:
663
+ worker.join()
664
+ failproofai_sdk._writer.flush_now()
665
+
666
+ assert not errors, errors
667
+ assert len(read_all(spool)) == 8 * 50
668
+
669
+
670
+ # ─────────────────────────────────────────────────────────────────────────────
671
+ # fork() — the flush thread does not survive it
672
+ # ─────────────────────────────────────────────────────────────────────────────
673
+
674
+
675
+ def test_a_forked_child_publishes_through_its_own_restarted_thread(tmp_path):
676
+ """The realistic shape: the child reuses the INHERITED singleton.
677
+
678
+ The sibling test above builds a fresh `EventWriter` in the child and calls
679
+ `flush_now()` by hand, which proves the child does not hang and nothing else.
680
+ Nobody writes an agent that way. They `import failproofai_sdk` once, and
681
+ whatever forks — gunicorn, celery, `multiprocessing` on Linux — inherits that
682
+ module-level writer.
683
+
684
+ Threads do not cross `fork()`, so before `os.register_at_fork` the child got
685
+ a queue and no drainer: `submit()` kept accepting, nothing was ever
686
+ published, and the events appeared only if the child happened to exit through
687
+ a normal interpreter shutdown. A prefork worker is killed instead, so all of
688
+ the telemetry — the workers are where the work happens — silently vanished.
689
+
690
+ The child here ends with `os._exit`, which skips atexit by definition. If the
691
+ event still lands, a background thread wrote it, which is the whole claim.
692
+ """
693
+ result = _run_script(
694
+ "import os, time\n"
695
+ "pid = os.fork()\n"
696
+ "if pid == 0:\n"
697
+ " failproofai_sdk.configure(base_dir=%r, flush_interval=0.05)\n"
698
+ " failproofai_sdk.event.agent_start(session_id='child', agent_id='a', goal='in-child')\n"
699
+ " time.sleep(1.5)\n"
700
+ " os._exit(0)\n"
701
+ "os.waitpid(pid, 0)\n" % str(tmp_path),
702
+ tmp_path,
703
+ )
704
+ assert result.returncode == 0, result.stderr
705
+ goals = [e["goal"] for e in read_all(tmp_path)]
706
+ assert goals == ["in-child"], (
707
+ f"the child's flush thread never restarted (got {goals!r}); "
708
+ "os._exit skips atexit, so only a live thread could have written this"
709
+ )
710
+
711
+
712
+ def test_a_fork_does_not_duplicate_the_events_the_parent_had_queued(tmp_path):
713
+ """The child inherits the parent's undrained queue; only one of them owns it.
714
+
715
+ Publishing from both produced a byte-identical duplicate of every event
716
+ buffered at the instant of the fork. Ingest would most likely collapse those
717
+ — its dedup key hashes the canonical payload — but relying on the server to
718
+ tidy up after the SDK is not a property worth shipping.
719
+ """
720
+ result = _run_script(
721
+ "import os\n"
722
+ "failproofai_sdk.event.agent_start(session_id='p', agent_id='a', goal='queued-before-fork')\n"
723
+ "pid = os.fork()\n"
724
+ "if pid == 0:\n"
725
+ " failproofai_sdk.event.agent_start(session_id='c', agent_id='a', goal='child-only')\n"
726
+ " failproofai_sdk._writer.flush_now()\n"
727
+ " os._exit(0)\n"
728
+ "os.waitpid(pid, 0)\n"
729
+ "failproofai_sdk._writer.flush_now()\n",
730
+ tmp_path,
731
+ )
732
+ assert result.returncode == 0, result.stderr
733
+ goals = sorted(e["goal"] for e in read_all(tmp_path))
734
+ assert goals == ["child-only", "queued-before-fork"], (
735
+ f"expected each event exactly once, got {goals!r}"
736
+ )
737
+
738
+
739
+ def test_the_fork_handler_prunes_writers_that_have_been_collected():
740
+ """`_live_writers` holds weak references, and drops the dead ones.
741
+
742
+ Note what this does NOT claim. A writer is not collectable while it exists:
743
+ its flush thread targets `self._flush_loop`, and a running thread holds its
744
+ target, so in practice every writer outlives every collection. The weakness
745
+ matters because a dead referent must be SKIPPED rather than restarted, and
746
+ because it keeps this list from being a second, independent reason a writer
747
+ can never be freed — which is what `atexit.register(self._flush)` was.
748
+
749
+ So the dead entry is injected rather than produced, because producing one
750
+ means defeating the thread that keeps it alive.
751
+ """
752
+ import gc
753
+ import sys
754
+ import weakref
755
+
756
+ writer_module = sys.modules["failproofai_sdk._writer"]
757
+
758
+ class _Collectable:
759
+ def _reinit_after_fork(self): # pragma: no cover - must never be reached
760
+ raise AssertionError("a collected writer was restarted after fork")
761
+
762
+ victim = _Collectable()
763
+ dead = weakref.ref(victim)
764
+ writer_module._live_writers.append(dead)
765
+ del victim
766
+ gc.collect()
767
+ assert dead() is None, "the test's own victim outlived it"
768
+
769
+ writer_module._reinit_all_after_fork()
770
+ assert dead not in writer_module._live_writers, "a dead weakref was left registered"
771
+
772
+
773
+ # ─────────────────────────────────────────────────────────────────────────────
774
+ # The queue is bounded
775
+ # ─────────────────────────────────────────────────────────────────────────────
776
+
777
+
778
+ def test_the_queue_is_capped_and_discards_oldest_first(spool, caplog):
779
+ """`submit` cannot block or raise, so the only other option is to bound it.
780
+
781
+ Unbounded, any condition that stops the spool draining turns a telemetry
782
+ outage into an OOM kill of the host agent — the SDK taking down the very
783
+ process it exists to observe.
784
+ """
785
+ writer = EventWriter(flush_interval=3600)
786
+ with caplog.at_level(logging.WARNING, logger="failproofai_sdk._writer"):
787
+ for i in range(_QUEUE_CAP + 250):
788
+ writer.submit({"type": "e", "n": i})
789
+
790
+ assert len(writer._queue) == _QUEUE_CAP, "the queue is unbounded"
791
+ ns = [e["n"] for e in writer._queue]
792
+ assert ns[0] == 250, "eviction is not oldest-first"
793
+ assert ns[-1] == _QUEUE_CAP + 249, "the newest event was dropped instead of the oldest"
794
+ assert any("queue is full" in r.getMessage() for r in caplog.records), (
795
+ "the cap discarded events without saying so"
796
+ )
797
+
798
+
799
+ def test_the_full_queue_warning_does_not_fire_on_every_single_drop(spool, caplog):
800
+ """A stuck spool must not become the thing that fills the disk."""
801
+ writer = EventWriter(flush_interval=3600)
802
+ with caplog.at_level(logging.WARNING, logger="failproofai_sdk._writer"):
803
+ for i in range(_QUEUE_CAP + 2500):
804
+ writer.submit({"type": "e", "n": i})
805
+
806
+ warnings = [r for r in caplog.records if "queue is full" in r.getMessage()]
807
+ assert 1 <= len(warnings) <= 5, f"{len(warnings)} warnings for 2500 drops"
808
+
809
+
810
+ # ─────────────────────────────────────────────────────────────────────────────
811
+ # The flush interval, and the shutdown race that changing it exposed
812
+ # ─────────────────────────────────────────────────────────────────────────────
813
+
814
+
815
+ def test_a_new_flush_interval_applies_to_the_cycle_already_waiting(spool):
816
+ """Otherwise `configure()` is ignored for one full cycle of the OLD interval.
817
+
818
+ The thread starts at import, so its first wait is always the 500 ms default —
819
+ which a caller asking for 50 ms has no way to know about, and which is long
820
+ enough for a fork or an exit to land inside it.
821
+ """
822
+ writer = EventWriter(flush_interval=3600)
823
+ writer.submit({"type": "e", "n": 1})
824
+
825
+ writer.set_flush_interval(0.05)
826
+ deadline = time.monotonic() + 5
827
+ while time.monotonic() < deadline and not list((spool / "events").glob("*.jsonl")):
828
+ time.sleep(0.02)
829
+
830
+ assert [e["n"] for e in read_all(spool)] == [1], (
831
+ "the writer sat on the hour-long interval it was configured away from"
832
+ )
833
+
834
+
835
+ def test_a_flush_racing_interpreter_shutdown_does_not_lose_the_batch(tmp_path):
836
+ """A batch is drained from the queue BEFORE it is written.
837
+
838
+ So a flush thread stopped part-way through — which is what happens to a
839
+ daemon thread once the interpreter starts finalising — takes those events
840
+ with it, leaving at most a stray `.tmp`. The atexit flush has to WAIT on an
841
+ in-flight batch rather than find an empty queue and return, which is why the
842
+ emptiness check lives inside `_flush_lock`.
843
+
844
+ Waking the thread on `set_flush_interval` is what made this likely enough to
845
+ reproduce: it puts a flush and the main thread's exit path in the same
846
+ moment, every run.
847
+ """
848
+ for attempt in range(8):
849
+ target = tmp_path / f"run-{attempt}"
850
+ result = _run_script(
851
+ "failproofai_sdk.event.error(session_id='s', agent_id='a', "
852
+ "error_type='RuntimeError', message='boom')\n"
853
+ "raise RuntimeError('boom')",
854
+ target,
855
+ )
856
+ assert result.returncode == 1
857
+ assert [e["type"] for e in read_all(target)] == ["error"], (
858
+ f"attempt {attempt}: the crashing run's telemetry was lost to the shutdown race"
859
+ )
860
+ assert list((target / "events").glob("*.tmp")) == [], (
861
+ f"attempt {attempt}: a batch was abandoned part-written"
862
+ )
863
+
864
+
865
+ # ─────────────────────────────────────────────────────────────────────────────
866
+ # Correlation state — session and agent scoping
867
+ # ─────────────────────────────────────────────────────────────────────────────
868
+
869
+
870
+ def test_tool_pairs_are_namespaced_by_session_and_agent():
871
+ """Two sessions in one process must not share a tool_call_id's timestamp.
872
+
873
+ `_pending` lives on a single process-wide `EventNamespace`, and a supervisor
874
+ running agents concurrently is the ordinary multi-agent shape — so a step id
875
+ that repeats across sessions (`step-1`, and both ids are frequently the
876
+ harness's own step counter) collided. Session B's start overwrote A's, A's
877
+ result reported B's interval, and B's result reported nothing at all.
878
+ """
879
+ writer = _NullWriter()
880
+ namespace = EventNamespace(writer)
881
+
882
+ namespace.tool_use(session_id="A", agent_id="a", tool_name="t", tool_call_id="step-1")
883
+ namespace.tool_use(session_id="B", agent_id="b", tool_name="t", tool_call_id="step-1")
884
+ assert len(namespace._pending) == 2, "B's start overwrote A's"
885
+
886
+ writer.entries.clear()
887
+ namespace.tool_result(session_id="A", agent_id="a", tool_name="t", tool_call_id="step-1")
888
+ assert "duration_ms" in writer.entries[0], "A's result could not find A's own start"
889
+
890
+ writer.entries.clear()
891
+ namespace.tool_result(session_id="B", agent_id="b", tool_name="t", tool_call_id="step-1")
892
+ assert "duration_ms" in writer.entries[0], "B's start had been consumed by A's result"
893
+
894
+
895
+ def test_hook_pairs_are_namespaced_by_session_and_agent():
896
+ """Same lookup pattern, same bug, same fix."""
897
+ writer = _NullWriter()
898
+ namespace = EventNamespace(writer)
899
+
900
+ namespace.hook_triggered(session_id="A", agent_id="a", hook_name="h", hook_id="step-1")
901
+ namespace.hook_triggered(session_id="B", agent_id="b", hook_name="h", hook_id="step-1")
902
+ assert len(namespace._pending) == 2
903
+
904
+ for session, agent in (("A", "a"), ("B", "b")):
905
+ writer.entries.clear()
906
+ namespace.hook_completed(session_id=session, agent_id=agent, hook_name="h", hook_id="step-1")
907
+ assert "duration_ms" in writer.entries[0], f"{session} lost its own start"
908
+
909
+
910
+ def test_a_pair_opened_and_closed_under_different_agents_still_pairs():
911
+ """The key is deliberately NOT agent-scoped, and this is why.
912
+
913
+ This test asserted the opposite when the keys were first namespaced: that a
914
+ tool id repeated under two agents in one session produced two independent
915
+ pairs. That looked like tightening; it was over-tightening. Once a framework
916
+ runs tools inside sub-agents — LangGraph and CrewAI both do — a `tool_use`
917
+ opened under `planner` and closed under `worker` is the ORDINARY case, and an
918
+ agent-scoped key makes it miss silently, dropping `duration_ms` for exactly
919
+ the nested runs that most need it.
920
+
921
+ The rule that survives both: key on what makes the id unique (kind, session)
922
+ and never on what can legitimately change between the two events (the agent).
923
+ """
924
+ writer = _NullWriter()
925
+ namespace = EventNamespace(writer)
926
+
927
+ namespace.tool_use(session_id="S", agent_id="planner", tool_name="t", tool_call_id="x")
928
+ writer.entries.clear()
929
+ namespace.tool_result(session_id="S", agent_id="worker", tool_name="t", tool_call_id="x")
930
+
931
+ assert "duration_ms" in writer.entries[0], (
932
+ "a tool handed from planner to worker lost its duration — the key is "
933
+ "agent-scoped again"
934
+ )
935
+ assert namespace._pending == {}, "the pending entry was left behind"
936
+
937
+
938
+ def test_a_hook_opened_and_closed_under_different_agents_still_pairs():
939
+ """Same rule, same reason, for the other adapter-driven pair type."""
940
+ writer = _NullWriter()
941
+ namespace = EventNamespace(writer)
942
+
943
+ namespace.hook_triggered(session_id="S", agent_id="planner", hook_name="h", hook_id="x")
944
+ writer.entries.clear()
945
+ namespace.hook_completed(session_id="S", agent_id="worker", hook_name="h", hook_id="x")
946
+ assert "duration_ms" in writer.entries[0]
947
+
948
+
949
+ def test_a_result_from_an_unrelated_session_gets_no_duration_at_all():
950
+ """The failure the scoping prevents: a duration measured across sessions.
951
+
952
+ No duration is the correct answer here. A plausible number would be worse
953
+ than an absent one, because nothing downstream can tell it is wrong.
954
+ """
955
+ writer = _NullWriter()
956
+ namespace = EventNamespace(writer)
957
+
958
+ namespace.tool_use(session_id="A", agent_id="a", tool_name="t", tool_call_id="shared")
959
+ writer.entries.clear()
960
+ namespace.tool_result(session_id="B", agent_id="b", tool_name="t", tool_call_id="shared")
961
+ assert "duration_ms" not in writer.entries[0], "a duration leaked across sessions"
962
+
963
+
964
+ # ─────────────────────────────────────────────────────────────────────────────
965
+ # Durability — atomic is not the same as committed
966
+ # ─────────────────────────────────────────────────────────────────────────────
967
+
968
+
969
+ def _fs_trace(monkeypatch):
970
+ """Record the order of fsync/replace calls made while writing a batch."""
971
+ import sys
972
+
973
+ writer_module = sys.modules["failproofai_sdk._writer"]
974
+ order = []
975
+ real_fsync, real_replace = os.fsync, writer_module.os.replace
976
+
977
+ def fsync_spy(fd):
978
+ try:
979
+ st = os.fstat(fd)
980
+ kind = "dir" if stat.S_ISDIR(st.st_mode) else "file"
981
+ except OSError: # pragma: no cover
982
+ kind = "?"
983
+ order.append(f"fsync:{kind}")
984
+ return real_fsync(fd)
985
+
986
+ def replace_spy(src, dst):
987
+ order.append("replace")
988
+ return real_replace(src, dst)
989
+
990
+ monkeypatch.setattr(writer_module.os, "fsync", fsync_spy)
991
+ monkeypatch.setattr(writer_module.os, "replace", replace_spy)
992
+ return order
993
+
994
+
995
+ def test_the_batch_is_fsynced_before_the_rename_and_the_dir_after(spool, monkeypatch):
996
+ """`os.replace` is atomic to READERS; it commits nothing to the platter.
997
+
998
+ Without the content fsync, a power loss can leave a correctly-named,
999
+ zero-length `.jsonl`. The collector reads it, POSTs an empty body, gets a
1000
+ 200 and then DELETES the file (`remove_file` in
1001
+ `crates/fpai-collect/src/uploader.rs`) — permanent, silent loss. Without the
1002
+ directory fsync the reverse survives: the bytes are on disk but the rename
1003
+ is not, so the batch sits under a `.tmp` name the watcher ignores by design.
1004
+
1005
+ This repo's own Rust spool writer has called `sync_all()` here from the
1006
+ start (`crates/fpai-collect/src/spool.rs`); the Python writer publishing
1007
+ into the same directories was the odd one out.
1008
+ """
1009
+ order = _fs_trace(monkeypatch)
1010
+ writer = EventWriter(flush_interval=3600)
1011
+ writer.submit({"type": "e", "n": 1})
1012
+ writer.flush_now()
1013
+
1014
+ assert "replace" in order, "no rename happened"
1015
+ assert order.index("fsync:file") < order.index("replace"), (
1016
+ f"content was renamed before it was committed: {order}"
1017
+ )
1018
+ assert any(o == "fsync:dir" for o in order[order.index("replace"):]), (
1019
+ f"the rename itself was never committed: {order}"
1020
+ )
1021
+
1022
+
1023
+ def test_a_published_batch_is_complete_on_disk_not_merely_present(spool):
1024
+ """The property the fsync exists to buy, asserted at the file level."""
1025
+ writer = EventWriter(flush_interval=3600)
1026
+ namespace = EventNamespace(writer)
1027
+ for i in range(200):
1028
+ namespace.agent_start(session_id="s", agent_id="a", goal=f"g{i}")
1029
+ writer.flush_now()
1030
+
1031
+ published = read_all(spool)
1032
+ assert len(published) == 200
1033
+ for path in (spool / "events").glob("*.jsonl"):
1034
+ text = path.read_text(encoding="utf-8")
1035
+ assert text.endswith("\n"), f"{path.name} is truncated mid-line"
1036
+ assert all(json.loads(line) for line in text.splitlines())
1037
+
1038
+
1039
+ def test_a_crash_between_write_and_rename_leaves_no_half_batch(spool, monkeypatch):
1040
+ """Fault injection: the rename never happens.
1041
+
1042
+ The watcher must see nothing — a `.tmp` is not a `.jsonl` — and the events
1043
+ must go back on the queue rather than being counted as delivered.
1044
+ """
1045
+ import sys
1046
+
1047
+ writer_module = sys.modules["failproofai_sdk._writer"]
1048
+ monkeypatch.setattr(
1049
+ writer_module.os, "replace",
1050
+ lambda *a: (_ for _ in ()).throw(OSError(5, "simulated crash before rename")),
1051
+ )
1052
+ writer = EventWriter(flush_interval=3600)
1053
+ writer.submit({"type": "e", "n": 1})
1054
+
1055
+ with pytest.raises(OSError):
1056
+ writer.flush_now()
1057
+
1058
+ assert list((spool / "events").glob("*.jsonl")) == [], "a batch was published anyway"
1059
+ assert len(writer._queue) == 1, "the events were dropped rather than retried"
1060
+
1061
+
1062
+ # ─────────────────────────────────────────────────────────────────────────────
1063
+ # The correlation map is mutated from the caller's own threads
1064
+ # ─────────────────────────────────────────────────────────────────────────────
1065
+
1066
+
1067
+ def test_evicting_at_the_cap_never_raises_into_the_callers_thread():
1068
+ """Regression: `len()` -> `next(iter())` -> `del` is a read-modify-write.
1069
+
1070
+ Nothing serialised the three, so two threads arriving at a full `_pending`
1071
+ picked the SAME victim and the second `del` raised KeyError — straight out
1072
+ of `event.tool_use()`, in the caller's agent loop. Measured at 24 crashes
1073
+ per 30_000 calls across 10 threads before the fix.
1074
+
1075
+ It only fires once `_pending` is full, which is exactly the long-running
1076
+ multi-agent process the cap exists for, so "rare" here means "only in
1077
+ production".
1078
+ """
1079
+ namespace = EventNamespace(_NullWriter())
1080
+ errors: list[BaseException] = []
1081
+
1082
+ def churn(worker: int):
1083
+ try:
1084
+ for i in range(3000):
1085
+ namespace.tool_use(
1086
+ session_id="s", agent_id="a", tool_name="t", tool_call_id=f"{worker}-{i}"
1087
+ )
1088
+ if i % 7 == 0:
1089
+ namespace.tool_result(
1090
+ session_id="s", agent_id="a", tool_name="t", tool_call_id=f"{worker}-{i}"
1091
+ )
1092
+ except BaseException as exc: # pragma: no cover - failure path
1093
+ errors.append(exc)
1094
+
1095
+ threads = [threading.Thread(target=churn, args=(w,)) for w in range(10)]
1096
+ for t in threads:
1097
+ t.start()
1098
+ for t in threads:
1099
+ t.join()
1100
+
1101
+ assert not errors, f"{len(errors)} exception(s) reached the caller: {errors[:3]}"
1102
+ assert len(namespace._pending) <= _PENDING_CAP * 2, (
1103
+ f"the cap stopped bounding anything: {len(namespace._pending)}"
1104
+ )
1105
+
1106
+
1107
+ def test_the_cap_still_bounds_the_map_after_the_concurrency_fix():
1108
+ """Tolerant eviction must not become no eviction."""
1109
+ namespace = EventNamespace(_NullWriter())
1110
+ for i in range(_PENDING_CAP + 500):
1111
+ namespace.tool_use(session_id="s", agent_id="a", tool_name="t", tool_call_id=f"c{i}")
1112
+ assert len(namespace._pending) == _PENDING_CAP
1113
+
1114
+
1115
+ def test_a_persistent_write_fault_does_not_strand_a_tmp_file_per_cycle(spool, monkeypatch):
1116
+ """Each flush picks a fresh stem, so a stuck rename leaked one file a cycle.
1117
+
1118
+ At the default 500 ms interval that is ~170_000 files a day, on the very
1119
+ disk that is already the problem — and the watcher ignores them by
1120
+ extension, so nothing else would ever notice or collect them.
1121
+
1122
+ The batch itself must survive: `_flush` returns the entries to the queue and
1123
+ the next cycle rewrites them under a new name.
1124
+ """
1125
+ import sys
1126
+
1127
+ writer_module = sys.modules["failproofai_sdk._writer"]
1128
+ real_replace = writer_module.os.replace
1129
+ monkeypatch.setattr(
1130
+ writer_module.os, "replace",
1131
+ lambda *a: (_ for _ in ()).throw(OSError(28, "No space left on device")),
1132
+ )
1133
+
1134
+ writer = EventWriter(flush_interval=3600)
1135
+ for i in range(50):
1136
+ writer.submit({"type": "e", "n": i})
1137
+ with pytest.raises(OSError):
1138
+ writer.flush_now()
1139
+
1140
+ assert list((spool / "events").glob("*.tmp")) == [], "orphaned .tmp files accumulated"
1141
+ assert len(writer._queue) == 50, "events were lost to the failed writes"
1142
+
1143
+ monkeypatch.setattr(writer_module.os, "replace", real_replace)
1144
+ writer.flush_now()
1145
+ assert len(read_all(spool)) == 50, "the recovered batch is incomplete"
1146
+ assert list((spool / "events").glob("*.tmp")) == []
1147
+
1148
+
1149
+ # ─────────────────────────────────────────────────────────────────────────────
1150
+ # SIGTERM — the exit path the docs got wrong
1151
+ # ─────────────────────────────────────────────────────────────────────────────
1152
+
1153
+
1154
+ def _run_child(tmp_path: Path, body: str) -> subprocess.CompletedProcess:
1155
+ """Run `body` in a fresh interpreter spooling into `tmp_path`.
1156
+
1157
+ A subprocess rather than a fork: the point is CPython's *default* signal
1158
+ disposition in a process this test did not otherwise touch, and pytest's own
1159
+ handlers are inherited across a fork.
1160
+ """
1161
+ src = (
1162
+ "import os, signal, sys\n"
1163
+ "import failproofai_sdk as fp\n"
1164
+ "from failproofai_sdk import event\n"
1165
+ f"fp.configure(base_dir={str(tmp_path)!r}, flush_interval=3600.0)\n" + body
1166
+ )
1167
+ return subprocess.run(
1168
+ [sys.executable, "-c", src],
1169
+ capture_output=True,
1170
+ text=True,
1171
+ timeout=60,
1172
+ cwd=str(Path(__file__).resolve().parents[1]),
1173
+ )
1174
+
1175
+
1176
+ def test_sigterm_drops_the_queue_because_cpython_runs_no_atexit_for_it(tmp_path):
1177
+ """The claim this replaces said `atexit` *does* run on `SIGTERM`. It does not.
1178
+
1179
+ CPython installs no handler for `SIGTERM` — `signal.getsignal(SIGTERM)` is
1180
+ `SIG_DFL` — so the OS terminates the process where it stands and the atexit
1181
+ flush never runs. `SKILL.md` told readers the opposite, under a heading
1182
+ naming rolling deploys and `docker stop`, which is exactly the population
1183
+ that would have believed it and shipped nothing.
1184
+
1185
+ The long flush interval isolates the exit path: in real use the 0.5s default
1186
+ is what bounds the loss, and that bound is the whole mitigation.
1187
+ """
1188
+ proc = _run_child(
1189
+ tmp_path,
1190
+ "with fp.session('sigterm-bare'):\n"
1191
+ " for i in range(20):\n"
1192
+ " event.agent_start(agent_id='a', goal=str(i))\n"
1193
+ "os.kill(os.getpid(), signal.SIGTERM)\n",
1194
+ )
1195
+ assert proc.returncode == -15, proc.stderr
1196
+ assert read_all(tmp_path) == [], (
1197
+ "SIGTERM must be shown losing the queue; if this now passes events "
1198
+ "through, the SDK grew a handler and SKILL.md's recipe is obsolete"
1199
+ )
1200
+
1201
+
1202
+ def test_the_documented_sigterm_handler_saves_the_queue_and_closes_the_run(tmp_path):
1203
+ """The recipe SKILL.md now ships, executed rather than described.
1204
+
1205
+ `sys.exit` and not `os._exit`: it unwinds, so an open `agent()` scope emits
1206
+ its `agent_end` before the flush — the events most likely to be in flight at
1207
+ shutdown are exactly the ones that close a run.
1208
+ """
1209
+ proc = _run_child(
1210
+ tmp_path,
1211
+ "def _flush_and_exit(signum, frame):\n"
1212
+ " fp._writer.flush_now()\n"
1213
+ " sys.exit(128 + signum)\n"
1214
+ "signal.signal(signal.SIGTERM, _flush_and_exit)\n"
1215
+ "with fp.agent('worker', session_id='sigterm-handled', goal='survive'):\n"
1216
+ " for i in range(20):\n"
1217
+ " event.tool_use(tool_name='t', tool_call_id=str(i), input={'i': i})\n"
1218
+ " os.kill(os.getpid(), signal.SIGTERM)\n",
1219
+ )
1220
+ assert proc.returncode == 128 + 15, proc.stderr
1221
+ events = read_all(tmp_path)
1222
+ kinds = {e["type"] for e in events}
1223
+ assert len([e for e in events if e["type"] == "tool_use"]) == 20
1224
+ assert "agent_start" in kinds and "agent_end" in kinds
1225
+ # The interrupted run closes as failed, carrying the SystemExit — an evicted
1226
+ # run did not finish, and that is the thing an operator needs to see.
1227
+ end = next(e for e in events if e["type"] == "agent_end")
1228
+ assert end["outcome"] == "failed"
1229
+
1230
+
1231
+ def test_a_second_sigterm_during_the_handlers_flush_does_not_deadlock(tmp_path):
1232
+ """The shipped SIGTERM recipe, re-entered — which is how it is actually met.
1233
+
1234
+ Signal handlers run on the MAIN thread, interrupting whatever bytecode it was
1235
+ executing, so `flush_now()` called from a handler re-enters `_flush` whenever
1236
+ the main thread was already inside it. With a plain `threading.Lock` that is a
1237
+ permanent self-deadlock: the thread blocks acquiring a lock it already holds,
1238
+ and the process cannot be signalled out of it because every further SIGTERM
1239
+ re-enters the wedged handler. `docker stop` followed by a second `kill` — a
1240
+ supervisor, an impatient operator — reaches it in one step.
1241
+
1242
+ The child sends itself 200 SIGTERMs 1 ms apart while a large queue is
1243
+ draining. Before the fix this hung until the outer `timeout` killed it.
1244
+ """
1245
+ proc = _run_child(
1246
+ tmp_path,
1247
+ "import threading, time\n"
1248
+ "with fp.session('sigterm-reentrant'):\n"
1249
+ " for i in range(4000):\n"
1250
+ " event.tool_use(agent_id='a', tool_name='t', tool_call_id=str(i),\n"
1251
+ " input={'x': 'y' * 200})\n"
1252
+ "entries = []\n"
1253
+ "def _handler(signum, frame):\n"
1254
+ " entries.append(1)\n"
1255
+ " fp._writer.flush_now()\n"
1256
+ "signal.signal(signal.SIGTERM, _handler)\n"
1257
+ "def _bomb():\n"
1258
+ " for _ in range(200):\n"
1259
+ " os.kill(os.getpid(), signal.SIGTERM)\n"
1260
+ " time.sleep(0.001)\n"
1261
+ "threading.Thread(target=_bomb, daemon=True).start()\n"
1262
+ "time.sleep(3)\n"
1263
+ "sys.stdout.write(str(len(entries)))\n",
1264
+ )
1265
+ assert proc.returncode == 0, proc.stderr
1266
+ # The handler ran many times over, and every one of them returned.
1267
+ assert int(proc.stdout) > 1, "the handler never re-entered; the test proves nothing"
1268
+ assert len(read_all(tmp_path)) == 4000
1269
+
1270
+
1271
+ def test_the_queue_is_bounded_by_measured_bytes_not_an_estimate(tmp_path):
1272
+ """`_QUEUE_CAP` alone is not a memory bound, and neither is an average.
1273
+
1274
+ The adapters budget 128 KiB of `fw_*` extras per event, so 10_000 of those is
1275
+ ~1.3 GB — an OOM kill of the host agent, the one outcome the cap exists to
1276
+ prevent. A first attempt derived the cap from a running average of encoded
1277
+ batch sizes, which is not a bound *until the average has been learned*: the
1278
+ seed assumed 1 KB/event, so the same 10_000 large events still queued 1.22 GB
1279
+ before it caught up. `submit` sizes each entry as it arrives instead.
1280
+ """
1281
+ writer_mod = sys.modules["failproofai_sdk._writer"]
1282
+
1283
+ w = writer_mod.EventWriter(flush_interval=3600)
1284
+ try:
1285
+ assert w._queued_bytes == 0
1286
+
1287
+ # 128 KiB each — the per-event budget the adapters actually allow.
1288
+ big = "z" * (128 * 1024)
1289
+ # 600 x 128 KiB = 75 MB, comfortably past the 64 MiB cap.
1290
+ for i in range(600):
1291
+ w.submit({"type": "tool_result", "tool_call_id": str(i), "output": big})
1292
+
1293
+ assert w._queued_bytes <= writer_mod._QUEUE_BYTE_CAP, (
1294
+ f"{w._queued_bytes} bytes queued against a "
1295
+ f"{writer_mod._QUEUE_BYTE_CAP} cap"
1296
+ )
1297
+ # It bit long before the COUNT cap would have: that is the whole point.
1298
+ assert len(w._queue) < writer_mod._QUEUE_CAP
1299
+ assert w._dropped > 0, "nothing was evicted; the fixture is too small"
1300
+
1301
+ # The bound holds from the very first submit — no warm-up window.
1302
+ fresh = writer_mod.EventWriter(flush_interval=3600)
1303
+ try:
1304
+ for i in range(200):
1305
+ fresh.submit({"type": "tool_result", "tool_call_id": str(i), "output": big})
1306
+ assert fresh._queued_bytes <= writer_mod._QUEUE_BYTE_CAP
1307
+ finally:
1308
+ writer_mod._live_writers[:] = [
1309
+ r for r in writer_mod._live_writers if r() is not fresh
1310
+ ]
1311
+ finally:
1312
+ writer_mod._live_writers[:] = [
1313
+ r for r in writer_mod._live_writers if r() is not w
1314
+ ]
1315
+
1316
+
1317
+ def test_an_oversized_event_is_capped_so_the_batch_stays_deliverable(tmp_path):
1318
+ """`uploader.rs` relies on an invariant the Python writer did not keep.
1319
+
1320
+ "A single line longer than max is emitted alone rather than dropped: the
1321
+ spool writer already guarantees no such line exists." The Rust spool writer
1322
+ does guarantee it; this one did not — so one `tool_result` carrying a large
1323
+ file was written as a single line, POSTed whole (`split_lines` can only split
1324
+ on newlines), rejected, and the ENTIRE spool file was parked, retried three
1325
+ times and poisoned. Every unrelated event batched alongside it went too.
1326
+ """
1327
+ writer_mod = sys.modules["failproofai_sdk._writer"]
1328
+
1329
+ w = EventWriter(flush_interval=3600)
1330
+ ns = EventNamespace(w)
1331
+ ns.tool_result(
1332
+ session_id="s", agent_id="a", tool_name="cat", tool_call_id="c1",
1333
+ output="z" * (9 * 1024 * 1024),
1334
+ )
1335
+ ns.tool_result(
1336
+ session_id="s", agent_id="a", tool_name="ls", tool_call_id="c2", output="small",
1337
+ )
1338
+ w.flush_now()
1339
+
1340
+ batches = sorted((tmp_path / "events").glob("*.jsonl"))
1341
+ assert batches, "nothing was published"
1342
+ for batch in batches:
1343
+ assert batch.stat().st_size <= writer_mod._MAX_BATCH_BYTES, (
1344
+ f"{batch.name} is {batch.stat().st_size} bytes, over the upload cap"
1345
+ )
1346
+ for line in batch.read_text(encoding="utf-8").splitlines():
1347
+ assert len(line) <= writer_mod._MAX_EVENT_BYTES, "a single line exceeds the cap"
1348
+
1349
+ # The neighbouring event is NOT held back by its oversized sibling.
1350
+ events = read_all(tmp_path)
1351
+ assert {e["tool_call_id"] for e in events} == {"c1", "c2"}
1352
+ assert events[0]["output"].endswith(writer_mod._FIELD_TRUNCATION_MARKER)