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,750 @@
1
+ import atexit
2
+ import collections
3
+ import itertools
4
+ import json
5
+ import logging
6
+ import math
7
+ import os
8
+ import threading
9
+ import weakref
10
+ from datetime import datetime, timezone
11
+
12
+ from failproofai_sdk._resolver import get_base_dir
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ #: Per-process batch counter, so two batches written inside the same millisecond
18
+ #: cannot land on the same filename.
19
+ #:
20
+ #: The timestamp alone was not enough, and the way it failed was invisible. Two
21
+ #: batches in the same millisecond produced the same stem, and the second
22
+ #: `os.replace` overwrote the first — no exception, no log line, no trace that
23
+ #: the events had ever existed. Three routine situations hit it:
24
+ #:
25
+ #: * the atexit flush racing the flush thread's own final cycle, which is
26
+ #: exactly when the last events of a run are written;
27
+ #: * any caller invoking `flush_now()` from more than one thread;
28
+ #: * several agent processes sharing one spool root — the normal deployment.
29
+ #: Nothing in the stem identified the writer, so unrelated processes
30
+ #: silently overwrote each other's batches.
31
+ #:
32
+ #: Hence the pid as well as the counter: the counter fixes the in-process race
33
+ #: and the pid fixes the cross-process one. The daemons require only that a
34
+ #: batch file end in `.jsonl` and not `.tmp` (`collector/src/watcher.rs` in
35
+ #: AgentEye, `crates/fpai-collect/src/spool.rs` here), so the rest of the stem
36
+ #: is ours to make unique — and fpai-collect's own batches carry a run id and a
37
+ #: sequence number for the same reason.
38
+ _batch_seq = itertools.count()
39
+
40
+ #: Hard cap on the in-memory queue, matching `_events._PENDING_CAP`.
41
+ #:
42
+ #: `submit()` is called from the caller's own agent loop and must never block or
43
+ #: raise, so it cannot apply backpressure — which leaves an unbounded queue as
44
+ #: the only other option, and that is a memory leak wearing a different hat. Any
45
+ #: condition that stops the spool draining (a full disk, a read-only mount, a
46
+ #: forked child before this module learned to restart its thread) then converts
47
+ #: a telemetry outage into an OOM kill of the host agent. Losing the oldest
48
+ #: events is the better failure: it is bounded, it is logged, and the events
49
+ #: most worth having are the recent ones.
50
+ #:
51
+ #: At the default 500 ms interval a process would have to emit 20_000
52
+ #: events/second to reach it. Anything that does hit this cap is not a busy
53
+ #: agent, it is a spool that has stopped.
54
+ #:
55
+ #: A COUNT alone is not the bound this docstring claims, because it says nothing
56
+ #: about how big an event is. `integrations/_core.py` budgets 128 KiB of `fw_*`
57
+ #: extras per event on top of the declared fields, so 10_000 of them is ~1.3 GB,
58
+ #: not the "roughly 10 MB" this used to promise — an OOM kill of the host agent,
59
+ #: which is the exact outcome the cap exists to make impossible. So the queue is
60
+ #: bounded by BYTES as well, below.
61
+ _QUEUE_CAP = 10_000
62
+
63
+ #: The ceiling, enforced against MEASURED bytes rather than an estimate. Chosen
64
+ #: to sit well under the memory a small container is given (512 MB is the common
65
+ #: floor), because the whole point is that a spool which has stopped draining
66
+ #: must not take the customer's agent down with it.
67
+ #:
68
+ #: This was briefly derived from a running average of encoded batch sizes, which
69
+ #: is not a bound at all before the first flush has happened: the seed assumed
70
+ #: 1 KB/event, so 10_000 events of 128 KiB queued 1.22 GB before the estimate
71
+ #: caught up — the exact OOM this exists to prevent, just later. `submit` sizes
72
+ #: each entry as it arrives instead. That costs one walk over the entry's NODES
73
+ #: (string lengths are O(1)), not over its characters, which is microseconds for
74
+ #: an event and is paid on the caller's thread only once per event.
75
+ _QUEUE_BYTE_CAP = 64 * 1024 * 1024
76
+
77
+ #: Per-STRING cap inside one event, mirroring `MAX_FIELD_BYTES` in
78
+ #: `crates/fpai-collect/src/spool.rs`. The Rust spool writer has always enforced
79
+ #: this; the Python writer publishing into the same directories did not.
80
+ _MAX_FIELD_BYTES = 1024 * 1024
81
+
82
+ #: Roll a batch file once it reaches this, mirroring `DEFAULT_MAX_BATCH_BYTES`
83
+ #: in `spool.rs` and staying under the uploader's `DEFAULT_MAX_UPLOAD_BYTES`.
84
+ #:
85
+ #: `uploader.rs` documents the invariant this restores: "A single line longer
86
+ #: than max is emitted alone rather than dropped: the spool writer already
87
+ #: guarantees no such line exists." No SDK-side writer guaranteed that, and
88
+ #: `split_lines` can only split on newlines — so one oversized event was POSTed
89
+ #: whole, rejected, and the WHOLE spool file (every unrelated event batched with
90
+ #: it) was parked, retried three times and poisoned. Never delivered, and nothing
91
+ #: in the host process ever learned.
92
+ _MAX_BATCH_BYTES = 8 * 1024 * 1024
93
+
94
+ #: An encoded event above this is over-large on its own and gets its fields
95
+ #: capped. Sits below `_MAX_BATCH_BYTES` so a capped event still leaves room for
96
+ #: the batch framing around it.
97
+ _MAX_EVENT_BYTES = 4 * 1024 * 1024
98
+
99
+ #: How deep `_sanitize` will walk before giving up on a branch. Guards the
100
+ #: fallback path against a RecursionError, which would defeat the point of
101
+ #: having a fallback at all.
102
+ _MAX_SANITIZE_DEPTH = 50
103
+
104
+ #: How `json.dumps(ensure_ascii=True)` writes a lone surrogate. Cheap to scan
105
+ #: for, and the only in-band signal that one is present — encoding never fails.
106
+ #:
107
+ #: The lead nibble matters. `\\ud` alone also matches U+D000–U+D7FF, which is most
108
+ #: of the Hangul syllable block — `json.dumps("한")` is `"\\ud55c"` — so every event
109
+ #: carrying ordinary Korean text took the rebuild path. That was documented as
110
+ #: costing nothing ("merely re-encoded to the same bytes"), and it is not:
111
+ #: `_sanitize` replaces anything past `_MAX_SANITIZE_DEPTH` with a marker, so a
112
+ #: payload the strict encoder had handled perfectly was silently truncated the
113
+ #: moment it also contained a Korean character. Real surrogates are U+D800–U+DFFF,
114
+ #: whose escapes all begin `\\ud8`, `\\ud9`, `\\uda`…`\\udf`.
115
+ _SURROGATE_ESCAPES = tuple(f"\\ud{c}" for c in "89abcdefABCDEF")
116
+
117
+ _FIELD_TRUNCATION_MARKER = "…[truncated]"
118
+
119
+ _CYCLE_MARKER = "<circular reference>"
120
+ _DEPTH_MARKER = "<max depth exceeded>"
121
+
122
+ #: Every live writer, weakly. Both the fork handler and the atexit flush iterate
123
+ #: this rather than binding to one instance, which is what lets the atexit hook
124
+ #: be registered once at module scope instead of once per writer —
125
+ #: `atexit.register(self._flush)` stored a strong reference to a bound method and
126
+ #: so made every EventWriter ever built immortal.
127
+ #:
128
+ #: Weak references do NOT make a writer collectable on their own: its flush
129
+ #: thread targets `self._flush_loop`, and a running thread holds its target. So
130
+ #: in practice a writer lives as long as its thread does, which is for the life
131
+ #: of the process. The weakness earns its keep on the fork path, where a dead
132
+ #: referent must be skipped rather than restarted, and it stops this list being a
133
+ #: second, independent reason a writer can never go away.
134
+ _live_writers: "list[weakref.ref[EventWriter]]" = []
135
+
136
+
137
+ def _validated_interval(flush_interval: float) -> float:
138
+ """A flush interval `_flush_loop` can actually wait on.
139
+
140
+ The wait happens BEFORE the loop's try/except, deliberately — a flush that
141
+ raises must be retried next cycle, and wrapping the wait would mean a bad
142
+ interval retries forever at full speed instead. The cost of that choice is
143
+ that an unwaitable interval kills the thread outright, and the thread dying
144
+ is the worst failure this class has: `submit()` keeps accepting events, the
145
+ queue fills to `_QUEUE_CAP` and then starts discarding, and the caller sees
146
+ no error until the process exits and takes everything with it.
147
+
148
+ So the value is rejected at the boundary instead, where a caller still has a
149
+ stack trace pointing at their own `configure()` call:
150
+
151
+ -1 -> ValueError from Event.wait, thread dies
152
+ nan -> ValueError from Event.wait, thread dies
153
+ inf -> OverflowError from Event.wait, thread dies
154
+ 0 -> waits not at all; a busy loop pinning a core and rewriting the
155
+ spool as fast as the disk allows
156
+ """
157
+ interval = float(flush_interval)
158
+ if not math.isfinite(interval) or interval <= 0:
159
+ raise ValueError(
160
+ f"flush_interval must be a finite number greater than zero, got {flush_interval!r}"
161
+ )
162
+ return interval
163
+
164
+
165
+ def _approx_size(value, depth: int = 0) -> int:
166
+ """Roughly how many bytes `value` will occupy once encoded.
167
+
168
+ Walks NODES, not characters: `len()` on a string is O(1), so an ordinary
169
+ event costs microseconds even though it may carry megabytes of text. That is
170
+ what makes it affordable on `submit`, which runs on the caller's agent loop.
171
+
172
+ Deliberately approximate — it ignores JSON punctuation and escaping — because
173
+ it backs a backstop against unbounded growth, not an exact quota.
174
+ """
175
+ if depth > _MAX_SANITIZE_DEPTH:
176
+ return 16
177
+ if value is None or isinstance(value, (bool, int, float)):
178
+ return 8
179
+ if isinstance(value, str):
180
+ return len(value)
181
+ if isinstance(value, bytes):
182
+ return len(value)
183
+ # NOTHING here may raise. `submit` runs on the caller's agent loop, so an
184
+ # exception escaping this function is a telemetry call taking down the host
185
+ # agent — the one failure mode this whole module is written to avoid. A key
186
+ # is measured only when it is ALREADY a `str`: calling `str()` on it would
187
+ # run the caller's `__str__`, which can raise anything at all (this is not
188
+ # hypothetical — `tests/test_encoding.py` plants exactly that object).
189
+ try:
190
+ if isinstance(value, dict):
191
+ return sum(
192
+ (len(k) if isinstance(k, str) else 16) + _approx_size(v, depth + 1)
193
+ for k, v in value.items()
194
+ )
195
+ if isinstance(value, (list, tuple, set, frozenset)):
196
+ return sum(_approx_size(v, depth + 1) for v in value)
197
+ except Exception: # pragma: no cover - a container whose iteration raises
198
+ return 16
199
+ return 16
200
+
201
+
202
+ def _cap_fields(value, limit: int, depth: int = 0):
203
+ """Truncate every string in `value` to `limit`, marking what was cut.
204
+
205
+ Mirrors `truncate_strings` in `crates/fpai-collect/src/spool.rs`, which has
206
+ always enforced this on the Rust side of the same spool.
207
+ """
208
+ if depth > _MAX_SANITIZE_DEPTH:
209
+ return value
210
+ # Same rule as `_approx_size`: never raise. This runs from `_encode_entry`,
211
+ # whose contract is that ONE bad event is dropped alone rather than taking
212
+ # the batch beside it down.
213
+ try:
214
+ if isinstance(value, str) and len(value) > limit:
215
+ return value[:limit] + _FIELD_TRUNCATION_MARKER
216
+ if isinstance(value, dict):
217
+ return {k: _cap_fields(v, limit, depth + 1) for k, v in value.items()}
218
+ if isinstance(value, (list, tuple)):
219
+ return [_cap_fields(v, limit, depth + 1) for v in value]
220
+ except Exception: # pragma: no cover - a container whose iteration raises
221
+ return value
222
+ return value
223
+
224
+
225
+ def _scrub_key(key) -> str:
226
+ """A dict key as a string with any lone surrogate made inert.
227
+
228
+ Mirrors the string branch of `_sanitize`; kept separate because a key must
229
+ always come back a `str`, whatever it started as.
230
+ """
231
+ if not isinstance(key, str):
232
+ key = str(key)
233
+ return key.encode("utf-8", "backslashreplace").decode("utf-8")
234
+
235
+
236
+ def _sanitize(value, seen: frozenset, depth: int = 0):
237
+ """Rewrite one payload into something `json.dumps` can definitely encode.
238
+
239
+ Only ever reached from `_encode_entry`'s fallback, so it may be slow; it
240
+ must not be lossy in the ordinary case, and it must not raise.
241
+
242
+ `seen` tracks the ids on the CURRENT PATH, not every id visited. A payload
243
+ that mentions the same dict twice as siblings is a DAG, not a cycle, and
244
+ json encodes it fine — flagging it would corrupt a perfectly good event.
245
+ """
246
+ if depth > _MAX_SANITIZE_DEPTH:
247
+ return _DEPTH_MARKER
248
+ # NaN / inf / -inf. `json.dumps` writes these as the bare tokens NaN,
249
+ # Infinity and -Infinity, which are a Python extension and not valid JSON —
250
+ # a strict NDJSON reader rejects the line, and the whole event is lost for
251
+ # one field. There is no in-band JSON value for them, so None it is: the
252
+ # field reads as absent rather than as a number that is not one.
253
+ if isinstance(value, float) and not math.isfinite(value):
254
+ return None
255
+ # Lone surrogates. `os.fsdecode` and `bytes.decode(errors="surrogateescape")`
256
+ # — the standard way Python carries bytes that are not valid UTF-8, and what
257
+ # a filesystem path or a truncated tool output arrives as — produce these.
258
+ # `json.dumps` escapes them happily as \udcff, so nothing fails locally, and
259
+ # then the SERVER skips the whole event: verified against a live ingest,
260
+ # `{"accepted":0,"skipped":1}` at 200 OK. `backslashreplace` keeps the byte
261
+ # visible in the payload instead of dropping it to a `?`.
262
+ if isinstance(value, str):
263
+ return value.encode("utf-8", "backslashreplace").decode("utf-8")
264
+ if isinstance(value, dict):
265
+ if id(value) in seen:
266
+ return _CYCLE_MARKER
267
+ seen = seen | {id(value)}
268
+ # Non-str keys are the common half of this bug: `json.dumps(default=...)`
269
+ # is never consulted for keys, so a tuple-keyed cache raises TypeError
270
+ # no matter what default is passed.
271
+ #
272
+ # Keys go through the SAME surrogate scrub as values. They used to pass
273
+ # through untouched, which left the scrub applied to every value and to
274
+ # no key — and a filesystem path, the source this module names as the
275
+ # realistic one, is most naturally a KEY (`{path: contents}`). An
276
+ # unscrubbed key reaches the wire as a JSON lone-surrogate escape, ingest
277
+ # answers 200 `{"accepted":0,"skipped":1}`, and the uploader now parks
278
+ # that batch and poisons it after three retries — so one bad key loses
279
+ # every event batched with it.
280
+ return {
281
+ _scrub_key(k): _sanitize(v, seen, depth + 1) for k, v in value.items()
282
+ }
283
+ if isinstance(value, (list, tuple)):
284
+ if id(value) in seen:
285
+ return _CYCLE_MARKER
286
+ seen = seen | {id(value)}
287
+ return [_sanitize(v, seen, depth + 1) for v in value]
288
+ return value
289
+
290
+
291
+ def _encode_entry(entry: dict) -> "str | None":
292
+ """One event as a JSON line, or None if it cannot be encoded at all.
293
+
294
+ THE POINT IS ISOLATION. This used to be a single `json.dumps` over the whole
295
+ batch, which meant one unencodable payload took every event beside it down:
296
+ `_flush` restored the batch and re-raised, `_flush_loop` logged and retried
297
+ the identical batch on the next interval, and the spool never advanced again.
298
+ A tuple-keyed dict or an object holding a back-reference — both ordinary
299
+ things to hand a telemetry call — permanently ended recording for the
300
+ process, and the only outward sign was a traceback at exit.
301
+
302
+ `default=str` does not prevent it. It is consulted for unsupported *values*
303
+ only, so it rescues datetime and UUID but not a non-str key and not a cycle:
304
+
305
+ {"k": {(1, 2): "v"}} -> TypeError: keys must be str, int, float, ...
306
+ d = {}; d["self"] = d -> ValueError: Circular reference detected
307
+
308
+ `allow_nan=False` is part of "strict" here. Left at its default, `json.dumps`
309
+ emits the bare tokens `NaN`, `Infinity` and `-Infinity` — a Python extension
310
+ that is NOT valid JSON and that a strict NDJSON reader rejects. Worse, it
311
+ does not raise, so the fallback below never ran and the malformed line went
312
+ out looking fine. With it off, a non-finite float raises like any other
313
+ unencodable value and `_sanitize` maps it to None.
314
+
315
+ So: try strict first (the fast path, byte-identical to what shipped before
316
+ for every payload that was already valid JSON), fall back to a sanitised
317
+ copy, and only then give up on that ONE event.
318
+ """
319
+ # `except Exception`, not a list of the three encoder errors. `default=str`
320
+ # runs the CALLER'S `__repr__`/`__str__`, which can raise anything at all —
321
+ # a RuntimeError out of a lazy ORM attribute, an OSError out of a property
322
+ # that touches the network. Those escaped the narrow clause, propagated out
323
+ # of `_write_batch`, and put the whole batch back on the queue to be retried
324
+ # identically forever: the exact wedge this function exists to prevent,
325
+ # reached through a different exception type.
326
+ #
327
+ # BaseException is deliberately NOT caught — a KeyboardInterrupt during a
328
+ # flush must still interrupt.
329
+ try:
330
+ encoded = json.dumps(entry, default=str, allow_nan=False)
331
+ except Exception:
332
+ encoded = None
333
+
334
+ if encoded is not None:
335
+ # `ensure_ascii` is on, so a lone surrogate leaves here as the literal
336
+ # text \udXXX rather than raising — which is exactly why it needed
337
+ # finding by inspection. One substring scan per line, and only a line
338
+ # that actually contains one pays for the rewrite below. A payload whose
339
+ # own text happens to contain a real surrogate escape trips this too and
340
+ # is merely re-encoded, which is rare enough to be worth the certainty.
341
+ if not any(esc in encoded for esc in _SURROGATE_ESCAPES):
342
+ return _cap_encoded(entry, encoded)
343
+
344
+ try:
345
+ sanitized = _sanitize(entry, frozenset())
346
+ return _cap_encoded(sanitized, json.dumps(sanitized, default=str, allow_nan=False))
347
+ except Exception:
348
+ # Nothing left to try. Losing this event is the correct outcome; losing
349
+ # the batch around it is not.
350
+ logger.exception(
351
+ "Failproof AI could not serialize an event (type=%r); dropping it",
352
+ entry.get("type") if isinstance(entry, dict) else None,
353
+ )
354
+ return None
355
+
356
+
357
+ def _roll(lines: list, limit: int):
358
+ """Split encoded lines into batches that each stay under `limit` bytes."""
359
+ chunk, size = [], 0
360
+ for line in lines:
361
+ cost = len(line) + 1 # the newline this line will be joined with
362
+ if chunk and size + cost > limit:
363
+ yield chunk
364
+ chunk, size = [], 0
365
+ chunk.append(line)
366
+ size += cost
367
+ if chunk:
368
+ yield chunk
369
+
370
+
371
+ def _cap_encoded(entry: dict, encoded: str) -> str:
372
+ """Bound ONE event, re-encoding only when it is actually over-large.
373
+
374
+ `ensure_ascii` is on, so the encoded string is ASCII and `len` is its byte
375
+ count exactly. The check is therefore free on the fast path and the walk is
376
+ paid only by the events that need it.
377
+
378
+ Why it has to happen at all: `uploader.rs` states the invariant it relies on
379
+ — "A single line longer than max is emitted alone rather than dropped: the
380
+ spool writer already guarantees no such line exists." The Rust spool writer
381
+ does guarantee it (`truncate_strings` at `MAX_FIELD_BYTES`). The Python
382
+ writer, publishing into the same directories, did not — so one
383
+ `tool_result(output=<a large file>)` was written as a single line, POSTed
384
+ whole because `split_lines` can only split on newlines, rejected, and the
385
+ ENTIRE spool file was parked, retried three times and poisoned. Every
386
+ unrelated event batched alongside it went too, and nothing in the host
387
+ process ever learned.
388
+ """
389
+ if len(encoded) <= _MAX_EVENT_BYTES:
390
+ return encoded
391
+ capped = _cap_fields(entry, _MAX_FIELD_BYTES)
392
+ try:
393
+ recoded = json.dumps(capped, default=str, allow_nan=False)
394
+ except Exception: # pragma: no cover - `entry` already encoded once
395
+ return encoded
396
+ logger.warning(
397
+ "Failproof AI truncated an oversized event (type=%r) from %d to %d bytes; "
398
+ "fields above %d bytes were cut so the batch stays deliverable",
399
+ entry.get("type") if isinstance(entry, dict) else None,
400
+ len(encoded),
401
+ len(recoded),
402
+ _MAX_FIELD_BYTES,
403
+ )
404
+ return recoded
405
+
406
+
407
+ def _flush_all_at_exit() -> None:
408
+ """Final flush for every live writer.
409
+
410
+ Registered once, at module scope, rather than per instance: `atexit` holds a
411
+ strong reference to whatever it is given, so `atexit.register(self._flush)`
412
+ made every EventWriter immortal.
413
+
414
+ Exceptions are swallowed here on purpose. An uncaught one at this point
415
+ prints `Exception ignored in atexit callback` plus a full traceback into the
416
+ host agent's stderr, during interpreter shutdown, where it reads as a crash
417
+ in the application rather than a telemetry flush that failed.
418
+
419
+ `_flush` takes `_flush_lock`, so this BLOCKS on any batch the flush thread
420
+ is part-way through rather than racing it. That matters more than it looks:
421
+ a batch is drained from the queue before it is written, so a flush thread
422
+ stopped mid-write — which is what happens to a daemon thread once the
423
+ interpreter starts finalizing — takes those events with it and leaves a
424
+ stray `.tmp` behind. atexit callbacks run BEFORE threads are hung, so
425
+ waiting here is enough for the in-flight write to finish normally.
426
+ """
427
+ for ref in list(_live_writers):
428
+ writer = ref()
429
+ if writer is None:
430
+ continue
431
+ try:
432
+ writer._flush()
433
+ except Exception:
434
+ logger.exception("Failproof AI final flush failed; buffered events were lost")
435
+
436
+
437
+ def _reinit_all_after_fork() -> None:
438
+ """Make every inherited writer usable again in a freshly-forked child.
439
+
440
+ Threads do not survive `fork()`. Without this the child inherits a queue,
441
+ an atexit hook and no thread to drain either: `submit()` keeps accepting,
442
+ nothing is ever published, and the events appear only if the child happens
443
+ to exit through a normal interpreter shutdown. A prefork worker (gunicorn,
444
+ celery, multiprocessing's default start method on Linux) never does — it is
445
+ killed — so telemetry from the workers, which is all of the telemetry,
446
+ silently never arrives.
447
+ """
448
+ survivors = []
449
+ for ref in _live_writers:
450
+ writer = ref()
451
+ if writer is None:
452
+ continue
453
+ survivors.append(ref)
454
+ try:
455
+ writer._reinit_after_fork()
456
+ except Exception: # pragma: no cover - defensive
457
+ logger.exception("Failproof AI could not restart its flush thread after fork")
458
+ _live_writers[:] = survivors
459
+
460
+
461
+ atexit.register(_flush_all_at_exit)
462
+
463
+ if hasattr(os, "register_at_fork"): # pragma: no branch - absent only on Windows
464
+ os.register_at_fork(after_in_child=_reinit_all_after_fork)
465
+
466
+
467
+ class EventWriter:
468
+ def __init__(self, flush_interval: float = 0.5) -> None:
469
+ self._queue: collections.deque[dict] = collections.deque()
470
+ self._flush_interval = _validated_interval(flush_interval)
471
+ self._dropped = 0
472
+ # Waited on instead of `time.sleep` so `set_flush_interval` takes effect
473
+ # on the current cycle rather than the next one. It matters at startup:
474
+ # the thread begins its first wait at import, before `configure()` has
475
+ # been called, so a caller asking for a 50 ms interval used to get one
476
+ # 500 ms cycle first — long enough for a fork or an exit to land inside
477
+ # it and take the events with it.
478
+ self._wake = threading.Event()
479
+ # Serialises `_flush`, so a batch is never being drained by two threads
480
+ # at once and — the case that actually bites — so the atexit flush waits
481
+ # for an in-flight write instead of racing interpreter shutdown against
482
+ # it. Never taken by `submit`, which must stay lock-free.
483
+ #
484
+ # RLock, not Lock, and that is load-bearing: signal handlers run on the
485
+ # MAIN thread, interrupting whatever bytecode it was executing. The
486
+ # SIGTERM recipe this SDK publishes (SKILL.md, docs/reference/custom-agents
487
+ # .mdx) calls `flush_now()` from a handler, so a plain Lock deadlocks the
488
+ # host process outright whenever the main thread is already inside
489
+ # `_flush` — a second SIGTERM during the first handler's flush, an app
490
+ # that calls `flush_now()` itself, or a SIGTERM landing during the atexit
491
+ # flush, which runs on this same thread. Once wedged the process cannot
492
+ # be signalled out of it: every further SIGTERM re-enters the deadlocked
493
+ # handler. Re-entering the write is safe — the outer call has already
494
+ # drained its batch into a local list, `_batch_seq` is atomic, and the
495
+ # nested batch simply lands under its own filename.
496
+ self._flush_lock = threading.RLock()
497
+ # Measured bytes currently queued. Maintained by `submit` and reset by
498
+ # `_flush`, which drains under the lock — so it self-corrects every cycle
499
+ # and any drift from a concurrent lock-free `submit` is bounded by one
500
+ # flush interval.
501
+ self._queued_bytes = 0
502
+ self._start_thread()
503
+ _live_writers.append(weakref.ref(self))
504
+
505
+ def _start_thread(self) -> None:
506
+ self._thread = threading.Thread(
507
+ target=self._flush_loop, daemon=True, name="failproofai-sdk-flush"
508
+ )
509
+ self._thread.start()
510
+
511
+ def _reinit_after_fork(self) -> None:
512
+ """Restore this writer inside the child half of a `fork()`.
513
+
514
+ The inherited queue is DISCARDED rather than published. Those events
515
+ belong to the parent, which still holds them and will write them itself;
516
+ publishing them here too produced a genuine duplicate of every event
517
+ buffered at the moment of the fork. Ingest would most likely collapse
518
+ them — its dedup key hashes the canonical payload and these are
519
+ byte-identical — but "the server will probably clean it up" is not a
520
+ property this SDK gets to rely on.
521
+
522
+ `self._wake` is rebuilt rather than reused: `threading.Event` is backed
523
+ by a lock, and a lock held by the flush thread at the instant of the
524
+ fork is inherited locked, by a thread that no longer exists. Setting it
525
+ would then deadlock the child.
526
+ """
527
+ inherited = len(self._queue)
528
+ self._queue.clear()
529
+ self._wake = threading.Event()
530
+ # RLock for the same reason as the constructor: the child may install the
531
+ # documented SIGTERM handler too.
532
+ self._flush_lock = threading.RLock()
533
+ self._queued_bytes = 0
534
+ self._start_thread()
535
+ if inherited:
536
+ logger.debug(
537
+ "Failproof AI discarded %d event(s) inherited from the parent process; "
538
+ "the parent still holds them",
539
+ inherited,
540
+ )
541
+
542
+ def submit(self, entry: dict) -> None:
543
+ # Bounded, and bounded without a lock: `len` and `popleft` on a deque are
544
+ # each a single atomic operation, and `submit` runs on the caller's agent
545
+ # loop where a lock is a latency risk and, on the fork path, a deadlock.
546
+ # A momentary overshoot under concurrent submits is fine; the cap is a
547
+ # backstop against unbounded growth, not an exact quota.
548
+ size = _approx_size(entry)
549
+ # BOTH bounds, and the byte one against measured sizes. A count alone is
550
+ # not a memory bound (the adapters budget 128 KiB of extras per event, so
551
+ # 10_000 of them is ~1.3 GB), and an average-based byte bound is not one
552
+ # either until the average has been learned.
553
+ while self._queue and (
554
+ len(self._queue) >= _QUEUE_CAP
555
+ or self._queued_bytes + size > _QUEUE_BYTE_CAP
556
+ ):
557
+ try:
558
+ evicted = self._queue.popleft()
559
+ except IndexError: # pragma: no cover - drained concurrently
560
+ break
561
+ self._queued_bytes = max(0, self._queued_bytes - _approx_size(evicted))
562
+ self._dropped += 1
563
+ # Powers of ten, so a stuck spool says so without becoming the thing
564
+ # that fills the disk it is complaining about.
565
+ if self._dropped == 1 or self._dropped % 1000 == 0:
566
+ logger.warning(
567
+ "Failproof AI event queue is full (%d events, %d bytes); discarding "
568
+ "oldest. %d dropped so far — the spool is not draining.",
569
+ len(self._queue),
570
+ self._queued_bytes,
571
+ self._dropped,
572
+ )
573
+ self._queue.append(entry)
574
+ self._queued_bytes += size
575
+
576
+ def set_flush_interval(self, interval: float) -> None:
577
+ # Validate first, assign second: a rejected value must leave the writer
578
+ # running on the interval it already had, not on a half-applied one.
579
+ self._flush_interval = _validated_interval(interval)
580
+ # Cut the current wait short so the new interval applies from now, not
581
+ # from the end of a cycle that may be an hour long.
582
+ self._wake.set()
583
+
584
+ def flush_now(self) -> None:
585
+ """Drain and write any buffered entries immediately (for testing)."""
586
+ self._flush()
587
+
588
+ def _flush_loop(self) -> None:
589
+ while True:
590
+ self._wake.wait(self._flush_interval)
591
+ self._wake.clear()
592
+ try:
593
+ self._flush()
594
+ except Exception:
595
+ # Recording must never die permanently because one flush hit a
596
+ # transient filesystem error. `_flush` restores the drained
597
+ # entries before re-raising, so the next interval retries them.
598
+ logger.exception(
599
+ "Failproof AI event flush failed; buffered events will be retried"
600
+ )
601
+
602
+ def _flush(self) -> None:
603
+ # The emptiness check is INSIDE the lock, and that is the whole point of
604
+ # having one. Outside it, a caller arriving while the flush thread had
605
+ # already drained the queue saw it empty and returned immediately — so
606
+ # the atexit flush did not wait, the interpreter finalised, and the
607
+ # thread died part-way through writing a batch it had already taken
608
+ # ownership of. The events were gone and the only trace was a stray
609
+ # `.tmp` — sometimes not even that.
610
+ with self._flush_lock:
611
+ if not self._queue:
612
+ return
613
+ entries = []
614
+ while self._queue:
615
+ try:
616
+ entries.append(self._queue.popleft())
617
+ except IndexError:
618
+ break
619
+ # Authoritative reset: everything the queue held is now in `entries`.
620
+ # Whatever a concurrent `submit` appended after the drain re-adds
621
+ # itself, so this both clears the total and corrects any drift.
622
+ self._queued_bytes = 0
623
+ if entries:
624
+ try:
625
+ self._write_batch(entries)
626
+ except Exception:
627
+ # Preserve FIFO order when returning the drained batch to the
628
+ # front of entries submitted concurrently during the write.
629
+ for entry in reversed(entries):
630
+ self._queue.appendleft(entry)
631
+ self._queued_bytes = sum(_approx_size(e) for e in self._queue)
632
+ raise
633
+
634
+ def _write_batch(self, entries: list[dict]) -> None:
635
+ # Encode BEFORE touching the filesystem. An unencodable event is a
636
+ # permanent condition — retrying it produces the identical failure — so
637
+ # it is dropped here, while a filesystem error raises from below and the
638
+ # whole batch goes back on the queue to be retried.
639
+ lines = []
640
+ dropped = 0
641
+ for entry in entries:
642
+ encoded = _encode_entry(entry)
643
+ if encoded is None:
644
+ dropped += 1
645
+ continue
646
+ lines.append(encoded)
647
+
648
+ if dropped:
649
+ logger.error(
650
+ "Failproof AI dropped %d unserializable event(s) from a batch of %d; "
651
+ "the rest of the batch was published",
652
+ dropped,
653
+ len(entries),
654
+ )
655
+ if not lines:
656
+ return
657
+
658
+ # Roll into as many files as the cap needs. `uploader.rs` posts a spool
659
+ # file whole when it fits and splits it on newlines when it does not, so
660
+ # oversizing a batch is survivable — but rolling here keeps each POST
661
+ # within `DEFAULT_MAX_UPLOAD_BYTES` without relying on that, and matches
662
+ # what the Rust spool writer already does with `DEFAULT_MAX_BATCH_BYTES`.
663
+ # Every line is individually under `_MAX_EVENT_BYTES` by now, so each
664
+ # chunk is non-empty and the loop always terminates.
665
+ for chunk in _roll(lines, _MAX_BATCH_BYTES):
666
+ self._write_one_file(chunk)
667
+
668
+ def _write_one_file(self, lines: list[str]) -> None:
669
+
670
+ events_dir = get_base_dir() / "events"
671
+ # 0700, and the batch below 0600. These files are not metadata: they
672
+ # carry goals, prompt text, tool arguments and tool output straight from
673
+ # the host agent. Under the ordinary umask 022 they landed 0644 inside
674
+ # 0755 directories, so on any shared host — a build box, a bastion, a
675
+ # container with several service accounts — every other local user could
676
+ # read every agent transcript this SDK spools, for the whole flush+upload
677
+ # window and forever if no daemon is running. The sibling `fp-cloud-cli`
678
+ # already does exactly this for its credential (`config.py`), and the
679
+ # daemon reads these as the SAME user (the unit is `User=<user>` with
680
+ # `HOME` set to that user's home), so tightening them costs no delivery.
681
+ events_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
682
+
683
+ now = datetime.now(timezone.utc)
684
+ ts_str = now.strftime("%Y-%m-%dT%H-%M-%S") + f"-{now.microsecond // 1000:03d}Z"
685
+ # See `_batch_seq` above: the timestamp orders batches for a human
686
+ # reading the directory, and the pid+counter suffix is what makes the
687
+ # name unique. `next()` on an itertools.count is atomic under CPython's
688
+ # GIL and, being a single C-level call, remains so on free-threaded
689
+ # builds — no lock, which matters because this runs on the atexit path
690
+ # where a lock held by a killed thread would hang the interpreter.
691
+ stem = f"event-{ts_str}-{os.getpid()}-{next(_batch_seq)}"
692
+
693
+ tmp_path = events_dir / f"{stem}.tmp"
694
+ final_path = events_dir / f"{stem}.jsonl"
695
+
696
+ content = "\n".join(lines) + "\n"
697
+
698
+ # fsync BEFORE the rename. `os.replace` is atomic with respect to
699
+ # readers, but atomic is not durable: it orders nothing against the page
700
+ # cache, so a power loss or kernel crash can leave a correctly-named,
701
+ # zero-length or truncated `.jsonl`. The collector reads whatever is
702
+ # there, POSTs it, and then DELETES the file (`remove_file` in
703
+ # `crates/fpai-collect/src/uploader.rs`) — so the loss is permanent and
704
+ # silent, and an empty batch is accepted with a 200.
705
+ #
706
+ # This is not a hypothetical asymmetry: this repo's own Rust spool
707
+ # writer already calls `sync_all()` here for exactly this reason
708
+ # (`crates/fpai-collect/src/spool.rs`), with the same comment. The
709
+ # Python writer publishing into the same directories was the odd one out.
710
+ # Clean up the partial file on ANY failure. Each flush picks a fresh
711
+ # stem, so without this a persistent fault — a full disk, a read-only
712
+ # mount, a cross-device rename — strands one `.tmp` per flush cycle:
713
+ # roughly 170_000 files a day at the default interval, on the very disk
714
+ # that is already the problem. The watcher ignores them by extension, so
715
+ # nothing else would ever notice or collect them.
716
+ #
717
+ # The batch itself is NOT lost by this: `_flush` returns the entries to
718
+ # the queue and the next cycle rewrites them under a new name.
719
+ try:
720
+ # O_EXCL + an explicit 0600 rather than `open(..., "wb")`, whose mode
721
+ # is 0666 & ~umask. The mode is applied at CREATE, so the payload is
722
+ # never briefly world-readable the way a follow-up chmod would leave it.
723
+ fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
724
+ with open(fd, "wb") as handle:
725
+ handle.write(content.encode("utf-8"))
726
+ handle.flush()
727
+ os.fsync(handle.fileno())
728
+
729
+ os.replace(tmp_path, final_path)
730
+ except BaseException:
731
+ tmp_path.unlink(missing_ok=True)
732
+ raise
733
+
734
+ # And fsync the DIRECTORY, or the rename itself can be lost while the
735
+ # file's contents survive — leaving the batch on disk under its `.tmp`
736
+ # name, which the watcher ignores by design.
737
+ #
738
+ # Best-effort: opening a directory for fsync is a POSIX behaviour, and
739
+ # platforms that refuse it (Windows) still get the content fsync above,
740
+ # which is the half that prevents a truncated delivery.
741
+ try:
742
+ dir_fd = os.open(events_dir, os.O_RDONLY)
743
+ except OSError: # pragma: no cover - platform dependent
744
+ return
745
+ try:
746
+ os.fsync(dir_fd)
747
+ except OSError: # pragma: no cover - platform dependent
748
+ pass
749
+ finally:
750
+ os.close(dir_fd)