graph-agents-cli 0.3.1__py3-none-any.whl

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 (291) hide show
  1. graph_agents_cli/__init__.py +26 -0
  2. graph_agents_cli/_api_policy.py +2145 -0
  3. graph_agents_cli/_approvals.py +400 -0
  4. graph_agents_cli/_build.py +186 -0
  5. graph_agents_cli/_build_info.json +7 -0
  6. graph_agents_cli/_chat_client.py +462 -0
  7. graph_agents_cli/_click.py +157 -0
  8. graph_agents_cli/_defaults.py +139 -0
  9. graph_agents_cli/_experiments.py +64 -0
  10. graph_agents_cli/_http.py +192 -0
  11. graph_agents_cli/_output.py +83 -0
  12. graph_agents_cli/_project.py +462 -0
  13. graph_agents_cli/_remote.py +220 -0
  14. graph_agents_cli/_response_schema.py +264 -0
  15. graph_agents_cli/_runner.py +319 -0
  16. graph_agents_cli/_skills_check.py +274 -0
  17. graph_agents_cli/_tools.py +189 -0
  18. graph_agents_cli/_trust.py +66 -0
  19. graph_agents_cli/api/__init__.py +15 -0
  20. graph_agents_cli/api/_changes.py +506 -0
  21. graph_agents_cli/api/_files.py +658 -0
  22. graph_agents_cli/api/cmd_api.py +2480 -0
  23. graph_agents_cli/deploy/__init__.py +15 -0
  24. graph_agents_cli/deploy/_config.py +171 -0
  25. graph_agents_cli/deploy/_image.py +128 -0
  26. graph_agents_cli/deploy/_kube.py +286 -0
  27. graph_agents_cli/deploy/_modes.py +234 -0
  28. graph_agents_cli/deploy/_preflight.py +370 -0
  29. graph_agents_cli/deploy/_values.py +168 -0
  30. graph_agents_cli/deploy/cmd_deploy.py +1866 -0
  31. graph_agents_cli/deploy/gitops.py +562 -0
  32. graph_agents_cli/deploy/local_load.py +273 -0
  33. graph_agents_cli/dev/__init__.py +13 -0
  34. graph_agents_cli/dev/cmd_build.py +131 -0
  35. graph_agents_cli/dev/cmd_install.py +78 -0
  36. graph_agents_cli/dev/cmd_lint.py +119 -0
  37. graph_agents_cli/dev/cmd_playground.py +297 -0
  38. graph_agents_cli/dev/policy_check.py +1287 -0
  39. graph_agents_cli/eval/__init__.py +22 -0
  40. graph_agents_cli/eval/_client.py +670 -0
  41. graph_agents_cli/eval/_common.py +177 -0
  42. graph_agents_cli/eval/_judge.py +168 -0
  43. graph_agents_cli/eval/_judge_runner.py +238 -0
  44. graph_agents_cli/eval/_paths.py +212 -0
  45. graph_agents_cli/eval/checks.py +581 -0
  46. graph_agents_cli/eval/cmd_analyze.py +278 -0
  47. graph_agents_cli/eval/cmd_compare.py +284 -0
  48. graph_agents_cli/eval/cmd_eval_group.py +80 -0
  49. graph_agents_cli/eval/cmd_generate.py +558 -0
  50. graph_agents_cli/eval/cmd_grade.py +466 -0
  51. graph_agents_cli/eval/cmd_metric.py +156 -0
  52. graph_agents_cli/eval/cmd_run.py +370 -0
  53. graph_agents_cli/eval/cmd_submit.py +400 -0
  54. graph_agents_cli/eval/config.py +435 -0
  55. graph_agents_cli/eval/dataset.py +350 -0
  56. graph_agents_cli/eval/gate.py +420 -0
  57. graph_agents_cli/eval/transcript.py +192 -0
  58. graph_agents_cli/extension/__init__.py +13 -0
  59. graph_agents_cli/extension/_compat.py +86 -0
  60. graph_agents_cli/extension/_loader.py +293 -0
  61. graph_agents_cli/extension/_manifest.py +135 -0
  62. graph_agents_cli/extension/_overrides.py +195 -0
  63. graph_agents_cli/extension/_paths.py +91 -0
  64. graph_agents_cli/extension/_refs.py +193 -0
  65. graph_agents_cli/extension/_resolver.py +453 -0
  66. graph_agents_cli/extension/_schema.py +106 -0
  67. graph_agents_cli/extension/_spec.py +253 -0
  68. graph_agents_cli/extension/_sync.py +102 -0
  69. graph_agents_cli/extension/_trust.py +58 -0
  70. graph_agents_cli/extension/cmd_extension_add.py +259 -0
  71. graph_agents_cli/extension/cmd_extension_group.py +57 -0
  72. graph_agents_cli/extension/cmd_extension_list.py +56 -0
  73. graph_agents_cli/extension/cmd_extension_remove.py +61 -0
  74. graph_agents_cli/extension/cmd_extension_update.py +195 -0
  75. graph_agents_cli/info/__init__.py +13 -0
  76. graph_agents_cli/info/cmd_info.py +222 -0
  77. graph_agents_cli/infra/__init__.py +15 -0
  78. graph_agents_cli/infra/checks.py +1169 -0
  79. graph_agents_cli/infra/cmd_infra.py +103 -0
  80. graph_agents_cli/main.py +591 -0
  81. graph_agents_cli/peer/__init__.py +15 -0
  82. graph_agents_cli/peer/_generate.py +254 -0
  83. graph_agents_cli/peer/cmd_peer.py +1151 -0
  84. graph_agents_cli/run/__init__.py +13 -0
  85. graph_agents_cli/run/_local_server.py +1157 -0
  86. graph_agents_cli/run/_signals.py +141 -0
  87. graph_agents_cli/run/cmd_approvals.py +530 -0
  88. graph_agents_cli/run/cmd_run.py +1421 -0
  89. graph_agents_cli/scaffold/__init__.py +19 -0
  90. graph_agents_cli/scaffold/agents/README.md +24 -0
  91. graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
  92. graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
  93. graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
  94. graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
  95. graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
  96. graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
  97. graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
  98. graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
  99. graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
  100. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
  101. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
  102. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
  103. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
  104. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
  105. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
  106. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
  107. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
  108. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
  109. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
  110. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
  111. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
  112. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
  113. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
  114. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
  115. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
  116. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
  117. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
  118. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
  119. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
  120. graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
  121. graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
  122. graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
  123. graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
  124. graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
  125. graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
  126. graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
  127. graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
  128. graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
  129. graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
  130. graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
  131. graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
  132. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
  133. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
  134. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
  135. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
  136. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
  137. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
  138. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
  139. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
  140. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
  141. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
  142. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
  143. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
  144. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
  145. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
  146. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
  147. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
  148. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
  149. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
  150. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
  151. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
  152. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
  153. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
  154. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
  155. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
  156. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
  157. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
  158. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
  159. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
  160. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
  161. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
  162. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
  163. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
  164. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
  165. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
  166. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
  167. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
  168. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
  169. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
  170. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
  171. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
  172. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
  173. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
  174. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
  175. graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
  176. graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
  177. graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
  178. graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
  179. graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
  180. graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
  181. graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
  182. graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
  183. graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
  184. graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
  185. graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
  186. graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
  187. graph_agents_cli/scaffold/commands/__init__.py +13 -0
  188. graph_agents_cli/scaffold/commands/create.py +1424 -0
  189. graph_agents_cli/scaffold/commands/enhance.py +1652 -0
  190. graph_agents_cli/scaffold/commands/upgrade.py +570 -0
  191. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
  192. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
  193. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
  194. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
  195. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
  196. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
  197. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
  198. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
  199. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
  200. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
  201. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
  202. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
  203. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
  204. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
  205. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
  206. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
  207. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
  208. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
  209. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
  210. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
  211. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
  212. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
  213. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
  214. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
  215. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
  216. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
  217. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
  218. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
  219. graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
  220. graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
  221. graph_agents_cli/scaffold/utils/__init__.py +13 -0
  222. graph_agents_cli/scaffold/utils/backup.py +212 -0
  223. graph_agents_cli/scaffold/utils/build_record.py +257 -0
  224. graph_agents_cli/scaffold/utils/cli_options.py +184 -0
  225. graph_agents_cli/scaffold/utils/fs.py +83 -0
  226. graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
  227. graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
  228. graph_agents_cli/scaffold/utils/keyedit.py +768 -0
  229. graph_agents_cli/scaffold/utils/keymerge.py +537 -0
  230. graph_agents_cli/scaffold/utils/language.py +138 -0
  231. graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
  232. graph_agents_cli/scaffold/utils/logging.py +77 -0
  233. graph_agents_cli/scaffold/utils/manifest.py +292 -0
  234. graph_agents_cli/scaffold/utils/merge.py +970 -0
  235. graph_agents_cli/scaffold/utils/merge3.py +216 -0
  236. graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
  237. graph_agents_cli/scaffold/utils/remote_template.py +376 -0
  238. graph_agents_cli/scaffold/utils/template.py +1352 -0
  239. graph_agents_cli/scaffold/utils/upgrade.py +894 -0
  240. graph_agents_cli/scaffold/utils/version.py +438 -0
  241. graph_agents_cli/secrets/__init__.py +15 -0
  242. graph_agents_cli/secrets/_apply.py +954 -0
  243. graph_agents_cli/secrets/_required.py +188 -0
  244. graph_agents_cli/secrets/cmd_secrets.py +211 -0
  245. graph_agents_cli/setup/__init__.py +13 -0
  246. graph_agents_cli/setup/_antigravity.py +221 -0
  247. graph_agents_cli/setup/cmd_auth.py +1030 -0
  248. graph_agents_cli/setup/cmd_dev_token.py +513 -0
  249. graph_agents_cli/setup/cmd_setup.py +428 -0
  250. graph_agents_cli/setup/cmd_update.py +140 -0
  251. graph_agents_cli/skills/__init__.py +13 -0
  252. graph_agents_cli/skills/_bundle.py +65 -0
  253. graph_agents_cli/skills/data/README.md +19 -0
  254. graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
  255. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
  256. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
  257. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
  258. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
  259. graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
  260. graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
  261. graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
  262. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
  263. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
  264. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
  265. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
  266. graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
  267. graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
  268. graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
  269. graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
  270. graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
  271. graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
  272. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
  273. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
  274. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
  275. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
  276. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
  277. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
  278. graph_agents_cli/system/__init__.py +15 -0
  279. graph_agents_cli/system/_apply.py +519 -0
  280. graph_agents_cli/system/_checks.py +1023 -0
  281. graph_agents_cli/system/_deploy.py +215 -0
  282. graph_agents_cli/system/_model.py +363 -0
  283. graph_agents_cli/system/_system.py +664 -0
  284. graph_agents_cli/system/_views.py +208 -0
  285. graph_agents_cli/system/cmd_system.py +423 -0
  286. graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
  287. graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
  288. graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
  289. graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
  290. graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
  291. graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
