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