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,1157 @@
1
+ # Copyright 2026 Google LLC
2
+ # Modifications Copyright 2026 graph-agents-cli contributors
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # https://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """Background local server management for ``run`` and ``eval generate``.
17
+
18
+ The pid file is ``.graph-agents-cli/run_server.json``
19
+ with keys ``{pid, port, started_at, last_activity, runtime, checkpointer, state,
20
+ create_time}``.
21
+ The command depends on the manifest ``runtime``:
22
+
23
+ * ``fastapi`` -> ``uv run uvicorn <agent_dir>.fast_api_app:app --host 127.0.0.1 --port N``
24
+ * ``langgraph-server`` -> ``uv run langgraph dev --no-browser --port N``
25
+
26
+ The port is ``--port`` (``run``) or ``GRAPH_AGENTS_CLI_RUN_PORT`` when given
27
+ (used exactly, refused when busy), else the first free one of 18080-18089. A
28
+ port counts as busy when anything answers on 127.0.0.1 or it cannot be bound on
29
+ 127.0.0.1 and on all interfaces: on macOS a loopback bind succeeds next to a
30
+ wildcard listener and would silently shadow it. A server idle for 30 minutes
31
+ is replaced; one the operating system will not let this command stop (a
32
+ sandbox that lets a command signal only its own processes) is reused with a
33
+ warning instead, so it never blocks later runs. A live server started for a
34
+ different runtime is a hard error (never terminated silently). Readiness is
35
+ ``GET /health`` answering 200.
36
+
37
+ The pid file is written as soon as the process is started (``"state":
38
+ "starting"``) and completed once it is ready, so a CLI killed during startup
39
+ still leaves a record that ``run --stop-server`` and the next invocation find.
40
+
41
+ Two invocations may race for the same project (``eval generate`` beside a
42
+ ``run``, two CI steps): the check-start-write sequence runs under a lock file
43
+ (``.graph-agents-cli/run_server.lock``), the pid file is written atomically,
44
+ and ``stop_server(pid=...)`` always stops the process this invocation started
45
+ even when the pid file has since been replaced by another invocation.
46
+
47
+ A record can outlive its server (a CLI killed at the wrong moment, a reboot)
48
+ and PIDs are reused, so a recorded PID is never trusted alone: ``create_time``
49
+ (the process creation time, from psutil) must still match before the process
50
+ is reused or signalled. A record without it (written by an older CLI) must at
51
+ least name a uvicorn or ``langgraph`` process serving the recorded port.
52
+ Stopping a server and removing its record runs with signals held
53
+ (``_signals.shielded``), so a SIGTERM during the teardown cannot leave a
54
+ record behind.
55
+
56
+ A server that cannot be started (it exits during startup, never gets
57
+ healthy, or a server for another runtime holds the project) is a tool failure:
58
+ :class:`ServerStartError`, exit 2.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import json
64
+ import logging
65
+ import os
66
+ import socket
67
+ import subprocess
68
+ import sys
69
+ import tempfile
70
+ import threading
71
+ import time
72
+ from collections.abc import Callable
73
+ from datetime import UTC, datetime
74
+ from pathlib import Path
75
+ from typing import Any, NamedTuple
76
+
77
+ import click
78
+ import httpx
79
+ import psutil
80
+ from filelock import FileLock
81
+ from filelock import Timeout as LockTimeout
82
+
83
+ from graph_agents_cli import _http
84
+ from graph_agents_cli._runner import popen_resolved_detached, redact_cmd
85
+ from graph_agents_cli.run._signals import shielded
86
+
87
+ PID_DIR = ".graph-agents-cli"
88
+ PID_FILENAME = "run_server.json"
89
+ LOCK_FILENAME = "run_server.lock"
90
+ LOG_FILENAME = "run_server.log"
91
+ STDERR_LOG_FILENAME = "run_server.stderr.log"
92
+ BASE_PORT = 18080
93
+ _MAX_PORT_ATTEMPTS = 10
94
+ RUN_PORT_ENV = "GRAPH_AGENTS_CLI_RUN_PORT"
95
+ # Exit code for a port that cannot be used: the fix is configuration (--port).
96
+ EXIT_PORT_UNAVAILABLE = 3
97
+ # Exit code for a server that could not be started: a tool failure, never the
98
+ # 1 that `eval run` reserves for a failed gate.
99
+ EXIT_SERVER_START_FAILED = 2
100
+ # Seconds two readings of one process's creation time may differ by.
101
+ _CREATE_TIME_TOLERANCE = 1.0
102
+ # Servers this process started: pid -> creation time, to recognise them when the
103
+ # pid file no longer names them.
104
+ _STARTED: dict[int, float | None] = {}
105
+ # Their Popen handles: a way to stop them that needs no process listing.
106
+ _HANDLES: dict[int, subprocess.Popen] = {}
107
+ STATE_STARTING = "starting"
108
+ STATE_READY = "ready"
109
+ DEFAULT_IDLE_TIMEOUT = 1800 # 30 minutes
110
+ _STARTUP_TIMEOUT_POSIX = 60
111
+ _STARTUP_TIMEOUT_WINDOWS = 120
112
+ DEFAULT_STARTUP_TIMEOUT = _STARTUP_TIMEOUT_WINDOWS if os.name == "nt" else _STARTUP_TIMEOUT_POSIX
113
+ # Seconds a stopped server gets to exit after SIGTERM, then after SIGKILL.
114
+ _TERM_WAIT = 3.0
115
+ _KILL_WAIT = 2.0
116
+ # Extra seconds a second invocation waits for the first one to finish starting.
117
+ _LOCK_GRACE = 30
118
+ DEFAULT_HEARTBEAT_INTERVAL = 60.0
119
+
120
+ RUNTIME_FASTAPI = "fastapi"
121
+ RUNTIME_LANGGRAPH_SERVER = "langgraph-server"
122
+ SUPPORTED_RUNTIMES = (RUNTIME_FASTAPI, RUNTIME_LANGGRAPH_SERVER)
123
+
124
+
125
+ class ServerInfo(NamedTuple):
126
+ """A running local server's port, PID, and whether *this* call started it.
127
+
128
+ ``started`` is ``True`` only when ``ensure_server`` launched a new
129
+ process; it is ``False`` when an already-running server was reused.
130
+ Callers use it to avoid tearing down a server someone else is keeping
131
+ alive (e.g. one started with ``--start-server``).
132
+ """
133
+
134
+ port: int
135
+ started: bool
136
+ pid: int
137
+ runtime: str = RUNTIME_FASTAPI
138
+ checkpointer: str = "memory"
139
+
140
+ @property
141
+ def base_url(self) -> str:
142
+ return f"http://127.0.0.1:{self.port}"
143
+
144
+
145
+ def dotenv_settings(path: Path) -> dict[str, str]:
146
+ """The ``KEY=value`` settings of a ``.env`` file (empty when absent or unreadable).
147
+
148
+ A key without a value (``KEY`` alone) is left out, as ``load_dotenv`` leaves it.
149
+ """
150
+ if not path.is_file():
151
+ return {}
152
+ from dotenv import dotenv_values
153
+
154
+ try:
155
+ values = dotenv_values(path)
156
+ except Exception: # an unreadable file: the app's own load_dotenv reports it
157
+ return {}
158
+ return {key: value for key, value in values.items() if value is not None}
159
+
160
+
161
+ def build_serve_command(*, agent_dir: str, port: int, runtime: str) -> list[str]:
162
+ """Return the command that serves the application locally for ``runtime``."""
163
+ if runtime == RUNTIME_FASTAPI:
164
+ return [
165
+ "uv",
166
+ "run",
167
+ "uvicorn",
168
+ f"{agent_dir}.fast_api_app:app",
169
+ "--host",
170
+ "127.0.0.1",
171
+ "--port",
172
+ str(port),
173
+ ]
174
+ if runtime == RUNTIME_LANGGRAPH_SERVER:
175
+ return ["uv", "run", "langgraph", "dev", "--no-browser", "--port", str(port)]
176
+ raise UnsupportedRuntimeError(
177
+ f"Unsupported runtime {runtime!r} in graph-agents-cli-manifest.yaml.\n"
178
+ f" Expected one of: {', '.join(SUPPORTED_RUNTIMES)}"
179
+ )
180
+
181
+
182
+ class PortUnavailableError(click.ClickException):
183
+ """The requested local port is invalid or taken, or every candidate is (exit 3)."""
184
+
185
+ exit_code = EXIT_PORT_UNAVAILABLE
186
+
187
+
188
+ class ServerStartError(click.ClickException):
189
+ """The local server could not be started or reused (exit 2, a tool failure)."""
190
+
191
+ exit_code = EXIT_SERVER_START_FAILED
192
+
193
+
194
+ class ServerStopError(click.ClickException):
195
+ """A running local server could not be stopped (exit 2, a tool failure).
196
+
197
+ Its record is kept, so ``run --stop-server`` can try again and later runs
198
+ reuse the server while it still answers, instead of colliding with its port.
199
+ ``reason`` says what is still running and why; ``kill_command`` stops it
200
+ from a shell that may signal it.
201
+ """
202
+
203
+ exit_code = EXIT_SERVER_START_FAILED
204
+
205
+ def __init__(
206
+ self,
207
+ message: str,
208
+ *,
209
+ pid: int,
210
+ port: int | None,
211
+ left: list[int],
212
+ reason: str = "",
213
+ kill_command: str = "",
214
+ ) -> None:
215
+ super().__init__(message)
216
+ self.pid = pid
217
+ self.port = port
218
+ self.left = left
219
+ self.reason = reason or message
220
+ self.kill_command = kill_command
221
+
222
+
223
+ class UnsupportedRuntimeError(click.ClickException):
224
+ """The manifest names a runtime this CLI cannot serve (exit 3, a configuration error)."""
225
+
226
+ exit_code = 3
227
+
228
+
229
+ def port_problem(port: int, host: str = "127.0.0.1") -> str | None:
230
+ """Why ``port`` cannot be used for a local server on ``host``, or None when it is free.
231
+
232
+ Three probes, because each misses a case: something already answering on
233
+ 127.0.0.1 (any listener that would receive our traffic), a bind on ``host``
234
+ (the address the server will use), and a bind on all interfaces, which
235
+ fails next to a wildcard listener even where the loopback bind would
236
+ succeed (macOS), so the new server would shadow the other one on loopback.
237
+
238
+ The binds use the socket options the server's own bind uses: uvicorn (also
239
+ under ``langgraph dev``) sets ``SO_REUSEADDR`` on POSIX, so the connections
240
+ a stopped server leaves in TIME_WAIT or FIN_WAIT_2 for a minute do not make
241
+ its port "in use"; only a listening socket does. Windows is left without
242
+ it: there the option would let the bind take over a live listener.
243
+ """
244
+ try:
245
+ with socket.create_connection(("127.0.0.1", port), timeout=0.25):
246
+ return f"something is already listening on 127.0.0.1:{port}"
247
+ except OSError:
248
+ pass
249
+ for address in dict.fromkeys((host, "0.0.0.0")):
250
+ try:
251
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
252
+ if os.name != "nt":
253
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
254
+ probe.bind((address, port))
255
+ except OSError as exc:
256
+ where = "all interfaces" if address == "0.0.0.0" else address
257
+ return f"port {port} cannot be bound on {where} ({exc.strerror or exc})"
258
+ return None
259
+
260
+
261
+ def requested_port(explicit: int | None = None) -> int | None:
262
+ """``explicit`` (``--port``), else ``GRAPH_AGENTS_CLI_RUN_PORT``, else None (search)."""
263
+ if explicit is not None:
264
+ return _valid_port(explicit, "--port")
265
+ raw = os.environ.get(RUN_PORT_ENV, "").strip()
266
+ if not raw:
267
+ return None
268
+ try:
269
+ value = int(raw)
270
+ except ValueError:
271
+ raise PortUnavailableError(f"{RUN_PORT_ENV}={raw!r} is not a port number.") from None
272
+ return _valid_port(value, RUN_PORT_ENV)
273
+
274
+
275
+ def _valid_port(port: int, source: str) -> int:
276
+ if not 1 <= port <= 65535:
277
+ raise PortUnavailableError(f"{source} must be between 1 and 65535 (got {port}).")
278
+ return port
279
+
280
+
281
+ def ensure_server(
282
+ project_root: Path,
283
+ agent_dir: str,
284
+ *,
285
+ runtime: str,
286
+ checkpointer: str = "memory",
287
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
288
+ keep_running: bool = False,
289
+ startup_timeout: int = DEFAULT_STARTUP_TIMEOUT,
290
+ lock_timeout: float | None = None,
291
+ port: int | None = None,
292
+ ) -> ServerInfo:
293
+ """Return a running local server's port, starting one if needed.
294
+
295
+ A recorded server is reused when its process is alive, its port answers,
296
+ it was started for the same ``runtime``, and it has been active within
297
+ ``idle_timeout`` seconds. A stale pid file is cleaned up; an idle server is
298
+ stopped and replaced. When the operating system refuses to stop it, a
299
+ warning names what is still running and the server is reused while it
300
+ still answers (else a fresh one starts beside it): see
301
+ :func:`_clear_for_restart`. A live server for a different runtime is a hard
302
+ error and is left running so the user can decide.
303
+
304
+ ``checkpointer`` is the manifest value; the value recorded in the pid file
305
+ is what ``GET /health`` reports once the server is up (the local ``.env``
306
+ usually selects ``memory``), falling back to the argument.
307
+
308
+ ``port`` (or ``GRAPH_AGENTS_CLI_RUN_PORT``) pins the port of a server this
309
+ call starts; it must be free (:class:`PortUnavailableError` otherwise), and
310
+ a running server on another port is not reused silently.
311
+
312
+ The whole read -> check -> start -> wait -> write sequence holds the
313
+ project's lock file, so a concurrent invocation waits (up to
314
+ ``lock_timeout``, default the startup timeout plus a grace period) and
315
+ then reuses the server instead of starting a second one.
316
+ """
317
+ if runtime not in SUPPORTED_RUNTIMES:
318
+ raise UnsupportedRuntimeError(
319
+ f"Unsupported runtime {runtime!r} in graph-agents-cli-manifest.yaml.\n"
320
+ f" Expected one of: {', '.join(SUPPORTED_RUNTIMES)}"
321
+ )
322
+
323
+ pinned_port = requested_port(port)
324
+ state_dir = project_root / PID_DIR
325
+ state_dir.mkdir(exist_ok=True)
326
+ wait = float(startup_timeout + _LOCK_GRACE) if lock_timeout is None else lock_timeout
327
+ lock = FileLock(str(state_dir / LOCK_FILENAME))
328
+ try:
329
+ with lock.acquire(timeout=wait):
330
+ return _ensure_server_locked(
331
+ project_root,
332
+ agent_dir,
333
+ runtime=runtime,
334
+ checkpointer=checkpointer,
335
+ idle_timeout=idle_timeout,
336
+ keep_running=keep_running,
337
+ startup_timeout=startup_timeout,
338
+ pinned_port=pinned_port,
339
+ )
340
+ except LockTimeout as exc:
341
+ raise ServerStartError(
342
+ f"Another graph-agents-cli invocation has held the local server lock for more than "
343
+ f"{wait:.0f}s ({PID_DIR}/{LOCK_FILENAME}).\n"
344
+ " Wait for it to finish, or remove the lock file if that process is gone."
345
+ ) from exc
346
+
347
+
348
+ def _ensure_server_locked(
349
+ project_root: Path,
350
+ agent_dir: str,
351
+ *,
352
+ runtime: str,
353
+ checkpointer: str,
354
+ idle_timeout: int,
355
+ keep_running: bool,
356
+ startup_timeout: int,
357
+ pinned_port: int | None = None,
358
+ ) -> ServerInfo:
359
+ info = read_pid_file(project_root)
360
+ if info:
361
+ if _is_server_alive(info.get("pid", 0), info.get("port", 0), info.get("create_time")):
362
+ if pinned_port is not None and info.get("port") != pinned_port:
363
+ raise PortUnavailableError(
364
+ f"This project's local server is already running on port "
365
+ f"{info.get('port')}, not {pinned_port}.\n"
366
+ " Drop --port / GRAPH_AGENTS_CLI_RUN_PORT to reuse it, or stop it first: "
367
+ "graph-agents-cli run --stop-server"
368
+ )
369
+ existing_runtime = info.get("runtime")
370
+ if existing_runtime and existing_runtime != runtime:
371
+ raise ServerStartError(
372
+ f"Cannot reuse the running local server: it was started for the "
373
+ f"{existing_runtime!r} runtime, but the project now uses {runtime!r}.\n"
374
+ " Run 'graph-agents-cli run --stop-server' first, then retry."
375
+ )
376
+ if not _is_idle(info, idle_timeout) or not _clear_for_restart(
377
+ project_root, info, idle_timeout=idle_timeout
378
+ ):
379
+ _update_activity(project_root)
380
+ return ServerInfo(
381
+ port=info["port"],
382
+ started=False,
383
+ pid=info["pid"],
384
+ runtime=existing_runtime or runtime,
385
+ checkpointer=info.get("checkpointer") or checkpointer,
386
+ )
387
+ else:
388
+ # Stale pid file: clean up before starting fresh.
389
+ _clear_for_restart(project_root, info)
390
+
391
+ if pinned_port is not None:
392
+ problem = port_problem(pinned_port)
393
+ if problem:
394
+ raise PortUnavailableError(
395
+ f"Cannot start the local server on port {pinned_port}: {problem}.\n"
396
+ f" Pick another one with --port or {RUN_PORT_ENV}."
397
+ )
398
+ port = pinned_port
399
+ else:
400
+ port = _find_free_port()
401
+ proc = _start_server(project_root=project_root, agent_dir=agent_dir, port=port, runtime=runtime)
402
+ pid = proc.pid
403
+ created = _create_time(pid)
404
+ _STARTED[pid] = created
405
+ _HANDLES[pid] = proc
406
+ try:
407
+ # Recorded before the (long) readiness wait: if this CLI is killed now,
408
+ # `run --stop-server` and the next invocation still find the process.
409
+ write_pid_file(
410
+ project_root,
411
+ pid=pid,
412
+ port=port,
413
+ runtime=runtime,
414
+ checkpointer=checkpointer,
415
+ state=STATE_STARTING,
416
+ create_time=created,
417
+ )
418
+ health = _wait_for_ready(project_root, port, proc=proc, timeout=startup_timeout)
419
+ except BaseException:
420
+ # A server that failed to come up (or a Ctrl-C/SIGTERM while waiting)
421
+ # must not keep running in the background on the chosen port. A child
422
+ # that already exited was reaped by poll(); terminating it again would
423
+ # only log a spurious "not found" warning.
424
+ with shielded():
425
+ try:
426
+ if proc.poll() is None:
427
+ _terminate_process(
428
+ pid, create_time=created, port=port, own_child=True, proc=proc
429
+ )
430
+ try:
431
+ proc.wait(timeout=5)
432
+ except (subprocess.TimeoutExpired, OSError):
433
+ pass
434
+ _remove_pid_file_if(project_root, pid)
435
+ except Exception as cleanup_error: # never replace the error being raised
436
+ logging.warning(
437
+ "Could not clean up the local server (PID %d) after a failed start: %s",
438
+ pid,
439
+ cleanup_error,
440
+ )
441
+ finally:
442
+ _STARTED.pop(pid, None)
443
+ _HANDLES.pop(pid, None)
444
+ raise
445
+ live_checkpointer = str(health.get("checkpointer") or checkpointer)
446
+ write_pid_file(
447
+ project_root,
448
+ pid=pid,
449
+ port=port,
450
+ runtime=runtime,
451
+ checkpointer=live_checkpointer,
452
+ state=STATE_READY,
453
+ create_time=created,
454
+ )
455
+ if keep_running:
456
+ click.secho(f"Local server started on port {port} (PID {pid}, {runtime}).", dim=True)
457
+ click.secho(" Stop with: graph-agents-cli run --stop-server", dim=True)
458
+ else:
459
+ click.secho(
460
+ f"Starting a temporary local server on port {port} ({runtime}; "
461
+ "stops automatically when done).",
462
+ dim=True,
463
+ )
464
+ return ServerInfo(
465
+ port=port, started=True, pid=pid, runtime=runtime, checkpointer=live_checkpointer
466
+ )
467
+
468
+
469
+ def _clear_for_restart(
470
+ project_root: Path, info: dict[str, Any], *, idle_timeout: int | None = None
471
+ ) -> bool:
472
+ """Stop the recorded server, idle or no longer answering, so a fresh one can start.
473
+
474
+ Returns False when it could not be stopped and still serves: the caller
475
+ reuses it. A sandbox may let a command signal only the processes it started
476
+ itself, so a server an earlier command started can survive every later
477
+ command's attempt; failing each of them would block all runs until someone
478
+ stops the server by hand. So the survivor is reported as a warning (what is
479
+ still running, why, and the ``kill`` command) and then reused while it still
480
+ serves, or left to its own devices when it no longer does: its port is closed
481
+ then, so the fresh server cannot collide with it.
482
+
483
+ ``idle_timeout``: the record is replaced for being idle that long (seconds);
484
+ None when its server no longer answers.
485
+ """
486
+ try:
487
+ _cleanup(project_root, info)
488
+ return True
489
+ except ServerStopError as exc:
490
+ pid, port = info.get("pid", 0), info.get("port", 0)
491
+ serving = _is_server_alive(pid, port, info.get("create_time"))
492
+ where = f"PID {pid}, port {port}"
493
+ if idle_timeout is not None:
494
+ why = f"The local server ({where}) has been idle for more than {_span(idle_timeout)}"
495
+ else:
496
+ why = f"The recorded local server ({where}) no longer answers on its port"
497
+ if serving:
498
+ then = (
499
+ "Reusing it; to have the next run start a fresh one, stop it from a shell "
500
+ f"that may signal it (`{exc.kill_command}`)."
501
+ )
502
+ else:
503
+ then = (
504
+ "Starting a fresh local server; stop the old one from a shell that may "
505
+ f"signal it (`{exc.kill_command}`), such as the one that started it."
506
+ )
507
+ click.secho(
508
+ f"Warning: {why}, but it could not be stopped: {exc.reason}. {then}",
509
+ fg="yellow",
510
+ err=True,
511
+ )
512
+ return not serving
513
+
514
+
515
+ def _span(seconds: int) -> str:
516
+ """``seconds`` in words: "30 minutes", "1 minute", "45 seconds"."""
517
+ if seconds >= 60 and seconds % 60 == 0:
518
+ minutes = seconds // 60
519
+ return f"{minutes} minute{'s' if minutes != 1 else ''}"
520
+ return f"{seconds} second{'s' if seconds != 1 else ''}"
521
+
522
+
523
+ def stop_server(project_root: Path, pid: int | None = None) -> bool:
524
+ """Stop the background server.
525
+
526
+ Without ``pid`` the recorded server is stopped and the pid file removed.
527
+ With ``pid`` (a server this invocation started): when the pid file still
528
+ names it, same thing; when the file is missing or names another process
529
+ (a concurrent invocation replaced it), only ``pid`` is terminated and the
530
+ file is left alone, so a server started by this invocation is never
531
+ orphaned and another invocation's record is never deleted.
532
+
533
+ Idempotent, and it runs to the end even when SIGTERM or Ctrl-C arrives
534
+ meanwhile (the signal is handled once the record is gone). A recorded
535
+ process that is no longer the server (its PID was reused) is never
536
+ signalled; its record is just removed.
537
+
538
+ Returns ``True`` if a running server was stopped (``False`` for none, or
539
+ only a stale record, which is removed). Raises :class:`ServerStopError`
540
+ when the server is still running afterwards (the operating system refused
541
+ the signal, as a sandbox does for a process an earlier command started);
542
+ its record is kept then. A teardown after a command catches it and warns,
543
+ so it never replaces that command's own result or error.
544
+ """
545
+ with shielded():
546
+ info = read_pid_file(project_root)
547
+ if pid is not None and (not info or info.get("pid") != pid):
548
+ stopped = _terminate_process(pid, create_time=_STARTED.get(pid))
549
+ _STARTED.pop(pid, None)
550
+ _HANDLES.pop(pid, None)
551
+ elif not info:
552
+ return False
553
+ else:
554
+ stopped = _cleanup(project_root, info)
555
+ if stopped:
556
+ click.secho("Local server stopped.", dim=True)
557
+ elif info and (pid is None or info.get("pid") == pid):
558
+ click.secho(
559
+ "Removed the record of a local server that was no longer running.", dim=True
560
+ )
561
+ return stopped
562
+
563
+
564
+ def warn_not_stopped(exc: ServerStopError) -> None:
565
+ """Report a server a teardown could not stop, without replacing the command's own result."""
566
+ click.secho(f"Warning: {exc.format_message()}", fg="yellow", err=True)
567
+
568
+
569
+ def get_server_port(project_root: Path) -> int | None:
570
+ """Return the port of the running local server, or ``None`` if absent."""
571
+ info = read_pid_file(project_root)
572
+ if not info:
573
+ return None
574
+ if not _is_server_alive(info.get("pid", 0), info.get("port", 0), info.get("create_time")):
575
+ return None
576
+ return info["port"]
577
+
578
+
579
+ def touch_activity(project_root: Path) -> None:
580
+ """Stamp ``last_activity`` on the pid file (a request was just served)."""
581
+ _update_activity(project_root)
582
+
583
+
584
+ def start_activity_heartbeat(
585
+ project_root: Path, interval: float = DEFAULT_HEARTBEAT_INTERVAL
586
+ ) -> Callable[[], None]:
587
+ """Stamp ``last_activity`` every ``interval`` seconds until the returned stop() is called.
588
+
589
+ A long ``eval generate`` session otherwise looks idle to a concurrent
590
+ ``run`` after 30 minutes, which would replace the server under it.
591
+ """
592
+ stop = threading.Event()
593
+
594
+ def _beat() -> None:
595
+ while not stop.wait(interval):
596
+ _update_activity(project_root)
597
+
598
+ thread = threading.Thread(target=_beat, name="graph-agents-cli-heartbeat", daemon=True)
599
+ thread.start()
600
+
601
+ def _stop() -> None:
602
+ stop.set()
603
+ thread.join(timeout=1)
604
+
605
+ return _stop
606
+
607
+
608
+ # ---------------------------------------------------------------------------
609
+ # Internal helpers
610
+ # ---------------------------------------------------------------------------
611
+
612
+
613
+ def _find_free_port(base: int | None = None, max_attempts: int = _MAX_PORT_ATTEMPTS) -> int:
614
+ """The first free local port (see :func:`port_problem`) from *base* (``BASE_PORT``)."""
615
+ base = BASE_PORT if base is None else base
616
+ for offset in range(max_attempts):
617
+ port = base + offset
618
+ if port_problem(port) is None:
619
+ return port
620
+ raise PortUnavailableError(
621
+ f"No free port found in range {base}-{base + max_attempts - 1}.\n"
622
+ f" Pick one with --port or {RUN_PORT_ENV}, stop other servers "
623
+ "(graph-agents-cli run --stop-server), or use --url to query a remote agent."
624
+ )
625
+
626
+
627
+ def _start_server(
628
+ *, project_root: Path, agent_dir: str, port: int, runtime: str
629
+ ) -> subprocess.Popen:
630
+ """Start the local server as a detached background process and return its handle.
631
+
632
+ The ``Popen`` is what ``_wait_for_ready`` polls: ``psutil.pid_exists`` is
633
+ true for a zombie, so a child that crashed at import time would otherwise
634
+ only be noticed when the readiness timeout expires.
635
+ """
636
+ state_dir = project_root / PID_DIR
637
+ state_dir.mkdir(exist_ok=True)
638
+ log_path = state_dir / LOG_FILENAME
639
+
640
+ cmd = build_serve_command(agent_dir=agent_dir, port=port, runtime=runtime)
641
+
642
+ # The app loads .env only when its graph module is imported, which the
643
+ # fastapi server does at startup, after the app is assembled: settings read
644
+ # while assembling it (the auth policy's startup check, the A2A card's
645
+ # security scheme, /docs) would miss .env. Hand .env to the child up front,
646
+ # as load_dotenv does (the environment wins).
647
+ env = {**dotenv_settings(project_root / ".env"), **os.environ}
648
+ env.setdefault("PYTHONUNBUFFERED", "1")
649
+ # The app reads PORT for its own logging; keep it consistent with --port.
650
+ env["PORT"] = str(port)
651
+ # The A2A agent card advertises APP_URL, else http://HOST:PORT, and A2A
652
+ # clients dial what it advertises. `langgraph dev` loads .env over this
653
+ # environment (the template's .env sets PORT=8000), so name the address
654
+ # this server listens on unless .env or the environment sets APP_URL.
655
+ env.setdefault("APP_URL", f"http://127.0.0.1:{port}")
656
+
657
+ log_file = open(log_path, "a", encoding="utf-8")
658
+ stderr_file = None
659
+ try:
660
+ log_file.write(f"=== Starting server at {datetime.now(UTC).isoformat()} ===\n")
661
+ log_file.write(f"Command: {redact_cmd(cmd)}\n")
662
+ log_file.write(f"CWD: {project_root}\n")
663
+ log_file.flush()
664
+
665
+ if sys.platform == "win32":
666
+ stderr_path = state_dir / STDERR_LOG_FILENAME
667
+ stderr_file = open(stderr_path, "a", encoding="utf-8")
668
+ stderr_file.write(
669
+ f"=== Starting server stderr at {datetime.now(UTC).isoformat()} ===\n"
670
+ )
671
+ stderr_file.flush()
672
+ child_stdout, child_stderr = log_file, stderr_file
673
+ else:
674
+ child_stdout = child_stderr = log_file
675
+
676
+ proc = popen_resolved_detached(
677
+ cmd,
678
+ cwd=str(project_root),
679
+ stdout=child_stdout,
680
+ stderr=child_stderr,
681
+ env=env,
682
+ )
683
+ log_file.write(f"=== Started server process {proc.pid} ===\n")
684
+ log_file.flush()
685
+ finally:
686
+ # Close the parent's copy of the fd; the child inherits its own.
687
+ log_file.close()
688
+ if stderr_file:
689
+ stderr_file.close()
690
+ return proc
691
+
692
+
693
+ def _fetch_health(port: int, timeout: float = 1.0) -> dict[str, Any] | None:
694
+ """GET ``/health`` on the local port; ``None`` until it answers 200.
695
+
696
+ Never through a proxy (``_http``): a proxy in the environment (an agent
697
+ sandbox's SOCKS ``ALL_PROXY``) would fail or divert the loopback request.
698
+ """
699
+ try:
700
+ resp = _http.get(f"http://127.0.0.1:{port}/health", timeout=timeout)
701
+ except httpx.HTTPError:
702
+ return None
703
+ if resp.status_code != 200:
704
+ return None
705
+ try:
706
+ data = resp.json()
707
+ except ValueError:
708
+ return {}
709
+ return data if isinstance(data, dict) else {}
710
+
711
+
712
+ def _tail_log(path: Path, limit: int = 50) -> str:
713
+ if not path.exists():
714
+ return "<Log file does not exist>"
715
+ try:
716
+ lines: list[str] = []
717
+ truncated = False
718
+ with open(path, encoding="utf-8", errors="replace") as f:
719
+ for line in f:
720
+ lines.append(line)
721
+ if len(lines) > limit:
722
+ lines.pop(0)
723
+ truncated = True
724
+ content = "".join(lines)
725
+ return "... (truncated) ...\n" + content if truncated else content
726
+ except Exception as exc:
727
+ return f"<Failed to read log file: {exc}>"
728
+
729
+
730
+ def _log_hint(project_root: Path) -> str:
731
+ log_name = STDERR_LOG_FILENAME if sys.platform == "win32" else LOG_FILENAME
732
+ return (
733
+ f" Check logs: {PID_DIR}/{LOG_FILENAME}\n"
734
+ f" Log content:\n{_tail_log(project_root / PID_DIR / log_name)}"
735
+ )
736
+
737
+
738
+ def _wait_for_ready(
739
+ project_root: Path,
740
+ port: int,
741
+ *,
742
+ proc: subprocess.Popen | None = None,
743
+ timeout: int = DEFAULT_STARTUP_TIMEOUT,
744
+ sleep: Callable[[float], None] = time.sleep,
745
+ ) -> dict[str, Any]:
746
+ """Wait until ``GET /health`` answers 200 and return its body.
747
+
748
+ ``proc.poll()`` (which also reaps the child) fails fast with the exit code
749
+ and the log tail when the server process exits during startup.
750
+ """
751
+ deadline = time.monotonic() + timeout
752
+ while time.monotonic() < deadline:
753
+ if proc is not None and proc.poll() is not None:
754
+ raise ServerStartError(
755
+ f"Local server process exited during startup (exit code {proc.returncode}).\n"
756
+ + _log_hint(project_root)
757
+ )
758
+ health = _fetch_health(port)
759
+ if health is not None:
760
+ return health
761
+ sleep(0.3)
762
+
763
+ raise ServerStartError(
764
+ f"Local server did not become healthy within {timeout}s (GET /health).\n"
765
+ + _log_hint(project_root)
766
+ )
767
+
768
+
769
+ # --- pid file helpers ---
770
+
771
+
772
+ def pid_file_path(project_root: Path) -> Path:
773
+ return project_root / PID_DIR / PID_FILENAME
774
+
775
+
776
+ def read_pid_file(project_root: Path) -> dict[str, Any] | None:
777
+ path = pid_file_path(project_root)
778
+ if not path.exists():
779
+ return None
780
+ try:
781
+ data = json.loads(path.read_text(encoding="utf-8"))
782
+ except (json.JSONDecodeError, OSError):
783
+ return None
784
+ return data if isinstance(data, dict) else None
785
+
786
+
787
+ def _write_json_atomic(path: Path, data: dict[str, Any]) -> None:
788
+ """Write ``data`` to ``path`` through a temporary file so readers never see a torn file."""
789
+ path.parent.mkdir(exist_ok=True)
790
+ fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent)
791
+ try:
792
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
793
+ handle.write(json.dumps(data, indent=2) + "\n")
794
+ os.replace(tmp_name, path)
795
+ except BaseException:
796
+ try:
797
+ os.unlink(tmp_name)
798
+ except OSError:
799
+ pass
800
+ raise
801
+
802
+
803
+ def write_pid_file(
804
+ project_root: Path,
805
+ *,
806
+ pid: int,
807
+ port: int,
808
+ runtime: str,
809
+ checkpointer: str,
810
+ state: str = STATE_READY,
811
+ create_time: float | None = None,
812
+ ) -> None:
813
+ now = datetime.now(UTC).isoformat()
814
+ data = {
815
+ "pid": pid,
816
+ "port": port,
817
+ "started_at": now,
818
+ "last_activity": now,
819
+ "runtime": runtime,
820
+ "checkpointer": checkpointer,
821
+ "state": state,
822
+ # The process's creation time: tells this server from a later process
823
+ # that was given the same PID (null when it could not be read).
824
+ "create_time": create_time,
825
+ }
826
+ _write_json_atomic(pid_file_path(project_root), data)
827
+
828
+
829
+ def _remove_pid_file_if(project_root: Path, pid: int) -> None:
830
+ """Remove the pid file when it still names ``pid`` (never another invocation's)."""
831
+ info = read_pid_file(project_root)
832
+ if info and info.get("pid") == pid:
833
+ try:
834
+ pid_file_path(project_root).unlink(missing_ok=True)
835
+ except OSError as exc:
836
+ logging.warning("Failed to remove pid file %s: %s", pid_file_path(project_root), exc)
837
+
838
+
839
+ def _update_activity(project_root: Path) -> None:
840
+ """Stamp ``last_activity`` so idle detection resets."""
841
+ path = pid_file_path(project_root)
842
+ try:
843
+ data = json.loads(path.read_text(encoding="utf-8"))
844
+ if not isinstance(data, dict):
845
+ return
846
+ data["last_activity"] = datetime.now(UTC).isoformat()
847
+ _write_json_atomic(path, data)
848
+ except (json.JSONDecodeError, OSError):
849
+ pass
850
+
851
+
852
+ def _is_idle(info: dict, idle_timeout: int) -> bool:
853
+ """Return ``True`` if the server has been idle longer than *idle_timeout*."""
854
+ try:
855
+ last = datetime.fromisoformat(info["last_activity"])
856
+ if last.tzinfo is None:
857
+ last = last.replace(tzinfo=UTC)
858
+ idle = (datetime.now(UTC) - last).total_seconds()
859
+ return idle > idle_timeout
860
+ except (KeyError, TypeError, ValueError):
861
+ # Treat missing or unparseable timestamps as stale.
862
+ return True
863
+
864
+
865
+ def _create_time(pid: int) -> float | None:
866
+ """The creation time of process ``pid``, or None when it cannot be read."""
867
+ try:
868
+ return psutil.Process(pid).create_time()
869
+ except (psutil.Error, OSError):
870
+ return None
871
+
872
+
873
+ def _server_process(
874
+ pid: Any, *, create_time: Any = None, port: Any = None
875
+ ) -> psutil.Process | None:
876
+ """Process ``pid`` when it is still the recorded local server, else None.
877
+
878
+ With a recorded ``create_time`` the process must have been created then
879
+ (a PID reused by another process was not). Without one (a record written
880
+ by an older CLI) its command line must be a local server's: uvicorn or
881
+ ``langgraph``, with the recorded ``port`` among its arguments.
882
+ """
883
+ if not isinstance(pid, int) or pid <= 0:
884
+ return None
885
+ try:
886
+ process = psutil.Process(pid)
887
+ if create_time is not None:
888
+ same = abs(process.create_time() - float(create_time)) < _CREATE_TIME_TOLERANCE
889
+ return process if same else None
890
+ cmdline = process.cmdline()
891
+ except (psutil.Error, OSError, TypeError, ValueError):
892
+ return None
893
+ if not any("uvicorn" in part or "langgraph" in part for part in cmdline):
894
+ return None
895
+ if port is not None and str(port) not in cmdline:
896
+ return None
897
+ return process
898
+
899
+
900
+ def _is_server_alive(pid: int, port: int, create_time: float | None = None) -> bool:
901
+ """``True`` when ``pid`` is still the recorded server process AND its port is open."""
902
+ if not pid or not port or _server_process(pid, create_time=create_time, port=port) is None:
903
+ return False
904
+ try:
905
+ with socket.create_connection(("127.0.0.1", port), timeout=1):
906
+ return True
907
+ except OSError:
908
+ return False
909
+
910
+
911
+ def _own_group(pid: int) -> int | None:
912
+ """``pid``'s process group when ``pid`` leads it, else None (and always on Windows).
913
+
914
+ A local server leads its own group (it is started in a new session) and its
915
+ children (uvicorn under ``uv run``) stay in it, so the group reaches them
916
+ without listing processes.
917
+ """
918
+ if os.name == "nt":
919
+ return None
920
+ try:
921
+ return pid if os.getpgid(pid) == pid else None
922
+ except OSError:
923
+ return None
924
+
925
+
926
+ def _signal_tree(
927
+ processes: list[psutil.Process],
928
+ *,
929
+ group: int | None,
930
+ handle: subprocess.Popen | None,
931
+ kill: bool,
932
+ ) -> bool:
933
+ """SIGTERM (``kill``: SIGKILL) to ``processes``, the process ``group`` and the ``handle``.
934
+
935
+ Returns True when the operating system refused a signal (permission denied).
936
+ """
937
+ refused = False
938
+ for process in processes:
939
+ try:
940
+ if kill:
941
+ process.kill()
942
+ else:
943
+ process.terminate()
944
+ except (psutil.AccessDenied, PermissionError):
945
+ refused = True
946
+ except (psutil.Error, OSError):
947
+ pass
948
+ if group is not None:
949
+ import signal
950
+
951
+ try:
952
+ os.killpg(group, signal.SIGKILL if kill else signal.SIGTERM)
953
+ except PermissionError:
954
+ refused = True
955
+ except OSError:
956
+ pass
957
+ if handle is not None and not processes and handle.poll() is None:
958
+ try:
959
+ if kill:
960
+ handle.kill()
961
+ else:
962
+ handle.terminate()
963
+ except PermissionError:
964
+ refused = True
965
+ except OSError:
966
+ pass
967
+ return refused
968
+
969
+
970
+ def _still_running(process: psutil.Process) -> bool:
971
+ """Whether ``process`` is alive (a zombie has exited; an unreadable one counts as alive)."""
972
+ try:
973
+ return process.is_running() and process.status() != psutil.STATUS_ZOMBIE
974
+ except psutil.NoSuchProcess:
975
+ return False
976
+ except (psutil.Error, OSError):
977
+ return True
978
+
979
+
980
+ def _group_alive(group: int) -> bool:
981
+ try:
982
+ os.killpg(group, 0)
983
+ except ProcessLookupError:
984
+ return False
985
+ except OSError: # it exists, but may not be signalled
986
+ return True
987
+ return True
988
+
989
+
990
+ def _wait_tree(
991
+ processes: list[psutil.Process],
992
+ *,
993
+ group: int | None,
994
+ handle: subprocess.Popen | None,
995
+ timeout: float,
996
+ ) -> list[psutil.Process]:
997
+ """Wait up to ``timeout`` s for the tree to exit; returns the listed processes still alive."""
998
+ deadline = time.monotonic() + timeout
999
+ alive = processes
1000
+ if processes:
1001
+ try:
1002
+ _gone, alive = psutil.wait_procs(processes, timeout=timeout)
1003
+ except (psutil.Error, OSError):
1004
+ alive = processes
1005
+ if handle is not None and not processes:
1006
+ try:
1007
+ handle.wait(timeout=max(0.0, deadline - time.monotonic()))
1008
+ except (subprocess.TimeoutExpired, OSError):
1009
+ pass
1010
+ while group is not None and _group_alive(group) and time.monotonic() < deadline:
1011
+ time.sleep(0.05)
1012
+ return list(alive)
1013
+
1014
+
1015
+ def _terminate_process(
1016
+ pid: int,
1017
+ *,
1018
+ create_time: float | None = None,
1019
+ port: int | None = None,
1020
+ own_child: bool = False,
1021
+ proc: subprocess.Popen | None = None,
1022
+ ) -> bool:
1023
+ """Stop the server ``pid`` and its children (SIGTERM, then SIGKILL after 3 s).
1024
+
1025
+ Nothing is signalled unless ``pid`` is still that server (see
1026
+ :func:`_server_process`): a record can outlive its process, and the PID
1027
+ may belong to something else by now. ``own_child``: ``pid`` is a child of
1028
+ this process that was not reaped yet, so its PID cannot have been reused;
1029
+ ``proc`` (default: the handle kept when this process started it) is its
1030
+ ``Popen`` handle.
1031
+
1032
+ Listing the server's children can be denied (a sandbox that refuses the
1033
+ process table raises ``PermissionError``, an ``OSError`` that is not a
1034
+ ``psutil.Error``). The server's own process group is signalled then, and
1035
+ the ``Popen`` handle when psutil cannot see the server at all. No OS or
1036
+ psutil error escapes: a teardown must never replace the error that led to it.
1037
+
1038
+ Returns True when it was stopped, False when ``pid`` is not (or no longer)
1039
+ the server. Raises :class:`ServerStopError`, naming what is still running,
1040
+ when a process of the server survives: a refused signal (a sandbox lets a
1041
+ command signal only the processes it started itself) is never "stopped".
1042
+ """
1043
+ handle = proc if proc is not None else _HANDLES.get(pid)
1044
+ if handle is not None and handle.poll() is None:
1045
+ own_child = True
1046
+ parent = _server_process(pid, create_time=create_time, port=port)
1047
+ if parent is None and own_child:
1048
+ try:
1049
+ parent = psutil.Process(pid)
1050
+ except (psutil.Error, OSError):
1051
+ parent = None
1052
+ if parent is None and not (own_child and handle is not None):
1053
+ if psutil.pid_exists(pid):
1054
+ logging.warning(
1055
+ "The recorded local server (PID %d) is gone and its PID now belongs to "
1056
+ "another process, which was left alone.",
1057
+ pid,
1058
+ )
1059
+ return False
1060
+ processes: list[psutil.Process] = []
1061
+ group: int | None = None
1062
+ if parent is not None:
1063
+ try:
1064
+ processes = [*parent.children(recursive=True), parent]
1065
+ except (psutil.Error, OSError) as exc:
1066
+ logging.debug(
1067
+ "Could not list the local server's children (%s); signalling its group", exc
1068
+ )
1069
+ processes = [parent]
1070
+ group = _own_group(pid)
1071
+ else:
1072
+ group = _own_group(pid)
1073
+ refused = _signal_tree(processes, group=group, handle=handle, kill=False)
1074
+ alive = _wait_tree(processes, group=group, handle=handle, timeout=_TERM_WAIT)
1075
+ if alive or (group is not None and _group_alive(group)):
1076
+ refused |= _signal_tree(alive, group=group, handle=handle, kill=True)
1077
+ alive = _wait_tree(alive, group=group, handle=handle, timeout=_KILL_WAIT)
1078
+ left = sorted(process.pid for process in alive if _still_running(process))
1079
+ if handle is not None and not processes and handle.poll() is None:
1080
+ left.append(pid)
1081
+ group_left = group is not None and _group_alive(group)
1082
+ if left or group_left:
1083
+ raise _stop_failure(
1084
+ pid,
1085
+ create_time=create_time,
1086
+ port=port,
1087
+ left=left,
1088
+ group=group if group_left else None,
1089
+ refused=refused,
1090
+ )
1091
+ return True
1092
+
1093
+
1094
+ def _stop_failure(
1095
+ pid: int,
1096
+ *,
1097
+ create_time: float | None,
1098
+ port: int | None,
1099
+ left: list[int],
1100
+ group: int | None,
1101
+ refused: bool,
1102
+ ) -> ServerStopError:
1103
+ """The :class:`ServerStopError` for a server that survived SIGTERM and SIGKILL.
1104
+
1105
+ ``port`` is known for the recorded server only (its record is kept then).
1106
+ Later runs reuse it only while it is still that server and answers on it,
1107
+ so the message promises that only then.
1108
+ """
1109
+ where = f"PID {pid}, port {port}" if port else f"PID {pid}"
1110
+ what = []
1111
+ if left:
1112
+ what.append(f"PID{'s' if len(left) > 1 else ''} {', '.join(map(str, left))}")
1113
+ if group is not None:
1114
+ what.append(f"processes of its group {group}")
1115
+ why = (
1116
+ "the operating system refused the signal (permission denied)"
1117
+ if refused
1118
+ else "they did not exit after SIGKILL"
1119
+ )
1120
+ reason = f"{' and '.join(what)} still running; {why}"
1121
+ kill_command = f"kill {' '.join(map(str, left))}" if left else f"kill -- -{group}"
1122
+ if not port:
1123
+ kept = ""
1124
+ elif _is_server_alive(pid, port, create_time):
1125
+ kept = (
1126
+ " Its record is kept: later runs reuse the server, and "
1127
+ "`graph-agents-cli run --stop-server` tries again."
1128
+ )
1129
+ else:
1130
+ kept = " Its record is kept, and `graph-agents-cli run --stop-server` tries again."
1131
+ message = (
1132
+ f"Could not stop the local server ({where}): {reason}.{kept} Stop it from a shell "
1133
+ f"that may signal it (`{kill_command}`), such as the one that started it or one "
1134
+ "outside the sandbox this command runs in."
1135
+ )
1136
+ return ServerStopError(
1137
+ message, pid=pid, port=port, left=left, reason=reason, kill_command=kill_command
1138
+ )
1139
+
1140
+
1141
+ def _cleanup(project_root: Path, info: dict) -> bool:
1142
+ """Stop the recorded server (if it is still that server) and remove its record.
1143
+
1144
+ Returns True when a running server was stopped, False for a stale record.
1145
+ A server that could not be stopped keeps its record (:class:`ServerStopError`).
1146
+ """
1147
+ with shielded():
1148
+ pid = info.get("pid")
1149
+ stopped = False
1150
+ if pid:
1151
+ stopped = _terminate_process(
1152
+ pid, create_time=info.get("create_time"), port=info.get("port")
1153
+ )
1154
+ _STARTED.pop(pid, None)
1155
+ _HANDLES.pop(pid, None)
1156
+ _remove_pid_file_if(project_root, pid)
1157
+ return stopped