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,580 @@
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
+ """App-owned tables beside the checkpointer schema, and the run-record store.
16
+
17
+ Run records are durable rows in Postgres whenever the app has a Postgres
18
+ database: `POSTGRES_DSN` under `CHECKPOINTER=postgres` (fastapi; tables `runs`
19
+ and `threads`), `DATABASE_URI` under langgraph-server (table `agent_runs`, so
20
+ nothing collides with the server's own schema). Otherwise they live in a
21
+ bounded in-process dict (the newest `MEMORY_RUNS_CAP` records). Approvals of
22
+ gated API calls (`approvals.py`) live beside them the same way: table
23
+ `approvals` (fastapi) or `agent_approvals` (langgraph-server), else in process
24
+ memory, which `langgraph dev` also writes to a file beside its own threads. So
25
+ do A2A tasks (`a2a.py`): table `a2a_tasks` (fastapi) or `agent_a2a_tasks`
26
+ (langgraph-server), else in process memory.
27
+
28
+ A run's record is written when it starts, with status `running`, and
29
+ updated when it ends (`ok`, `step_limit`, `error`, `timeout`, `cancelled` or
30
+ `interrupted`). A run whose process died (a crash, an OOM kill, a lost node, a
31
+ rollout that ran out of grace) never updates its record: `RunStore.reconcile`
32
+ marks such records `interrupted` once the run's lease on its thread has
33
+ expired (see `run_locks.py`), at startup and every minute.
34
+
35
+ Records always hold the `metadata` capture set plus the caller's (capped)
36
+ `/chat` metadata (and, for a run an agent made for its user, that agent's
37
+ id, `actor`: a client name, not personal data), and hold request/response
38
+ content only under `TRACE_CAPTURE=full`. The schema is created with `CREATE ... IF NOT EXISTS` /
39
+ `ADD COLUMN IF NOT EXISTS` under a Postgres advisory lock, so replicas
40
+ starting together do not race and an existing database is upgraded in place
41
+ (indexes added to existing `threads` and `runs` tables are built concurrently,
42
+ without blocking writes). `RETENTION_DAYS` (see `chat.py`) purges the records
43
+ of idle threads.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json
49
+ import os
50
+ from collections import OrderedDict
51
+ from collections.abc import Awaitable, Callable, Sequence
52
+ from dataclasses import asdict, dataclass, field
53
+ from datetime import UTC, datetime
54
+ from typing import Any
55
+
56
+ from {{cookiecutter.agent_directory}}.app_utils.checkpointer import (
57
+ MEMORY,
58
+ POSTGRES,
59
+ DbHealth,
60
+ checkpointer_kind,
61
+ is_connection_error,
62
+ open_pool,
63
+ postgres_dsn,
64
+ schema_lock,
65
+ )
66
+
67
+ RUNS_TABLE = "runs"
68
+ SERVER_RUNS_TABLE = "agent_runs"
69
+ LOCKS_TABLE = "thread_locks"
70
+ SERVER_LOCKS_TABLE = "agent_thread_locks"
71
+ APPROVALS_TABLE = "approvals"
72
+ SERVER_APPROVALS_TABLE = "agent_approvals"
73
+ A2A_TASKS_TABLE = "a2a_tasks"
74
+ SERVER_A2A_TASKS_TABLE = "agent_a2a_tasks"
75
+ MEMORY_RUNS_CAP = 10_000
76
+
77
+ # Run statuses owned by the store (the others are set by `chat.py`).
78
+ RUN_RUNNING = "running"
79
+ RUN_INTERRUPTED = "interrupted"
80
+
81
+ # Table names are constants of this module, never caller input.
82
+ RUNS_DDL = """
83
+ CREATE TABLE IF NOT EXISTS {runs} (
84
+ run_id TEXT PRIMARY KEY,
85
+ thread_id TEXT NOT NULL,
86
+ principal_hash TEXT NOT NULL,
87
+ model TEXT,
88
+ status TEXT NOT NULL,
89
+ input_tokens INTEGER,
90
+ output_tokens INTEGER,
91
+ latency_ms INTEGER,
92
+ error_type TEXT,
93
+ metadata JSONB,
94
+ payload JSONB,
95
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
96
+ );
97
+ ALTER TABLE {runs} ADD COLUMN IF NOT EXISTS metadata JSONB;
98
+ ALTER TABLE {runs} ADD COLUMN IF NOT EXISTS actor TEXT;
99
+ CREATE INDEX IF NOT EXISTS {runs}_thread_id_idx ON {runs} (thread_id);
100
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS {runs}_running_idx ON {runs} (created_at)
101
+ WHERE status = 'running';
102
+ """
103
+
104
+ # One row per thread with a run in progress: the replica holding it (`owner`),
105
+ # a fencing token that changes whenever the thread changes hands, and the
106
+ # lease expiry the holder keeps pushing forward (see `run_locks.py`).
107
+ LOCKS_DDL = """
108
+ CREATE TABLE IF NOT EXISTS {locks} (
109
+ thread_id TEXT PRIMARY KEY,
110
+ owner TEXT NOT NULL,
111
+ token BIGINT NOT NULL,
112
+ expires_at TIMESTAMPTZ NOT NULL,
113
+ acquired_at TIMESTAMPTZ NOT NULL DEFAULT now()
114
+ );
115
+ CREATE SEQUENCE IF NOT EXISTS {locks}_token_seq;
116
+ """
117
+
118
+ # Human approvals of gated outbound API calls (see `approvals.py`). The requester
119
+ # and the decider are hashed ids; `payload` is what approvers are shown (the
120
+ # call, masked fields masked); `requester_context` the requester's roles and
121
+ # public attributes, which a run resumed by another principal acts with;
122
+ # `message_id` and `tool_call_id` name the tool call that asked, which the API
123
+ # client looks up (with `interrupt_id`) when that tool call runs again;
124
+ # `requester_actor` the agent the requester's run acted through ('' for none);
125
+ # `decide_with` and `relayers` how the requester decides (bound when asked),
126
+ # `decided_via` the agent that relayed the decision, `display_digest` a hash of
127
+ # what the approver was shown (a relayed decision names it).
128
+ APPROVALS_DDL = """
129
+ CREATE TABLE IF NOT EXISTS {approvals} (
130
+ approval_id TEXT PRIMARY KEY,
131
+ thread_id TEXT NOT NULL,
132
+ run_id TEXT NOT NULL,
133
+ interrupt_id TEXT NOT NULL,
134
+ requester_hash TEXT NOT NULL,
135
+ requester_context JSONB,
136
+ api TEXT NOT NULL,
137
+ method TEXT NOT NULL,
138
+ path TEXT NOT NULL,
139
+ operation_id TEXT,
140
+ call_hash TEXT NOT NULL,
141
+ tool_call_id TEXT,
142
+ message_id TEXT,
143
+ approvers JSONB NOT NULL,
144
+ payload JSONB NOT NULL,
145
+ status TEXT NOT NULL,
146
+ decided_by TEXT,
147
+ decided_at TIMESTAMPTZ,
148
+ comment TEXT,
149
+ used_at TIMESTAMPTZ,
150
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
151
+ expires_at TIMESTAMPTZ NOT NULL
152
+ );
153
+ ALTER TABLE {approvals} ADD COLUMN IF NOT EXISTS message_id TEXT;
154
+ ALTER TABLE {approvals} ADD COLUMN IF NOT EXISTS requester_actor TEXT NOT NULL DEFAULT '';
155
+ ALTER TABLE {approvals} ADD COLUMN IF NOT EXISTS decide_with TEXT NOT NULL DEFAULT 'direct';
156
+ ALTER TABLE {approvals} ADD COLUMN IF NOT EXISTS relayers JSONB NOT NULL DEFAULT '[]'::jsonb;
157
+ ALTER TABLE {approvals} ADD COLUMN IF NOT EXISTS decided_via TEXT;
158
+ ALTER TABLE {approvals} ADD COLUMN IF NOT EXISTS display_digest TEXT;
159
+ CREATE INDEX IF NOT EXISTS {approvals}_thread_id_idx ON {approvals} (thread_id);
160
+ CREATE INDEX IF NOT EXISTS {approvals}_tool_call_idx ON {approvals} (message_id, tool_call_id);
161
+ CREATE INDEX IF NOT EXISTS {approvals}_interrupt_idx ON {approvals} (interrupt_id);
162
+ CREATE INDEX IF NOT EXISTS {approvals}_pending_idx ON {approvals} (expires_at)
163
+ WHERE status = 'pending';
164
+ CREATE INDEX IF NOT EXISTS {approvals}_requester_idx ON {approvals} (requester_hash);
165
+ CREATE INDEX IF NOT EXISTS {approvals}_approvers_idx ON {approvals} USING gin (approvers);
166
+ """
167
+
168
+ # A2A tasks (see `a2a.py`): the SDK's Task as JSON, keyed by its owner (the principal
169
+ # id, as `threads.principal_id`) and its id. `thread_id` is the conversation its
170
+ # contextId names (deleting the thread deletes its tasks), `state` and `status_at`
171
+ # its status (ListTasks filters and orders by them), `updated_at` its last save
172
+ # (`A2A_TASK_TTL_S` counts from it, on the database clock).
173
+ A2A_TASKS_DDL = """
174
+ CREATE TABLE IF NOT EXISTS {tasks} (
175
+ owner TEXT NOT NULL,
176
+ task_id TEXT NOT NULL,
177
+ context_id TEXT NOT NULL,
178
+ thread_id TEXT NOT NULL,
179
+ state TEXT NOT NULL,
180
+ status_at TIMESTAMPTZ,
181
+ task JSONB NOT NULL,
182
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
183
+ PRIMARY KEY (owner, task_id)
184
+ );
185
+ CREATE INDEX IF NOT EXISTS {tasks}_thread_id_idx ON {tasks} (thread_id);
186
+ CREATE INDEX IF NOT EXISTS {tasks}_updated_at_idx ON {tasks} (updated_at);
187
+ CREATE INDEX IF NOT EXISTS {tasks}_owner_status_idx
188
+ ON {tasks} (owner, status_at DESC, task_id DESC);
189
+ CREATE INDEX IF NOT EXISTS {tasks}_running_idx ON {tasks} (updated_at)
190
+ WHERE state IN ('TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING');
191
+ """
192
+
193
+ THREADS_DDL = """
194
+ CREATE TABLE IF NOT EXISTS threads (
195
+ thread_id TEXT PRIMARY KEY,
196
+ principal_id TEXT NOT NULL,
197
+ tenant TEXT,
198
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
199
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
200
+ );
201
+ ALTER TABLE threads ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
202
+ ALTER TABLE threads ADD COLUMN IF NOT EXISTS actor TEXT NOT NULL DEFAULT '';
203
+ CREATE INDEX IF NOT EXISTS threads_principal_id_idx ON threads (principal_id);
204
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS threads_updated_at_idx ON threads (updated_at);
205
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS threads_principal_actor_idx ON threads (principal_id, actor);
206
+ """
207
+
208
+
209
+ def capture_full() -> bool:
210
+ """True when `TRACE_CAPTURE=full`; the default is `metadata`."""
211
+ return (os.environ.get("TRACE_CAPTURE") or "metadata").strip().lower() == "full"
212
+
213
+
214
+ def utcnow_iso() -> str:
215
+ return datetime.now(tz=UTC).isoformat()
216
+
217
+
218
+ def is_postgres_url(url: str | None) -> bool:
219
+ return bool(url) and str(url).strip().lower().startswith(("postgres://", "postgresql://"))
220
+
221
+
222
+ class StorageNotReady(RuntimeError):
223
+ """The app's database is not set up yet (it has been unreachable since startup)."""
224
+
225
+
226
+ def is_database_unavailable(exc: BaseException) -> bool:
227
+ """True when `exc` means the database cannot be reached (a 503, not a 500)."""
228
+ return isinstance(exc, StorageNotReady) or is_connection_error(exc)
229
+
230
+
231
+ @dataclass
232
+ class RunRecord:
233
+ run_id: str
234
+ thread_id: str
235
+ principal_hash: str
236
+ model: str | None
237
+ status: str
238
+ input_tokens: int | None = None
239
+ output_tokens: int | None = None
240
+ latency_ms: int | None = None
241
+ error_type: str | None = None
242
+ metadata: dict[str, Any] | None = None
243
+ payload: dict[str, Any] | None = None
244
+ created_at: str = field(default_factory=utcnow_iso)
245
+ # The agent that ran it for the principal (a delegated request's actor), else None.
246
+ actor: str | None = None
247
+
248
+ def as_dict(self) -> dict[str, Any]:
249
+ return asdict(self)
250
+
251
+
252
+ class Database:
253
+ """The memory/postgres switch shared by the run and thread stores.
254
+
255
+ Under postgres one connection pool serves the app tables and (fastapi)
256
+ the checkpointer; see `checkpointer.open_pool` for its health check and
257
+ sizing. `health` tracks whether the database answered at last contact.
258
+ """
259
+
260
+ def __init__(
261
+ self,
262
+ kind: str,
263
+ dsn: str | None = None,
264
+ *,
265
+ runs_table: str = RUNS_TABLE,
266
+ locks_table: str = LOCKS_TABLE,
267
+ approvals_table: str = APPROVALS_TABLE,
268
+ a2a_tasks_table: str = A2A_TASKS_TABLE,
269
+ with_threads: bool = True,
270
+ ) -> None:
271
+ self.kind = kind
272
+ self.dsn = dsn
273
+ self.runs_table = runs_table
274
+ self.locks_table = locks_table
275
+ self.approvals_table = approvals_table
276
+ self.a2a_tasks_table = a2a_tasks_table
277
+ self.with_threads = with_threads
278
+ self.health = DbHealth()
279
+ self.pool: Any = None
280
+ self._pool_cm: Any = None
281
+
282
+ @classmethod
283
+ def from_env(cls) -> Database:
284
+ """The fastapi runtime's database, from `CHECKPOINTER` / `POSTGRES_DSN`."""
285
+ kind = checkpointer_kind()
286
+ if kind == POSTGRES:
287
+ return cls(POSTGRES, postgres_dsn())
288
+ if kind == MEMORY:
289
+ return cls(MEMORY)
290
+ raise RuntimeError(f"Unknown CHECKPOINTER {kind!r}; expected 'memory' or 'postgres'.")
291
+
292
+ @classmethod
293
+ def for_server(cls) -> Database:
294
+ """The langgraph-server runtime's run-record store: the server's Postgres, if any.
295
+
296
+ `langgraph dev` sets `DATABASE_URI=:memory:`; only a postgres URL is a database.
297
+ """
298
+ uri = (os.environ.get("DATABASE_URI") or "").strip()
299
+ tables = {
300
+ "runs_table": SERVER_RUNS_TABLE,
301
+ "locks_table": SERVER_LOCKS_TABLE,
302
+ "approvals_table": SERVER_APPROVALS_TABLE,
303
+ "a2a_tasks_table": SERVER_A2A_TASKS_TABLE,
304
+ }
305
+ if is_postgres_url(uri):
306
+ return cls(POSTGRES, uri, with_threads=False, **tables)
307
+ return cls(MEMORY, with_threads=False, **tables)
308
+
309
+ @property
310
+ def is_postgres(self) -> bool:
311
+ return self.kind == POSTGRES
312
+
313
+ async def open(self) -> None:
314
+ """Open the pool and set up the schema at once (see `open_pool` and `setup`)."""
315
+ await self.open_pool()
316
+ try:
317
+ await self.setup()
318
+ except BaseException:
319
+ await self.close()
320
+ raise
321
+
322
+ async def open_pool(self) -> None:
323
+ """Open the connection pool without waiting for the database (postgres only).
324
+
325
+ A DSN that does not parse raises `SettingsError`; an unreachable
326
+ database does not: the pool connects once the database answers.
327
+ """
328
+ if not self.is_postgres or self.pool is not None:
329
+ return
330
+ self._pool_cm = open_pool(self.dsn or "", self.health)
331
+ self.pool = await self._pool_cm.__aenter__()
332
+
333
+ def ddl(self) -> str:
334
+ return (
335
+ RUNS_DDL.format(runs=self.runs_table)
336
+ + LOCKS_DDL.format(locks=self.locks_table)
337
+ + APPROVALS_DDL.format(approvals=self.approvals_table)
338
+ + A2A_TASKS_DDL.format(tasks=self.a2a_tasks_table)
339
+ + (THREADS_DDL if self.with_threads else "")
340
+ )
341
+
342
+ async def setup(self, extra: Callable[[], Awaitable[None]] | None = None) -> None:
343
+ """Create or upgrade the app tables, then run `extra` (the saver's own setup).
344
+
345
+ Both run under the schema lock, so replicas starting together take
346
+ turns. Raises while the database is unreachable; the caller retries.
347
+ """
348
+ if not self.is_postgres:
349
+ return
350
+ if self.pool is None:
351
+ raise StorageNotReady("the database pool is not open")
352
+ async with schema_lock(self.dsn or "", health=self.health):
353
+ async with self.pool.connection() as conn:
354
+ for statement in _statements(self.ddl()):
355
+ await conn.execute(statement)
356
+ if extra is not None:
357
+ await extra()
358
+
359
+ async def close(self) -> None:
360
+ cm, self._pool_cm = self._pool_cm, None
361
+ self.pool = None
362
+ if cm is not None:
363
+ await cm.__aexit__(None, None, None)
364
+
365
+ async def ping(self) -> None:
366
+ """A trivial query; raises when the database does not answer."""
367
+ if not self.is_postgres:
368
+ return
369
+ await self.fetchone("SELECT 1 AS ok")
370
+
371
+ async def execute(self, sql: str, params: Sequence[Any] = ()) -> None:
372
+ await self._run(sql, params, None)
373
+
374
+ async def fetchone(self, sql: str, params: Sequence[Any] = ()) -> dict[str, Any] | None:
375
+ row = await self._run(sql, params, "one")
376
+ return dict(row) if row is not None else None
377
+
378
+ async def fetchall(self, sql: str, params: Sequence[Any] = ()) -> list[dict[str, Any]]:
379
+ return [dict(r) for r in await self._run(sql, params, "all")]
380
+
381
+ async def _run(self, sql: str, params: Sequence[Any], fetch: str | None) -> Any:
382
+ if self.pool is None:
383
+ raise StorageNotReady("the database pool is not open")
384
+ async with self.pool.connection() as conn:
385
+ try:
386
+ cur = await conn.execute(sql, params)
387
+ if fetch == "one":
388
+ return await cur.fetchone()
389
+ if fetch == "all":
390
+ return await cur.fetchall()
391
+ return None
392
+ except Exception as exc:
393
+ if getattr(conn, "broken", False) or getattr(conn, "closed", False):
394
+ self.health.mark_down(exc)
395
+ raise
396
+
397
+
398
+ def _statements(ddl: str) -> list[str]:
399
+ """One statement per `execute` (the pool may prepare statements, which run one at a time)."""
400
+ return [part.strip() for part in ddl.split(";") if part.strip()]
401
+
402
+
403
+ def _json(value: dict[str, Any] | None) -> str | None:
404
+ return json.dumps(value) if value is not None else None
405
+
406
+
407
+ class RunStore:
408
+ """Run records: a Postgres table, or a bounded in-process dict under memory."""
409
+
410
+ def __init__(self, db: Database) -> None:
411
+ self.db = db
412
+ self.table = db.runs_table
413
+ self.locks_table = db.locks_table
414
+ self._memory: OrderedDict[str, RunRecord] = OrderedDict()
415
+
416
+ async def start(self, run: RunRecord) -> None:
417
+ """Record a run as it starts (status `running`); `record` writes how it ended."""
418
+ run.status = RUN_RUNNING
419
+ if not self.db.is_postgres:
420
+ self._remember(run)
421
+ return
422
+ await self.db.execute(
423
+ f"""
424
+ INSERT INTO {self.table} (run_id, thread_id, principal_hash, model, status,
425
+ metadata, created_at, actor)
426
+ VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
427
+ ON CONFLICT (run_id) DO NOTHING
428
+ """,
429
+ (
430
+ run.run_id,
431
+ run.thread_id,
432
+ run.principal_hash,
433
+ run.model,
434
+ run.status,
435
+ _json(run.metadata),
436
+ run.created_at,
437
+ run.actor,
438
+ ),
439
+ )
440
+
441
+ async def reconcile(self, grace_s: float) -> list[str]:
442
+ """Mark `running` records whose run is gone as `interrupted`; return their run ids.
443
+
444
+ A run holds a lease on its thread for as long as it runs, so a
445
+ `running` record older than `grace_s` whose thread has no live lease
446
+ belongs to a process that died before it could record how the run
447
+ ended. Replicas may reconcile at the same time: each record is updated
448
+ once (the second update no longer matches `status = 'running'`).
449
+ """
450
+ if not self.db.is_postgres:
451
+ return []
452
+ rows = await self.db.fetchall(
453
+ f"""
454
+ UPDATE {self.table} AS r
455
+ SET status = %s, error_type = COALESCE(r.error_type, 'ProcessLost')
456
+ WHERE r.status = %s
457
+ AND r.created_at < now() - make_interval(secs => %s)
458
+ AND NOT EXISTS (
459
+ SELECT 1 FROM {self.locks_table} AS l
460
+ WHERE l.thread_id = r.thread_id AND l.expires_at > now())
461
+ RETURNING r.run_id
462
+ """,
463
+ (RUN_INTERRUPTED, RUN_RUNNING, float(grace_s)),
464
+ )
465
+ return [r["run_id"] for r in rows]
466
+
467
+ def _remember(self, run: RunRecord) -> None:
468
+ self._memory[run.run_id] = run
469
+ self._memory.move_to_end(run.run_id)
470
+ while len(self._memory) > MEMORY_RUNS_CAP:
471
+ self._memory.popitem(last=False)
472
+
473
+ async def record(self, run: RunRecord) -> None:
474
+ if not self.db.is_postgres:
475
+ self._remember(run)
476
+ return
477
+ await self.db.execute(
478
+ f"""
479
+ INSERT INTO {self.table} (run_id, thread_id, principal_hash, model, status,
480
+ input_tokens, output_tokens, latency_ms, error_type, metadata, payload, created_at,
481
+ actor)
482
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s)
483
+ ON CONFLICT (run_id) DO UPDATE SET
484
+ status = EXCLUDED.status, input_tokens = EXCLUDED.input_tokens,
485
+ output_tokens = EXCLUDED.output_tokens, latency_ms = EXCLUDED.latency_ms,
486
+ error_type = EXCLUDED.error_type, metadata = EXCLUDED.metadata,
487
+ payload = EXCLUDED.payload
488
+ """,
489
+ (
490
+ run.run_id,
491
+ run.thread_id,
492
+ run.principal_hash,
493
+ run.model,
494
+ run.status,
495
+ run.input_tokens,
496
+ run.output_tokens,
497
+ run.latency_ms,
498
+ run.error_type,
499
+ _json(run.metadata),
500
+ _json(run.payload),
501
+ run.created_at,
502
+ run.actor,
503
+ ),
504
+ )
505
+
506
+ async def get(self, run_id: str) -> RunRecord | None:
507
+ if not self.db.is_postgres:
508
+ return self._memory.get(run_id)
509
+ row = await self.db.fetchone(f"SELECT * FROM {self.table} WHERE run_id = %s", (run_id,))
510
+ return _run_from_row(row) if row else None
511
+
512
+ async def list_for_thread(self, thread_id: str) -> list[RunRecord]:
513
+ if not self.db.is_postgres:
514
+ return sorted(
515
+ (r for r in self._memory.values() if r.thread_id == thread_id),
516
+ key=lambda r: r.created_at,
517
+ )
518
+ rows = await self.db.fetchall(
519
+ f"SELECT * FROM {self.table} WHERE thread_id = %s ORDER BY created_at", (thread_id,)
520
+ )
521
+ return [_run_from_row(r) for r in rows]
522
+
523
+ async def thread_ids_before(
524
+ self, cutoff_iso: str, *, after: str | None = None, limit: int = 500
525
+ ) -> list[str]:
526
+ """Threads that have a run record older than `cutoff_iso`, in id order.
527
+
528
+ One page: ids greater than `after` (pass the last id of the previous
529
+ page to get the next one).
530
+ """
531
+ if not self.db.is_postgres:
532
+ ids = {
533
+ r.thread_id
534
+ for r in self._memory.values()
535
+ if r.created_at < cutoff_iso and (after is None or r.thread_id > after)
536
+ }
537
+ return sorted(ids)[:limit]
538
+ if after is None:
539
+ rows = await self.db.fetchall(
540
+ f"SELECT DISTINCT thread_id FROM {self.table} WHERE created_at < %s "
541
+ "ORDER BY thread_id LIMIT %s",
542
+ (cutoff_iso, limit),
543
+ )
544
+ else:
545
+ rows = await self.db.fetchall(
546
+ f"SELECT DISTINCT thread_id FROM {self.table} WHERE created_at < %s "
547
+ "AND thread_id > %s ORDER BY thread_id LIMIT %s",
548
+ (cutoff_iso, after, limit),
549
+ )
550
+ return [r["thread_id"] for r in rows]
551
+
552
+ async def delete_for_thread(self, thread_id: str) -> None:
553
+ if not self.db.is_postgres:
554
+ for run_id in [k for k, r in self._memory.items() if r.thread_id == thread_id]:
555
+ del self._memory[run_id]
556
+ return
557
+ await self.db.execute(f"DELETE FROM {self.table} WHERE thread_id = %s", (thread_id,))
558
+
559
+
560
+ def _decode(value: Any) -> Any:
561
+ return json.loads(value) if isinstance(value, str) else value
562
+
563
+
564
+ def _run_from_row(row: dict[str, Any]) -> RunRecord:
565
+ created = row.get("created_at")
566
+ return RunRecord(
567
+ run_id=row["run_id"],
568
+ thread_id=row["thread_id"],
569
+ principal_hash=row["principal_hash"],
570
+ model=row.get("model"),
571
+ status=row["status"],
572
+ input_tokens=row.get("input_tokens"),
573
+ output_tokens=row.get("output_tokens"),
574
+ latency_ms=row.get("latency_ms"),
575
+ error_type=row.get("error_type"),
576
+ metadata=_decode(row.get("metadata")),
577
+ payload=_decode(row.get("payload")),
578
+ created_at=created.isoformat() if isinstance(created, datetime) else str(created),
579
+ actor=row.get("actor"),
580
+ )