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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +6 -6
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +66 -22
- package/.next/standalone/.next/server/app/policies/page.js +5 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js +1 -1
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0e32-7c._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__1s462yl._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -6
- package/.next/standalone/.next/server/chunks/lib_telemetry-id_ts_1k8gj9o._.js +3 -0
- 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
- 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
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +12 -0
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0spkm68._.js → [root-of-the-server]__07gm3zl._.js} +21 -20
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +6 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07tbbf3._.js → [root-of-the-server]__0zig5sv._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{_0lvyuv4._.js → _00e9tnf._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0u5zauv._.js → _12l4a_k._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +13 -13
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_08o7bqo._.js +10 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware.js +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +71 -27
- package/.next/standalone/.next/static/chunks/{37fcpc36x9gpe.js → 0eqky-7n7dgdm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1ide1wl2f7zi1.js → 0sa9v5mhc8861.js} +13 -13
- package/.next/standalone/.next/static/chunks/1mfzsja6ftim5.css +2 -0
- package/.next/standalone/.next/static/chunks/{3k7mxbp9hiom0.js → 1nuqytbta44af.js} +1 -1
- package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +6 -0
- package/.next/standalone/.next/static/chunks/{2_2lhuojq2xop.js → 2aawsjld9ied_.js} +1 -1
- package/.next/standalone/.next/static/chunks/2gn7ut4svofm2.js +1 -0
- package/.next/standalone/.next/static/chunks/{31s1smp-g7gks.js → 2hs7ycvsav7v2.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0k5050ezjvv49.js → 2i2yt3hzjredm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3c808qrfcq0im.js → 35xg6retpe6rp.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2zpp1we2jv6tb.js → 3y54nw0i7dsjl.js} +1 -1
- package/.next/standalone/SECURITY.md +5 -4
- package/.next/standalone/app/actions/get-hooks-config.ts +108 -17
- package/.next/standalone/app/actions/pack-actions.ts +153 -0
- package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
- package/.next/standalone/app/audit/_components/share-templates.ts +13 -13
- package/.next/standalone/app/policies/hooks-client.tsx +320 -14
- package/.next/standalone/components/navbar.tsx +2 -28
- package/.next/standalone/components/reach-developers.tsx +8 -1
- package/.next/standalone/docs-old/agenteye/python-sdk-skill.mdx +1 -1
- package/.next/standalone/docs-old/agenteye/python-sdk.mdx +59 -35
- package/.next/standalone/docs-old/i18n/README.ar.md +1 -0
- package/.next/standalone/docs-old/i18n/README.de.md +1 -0
- package/.next/standalone/docs-old/i18n/README.es.md +1 -0
- package/.next/standalone/docs-old/i18n/README.fr.md +1 -0
- package/.next/standalone/docs-old/i18n/README.he.md +1 -0
- package/.next/standalone/docs-old/i18n/README.hi.md +1 -0
- package/.next/standalone/docs-old/i18n/README.it.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ja.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ko.md +1 -0
- package/.next/standalone/docs-old/i18n/README.pt-br.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ru.md +1 -0
- package/.next/standalone/docs-old/i18n/README.tr.md +1 -0
- package/.next/standalone/docs-old/i18n/README.vi.md +1 -0
- package/.next/standalone/docs-old/i18n/README.zh.md +1 -0
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +431 -0
- package/.next/standalone/fp-cloud-cli/LICENSE +42 -0
- package/.next/standalone/fp-cloud-cli/MANIFEST.in +10 -0
- package/.next/standalone/fp-cloud-cli/README.md +259 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/__init__.py +10 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/__main__.py +4 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +64 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_context.py +332 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics.py +432 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics_config.py +77 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics_registry.py +83 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/app.py +492 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +160 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1694 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/__init__.py +0 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +214 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +407 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +445 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +1054 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/auth_cmds.py +512 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/errors_cmds.py +190 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/evals_cmds.py +161 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +159 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +416 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/guardrails_cmds.py +148 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +472 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +407 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +63 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +319 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +499 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +378 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +151 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +150 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +35 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +404 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/config.py +330 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/dates.py +78 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +345 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +98 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/models.py +891 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/orgs.py +30 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/output.py +6593 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +208 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +290 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/py.typed +0 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/select.py +322 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +53 -0
- package/.next/standalone/fp-cloud-cli/pyproject.toml +106 -0
- package/.next/standalone/fp-cloud-cli/skill/SKILL.md +302 -0
- package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +8 -0
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +254 -0
- package/.next/standalone/fp-cloud-cli/tests/__init__.py +0 -0
- package/.next/standalone/fp-cloud-cli/tests/conftest.py +92 -0
- package/.next/standalone/fp-cloud-cli/tests/test_alerting.py +644 -0
- package/.next/standalone/fp-cloud-cli/tests/test_analytics.py +385 -0
- package/.next/standalone/fp-cloud-cli/tests/test_audits.py +950 -0
- package/.next/standalone/fp-cloud-cli/tests/test_auth.py +98 -0
- package/.next/standalone/fp-cloud-cli/tests/test_auth_mode.py +272 -0
- package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +118 -0
- package/.next/standalone/fp-cloud-cli/tests/test_client.py +299 -0
- package/.next/standalone/fp-cloud-cli/tests/test_commands.py +814 -0
- package/.next/standalone/fp-cloud-cli/tests/test_config.py +99 -0
- package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +340 -0
- package/.next/standalone/fp-cloud-cli/tests/test_dates.py +76 -0
- package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +481 -0
- package/.next/standalone/fp-cloud-cli/tests/test_facets.py +170 -0
- package/.next/standalone/fp-cloud-cli/tests/test_failproofai_home.py +722 -0
- package/.next/standalone/fp-cloud-cli/tests/test_fp_home_contract.py +198 -0
- package/.next/standalone/fp-cloud-cli/tests/test_hardening.py +186 -0
- package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +127 -0
- package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +496 -0
- package/.next/standalone/fp-cloud-cli/tests/test_list.py +59 -0
- package/.next/standalone/fp-cloud-cli/tests/test_multivalue.py +183 -0
- package/.next/standalone/fp-cloud-cli/tests/test_no_customer_identifiers.py +168 -0
- package/.next/standalone/fp-cloud-cli/tests/test_operator.py +492 -0
- package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +692 -0
- package/.next/standalone/fp-cloud-cli/tests/test_output.py +1732 -0
- package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +191 -0
- package/.next/standalone/fp-cloud-cli/tests/test_readme_matches_reality.py +125 -0
- package/.next/standalone/fp-cloud-cli/tests/test_review_fixes.py +161 -0
- package/.next/standalone/fp-cloud-cli/tests/test_telemetry_completeness.py +90 -0
- package/.next/standalone/fp-cloud-cli/tests/test_usage.py +115 -0
- package/.next/standalone/fp-cloud-cli/tests/test_v1_origin_diagnostic.py +70 -0
- package/.next/standalone/fp-cloud-cli/tests/test_v1_routing.py +214 -0
- package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +104 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +552 -0
- package/.next/standalone/package.json +29 -22
- package/.next/standalone/sdk/python/CHANGELOG.md +70 -0
- package/.next/standalone/sdk/python/LICENSE +42 -0
- package/.next/standalone/sdk/python/MANIFEST.in +21 -0
- package/.next/standalone/sdk/python/README.md +466 -0
- package/.next/standalone/sdk/python/docs/README.md +249 -0
- package/.next/standalone/sdk/python/docs/_shared/README.md +43 -0
- package/.next/standalone/sdk/python/docs/_shared/__init__.py +195 -0
- package/.next/standalone/sdk/python/docs/crewai/README.md +328 -0
- package/.next/standalone/sdk/python/docs/crewai/examples/quickstart.py +61 -0
- package/.next/standalone/sdk/python/docs/crewai/examples/research_crew.py +92 -0
- package/.next/standalone/sdk/python/docs/langgraph/README.md +481 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/quickstart.py +51 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/research_agent.py +86 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/supervisor_handoff.py +83 -0
- package/.next/standalone/sdk/python/docs/llama_index/README.md +332 -0
- package/.next/standalone/sdk/python/docs/llama_index/examples/quickstart.py +62 -0
- package/.next/standalone/sdk/python/docs/llama_index/examples/research_agent.py +93 -0
- package/.next/standalone/sdk/python/docs/manual/README.md +491 -0
- package/.next/standalone/sdk/python/docs/manual/examples/quickstart.py +57 -0
- package/.next/standalone/sdk/python/docs/manual/examples/research_agent.py +133 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/README.md +323 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/examples/quickstart.py +48 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/examples/research_agent.py +94 -0
- package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +131 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_context.py +218 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_environment.py +78 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_events.py +752 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_resolver.py +118 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_runtime.py +21 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_schema.py +317 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_scopes.py +422 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_writer.py +750 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +274 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/_compat.py +247 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/_core.py +1076 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/crewai.py +1669 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/langchain.py +2151 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/llama_index.py +1588 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/pydantic_ai.py +977 -0
- package/.next/standalone/sdk/python/failproofai_sdk/py.typed +0 -0
- package/.next/standalone/sdk/python/pyproject.toml +92 -0
- package/.next/standalone/sdk/python/skill/SKILL.md +399 -0
- package/.next/standalone/sdk/python/skill/agents/openai.yaml +8 -0
- package/.next/standalone/sdk/python/skill/references/events.md +234 -0
- package/.next/standalone/sdk/python/skill/references/frameworks.md +314 -0
- package/.next/standalone/sdk/python/skill/references/install.md +100 -0
- package/.next/standalone/sdk/python/skill/references/integration.md +352 -0
- package/.next/standalone/sdk/python/tests/__init__.py +0 -0
- package/.next/standalone/sdk/python/tests/conftest.py +169 -0
- package/.next/standalone/sdk/python/tests/integrations/__init__.py +6 -0
- package/.next/standalone/sdk/python/tests/integrations/test_crewai.py +1299 -0
- package/.next/standalone/sdk/python/tests/integrations/test_langchain.py +2338 -0
- package/.next/standalone/sdk/python/tests/integrations/test_llama_index.py +1576 -0
- package/.next/standalone/sdk/python/tests/integrations/test_pydantic_ai.py +1102 -0
- package/.next/standalone/sdk/python/tests/test_context.py +410 -0
- package/.next/standalone/sdk/python/tests/test_docs.py +411 -0
- package/.next/standalone/sdk/python/tests/test_durability.py +1352 -0
- package/.next/standalone/sdk/python/tests/test_encoding.py +571 -0
- package/.next/standalone/sdk/python/tests/test_integrations.py +1127 -0
- package/.next/standalone/sdk/python/tests/test_no_customer_identifiers.py +214 -0
- package/.next/standalone/sdk/python/tests/test_packaging.py +185 -0
- package/.next/standalone/sdk/python/tests/test_resolver_umbrella.py +178 -0
- package/.next/standalone/sdk/python/tests/test_scopes.py +487 -0
- package/.next/standalone/sdk/python/tests/test_sdk.py +923 -0
- package/.next/standalone/sdk/python/tests/test_server_contract.py +650 -0
- package/.next/standalone/sdk/python/tests/test_site_docs.py +371 -0
- package/.next/standalone/sdk/python/tests/test_skill_snippets.py +165 -0
- package/.next/standalone/sdk/python/tests/test_spool_contract.py +287 -0
- package/.next/standalone/sdk/python/tests/test_spool_creation.py +360 -0
- package/.next/standalone/sdk/python/tests/test_wire_format.py +279 -0
- package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +378 -0
- package/.next/standalone/sdk/python/uv.lock +6005 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +77 -21
- package/bin/failproofai.mjs +145 -11
- package/dist/cli.mjs +4482 -2205
- package/dist/index.js +5 -5
- package/dist/worker.mjs +2399 -1978
- package/package.json +29 -22
- package/policy-pack/SHA256SUMS +2 -0
- package/policy-pack/failproofai-pack.json +732 -0
- package/policy-pack/failproofai-pack.mjs +2017 -0
- package/scripts/build-policy-pack.mjs +121 -0
- package/scripts/changelog-section.py +77 -0
- package/scripts/prune-standalone.mjs +3 -0
- package/scripts/python-version.py +232 -0
- package/scripts/translate-docs/cli.ts +21 -0
- package/scripts/translate-docs/mintlify-nav.ts +85 -13
- package/scripts/translate-docs/translator.ts +66 -11
- package/src/audit/cache.ts +40 -3
- package/src/audit/cli.ts +1 -1
- package/src/audit/index.ts +1 -1
- package/src/audit/replay.ts +89 -3
- package/src/audit/report.ts +5 -5
- package/src/audit/schedule-cli.ts +49 -53
- package/src/hooks/builtin-policies.ts +217 -579
- package/src/hooks/cloud-enrollment-cli.ts +66 -27
- package/src/hooks/cloud-managed-policies.ts +6 -1
- package/src/hooks/configure-wizard.ts +1 -1
- package/src/hooks/custom-hooks-loader.ts +89 -6
- package/src/hooks/fp-home.ts +92 -4
- package/src/hooks/fp-reset.ts +26 -2
- package/src/hooks/handler.ts +181 -11
- package/src/hooks/harness-cli.ts +79 -25
- package/src/hooks/hook-activity-store.ts +13 -3
- package/src/hooks/install-prompt.ts +25 -3
- package/src/hooks/manager.ts +418 -172
- package/src/hooks/migrations.ts +2 -2
- package/src/hooks/pack-cli.ts +505 -0
- package/src/hooks/pack-failclosed.ts +189 -0
- package/src/hooks/pack-manifest.ts +344 -0
- package/src/hooks/pack-store.ts +930 -0
- package/src/hooks/policy-catalog.ts +535 -0
- package/src/hooks/policy-evaluator.ts +24 -16
- package/src/hooks/policy-presets.ts +4 -2
- package/src/hooks/policy-registry.ts +8 -2
- package/src/hooks/policy-types.ts +26 -0
- package/src/hooks/session-pause-cli.ts +33 -9
- package/src/hooks/tui.ts +570 -0
- package/src/hooks/types.ts +15 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1ger0a2._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_1iiwm5e._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kc8_v5._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mhqk5a._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pltpn8._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qkcyf-._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w0x_ok._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bfex62._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +0 -6
- package/.next/standalone/.next/static/chunks/36fw6yk1s6x_f.js +0 -1
- package/.next/standalone/.next/static/chunks/3tzq0260n17ae.js +0 -6
- package/.next/standalone/.next/static/chunks/3wq9x0c-7ld5d.css +0 -2
- /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_clientMiddlewareManifest.js +0 -0
- /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)
|