failproofai 1.0.3 → 1.0.4-beta.2

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 (251) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/prerender-manifest.json +5 -5
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  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/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  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.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/invite/route_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -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/run/route_client-reference-manifest.js +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
  42. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  43. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/index.html +1 -1
  46. package/.next/standalone/.next/server/app/index.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  48. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  73. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[externals]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.js} +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
  83. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
  99. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
  103. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
  106. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  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-config_ts_04t589g._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  115. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  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 +54 -21
  120. package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
  121. package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
  123. package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
  124. package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
  126. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
  128. package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
  130. package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
  135. package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
  136. package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
  137. package/.next/standalone/app/actions/get-leaks.ts +100 -0
  138. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
  139. package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
  140. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
  141. package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
  142. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
  143. package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
  144. package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
  145. package/.next/standalone/app/audit/audit-styles.css +114 -1
  146. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
  147. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
  148. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
  149. package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
  150. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
  151. package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
  152. package/.next/standalone/lib/auth/api-server-client.ts +28 -0
  153. package/.next/standalone/lib/claude-sessions.ts +101 -27
  154. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  155. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  156. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  157. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  158. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
  159. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
  160. package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
  161. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  162. package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
  163. package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
  164. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  165. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  166. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  167. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  168. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
  169. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  170. package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
  171. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  172. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  173. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  174. package/.next/standalone/node_modules/next/package.json +11 -11
  175. package/.next/standalone/package.json +14 -13
  176. package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
  177. package/.next/standalone/sdk/python/README.md +8 -0
  178. package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
  179. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  180. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
  181. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
  182. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
  183. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
  184. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
  185. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
  186. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
  187. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
  188. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
  189. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
  190. package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
  191. package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
  192. package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
  193. package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
  194. package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
  195. package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
  196. package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
  197. package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
  198. package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
  199. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
  200. package/.next/standalone/sdk/python/uv.lock +29 -29
  201. package/.next/standalone/server.js +1 -1
  202. package/README.md +13 -58
  203. package/dist/cli.mjs +5368 -8756
  204. package/dist/worker.mjs +539 -312
  205. package/lib/auth/api-server-client.ts +28 -0
  206. package/lib/claude-sessions.ts +101 -27
  207. package/package.json +14 -13
  208. package/pi-extension/index.ts +42 -4
  209. package/scripts/changelog-open.py +115 -0
  210. package/src/audit/cli.ts +134 -0
  211. package/src/audit/desktop-notify.ts +420 -0
  212. package/src/audit/harm-report.ts +101 -0
  213. package/src/audit/index.ts +141 -13
  214. package/src/audit/leak-fingerprint.ts +200 -0
  215. package/src/audit/leak-notice.ts +161 -0
  216. package/src/audit/leak-record.ts +232 -0
  217. package/src/audit/leak-scan.ts +292 -0
  218. package/src/audit/leak-store.ts +217 -0
  219. package/src/audit/macos-notifier.ts +310 -0
  220. package/src/audit/redact-example.ts +266 -14
  221. package/src/audit/report-harm.ts +12 -1
  222. package/src/audit/report.ts +6 -3
  223. package/src/audit/schedule-cli.ts +37 -0
  224. package/src/audit/scoring.ts +49 -0
  225. package/src/audit/types.ts +41 -0
  226. package/src/hooks/builtin-policies.ts +67 -2
  227. package/src/hooks/configure-wizard.ts +20 -0
  228. package/src/hooks/fp-config.ts +71 -11
  229. package/src/hooks/fp-home.ts +27 -0
  230. package/src/hooks/handler.ts +59 -1
  231. package/src/hooks/integrations.ts +47 -13
  232. package/src/hooks/notice.ts +155 -0
  233. package/src/hooks/uninstall-cli.ts +15 -0
  234. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
  235. package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
  236. package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
  237. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
  238. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
  239. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
  240. package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
  241. package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
  242. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
  243. package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
  244. package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
  245. package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
  246. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
  247. package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
  248. package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
  249. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
  250. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
  251. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_ssgManifest.js +0 -0
