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