failproofai 1.0.1 → 1.0.2-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (389) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +6 -6
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  24. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/page.js +2 -2
  48. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +66 -22
  51. package/.next/standalone/.next/server/app/policies/page.js +5 -4
  52. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  65. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  68. package/.next/standalone/.next/server/app/settings/page.js +1 -1
  69. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0e32-7c._.js} +1 -1
  72. package/.next/standalone/.next/server/chunks/[externals]__1s462yl._.js +3 -0
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -6
  84. package/.next/standalone/.next/server/chunks/lib_telemetry-id_ts_1k8gj9o._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +12 -0
  89. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +4 -0
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +4 -0
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +4 -0
  93. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0spkm68._.js → [root-of-the-server]__07gm3zl._.js} +21 -20
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +4 -0
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +4 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +4 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +6 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07tbbf3._.js → [root-of-the-server]__0zig5sv._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +5 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +4 -0
  102. package/.next/standalone/.next/server/chunks/ssr/{_0lvyuv4._.js → _00e9tnf._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{_0u5zauv._.js → _12l4a_k._.js} +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +13 -13
  108. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_08o7bqo._.js +10 -0
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  114. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  115. package/.next/standalone/.next/server/middleware.js +1 -1
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +71 -27
  120. package/.next/standalone/.next/static/chunks/{37fcpc36x9gpe.js → 0eqky-7n7dgdm.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{1ide1wl2f7zi1.js → 0sa9v5mhc8861.js} +13 -13
  122. package/.next/standalone/.next/static/chunks/1mfzsja6ftim5.css +2 -0
  123. package/.next/standalone/.next/static/chunks/{3k7mxbp9hiom0.js → 1nuqytbta44af.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +6 -0
  125. package/.next/standalone/.next/static/chunks/{2_2lhuojq2xop.js → 2aawsjld9ied_.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/2gn7ut4svofm2.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{31s1smp-g7gks.js → 2hs7ycvsav7v2.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{0k5050ezjvv49.js → 2i2yt3hzjredm.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{3c808qrfcq0im.js → 35xg6retpe6rp.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{2zpp1we2jv6tb.js → 3y54nw0i7dsjl.js} +1 -1
  131. package/.next/standalone/SECURITY.md +5 -4
  132. package/.next/standalone/app/actions/get-hooks-config.ts +108 -17
  133. package/.next/standalone/app/actions/pack-actions.ts +153 -0
  134. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  135. package/.next/standalone/app/audit/_components/share-templates.ts +13 -13
  136. package/.next/standalone/app/policies/hooks-client.tsx +320 -14
  137. package/.next/standalone/components/navbar.tsx +2 -28
  138. package/.next/standalone/components/reach-developers.tsx +8 -1
  139. package/.next/standalone/docs-old/agenteye/python-sdk-skill.mdx +1 -1
  140. package/.next/standalone/docs-old/agenteye/python-sdk.mdx +59 -35
  141. package/.next/standalone/docs-old/i18n/README.ar.md +1 -0
  142. package/.next/standalone/docs-old/i18n/README.de.md +1 -0
  143. package/.next/standalone/docs-old/i18n/README.es.md +1 -0
  144. package/.next/standalone/docs-old/i18n/README.fr.md +1 -0
  145. package/.next/standalone/docs-old/i18n/README.he.md +1 -0
  146. package/.next/standalone/docs-old/i18n/README.hi.md +1 -0
  147. package/.next/standalone/docs-old/i18n/README.it.md +1 -0
  148. package/.next/standalone/docs-old/i18n/README.ja.md +1 -0
  149. package/.next/standalone/docs-old/i18n/README.ko.md +1 -0
  150. package/.next/standalone/docs-old/i18n/README.pt-br.md +1 -0
  151. package/.next/standalone/docs-old/i18n/README.ru.md +1 -0
  152. package/.next/standalone/docs-old/i18n/README.tr.md +1 -0
  153. package/.next/standalone/docs-old/i18n/README.vi.md +1 -0
  154. package/.next/standalone/docs-old/i18n/README.zh.md +1 -0
  155. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +431 -0
  156. package/.next/standalone/fp-cloud-cli/LICENSE +42 -0
  157. package/.next/standalone/fp-cloud-cli/MANIFEST.in +10 -0
  158. package/.next/standalone/fp-cloud-cli/README.md +259 -0
  159. package/.next/standalone/fp-cloud-cli/fp_cli/__init__.py +10 -0
  160. package/.next/standalone/fp-cloud-cli/fp_cli/__main__.py +4 -0
  161. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +64 -0
  162. package/.next/standalone/fp-cloud-cli/fp_cli/_context.py +332 -0
  163. package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -0
  164. package/.next/standalone/fp-cloud-cli/fp_cli/analytics.py +432 -0
  165. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_config.py +77 -0
  166. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_registry.py +83 -0
  167. package/.next/standalone/fp-cloud-cli/fp_cli/app.py +492 -0
  168. package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +160 -0
  169. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1694 -0
  170. package/.next/standalone/fp-cloud-cli/fp_cli/commands/__init__.py +0 -0
  171. package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +214 -0
  172. package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +407 -0
  173. package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +445 -0
  174. package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +1054 -0
  175. package/.next/standalone/fp-cloud-cli/fp_cli/commands/auth_cmds.py +512 -0
  176. package/.next/standalone/fp-cloud-cli/fp_cli/commands/errors_cmds.py +190 -0
  177. package/.next/standalone/fp-cloud-cli/fp_cli/commands/evals_cmds.py +161 -0
  178. package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +159 -0
  179. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +416 -0
  180. package/.next/standalone/fp-cloud-cli/fp_cli/commands/guardrails_cmds.py +148 -0
  181. package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +472 -0
  182. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +407 -0
  183. package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +63 -0
  184. package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +319 -0
  185. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +499 -0
  186. package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +378 -0
  187. package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +151 -0
  188. package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +150 -0
  189. package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +35 -0
  190. package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +404 -0
  191. package/.next/standalone/fp-cloud-cli/fp_cli/config.py +330 -0
  192. package/.next/standalone/fp-cloud-cli/fp_cli/dates.py +78 -0
  193. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +345 -0
  194. package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +98 -0
  195. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +891 -0
  196. package/.next/standalone/fp-cloud-cli/fp_cli/orgs.py +30 -0
  197. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +6593 -0
  198. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +208 -0
  199. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +290 -0
  200. package/.next/standalone/fp-cloud-cli/fp_cli/py.typed +0 -0
  201. package/.next/standalone/fp-cloud-cli/fp_cli/select.py +322 -0
  202. package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +53 -0
  203. package/.next/standalone/fp-cloud-cli/pyproject.toml +106 -0
  204. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +302 -0
  205. package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +8 -0
  206. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +254 -0
  207. package/.next/standalone/fp-cloud-cli/tests/__init__.py +0 -0
  208. package/.next/standalone/fp-cloud-cli/tests/conftest.py +92 -0
  209. package/.next/standalone/fp-cloud-cli/tests/test_alerting.py +644 -0
  210. package/.next/standalone/fp-cloud-cli/tests/test_analytics.py +385 -0
  211. package/.next/standalone/fp-cloud-cli/tests/test_audits.py +950 -0
  212. package/.next/standalone/fp-cloud-cli/tests/test_auth.py +98 -0
  213. package/.next/standalone/fp-cloud-cli/tests/test_auth_mode.py +272 -0
  214. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +118 -0
  215. package/.next/standalone/fp-cloud-cli/tests/test_client.py +299 -0
  216. package/.next/standalone/fp-cloud-cli/tests/test_commands.py +814 -0
  217. package/.next/standalone/fp-cloud-cli/tests/test_config.py +99 -0
  218. package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +340 -0
  219. package/.next/standalone/fp-cloud-cli/tests/test_dates.py +76 -0
  220. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +481 -0
  221. package/.next/standalone/fp-cloud-cli/tests/test_facets.py +170 -0
  222. package/.next/standalone/fp-cloud-cli/tests/test_failproofai_home.py +722 -0
  223. package/.next/standalone/fp-cloud-cli/tests/test_fp_home_contract.py +198 -0
  224. package/.next/standalone/fp-cloud-cli/tests/test_hardening.py +186 -0
  225. package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +127 -0
  226. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +496 -0
  227. package/.next/standalone/fp-cloud-cli/tests/test_list.py +59 -0
  228. package/.next/standalone/fp-cloud-cli/tests/test_multivalue.py +183 -0
  229. package/.next/standalone/fp-cloud-cli/tests/test_no_customer_identifiers.py +168 -0
  230. package/.next/standalone/fp-cloud-cli/tests/test_operator.py +492 -0
  231. package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +692 -0
  232. package/.next/standalone/fp-cloud-cli/tests/test_output.py +1732 -0
  233. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +191 -0
  234. package/.next/standalone/fp-cloud-cli/tests/test_readme_matches_reality.py +125 -0
  235. package/.next/standalone/fp-cloud-cli/tests/test_review_fixes.py +161 -0
  236. package/.next/standalone/fp-cloud-cli/tests/test_telemetry_completeness.py +90 -0
  237. package/.next/standalone/fp-cloud-cli/tests/test_usage.py +115 -0
  238. package/.next/standalone/fp-cloud-cli/tests/test_v1_origin_diagnostic.py +70 -0
  239. package/.next/standalone/fp-cloud-cli/tests/test_v1_routing.py +214 -0
  240. package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +104 -0
  241. package/.next/standalone/fp-cloud-cli/uv.lock +552 -0
  242. package/.next/standalone/package.json +29 -22
  243. package/.next/standalone/sdk/python/CHANGELOG.md +70 -0
  244. package/.next/standalone/sdk/python/LICENSE +42 -0
  245. package/.next/standalone/sdk/python/MANIFEST.in +21 -0
  246. package/.next/standalone/sdk/python/README.md +466 -0
  247. package/.next/standalone/sdk/python/docs/README.md +249 -0
  248. package/.next/standalone/sdk/python/docs/_shared/README.md +43 -0
  249. package/.next/standalone/sdk/python/docs/_shared/__init__.py +195 -0
  250. package/.next/standalone/sdk/python/docs/crewai/README.md +328 -0
  251. package/.next/standalone/sdk/python/docs/crewai/examples/quickstart.py +61 -0
  252. package/.next/standalone/sdk/python/docs/crewai/examples/research_crew.py +92 -0
  253. package/.next/standalone/sdk/python/docs/langgraph/README.md +481 -0
  254. package/.next/standalone/sdk/python/docs/langgraph/examples/quickstart.py +51 -0
  255. package/.next/standalone/sdk/python/docs/langgraph/examples/research_agent.py +86 -0
  256. package/.next/standalone/sdk/python/docs/langgraph/examples/supervisor_handoff.py +83 -0
  257. package/.next/standalone/sdk/python/docs/llama_index/README.md +332 -0
  258. package/.next/standalone/sdk/python/docs/llama_index/examples/quickstart.py +62 -0
  259. package/.next/standalone/sdk/python/docs/llama_index/examples/research_agent.py +93 -0
  260. package/.next/standalone/sdk/python/docs/manual/README.md +491 -0
  261. package/.next/standalone/sdk/python/docs/manual/examples/quickstart.py +57 -0
  262. package/.next/standalone/sdk/python/docs/manual/examples/research_agent.py +133 -0
  263. package/.next/standalone/sdk/python/docs/pydantic_ai/README.md +323 -0
  264. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/quickstart.py +48 -0
  265. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/research_agent.py +94 -0
  266. package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +131 -0
  267. package/.next/standalone/sdk/python/failproofai_sdk/_context.py +218 -0
  268. package/.next/standalone/sdk/python/failproofai_sdk/_environment.py +78 -0
  269. package/.next/standalone/sdk/python/failproofai_sdk/_events.py +752 -0
  270. package/.next/standalone/sdk/python/failproofai_sdk/_resolver.py +118 -0
  271. package/.next/standalone/sdk/python/failproofai_sdk/_runtime.py +21 -0
  272. package/.next/standalone/sdk/python/failproofai_sdk/_schema.py +317 -0
  273. package/.next/standalone/sdk/python/failproofai_sdk/_scopes.py +422 -0
  274. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -0
  275. package/.next/standalone/sdk/python/failproofai_sdk/_writer.py +750 -0
  276. package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +274 -0
  277. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_compat.py +247 -0
  278. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_core.py +1076 -0
  279. package/.next/standalone/sdk/python/failproofai_sdk/integrations/crewai.py +1669 -0
  280. package/.next/standalone/sdk/python/failproofai_sdk/integrations/langchain.py +2151 -0
  281. package/.next/standalone/sdk/python/failproofai_sdk/integrations/llama_index.py +1588 -0
  282. package/.next/standalone/sdk/python/failproofai_sdk/integrations/pydantic_ai.py +977 -0
  283. package/.next/standalone/sdk/python/failproofai_sdk/py.typed +0 -0
  284. package/.next/standalone/sdk/python/pyproject.toml +92 -0
  285. package/.next/standalone/sdk/python/skill/SKILL.md +399 -0
  286. package/.next/standalone/sdk/python/skill/agents/openai.yaml +8 -0
  287. package/.next/standalone/sdk/python/skill/references/events.md +234 -0
  288. package/.next/standalone/sdk/python/skill/references/frameworks.md +314 -0
  289. package/.next/standalone/sdk/python/skill/references/install.md +100 -0
  290. package/.next/standalone/sdk/python/skill/references/integration.md +352 -0
  291. package/.next/standalone/sdk/python/tests/__init__.py +0 -0
  292. package/.next/standalone/sdk/python/tests/conftest.py +169 -0
  293. package/.next/standalone/sdk/python/tests/integrations/__init__.py +6 -0
  294. package/.next/standalone/sdk/python/tests/integrations/test_crewai.py +1299 -0
  295. package/.next/standalone/sdk/python/tests/integrations/test_langchain.py +2338 -0
  296. package/.next/standalone/sdk/python/tests/integrations/test_llama_index.py +1576 -0
  297. package/.next/standalone/sdk/python/tests/integrations/test_pydantic_ai.py +1102 -0
  298. package/.next/standalone/sdk/python/tests/test_context.py +410 -0
  299. package/.next/standalone/sdk/python/tests/test_docs.py +411 -0
  300. package/.next/standalone/sdk/python/tests/test_durability.py +1352 -0
  301. package/.next/standalone/sdk/python/tests/test_encoding.py +571 -0
  302. package/.next/standalone/sdk/python/tests/test_integrations.py +1127 -0
  303. package/.next/standalone/sdk/python/tests/test_no_customer_identifiers.py +214 -0
  304. package/.next/standalone/sdk/python/tests/test_packaging.py +185 -0
  305. package/.next/standalone/sdk/python/tests/test_resolver_umbrella.py +178 -0
  306. package/.next/standalone/sdk/python/tests/test_scopes.py +487 -0
  307. package/.next/standalone/sdk/python/tests/test_sdk.py +923 -0
  308. package/.next/standalone/sdk/python/tests/test_server_contract.py +650 -0
  309. package/.next/standalone/sdk/python/tests/test_site_docs.py +371 -0
  310. package/.next/standalone/sdk/python/tests/test_skill_snippets.py +165 -0
  311. package/.next/standalone/sdk/python/tests/test_spool_contract.py +287 -0
  312. package/.next/standalone/sdk/python/tests/test_spool_creation.py +360 -0
  313. package/.next/standalone/sdk/python/tests/test_wire_format.py +279 -0
  314. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +378 -0
  315. package/.next/standalone/sdk/python/uv.lock +6005 -0
  316. package/.next/standalone/server.js +1 -1
  317. package/README.md +77 -21
  318. package/bin/failproofai.mjs +145 -11
  319. package/dist/cli.mjs +4482 -2205
  320. package/dist/index.js +5 -5
  321. package/dist/worker.mjs +2399 -1978
  322. package/package.json +29 -22
  323. package/policy-pack/SHA256SUMS +2 -0
  324. package/policy-pack/failproofai-pack.json +732 -0
  325. package/policy-pack/failproofai-pack.mjs +2017 -0
  326. package/scripts/build-policy-pack.mjs +121 -0
  327. package/scripts/changelog-section.py +77 -0
  328. package/scripts/prune-standalone.mjs +3 -0
  329. package/scripts/python-version.py +232 -0
  330. package/scripts/translate-docs/cli.ts +21 -0
  331. package/scripts/translate-docs/mintlify-nav.ts +85 -13
  332. package/scripts/translate-docs/translator.ts +66 -11
  333. package/src/audit/cache.ts +40 -3
  334. package/src/audit/cli.ts +1 -1
  335. package/src/audit/index.ts +1 -1
  336. package/src/audit/replay.ts +89 -3
  337. package/src/audit/report.ts +5 -5
  338. package/src/audit/schedule-cli.ts +49 -53
  339. package/src/hooks/builtin-policies.ts +217 -579
  340. package/src/hooks/cloud-enrollment-cli.ts +66 -27
  341. package/src/hooks/cloud-managed-policies.ts +6 -1
  342. package/src/hooks/configure-wizard.ts +1 -1
  343. package/src/hooks/custom-hooks-loader.ts +89 -6
  344. package/src/hooks/fp-home.ts +92 -4
  345. package/src/hooks/fp-reset.ts +26 -2
  346. package/src/hooks/handler.ts +181 -11
  347. package/src/hooks/harness-cli.ts +79 -25
  348. package/src/hooks/hook-activity-store.ts +13 -3
  349. package/src/hooks/install-prompt.ts +25 -3
  350. package/src/hooks/manager.ts +418 -172
  351. package/src/hooks/migrations.ts +2 -2
  352. package/src/hooks/pack-cli.ts +505 -0
  353. package/src/hooks/pack-failclosed.ts +189 -0
  354. package/src/hooks/pack-manifest.ts +344 -0
  355. package/src/hooks/pack-store.ts +930 -0
  356. package/src/hooks/policy-catalog.ts +535 -0
  357. package/src/hooks/policy-evaluator.ts +24 -16
  358. package/src/hooks/policy-presets.ts +4 -2
  359. package/src/hooks/policy-registry.ts +8 -2
  360. package/src/hooks/policy-types.ts +26 -0
  361. package/src/hooks/session-pause-cli.ts +33 -9
  362. package/src/hooks/tui.ts +570 -0
  363. package/src/hooks/types.ts +15 -1
  364. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
  365. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
  366. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +0 -3
  367. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +0 -3
  368. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
  369. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
  370. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +0 -3
  371. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
  372. package/.next/standalone/.next/server/chunks/_1ger0a2._.js +0 -3
  373. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
  374. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_1iiwm5e._.js +0 -3
  375. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kc8_v5._.js +0 -4
  376. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +0 -4
  377. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mhqk5a._.js +0 -4
  378. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pltpn8._.js +0 -4
  379. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qkcyf-._.js +0 -4
  380. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w0x_ok._.js +0 -4
  381. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +0 -4
  382. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bfex62._.js +0 -5
  383. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +0 -6
  384. package/.next/standalone/.next/static/chunks/36fw6yk1s6x_f.js +0 -1
  385. package/.next/standalone/.next/static/chunks/3tzq0260n17ae.js +0 -6
  386. package/.next/standalone/.next/static/chunks/3wq9x0c-7ld5d.css +0 -2
  387. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_buildManifest.js +0 -0
  388. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_clientMiddlewareManifest.js +0 -0
  389. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_ssgManifest.js +0 -0
@@ -0,0 +1,950 @@
1
+ """Audits: definitions CRUD + runs + findings triage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import httpx
8
+ import pytest
9
+ import respx
10
+
11
+ from fp_cli import analytics
12
+ from fp_cli.app import app
13
+
14
+ BASE = "http://dash.test"
15
+
16
+ _FULL_AUDIT = {
17
+ "id": "au1",
18
+ "name": "nightly-prod",
19
+ "description": "nightly failure sweep",
20
+ "enabled": True,
21
+ "schedule_interval_secs": 86400,
22
+ "window_mode": "since_last",
23
+ "lookback_window_secs": 604800,
24
+ "scope": {"environments": ["prod"]},
25
+ "ignore_error_types": ["TimeoutError"],
26
+ "llm_enabled": True,
27
+ "top_k": 50,
28
+ "sensitivity": "medium",
29
+ "channels": [{"kind": "slack"}],
30
+ "created_by": "ops@example.com",
31
+ "created_at": "2026-06-28T00:00:00Z",
32
+ "updated_at": "2026-06-28T00:00:00Z",
33
+ "open_findings": 3,
34
+ "last_run_status": "succeeded",
35
+ "last_run_finished_at": "2026-06-28T01:00:00Z",
36
+ }
37
+
38
+ _FULL_FINDING = {
39
+ "id": "1f5803aa-aaaa-bbbb-cccc-000000009826",
40
+ "audit_id": "au1",
41
+ "audit_name": "nightly-prod",
42
+ "fingerprint": "fp-1",
43
+ "title": "retry storm on checkout tool",
44
+ "category": "reliability",
45
+ "failure_type": "tool_error",
46
+ "description": "the checkout tool is retried until the budget is exhausted",
47
+ "root_cause_hypothesis": "upstream 502s are not treated as terminal",
48
+ "severity": "critical",
49
+ "magnitude": "big",
50
+ "priority": 0.92,
51
+ "status": "open",
52
+ "occurrences": 41,
53
+ "first_seen_at": "2026-06-20T00:00:00Z",
54
+ "last_seen_at": "2026-06-28T00:00:00Z",
55
+ "recommendation": "treat 502 as terminal and fail fast",
56
+ "expected_impact": "removes ~40 wasted calls a day",
57
+ "effort": "small",
58
+ "evidence": {"sessions": ["run-001"]},
59
+ "evidence_queries": ["SELECT 1"],
60
+ "scope": {"environments": ["prod"]},
61
+ "kind": "failure",
62
+ "assigned_to": None,
63
+ }
64
+
65
+
66
+ # --- audits: definitions ----------------------------------------------------
67
+
68
+
69
+ @respx.mock
70
+ def test_audits_list_json(logged_in, runner):
71
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
72
+ result = runner.invoke(app, ["--json", "audits", "list"])
73
+ assert result.exit_code == 0, result.output
74
+ body = json.loads(result.stdout)["audits"][0]
75
+ assert body["id"] == "au1" and body["name"] == "nightly-prod"
76
+ assert body["scope"] == {"environments": ["prod"]} # opaque blob passes through untouched
77
+ assert body["open_findings"] == 3
78
+
79
+
80
+ @respx.mock
81
+ def test_audits_list_human_boxed(logged_in, runner):
82
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[
83
+ _FULL_AUDIT,
84
+ {**_FULL_AUDIT, "id": "au2", "name": "paused", "enabled": False, "open_findings": 0,
85
+ "created_at": "2026-06-20T00:00:00Z", "last_run_status": None,
86
+ "last_run_finished_at": None},
87
+ ]))
88
+ result = runner.invoke(app, ["audits", "list"])
89
+ assert result.exit_code == 0, result.output
90
+ out = result.stdout + result.stderr
91
+ assert "audits" in out and "newest first" in out
92
+ for c in ("created", "name", "every", "findings", "status", "last run"):
93
+ assert c in out
94
+ assert "nightly-prod" in out and "paused" in out
95
+ assert "1d" in out # humanized 86400s schedule
96
+ assert "never" in out # the paused audit has never run
97
+ assert "on" in out and "off" in out # the on/off split in the footer
98
+ assert "3 open findings" in out # footer findings roll-up
99
+
100
+
101
+ @respx.mock
102
+ def test_audits_list_enabled_only(logged_in, runner):
103
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[
104
+ _FULL_AUDIT, {**_FULL_AUDIT, "id": "au2", "name": "paused", "enabled": False}]))
105
+ result = runner.invoke(app, ["--json", "audits", "list", "--enabled-only"])
106
+ assert result.exit_code == 0, result.output
107
+ audits = json.loads(result.stdout)["audits"]
108
+ assert [a["name"] for a in audits] == ["nightly-prod"]
109
+
110
+
111
+ @respx.mock
112
+ def test_audits_show_by_name_json(logged_in, runner):
113
+ # `show` resolves the NAME through the list endpoint (no GET by id needed).
114
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
115
+ result = runner.invoke(app, ["--json", "audits", "show", "nightly-prod"])
116
+ assert result.exit_code == 0, result.output
117
+ assert json.loads(result.stdout)["scope"] == {"environments": ["prod"]}
118
+
119
+
120
+ @respx.mock
121
+ def test_audits_show_human_cards(logged_in, runner):
122
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
123
+ result = runner.invoke(app, ["audits", "show", "nightly-prod"])
124
+ assert result.exit_code == 0, result.output
125
+ out = result.stdout
126
+ assert "nightly-prod" in out and "enabled" in out and "3 open findings" in out
127
+ assert "schedule" in out and "runs every" in out and "1d" in out
128
+ assert "scope" in out and "prod" in out and "TimeoutError" in out # covers + ignores
129
+ assert "analysis" in out and "sensitivity" in out and "medium" in out
130
+ assert "channels" in out and "slack" in out
131
+
132
+
133
+ @respx.mock
134
+ def test_audits_show_not_found_exits_6(logged_in, runner):
135
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
136
+ result = runner.invoke(app, ["audits", "show", "ghost"])
137
+ assert result.exit_code == 6
138
+
139
+
140
+ @respx.mock
141
+ def test_audits_create_sends_definition(logged_in, runner):
142
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[])) # no collision
143
+ route = respx.post(f"{BASE}/api/audits").mock(
144
+ return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"}))
145
+ result = runner.invoke(app, [
146
+ "--json", "audits", "create", "nightly-prod",
147
+ "--scope", '{"environments":["prod"]}',
148
+ "--schedule-interval-secs", "86400",
149
+ "--sensitivity", "high",
150
+ "--ignore-error-type", "TimeoutError,ValueError",
151
+ ])
152
+ assert result.exit_code == 0, result.output
153
+ body = json.loads(route.calls.last.request.content)
154
+ assert body["name"] == "nightly-prod"
155
+ assert body["scope"] == {"environments": ["prod"]}
156
+ assert body["sensitivity"] == "high"
157
+ assert body["ignore_error_types"] == ["TimeoutError", "ValueError"] # CSV → list
158
+ assert json.loads(result.stdout)["id"] == "au9"
159
+
160
+
161
+ @respx.mock
162
+ def test_audits_create_from_file(logged_in, runner, tmp_path):
163
+ payload = {"name": "weekly", "schedule_interval_secs": 604800, "sensitivity": "low"}
164
+ f = tmp_path / "audit.json"
165
+ f.write_text(json.dumps(payload))
166
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[]))
167
+ route = respx.post(f"{BASE}/api/audits").mock(
168
+ return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"}))
169
+ result = runner.invoke(app, ["--json", "audits", "create", "weekly", "--file", str(f),
170
+ "--sensitivity", "high"]) # a flag overrides the file
171
+ assert result.exit_code == 0, result.output
172
+ body = json.loads(route.calls.last.request.content)
173
+ assert body["schedule_interval_secs"] == 604800 and body["sensitivity"] == "high"
174
+
175
+
176
+ @respx.mock
177
+ def test_audits_create_sends_context_in_the_same_request(logged_in, runner):
178
+ """Context goes with the definition, not in a follow-up request.
179
+
180
+ Creating an enabled audit queues its first run due immediately, so a second
181
+ request can lose the race: the run an operator watches would argue without
182
+ the brief they just attached. No `PUT .../context` route is registered here
183
+ deliberately — if the command made that call, respx would fail the test."""
184
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[]))
185
+ route = respx.post(f"{BASE}/api/audits").mock(
186
+ return_value=httpx.Response(201, json={"id": "au9", "created_at": "t", "sources": 2}))
187
+ other = "https://docs.example.com/slo"
188
+ result = runner.invoke(app, [
189
+ "--json", "audits", "create", "nightly-prod",
190
+ "--text", "checkout agent for a retail store",
191
+ "--url", _DOC_URL, "--url", other,
192
+ ])
193
+ assert result.exit_code == 0, result.output
194
+ body = json.loads(route.calls.last.request.content)
195
+ assert body["context"] == {
196
+ "text": "checkout agent for a retail store",
197
+ "urls": [_DOC_URL, other],
198
+ }
199
+
200
+
201
+ @respx.mock
202
+ def test_audits_create_without_context_sends_no_context_key(logged_in, runner):
203
+ """An omitted brief must not become an empty one — `context` absent, not `{}`."""
204
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[]))
205
+ route = respx.post(f"{BASE}/api/audits").mock(
206
+ return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"}))
207
+ result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod"])
208
+ assert result.exit_code == 0, result.output
209
+ assert "context" not in json.loads(route.calls.last.request.content)
210
+
211
+
212
+ @respx.mock
213
+ def test_audits_create_reads_the_brief_from_a_file(logged_in, runner, tmp_path):
214
+ f = tmp_path / "brief.md"
215
+ f.write_text("# Checkout agent\n\nRetries are expected on 429.")
216
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[]))
217
+ route = respx.post(f"{BASE}/api/audits").mock(
218
+ return_value=httpx.Response(201, json={"id": "au9", "created_at": "t", "sources": 0}))
219
+ result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod",
220
+ "--text-file", str(f)])
221
+ assert result.exit_code == 0, result.output
222
+ body = json.loads(route.calls.last.request.content)
223
+ assert body["context"]["text"].startswith("# Checkout agent")
224
+ assert body["context"]["urls"] == []
225
+
226
+
227
+ def test_audits_create_rejects_too_many_urls(logged_in, runner):
228
+ # The server's cap, applied before the request goes out.
229
+ result = runner.invoke(app, ["audits", "create", "x"] + sum(
230
+ (["--url", f"https://docs.example.com/{i}"] for i in range(6)), []))
231
+ assert result.exit_code == 2, result.output
232
+
233
+
234
+ def test_audits_create_rejects_text_and_text_file_together(logged_in, runner):
235
+ result = runner.invoke(app, ["audits", "create", "x", "--text", "a", "--text-file", "b.md"])
236
+ assert result.exit_code == 2, result.output
237
+
238
+
239
+ @respx.mock
240
+ def test_audits_create_human_renders_card(logged_in, runner):
241
+ # Human mode: pre-check (no collision) → POST → re-read the canonical audit → render the card.
242
+ respx.get(f"{BASE}/api/audits").mock(side_effect=[
243
+ httpx.Response(200, json=[]), # pre-check: no collision
244
+ httpx.Response(200, json=[_FULL_AUDIT]), # re-fetch after the write
245
+ ])
246
+ respx.post(f"{BASE}/api/audits").mock(
247
+ return_value=httpx.Response(201, json={"id": "au1", "created_at": "t"}))
248
+ result = runner.invoke(app, ["audits", "create", "nightly-prod"])
249
+ assert result.exit_code == 0, result.output
250
+ out = result.stdout
251
+ assert "audit created" in out and "nightly-prod" in out
252
+ assert "schedule" in out and "channels" in out # the same cards `show` renders
253
+
254
+
255
+ @respx.mock
256
+ def test_audits_create_renders_from_body_when_refetch_misses(logged_in, runner):
257
+ # If the post-write re-read doesn't find the audit, the card still renders — built from the
258
+ # request body — rather than crashing on the missing canonical row.
259
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[]))
260
+ respx.post(f"{BASE}/api/audits").mock(
261
+ return_value=httpx.Response(201, json={"id": "au9", "created_at": "t"}))
262
+ result = runner.invoke(app, ["audits", "create", "weekly", "--sensitivity", "high",
263
+ "--scope", '{"environments":["prod"]}'])
264
+ assert result.exit_code == 0, result.output
265
+ out = result.stdout
266
+ assert "audit created" in out and "weekly" in out
267
+ assert "high" in out and "prod" in out
268
+
269
+
270
+ @respx.mock
271
+ def test_audits_create_name_collision_exits_2(logged_in, runner):
272
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
273
+ result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod"])
274
+ assert result.exit_code == 2
275
+ assert "already exists" in json.loads(result.stdout)["error"]
276
+
277
+
278
+ def test_audits_create_bad_interval_exits_2(logged_in, runner):
279
+ # Below the server's 1h floor — rejected locally, before any HTTP call.
280
+ result = runner.invoke(app, ["audits", "create", "x", "--schedule-interval-secs", "60"])
281
+ assert result.exit_code == 2
282
+
283
+
284
+ def test_audits_create_bad_window_mode_exits_2(logged_in, runner):
285
+ result = runner.invoke(app, ["audits", "create", "x", "--window-mode", "bogus"])
286
+ assert result.exit_code == 2
287
+
288
+
289
+ def test_audits_create_bad_sensitivity_exits_2(logged_in, runner):
290
+ result = runner.invoke(app, ["audits", "create", "x", "--sensitivity", "bogus"])
291
+ assert result.exit_code == 2
292
+
293
+
294
+ @respx.mock
295
+ def test_audits_edit_flag_only_resends_full_body(logged_in, runner):
296
+ # A flag-only edit resolves the NAME via the list, then PUTs the WHOLE definition back with
297
+ # just the changed field — the server replaces the definition on update.
298
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
299
+ put = respx.put(f"{BASE}/api/audits/au1").mock(
300
+ return_value=httpx.Response(200, json={"id": "au1", "updated": True}))
301
+ result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod",
302
+ "--sensitivity", "high", "--yes"])
303
+ assert result.exit_code == 0, result.output
304
+ body = json.loads(put.calls.last.request.content)
305
+ assert body["sensitivity"] == "high" # the changed field
306
+ assert body["name"] == "nightly-prod" # everything else carried over
307
+ assert body["scope"] == {"environments": ["prod"]}
308
+ assert body["schedule_interval_secs"] == 86400
309
+ assert body["channels"] == [{"kind": "slack"}]
310
+ assert body["enabled"] is True
311
+ assert "open_findings" not in body # server-derived state never sent
312
+
313
+
314
+ @respx.mock
315
+ def test_audits_edit_disable(logged_in, runner):
316
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
317
+ put = respx.put(f"{BASE}/api/audits/au1").mock(
318
+ return_value=httpx.Response(200, json={"id": "au1", "updated": True}))
319
+ result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod", "--disabled", "--yes"])
320
+ assert result.exit_code == 0, result.output
321
+ assert json.loads(put.calls.last.request.content)["enabled"] is False
322
+
323
+
324
+ @respx.mock
325
+ def test_audits_edit_human_renders_card(logged_in, runner):
326
+ # First GET resolves the name (old state); the post-PUT re-fetch returns the new state.
327
+ respx.get(f"{BASE}/api/audits").mock(side_effect=[
328
+ httpx.Response(200, json=[_FULL_AUDIT]),
329
+ httpx.Response(200, json=[{**_FULL_AUDIT, "sensitivity": "high"}]),
330
+ ])
331
+ respx.put(f"{BASE}/api/audits/au1").mock(
332
+ return_value=httpx.Response(200, json={"id": "au1", "updated": True}))
333
+ result = runner.invoke(app, ["audits", "edit", "nightly-prod", "--sensitivity", "high", "--yes"])
334
+ assert result.exit_code == 0, result.output
335
+ out = result.stdout
336
+ assert "audit updated" in out and "nightly-prod" in out and "high" in out
337
+
338
+
339
+ @respx.mock
340
+ def test_audits_edit_rename_collision_exits_2(logged_in, runner):
341
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[
342
+ _FULL_AUDIT, {**_FULL_AUDIT, "id": "au2", "name": "taken"}]))
343
+ result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod", "--name", "taken", "--yes"])
344
+ assert result.exit_code == 2
345
+ assert "already exists" in json.loads(result.stdout)["error"]
346
+
347
+
348
+ @respx.mock
349
+ def test_audits_edit_missing_audit_exits_6(logged_in, runner):
350
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
351
+ put = respx.put(f"{BASE}/api/audits/au1").mock(return_value=httpx.Response(200, json={}))
352
+ result = runner.invoke(app, ["audits", "edit", "ghost", "--sensitivity", "high", "--yes"])
353
+ assert result.exit_code == 6, result.output
354
+ assert not put.called
355
+
356
+
357
+ @respx.mock
358
+ def test_audits_delete_by_name(logged_in, runner):
359
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
360
+ route = respx.delete(f"{BASE}/api/audits/au1").mock(return_value=httpx.Response(204))
361
+ result = runner.invoke(app, ["--json", "audits", "delete", "nightly-prod", "--yes"])
362
+ assert result.exit_code == 0, result.output
363
+ assert route.called
364
+ body = json.loads(result.stdout)
365
+ assert body["deleted"] is True and body["name"] == "nightly-prod"
366
+
367
+
368
+ @respx.mock
369
+ def test_audits_delete_not_found_exits_6(logged_in, runner):
370
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
371
+ result = runner.invoke(app, ["audits", "delete", "ghost", "--yes"])
372
+ assert result.exit_code == 6
373
+
374
+
375
+ # --- audits: run + runs -----------------------------------------------------
376
+
377
+
378
+ @respx.mock
379
+ def test_audits_run_queues_202(logged_in, runner):
380
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
381
+ route = respx.post(f"{BASE}/api/audits/au1/run").mock(
382
+ return_value=httpx.Response(202, json={"queued": True}))
383
+ result = runner.invoke(app, ["--json", "audits", "run", "nightly-prod"])
384
+ assert result.exit_code == 0, result.output
385
+ assert route.called
386
+ assert json.loads(result.stdout)["queued"] is True
387
+
388
+
389
+ @respx.mock
390
+ def test_audits_run_human(logged_in, runner):
391
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
392
+ respx.post(f"{BASE}/api/audits/au1/run").mock(
393
+ return_value=httpx.Response(202, json={"queued": True}))
394
+ result = runner.invoke(app, ["audits", "run", "nightly-prod"])
395
+ assert result.exit_code == 0, result.output
396
+ out = result.stdout + result.stderr
397
+ assert "queued a run for" in out and "nightly-prod" in out
398
+
399
+
400
+ @respx.mock
401
+ def test_audits_run_conflict_409(logged_in, runner):
402
+ # A run already in progress → the server's 409 message, never a double-queue.
403
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
404
+ respx.post(f"{BASE}/api/audits/au1/run").mock(return_value=httpx.Response(
405
+ 409, json={"error": "a run is already in progress; it must finish before another can be queued"}))
406
+ result = runner.invoke(app, ["audits", "run", "nightly-prod"])
407
+ assert result.exit_code == 1, result.output # ApiError → exit 1
408
+ assert "already in progress" in result.stderr
409
+ assert "HTTP" not in (result.stdout + result.stderr) # never leak the raw status
410
+
411
+
412
+ @respx.mock
413
+ def test_audits_run_conflict_409_json(logged_in, runner):
414
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
415
+ respx.post(f"{BASE}/api/audits/au1/run").mock(return_value=httpx.Response(
416
+ 409, json={"error": "a run is already in progress; it must finish before another can be queued"}))
417
+ result = runner.invoke(app, ["--json", "audits", "run", "nightly-prod"])
418
+ assert result.exit_code == 1
419
+ body = json.loads(result.stdout)
420
+ assert "already in progress" in body["error"] and body["status"] == 409
421
+ assert "audits runs" in body["hint"]
422
+
423
+
424
+ @respx.mock
425
+ def test_audits_runs_json(logged_in, runner):
426
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
427
+ respx.get(f"{BASE}/api/audits/au1/runs").mock(return_value=httpx.Response(200, json=[
428
+ {"id": "r1", "status": "succeeded", "trigger_kind": "schedule",
429
+ "window_from": "2026-06-27T00:00:00Z", "window_to": "2026-06-28T00:00:00Z",
430
+ "started_at": "2026-06-28T00:00:00Z", "finished_at": "2026-06-28T00:00:30Z",
431
+ "stats": {"events": 100}, "findings_count": 4, "new_findings_count": 1,
432
+ "report": "all good", "error": None},
433
+ ]))
434
+ result = runner.invoke(app, ["--json", "audits", "runs", "nightly-prod"])
435
+ assert result.exit_code == 0, result.output
436
+ run = json.loads(result.stdout)["runs"][0]
437
+ assert run["id"] == "r1" and run["findings_count"] == 4 and run["stats"] == {"events": 100}
438
+
439
+
440
+ @respx.mock
441
+ def test_audits_runs_human_boxed(logged_in, runner):
442
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
443
+ respx.get(f"{BASE}/api/audits/au1/runs").mock(return_value=httpx.Response(200, json=[
444
+ {"id": "r1", "status": "succeeded", "trigger_kind": "schedule",
445
+ "started_at": "2026-06-28T00:00:00Z", "finished_at": "2026-06-28T00:00:30Z",
446
+ "findings_count": 4, "new_findings_count": 1},
447
+ {"id": "r2", "status": "failed", "trigger_kind": "manual",
448
+ "started_at": "2026-06-27T00:00:00Z", "finished_at": "2026-06-27T00:00:10Z",
449
+ "findings_count": 0, "new_findings_count": 0, "error": "clickhouse timeout"},
450
+ ]))
451
+ result = runner.invoke(app, ["audits", "runs", "nightly-prod"])
452
+ assert result.exit_code == 0, result.output
453
+ out = result.stdout + result.stderr
454
+ assert "runs" in out and "nightly-prod" in out
455
+ for c in ("started", "status", "trigger", "findings", "new", "took"):
456
+ assert c in out
457
+ assert "succeeded" in out and "failed" in out and "30s" in out # computed wall time
458
+
459
+
460
+ @respx.mock
461
+ def test_audits_runs_limit_zero_exits_2(logged_in, runner):
462
+ result = runner.invoke(app, ["audits", "runs", "nightly-prod", "--limit", "0"])
463
+ assert result.exit_code == 2
464
+
465
+
466
+ # --- audits: findings -------------------------------------------------------
467
+
468
+
469
+ @respx.mock
470
+ def test_audits_findings_json_with_filters(logged_in, runner):
471
+ route = respx.get(f"{BASE}/api/audits/findings").mock(
472
+ return_value=httpx.Response(200, json=[_FULL_FINDING]))
473
+ result = runner.invoke(app, ["--json", "audits", "findings", "--status", "open,recurring",
474
+ "--run-id", "r1", "--limit", "20", "--offset", "5"])
475
+ assert result.exit_code == 0, result.output
476
+ params = route.calls.last.request.url.params
477
+ assert params["status"] == "open,recurring"
478
+ assert params["run_id"] == "r1"
479
+ assert params["limit"] == "20" and params["offset"] == "5"
480
+ assert json.loads(result.stdout)["findings"][0]["id"] == _FULL_FINDING["id"]
481
+
482
+
483
+ @respx.mock
484
+ def test_audits_findings_by_audit_name_resolves_id(logged_in, runner):
485
+ # `--audit` takes the audit NAME; the CLI resolves it to the id the server filter wants.
486
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
487
+ route = respx.get(f"{BASE}/api/audits/findings").mock(return_value=httpx.Response(200, json=[]))
488
+ result = runner.invoke(app, ["--json", "audits", "findings", "--audit", "nightly-prod"])
489
+ assert result.exit_code == 0, result.output
490
+ assert route.calls.last.request.url.params["audit_id"] == "au1"
491
+
492
+
493
+ @respx.mock
494
+ def test_audits_findings_unknown_audit_exits_6(logged_in, runner):
495
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
496
+ result = runner.invoke(app, ["audits", "findings", "--audit", "ghost"])
497
+ assert result.exit_code == 6
498
+
499
+
500
+ @respx.mock
501
+ def test_audits_findings_human_boxed(logged_in, runner):
502
+ respx.get(f"{BASE}/api/audits/findings").mock(return_value=httpx.Response(200, json=[
503
+ _FULL_FINDING,
504
+ {**_FULL_FINDING, "id": "22220000-aaaa-bbbb-cccc-000000001111", "title": "slow tool",
505
+ "severity": "warning", "status": "muted", "kind": "improvement", "occurrences": 2},
506
+ ]))
507
+ result = runner.invoke(app, ["audits", "findings"])
508
+ assert result.exit_code == 0, result.output
509
+ out = result.stdout + result.stderr
510
+ assert "findings" in out and "highest priority first" in out
511
+ for c in ("title", "severity", "status", "kind", "seen", "last"):
512
+ assert c in out
513
+ assert "1f58" in out # short id is the handle
514
+ # The title truncates to the leftover width (80-col test console) so the fixed columns
515
+ # always survive — the full text is in `audits finding` / --json.
516
+ assert "retry" in out and "critical" in out
517
+ assert "open" in out and "muted" in out # status words + footer distribution
518
+ assert "1 critical" in out # footer severity roll-up
519
+
520
+
521
+ def test_audits_findings_bad_status_exits_2(logged_in, runner):
522
+ result = runner.invoke(app, ["audits", "findings", "--status", "bogus"])
523
+ assert result.exit_code == 2
524
+
525
+
526
+ def test_audits_findings_limit_zero_exits_2(logged_in, runner):
527
+ result = runner.invoke(app, ["audits", "findings", "--limit", "0"])
528
+ assert result.exit_code == 2
529
+
530
+
531
+ def test_audits_findings_negative_offset_exits_2(logged_in, runner):
532
+ result = runner.invoke(app, ["audits", "findings", "--offset", "-1"])
533
+ assert result.exit_code == 2
534
+
535
+
536
+ @respx.mock
537
+ def test_audits_finding_show_json(logged_in, runner):
538
+ fid = _FULL_FINDING["id"]
539
+ respx.get(f"{BASE}/api/audits/findings/{fid}").mock(
540
+ return_value=httpx.Response(200, json=_FULL_FINDING))
541
+ result = runner.invoke(app, ["--json", "audits", "finding", fid])
542
+ assert result.exit_code == 0, result.output
543
+ body = json.loads(result.stdout)
544
+ assert body["title"] == "retry storm on checkout tool"
545
+ assert body["evidence"] == {"sessions": ["run-001"]}
546
+
547
+
548
+ @respx.mock
549
+ def test_audits_finding_show_human_cards(logged_in, runner):
550
+ fid = _FULL_FINDING["id"]
551
+ respx.get(f"{BASE}/api/audits/findings/{fid}").mock(
552
+ return_value=httpx.Response(200, json=_FULL_FINDING))
553
+ result = runner.invoke(app, ["audits", "finding", fid])
554
+ assert result.exit_code == 0, result.output
555
+ out = result.stdout
556
+ assert "retry storm on checkout tool" in out and "critical" in out and "failure" in out
557
+ assert "seen" in out and "41" in out
558
+ assert "analysis" in out and "upstream 502s" in out # root-cause hypothesis
559
+ assert "recommendation" in out and "fail fast" in out and "small" in out
560
+ assert "evidence" in out and "run-001" in out
561
+
562
+
563
+ @respx.mock
564
+ def test_audits_finding_not_found_exits_6(logged_in, runner):
565
+ fid = "1f5803aa-aaaa-bbbb-cccc-000000000000"
566
+ respx.get(f"{BASE}/api/audits/findings/{fid}").mock(
567
+ return_value=httpx.Response(404, json={"error": "not found"}))
568
+ result = runner.invoke(app, ["audits", "finding", fid])
569
+ assert result.exit_code == 6, result.output
570
+ assert "no finding" in result.stderr
571
+ assert "HTTP" not in (result.stdout + result.stderr)
572
+
573
+
574
+ @respx.mock
575
+ def test_audits_finding_malformed_id_is_not_found(logged_in, runner):
576
+ # The server's path extractor answers a non-UUID id with a 400 — surface the calm not-found.
577
+ respx.get(f"{BASE}/api/audits/findings/not-a-uuid").mock(
578
+ return_value=httpx.Response(400, json={"error": "invalid uuid"}))
579
+ result = runner.invoke(app, ["--json", "audits", "finding", "not-a-uuid"])
580
+ assert result.exit_code == 6
581
+ assert "no finding" in json.loads(result.stdout)["error"]
582
+
583
+
584
+ @respx.mock
585
+ def test_audits_finding_forbidden_exits_5(logged_in, runner):
586
+ fid = _FULL_FINDING["id"]
587
+ respx.get(f"{BASE}/api/audits/findings/{fid}").mock(return_value=httpx.Response(
588
+ 403, json={"error": "forbidden", "required_permission": "audits:read"}))
589
+ result = runner.invoke(app, ["audits", "finding", fid])
590
+ assert result.exit_code == 5, result.output
591
+ assert "audits:read" in result.stderr # the denial names the missing permission
592
+
593
+
594
+ @respx.mock
595
+ def test_audits_list_forbidden_exits_5(logged_in, runner):
596
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(
597
+ 403, json={"error": "forbidden", "required_permission": "audits:read"}))
598
+ result = runner.invoke(app, ["--json", "audits", "list"])
599
+ assert result.exit_code == 5
600
+ assert "audits:read" in json.loads(result.stdout)["error"]
601
+
602
+
603
+ # --- audits: finding triage -------------------------------------------------
604
+
605
+
606
+ @respx.mock
607
+ def test_finding_ack(logged_in, runner):
608
+ fid = _FULL_FINDING["id"]
609
+ route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(
610
+ return_value=httpx.Response(200, json={"id": fid, "action": "ack", "ok": True}))
611
+ result = runner.invoke(app, ["--json", "audits", "ack", fid, "--reason", "known"])
612
+ assert result.exit_code == 0, result.output
613
+ body = json.loads(route.calls.last.request.content)
614
+ assert body == {"action": "ack", "reason": "known"}
615
+ assert json.loads(result.stdout)["ok"] is True
616
+
617
+
618
+ @respx.mock
619
+ def test_finding_mute(logged_in, runner):
620
+ fid = _FULL_FINDING["id"]
621
+ route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(
622
+ return_value=httpx.Response(200, json={"id": fid, "action": "mute", "ok": True}))
623
+ result = runner.invoke(app, ["--json", "audits", "mute", fid, "--reason", "expected", "--yes"])
624
+ assert result.exit_code == 0, result.output
625
+ assert json.loads(route.calls.last.request.content)["action"] == "mute"
626
+
627
+
628
+ @respx.mock
629
+ def test_finding_dismiss(logged_in, runner):
630
+ fid = _FULL_FINDING["id"]
631
+ route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(
632
+ return_value=httpx.Response(200, json={"id": fid, "action": "dismiss", "ok": True}))
633
+ result = runner.invoke(app, ["--json", "audits", "dismiss", fid, "--yes"])
634
+ assert result.exit_code == 0, result.output
635
+ assert json.loads(route.calls.last.request.content) == {"action": "dismiss"}
636
+
637
+
638
+ @respx.mock
639
+ def test_finding_resolve_human(logged_in, runner):
640
+ fid = _FULL_FINDING["id"]
641
+ respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(
642
+ return_value=httpx.Response(200, json={"id": fid, "action": "resolve", "ok": True}))
643
+ result = runner.invoke(app, ["audits", "resolve", fid, "--yes"])
644
+ assert result.exit_code == 0, result.output
645
+ assert "resolved finding" in result.stderr
646
+
647
+
648
+ @respx.mock
649
+ def test_finding_reopen(logged_in, runner):
650
+ fid = _FULL_FINDING["id"]
651
+ route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(
652
+ return_value=httpx.Response(200, json={"id": fid, "action": "reopen", "ok": True}))
653
+ result = runner.invoke(app, ["audits", "reopen", fid])
654
+ assert result.exit_code == 0, result.output
655
+ assert json.loads(route.calls.last.request.content) == {"action": "reopen"}
656
+ assert "reopened finding" in result.stderr
657
+
658
+
659
+ @respx.mock
660
+ def test_finding_assign_sends_assignee(logged_in, runner):
661
+ fid = _FULL_FINDING["id"]
662
+ route = respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(
663
+ return_value=httpx.Response(200, json={"id": fid, "action": "assign", "ok": True}))
664
+ result = runner.invoke(app, ["audits", "assign", fid, "--to", "alice@example.com"])
665
+ assert result.exit_code == 0, result.output
666
+ assert json.loads(route.calls.last.request.content) == {
667
+ "action": "assign", "assigned_to": "alice@example.com"}
668
+ assert "assigned finding" in result.stderr and "alice@example.com" in result.stderr
669
+
670
+
671
+ def test_finding_assign_without_to_exits_2(logged_in, runner):
672
+ result = runner.invoke(app, ["audits", "assign", _FULL_FINDING["id"]])
673
+ assert result.exit_code == 2 # --to is required
674
+
675
+
676
+ def test_finding_unknown_action_exits_2(logged_in, runner):
677
+ # The triage verbs are the command surface — an unknown action is a usage error, never a 422.
678
+ result = runner.invoke(app, ["audits", "snooze", _FULL_FINDING["id"]])
679
+ assert result.exit_code == 2
680
+
681
+
682
+ def test_triage_action_validator_rejects_unknown():
683
+ # The shared validator behind every triage verb (defence in depth for the --action value).
684
+ import pytest
685
+
686
+ from fp_cli import _click_compat as click # the Click Typer is running
687
+ from fp_cli.commands import audits_cmds
688
+
689
+ with pytest.raises(click.BadParameter):
690
+ audits_cmds._validate_action("snooze")
691
+ assert audits_cmds._validate_action("ack") == "ack"
692
+
693
+
694
+ @respx.mock
695
+ def test_finding_triage_not_found_exits_6(logged_in, runner):
696
+ fid = "1f5803aa-aaaa-bbbb-cccc-000000000000"
697
+ respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(
698
+ return_value=httpx.Response(404, json={"error": "not found"}))
699
+ result = runner.invoke(app, ["audits", "ack", fid])
700
+ assert result.exit_code == 6, result.output
701
+ assert "no finding" in result.stderr
702
+
703
+
704
+ @respx.mock
705
+ def test_finding_triage_forbidden_exits_5(logged_in, runner):
706
+ fid = _FULL_FINDING["id"]
707
+ respx.post(f"{BASE}/api/audits/findings/{fid}/status").mock(return_value=httpx.Response(
708
+ 403, json={"error": "forbidden", "required_permission": "audits:write"}))
709
+ result = runner.invoke(app, ["--json", "audits", "ack", fid])
710
+ assert result.exit_code == 5
711
+ assert "audits:write" in json.loads(result.stdout)["error"]
712
+
713
+
714
+ # --- audits: reference context ---
715
+ #
716
+ # This surface shipped with zero tests, and the first thing an operator does
717
+ # after `context-set` is the thing that crashed: `context-show` dereferenced a
718
+ # theme token that does not exist, so every human-mode render raised
719
+ # AttributeError. `--json` returns before the formatter, which is why nothing
720
+ # scripted noticed. The human-render assertions below are the ones that matter.
721
+
722
+ _DOC_URL = "https://docs.example.com/runbook"
723
+
724
+ _CTX_PENDING = {
725
+ "text": "checkout agent for a retail store",
726
+ "sources": [
727
+ {"id": "s1", "url": _DOC_URL, "position": 0,
728
+ "status": "pending", "final_url": None, "title": None, "content_type": None,
729
+ "preview": "", "preview_truncated": False, "chars": 0, "chars_total": 0,
730
+ "truncated": False, "redactions": 0, "injection_markers": [],
731
+ "error_code": None, "error_detail": None, "fetched_at": None,
732
+ "attempted_at": None, "changed_at": None},
733
+ ],
734
+ }
735
+
736
+
737
+ @respx.mock
738
+ def test_context_show_renders_a_pending_source(logged_in, runner):
739
+ """The regression test for the AttributeError: a non-`ok` status took the
740
+ branch that referenced a missing theme token."""
741
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
742
+ respx.get(f"{BASE}/api/audits/au1/context").mock(
743
+ return_value=httpx.Response(200, json=_CTX_PENDING))
744
+ result = runner.invoke(app, ["audits", "context-show", "nightly-prod"])
745
+ assert result.exit_code == 0, result.output
746
+ assert "pending" in result.stderr
747
+ assert "docs.example.com" in result.stderr
748
+
749
+
750
+ @respx.mock
751
+ def test_context_show_renders_an_ok_source_with_injection_markers(logged_in, runner):
752
+ """The other crashing branch: a stored page that carries markers — i.e. the
753
+ exact case the feature exists to surface."""
754
+ ctx = json.loads(json.dumps(_CTX_PENDING))
755
+ ctx["sources"][0].update(status="ok", chars=1200, chars_total=1200,
756
+ injection_markers=["ignore previous instructions"])
757
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
758
+ respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx))
759
+ result = runner.invoke(app, ["audits", "context-show", "nightly-prod"])
760
+ assert result.exit_code == 0, result.output
761
+ assert "review" in result.stderr
762
+ assert "instructions to an AI" in result.stderr
763
+
764
+
765
+ @respx.mock
766
+ def test_context_show_tolerates_an_unknown_status(logged_in, runner):
767
+ """The server owns this vocabulary; a value we do not know must render, not raise."""
768
+ ctx = json.loads(json.dumps(_CTX_PENDING))
769
+ ctx["sources"][0]["status"] = "quarantined"
770
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
771
+ respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx))
772
+ result = runner.invoke(app, ["audits", "context-show", "nightly-prod"])
773
+ assert result.exit_code == 0, result.output
774
+ assert "quarantined" in result.stderr
775
+
776
+
777
+ @respx.mock
778
+ def test_context_show_marks_a_retained_snapshot_as_still_used(logged_in, runner):
779
+ """A `failed` re-read does not withdraw the snapshot we already hold — the
780
+ server still puts it in the prompt. Reporting it as simply "failed" told the
781
+ operator the opposite, and suppressed its injection markers with it."""
782
+ ctx = json.loads(json.dumps(_CTX_PENDING))
783
+ ctx["sources"][0].update(status="failed", chars=1500, chars_total=1500,
784
+ error_detail="connection reset",
785
+ injection_markers=["ignore previous instructions"])
786
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
787
+ respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx))
788
+ result = runner.invoke(app, ["audits", "context-show", "nightly-prod"])
789
+ assert result.exit_code == 0, result.output
790
+ assert "review" in result.stderr, "a retained page with markers must still say review"
791
+ assert "still used" in result.stderr
792
+ assert "1500 chars" in result.stderr
793
+ assert "instructions to an AI" in result.stderr
794
+
795
+
796
+ @respx.mock
797
+ def test_context_show_does_not_claim_a_blocked_page_is_used(logged_in, runner):
798
+ """`blocked` is the one status that overrides a retained snapshot: the guard
799
+ refused the URL, so it is not in the prompt and must not read as though it is."""
800
+ ctx = json.loads(json.dumps(_CTX_PENDING))
801
+ ctx["sources"][0].update(status="blocked", chars=900, error_detail="private address")
802
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
803
+ respx.get(f"{BASE}/api/audits/au1/context").mock(return_value=httpx.Response(200, json=ctx))
804
+ result = runner.invoke(app, ["audits", "context-show", "nightly-prod"])
805
+ assert result.exit_code == 0, result.output
806
+ assert "blocked" in result.stderr
807
+ assert "still used" not in result.stderr
808
+ assert "private address" in result.stderr
809
+
810
+
811
+ @respx.mock
812
+ def test_context_set_text_only_preserves_existing_urls(logged_in, runner):
813
+ """`--text` alone used to send `"urls": []`, deleting every reference page and
814
+ its snapshot — while the brief was read-merged three lines away."""
815
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
816
+ respx.get(f"{BASE}/api/audits/au1/context").mock(
817
+ return_value=httpx.Response(200, json=_CTX_PENDING))
818
+ put = respx.put(f"{BASE}/api/audits/au1/context").mock(
819
+ return_value=httpx.Response(200, json={"id": "au1", "sources": 1, "queued": 0}))
820
+ result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod",
821
+ "--text", "new brief"])
822
+ assert result.exit_code == 0, result.output
823
+ sent = json.loads(put.calls[0].request.content)
824
+ assert sent["text"] == "new brief"
825
+ assert sent["urls"] == [_DOC_URL], "URLs must survive a --text edit"
826
+
827
+
828
+ @respx.mock
829
+ def test_context_set_url_only_preserves_the_brief(logged_in, runner):
830
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
831
+ respx.get(f"{BASE}/api/audits/au1/context").mock(
832
+ return_value=httpx.Response(200, json=_CTX_PENDING))
833
+ put = respx.put(f"{BASE}/api/audits/au1/context").mock(
834
+ return_value=httpx.Response(200, json={"id": "au1", "sources": 1, "queued": 1}))
835
+ other = "https://docs.example.com/other"
836
+ result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod",
837
+ "--url", other])
838
+ assert result.exit_code == 0, result.output
839
+ sent = json.loads(put.calls[0].request.content)
840
+ assert sent["text"] == _CTX_PENDING["text"]
841
+ assert sent["urls"] == [other]
842
+
843
+
844
+ @respx.mock
845
+ def test_context_set_clear_urls_sends_an_empty_list(logged_in, runner):
846
+ """Removal has to stay expressible — it is now explicit rather than implied."""
847
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
848
+ respx.get(f"{BASE}/api/audits/au1/context").mock(
849
+ return_value=httpx.Response(200, json=_CTX_PENDING))
850
+ put = respx.put(f"{BASE}/api/audits/au1/context").mock(
851
+ return_value=httpx.Response(200, json={"id": "au1", "sources": 0, "queued": 0}))
852
+ result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod",
853
+ "--clear-urls"])
854
+ assert result.exit_code == 0, result.output
855
+ assert json.loads(put.calls[0].request.content)["urls"] == []
856
+
857
+
858
+ @respx.mock
859
+ def test_context_set_rejects_url_and_clear_urls_together(logged_in, runner):
860
+ result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod",
861
+ "--url", _DOC_URL, "--clear-urls"])
862
+ assert result.exit_code == 2, result.output
863
+
864
+
865
+ # --- audits: reference-context telemetry ---
866
+ #
867
+ # These pin the emitted PROPERTY NAMES, which nothing else does: the CLI sent this
868
+ # count as `count` while the dashboard sent `url_count`, so one event carried two
869
+ # names and neither answered "how much context is being attached?" on its own.
870
+ # They assert what actually leaves `record_action`, because it filters through the
871
+ # `_SAFE_PROP_KEYS` allowlist (`commands/_write.py`) first — a property missing from
872
+ # that list is dropped with no error, so a rename that forgets its allowlist entry
873
+ # reads as correct at the call site and silently emits nothing.
874
+
875
+
876
+ @pytest.fixture
877
+ def emitted(monkeypatch):
878
+ """Every ``(event, properties)`` the command sends, as the allowlist leaves it."""
879
+ events = []
880
+ monkeypatch.setattr(
881
+ analytics, "capture",
882
+ lambda event, properties=None: events.append((event, properties or {})),
883
+ )
884
+ return events
885
+
886
+
887
+ def _props(emitted, event: str) -> dict:
888
+ """The one ``event``'s properties — failing if it did not fire exactly once."""
889
+ matches = [props for name, props in emitted if name == event]
890
+ assert len(matches) == 1, f"expected one {event}, got {[name for name, _ in emitted]}"
891
+ return matches[0]
892
+
893
+
894
+ @respx.mock
895
+ def test_context_set_emits_url_count(logged_in, runner, emitted):
896
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
897
+ respx.get(f"{BASE}/api/audits/au1/context").mock(
898
+ return_value=httpx.Response(200, json=_CTX_PENDING))
899
+ respx.put(f"{BASE}/api/audits/au1/context").mock(
900
+ return_value=httpx.Response(200, json={"id": "au1", "sources": 2, "queued": 2}))
901
+ result = runner.invoke(app, ["--json", "audits", "context-set", "nightly-prod",
902
+ "--url", _DOC_URL, "--url", "https://docs.example.com/slo"])
903
+ assert result.exit_code == 0, result.output
904
+ props = _props(emitted, "audit_context_saved")
905
+ assert props["url_count"] == 2
906
+ assert "count" not in props, "the dashboard's name for this is url_count — one series, one name"
907
+ assert props["via"] == "cli"
908
+
909
+
910
+ @respx.mock
911
+ def test_audits_create_with_context_emits_url_count(logged_in, runner, emitted):
912
+ """The second call site of the same event: context attached at creation."""
913
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[]))
914
+ respx.post(f"{BASE}/api/audits").mock(
915
+ return_value=httpx.Response(201, json={"id": "au9", "created_at": "t", "sources": 1}))
916
+ result = runner.invoke(app, ["--json", "audits", "create", "nightly-prod",
917
+ "--text", "checkout agent", "--url", _DOC_URL])
918
+ assert result.exit_code == 0, result.output
919
+ props = _props(emitted, "audit_context_saved")
920
+ assert props["url_count"] == 1 and "count" not in props
921
+
922
+
923
+ @respx.mock
924
+ def test_context_refresh_emits_url_count(logged_in, runner, emitted):
925
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
926
+ respx.post(f"{BASE}/api/audits/au1/context/refresh").mock(
927
+ return_value=httpx.Response(200, json={"queued": 3, "skipped": 1}))
928
+ result = runner.invoke(app, ["--json", "audits", "context-refresh", "nightly-prod"])
929
+ assert result.exit_code == 0, result.output
930
+ props = _props(emitted, "audit_context_refreshed")
931
+ assert props["url_count"] == 3 and "count" not in props
932
+
933
+
934
+ @respx.mock
935
+ def test_edit_from_show_json_round_trips(logged_in, runner, tmp_path):
936
+ """`audits show --json` emits every field, including the two the definition
937
+ endpoint 422s. Feeding that straight back is the documented workflow, so the
938
+ read-only keys are stripped client-side."""
939
+ shown = dict(_FULL_AUDIT, additional_context="a brief", reference_url_count=2, run_count=9)
940
+ f = tmp_path / "audit.json"
941
+ f.write_text(json.dumps(shown))
942
+ respx.get(f"{BASE}/api/audits").mock(return_value=httpx.Response(200, json=[_FULL_AUDIT]))
943
+ put = respx.put(f"{BASE}/api/audits/au1").mock(
944
+ return_value=httpx.Response(200, json=_FULL_AUDIT))
945
+ result = runner.invoke(app, ["--json", "audits", "edit", "nightly-prod", "--file", str(f)])
946
+ assert result.exit_code == 0, result.output
947
+ sent = json.loads(put.calls[0].request.content)
948
+ for banned in ("additional_context", "reference_url_count", "run_count", "id", "open_findings"):
949
+ assert banned not in sent, f"{banned} must not reach the definition endpoint"
950
+ assert sent["name"] == "nightly-prod"