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.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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
|