@@ -0,0 +1,1349 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Human approval of gated outbound API calls: the records, who decides, the ledger.
16
+
17
+ An API's `approval` block in `api-policy.yaml` gates calls; `api_client.py`
18
+ pauses the run before a gated call is sent, with an interrupt whose value
19
+ describes the exact request. When a run ends paused that way, the chat
20
+ runtime (`chat.py`) records one pending approval per interrupt here
21
+ (`ApprovalStore.add`) and ends the stream with `message.end` status
22
+ `awaiting_approval` and the approval (`ApprovalRecord.public()`).
23
+
24
+ Deciding is the action `approval.decide` of the auth policy, then this rule
25
+ (`may_decide`): `requester` in `approvers` lets the principal who started the
26
+ run (the thread's owner: only the owner runs on a thread) decide; `role:<x>`
27
+ lets any principal holding role `x` decide, except the requester, who may
28
+ approve their own call only when `requester` is listed. Anyone else gets 403.
29
+ A decision is one atomic change of a pending, unexpired row
30
+ (`ApprovalStore.decide`), so of two concurrent decisions one wins and the
31
+ other finds the approval no longer pending. The run then resumes with the
32
+ decision; the tool runs again, and `api_client` sends the call only when the
33
+ decision approves exactly the request it rebuilds (the same `call_hash`) and
34
+ `ApprovalStore.consume` (the ledger) marks the approval used: once, on the
35
+ thread it was made for. A tool call that runs again without a decision (a run
36
+ continued or replayed through LangGraph Server's own API) asks the ledger
37
+ first (`ApprovalStore.bound_approvals`) and sends no call an approval was
38
+ asked for.
39
+
40
+ Records (table `approvals`, `agent_approvals` under langgraph-server, in the
41
+ app's database; in process memory without one, and under `langgraph dev` also
42
+ in the file `.langgraph_api/agent_approvals.json` beside the dev server's own
43
+ threads, see `dev_ledger_path`): the thread, the run that
44
+ paused, the interrupt, the requester's hashed id and the roles and public
45
+ attributes the run acted with (never credentials), the tool call that asked
46
+ (the model message and its call id), the call (API, method, path, operation
47
+ id, `call_hash`, and in `payload` the query and JSON body as the approver sees
48
+ them, with the fields the tool named in `redact=` masked),
49
+ the approvers, the status (`pending`, `approved`, `rejected`, `expired`), the
50
+ decider's hashed id, the decision time and comment, when the approval was
51
+ used, and when it expires (`approval.timeout_s` after it was requested). A
52
+ decision relayed to another agent also keeps, in `payload`, the approval it
53
+ decides there (`nested`) and the call that will then happen (`effect`), and
54
+ expires 5 s before that approval at the latest. A call an agent's request
55
+ paused (under the fastapi runtime) also keeps the user's own words that agent
56
+ forwarded (`origin`, never shown): the run a decision resumes acts on them
57
+ (`resume_principal`). Once
58
+ decided or expired the query and body (and those of every nested call and of
59
+ the effect) are dropped from the record unless `TRACE_CAPTURE=full`, and the
60
+ user's words whatever it says. A pending approval past its expiry is expired (=
61
+ rejected): the runtime's sweep marks it every `SWEEP_INTERVAL_S` seconds and
62
+ every read treats it so. Deleting a thread deletes its approvals.
63
+
64
+ Who sees an approval (`may_view`): the thread's owner, a principal who may
65
+ decide it, and roles in `AUTH_READ_ACROSS_ROLES` (listing only; they see the
66
+ query and body only under `TRACE_CAPTURE=full`).
67
+
68
+ Agents calling agents: a thread's owner is its subject and, for a thread an
69
+ agent started for its user, that agent (`threads.ThreadRecord.actor`). The
70
+ user (a direct principal with that subject) is the requester of every
71
+ approval on the thread, whichever agent started it: they see it and, when
72
+ `requester` is listed, decide it. A delegated principal (an agent presenting
73
+ the user's token) sees only the approvals of threads started under its own
74
+ subject and actor; its roles never count as a role approver or a read-across
75
+ role. It decides only a gate whose rule opted into `decide_with: relayed` and
76
+ lists its actor in `relayers`, only on a thread it started itself, and only
77
+ with the approval's `digest` (`decide_refusal`, `digest_refusal`): it then
78
+ delivers the person's decision, recorded as `decided_via`. Under the default
79
+ `decide_with: direct` the person decides with their own credentials. How the
80
+ approvers decide is bound to the approval when it is asked, as the approvers
81
+ are. The requester's actor is recorded with the approval (`requester_actor`)
82
+ and restored when a role approver's decision resumes the run
83
+ (`resume_principal`).
84
+
85
+ `langgraph dev` keeps its threads in `.langgraph_api/` and loads them again
86
+ after a restart or a hot reload (a code change reloads the server), so the
87
+ approvals that bind their tool calls must outlive the process too: without
88
+ them, a run continued without input or replayed from a checkpoint would find
89
+ no record of a rejected, pending or used approval and, once the policy no
90
+ longer gates the call, send it. There the store writes its records to
91
+ `.langgraph_api/agent_approvals.json` (mode 0600) before a change takes
92
+ effect (a pending approval before the run ends awaiting it, a decision
93
+ before the run resumes, a use before the call is sent) and reads them at
94
+ startup; a file it cannot read stops the startup, and while a write fails
95
+ the store answers nothing (every lookup fails, so calls are refused) until
96
+ it writes again. Deleting `.langgraph_api/` resets the threads and their
97
+ approvals together. Without file persistence (`LANGGRAPH_DISABLE_FILE_PERSISTENCE`)
98
+ the dev server keeps no threads either, and the approvals stay in memory.
99
+ """
100
+
101
+ from __future__ import annotations
102
+
103
+ import asyncio
104
+ import contextlib
105
+ import hashlib
106
+ import json
107
+ import logging
108
+ import os
109
+ import threading
110
+ import uuid
111
+ from collections import OrderedDict
112
+ from collections.abc import Iterable, Mapping
113
+ from dataclasses import asdict, dataclass, field
114
+ from datetime import UTC, datetime, timedelta
115
+ from pathlib import Path
116
+ from typing import Any
117
+
118
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
119
+ A2A_OPERATION_KEY,
120
+ APPROVAL_DECISION,
121
+ APPROVAL_INTERRUPT,
122
+ DECIDE_DIRECT,
123
+ DECIDE_RELAYED,
124
+ DECIDE_WITH_VALUES,
125
+ DEFAULT_APPROVAL_TIMEOUT_S,
126
+ MAX_APPROVAL_TIMEOUT_S,
127
+ MIN_APPROVAL_TIMEOUT_S,
128
+ NESTED_MAX_DEPTH,
129
+ REQUESTER_APPROVER,
130
+ ROLE_APPROVER_PREFIX,
131
+ RPC_METHOD_KEY,
132
+ BoundApproval,
133
+ )
134
+ from {{cookiecutter.agent_directory}}.app_utils.auth import (
135
+ Principal,
136
+ actor_of_attributes,
137
+ owner_key_of,
138
+ read_across_roles,
139
+ with_origin,
140
+ )
141
+ from {{cookiecutter.agent_directory}}.app_utils.db import Database, StorageNotReady, capture_full
142
+ from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadRecord
143
+
144
+ logger = logging.getLogger(__name__)
145
+
146
+ PENDING = "pending"
147
+ APPROVED = "approved"
148
+ REJECTED = "rejected"
149
+ EXPIRED = "expired"
150
+ STATUSES = (PENDING, APPROVED, REJECTED, EXPIRED)
151
+
152
+ # Decisions a decider sends (`POST /threads/{id}/approvals/{approval_id}`).
153
+ APPROVE = "approve"
154
+ REJECT = "reject"
155
+ DECISIONS = {APPROVE: APPROVED, REJECT: REJECTED}
156
+
157
+ # Error codes clients see.
158
+ CODE_APPROVAL_PENDING = "approval_pending" # 409 on /chat while an approval is pending
159
+ CODE_NOT_PENDING = "approval_not_pending" # 409 on a decision: already decided
160
+ CODE_EXPIRED = "approval_expired" # 410 on a decision: it expired
161
+ CODE_NOT_AN_APPROVER = "not_an_approver" # 403 on a decision: not one of its approvers
162
+ CODE_DIRECT_ONLY = "approval_direct_only" # 403: an agent may not decide it for the person
163
+ CODE_DIGEST_MISMATCH = "approval_digest_mismatch" # 409: decided on another view of the call
164
+ NOT_AN_APPROVER_DETAIL = "You may not decide this approval (see its approvers)."
165
+ DIGEST_MISMATCH_DETAIL = (
166
+ "The decision was taken on a different view of the call; fetch the approval again."
167
+ )
168
+ # `sha256:<hex>`, at most this long in a decision.
169
+ DIGEST_MAX_CHARS = 80
170
+ DIGEST_PREFIX = "sha256:"
171
+
172
+ # How often every replica marks pending approvals past their expiry `expired`.
173
+ SWEEP_INTERVAL_S = 30.0
174
+ # Approvals kept without a database (the oldest decided ones go first).
175
+ MEMORY_APPROVALS_CAP = 10_000
176
+ COMMENT_MAX_CHARS = 1000
177
+ # Payload keys dropped once an approval is decided or expired (unless TRACE_CAPTURE=full).
178
+ CALL_CONTENT_KEYS = ("query", "body")
179
+ # The user's own words the request that paused carried (`credentials["@origin"]`, which an
180
+ # agent calling for them forwarded): kept in the payload while the approval waits, so the run
181
+ # it resumes acts on them whoever delivers the decision (`resume_principal`); dropped once it
182
+ # is decided or expired, whatever TRACE_CAPTURE says, and never shown.
183
+ ORIGIN_PAYLOAD_KEY = "origin"
184
+ # A decision relayed to another agent: the approval it decides there (`nested`, recursive:
185
+ # its `call`, and the approval that one relays in turn) and the call that will then happen
186
+ # (`effect`). Their query and body go with the call's once decided.
187
+ NESTED_KEY = "nested"
188
+ EFFECT_KEY = "effect"
189
+ # The relayed approval must still be open downstream when the person decides here: this
190
+ # approval expires this long before it at the latest.
191
+ NESTED_EXPIRY_MARGIN_S = 5
192
+ # What a call to a JSON-RPC API was, read from its body (`api_client.derive_rpc`): kept in
193
+ # the payload (only for such calls), part of which call the approval is bound to, and of the
194
+ # approver's view.
195
+ RPC_KEYS = (RPC_METHOD_KEY, A2A_OPERATION_KEY)
196
+
197
+ # Where `langgraph dev` keeps its threads (relative to the directory it runs in),
198
+ # and the file the app keeps their approvals in there.
199
+ DEV_STATE_DIR = ".langgraph_api"
200
+ DEV_LEDGER_FILE = "agent_approvals.json"
201
+ # Version 2 (0.3) adds each approval's requester actor, how it is decided (`decide_with`,
202
+ # `relayers`), `decided_via` and `display_digest`; a version-1 file reads with the defaults.
203
+ LEDGER_FILE_VERSION = 2
204
+ LEDGER_FILE_VERSIONS = (1, 2)
205
+ _DATETIME_FIELDS = ("decided_at", "used_at", "created_at", "expires_at")
206
+
207
+
208
+ def dev_ledger_path() -> Path | None:
209
+ """The file the approvals are kept in under `langgraph dev`, else None.
210
+
211
+ `langgraph dev` runs LangGraph Server's in-memory edition, which keeps its
212
+ threads in `.langgraph_api/` of the directory it runs in (unless file
213
+ persistence is off): the approvals are kept there too, so both survive a
214
+ restart or a hot reload. Everywhere else they live in the app's database
215
+ (or, with the fastapi runtime's in-memory checkpointer, in memory with
216
+ the threads).
217
+ """
218
+ edition = (os.environ.get("LANGGRAPH_RUNTIME_EDITION") or "").strip().lower()
219
+ if edition != "inmem":
220
+ return None
221
+ disabled = (os.environ.get("LANGGRAPH_DISABLE_FILE_PERSISTENCE") or "").strip().lower()
222
+ if disabled == "true":
223
+ return None
224
+ return Path(DEV_STATE_DIR) / DEV_LEDGER_FILE
225
+
226
+
227
+ class LedgerUnavailable(StorageNotReady):
228
+ """The approvals file cannot be written or read: the store answers nothing (a 503)."""
229
+
230
+
231
+ def utcnow() -> datetime:
232
+ return datetime.now(tz=UTC)
233
+
234
+
235
+ def _iso(value: datetime | None) -> str | None:
236
+ return value.isoformat() if value is not None else None
237
+
238
+
239
+ def _as_datetime(value: Any) -> datetime | None:
240
+ """A stored time as an aware datetime in UTC (naive: read as UTC).
241
+
242
+ Postgres answers `timestamptz` in the session's time zone: normalised, an
243
+ approval reads back with the very times it was written with (`public()`
244
+ shows them, and a relay that reads the ledger binds them in its decision).
245
+ """
246
+ if value is None:
247
+ return None
248
+ if isinstance(value, datetime):
249
+ parsed = value
250
+ else:
251
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
252
+ return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
253
+
254
+
255
+ def _json(value: Any) -> str:
256
+ return json.dumps(value, default=str)
257
+
258
+
259
+ def _decode(value: Any) -> Any:
260
+ return json.loads(value) if isinstance(value, str) else value
261
+
262
+
263
+ def is_approval_interrupt(value: Any) -> bool:
264
+ """Whether an interrupt value is a gated API call's (raised by `api_client`)."""
265
+ return isinstance(value, Mapping) and value.get("type") == APPROVAL_INTERRUPT
266
+
267
+
268
+ @dataclass
269
+ class ApprovalRecord:
270
+ approval_id: str
271
+ thread_id: str
272
+ run_id: str
273
+ interrupt_id: str
274
+ requester_hash: str
275
+ api: str
276
+ method: str
277
+ path: str
278
+ call_hash: str
279
+ approvers: list[str]
280
+ payload: dict[str, Any]
281
+ expires_at: datetime
282
+ operation_id: str | None = None
283
+ tool_call_id: str | None = None
284
+ # The model message that made the tool call (with `tool_call_id`, the tool call).
285
+ message_id: str | None = None
286
+ requester_context: dict[str, Any] = field(default_factory=dict)
287
+ status: str = PENDING
288
+ decided_by: str | None = None
289
+ decided_at: datetime | None = None
290
+ comment: str | None = None
291
+ used_at: datetime | None = None
292
+ created_at: datetime = field(default_factory=utcnow)
293
+ # The agent the requester's run acted through ("" for a direct requester).
294
+ requester_actor: str = ""
295
+ # How the requester decides, bound when the approval was asked: `direct`, or `relayed`
296
+ # by the agents in `relayers`.
297
+ decide_with: str = DECIDE_DIRECT
298
+ relayers: list[str] = field(default_factory=list)
299
+ # The agent that delivered the decision (a relayed decision), else None.
300
+ decided_via: str | None = None
301
+ # What the approver was shown, hashed (`approval_digest`): a relayed decision names it.
302
+ display_digest: str | None = None
303
+
304
+ def rpc(self) -> dict[str, str]:
305
+ """What a call to a JSON-RPC API was (`rpc_method`, `a2a_operation`); empty otherwise."""
306
+ return {
307
+ key: self.payload[key] for key in RPC_KEYS if isinstance(self.payload.get(key), str)
308
+ }
309
+
310
+ def effective_status(self, now: datetime | None = None) -> str:
311
+ """The status, with a pending approval past its expiry read as `expired`."""
312
+ if self.status == PENDING and self.expires_at <= (now or utcnow()):
313
+ return EXPIRED
314
+ return self.status
315
+
316
+ def is_pending(self, now: datetime | None = None) -> bool:
317
+ return self.effective_status(now) == PENDING
318
+
319
+ def public(self, *, include_call: bool = True, now: datetime | None = None) -> dict[str, Any]:
320
+ """The approval as clients see it (never the call hash, the interrupt or the rule).
321
+
322
+ `query` and `body` are there when `include_call` and the record still
323
+ holds them (pending, or `TRACE_CAPTURE=full`).
324
+ """
325
+ out: dict[str, Any] = {
326
+ "approval_id": self.approval_id,
327
+ "thread_id": self.thread_id,
328
+ "run_id": self.run_id,
329
+ "status": self.effective_status(now),
330
+ "api": self.api,
331
+ "method": self.method,
332
+ "path": self.path,
333
+ "operation_id": self.operation_id,
334
+ }
335
+ if include_call:
336
+ for key in CALL_CONTENT_KEYS:
337
+ if key in self.payload:
338
+ out[key] = self.payload[key]
339
+ out.update(self.rpc())
340
+ for key in (EFFECT_KEY, NESTED_KEY):
341
+ if isinstance(self.payload.get(key), dict):
342
+ out[key] = (
343
+ self.payload[key]
344
+ if include_call
345
+ else without_nested_call({key: self.payload[key]})[key]
346
+ )
347
+ out.update(
348
+ {
349
+ "tool": self.payload.get("tool"),
350
+ "reason": self.payload.get("reason"),
351
+ "approvers": list(self.approvers),
352
+ "decide_with": self.decide_with,
353
+ "requester": self.requester_hash,
354
+ "requester_actor": self.requester_actor or None,
355
+ "decided_via": self.decided_via,
356
+ "digest": self.display_digest,
357
+ "created_at": _iso(self.created_at),
358
+ "expires_at": _iso(self.expires_at),
359
+ "decided_by": self.decided_by,
360
+ "decided_at": _iso(self.decided_at),
361
+ "comment": self.comment,
362
+ }
363
+ )
364
+ return out
365
+
366
+
367
+ def approval_view(record: ApprovalRecord) -> dict[str, Any]:
368
+ """The call as the approver sees it while the approval is pending (masked fields masked).
369
+
370
+ `rpc_method` and `a2a_operation` are what a call to a JSON-RPC API was, read
371
+ from its body; None for any other call.
372
+ """
373
+ rpc = record.rpc()
374
+ return {
375
+ "api": record.api,
376
+ "method": record.method,
377
+ "path": record.path,
378
+ "operation_id": record.operation_id,
379
+ "rpc_method": rpc.get(RPC_METHOD_KEY),
380
+ "a2a_operation": rpc.get(A2A_OPERATION_KEY),
381
+ "query": record.payload.get("query"),
382
+ "body": record.payload.get("body"),
383
+ }
384
+
385
+
386
+ def _nested_expiry(nested: Any) -> datetime | None:
387
+ """The earliest `expires_at` of a relayed approval chain (`nested`), or None."""
388
+ earliest: datetime | None = None
389
+ level, depth = nested, 0
390
+ while isinstance(level, Mapping) and depth < NESTED_MAX_DEPTH:
391
+ try:
392
+ expiry = _as_datetime(level.get("expires_at"))
393
+ except (TypeError, ValueError):
394
+ expiry = None
395
+ if expiry is not None and (earliest is None or expiry < earliest):
396
+ earliest = expiry
397
+ level, depth = level.get(NESTED_KEY), depth + 1
398
+ return earliest
399
+
400
+
401
+ def without_nested_call(payload: Mapping[str, Any]) -> dict[str, Any]:
402
+ """`payload` with the query and body of its relayed approvals' calls, at every level
403
+ (`nested.call`, `nested.nested.call`, ...), and of its `effect`, taken out."""
404
+
405
+ def strip(level: Any, depth: int) -> Any:
406
+ if not isinstance(level, Mapping) or depth >= NESTED_MAX_DEPTH:
407
+ return level
408
+ out = dict(level)
409
+ if isinstance(out.get("call"), Mapping):
410
+ out["call"] = {k: v for k, v in out["call"].items() if k not in CALL_CONTENT_KEYS}
411
+ if NESTED_KEY in out:
412
+ out[NESTED_KEY] = strip(out[NESTED_KEY], depth + 1)
413
+ return out
414
+
415
+ out = dict(payload)
416
+ if isinstance(out.get(NESTED_KEY), Mapping):
417
+ out[NESTED_KEY] = strip(out[NESTED_KEY], 0)
418
+ if isinstance(out.get(EFFECT_KEY), Mapping):
419
+ out[EFFECT_KEY] = {k: v for k, v in out[EFFECT_KEY].items() if k not in CALL_CONTENT_KEYS}
420
+ return out
421
+
422
+
423
+ def approval_digest(view: Mapping[str, Any]) -> str:
424
+ """`sha256:<hex>` of the approver's view of a call (canonical JSON): what a relayed
425
+ decision names, so a decision taken on another view is refused."""
426
+ text = json.dumps(view, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
427
+ return DIGEST_PREFIX + hashlib.sha256(text.encode("utf-8")).hexdigest()
428
+
429
+
430
+ def record_from_interrupt(
431
+ value: Mapping[str, Any],
432
+ *,
433
+ interrupt_id: str,
434
+ thread_id: str,
435
+ run_id: str,
436
+ requester: Principal,
437
+ now: datetime | None = None,
438
+ origin: Mapping[str, Any] | None = None,
439
+ ) -> ApprovalRecord:
440
+ """A pending approval for the interrupt a gated call raised (`is_approval_interrupt`).
441
+
442
+ `origin` is the user's own words the paused request carried (`auth.origin_of`),
443
+ kept until the approval is decided so the resumed run acts on them.
444
+ """
445
+ now = now or utcnow()
446
+ raw_timeout = value.get("timeout_s")
447
+ timeout = (
448
+ raw_timeout
449
+ if isinstance(raw_timeout, int) and not isinstance(raw_timeout, bool)
450
+ else DEFAULT_APPROVAL_TIMEOUT_S
451
+ )
452
+ timeout = min(max(timeout, MIN_APPROVAL_TIMEOUT_S), MAX_APPROVAL_TIMEOUT_S)
453
+ approvers = [str(a) for a in value.get("approvers") or [] if isinstance(a, str)]
454
+ decide_with = value.get("decide_with")
455
+ if decide_with not in DECIDE_WITH_VALUES:
456
+ decide_with = DECIDE_DIRECT # a call paused before 0.3
457
+ relayers = value.get("relayers") if decide_with == DECIDE_RELAYED else None
458
+ payload = {
459
+ "query": value.get("query") or {},
460
+ "body": value.get("body"),
461
+ "tool": value.get("tool"),
462
+ "reason": value.get("reason"),
463
+ }
464
+ payload.update({key: value[key] for key in RPC_KEYS if isinstance(value.get(key), str)})
465
+ payload.update(
466
+ {key: value[key] for key in (NESTED_KEY, EFFECT_KEY) if isinstance(value.get(key), dict)}
467
+ )
468
+ if origin is not None and isinstance(origin.get("text"), str):
469
+ payload[ORIGIN_PAYLOAD_KEY] = dict(origin)
470
+ expires_at = now + timedelta(seconds=timeout)
471
+ downstream = _nested_expiry(payload.get(NESTED_KEY))
472
+ if downstream is not None:
473
+ # Never ask the person to approve what has expired where it happens.
474
+ expires_at = min(expires_at, downstream - timedelta(seconds=NESTED_EXPIRY_MARGIN_S))
475
+ record = ApprovalRecord(
476
+ approval_id=uuid.uuid4().hex,
477
+ thread_id=thread_id,
478
+ run_id=run_id,
479
+ interrupt_id=str(interrupt_id),
480
+ requester_hash=requester.hashed_id(),
481
+ requester_actor=requester.actor.id if requester.actor is not None else "",
482
+ requester_context={
483
+ "roles": [str(r) for r in requester.roles],
484
+ "attributes": requester.public_attributes(),
485
+ },
486
+ api=str(value.get("api") or ""),
487
+ method=str(value.get("method") or "").upper(),
488
+ path=str(value.get("path") or ""),
489
+ operation_id=str(value["operation_id"]) if value.get("operation_id") else None,
490
+ call_hash=str(value.get("call_hash") or ""),
491
+ tool_call_id=str(value["tool_call_id"]) if value.get("tool_call_id") else None,
492
+ message_id=str(value["message_id"]) if value.get("message_id") else None,
493
+ approvers=approvers,
494
+ payload=payload,
495
+ created_at=now,
496
+ expires_at=expires_at,
497
+ decide_with=str(decide_with),
498
+ relayers=[str(r) for r in relayers or [] if isinstance(r, str)],
499
+ )
500
+ record.display_digest = approval_digest(approval_view(record))
501
+ return record
502
+
503
+
504
+ def decision_value(record: ApprovalRecord, decision: str) -> dict[str, Any]:
505
+ """The resume value for the interrupt of `record`: `approve`, `reject` or `expired`,
506
+ or `pending` for a call whose approval still waits (another one was decided).
507
+
508
+ It names the call it was taken for (API, method, path, and for a JSON-RPC
509
+ call its `rpc_method` and `a2a_operation`): the client applies it to that
510
+ call whatever the policy says about gating it by then, so a rejected or
511
+ expired call is never sent and a pending one pauses again.
512
+ `approvers` (and how they decide: `decide_with`, `relayers`) are the ones
513
+ the approval was asked of: the client refuses an approval whose approvers,
514
+ or how they decide, differ from what the policy's gate names when the call
515
+ is about to be sent.
516
+ """
517
+ return {
518
+ "type": APPROVAL_DECISION,
519
+ "approval_id": record.approval_id,
520
+ "decision": decision,
521
+ "api": record.api,
522
+ "method": record.method,
523
+ "path": record.path,
524
+ "call_hash": record.call_hash,
525
+ "approvers": list(record.approvers),
526
+ "decide_with": record.decide_with,
527
+ "relayers": list(record.relayers),
528
+ "comment": record.comment,
529
+ **record.rpc(),
530
+ }
531
+
532
+
533
+ # ---------------------------------------------------------------------------
534
+ # Who decides, who sees
535
+ # ---------------------------------------------------------------------------
536
+
537
+
538
+ def role_approvers(approvers: Iterable[str]) -> set[str]:
539
+ return {
540
+ a[len(ROLE_APPROVER_PREFIX) :]
541
+ for a in approvers
542
+ if isinstance(a, str) and a.startswith(ROLE_APPROVER_PREFIX)
543
+ }
544
+
545
+
546
+ # The thread an approval was asked on: its `ThreadRecord`, or the owner's subject alone
547
+ # (a direct thread), as older callers pass it.
548
+ ThreadOwner = ThreadRecord | str | None
549
+
550
+
551
+ def thread_owner(owner: ThreadOwner) -> tuple[str, str]:
552
+ """`(subject, actor)` of a thread (`actor` "" for a direct thread)."""
553
+ if isinstance(owner, ThreadRecord):
554
+ return owner.principal_id or "", owner.actor or ""
555
+ return owner or "", ""
556
+
557
+
558
+ def is_requester(principal: Principal, owner: ThreadOwner) -> bool:
559
+ """The principal the thread's runs act for: the thread's owner (only the owner runs on it).
560
+
561
+ The subject directly, whichever agent started the thread; an agent only on
562
+ a thread started under its own subject and actor.
563
+ """
564
+ subject, actor = thread_owner(owner)
565
+ if not subject or principal.id != subject:
566
+ return False
567
+ return principal.actor is None or principal.actor.id == actor
568
+
569
+
570
+ def decide_refusal(
571
+ principal: Principal,
572
+ owner: ThreadOwner,
573
+ approvers: Iterable[str],
574
+ *,
575
+ decide_with: str = DECIDE_DIRECT,
576
+ relayers: Iterable[str] = (),
577
+ ) -> tuple[str, str] | None:
578
+ """Why `principal` may not approve or reject (`(code, detail)`, a 403), or None.
579
+
580
+ A direct principal: the requester decides only when `requester` is
581
+ listed, never through a role (no self-approval unless the policy asks for
582
+ requester confirmation); anyone else through a listed `role:` it holds. A
583
+ delegated principal (an agent) decides only on a thread it started for
584
+ that user (else it learns nothing more than that it is no approver), when
585
+ the rule is `decide_with: relayed` with `requester` listed (else
586
+ `approval_direct_only`: the person decides with their own credentials),
587
+ and when its actor is one of the rule's `relayers`. It must also send the
588
+ approval's digest (`digest_refusal`).
589
+ """
590
+ approvers = list(approvers)
591
+ if principal.actor is not None:
592
+ if not is_requester(principal, owner):
593
+ return CODE_NOT_AN_APPROVER, NOT_AN_APPROVER_DETAIL
594
+ if decide_with != DECIDE_RELAYED or REQUESTER_APPROVER not in approvers:
595
+ return (
596
+ CODE_DIRECT_ONLY,
597
+ "This approval must be decided by the person at this agent (decide_with: "
598
+ f"direct), not relayed by agent {principal.actor.id}.",
599
+ )
600
+ if principal.actor.id not in set(relayers):
601
+ return (
602
+ CODE_NOT_AN_APPROVER,
603
+ f"{principal.actor.id} may not relay decisions for this approval.",
604
+ )
605
+ return None
606
+ if is_requester(principal, owner):
607
+ allowed = REQUESTER_APPROVER in approvers
608
+ else:
609
+ allowed = bool(role_approvers(approvers) & set(principal.roles))
610
+ return None if allowed else (CODE_NOT_AN_APPROVER, NOT_AN_APPROVER_DETAIL)
611
+
612
+
613
+ def may_decide(
614
+ principal: Principal,
615
+ owner: ThreadOwner,
616
+ approvers: Iterable[str],
617
+ *,
618
+ decide_with: str = DECIDE_DIRECT,
619
+ relayers: Iterable[str] = (),
620
+ ) -> bool:
621
+ """Whether `principal` may approve or reject: see `decide_refusal`."""
622
+ refusal = decide_refusal(
623
+ principal, owner, approvers, decide_with=decide_with, relayers=relayers
624
+ )
625
+ return refusal is None
626
+
627
+
628
+ def digest_refusal(principal: Principal, record: ApprovalRecord, digest: Any) -> bool:
629
+ """Whether a decision's `digest` refuses it (409 `approval_digest_mismatch`).
630
+
631
+ A delegated decider (a relayer) must name the digest of the view the
632
+ person approved (`ApprovalRecord.display_digest`); a direct decider may,
633
+ and a digest it sends is checked too.
634
+ """
635
+ if digest is None and principal.actor is None:
636
+ return False
637
+ expected = record.display_digest
638
+ return not (isinstance(digest, str) and expected is not None and digest == expected)
639
+
640
+
641
+ def reads_across(principal: Principal) -> bool:
642
+ """A direct principal holding a read-across role (a delegated one never reads across)."""
643
+ return not principal.delegated and bool(set(principal.roles) & read_across_roles())
644
+
645
+
646
+ def may_view(principal: Principal, owner: ThreadOwner, approvers: Iterable[str]) -> bool:
647
+ """The owner, a decider, or a read-across role (for listing)."""
648
+ return (
649
+ is_requester(principal, owner)
650
+ or may_decide(principal, owner, approvers)
651
+ or reads_across(principal)
652
+ )
653
+
654
+
655
+ def sees_call(principal: Principal, owner: ThreadOwner, approvers: Iterable[str]) -> bool:
656
+ """Whether the viewer sees the call's query and body: the owner and the deciders
657
+ do (they need them); read-across roles only under `TRACE_CAPTURE=full`."""
658
+ return (
659
+ is_requester(principal, owner) or may_decide(principal, owner, approvers) or capture_full()
660
+ )
661
+
662
+
663
+ def resume_principal(
664
+ record: ApprovalRecord,
665
+ owner: ThreadOwner,
666
+ decider: Principal,
667
+ origin: Mapping[str, Any] | None = None,
668
+ ) -> Principal:
669
+ """Who the resumed run acts as: always the requester, never another decider.
670
+
671
+ When the requester decides (the same subject and actor, or the subject
672
+ itself on a thread its agent started), their own principal of this
673
+ request (its credentials included, for `auth: forward` APIs). When
674
+ someone else does (a role approver), the requester with the roles, public
675
+ attributes and actor the run had when it paused; credentials are never
676
+ stored, so an `auth: forward` call approved by someone else has none and
677
+ is not sent.
678
+
679
+ An agent's run acts on the user's words of the request that paused it
680
+ (`origin`, which the approval kept: `ORIGIN_PAYLOAD_KEY`), never on those
681
+ a decision it relays carries (the person's words when approving, at the
682
+ agent that asked): its tools check the same words again, and a relay it
683
+ makes in turn rebuilds the very call the person approved. The person
684
+ deciding directly acts with their own words.
685
+ """
686
+ subject, actor = thread_owner(owner)
687
+ if decider.actor is None and decider.id == subject:
688
+ return decider
689
+ if decider.owner_key() == owner_key_of(subject, actor):
690
+ return with_origin(decider, origin)
691
+ context = record.requester_context or {}
692
+ attributes = dict(context.get("attributes") or {})
693
+ rebuilt = Principal(
694
+ id=subject,
695
+ roles=[str(r) for r in context.get("roles") or []],
696
+ attributes=attributes,
697
+ actor=actor_of_attributes(attributes),
698
+ )
699
+ return with_origin(rebuilt, origin) if origin is not None else rebuilt
700
+
701
+
702
+ # ---------------------------------------------------------------------------
703
+ # The store (and the ledger `api_client` consults)
704
+ # ---------------------------------------------------------------------------
705
+
706
+
707
+ def _without_call(payload: Mapping[str, Any]) -> dict[str, Any]:
708
+ kept = {k: v for k, v in payload.items() if k != ORIGIN_PAYLOAD_KEY}
709
+ if capture_full():
710
+ return kept
711
+ return without_nested_call({k: v for k, v in kept.items() if k not in CALL_CONTENT_KEYS})
712
+
713
+
714
+ _COLUMNS = (
715
+ "approval_id, thread_id, run_id, interrupt_id, requester_hash, requester_context, api, "
716
+ "method, path, operation_id, call_hash, tool_call_id, message_id, approvers, payload, "
717
+ "status, decided_by, decided_at, comment, used_at, created_at, expires_at, requester_actor, "
718
+ "decide_with, relayers, decided_via, display_digest"
719
+ )
720
+
721
+
722
+ def _cleared_payload() -> str:
723
+ """The SQL of the payload with the call cleared, unless the first parameter is true
724
+ (TRACE_CAPTURE=full): its query and body, those of the relayed approvals' calls at
725
+ every level (as `without_nested_call`), and those of its `effect`. The user's words
726
+ (`ORIGIN_PAYLOAD_KEY`) go whatever the parameter says."""
727
+ paths = ["'{effect,query}'", "'{effect,body}'"]
728
+ for depth in range(1, NESTED_MAX_DEPTH + 1):
729
+ prefix = ",".join([NESTED_KEY] * depth)
730
+ # A text[] literal such as '{nested,call,body}' (built without doubled braces).
731
+ paths += ["'{" + prefix + ",call," + key + "}'" for key in CALL_CONTENT_KEYS]
732
+ cleared = " ".join(f"#- {path}" for path in paths)
733
+ return (
734
+ f"(CASE WHEN %s THEN payload ELSE (payload - 'query' - 'body') {cleared} END)"
735
+ f" - '{ORIGIN_PAYLOAD_KEY}'"
736
+ )
737
+
738
+
739
+ # Clears the call from the payload unless the first parameter is true (TRACE_CAPTURE=full).
740
+ _CLEARED_PAYLOAD = _cleared_payload()
741
+
742
+
743
+ def _record_from_row(row: Mapping[str, Any]) -> ApprovalRecord:
744
+ return ApprovalRecord(
745
+ approval_id=row["approval_id"],
746
+ thread_id=row["thread_id"],
747
+ run_id=row["run_id"],
748
+ interrupt_id=row["interrupt_id"],
749
+ requester_hash=row["requester_hash"],
750
+ requester_context=_decode(row.get("requester_context")) or {},
751
+ api=row["api"],
752
+ method=row["method"],
753
+ path=row["path"],
754
+ operation_id=row.get("operation_id"),
755
+ call_hash=row["call_hash"],
756
+ tool_call_id=row.get("tool_call_id"),
757
+ message_id=row.get("message_id"),
758
+ approvers=list(_decode(row.get("approvers")) or []),
759
+ payload=dict(_decode(row.get("payload")) or {}),
760
+ status=row["status"],
761
+ decided_by=row.get("decided_by"),
762
+ decided_at=_as_datetime(row.get("decided_at")),
763
+ comment=row.get("comment"),
764
+ used_at=_as_datetime(row.get("used_at")),
765
+ created_at=_as_datetime(row.get("created_at")) or utcnow(),
766
+ expires_at=_as_datetime(row.get("expires_at")) or utcnow(),
767
+ requester_actor=row.get("requester_actor") or "",
768
+ decide_with=(
769
+ row["decide_with"] if row.get("decide_with") in DECIDE_WITH_VALUES else DECIDE_DIRECT
770
+ ),
771
+ relayers=[str(r) for r in _decode(row.get("relayers")) or []],
772
+ decided_via=row.get("decided_via"),
773
+ display_digest=row.get("display_digest"),
774
+ )
775
+
776
+
777
+ def _row_of(record: ApprovalRecord) -> dict[str, Any]:
778
+ """A record as the approvals file keeps it (the table's columns, times in ISO form)."""
779
+ row = asdict(record)
780
+ for key in _DATETIME_FIELDS:
781
+ row[key] = _iso(row[key])
782
+ return row
783
+
784
+
785
+ class ApprovalStore:
786
+ """The approvals table under postgres, an in-process dict otherwise.
787
+
788
+ Also the ledger `api_client` marks approvals used in (`consume`). Times
789
+ are this process's clock (`clock`), for the expiry and the decision alike.
790
+ Without a database, `path` (`dev_ledger_path`, under `langgraph dev`) is
791
+ the file the records are kept in: call `load` once before use.
792
+ """
793
+
794
+ def __init__(
795
+ self, db: Database, *, clock: Any = utcnow, path: str | Path | None = None
796
+ ) -> None:
797
+ self.db = db
798
+ self.table = db.approvals_table
799
+ self._clock = clock
800
+ self._memory: OrderedDict[str, ApprovalRecord] = OrderedDict()
801
+ self.path = Path(path) if path is not None and not db.is_postgres else None
802
+ # The records, which graph runs may reach from other threads.
803
+ self._lock = threading.Lock()
804
+ # One file write at a time; a write older than the file's is skipped.
805
+ self._file_lock = threading.Lock()
806
+ self._changes = 0 # changes numbered as they are written out
807
+ self._saved = 0 # the newest change the file holds
808
+ self._failed = 0 # the newest change whose write failed
809
+
810
+ def now(self) -> datetime:
811
+ return self._clock()
812
+
813
+ # -- the file (langgraph dev) ------------------------------------------------
814
+
815
+ async def load(self) -> int:
816
+ """Read the records the file keeps (none without a file); how many.
817
+
818
+ Raises `LedgerUnavailable` when the file exists but cannot be read:
819
+ the server must not start without the approvals that bind the tool
820
+ calls of its threads.
821
+ """
822
+ if self.path is None:
823
+ return 0
824
+ records = await asyncio.to_thread(self._read)
825
+ with self._lock:
826
+ self._memory = OrderedDict((r.approval_id, r) for r in records)
827
+ logger.info("approvals kept in %s: %d loaded", self.path, len(records))
828
+ return len(records)
829
+
830
+ def _read(self) -> list[ApprovalRecord]:
831
+ assert self.path is not None
832
+ try:
833
+ raw = self.path.read_bytes()
834
+ except FileNotFoundError:
835
+ return []
836
+ except OSError as exc:
837
+ raise self._unreadable(exc) from exc
838
+ try:
839
+ data = json.loads(raw)
840
+ if not isinstance(data, dict) or data.get("version") not in LEDGER_FILE_VERSIONS:
841
+ raise ValueError(f"not a version {LEDGER_FILE_VERSION} approvals file")
842
+ return [_record_from_row(row) for row in data["approvals"]]
843
+ except Exception as exc:
844
+ raise self._unreadable(exc) from exc
845
+
846
+ def _unreadable(self, exc: BaseException) -> LedgerUnavailable:
847
+ return LedgerUnavailable(
848
+ f"the approvals file {self.path} cannot be read ({type(exc).__name__}). It binds "
849
+ "the tool calls of this server's threads to their approvals: without it, calls "
850
+ "rejected, pending or sent already could be sent again. Restore it, or delete "
851
+ f"{self.path.parent if self.path else DEV_STATE_DIR}/ to reset the dev server's "
852
+ "threads and approvals together."
853
+ )
854
+
855
+ def _dump(self) -> bytes:
856
+ """The records as the file keeps them (call with `_lock` held)."""
857
+ rows = [_row_of(r) for r in self._memory.values()]
858
+ return json.dumps({"version": LEDGER_FILE_VERSION, "approvals": rows}, default=str).encode()
859
+
860
+ def _write(self, change: int, data: bytes) -> None:
861
+ """Replace the file with `data` (atomically, mode 0600), unless it holds a newer change."""
862
+ assert self.path is not None
863
+ with self._file_lock:
864
+ if change <= self._saved:
865
+ return
866
+ self.path.parent.mkdir(parents=True, exist_ok=True)
867
+ partial = self.path.with_name(f".{self.path.name}.{os.getpid()}.partial")
868
+ try:
869
+ fd = os.open(partial, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
870
+ with os.fdopen(fd, "wb") as out:
871
+ out.write(data)
872
+ out.flush()
873
+ os.fsync(out.fileno())
874
+ os.replace(partial, self.path)
875
+ except BaseException:
876
+ with contextlib.suppress(OSError):
877
+ os.unlink(partial)
878
+ raise
879
+ self._saved = change
880
+
881
+ async def _save(self) -> None:
882
+ """Write the records out, after a change and before it takes effect (no file: nothing).
883
+
884
+ A failed write raises `LedgerUnavailable`, and the store answers
885
+ nothing until a later write succeeds (`_check_saved`).
886
+ """
887
+ if self.path is None:
888
+ return
889
+ with self._lock:
890
+ self._changes += 1
891
+ change = self._changes
892
+ data = self._dump()
893
+ try:
894
+ await asyncio.to_thread(self._write, change, data)
895
+ except Exception as exc:
896
+ self._failed = max(self._failed, change)
897
+ logger.error(
898
+ "the approvals file %s could not be written (%s): gated API calls and runs "
899
+ "on threads with approvals are refused until it is",
900
+ self.path,
901
+ type(exc).__name__,
902
+ )
903
+ raise LedgerUnavailable(
904
+ f"the approvals file could not be written ({type(exc).__name__})"
905
+ ) from exc
906
+
907
+ async def _check_saved(self) -> None:
908
+ """Before any use of the records: they are all in the file (a failed write is retried)."""
909
+ if self.path is not None and self._saved < self._failed:
910
+ await self._save()
911
+
912
+ # -- writes -----------------------------------------------------------------
913
+
914
+ async def add(self, record: ApprovalRecord) -> tuple[ApprovalRecord, bool, int]:
915
+ """Record a pending approval: `(record, created, superseded)`.
916
+
917
+ A tool asked again for the same interrupt (the run resumed for another
918
+ approval, so this tool call ran again) keeps the pending approval of the
919
+ same request instead of a second one (`created` False). A pending
920
+ approval of that interrupt for a different request, or past its expiry,
921
+ is marked expired (`superseded` counts them).
922
+ """
923
+ now = self.now()
924
+ if not self.db.is_postgres:
925
+ await self._check_saved()
926
+ with self._lock:
927
+ superseded = 0
928
+ kept = None
929
+ for existing in list(self._memory.values()):
930
+ if (
931
+ existing.thread_id != record.thread_id
932
+ or existing.interrupt_id != record.interrupt_id
933
+ or existing.status != PENDING
934
+ ):
935
+ continue
936
+ if existing.call_hash == record.call_hash and existing.expires_at > now:
937
+ kept = existing
938
+ break
939
+ self._close(existing, EXPIRED, now)
940
+ superseded += 1
941
+ if kept is None:
942
+ self._memory[record.approval_id] = record
943
+ self._evict()
944
+ if kept is not None and not superseded:
945
+ return kept, False, 0
946
+ await self._save()
947
+ return (kept, False, superseded) if kept is not None else (record, True, superseded)
948
+ rows = await self.db.fetchall(
949
+ f"""
950
+ UPDATE {self.table}
951
+ SET status = %s, decided_at = %s, payload = {_CLEARED_PAYLOAD}
952
+ WHERE thread_id = %s AND interrupt_id = %s AND status = %s
953
+ AND (call_hash <> %s OR expires_at <= %s)
954
+ RETURNING approval_id
955
+ """,
956
+ (
957
+ EXPIRED,
958
+ now,
959
+ capture_full(),
960
+ record.thread_id,
961
+ record.interrupt_id,
962
+ PENDING,
963
+ record.call_hash,
964
+ now,
965
+ ),
966
+ )
967
+ existing = await self.db.fetchone(
968
+ f"""
969
+ SELECT {_COLUMNS} FROM {self.table}
970
+ WHERE thread_id = %s AND interrupt_id = %s AND status = %s AND call_hash = %s
971
+ AND expires_at > %s
972
+ ORDER BY created_at DESC LIMIT 1
973
+ """,
974
+ (record.thread_id, record.interrupt_id, PENDING, record.call_hash, now),
975
+ )
976
+ if existing is not None:
977
+ return _record_from_row(existing), False, len(rows)
978
+ await self.db.execute(
979
+ f"""
980
+ INSERT INTO {self.table} ({_COLUMNS})
981
+ VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s::jsonb,
982
+ %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s)
983
+ """,
984
+ (
985
+ record.approval_id,
986
+ record.thread_id,
987
+ record.run_id,
988
+ record.interrupt_id,
989
+ record.requester_hash,
990
+ _json(record.requester_context),
991
+ record.api,
992
+ record.method,
993
+ record.path,
994
+ record.operation_id,
995
+ record.call_hash,
996
+ record.tool_call_id,
997
+ record.message_id,
998
+ _json(record.approvers),
999
+ _json(record.payload),
1000
+ record.status,
1001
+ record.decided_by,
1002
+ record.decided_at,
1003
+ record.comment,
1004
+ record.used_at,
1005
+ record.created_at,
1006
+ record.expires_at,
1007
+ record.requester_actor,
1008
+ record.decide_with,
1009
+ _json(record.relayers),
1010
+ record.decided_via,
1011
+ record.display_digest,
1012
+ ),
1013
+ )
1014
+ return record, True, len(rows)
1015
+
1016
+ async def decide(
1017
+ self,
1018
+ approval_id: str,
1019
+ status: str,
1020
+ decided_by: str,
1021
+ comment: str | None,
1022
+ decided_via: str | None = None,
1023
+ ) -> ApprovalRecord | None:
1024
+ """Decide a pending, unexpired approval (`approved` or `rejected`), atomically.
1025
+
1026
+ None when it is not pending any more (decided, expired, gone): of two
1027
+ concurrent decisions exactly one gets the record. `decided_via` is the
1028
+ agent that relayed the decision (None: decided directly).
1029
+ """
1030
+ if status not in (APPROVED, REJECTED):
1031
+ raise ValueError(f"not a decision: {status!r}")
1032
+ now = self.now()
1033
+ comment = (comment or "").strip()[:COMMENT_MAX_CHARS] or None
1034
+ if not self.db.is_postgres:
1035
+ await self._check_saved()
1036
+ with self._lock:
1037
+ record = self._memory.get(approval_id)
1038
+ if record is None or not record.is_pending(now):
1039
+ return None
1040
+ self._close(record, status, now, decided_by=decided_by, comment=comment)
1041
+ record.decided_via = decided_via
1042
+ await self._save()
1043
+ return record
1044
+ row = await self.db.fetchone(
1045
+ f"""
1046
+ UPDATE {self.table}
1047
+ SET status = %s, decided_by = %s, decided_at = %s, comment = %s,
1048
+ decided_via = %s, payload = {_CLEARED_PAYLOAD}
1049
+ WHERE approval_id = %s AND status = %s AND expires_at > %s
1050
+ RETURNING {_COLUMNS}
1051
+ """,
1052
+ (
1053
+ status,
1054
+ decided_by,
1055
+ now,
1056
+ comment,
1057
+ decided_via,
1058
+ capture_full(),
1059
+ approval_id,
1060
+ PENDING,
1061
+ now,
1062
+ ),
1063
+ )
1064
+ return _record_from_row(row) if row is not None else None
1065
+
1066
+ async def consume(
1067
+ self, approval_id: str, call_hash: str, thread_id: str | None = None
1068
+ ) -> str | None:
1069
+ """The ledger: mark an approved approval used, once. None when it may be sent now.
1070
+
1071
+ It must be approved, for exactly the request `call_hash`, on thread
1072
+ `thread_id` (when given), and never used before; else the reason.
1073
+ """
1074
+ now = self.now()
1075
+ if not self.db.is_postgres:
1076
+ await self._check_saved()
1077
+ with self._lock:
1078
+ record = self._memory.get(approval_id)
1079
+ problem = self._unusable(record, call_hash, thread_id)
1080
+ if problem is None and record is not None:
1081
+ record.used_at = now
1082
+ if problem is None:
1083
+ await self._save() # before the call is sent
1084
+ return problem
1085
+ row = await self.db.fetchone(
1086
+ f"""
1087
+ UPDATE {self.table} SET used_at = %s
1088
+ WHERE approval_id = %s AND status = %s AND used_at IS NULL AND call_hash = %s
1089
+ AND (%s::text IS NULL OR thread_id = %s::text)
1090
+ RETURNING approval_id
1091
+ """,
1092
+ (now, approval_id, APPROVED, call_hash, thread_id, thread_id),
1093
+ )
1094
+ if row is not None:
1095
+ return None
1096
+ return self._unusable(await self.get(approval_id), call_hash, thread_id) or (
1097
+ "the approval is not usable"
1098
+ )
1099
+
1100
+ @staticmethod
1101
+ def _unusable(
1102
+ record: ApprovalRecord | None, call_hash: str, thread_id: str | None
1103
+ ) -> str | None:
1104
+ if record is None:
1105
+ return "no such approval"
1106
+ if thread_id is not None and record.thread_id != thread_id:
1107
+ return "the approval belongs to another thread"
1108
+ if record.status != APPROVED:
1109
+ return f"the approval is {record.effective_status()}, not approved"
1110
+ if record.call_hash != call_hash:
1111
+ return "the approval is for a different request"
1112
+ if record.used_at is not None:
1113
+ return "the approval was already used"
1114
+ return None
1115
+
1116
+ async def expire(self, approval_id: str) -> ApprovalRecord | None:
1117
+ """Mark one pending approval `expired` (its run no longer waits for it); None if not pending."""
1118
+ now = self.now()
1119
+ if not self.db.is_postgres:
1120
+ await self._check_saved()
1121
+ with self._lock:
1122
+ record = self._memory.get(approval_id)
1123
+ if record is None or record.status != PENDING:
1124
+ return None
1125
+ self._close(record, EXPIRED, now)
1126
+ await self._save()
1127
+ return record
1128
+ row = await self.db.fetchone(
1129
+ f"""
1130
+ UPDATE {self.table}
1131
+ SET status = %s, decided_at = %s, payload = {_CLEARED_PAYLOAD}
1132
+ WHERE approval_id = %s AND status = %s
1133
+ RETURNING {_COLUMNS}
1134
+ """,
1135
+ (EXPIRED, now, capture_full(), approval_id, PENDING),
1136
+ )
1137
+ return _record_from_row(row) if row is not None else None
1138
+
1139
+ async def expire_due(self) -> list[ApprovalRecord]:
1140
+ """Mark every pending approval past its expiry `expired`; return them (the sweep).
1141
+
1142
+ Also retries a failed write of the approvals file.
1143
+ """
1144
+ now = self.now()
1145
+ if not self.db.is_postgres:
1146
+ await self._check_saved()
1147
+ with self._lock:
1148
+ due = [
1149
+ r for r in self._memory.values() if r.status == PENDING and r.expires_at <= now
1150
+ ]
1151
+ for record in due:
1152
+ self._close(record, EXPIRED, now)
1153
+ if due:
1154
+ await self._save()
1155
+ return due
1156
+ rows = await self.db.fetchall(
1157
+ f"""
1158
+ UPDATE {self.table}
1159
+ SET status = %s, decided_at = %s, payload = {_CLEARED_PAYLOAD}
1160
+ WHERE status = %s AND expires_at <= %s
1161
+ RETURNING {_COLUMNS}
1162
+ """,
1163
+ (EXPIRED, now, capture_full(), PENDING, now),
1164
+ )
1165
+ return [_record_from_row(r) for r in rows]
1166
+
1167
+ async def delete_for_thread(self, thread_id: str) -> int:
1168
+ """Delete every approval of a thread (the thread was deleted); how many."""
1169
+ if not self.db.is_postgres:
1170
+ await self._check_saved()
1171
+ with self._lock:
1172
+ gone = [k for k, r in self._memory.items() if r.thread_id == thread_id]
1173
+ for key in gone:
1174
+ del self._memory[key]
1175
+ if gone:
1176
+ await self._save()
1177
+ return len(gone)
1178
+ rows = await self.db.fetchall(
1179
+ f"DELETE FROM {self.table} WHERE thread_id = %s RETURNING approval_id", (thread_id,)
1180
+ )
1181
+ return len(rows)
1182
+
1183
+ # -- reads ------------------------------------------------------------------
1184
+
1185
+ async def _records(self) -> list[ApprovalRecord]:
1186
+ """The records kept without a database, in the order they were added."""
1187
+ await self._check_saved()
1188
+ with self._lock:
1189
+ return list(self._memory.values())
1190
+
1191
+ async def bound_approvals(
1192
+ self, *, tool_call: tuple[str, str] | None = None, interrupt_id: str | None = None
1193
+ ) -> list[BoundApproval]:
1194
+ """The ledger's answer for a tool call that runs again without a decision.
1195
+
1196
+ Every approval asked by the tool call `(message id, tool call id)` or by
1197
+ the interrupt `interrupt_id`, newest first, on any thread: a copy of a
1198
+ thread keeps its messages, so a tool call there is the same tool call
1199
+ (message ids are unique, so another thread's tool call never matches).
1200
+ `api_client` refuses the call when one of them was asked for it.
1201
+ """
1202
+ now = self.now()
1203
+ message_id, call_id = tool_call or (None, None)
1204
+ if not self.db.is_postgres:
1205
+ records = [
1206
+ r
1207
+ for r in await self._records()
1208
+ if (message_id and r.message_id == message_id and r.tool_call_id == call_id)
1209
+ or (interrupt_id and r.interrupt_id == interrupt_id)
1210
+ ]
1211
+ else:
1212
+ rows = await self.db.fetchall(
1213
+ f"""
1214
+ SELECT {_COLUMNS} FROM {self.table}
1215
+ WHERE (message_id = %s::text AND tool_call_id = %s::text)
1216
+ OR interrupt_id = %s::text
1217
+ """,
1218
+ (message_id, call_id, interrupt_id),
1219
+ )
1220
+ records = [_record_from_row(r) for r in rows]
1221
+ records.sort(key=lambda r: r.created_at, reverse=True)
1222
+ return [
1223
+ BoundApproval(
1224
+ api=r.api,
1225
+ method=r.method,
1226
+ path=r.path,
1227
+ status=r.effective_status(now),
1228
+ used=r.used_at is not None,
1229
+ rpc_method=r.rpc().get(RPC_METHOD_KEY),
1230
+ a2a_operation=r.rpc().get(A2A_OPERATION_KEY),
1231
+ )
1232
+ for r in records
1233
+ ]
1234
+
1235
+ async def get(self, approval_id: str) -> ApprovalRecord | None:
1236
+ if not self.db.is_postgres:
1237
+ await self._check_saved()
1238
+ with self._lock:
1239
+ return self._memory.get(approval_id)
1240
+ row = await self.db.fetchone(
1241
+ f"SELECT {_COLUMNS} FROM {self.table} WHERE approval_id = %s", (approval_id,)
1242
+ )
1243
+ return _record_from_row(row) if row is not None else None
1244
+
1245
+ async def for_thread(self, thread_id: str) -> list[ApprovalRecord]:
1246
+ """Every approval of a thread, newest first."""
1247
+ if not self.db.is_postgres:
1248
+ return sorted(
1249
+ (r for r in await self._records() if r.thread_id == thread_id),
1250
+ key=lambda r: r.created_at,
1251
+ reverse=True,
1252
+ )
1253
+ rows = await self.db.fetchall(
1254
+ f"SELECT {_COLUMNS} FROM {self.table} WHERE thread_id = %s "
1255
+ "ORDER BY created_at DESC, approval_id",
1256
+ (thread_id,),
1257
+ )
1258
+ return [_record_from_row(r) for r in rows]
1259
+
1260
+ async def pending_for_thread(self, thread_id: str) -> list[ApprovalRecord]:
1261
+ """The thread's pending approvals that have not expired, oldest first."""
1262
+ now = self.now()
1263
+ return sorted(
1264
+ (r for r in await self.for_thread(thread_id) if r.is_pending(now)),
1265
+ key=lambda r: r.created_at,
1266
+ )
1267
+
1268
+ async def visible(
1269
+ self,
1270
+ principal: Principal,
1271
+ *,
1272
+ status: str | None = None,
1273
+ limit: int = 50,
1274
+ offset: int = 0,
1275
+ ) -> list[ApprovalRecord]:
1276
+ """Approvals the caller may see across threads, newest first (`GET /approvals`).
1277
+
1278
+ Their own (requested on their threads), the ones a role of theirs may
1279
+ decide, and every one for a read-across role. A delegated principal
1280
+ sees only the ones requested under its own subject and actor. `status`
1281
+ filters (a pending approval past its expiry counts as `expired`).
1282
+ """
1283
+ now = self.now()
1284
+ own = principal.hashed_id()
1285
+ actor = principal.actor.id if principal.actor is not None else None
1286
+ across = reads_across(principal)
1287
+ # A delegated principal's roles never make it an approver.
1288
+ roles = (
1289
+ set() if actor is not None else {f"{ROLE_APPROVER_PREFIX}{r}" for r in principal.roles}
1290
+ )
1291
+
1292
+ def mine(r: ApprovalRecord) -> bool:
1293
+ return r.requester_hash == own and (actor is None or r.requester_actor == actor)
1294
+
1295
+ if not self.db.is_postgres:
1296
+ rows = [
1297
+ r
1298
+ for r in sorted(await self._records(), key=lambda r: r.created_at, reverse=True)
1299
+ if (across or mine(r) or roles & set(r.approvers))
1300
+ and (status is None or r.effective_status(now) == status)
1301
+ ]
1302
+ return rows[offset : offset + limit]
1303
+ conditions = [
1304
+ "(%s OR (requester_hash = %s AND (%s::text IS NULL OR requester_actor = %s::text)) "
1305
+ "OR approvers ?| %s::text[])"
1306
+ ]
1307
+ params: list[Any] = [across, own, actor, actor, sorted(roles)]
1308
+ if status == PENDING:
1309
+ conditions.append("status = %s AND expires_at > %s")
1310
+ params += [PENDING, now]
1311
+ elif status == EXPIRED:
1312
+ conditions.append("(status = %s OR (status = %s AND expires_at <= %s))")
1313
+ params += [EXPIRED, PENDING, now]
1314
+ elif status is not None:
1315
+ conditions.append("status = %s")
1316
+ params.append(status)
1317
+ rows = await self.db.fetchall(
1318
+ f"SELECT {_COLUMNS} FROM {self.table} WHERE {' AND '.join(conditions)} "
1319
+ "ORDER BY created_at DESC, approval_id LIMIT %s OFFSET %s",
1320
+ (*params, limit, offset),
1321
+ )
1322
+ return [_record_from_row(r) for r in rows]
1323
+
1324
+ # -- memory -----------------------------------------------------------------
1325
+
1326
+ @staticmethod
1327
+ def _close(
1328
+ record: ApprovalRecord,
1329
+ status: str,
1330
+ now: datetime,
1331
+ *,
1332
+ decided_by: str | None = None,
1333
+ comment: str | None = None,
1334
+ ) -> None:
1335
+ record.status = status
1336
+ record.decided_at = now
1337
+ if decided_by is not None:
1338
+ record.decided_by = decided_by
1339
+ if comment is not None:
1340
+ record.comment = comment
1341
+ record.payload = _without_call(record.payload)
1342
+
1343
+ def _evict(self) -> None:
1344
+ while len(self._memory) > MEMORY_APPROVALS_CAP:
1345
+ victim = next(
1346
+ (k for k, r in self._memory.items() if r.status != PENDING),
1347
+ next(iter(self._memory)),
1348
+ )
1349
+ del self._memory[victim]