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