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,230 @@
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
+ """The dev-only chat page served at `/playground` when `APP_ENV=dev`.
16
+
17
+ Plain HTML and JS, no build step. It talks to `POST /chat` with the bearer key
18
+ typed into the page, through the same policy adapter every client uses, and
19
+ renders the SSE events as they arrive. A run that pauses for the approval of a
20
+ gated API call (`message.end` status `awaiting_approval`, or a 409
21
+ `approval_pending`) shows each approval (the call, the model's stated reason,
22
+ the approvers, the expiry) with Approve and Reject buttons; a decision is
23
+ `POST /threads/{thread_id}/approvals/{approval_id}` with the same key, and the
24
+ resumed run streams into the page. The server decides whether this key may
25
+ decide (403 otherwise). Every value is shown as text, never as HTML.
26
+ """
27
+
28
+ PLAYGROUND_HTML = """<!doctype html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <meta name="viewport" content="width=device-width, initial-scale=1">
33
+ <title>Playground</title>
34
+ <style>
35
+ :root { --bg: #f6f7f9; --fg: #1a1c1f; --muted: #6b7280; --card: #ffffff; --line: #e5e7eb; --accent: #2563eb; --tool: #fff7ed; --err: #fee2e2; --warn: #fef9c3; --ok: #16a34a; --no: #dc2626; }
36
+ @media (prefers-color-scheme: dark) { :root { --bg: #0f1115; --fg: #e5e7eb; --muted: #9ca3af; --card: #181b21; --line: #2a2f3a; --accent: #60a5fa; --tool: #2a2216; --err: #3b1d1d; --warn: #3a3514; --ok: #22c55e; --no: #f87171; } }
37
+ * { box-sizing: border-box; }
38
+ body { margin: 0; font: 15px/1.5 system-ui, -apple-system, Segoe UI, sans-serif; background: var(--bg); color: var(--fg); }
39
+ header { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--card); }
40
+ header h1 { font-size: 16px; margin: 0 auto 0 0; }
41
+ header label { color: var(--muted); font-size: 13px; }
42
+ header input { font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 220px; }
43
+ main { max-width: 860px; margin: 0 auto; padding: 16px; }
44
+ #log { display: flex; flex-direction: column; gap: 10px; min-height: 50vh; }
45
+ .msg { padding: 10px 14px; border-radius: 10px; background: var(--card); border: 1px solid var(--line); white-space: pre-wrap; word-break: break-word; }
46
+ .user { border-color: var(--accent); }
47
+ .tool { background: var(--tool); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
48
+ .error { background: var(--err); }
49
+ .approval { background: var(--warn); white-space: normal; }
50
+ .approval pre { margin: 6px 0; white-space: pre-wrap; word-break: break-word; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
51
+ .approval .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
52
+ .approval input { flex: 1; min-width: 160px; font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); }
53
+ .approval .approve { background: var(--ok); }
54
+ .approval .reject { background: var(--no); }
55
+ .approval button { padding: 6px 14px; }
56
+ .meta { color: var(--muted); font-size: 12px; }
57
+ form { display: flex; gap: 8px; margin-top: 16px; }
58
+ textarea { flex: 1; font: inherit; padding: 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--fg); resize: vertical; min-height: 56px; }
59
+ button { font: inherit; padding: 0 18px; border: 0; border-radius: 8px; background: var(--accent); color: white; cursor: pointer; }
60
+ button[disabled] { opacity: .5; cursor: default; }
61
+ footer { color: var(--muted); font-size: 12px; margin-top: 12px; }
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <header>
66
+ <h1>Playground</h1>
67
+ <label>Bearer key <input id="key" type="password" placeholder="API_KEY" autocomplete="off"></label>
68
+ <label>Thread <input id="thread" placeholder="new thread" readonly></label>
69
+ <button id="reset" type="button">New thread</button>
70
+ </header>
71
+ <main>
72
+ <div id="log"></div>
73
+ <form id="form">
74
+ <textarea id="input" placeholder="Ask something... (Enter to send, Shift+Enter for a new line)" required></textarea>
75
+ <button id="send" type="submit">Send</button>
76
+ </form>
77
+ <footer>Dev only: this page exists because APP_ENV=dev. It calls POST /chat with the key above, exactly as graph-agents-cli run and eval generate do. Actions that need approval show Approve and Reject; the server decides whether this key may decide.</footer>
78
+ </main>
79
+ <script>
80
+ (function () {
81
+ var log = document.getElementById('log');
82
+ var keyInput = document.getElementById('key');
83
+ var threadInput = document.getElementById('thread');
84
+ var input = document.getElementById('input');
85
+ var send = document.getElementById('send');
86
+ var form = document.getElementById('form');
87
+ try { keyInput.value = localStorage.getItem('playground.key') || ''; } catch (e) {}
88
+ keyInput.addEventListener('change', function () { try { localStorage.setItem('playground.key', keyInput.value); } catch (e) {} });
89
+ document.getElementById('reset').addEventListener('click', function () { threadInput.value = ''; log.innerHTML = ''; });
90
+
91
+ function add(cls, text) {
92
+ var div = document.createElement('div');
93
+ div.className = 'msg ' + cls;
94
+ div.textContent = text;
95
+ log.appendChild(div);
96
+ div.scrollIntoView({ block: 'end' });
97
+ return div;
98
+ }
99
+
100
+ function el(tag, text, cls) {
101
+ var node = document.createElement(tag);
102
+ if (text !== undefined && text !== null) node.textContent = text;
103
+ if (cls) node.className = cls;
104
+ return node;
105
+ }
106
+
107
+ function headers() {
108
+ return { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', 'Authorization': 'Bearer ' + keyInput.value };
109
+ }
110
+
111
+ function parseSSE(buffer, onEvent) {
112
+ var parts = buffer.split('\\n\\n');
113
+ var rest = parts.pop();
114
+ parts.forEach(function (block) {
115
+ var event = 'message', data = '';
116
+ block.split('\\n').forEach(function (line) {
117
+ if (line.indexOf('event:') === 0) event = line.slice(6).trim();
118
+ else if (line.indexOf('data:') === 0) data += line.slice(5).trim();
119
+ });
120
+ if (data) { try { onEvent(event, JSON.parse(data)); } catch (e) { onEvent('error', { code: 'parse', message: data }); } }
121
+ });
122
+ return rest;
123
+ }
124
+
125
+ function showApproval(approval) {
126
+ var card = el('div', null, 'msg approval');
127
+ card.appendChild(el('strong', 'Approval needed: ' + approval.method + ' ' + approval.path));
128
+ var facts = [];
129
+ if (approval.operation_id) facts.push('operation ' + approval.operation_id);
130
+ facts.push('API ' + approval.api);
131
+ facts.push('approvers ' + (approval.approvers || []).join(', '));
132
+ facts.push('expires ' + approval.expires_at);
133
+ card.appendChild(el('div', facts.join(' | '), 'meta'));
134
+ if (approval.reason) card.appendChild(el('div', 'Stated reason: ' + approval.reason));
135
+ if (approval.query && Object.keys(approval.query).length) card.appendChild(el('pre', 'query ' + JSON.stringify(approval.query, null, 2)));
136
+ if (approval.body !== undefined && approval.body !== null) card.appendChild(el('pre', 'body ' + JSON.stringify(approval.body, null, 2)));
137
+ var row = el('div', null, 'row');
138
+ var comment = el('input');
139
+ comment.placeholder = 'comment (optional)';
140
+ var approve = el('button', 'Approve', 'approve');
141
+ var reject = el('button', 'Reject', 'reject');
142
+ approve.type = reject.type = 'button';
143
+ row.appendChild(comment); row.appendChild(approve); row.appendChild(reject);
144
+ card.appendChild(row);
145
+ function decide(decision) {
146
+ approve.disabled = reject.disabled = comment.disabled = true;
147
+ var body = { decision: decision };
148
+ if (comment.value.trim()) body.comment = comment.value.trim();
149
+ var url = '/threads/' + encodeURIComponent(approval.thread_id) + '/approvals/' + encodeURIComponent(approval.approval_id);
150
+ // Said once the server has accepted the decision; a refusal (403, 409, 410) shows its error instead.
151
+ var note = el('div', (decision === 'approve' ? 'Approving' : 'Rejecting') + '...', 'meta');
152
+ card.appendChild(note);
153
+ run(url, body).then(function (ok) {
154
+ if (ok) { note.textContent = (decision === 'approve' ? 'Approved' : 'Rejected') + ' by you.'; return; }
155
+ note.remove();
156
+ approve.disabled = reject.disabled = comment.disabled = false;
157
+ });
158
+ }
159
+ approve.addEventListener('click', function () { decide('approve'); });
160
+ reject.addEventListener('click', function () { decide('reject'); });
161
+ log.appendChild(card);
162
+ card.scrollIntoView({ block: 'end' });
163
+ }
164
+
165
+ async function run(url, body) {
166
+ send.disabled = true;
167
+ var reply = add('assistant', '');
168
+ try {
169
+ var res = await fetch(url, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
170
+ if (!res.ok) {
171
+ var text = await res.text();
172
+ var detail = null;
173
+ try { detail = JSON.parse(text); } catch (e) {}
174
+ reply.className = 'msg error';
175
+ reply.textContent = 'HTTP ' + res.status + ': ' + (detail && detail.code ? detail.code + ': ' + detail.detail : text);
176
+ if (detail && detail.code === 'approval_pending') { (detail.approvals || []).forEach(showApproval); }
177
+ return false;
178
+ }
179
+ var reader = res.body.getReader();
180
+ var decoder = new TextDecoder();
181
+ var buffer = '';
182
+ while (true) {
183
+ var chunk = await reader.read();
184
+ if (chunk.done) break;
185
+ buffer += decoder.decode(chunk.value, { stream: true });
186
+ buffer = parseSSE(buffer, function (event, data) {
187
+ if (event === 'message.start') { threadInput.value = data.thread_id; }
188
+ else if (event === 'message.delta') { reply.textContent += data.text; }
189
+ else if (event === 'tool.call') { add('tool', 'tool.call ' + data.name + '(' + JSON.stringify(data.args || {}) + ')'); }
190
+ else if (event === 'tool.result') { add('tool', 'tool.result ' + data.name + (data.is_error ? ' [error]' : '') + ': ' + data.result); }
191
+ else if (event === 'message.end') {
192
+ add('meta', 'run ' + data.run_id + ' | ' + data.status + ' | ' + data.latency_ms + ' ms | tokens in ' + data.usage.input_tokens + ' out ' + data.usage.output_tokens);
193
+ if (data.status === 'awaiting_approval') { (data.approvals || [data.approval]).filter(Boolean).forEach(showApproval); }
194
+ }
195
+ else if (event === 'error') {
196
+ add('error', data.code + ': ' + data.message + (data.detail ? '\\n' + data.detail : ''));
197
+ if (data.code === 'approval_pending') { (data.approvals || []).forEach(showApproval); }
198
+ }
199
+ });
200
+ }
201
+ if (!reply.textContent) reply.remove(); // a run that only paused wrote no text
202
+ return true;
203
+ } catch (e) {
204
+ add('error', String(e));
205
+ return false;
206
+ } finally {
207
+ send.disabled = false;
208
+ input.focus();
209
+ }
210
+ }
211
+
212
+ form.addEventListener('submit', function (ev) {
213
+ ev.preventDefault();
214
+ var text = input.value.trim();
215
+ if (!text) return;
216
+ add('user', text);
217
+ input.value = '';
218
+ var body = { message: text, metadata: { source: 'playground' } };
219
+ if (threadInput.value) body.thread_id = threadInput.value;
220
+ run('/chat', body);
221
+ });
222
+
223
+ input.addEventListener('keydown', function (e) {
224
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); }
225
+ });
226
+ })();
227
+ </script>
228
+ </body>
229
+ </html>
230
+ """
@@ -0,0 +1,459 @@
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
+ """One run per thread: in-process run locks, plus leases in Postgres that every replica honours.
16
+
17
+ A second run on a busy thread raises `ThreadBusy` (HTTP 409
18
+ `{"code": "thread_busy"}`) instead of racing the first one on the same
19
+ checkpoint. In-process the lock is a set of thread ids. With a Postgres
20
+ database (`dsn`) a replica also needs the thread's lease: a row of the locks
21
+ table (`thread_locks`, or `agent_thread_locks` under langgraph-server) naming
22
+ the holder (`owner`, unique per process), a fencing token that changes every
23
+ time the thread changes hands, and an expiry.
24
+
25
+ * The holder renews its leases every `RENEW_EVERY_S` seconds, pushing the
26
+ expiry `LEASE_TTL_S` (30 s) ahead of the database clock. A replica that is
27
+ lost without closing its connections (a node failure, a network partition,
28
+ an OOM kill) stops renewing, and its threads are free again 30 s later.
29
+ * A lease is a row, not a session: a Postgres restart or failover, an admin
30
+ killing the session or a proxy resetting it drops nothing, and another
31
+ replica keeps getting 409 while the run goes on.
32
+ * The holder treats a lease as valid for `LOCAL_VALIDITY_S` after the last
33
+ renewal it sent, well inside the expiry the database enforces. When a lease
34
+ cannot be renewed in that time, or the renewal finds it taken over, the lease
35
+ is lost: `on_lost` callbacks stop the run, and `fence(thread_id)` (called by
36
+ the checkpointer before every write, see `checkpointer.postgres_saver`)
37
+ refuses its writes, so a run that may no longer own its thread stops before
38
+ it writes.
39
+ * A lease whose release fails (the database is down) is released again in
40
+ the background; until then this process may take it back at once, and other
41
+ replicas once it expires.
42
+
43
+ Deleting a thread and the retention purge take the same lease. Every lease
44
+ statement stands alone (no session state), on one connection per process.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import asyncio
50
+ import contextvars
51
+ import logging
52
+ import math
53
+ import os
54
+ import socket
55
+ import uuid
56
+ from collections.abc import Callable
57
+ from typing import Any
58
+
59
+ from {{cookiecutter.agent_directory}}.app_utils.checkpointer import DbHealth, connect
60
+ from {{cookiecutter.agent_directory}}.app_utils.db import LOCKS_TABLE
61
+
62
+ logger = logging.getLogger(__name__)
63
+
64
+ THREAD_BUSY = "thread_busy"
65
+
66
+ # How long a lease lives after its last renewal, by the database clock.
67
+ LEASE_TTL_S = 30.0
68
+ # How often the holder renews its leases.
69
+ RENEW_EVERY_S = 5.0
70
+ # How long after sending its last successful renewal the holder still writes.
71
+ # The margin to LEASE_TTL_S covers a write that waits for a connection and
72
+ # lands late: it still lands before any other replica can take the thread.
73
+ LOCAL_VALIDITY_S = 15.0
74
+ # Retry pace while renewals fail.
75
+ RETRY_EVERY_S = 1.0
76
+ # Bound on one lease statement (connecting included).
77
+ OP_TIMEOUT_S = 5.0
78
+
79
+
80
+ class ThreadBusy(Exception):
81
+ """The thread already has a run in progress (HTTP 409 `thread_busy`)."""
82
+
83
+ def __init__(self, thread_id: str) -> None:
84
+ super().__init__("This thread already has a run in progress.")
85
+ self.thread_id = thread_id
86
+
87
+
88
+ class LeaseLost(Exception):
89
+ """This process may no longer write the thread: its run lease expired or changed hands."""
90
+
91
+ def __init__(self, thread_id: str, reason: str) -> None:
92
+ super().__init__(f"the run lease on the thread was lost: {reason}")
93
+ self.thread_id = thread_id
94
+ self.reason = reason
95
+
96
+
97
+ class ThreadLease:
98
+ """A held run lock on one thread; `release()` is idempotent.
99
+
100
+ `token` is the fencing token of the Postgres lease (None in-process).
101
+ """
102
+
103
+ def __init__(
104
+ self, locks: ThreadLocks, thread_id: str, token: int | None, valid_until: float
105
+ ) -> None:
106
+ self._locks = locks
107
+ self.thread_id = thread_id
108
+ self.token = token
109
+ self.valid_until = valid_until
110
+ self.released = False
111
+ self.lost = False
112
+ self.lost_reason: str | None = None
113
+ self._callbacks: list[Callable[[], Any]] = []
114
+ self._holds: set[asyncio.Future[Any]] = set()
115
+ self._release_deferred = False
116
+
117
+ def hold_until(self, task: asyncio.Future[Any]) -> None:
118
+ """Keep the thread locked until `task` is done, even once `release()` was called.
119
+
120
+ A cancelled run can take a moment to stop (it waits for its in-flight
121
+ writes): the thread stays busy until it has, so no new run overlaps
122
+ it, while `check()` already refuses those writes.
123
+ """
124
+ if task.done():
125
+ return
126
+ self._holds.add(task)
127
+ task.add_done_callback(self._hold_done)
128
+
129
+ def _hold_done(self, task: asyncio.Future[Any]) -> None:
130
+ self._holds.discard(task)
131
+ if self._release_deferred and not self._holds:
132
+ self._release_deferred = False
133
+ self._locks._release_soon(self)
134
+
135
+ def on_lost(self, callback: Callable[[], Any]) -> None:
136
+ """Call `callback` once when the lease is lost (at once when it already is)."""
137
+ if self.lost:
138
+ callback()
139
+ else:
140
+ self._callbacks.append(callback)
141
+
142
+ def check(self) -> None:
143
+ """Raise `LeaseLost` unless this process still owns the thread."""
144
+ if self.released:
145
+ raise LeaseLost(self.thread_id, "the run already released it")
146
+ if not self.lost and asyncio.get_running_loop().time() >= self.valid_until:
147
+ self._lose("it could not be renewed in time")
148
+ if self.lost:
149
+ raise LeaseLost(self.thread_id, self.lost_reason or "lost")
150
+
151
+ def _lose(self, reason: str) -> None:
152
+ if self.lost or self.released:
153
+ return
154
+ self.lost = True
155
+ self.lost_reason = reason
156
+ logger.warning(
157
+ "run lease lost; stopping the thread's run: %s",
158
+ reason,
159
+ extra={"thread_id": self.thread_id},
160
+ )
161
+ callbacks, self._callbacks = self._callbacks, []
162
+ for callback in callbacks:
163
+ try:
164
+ callback()
165
+ except Exception:
166
+ logger.exception("a lease-lost callback failed")
167
+
168
+ async def release(self) -> None:
169
+ if self.released:
170
+ return
171
+ self.released = True
172
+ self._callbacks.clear()
173
+ if self._holds:
174
+ self._release_deferred = True # released when the last hold is done
175
+ return
176
+ await self._locks._release(self)
177
+
178
+
179
+ # Statements; the table name is a constant of `db.py`, never caller input.
180
+ _ACQUIRE = """
181
+ INSERT INTO {t} AS l (thread_id, owner, token, expires_at, acquired_at)
182
+ VALUES (%(thread)s, %(owner)s, nextval('{t}_token_seq'),
183
+ now() + make_interval(secs => %(ttl)s), now())
184
+ ON CONFLICT (thread_id) DO UPDATE
185
+ SET owner = EXCLUDED.owner, token = EXCLUDED.token,
186
+ expires_at = EXCLUDED.expires_at, acquired_at = EXCLUDED.acquired_at
187
+ WHERE l.expires_at < now() OR l.owner = %(owner)s
188
+ RETURNING token
189
+ """
190
+ _RENEW = """
191
+ UPDATE {t} AS l SET expires_at = now() + make_interval(secs => %(ttl)s)
192
+ FROM unnest(%(threads)s::text[], %(tokens)s::bigint[]) AS h(thread_id, token)
193
+ WHERE l.thread_id = h.thread_id AND l.token = h.token AND l.owner = %(owner)s
194
+ RETURNING l.thread_id, l.token
195
+ """
196
+ _RELEASE = (
197
+ "DELETE FROM {t} WHERE thread_id = %(thread)s AND owner = %(owner)s AND token = %(token)s"
198
+ )
199
+ _RELEASE_ANY = "DELETE FROM {t} WHERE thread_id = %(thread)s AND owner = %(owner)s"
200
+ _RELEASE_ALL = "DELETE FROM {t} WHERE owner = %(owner)s"
201
+
202
+
203
+ def _owner_id() -> str:
204
+ return f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:12]}"
205
+
206
+
207
+ class ThreadLocks:
208
+ """In-process run locks, plus Postgres leases when `dsn` is set (see the module doc)."""
209
+
210
+ def __init__(
211
+ self,
212
+ dsn: str | None = None,
213
+ *,
214
+ table: str = LOCKS_TABLE,
215
+ health: DbHealth | None = None,
216
+ ttl_s: float | None = None,
217
+ renew_every_s: float | None = None,
218
+ validity_s: float | None = None,
219
+ ) -> None:
220
+ self.ttl_s = LEASE_TTL_S if ttl_s is None else ttl_s
221
+ self.renew_every_s = RENEW_EVERY_S if renew_every_s is None else renew_every_s
222
+ self.validity_s = LOCAL_VALIDITY_S if validity_s is None else validity_s
223
+ if not 0 < self.validity_s < self.ttl_s:
224
+ raise ValueError("validity_s must be positive and shorter than ttl_s")
225
+ self.dsn = dsn
226
+ self.table = table
227
+ self.owner = _owner_id()
228
+ self._health = health
229
+ self._held: dict[str, ThreadLease] = {}
230
+ self._pending: set[str] = set()
231
+ # Threads whose lease row may still be ours after a failed release:
232
+ # thread id -> token (None: whatever token we hold there).
233
+ self._unreleased: dict[str, int | None] = {}
234
+ self._conn: Any = None
235
+ self._conn_lock = asyncio.Lock()
236
+ self._task: asyncio.Task[None] | None = None
237
+ self._releasing: set[asyncio.Future[None]] = set()
238
+ self._failing = False
239
+ self._closed = False
240
+
241
+ @property
242
+ def held(self) -> frozenset[str]:
243
+ return frozenset(self._held)
244
+
245
+ def lease(self, thread_id: str) -> ThreadLease | None:
246
+ return self._held.get(thread_id)
247
+
248
+ async def acquire(self, thread_id: str) -> ThreadLease:
249
+ """Take the thread's run lock or raise `ThreadBusy`."""
250
+ if self._closed:
251
+ raise RuntimeError("the run locks are closed")
252
+ if thread_id in self._held or thread_id in self._pending:
253
+ raise ThreadBusy(thread_id)
254
+ self._pending.add(thread_id) # no await since the check: atomic in this process
255
+ try:
256
+ token: int | None = None
257
+ valid_until = math.inf
258
+ if self.dsn:
259
+ sent = asyncio.get_running_loop().time()
260
+ try:
261
+ rows = await self._execute(
262
+ _ACQUIRE, {"thread": thread_id, "owner": self.owner, "ttl": self.ttl_s}
263
+ )
264
+ except BaseException:
265
+ # The row may have been written before the failure: clean it up later.
266
+ self._unreleased.setdefault(thread_id, None)
267
+ self._ensure_heartbeat()
268
+ raise
269
+ if not rows:
270
+ raise ThreadBusy(thread_id)
271
+ token = int(rows[0][0])
272
+ valid_until = sent + self.validity_s
273
+ self._unreleased.pop(thread_id, None) # taken over by this lease
274
+ lease = ThreadLease(self, thread_id, token, valid_until)
275
+ self._held[thread_id] = lease
276
+ finally:
277
+ self._pending.discard(thread_id)
278
+ if self.dsn:
279
+ self._ensure_heartbeat()
280
+ return lease
281
+
282
+ def fence(self, thread_id: str) -> None:
283
+ """Raise `LeaseLost` unless this process holds a valid lease on `thread_id`.
284
+
285
+ The checkpointer calls it before every write of the thread's state.
286
+ """
287
+ lease = self._held.get(thread_id)
288
+ if lease is None:
289
+ raise LeaseLost(thread_id, "this process holds no run lease on the thread")
290
+ lease.check()
291
+
292
+ def _release_soon(self, lease: ThreadLease) -> None:
293
+ """Release `lease` in a task of its own (from a callback, which cannot await)."""
294
+ task = asyncio.ensure_future(self._release(lease))
295
+ self._releasing.add(task)
296
+ task.add_done_callback(self._releasing.discard)
297
+
298
+ async def _release(self, lease: ThreadLease) -> None:
299
+ if self._held.get(lease.thread_id) is lease:
300
+ del self._held[lease.thread_id]
301
+ if not self.dsn or lease.token is None or self._closed:
302
+ return
303
+ params = {"thread": lease.thread_id, "owner": self.owner, "token": lease.token}
304
+ try:
305
+ if self._health is not None and not self._health.up:
306
+ raise ConnectionError("the database is known to be down")
307
+ await self._execute(_RELEASE, params, fetch=False, timeout=min(OP_TIMEOUT_S, 2.0))
308
+ except Exception as exc:
309
+ # Released again by the heartbeat; this process can take it back
310
+ # meanwhile, other replicas once it expires.
311
+ logger.warning(
312
+ "could not release a run lease (%s); retrying in the background",
313
+ type(exc).__name__,
314
+ extra={"thread_id": lease.thread_id},
315
+ )
316
+ if lease.thread_id not in self._held:
317
+ self._unreleased[lease.thread_id] = lease.token
318
+ self._ensure_heartbeat()
319
+
320
+ async def close(self) -> None:
321
+ """Stop renewing and give back every lease of this process (best effort)."""
322
+ self._closed = True
323
+ task, self._task = self._task, None
324
+ if task is not None:
325
+ task.cancel()
326
+ await asyncio.gather(task, return_exceptions=True)
327
+ had_leases = bool(self._held or self._unreleased)
328
+ for lease in self._held.values():
329
+ lease.released = True
330
+ self._held.clear()
331
+ self._unreleased.clear()
332
+ if self.dsn and had_leases:
333
+ try:
334
+ await self._execute(_RELEASE_ALL, {"owner": self.owner}, fetch=False, timeout=2.0)
335
+ except Exception:
336
+ logger.debug("could not give back the run leases at shutdown", exc_info=True)
337
+ await self._reset()
338
+
339
+ # -- heartbeat ------------------------------------------------------------
340
+
341
+ def _ensure_heartbeat(self) -> None:
342
+ if self._closed or not self.dsn:
343
+ return
344
+ if self._task is None or self._task.done():
345
+ # A context of its own: not the log context of the request that started it.
346
+ self._task = asyncio.create_task(
347
+ self._heartbeat(), name="run-lease-heartbeat", context=contextvars.Context()
348
+ )
349
+
350
+ async def _heartbeat(self) -> None:
351
+ loop = asyncio.get_running_loop()
352
+ delay = RETRY_EVERY_S if self._unreleased else self.renew_every_s
353
+ while not self._closed:
354
+ await asyncio.sleep(delay)
355
+ delay = self.renew_every_s
356
+ try:
357
+ flushed = await self._flush_releases()
358
+ renewed = await self._renew()
359
+ if self._failing and (flushed or renewed):
360
+ self._failing = False
361
+ logger.info("run leases reachable again")
362
+ except asyncio.CancelledError:
363
+ raise
364
+ except Exception as exc:
365
+ delay = RETRY_EVERY_S
366
+ if not self._failing:
367
+ self._failing = True
368
+ logger.warning("could not renew the run leases: %s", type(exc).__name__)
369
+ now = loop.time()
370
+ for lease in list(self._held.values()):
371
+ if lease.token is not None and not lease.lost and now >= lease.valid_until:
372
+ lease._lose("it could not be renewed in time")
373
+ if not self._held and not self._unreleased:
374
+ self._task = None
375
+ return # started again by the next acquire
376
+
377
+ async def _renew(self) -> bool:
378
+ """Renew every live lease in one statement; False when there was none to renew."""
379
+ leases = [
380
+ lease
381
+ for lease in self._held.values()
382
+ if lease.token is not None and not lease.lost and not lease.released
383
+ ]
384
+ if not leases:
385
+ return False
386
+ sent = asyncio.get_running_loop().time()
387
+ rows = await self._execute(
388
+ _RENEW,
389
+ {
390
+ "ttl": self.ttl_s,
391
+ "owner": self.owner,
392
+ "threads": [lease.thread_id for lease in leases],
393
+ "tokens": [lease.token for lease in leases],
394
+ },
395
+ )
396
+ renewed = {(str(r[0]), int(r[1])) for r in rows}
397
+ for lease in leases:
398
+ if (lease.thread_id, lease.token) in renewed:
399
+ lease.valid_until = max(lease.valid_until, sent + self.validity_s)
400
+ elif not lease.released:
401
+ lease._lose("another replica took the thread over, or its lease row is gone")
402
+ return True
403
+
404
+ async def _flush_releases(self) -> bool:
405
+ """Retry the releases that failed; False when there was none."""
406
+ flushed = False
407
+ for thread_id, token in list(self._unreleased.items()):
408
+ if thread_id in self._held or thread_id in self._pending:
409
+ self._unreleased.pop(thread_id, None) # ours again: nothing to give back
410
+ continue
411
+ params: dict[str, Any] = {"thread": thread_id, "owner": self.owner}
412
+ if token is None:
413
+ await self._execute(_RELEASE_ANY, params, fetch=False)
414
+ else:
415
+ await self._execute(_RELEASE, {**params, "token": token}, fetch=False)
416
+ self._unreleased.pop(thread_id, None)
417
+ flushed = True
418
+ return flushed
419
+
420
+ # -- Postgres -----------------------------------------------------------------
421
+
422
+ async def _execute(
423
+ self, sql: str, params: dict[str, Any], *, fetch: bool = True, timeout: float = OP_TIMEOUT_S
424
+ ) -> list[Any]:
425
+ """Run one lease statement on the lease connection (reconnecting once), bounded."""
426
+ statement = sql.format(t=self.table)
427
+ async with asyncio.timeout(timeout), self._conn_lock:
428
+ for attempt in (1, 2):
429
+ conn = await self._connection()
430
+ try:
431
+ cur = await conn.execute(statement, params)
432
+ return list(await cur.fetchall()) if fetch else []
433
+ except Exception:
434
+ await self._reset_locked()
435
+ if attempt == 2:
436
+ raise
437
+ except BaseException:
438
+ # Cancelled mid-statement: the connection's state is unknown.
439
+ await self._reset_locked()
440
+ raise
441
+ return []
442
+
443
+ async def _connection(self) -> Any:
444
+ if self._conn is not None and not self._conn.closed:
445
+ return self._conn
446
+ self._conn = await connect(self.dsn or "", health=self._health)
447
+ return self._conn
448
+
449
+ async def _reset(self) -> None:
450
+ async with self._conn_lock:
451
+ await self._reset_locked()
452
+
453
+ async def _reset_locked(self) -> None:
454
+ conn, self._conn = self._conn, None
455
+ if conn is not None:
456
+ try:
457
+ await conn.close()
458
+ except Exception:
459
+ logger.debug("closing the lease connection failed", exc_info=True)