@@ -0,0 +1,1103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import threading
6
+ import time
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from failproofai_sdk.evaluator import (
13
+ AssignmentDefinition,
14
+ ClaimResponse,
15
+ ConditionResult,
16
+ DefinitionsResponse,
17
+ EvalResult,
18
+ Evaluator,
19
+ EvaluatorAPIError,
20
+ ExecutionMode,
21
+ HeartbeatResponse,
22
+ PlannedRun,
23
+ PlanResponse,
24
+ RegisterResponse,
25
+ ResultKind,
26
+ Score,
27
+ SessionTranscript,
28
+ WorkerConfig,
29
+ WorkerRuntime,
30
+ source_checksum,
31
+ )
32
+
33
+ FIXTURE = Path(__file__).parent / "fixtures" / "evaluator_v2" / "contract.json"
34
+
35
+
36
+ def _samples():
37
+ return json.loads(FIXTURE.read_text(encoding="utf-8"))["samples"]
38
+
39
+
40
+ class FakeClient:
41
+ def __init__(self):
42
+ samples = _samples()
43
+ self.assignment = ClaimResponse.from_wire(
44
+ samples["claim_response"]
45
+ ).assignments[0]
46
+ self.assignment = replace(self.assignment, definitions_url="")
47
+ self.session = SessionTranscript.from_wire(samples["transcript_response"])
48
+ self.register_requests = []
49
+ self.claim_requests = []
50
+ self.plans = []
51
+ self.submissions = []
52
+ self.heartbeats = []
53
+
54
+ def register(self, request):
55
+ self.register_requests.append(request)
56
+ return RegisterResponse.from_wire(_samples()["register_response"])
57
+
58
+ def claim(self, request):
59
+ self.claim_requests.append(request)
60
+ return ClaimResponse(assignments=(self.assignment,))
61
+
62
+ def transcript(self, assignment, *, worker_id):
63
+ assert assignment == self.assignment
64
+ assert worker_id == "worker-test"
65
+ return self.session
66
+
67
+ def plan(self, assignment_id, request):
68
+ self.plans.append(request)
69
+ return PlanResponse(
70
+ assignment_id=assignment_id,
71
+ assignment_status="planned" if request.selected else "skipped",
72
+ runs=tuple(
73
+ PlannedRun(f"run-{item.eval_key}", item.eval_key, item.eval_version)
74
+ for item in request.selected
75
+ ),
76
+ )
77
+
78
+ def submit_result(self, run_id, request):
79
+ self.submissions.append((run_id, request))
80
+
81
+ def heartbeat(self, request):
82
+ self.heartbeats.append(request)
83
+ return HeartbeatResponse(
84
+ lease_expires_at="2026-08-28T12:02:30.000000Z",
85
+ accepted_run_ids=tuple(item.evaluation_run_id for item in request.runs),
86
+ )
87
+
88
+
89
+ def _runtime(evaluator, client):
90
+ return WorkerRuntime(
91
+ evaluator,
92
+ WorkerConfig(
93
+ server_url="https://cloud.example",
94
+ credential="secret",
95
+ worker_id="worker-test",
96
+ max_concurrency=2,
97
+ ),
98
+ client=client,
99
+ )
100
+
101
+
102
+ def test_managed_definition_is_fetched_verified_and_executed():
103
+ source = "EvalResult(score=Score(0.75, passed=True), summary='hosted')"
104
+
105
+ class HostedClient(FakeClient):
106
+ def __init__(self):
107
+ super().__init__()
108
+ self.assignment = replace(
109
+ self.assignment,
110
+ definitions_url=f"/v1/evaluator/assignments/{self.assignment.assignment_id}/definitions",
111
+ )
112
+
113
+ def definitions(self, assignment, *, worker_id):
114
+ assert assignment == self.assignment
115
+ assert worker_id == "worker-test"
116
+ return DefinitionsResponse(
117
+ assignment_id=assignment.assignment_id,
118
+ catalog_revision="sha256:hosted",
119
+ definitions=(
120
+ AssignmentDefinition(
121
+ eval_key="hosted_quality",
122
+ display_name="Hosted quality",
123
+ eval_version="1",
124
+ result_kind=ResultKind.SCORE,
125
+ execution_mode=ExecutionMode.PYTHON,
126
+ source_checksum=source_checksum(None, source),
127
+ ),
128
+ ),
129
+ )
130
+
131
+ def plan(self, assignment_id, request):
132
+ self.plans.append(request)
133
+ return PlanResponse(
134
+ assignment_id=assignment_id,
135
+ assignment_status="planned",
136
+ runs=(
137
+ PlannedRun(
138
+ "run-hosted",
139
+ "hosted_quality",
140
+ "1",
141
+ execution_mode=ExecutionMode.PYTHON,
142
+ evaluator_source=source,
143
+ source_checksum=source_checksum(None, source),
144
+ timeout_seconds=1,
145
+ ),
146
+ ),
147
+ )
148
+
149
+ client = HostedClient()
150
+ asyncio.run(
151
+ _runtime(Evaluator(name="managed", version="1"), client).process_assignment(
152
+ client.assignment
153
+ )
154
+ )
155
+
156
+ assert len(client.submissions) == 1
157
+ run_id, result = client.submissions[0]
158
+ assert run_id == "run-hosted"
159
+ assert result.status.value == "succeeded"
160
+ assert result.summary == "hosted"
161
+ assert result.results[0].numeric_value == 0.75
162
+
163
+
164
+ def test_managed_definition_that_fails_to_compile_dead_letters_as_one_failed_run():
165
+ # Unsafe/malformed server-authored source is rejected by the sandbox at
166
+ # compile time. That rejection must surface as a single bounded FAILED run,
167
+ # NOT as an exception out of assignment setup that crashes the task and
168
+ # forces the whole assignment to be reclaimed and retried.
169
+ unsafe = (
170
+ 'EvalResult(score=Score(1.0), '
171
+ 'reasoning="{0.__class__}".format(session))'
172
+ )
173
+
174
+ class HostedClient(FakeClient):
175
+ def __init__(self):
176
+ super().__init__()
177
+ self.assignment = replace(
178
+ self.assignment,
179
+ definitions_url=f"/v1/evaluator/assignments/{self.assignment.assignment_id}/definitions",
180
+ )
181
+
182
+ def definitions(self, assignment, *, worker_id):
183
+ return DefinitionsResponse(
184
+ assignment_id=assignment.assignment_id,
185
+ catalog_revision="sha256:hosted",
186
+ definitions=(
187
+ AssignmentDefinition(
188
+ eval_key="hosted_quality",
189
+ display_name="Hosted quality",
190
+ eval_version="1",
191
+ result_kind=ResultKind.SCORE,
192
+ execution_mode=ExecutionMode.PYTHON,
193
+ source_checksum=source_checksum(None, unsafe),
194
+ ),
195
+ ),
196
+ )
197
+
198
+ def plan(self, assignment_id, request):
199
+ self.plans.append(request)
200
+ return PlanResponse(
201
+ assignment_id=assignment_id,
202
+ assignment_status="planned",
203
+ runs=(
204
+ PlannedRun(
205
+ "run-hosted",
206
+ "hosted_quality",
207
+ "1",
208
+ execution_mode=ExecutionMode.PYTHON,
209
+ evaluator_source=unsafe,
210
+ source_checksum=source_checksum(None, unsafe),
211
+ timeout_seconds=1,
212
+ ),
213
+ ),
214
+ )
215
+
216
+ client = HostedClient()
217
+ # Must NOT raise — the poison definition is contained to its own run.
218
+ asyncio.run(
219
+ _runtime(Evaluator(name="managed", version="1"), client).process_assignment(
220
+ client.assignment
221
+ )
222
+ )
223
+
224
+ assert len(client.submissions) == 1
225
+ run_id, result = client.submissions[0]
226
+ assert run_id == "run-hosted"
227
+ assert result.status.value == "failed"
228
+ assert result.error_code == "eval_error"
229
+ # Nothing derived from the rejected source may be reported.
230
+ assert result.results == ()
231
+ assert result.summary is None
232
+
233
+
234
+ def test_managed_condition_governs_even_when_a_local_key_collides():
235
+ # COR-001: `local` is keyed on (eval_key, eval_version) alone, so a managed
236
+ # (PYTHON) definition can collide with a local one the worker also registered.
237
+ # The server's managed condition must decide applicability — NOT the matching
238
+ # local condition. Here the local condition returns True and the managed
239
+ # `condition_source` is "False": the definition must be recorded as skipped
240
+ # (condition_false) and the managed evaluator source must never run.
241
+ source = "EvalResult(score=Score(1.0), summary='should never run')"
242
+
243
+ evaluator = Evaluator(name="managed", version="1")
244
+
245
+ @evaluator.eval("hosted_quality", version="1", when=lambda session: True)
246
+ def hosted_quality(session): # a colliding LOCAL definition, condition True
247
+ return EvalResult(score=Score(1.0, passed=True), summary="local")
248
+
249
+ class HostedClient(FakeClient):
250
+ def __init__(self):
251
+ super().__init__()
252
+ self.assignment = replace(
253
+ self.assignment,
254
+ definitions_url=f"/v1/evaluator/assignments/{self.assignment.assignment_id}/definitions",
255
+ )
256
+
257
+ def definitions(self, assignment, *, worker_id):
258
+ return DefinitionsResponse(
259
+ assignment_id=assignment.assignment_id,
260
+ catalog_revision="sha256:hosted",
261
+ definitions=(
262
+ AssignmentDefinition(
263
+ eval_key="hosted_quality",
264
+ display_name="Hosted quality",
265
+ eval_version="1",
266
+ result_kind=ResultKind.SCORE,
267
+ execution_mode=ExecutionMode.PYTHON,
268
+ condition_source="False",
269
+ source_checksum=source_checksum("False", source),
270
+ timeout_seconds=1,
271
+ ),
272
+ ),
273
+ )
274
+
275
+ client = HostedClient()
276
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
277
+
278
+ # The server's managed condition (False) wins over the local one (True):
279
+ # recorded as skipped, nothing selected, and no managed run submitted.
280
+ assert client.plans[0].selected == ()
281
+ assert {(item.eval_key, item.reason_code) for item in client.plans[0].skipped} == {
282
+ ("hosted_quality", "condition_false"),
283
+ }
284
+ assert client.submissions == []
285
+
286
+
287
+ def test_two_assignments_share_the_bounded_sync_eval_pool_and_keep_heartbeating():
288
+ evaluator = Evaluator(name="parallel", version="1")
289
+ lock = threading.Lock()
290
+ active = 0
291
+ peak = 0
292
+
293
+ def measured(_session):
294
+ nonlocal active, peak
295
+ with lock:
296
+ active += 1
297
+ peak = max(peak, active)
298
+ time.sleep(0.04)
299
+ with lock:
300
+ active -= 1
301
+ return EvalResult(score=Score(1))
302
+
303
+ for index in range(5):
304
+ evaluator.eval(
305
+ f"eval_{index}",
306
+ version="1",
307
+ when=lambda session, index=index: (
308
+ index < 3 if session.session_id == "session-a" else index >= 3
309
+ ),
310
+ )(measured)
311
+
312
+ class ParallelClient(FakeClient):
313
+ def transcript(self, assignment, *, worker_id):
314
+ assert worker_id == "worker-test"
315
+ return replace(
316
+ self.session,
317
+ assignment_id=assignment.assignment_id,
318
+ session_id=assignment.session_id,
319
+ session_revision_id=assignment.session_revision_id,
320
+ )
321
+
322
+ client = ParallelClient()
323
+ first = replace(
324
+ client.assignment,
325
+ assignment_id="assignment-a",
326
+ session_id="session-a",
327
+ session_revision_id="revision-a",
328
+ )
329
+ second = replace(
330
+ client.assignment,
331
+ assignment_id="assignment-b",
332
+ session_id="session-b",
333
+ session_revision_id="revision-b",
334
+ )
335
+ runtime = _runtime(evaluator, client)
336
+ runtime._heartbeat_interval = 0.01
337
+
338
+ async def exercise():
339
+ await asyncio.gather(
340
+ runtime.process_assignment(first), runtime.process_assignment(second)
341
+ )
342
+
343
+ asyncio.run(exercise())
344
+
345
+ assert peak == 2
346
+ assert len(client.submissions) == 5
347
+ assert client.heartbeats
348
+
349
+
350
+ def test_condition_failures_are_isolated_and_plan_is_declared_first():
351
+ evaluator = Evaluator(name="test", version="1")
352
+
353
+ @evaluator.eval("selected", version="1", when=lambda session: True)
354
+ def selected(session):
355
+ return EvalResult(score=Score(1))
356
+
357
+ @evaluator.eval("not_applicable", version="1", when=lambda session: False)
358
+ def not_applicable(session):
359
+ return EvalResult(score=Score(1))
360
+
361
+ def broken_condition(session):
362
+ raise RuntimeError("condition exploded")
363
+
364
+ @evaluator.eval("broken_condition", version="1", when=broken_condition)
365
+ def never_runs(session):
366
+ raise AssertionError("must not run")
367
+
368
+ client = FakeClient()
369
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
370
+
371
+ assert len(client.plans) == 1
372
+ assert [item.eval_key for item in client.plans[0].selected] == ["selected"]
373
+ assert {(item.eval_key, item.reason_code) for item in client.plans[0].skipped} == {
374
+ ("not_applicable", "condition_false"),
375
+ ("broken_condition", "condition_error"),
376
+ }
377
+ assert [run_id for run_id, _ in client.submissions] == ["run-selected"]
378
+
379
+
380
+ def test_condition_can_supply_a_stable_skip_reason():
381
+ evaluator = Evaluator(name="test", version="1")
382
+
383
+ @evaluator.eval(
384
+ "retrieval_only",
385
+ version="1",
386
+ when=lambda session: ConditionResult(False, "no_retrieval_events"),
387
+ )
388
+ def retrieval_only(session):
389
+ raise AssertionError("must not run")
390
+
391
+ client = FakeClient()
392
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
393
+ assert client.plans[0].skipped[0].reason_code == "no_retrieval_events"
394
+
395
+
396
+ def test_one_eval_failure_does_not_block_another_result():
397
+ evaluator = Evaluator(name="test", version="1")
398
+
399
+ @evaluator.eval("fails", version="1")
400
+ def fails(session):
401
+ raise RuntimeError("secret details should be bounded")
402
+
403
+ @evaluator.eval("succeeds", version="1")
404
+ async def succeeds(session):
405
+ await asyncio.sleep(0)
406
+ return EvalResult(score=Score(0.8), summary="good")
407
+
408
+ client = FakeClient()
409
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
410
+
411
+ by_run = {run_id: request for run_id, request in client.submissions}
412
+ assert by_run["run-fails"].status.value == "failed"
413
+ assert by_run["run-fails"].error_code == "eval_error"
414
+ assert by_run["run-fails"].results == ()
415
+ assert by_run["run-succeeds"].status.value == "succeeded"
416
+ assert by_run["run-succeeds"].results[0].result_kind == ResultKind.SCORE
417
+
418
+
419
+ def test_timeout_is_submitted_as_a_terminal_run():
420
+ evaluator = Evaluator(name="test", version="1")
421
+ cancelled = []
422
+
423
+ @evaluator.eval(
424
+ "slow",
425
+ version="1",
426
+ timeout_seconds=0.01,
427
+ on_cancel=lambda session: cancelled.append(session.session_revision_id),
428
+ )
429
+ async def slow(session):
430
+ await asyncio.sleep(1)
431
+ return EvalResult(score=Score(1))
432
+
433
+ client = FakeClient()
434
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
435
+ request = client.submissions[0][1]
436
+ assert request.status.value == "timed_out"
437
+ assert request.error_code == "eval_timeout"
438
+ assert cancelled == [client.assignment.session_revision_id]
439
+
440
+
441
+ def test_lost_lease_cancels_local_execution():
442
+ evaluator = Evaluator(name="test", version="1")
443
+
444
+ @evaluator.eval("slow", version="1")
445
+ async def slow(session):
446
+ await asyncio.sleep(1)
447
+ return EvalResult(score=Score(1))
448
+
449
+ class LeaseLostClient(FakeClient):
450
+ def heartbeat(self, request):
451
+ raise EvaluatorAPIError(
452
+ status=409,
453
+ code="lease_lost",
454
+ message="gone",
455
+ retryable=False,
456
+ )
457
+
458
+ client = LeaseLostClient()
459
+ runtime = _runtime(evaluator, client)
460
+ runtime._heartbeat_interval = 0.01
461
+ with pytest.raises(asyncio.CancelledError):
462
+ asyncio.run(runtime.process_assignment(client.assignment))
463
+ assert client.submissions == []
464
+
465
+
466
+ def test_partial_heartbeat_acceptance_cancels_only_the_fenced_run():
467
+ evaluator = Evaluator(name="test", version="1")
468
+
469
+ class PartialHeartbeatClient(FakeClient):
470
+ def heartbeat(self, request):
471
+ self.heartbeats.append(request)
472
+ return HeartbeatResponse(
473
+ lease_expires_at="2026-08-28T12:02:30.000000Z",
474
+ accepted_run_ids=(request.runs[0].evaluation_run_id,),
475
+ )
476
+
477
+ client = PartialHeartbeatClient()
478
+ runtime = _runtime(evaluator, client)
479
+ runtime._heartbeat_interval = 0.01
480
+
481
+ async def exercise():
482
+ first = asyncio.create_task(asyncio.sleep(60))
483
+ second = asyncio.create_task(asyncio.sleep(60))
484
+ heartbeat = asyncio.create_task(
485
+ runtime._heartbeat(
486
+ client.assignment, {"run-first": first, "run-second": second}
487
+ )
488
+ )
489
+ while not client.heartbeats:
490
+ await asyncio.sleep(0.001)
491
+ for _ in range(100):
492
+ if second.done():
493
+ break
494
+ await asyncio.sleep(0.001)
495
+ assert first.done() is False
496
+ assert second.cancelled() is True
497
+ heartbeat.cancel()
498
+ first.cancel()
499
+ await asyncio.gather(first, second, heartbeat, return_exceptions=True)
500
+
501
+ asyncio.run(exercise())
502
+
503
+
504
+ def test_transcript_revision_must_match_the_claimed_assignment():
505
+ evaluator = Evaluator(name="test", version="1")
506
+ client = FakeClient()
507
+ client.session = SessionTranscript.from_wire(
508
+ {
509
+ **_samples()["transcript_response"],
510
+ "session_revision_id": "different-revision",
511
+ }
512
+ )
513
+
514
+ with pytest.raises(RuntimeError, match="revision does not match"):
515
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
516
+ assert client.plans == []
517
+
518
+
519
+ def test_server_cannot_add_a_run_when_every_eval_was_skipped():
520
+ evaluator = Evaluator(name="test", version="1")
521
+
522
+ @evaluator.eval("skipped", version="1", when=lambda session: False)
523
+ def skipped(session):
524
+ raise AssertionError("must not run")
525
+
526
+ class UnexpectedRunClient(FakeClient):
527
+ def plan(self, assignment_id, request):
528
+ self.plans.append(request)
529
+ return PlanResponse(
530
+ assignment_id=assignment_id,
531
+ assignment_status="skipped",
532
+ runs=(PlannedRun("run-injected", "skipped", "1"),),
533
+ )
534
+
535
+ client = UnexpectedRunClient()
536
+ with pytest.raises(RuntimeError, match="unrequested evaluation run"):
537
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
538
+ assert client.submissions == []
539
+
540
+
541
+ def test_server_plan_must_match_assignment_and_include_each_new_selected_eval():
542
+ evaluator = Evaluator(name="test", version="1")
543
+
544
+ @evaluator.eval("selected", version="1")
545
+ def selected(session):
546
+ return EvalResult(score=Score(1))
547
+
548
+ class WrongAssignmentClient(FakeClient):
549
+ def plan(self, assignment_id, request):
550
+ return PlanResponse(
551
+ assignment_id="another-assignment",
552
+ assignment_status="planned",
553
+ runs=(PlannedRun("run-selected", "selected", "1"),),
554
+ )
555
+
556
+ wrong_assignment = WrongAssignmentClient()
557
+ with pytest.raises(RuntimeError, match="different assignment"):
558
+ asyncio.run(
559
+ _runtime(evaluator, wrong_assignment).process_assignment(
560
+ wrong_assignment.assignment
561
+ )
562
+ )
563
+
564
+ class WrongStatusClient(FakeClient):
565
+ def plan(self, assignment_id, request):
566
+ return PlanResponse(
567
+ assignment_id=assignment_id,
568
+ assignment_status="skipped",
569
+ runs=(PlannedRun("run-selected", "selected", "1"),),
570
+ )
571
+
572
+ wrong_status = WrongStatusClient()
573
+ with pytest.raises(RuntimeError, match="inconsistent assignment status"):
574
+ asyncio.run(
575
+ _runtime(evaluator, wrong_status).process_assignment(
576
+ wrong_status.assignment
577
+ )
578
+ )
579
+
580
+ class OmittedRunClient(FakeClient):
581
+ def plan(self, assignment_id, request):
582
+ return PlanResponse(
583
+ assignment_id=assignment_id,
584
+ assignment_status="planned",
585
+ runs=(),
586
+ )
587
+
588
+ omitted = OmittedRunClient()
589
+ with pytest.raises(RuntimeError, match="omitted a selected evaluation run"):
590
+ asyncio.run(_runtime(evaluator, omitted).process_assignment(omitted.assignment))
591
+
592
+ class ReplayedPlanClient(FakeClient):
593
+ def plan(self, assignment_id, request):
594
+ return PlanResponse(
595
+ assignment_id=assignment_id,
596
+ assignment_status="planned",
597
+ runs=(),
598
+ idempotent_replay=True,
599
+ )
600
+
601
+ replayed = ReplayedPlanClient()
602
+ asyncio.run(_runtime(evaluator, replayed).process_assignment(replayed.assignment))
603
+ assert replayed.submissions == []
604
+
605
+
606
+ def test_server_plan_rejects_duplicate_run_ids():
607
+ evaluator = Evaluator(name="test", version="1")
608
+ evaluator.eval("first", version="1")(lambda session: EvalResult(score=Score(1)))
609
+ evaluator.eval("second", version="1")(lambda session: EvalResult(score=Score(1)))
610
+
611
+ class DuplicateRunClient(FakeClient):
612
+ def plan(self, assignment_id, request):
613
+ return PlanResponse(
614
+ assignment_id=assignment_id,
615
+ assignment_status="planned",
616
+ runs=(
617
+ PlannedRun("same-run", "first", "1"),
618
+ PlannedRun("same-run", "second", "1"),
619
+ ),
620
+ )
621
+
622
+ client = DuplicateRunClient()
623
+ with pytest.raises(RuntimeError, match="duplicate evaluation run id"):
624
+ asyncio.run(_runtime(evaluator, client).process_assignment(client.assignment))
625
+ assert client.submissions == []
626
+
627
+
628
+ def test_register_advertises_the_deterministic_catalog():
629
+ evaluator = Evaluator(name="test", version="1")
630
+
631
+ @evaluator.eval("quality", version="7")
632
+ def quality(session):
633
+ return EvalResult(score=Score(1))
634
+
635
+ client = FakeClient()
636
+ runtime = _runtime(evaluator, client)
637
+ asyncio.run(runtime.register())
638
+ request = client.register_requests[0]
639
+ assert request.catalog_revision == evaluator.catalog_revision
640
+ assert request.definitions[0].eval_version == "7"
641
+ assert runtime._heartbeat_interval == 30
642
+
643
+
644
+ def test_runtime_readiness_tracks_registration_contact_and_shutdown(monkeypatch):
645
+ evaluator = Evaluator(name="test", version="1")
646
+ runtime = _runtime(evaluator, FakeClient())
647
+
648
+ assert runtime.is_ready() is False
649
+ assert runtime.metrics() == {}
650
+
651
+ asyncio.run(runtime.register())
652
+ assert runtime.is_ready() is True
653
+ assert runtime.metrics() == {"registration_success": 1}
654
+
655
+ last_contact = runtime._last_server_contact
656
+ assert last_contact is not None
657
+ monkeypatch.setattr(time, "monotonic", lambda: last_contact + 121)
658
+ assert runtime.is_ready() is False
659
+
660
+ monkeypatch.setattr(time, "monotonic", lambda: last_contact)
661
+ runtime.stop()
662
+ assert runtime.is_ready() is False
663
+
664
+
665
+ def test_runtime_metrics_count_claims_conditions_and_outcomes():
666
+ evaluator = Evaluator(name="test", version="1")
667
+
668
+ @evaluator.eval("selected", version="1", when=lambda session: True)
669
+ def selected(session):
670
+ return EvalResult(score=Score(1))
671
+
672
+ @evaluator.eval("skipped", version="1", when=lambda session: False)
673
+ def skipped(session):
674
+ raise AssertionError("must not run")
675
+
676
+ runtime = _runtime(evaluator, FakeClient())
677
+
678
+ async def exercise():
679
+ await runtime.register()
680
+ return await runtime.run_once()
681
+
682
+ assert asyncio.run(exercise()) == 1
683
+ assert runtime.metrics() == {
684
+ "assignments_claimed": 1,
685
+ "conditions_selected": 1,
686
+ "conditions_skipped": 1,
687
+ "registration_success": 1,
688
+ "runs_succeeded": 1,
689
+ }
690
+
691
+
692
+ def test_runtime_metrics_count_registration_failure():
693
+ evaluator = Evaluator(name="test", version="1")
694
+
695
+ class BrokenClient(FakeClient):
696
+ def register(self, request):
697
+ raise EvaluatorAPIError(
698
+ status=503,
699
+ code="unavailable",
700
+ message="try later",
701
+ retryable=True,
702
+ )
703
+
704
+ runtime = _runtime(evaluator, BrokenClient())
705
+ with pytest.raises(EvaluatorAPIError):
706
+ asyncio.run(runtime.register())
707
+ assert runtime.is_ready() is False
708
+ assert runtime.metrics() == {"registration_failure": 1}
709
+
710
+
711
+ def test_invalid_registration_response_does_not_make_runtime_ready():
712
+ evaluator = Evaluator(name="test", version="1")
713
+
714
+ class InvalidTimingClient(FakeClient):
715
+ def register(self, request):
716
+ return RegisterResponse(
717
+ evaluator_instance_id="instance",
718
+ evaluator_kind=self._kind(),
719
+ heartbeat_interval_seconds=120,
720
+ lease_duration_seconds=120,
721
+ poll_interval_seconds=10,
722
+ claim_limit=1,
723
+ )
724
+
725
+ @staticmethod
726
+ def _kind():
727
+ from failproofai_sdk.evaluator import EvaluatorKind
728
+
729
+ return EvaluatorKind.CUSTOMER
730
+
731
+ runtime = _runtime(evaluator, InvalidTimingClient())
732
+ with pytest.raises(RuntimeError, match="invalid evaluator timing"):
733
+ asyncio.run(runtime.register())
734
+ assert runtime.is_ready() is False
735
+ assert runtime.metrics() == {"registration_failure": 1}
736
+
737
+
738
+ def test_lost_claim_response_waits_out_the_lease_before_claiming_again():
739
+ evaluator = Evaluator(name="test", version="1")
740
+
741
+ class LostResponseClient(FakeClient):
742
+ def claim(self, request):
743
+ self.claim_requests.append(request)
744
+ raise EvaluatorAPIError(
745
+ status=None,
746
+ code="transport_error",
747
+ message="response lost",
748
+ retryable=True,
749
+ )
750
+
751
+ runtime = _runtime(evaluator, LostResponseClient())
752
+ waits = []
753
+
754
+ async def stop_after_wait(seconds):
755
+ waits.append(seconds)
756
+ runtime.stop()
757
+
758
+ runtime._wait_or_stop = stop_after_wait
759
+ asyncio.run(runtime.run_forever())
760
+
761
+ assert waits == [120.0]
762
+ assert len(runtime.client.claim_requests) == 1
763
+ assert runtime.metrics() == {
764
+ "claim_failures": 1,
765
+ "registration_success": 1,
766
+ }
767
+
768
+
769
+ def test_idle_claim_waits_the_advertised_poll_interval_before_polling_again():
770
+ # Normal short polling: an empty claim returns immediately (no long-poll), so
771
+ # the worker sleeps the server-advertised poll_interval_seconds — 10 in the
772
+ # fixture register response — instead of hot-looping. The claim request also no
773
+ # longer carries a wait_seconds field.
774
+ evaluator = Evaluator(name="test", version="1")
775
+
776
+ class IdleClient(FakeClient):
777
+ def claim(self, request):
778
+ self.claim_requests.append(request)
779
+ return ClaimResponse(assignments=())
780
+
781
+ runtime = _runtime(evaluator, IdleClient())
782
+ waits = []
783
+
784
+ async def stop_after_wait(seconds):
785
+ waits.append(seconds)
786
+ runtime.stop()
787
+
788
+ runtime._wait_or_stop = stop_after_wait
789
+ asyncio.run(runtime.run_forever())
790
+
791
+ assert waits == [10.0]
792
+ assert len(runtime.client.claim_requests) == 1
793
+ assert not hasattr(runtime.client.claim_requests[0], "wait_seconds")
794
+
795
+
796
+ def test_nonretryable_claim_failure_stops_the_worker():
797
+ evaluator = Evaluator(name="test", version="1")
798
+
799
+ class RejectedClaimClient(FakeClient):
800
+ def claim(self, request):
801
+ self.claim_requests.append(request)
802
+ raise EvaluatorAPIError(
803
+ status=409,
804
+ code="catalog_mismatch",
805
+ message="register again with the current catalog",
806
+ retryable=False,
807
+ )
808
+
809
+ runtime = _runtime(evaluator, RejectedClaimClient())
810
+ with pytest.raises(EvaluatorAPIError, match="catalog_mismatch"):
811
+ asyncio.run(runtime.run_forever())
812
+ assert len(runtime.client.claim_requests) == 1
813
+ assert runtime.metrics() == {
814
+ "claim_failures": 1,
815
+ "registration_success": 1,
816
+ }
817
+
818
+
819
+ @pytest.mark.parametrize(
820
+ ("assignments", "message"),
821
+ [
822
+ (lambda item: (item, item), "duplicate assignments"),
823
+ (
824
+ lambda item: tuple(
825
+ replace(item, assignment_id=f"assignment-{index}") for index in range(3)
826
+ ),
827
+ "more assignments than requested",
828
+ ),
829
+ ],
830
+ )
831
+ def test_claim_response_cannot_exceed_capacity_or_repeat_work(assignments, message):
832
+ evaluator = Evaluator(name="test", version="1")
833
+
834
+ class InvalidClaimClient(FakeClient):
835
+ def claim(self, request):
836
+ return ClaimResponse(assignments=assignments(self.assignment))
837
+
838
+ runtime = _runtime(evaluator, InvalidClaimClient())
839
+ with pytest.raises(RuntimeError, match=message):
840
+ asyncio.run(runtime.run_once())
841
+ assert runtime.metrics() == {}
842
+
843
+
844
+ def test_register_applies_server_claim_limit_and_disabled_definitions():
845
+ evaluator = Evaluator(name="test", version="1")
846
+
847
+ @evaluator.eval("disabled", version="1")
848
+ def disabled(session):
849
+ raise AssertionError("disabled eval must not run")
850
+
851
+ class RestrictedClient(FakeClient):
852
+ def register(self, request):
853
+ self.register_requests.append(request)
854
+ return RegisterResponse(
855
+ evaluator_instance_id="instance",
856
+ evaluator_kind=self._kind(),
857
+ heartbeat_interval_seconds=10,
858
+ lease_duration_seconds=120,
859
+ poll_interval_seconds=10,
860
+ claim_limit=1,
861
+ disabled_definitions=("disabled",),
862
+ )
863
+
864
+ @staticmethod
865
+ def _kind():
866
+ from failproofai_sdk.evaluator import EvaluatorKind
867
+
868
+ return EvaluatorKind.CUSTOMER
869
+
870
+ client = RestrictedClient()
871
+ runtime = _runtime(evaluator, client)
872
+
873
+ async def exercise():
874
+ await runtime.register()
875
+ await runtime.run_once()
876
+
877
+ asyncio.run(exercise())
878
+ assert runtime._claim_limit == 1
879
+ assert client.claim_requests[0].capacity == 1
880
+ assert client.plans[0].selected == ()
881
+ assert client.plans[0].skipped[0].reason_code == "disabled_by_server"
882
+
883
+
884
+ def test_worker_config_requires_dedicated_credentials(monkeypatch):
885
+ monkeypatch.delenv("FAILPROOFAI_EVALUATOR_URL", raising=False)
886
+ monkeypatch.delenv("FAILPROOFAI_EVALUATOR_TOKEN", raising=False)
887
+ with pytest.raises(ValueError, match="URL is required"):
888
+ WorkerConfig.from_env()
889
+
890
+
891
+ def test_register_rejects_non_positive_poll_interval():
892
+ # The worker adopts the server-advertised poll_interval_seconds (normal short
893
+ # polling — there is no long-poll wait). A non-positive interval would make the
894
+ # claim loop hot-spin, so registration must refuse it.
895
+ evaluator = Evaluator(name="test", version="1")
896
+
897
+ class ZeroPollClient(FakeClient):
898
+ def register(self, request):
899
+ return RegisterResponse(
900
+ evaluator_instance_id="instance",
901
+ evaluator_kind=self._kind(),
902
+ heartbeat_interval_seconds=30,
903
+ lease_duration_seconds=120,
904
+ poll_interval_seconds=0,
905
+ claim_limit=1,
906
+ )
907
+
908
+ @staticmethod
909
+ def _kind():
910
+ from failproofai_sdk.evaluator import EvaluatorKind
911
+
912
+ return EvaluatorKind.CUSTOMER
913
+
914
+ runtime = _runtime(evaluator, ZeroPollClient())
915
+ with pytest.raises(RuntimeError, match="invalid evaluator timing"):
916
+ asyncio.run(runtime.register())
917
+ assert runtime.is_ready() is False
918
+
919
+
920
+ def test_worker_config_rejects_header_control_characters(monkeypatch):
921
+ monkeypatch.setenv("FAILPROOFAI_EVALUATOR_URL", "https://cloud.example")
922
+ monkeypatch.setenv("FAILPROOFAI_EVALUATOR_TOKEN", "secret")
923
+ monkeypatch.setenv("FAILPROOFAI_EVALUATOR_WORKER_ID", "worker\nforged")
924
+ with pytest.raises(ValueError, match="control characters"):
925
+ WorkerConfig.from_env()
926
+
927
+
928
+ def test_graceful_drain_cancels_work_after_the_configured_deadline():
929
+ evaluator = Evaluator(name="test", version="1")
930
+ client = FakeClient()
931
+ runtime = WorkerRuntime(
932
+ evaluator,
933
+ WorkerConfig(
934
+ server_url="https://cloud.example",
935
+ credential="secret",
936
+ worker_id="worker-test",
937
+ drain_timeout_seconds=1,
938
+ ),
939
+ client=client,
940
+ )
941
+ cancelled = False
942
+
943
+ async def exercise():
944
+ nonlocal cancelled
945
+
946
+ async def active_work():
947
+ nonlocal cancelled
948
+ try:
949
+ await asyncio.sleep(60)
950
+ except asyncio.CancelledError:
951
+ cancelled = True
952
+ raise
953
+
954
+ task = asyncio.create_task(active_work())
955
+ runtime._active.add(task)
956
+ await asyncio.sleep(0)
957
+ runtime.config = WorkerConfig(
958
+ server_url=runtime.config.server_url,
959
+ credential=runtime.config.credential,
960
+ worker_id=runtime.config.worker_id,
961
+ drain_timeout_seconds=0,
962
+ )
963
+ await runtime.drain()
964
+
965
+ asyncio.run(exercise())
966
+ assert cancelled is True
967
+ assert runtime._active == set()
968
+
969
+
970
+ def test_stop_interrupts_capacity_wait_and_enters_drain():
971
+ runtime = _runtime(Evaluator(name="test", version="1"), FakeClient())
972
+
973
+ async def exercise():
974
+ blocker = asyncio.Event()
975
+ work = asyncio.create_task(blocker.wait())
976
+ runtime._active.add(work)
977
+ await asyncio.sleep(0)
978
+
979
+ runtime.stop()
980
+ await asyncio.wait_for(runtime._wait_for_progress(), timeout=0.1)
981
+
982
+ assert work.done() is False
983
+ work.cancel()
984
+ await asyncio.gather(work, return_exceptions=True)
985
+
986
+ asyncio.run(exercise())
987
+
988
+
989
+ def test_eval_execution_respects_process_concurrency():
990
+ evaluator = Evaluator(name="test", version="1")
991
+ active = 0
992
+ peak = 0
993
+
994
+ async def measured(session):
995
+ nonlocal active, peak
996
+ active += 1
997
+ peak = max(peak, active)
998
+ await asyncio.sleep(0.01)
999
+ active -= 1
1000
+ return EvalResult(score=Score(1))
1001
+
1002
+ evaluator.eval("first", version="1")(measured)
1003
+ evaluator.eval("second", version="1")(measured)
1004
+ client = FakeClient()
1005
+ runtime = WorkerRuntime(
1006
+ evaluator,
1007
+ WorkerConfig(
1008
+ server_url="https://cloud.example",
1009
+ credential="secret",
1010
+ worker_id="worker-test",
1011
+ max_concurrency=1,
1012
+ ),
1013
+ client=client,
1014
+ )
1015
+ asyncio.run(runtime.process_assignment(client.assignment))
1016
+ assert peak == 1
1017
+ DefinitionsResponse,
1018
+ ExecutionMode,
1019
+
1020
+
1021
+ def test_synchronous_evaluation_timeout_is_counted_as_orphaned():
1022
+ # A synchronous evaluator that overruns its timeout cannot be cancelled: the
1023
+ # runtime submits a terminal timed_out result and records the orphaned thread
1024
+ # so a hung evaluator is findable. The executor is sized with headroom over
1025
+ # the concurrency limit so this orphan does not starve live capacity.
1026
+ evaluator = Evaluator(name="test", version="1")
1027
+
1028
+ @evaluator.eval("slow", version="1", timeout_seconds=0.05)
1029
+ def slow(session):
1030
+ time.sleep(0.5)
1031
+ return EvalResult(score=Score(1))
1032
+
1033
+ client = FakeClient()
1034
+ runtime = _runtime(evaluator, client)
1035
+ try:
1036
+ asyncio.run(runtime.process_assignment(client.assignment))
1037
+ request = client.submissions[0][1]
1038
+ assert request.status.value == "timed_out"
1039
+ assert request.error_code == "eval_timeout"
1040
+ assert runtime.metrics().get("sync_evaluations_orphaned") == 1
1041
+ assert runtime._eval_executor._max_workers > runtime.config.max_concurrency
1042
+ finally:
1043
+ runtime._eval_executor.shutdown(wait=True)
1044
+
1045
+
1046
+ def test_conditions_are_skipped_when_the_lease_is_exhausted():
1047
+ # With no lease time left before the plan must be submitted, the worker skips
1048
+ # the condition (without running it) instead of burning the lease and getting
1049
+ # the plan fenced as lease_lost.
1050
+ evaluator = Evaluator(name="test", version="1")
1051
+ ran = []
1052
+
1053
+ def gate(session):
1054
+ ran.append(True)
1055
+ return True
1056
+
1057
+ @evaluator.eval("slow", version="1", when=gate)
1058
+ def slow(session):
1059
+ return EvalResult(score=Score(1))
1060
+
1061
+ client = FakeClient()
1062
+ runtime = _runtime(evaluator, client)
1063
+ # Force an already-exhausted condition-phase deadline.
1064
+ runtime._condition_phase_deadline = lambda assignment: time.monotonic()
1065
+ asyncio.run(runtime.process_assignment(client.assignment))
1066
+
1067
+ assert ran == [], "the condition must not run once the lease is exhausted"
1068
+ assert client.plans, "a plan must still be submitted"
1069
+ plan_request = client.plans[-1]
1070
+ assert not plan_request.selected
1071
+ reasons = {(s.eval_key, s.reason_code) for s in plan_request.skipped}
1072
+ assert ("slow", "lease_exhausted") in reasons
1073
+ assert runtime.metrics().get("conditions_lease_exhausted") == 1
1074
+ assert client.submissions == []
1075
+
1076
+
1077
+ def test_condition_phase_deadline_and_budget_are_lease_bounded():
1078
+ from datetime import datetime, timedelta, timezone
1079
+
1080
+ evaluator = Evaluator(name="test", version="1")
1081
+ client = FakeClient()
1082
+ runtime = _runtime(evaluator, client)
1083
+ runtime._lease_duration = 120
1084
+
1085
+ # A stale (past) lease_expires_at falls back to the negotiated lease duration,
1086
+ # so the bound never fires spuriously under clock skew or a replayed fixture.
1087
+ stale = replace(client.assignment, lease_expires_at="2000-01-01T00:00:00.000000Z")
1088
+ fallback = runtime._condition_phase_deadline(stale) - time.monotonic()
1089
+ assert 110 <= fallback <= 125
1090
+
1091
+ # A future lease is honored.
1092
+ future_ts = (datetime.now(timezone.utc) + timedelta(seconds=300)).strftime(
1093
+ "%Y-%m-%dT%H:%M:%S.%f"
1094
+ ) + "Z"
1095
+ future = replace(client.assignment, lease_expires_at=future_ts)
1096
+ ahead = runtime._condition_phase_deadline(future) - time.monotonic()
1097
+ assert 250 <= ahead <= 305
1098
+
1099
+ # Budget is capped by both the remaining lease and the per-definition timeout.
1100
+ deadline = time.monotonic() + 100
1101
+ assert runtime._condition_budget(deadline, None) == pytest.approx(95, abs=2)
1102
+ assert runtime._condition_budget(deadline, 10) == pytest.approx(10, abs=0.05)
1103
+ assert runtime._condition_budget(time.monotonic(), None) < 0