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,1349 @@
|
|
|
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
|
+
"""Human approval of gated outbound API calls: the records, who decides, the ledger.
|
|
16
|
+
|
|
17
|
+
An API's `approval` block in `api-policy.yaml` gates calls; `api_client.py`
|
|
18
|
+
pauses the run before a gated call is sent, with an interrupt whose value
|
|
19
|
+
describes the exact request. When a run ends paused that way, the chat
|
|
20
|
+
runtime (`chat.py`) records one pending approval per interrupt here
|
|
21
|
+
(`ApprovalStore.add`) and ends the stream with `message.end` status
|
|
22
|
+
`awaiting_approval` and the approval (`ApprovalRecord.public()`).
|
|
23
|
+
|
|
24
|
+
Deciding is the action `approval.decide` of the auth policy, then this rule
|
|
25
|
+
(`may_decide`): `requester` in `approvers` lets the principal who started the
|
|
26
|
+
run (the thread's owner: only the owner runs on a thread) decide; `role:<x>`
|
|
27
|
+
lets any principal holding role `x` decide, except the requester, who may
|
|
28
|
+
approve their own call only when `requester` is listed. Anyone else gets 403.
|
|
29
|
+
A decision is one atomic change of a pending, unexpired row
|
|
30
|
+
(`ApprovalStore.decide`), so of two concurrent decisions one wins and the
|
|
31
|
+
other finds the approval no longer pending. The run then resumes with the
|
|
32
|
+
decision; the tool runs again, and `api_client` sends the call only when the
|
|
33
|
+
decision approves exactly the request it rebuilds (the same `call_hash`) and
|
|
34
|
+
`ApprovalStore.consume` (the ledger) marks the approval used: once, on the
|
|
35
|
+
thread it was made for. A tool call that runs again without a decision (a run
|
|
36
|
+
continued or replayed through LangGraph Server's own API) asks the ledger
|
|
37
|
+
first (`ApprovalStore.bound_approvals`) and sends no call an approval was
|
|
38
|
+
asked for.
|
|
39
|
+
|
|
40
|
+
Records (table `approvals`, `agent_approvals` under langgraph-server, in the
|
|
41
|
+
app's database; in process memory without one, and under `langgraph dev` also
|
|
42
|
+
in the file `.langgraph_api/agent_approvals.json` beside the dev server's own
|
|
43
|
+
threads, see `dev_ledger_path`): the thread, the run that
|
|
44
|
+
paused, the interrupt, the requester's hashed id and the roles and public
|
|
45
|
+
attributes the run acted with (never credentials), the tool call that asked
|
|
46
|
+
(the model message and its call id), the call (API, method, path, operation
|
|
47
|
+
id, `call_hash`, and in `payload` the query and JSON body as the approver sees
|
|
48
|
+
them, with the fields the tool named in `redact=` masked),
|
|
49
|
+
the approvers, the status (`pending`, `approved`, `rejected`, `expired`), the
|
|
50
|
+
decider's hashed id, the decision time and comment, when the approval was
|
|
51
|
+
used, and when it expires (`approval.timeout_s` after it was requested). A
|
|
52
|
+
decision relayed to another agent also keeps, in `payload`, the approval it
|
|
53
|
+
decides there (`nested`) and the call that will then happen (`effect`), and
|
|
54
|
+
expires 5 s before that approval at the latest. A call an agent's request
|
|
55
|
+
paused (under the fastapi runtime) also keeps the user's own words that agent
|
|
56
|
+
forwarded (`origin`, never shown): the run a decision resumes acts on them
|
|
57
|
+
(`resume_principal`). Once
|
|
58
|
+
decided or expired the query and body (and those of every nested call and of
|
|
59
|
+
the effect) are dropped from the record unless `TRACE_CAPTURE=full`, and the
|
|
60
|
+
user's words whatever it says. A pending approval past its expiry is expired (=
|
|
61
|
+
rejected): the runtime's sweep marks it every `SWEEP_INTERVAL_S` seconds and
|
|
62
|
+
every read treats it so. Deleting a thread deletes its approvals.
|
|
63
|
+
|
|
64
|
+
Who sees an approval (`may_view`): the thread's owner, a principal who may
|
|
65
|
+
decide it, and roles in `AUTH_READ_ACROSS_ROLES` (listing only; they see the
|
|
66
|
+
query and body only under `TRACE_CAPTURE=full`).
|
|
67
|
+
|
|
68
|
+
Agents calling agents: a thread's owner is its subject and, for a thread an
|
|
69
|
+
agent started for its user, that agent (`threads.ThreadRecord.actor`). The
|
|
70
|
+
user (a direct principal with that subject) is the requester of every
|
|
71
|
+
approval on the thread, whichever agent started it: they see it and, when
|
|
72
|
+
`requester` is listed, decide it. A delegated principal (an agent presenting
|
|
73
|
+
the user's token) sees only the approvals of threads started under its own
|
|
74
|
+
subject and actor; its roles never count as a role approver or a read-across
|
|
75
|
+
role. It decides only a gate whose rule opted into `decide_with: relayed` and
|
|
76
|
+
lists its actor in `relayers`, only on a thread it started itself, and only
|
|
77
|
+
with the approval's `digest` (`decide_refusal`, `digest_refusal`): it then
|
|
78
|
+
delivers the person's decision, recorded as `decided_via`. Under the default
|
|
79
|
+
`decide_with: direct` the person decides with their own credentials. How the
|
|
80
|
+
approvers decide is bound to the approval when it is asked, as the approvers
|
|
81
|
+
are. The requester's actor is recorded with the approval (`requester_actor`)
|
|
82
|
+
and restored when a role approver's decision resumes the run
|
|
83
|
+
(`resume_principal`).
|
|
84
|
+
|
|
85
|
+
`langgraph dev` keeps its threads in `.langgraph_api/` and loads them again
|
|
86
|
+
after a restart or a hot reload (a code change reloads the server), so the
|
|
87
|
+
approvals that bind their tool calls must outlive the process too: without
|
|
88
|
+
them, a run continued without input or replayed from a checkpoint would find
|
|
89
|
+
no record of a rejected, pending or used approval and, once the policy no
|
|
90
|
+
longer gates the call, send it. There the store writes its records to
|
|
91
|
+
`.langgraph_api/agent_approvals.json` (mode 0600) before a change takes
|
|
92
|
+
effect (a pending approval before the run ends awaiting it, a decision
|
|
93
|
+
before the run resumes, a use before the call is sent) and reads them at
|
|
94
|
+
startup; a file it cannot read stops the startup, and while a write fails
|
|
95
|
+
the store answers nothing (every lookup fails, so calls are refused) until
|
|
96
|
+
it writes again. Deleting `.langgraph_api/` resets the threads and their
|
|
97
|
+
approvals together. Without file persistence (`LANGGRAPH_DISABLE_FILE_PERSISTENCE`)
|
|
98
|
+
the dev server keeps no threads either, and the approvals stay in memory.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
from __future__ import annotations
|
|
102
|
+
|
|
103
|
+
import asyncio
|
|
104
|
+
import contextlib
|
|
105
|
+
import hashlib
|
|
106
|
+
import json
|
|
107
|
+
import logging
|
|
108
|
+
import os
|
|
109
|
+
import threading
|
|
110
|
+
import uuid
|
|
111
|
+
from collections import OrderedDict
|
|
112
|
+
from collections.abc import Iterable, Mapping
|
|
113
|
+
from dataclasses import asdict, dataclass, field
|
|
114
|
+
from datetime import UTC, datetime, timedelta
|
|
115
|
+
from pathlib import Path
|
|
116
|
+
from typing import Any
|
|
117
|
+
|
|
118
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
119
|
+
A2A_OPERATION_KEY,
|
|
120
|
+
APPROVAL_DECISION,
|
|
121
|
+
APPROVAL_INTERRUPT,
|
|
122
|
+
DECIDE_DIRECT,
|
|
123
|
+
DECIDE_RELAYED,
|
|
124
|
+
DECIDE_WITH_VALUES,
|
|
125
|
+
DEFAULT_APPROVAL_TIMEOUT_S,
|
|
126
|
+
MAX_APPROVAL_TIMEOUT_S,
|
|
127
|
+
MIN_APPROVAL_TIMEOUT_S,
|
|
128
|
+
NESTED_MAX_DEPTH,
|
|
129
|
+
REQUESTER_APPROVER,
|
|
130
|
+
ROLE_APPROVER_PREFIX,
|
|
131
|
+
RPC_METHOD_KEY,
|
|
132
|
+
BoundApproval,
|
|
133
|
+
)
|
|
134
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import (
|
|
135
|
+
Principal,
|
|
136
|
+
actor_of_attributes,
|
|
137
|
+
owner_key_of,
|
|
138
|
+
read_across_roles,
|
|
139
|
+
with_origin,
|
|
140
|
+
)
|
|
141
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import Database, StorageNotReady, capture_full
|
|
142
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadRecord
|
|
143
|
+
|
|
144
|
+
logger = logging.getLogger(__name__)
|
|
145
|
+
|
|
146
|
+
PENDING = "pending"
|
|
147
|
+
APPROVED = "approved"
|
|
148
|
+
REJECTED = "rejected"
|
|
149
|
+
EXPIRED = "expired"
|
|
150
|
+
STATUSES = (PENDING, APPROVED, REJECTED, EXPIRED)
|
|
151
|
+
|
|
152
|
+
# Decisions a decider sends (`POST /threads/{id}/approvals/{approval_id}`).
|
|
153
|
+
APPROVE = "approve"
|
|
154
|
+
REJECT = "reject"
|
|
155
|
+
DECISIONS = {APPROVE: APPROVED, REJECT: REJECTED}
|
|
156
|
+
|
|
157
|
+
# Error codes clients see.
|
|
158
|
+
CODE_APPROVAL_PENDING = "approval_pending" # 409 on /chat while an approval is pending
|
|
159
|
+
CODE_NOT_PENDING = "approval_not_pending" # 409 on a decision: already decided
|
|
160
|
+
CODE_EXPIRED = "approval_expired" # 410 on a decision: it expired
|
|
161
|
+
CODE_NOT_AN_APPROVER = "not_an_approver" # 403 on a decision: not one of its approvers
|
|
162
|
+
CODE_DIRECT_ONLY = "approval_direct_only" # 403: an agent may not decide it for the person
|
|
163
|
+
CODE_DIGEST_MISMATCH = "approval_digest_mismatch" # 409: decided on another view of the call
|
|
164
|
+
NOT_AN_APPROVER_DETAIL = "You may not decide this approval (see its approvers)."
|
|
165
|
+
DIGEST_MISMATCH_DETAIL = (
|
|
166
|
+
"The decision was taken on a different view of the call; fetch the approval again."
|
|
167
|
+
)
|
|
168
|
+
# `sha256:<hex>`, at most this long in a decision.
|
|
169
|
+
DIGEST_MAX_CHARS = 80
|
|
170
|
+
DIGEST_PREFIX = "sha256:"
|
|
171
|
+
|
|
172
|
+
# How often every replica marks pending approvals past their expiry `expired`.
|
|
173
|
+
SWEEP_INTERVAL_S = 30.0
|
|
174
|
+
# Approvals kept without a database (the oldest decided ones go first).
|
|
175
|
+
MEMORY_APPROVALS_CAP = 10_000
|
|
176
|
+
COMMENT_MAX_CHARS = 1000
|
|
177
|
+
# Payload keys dropped once an approval is decided or expired (unless TRACE_CAPTURE=full).
|
|
178
|
+
CALL_CONTENT_KEYS = ("query", "body")
|
|
179
|
+
# The user's own words the request that paused carried (`credentials["@origin"]`, which an
|
|
180
|
+
# agent calling for them forwarded): kept in the payload while the approval waits, so the run
|
|
181
|
+
# it resumes acts on them whoever delivers the decision (`resume_principal`); dropped once it
|
|
182
|
+
# is decided or expired, whatever TRACE_CAPTURE says, and never shown.
|
|
183
|
+
ORIGIN_PAYLOAD_KEY = "origin"
|
|
184
|
+
# A decision relayed to another agent: the approval it decides there (`nested`, recursive:
|
|
185
|
+
# its `call`, and the approval that one relays in turn) and the call that will then happen
|
|
186
|
+
# (`effect`). Their query and body go with the call's once decided.
|
|
187
|
+
NESTED_KEY = "nested"
|
|
188
|
+
EFFECT_KEY = "effect"
|
|
189
|
+
# The relayed approval must still be open downstream when the person decides here: this
|
|
190
|
+
# approval expires this long before it at the latest.
|
|
191
|
+
NESTED_EXPIRY_MARGIN_S = 5
|
|
192
|
+
# What a call to a JSON-RPC API was, read from its body (`api_client.derive_rpc`): kept in
|
|
193
|
+
# the payload (only for such calls), part of which call the approval is bound to, and of the
|
|
194
|
+
# approver's view.
|
|
195
|
+
RPC_KEYS = (RPC_METHOD_KEY, A2A_OPERATION_KEY)
|
|
196
|
+
|
|
197
|
+
# Where `langgraph dev` keeps its threads (relative to the directory it runs in),
|
|
198
|
+
# and the file the app keeps their approvals in there.
|
|
199
|
+
DEV_STATE_DIR = ".langgraph_api"
|
|
200
|
+
DEV_LEDGER_FILE = "agent_approvals.json"
|
|
201
|
+
# Version 2 (0.3) adds each approval's requester actor, how it is decided (`decide_with`,
|
|
202
|
+
# `relayers`), `decided_via` and `display_digest`; a version-1 file reads with the defaults.
|
|
203
|
+
LEDGER_FILE_VERSION = 2
|
|
204
|
+
LEDGER_FILE_VERSIONS = (1, 2)
|
|
205
|
+
_DATETIME_FIELDS = ("decided_at", "used_at", "created_at", "expires_at")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def dev_ledger_path() -> Path | None:
|
|
209
|
+
"""The file the approvals are kept in under `langgraph dev`, else None.
|
|
210
|
+
|
|
211
|
+
`langgraph dev` runs LangGraph Server's in-memory edition, which keeps its
|
|
212
|
+
threads in `.langgraph_api/` of the directory it runs in (unless file
|
|
213
|
+
persistence is off): the approvals are kept there too, so both survive a
|
|
214
|
+
restart or a hot reload. Everywhere else they live in the app's database
|
|
215
|
+
(or, with the fastapi runtime's in-memory checkpointer, in memory with
|
|
216
|
+
the threads).
|
|
217
|
+
"""
|
|
218
|
+
edition = (os.environ.get("LANGGRAPH_RUNTIME_EDITION") or "").strip().lower()
|
|
219
|
+
if edition != "inmem":
|
|
220
|
+
return None
|
|
221
|
+
disabled = (os.environ.get("LANGGRAPH_DISABLE_FILE_PERSISTENCE") or "").strip().lower()
|
|
222
|
+
if disabled == "true":
|
|
223
|
+
return None
|
|
224
|
+
return Path(DEV_STATE_DIR) / DEV_LEDGER_FILE
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class LedgerUnavailable(StorageNotReady):
|
|
228
|
+
"""The approvals file cannot be written or read: the store answers nothing (a 503)."""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def utcnow() -> datetime:
|
|
232
|
+
return datetime.now(tz=UTC)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _iso(value: datetime | None) -> str | None:
|
|
236
|
+
return value.isoformat() if value is not None else None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _as_datetime(value: Any) -> datetime | None:
|
|
240
|
+
"""A stored time as an aware datetime in UTC (naive: read as UTC).
|
|
241
|
+
|
|
242
|
+
Postgres answers `timestamptz` in the session's time zone: normalised, an
|
|
243
|
+
approval reads back with the very times it was written with (`public()`
|
|
244
|
+
shows them, and a relay that reads the ledger binds them in its decision).
|
|
245
|
+
"""
|
|
246
|
+
if value is None:
|
|
247
|
+
return None
|
|
248
|
+
if isinstance(value, datetime):
|
|
249
|
+
parsed = value
|
|
250
|
+
else:
|
|
251
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
252
|
+
return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _json(value: Any) -> str:
|
|
256
|
+
return json.dumps(value, default=str)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _decode(value: Any) -> Any:
|
|
260
|
+
return json.loads(value) if isinstance(value, str) else value
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def is_approval_interrupt(value: Any) -> bool:
|
|
264
|
+
"""Whether an interrupt value is a gated API call's (raised by `api_client`)."""
|
|
265
|
+
return isinstance(value, Mapping) and value.get("type") == APPROVAL_INTERRUPT
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass
|
|
269
|
+
class ApprovalRecord:
|
|
270
|
+
approval_id: str
|
|
271
|
+
thread_id: str
|
|
272
|
+
run_id: str
|
|
273
|
+
interrupt_id: str
|
|
274
|
+
requester_hash: str
|
|
275
|
+
api: str
|
|
276
|
+
method: str
|
|
277
|
+
path: str
|
|
278
|
+
call_hash: str
|
|
279
|
+
approvers: list[str]
|
|
280
|
+
payload: dict[str, Any]
|
|
281
|
+
expires_at: datetime
|
|
282
|
+
operation_id: str | None = None
|
|
283
|
+
tool_call_id: str | None = None
|
|
284
|
+
# The model message that made the tool call (with `tool_call_id`, the tool call).
|
|
285
|
+
message_id: str | None = None
|
|
286
|
+
requester_context: dict[str, Any] = field(default_factory=dict)
|
|
287
|
+
status: str = PENDING
|
|
288
|
+
decided_by: str | None = None
|
|
289
|
+
decided_at: datetime | None = None
|
|
290
|
+
comment: str | None = None
|
|
291
|
+
used_at: datetime | None = None
|
|
292
|
+
created_at: datetime = field(default_factory=utcnow)
|
|
293
|
+
# The agent the requester's run acted through ("" for a direct requester).
|
|
294
|
+
requester_actor: str = ""
|
|
295
|
+
# How the requester decides, bound when the approval was asked: `direct`, or `relayed`
|
|
296
|
+
# by the agents in `relayers`.
|
|
297
|
+
decide_with: str = DECIDE_DIRECT
|
|
298
|
+
relayers: list[str] = field(default_factory=list)
|
|
299
|
+
# The agent that delivered the decision (a relayed decision), else None.
|
|
300
|
+
decided_via: str | None = None
|
|
301
|
+
# What the approver was shown, hashed (`approval_digest`): a relayed decision names it.
|
|
302
|
+
display_digest: str | None = None
|
|
303
|
+
|
|
304
|
+
def rpc(self) -> dict[str, str]:
|
|
305
|
+
"""What a call to a JSON-RPC API was (`rpc_method`, `a2a_operation`); empty otherwise."""
|
|
306
|
+
return {
|
|
307
|
+
key: self.payload[key] for key in RPC_KEYS if isinstance(self.payload.get(key), str)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
def effective_status(self, now: datetime | None = None) -> str:
|
|
311
|
+
"""The status, with a pending approval past its expiry read as `expired`."""
|
|
312
|
+
if self.status == PENDING and self.expires_at <= (now or utcnow()):
|
|
313
|
+
return EXPIRED
|
|
314
|
+
return self.status
|
|
315
|
+
|
|
316
|
+
def is_pending(self, now: datetime | None = None) -> bool:
|
|
317
|
+
return self.effective_status(now) == PENDING
|
|
318
|
+
|
|
319
|
+
def public(self, *, include_call: bool = True, now: datetime | None = None) -> dict[str, Any]:
|
|
320
|
+
"""The approval as clients see it (never the call hash, the interrupt or the rule).
|
|
321
|
+
|
|
322
|
+
`query` and `body` are there when `include_call` and the record still
|
|
323
|
+
holds them (pending, or `TRACE_CAPTURE=full`).
|
|
324
|
+
"""
|
|
325
|
+
out: dict[str, Any] = {
|
|
326
|
+
"approval_id": self.approval_id,
|
|
327
|
+
"thread_id": self.thread_id,
|
|
328
|
+
"run_id": self.run_id,
|
|
329
|
+
"status": self.effective_status(now),
|
|
330
|
+
"api": self.api,
|
|
331
|
+
"method": self.method,
|
|
332
|
+
"path": self.path,
|
|
333
|
+
"operation_id": self.operation_id,
|
|
334
|
+
}
|
|
335
|
+
if include_call:
|
|
336
|
+
for key in CALL_CONTENT_KEYS:
|
|
337
|
+
if key in self.payload:
|
|
338
|
+
out[key] = self.payload[key]
|
|
339
|
+
out.update(self.rpc())
|
|
340
|
+
for key in (EFFECT_KEY, NESTED_KEY):
|
|
341
|
+
if isinstance(self.payload.get(key), dict):
|
|
342
|
+
out[key] = (
|
|
343
|
+
self.payload[key]
|
|
344
|
+
if include_call
|
|
345
|
+
else without_nested_call({key: self.payload[key]})[key]
|
|
346
|
+
)
|
|
347
|
+
out.update(
|
|
348
|
+
{
|
|
349
|
+
"tool": self.payload.get("tool"),
|
|
350
|
+
"reason": self.payload.get("reason"),
|
|
351
|
+
"approvers": list(self.approvers),
|
|
352
|
+
"decide_with": self.decide_with,
|
|
353
|
+
"requester": self.requester_hash,
|
|
354
|
+
"requester_actor": self.requester_actor or None,
|
|
355
|
+
"decided_via": self.decided_via,
|
|
356
|
+
"digest": self.display_digest,
|
|
357
|
+
"created_at": _iso(self.created_at),
|
|
358
|
+
"expires_at": _iso(self.expires_at),
|
|
359
|
+
"decided_by": self.decided_by,
|
|
360
|
+
"decided_at": _iso(self.decided_at),
|
|
361
|
+
"comment": self.comment,
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
return out
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def approval_view(record: ApprovalRecord) -> dict[str, Any]:
|
|
368
|
+
"""The call as the approver sees it while the approval is pending (masked fields masked).
|
|
369
|
+
|
|
370
|
+
`rpc_method` and `a2a_operation` are what a call to a JSON-RPC API was, read
|
|
371
|
+
from its body; None for any other call.
|
|
372
|
+
"""
|
|
373
|
+
rpc = record.rpc()
|
|
374
|
+
return {
|
|
375
|
+
"api": record.api,
|
|
376
|
+
"method": record.method,
|
|
377
|
+
"path": record.path,
|
|
378
|
+
"operation_id": record.operation_id,
|
|
379
|
+
"rpc_method": rpc.get(RPC_METHOD_KEY),
|
|
380
|
+
"a2a_operation": rpc.get(A2A_OPERATION_KEY),
|
|
381
|
+
"query": record.payload.get("query"),
|
|
382
|
+
"body": record.payload.get("body"),
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _nested_expiry(nested: Any) -> datetime | None:
|
|
387
|
+
"""The earliest `expires_at` of a relayed approval chain (`nested`), or None."""
|
|
388
|
+
earliest: datetime | None = None
|
|
389
|
+
level, depth = nested, 0
|
|
390
|
+
while isinstance(level, Mapping) and depth < NESTED_MAX_DEPTH:
|
|
391
|
+
try:
|
|
392
|
+
expiry = _as_datetime(level.get("expires_at"))
|
|
393
|
+
except (TypeError, ValueError):
|
|
394
|
+
expiry = None
|
|
395
|
+
if expiry is not None and (earliest is None or expiry < earliest):
|
|
396
|
+
earliest = expiry
|
|
397
|
+
level, depth = level.get(NESTED_KEY), depth + 1
|
|
398
|
+
return earliest
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def without_nested_call(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
402
|
+
"""`payload` with the query and body of its relayed approvals' calls, at every level
|
|
403
|
+
(`nested.call`, `nested.nested.call`, ...), and of its `effect`, taken out."""
|
|
404
|
+
|
|
405
|
+
def strip(level: Any, depth: int) -> Any:
|
|
406
|
+
if not isinstance(level, Mapping) or depth >= NESTED_MAX_DEPTH:
|
|
407
|
+
return level
|
|
408
|
+
out = dict(level)
|
|
409
|
+
if isinstance(out.get("call"), Mapping):
|
|
410
|
+
out["call"] = {k: v for k, v in out["call"].items() if k not in CALL_CONTENT_KEYS}
|
|
411
|
+
if NESTED_KEY in out:
|
|
412
|
+
out[NESTED_KEY] = strip(out[NESTED_KEY], depth + 1)
|
|
413
|
+
return out
|
|
414
|
+
|
|
415
|
+
out = dict(payload)
|
|
416
|
+
if isinstance(out.get(NESTED_KEY), Mapping):
|
|
417
|
+
out[NESTED_KEY] = strip(out[NESTED_KEY], 0)
|
|
418
|
+
if isinstance(out.get(EFFECT_KEY), Mapping):
|
|
419
|
+
out[EFFECT_KEY] = {k: v for k, v in out[EFFECT_KEY].items() if k not in CALL_CONTENT_KEYS}
|
|
420
|
+
return out
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def approval_digest(view: Mapping[str, Any]) -> str:
|
|
424
|
+
"""`sha256:<hex>` of the approver's view of a call (canonical JSON): what a relayed
|
|
425
|
+
decision names, so a decision taken on another view is refused."""
|
|
426
|
+
text = json.dumps(view, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
427
|
+
return DIGEST_PREFIX + hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def record_from_interrupt(
|
|
431
|
+
value: Mapping[str, Any],
|
|
432
|
+
*,
|
|
433
|
+
interrupt_id: str,
|
|
434
|
+
thread_id: str,
|
|
435
|
+
run_id: str,
|
|
436
|
+
requester: Principal,
|
|
437
|
+
now: datetime | None = None,
|
|
438
|
+
origin: Mapping[str, Any] | None = None,
|
|
439
|
+
) -> ApprovalRecord:
|
|
440
|
+
"""A pending approval for the interrupt a gated call raised (`is_approval_interrupt`).
|
|
441
|
+
|
|
442
|
+
`origin` is the user's own words the paused request carried (`auth.origin_of`),
|
|
443
|
+
kept until the approval is decided so the resumed run acts on them.
|
|
444
|
+
"""
|
|
445
|
+
now = now or utcnow()
|
|
446
|
+
raw_timeout = value.get("timeout_s")
|
|
447
|
+
timeout = (
|
|
448
|
+
raw_timeout
|
|
449
|
+
if isinstance(raw_timeout, int) and not isinstance(raw_timeout, bool)
|
|
450
|
+
else DEFAULT_APPROVAL_TIMEOUT_S
|
|
451
|
+
)
|
|
452
|
+
timeout = min(max(timeout, MIN_APPROVAL_TIMEOUT_S), MAX_APPROVAL_TIMEOUT_S)
|
|
453
|
+
approvers = [str(a) for a in value.get("approvers") or [] if isinstance(a, str)]
|
|
454
|
+
decide_with = value.get("decide_with")
|
|
455
|
+
if decide_with not in DECIDE_WITH_VALUES:
|
|
456
|
+
decide_with = DECIDE_DIRECT # a call paused before 0.3
|
|
457
|
+
relayers = value.get("relayers") if decide_with == DECIDE_RELAYED else None
|
|
458
|
+
payload = {
|
|
459
|
+
"query": value.get("query") or {},
|
|
460
|
+
"body": value.get("body"),
|
|
461
|
+
"tool": value.get("tool"),
|
|
462
|
+
"reason": value.get("reason"),
|
|
463
|
+
}
|
|
464
|
+
payload.update({key: value[key] for key in RPC_KEYS if isinstance(value.get(key), str)})
|
|
465
|
+
payload.update(
|
|
466
|
+
{key: value[key] for key in (NESTED_KEY, EFFECT_KEY) if isinstance(value.get(key), dict)}
|
|
467
|
+
)
|
|
468
|
+
if origin is not None and isinstance(origin.get("text"), str):
|
|
469
|
+
payload[ORIGIN_PAYLOAD_KEY] = dict(origin)
|
|
470
|
+
expires_at = now + timedelta(seconds=timeout)
|
|
471
|
+
downstream = _nested_expiry(payload.get(NESTED_KEY))
|
|
472
|
+
if downstream is not None:
|
|
473
|
+
# Never ask the person to approve what has expired where it happens.
|
|
474
|
+
expires_at = min(expires_at, downstream - timedelta(seconds=NESTED_EXPIRY_MARGIN_S))
|
|
475
|
+
record = ApprovalRecord(
|
|
476
|
+
approval_id=uuid.uuid4().hex,
|
|
477
|
+
thread_id=thread_id,
|
|
478
|
+
run_id=run_id,
|
|
479
|
+
interrupt_id=str(interrupt_id),
|
|
480
|
+
requester_hash=requester.hashed_id(),
|
|
481
|
+
requester_actor=requester.actor.id if requester.actor is not None else "",
|
|
482
|
+
requester_context={
|
|
483
|
+
"roles": [str(r) for r in requester.roles],
|
|
484
|
+
"attributes": requester.public_attributes(),
|
|
485
|
+
},
|
|
486
|
+
api=str(value.get("api") or ""),
|
|
487
|
+
method=str(value.get("method") or "").upper(),
|
|
488
|
+
path=str(value.get("path") or ""),
|
|
489
|
+
operation_id=str(value["operation_id"]) if value.get("operation_id") else None,
|
|
490
|
+
call_hash=str(value.get("call_hash") or ""),
|
|
491
|
+
tool_call_id=str(value["tool_call_id"]) if value.get("tool_call_id") else None,
|
|
492
|
+
message_id=str(value["message_id"]) if value.get("message_id") else None,
|
|
493
|
+
approvers=approvers,
|
|
494
|
+
payload=payload,
|
|
495
|
+
created_at=now,
|
|
496
|
+
expires_at=expires_at,
|
|
497
|
+
decide_with=str(decide_with),
|
|
498
|
+
relayers=[str(r) for r in relayers or [] if isinstance(r, str)],
|
|
499
|
+
)
|
|
500
|
+
record.display_digest = approval_digest(approval_view(record))
|
|
501
|
+
return record
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def decision_value(record: ApprovalRecord, decision: str) -> dict[str, Any]:
|
|
505
|
+
"""The resume value for the interrupt of `record`: `approve`, `reject` or `expired`,
|
|
506
|
+
or `pending` for a call whose approval still waits (another one was decided).
|
|
507
|
+
|
|
508
|
+
It names the call it was taken for (API, method, path, and for a JSON-RPC
|
|
509
|
+
call its `rpc_method` and `a2a_operation`): the client applies it to that
|
|
510
|
+
call whatever the policy says about gating it by then, so a rejected or
|
|
511
|
+
expired call is never sent and a pending one pauses again.
|
|
512
|
+
`approvers` (and how they decide: `decide_with`, `relayers`) are the ones
|
|
513
|
+
the approval was asked of: the client refuses an approval whose approvers,
|
|
514
|
+
or how they decide, differ from what the policy's gate names when the call
|
|
515
|
+
is about to be sent.
|
|
516
|
+
"""
|
|
517
|
+
return {
|
|
518
|
+
"type": APPROVAL_DECISION,
|
|
519
|
+
"approval_id": record.approval_id,
|
|
520
|
+
"decision": decision,
|
|
521
|
+
"api": record.api,
|
|
522
|
+
"method": record.method,
|
|
523
|
+
"path": record.path,
|
|
524
|
+
"call_hash": record.call_hash,
|
|
525
|
+
"approvers": list(record.approvers),
|
|
526
|
+
"decide_with": record.decide_with,
|
|
527
|
+
"relayers": list(record.relayers),
|
|
528
|
+
"comment": record.comment,
|
|
529
|
+
**record.rpc(),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# Who decides, who sees
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def role_approvers(approvers: Iterable[str]) -> set[str]:
|
|
539
|
+
return {
|
|
540
|
+
a[len(ROLE_APPROVER_PREFIX) :]
|
|
541
|
+
for a in approvers
|
|
542
|
+
if isinstance(a, str) and a.startswith(ROLE_APPROVER_PREFIX)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# The thread an approval was asked on: its `ThreadRecord`, or the owner's subject alone
|
|
547
|
+
# (a direct thread), as older callers pass it.
|
|
548
|
+
ThreadOwner = ThreadRecord | str | None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def thread_owner(owner: ThreadOwner) -> tuple[str, str]:
|
|
552
|
+
"""`(subject, actor)` of a thread (`actor` "" for a direct thread)."""
|
|
553
|
+
if isinstance(owner, ThreadRecord):
|
|
554
|
+
return owner.principal_id or "", owner.actor or ""
|
|
555
|
+
return owner or "", ""
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def is_requester(principal: Principal, owner: ThreadOwner) -> bool:
|
|
559
|
+
"""The principal the thread's runs act for: the thread's owner (only the owner runs on it).
|
|
560
|
+
|
|
561
|
+
The subject directly, whichever agent started the thread; an agent only on
|
|
562
|
+
a thread started under its own subject and actor.
|
|
563
|
+
"""
|
|
564
|
+
subject, actor = thread_owner(owner)
|
|
565
|
+
if not subject or principal.id != subject:
|
|
566
|
+
return False
|
|
567
|
+
return principal.actor is None or principal.actor.id == actor
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def decide_refusal(
|
|
571
|
+
principal: Principal,
|
|
572
|
+
owner: ThreadOwner,
|
|
573
|
+
approvers: Iterable[str],
|
|
574
|
+
*,
|
|
575
|
+
decide_with: str = DECIDE_DIRECT,
|
|
576
|
+
relayers: Iterable[str] = (),
|
|
577
|
+
) -> tuple[str, str] | None:
|
|
578
|
+
"""Why `principal` may not approve or reject (`(code, detail)`, a 403), or None.
|
|
579
|
+
|
|
580
|
+
A direct principal: the requester decides only when `requester` is
|
|
581
|
+
listed, never through a role (no self-approval unless the policy asks for
|
|
582
|
+
requester confirmation); anyone else through a listed `role:` it holds. A
|
|
583
|
+
delegated principal (an agent) decides only on a thread it started for
|
|
584
|
+
that user (else it learns nothing more than that it is no approver), when
|
|
585
|
+
the rule is `decide_with: relayed` with `requester` listed (else
|
|
586
|
+
`approval_direct_only`: the person decides with their own credentials),
|
|
587
|
+
and when its actor is one of the rule's `relayers`. It must also send the
|
|
588
|
+
approval's digest (`digest_refusal`).
|
|
589
|
+
"""
|
|
590
|
+
approvers = list(approvers)
|
|
591
|
+
if principal.actor is not None:
|
|
592
|
+
if not is_requester(principal, owner):
|
|
593
|
+
return CODE_NOT_AN_APPROVER, NOT_AN_APPROVER_DETAIL
|
|
594
|
+
if decide_with != DECIDE_RELAYED or REQUESTER_APPROVER not in approvers:
|
|
595
|
+
return (
|
|
596
|
+
CODE_DIRECT_ONLY,
|
|
597
|
+
"This approval must be decided by the person at this agent (decide_with: "
|
|
598
|
+
f"direct), not relayed by agent {principal.actor.id}.",
|
|
599
|
+
)
|
|
600
|
+
if principal.actor.id not in set(relayers):
|
|
601
|
+
return (
|
|
602
|
+
CODE_NOT_AN_APPROVER,
|
|
603
|
+
f"{principal.actor.id} may not relay decisions for this approval.",
|
|
604
|
+
)
|
|
605
|
+
return None
|
|
606
|
+
if is_requester(principal, owner):
|
|
607
|
+
allowed = REQUESTER_APPROVER in approvers
|
|
608
|
+
else:
|
|
609
|
+
allowed = bool(role_approvers(approvers) & set(principal.roles))
|
|
610
|
+
return None if allowed else (CODE_NOT_AN_APPROVER, NOT_AN_APPROVER_DETAIL)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def may_decide(
|
|
614
|
+
principal: Principal,
|
|
615
|
+
owner: ThreadOwner,
|
|
616
|
+
approvers: Iterable[str],
|
|
617
|
+
*,
|
|
618
|
+
decide_with: str = DECIDE_DIRECT,
|
|
619
|
+
relayers: Iterable[str] = (),
|
|
620
|
+
) -> bool:
|
|
621
|
+
"""Whether `principal` may approve or reject: see `decide_refusal`."""
|
|
622
|
+
refusal = decide_refusal(
|
|
623
|
+
principal, owner, approvers, decide_with=decide_with, relayers=relayers
|
|
624
|
+
)
|
|
625
|
+
return refusal is None
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def digest_refusal(principal: Principal, record: ApprovalRecord, digest: Any) -> bool:
|
|
629
|
+
"""Whether a decision's `digest` refuses it (409 `approval_digest_mismatch`).
|
|
630
|
+
|
|
631
|
+
A delegated decider (a relayer) must name the digest of the view the
|
|
632
|
+
person approved (`ApprovalRecord.display_digest`); a direct decider may,
|
|
633
|
+
and a digest it sends is checked too.
|
|
634
|
+
"""
|
|
635
|
+
if digest is None and principal.actor is None:
|
|
636
|
+
return False
|
|
637
|
+
expected = record.display_digest
|
|
638
|
+
return not (isinstance(digest, str) and expected is not None and digest == expected)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def reads_across(principal: Principal) -> bool:
|
|
642
|
+
"""A direct principal holding a read-across role (a delegated one never reads across)."""
|
|
643
|
+
return not principal.delegated and bool(set(principal.roles) & read_across_roles())
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def may_view(principal: Principal, owner: ThreadOwner, approvers: Iterable[str]) -> bool:
|
|
647
|
+
"""The owner, a decider, or a read-across role (for listing)."""
|
|
648
|
+
return (
|
|
649
|
+
is_requester(principal, owner)
|
|
650
|
+
or may_decide(principal, owner, approvers)
|
|
651
|
+
or reads_across(principal)
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def sees_call(principal: Principal, owner: ThreadOwner, approvers: Iterable[str]) -> bool:
|
|
656
|
+
"""Whether the viewer sees the call's query and body: the owner and the deciders
|
|
657
|
+
do (they need them); read-across roles only under `TRACE_CAPTURE=full`."""
|
|
658
|
+
return (
|
|
659
|
+
is_requester(principal, owner) or may_decide(principal, owner, approvers) or capture_full()
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def resume_principal(
|
|
664
|
+
record: ApprovalRecord,
|
|
665
|
+
owner: ThreadOwner,
|
|
666
|
+
decider: Principal,
|
|
667
|
+
origin: Mapping[str, Any] | None = None,
|
|
668
|
+
) -> Principal:
|
|
669
|
+
"""Who the resumed run acts as: always the requester, never another decider.
|
|
670
|
+
|
|
671
|
+
When the requester decides (the same subject and actor, or the subject
|
|
672
|
+
itself on a thread its agent started), their own principal of this
|
|
673
|
+
request (its credentials included, for `auth: forward` APIs). When
|
|
674
|
+
someone else does (a role approver), the requester with the roles, public
|
|
675
|
+
attributes and actor the run had when it paused; credentials are never
|
|
676
|
+
stored, so an `auth: forward` call approved by someone else has none and
|
|
677
|
+
is not sent.
|
|
678
|
+
|
|
679
|
+
An agent's run acts on the user's words of the request that paused it
|
|
680
|
+
(`origin`, which the approval kept: `ORIGIN_PAYLOAD_KEY`), never on those
|
|
681
|
+
a decision it relays carries (the person's words when approving, at the
|
|
682
|
+
agent that asked): its tools check the same words again, and a relay it
|
|
683
|
+
makes in turn rebuilds the very call the person approved. The person
|
|
684
|
+
deciding directly acts with their own words.
|
|
685
|
+
"""
|
|
686
|
+
subject, actor = thread_owner(owner)
|
|
687
|
+
if decider.actor is None and decider.id == subject:
|
|
688
|
+
return decider
|
|
689
|
+
if decider.owner_key() == owner_key_of(subject, actor):
|
|
690
|
+
return with_origin(decider, origin)
|
|
691
|
+
context = record.requester_context or {}
|
|
692
|
+
attributes = dict(context.get("attributes") or {})
|
|
693
|
+
rebuilt = Principal(
|
|
694
|
+
id=subject,
|
|
695
|
+
roles=[str(r) for r in context.get("roles") or []],
|
|
696
|
+
attributes=attributes,
|
|
697
|
+
actor=actor_of_attributes(attributes),
|
|
698
|
+
)
|
|
699
|
+
return with_origin(rebuilt, origin) if origin is not None else rebuilt
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
# The store (and the ledger `api_client` consults)
|
|
704
|
+
# ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _without_call(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
708
|
+
kept = {k: v for k, v in payload.items() if k != ORIGIN_PAYLOAD_KEY}
|
|
709
|
+
if capture_full():
|
|
710
|
+
return kept
|
|
711
|
+
return without_nested_call({k: v for k, v in kept.items() if k not in CALL_CONTENT_KEYS})
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
_COLUMNS = (
|
|
715
|
+
"approval_id, thread_id, run_id, interrupt_id, requester_hash, requester_context, api, "
|
|
716
|
+
"method, path, operation_id, call_hash, tool_call_id, message_id, approvers, payload, "
|
|
717
|
+
"status, decided_by, decided_at, comment, used_at, created_at, expires_at, requester_actor, "
|
|
718
|
+
"decide_with, relayers, decided_via, display_digest"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _cleared_payload() -> str:
|
|
723
|
+
"""The SQL of the payload with the call cleared, unless the first parameter is true
|
|
724
|
+
(TRACE_CAPTURE=full): its query and body, those of the relayed approvals' calls at
|
|
725
|
+
every level (as `without_nested_call`), and those of its `effect`. The user's words
|
|
726
|
+
(`ORIGIN_PAYLOAD_KEY`) go whatever the parameter says."""
|
|
727
|
+
paths = ["'{effect,query}'", "'{effect,body}'"]
|
|
728
|
+
for depth in range(1, NESTED_MAX_DEPTH + 1):
|
|
729
|
+
prefix = ",".join([NESTED_KEY] * depth)
|
|
730
|
+
# A text[] literal such as '{nested,call,body}' (built without doubled braces).
|
|
731
|
+
paths += ["'{" + prefix + ",call," + key + "}'" for key in CALL_CONTENT_KEYS]
|
|
732
|
+
cleared = " ".join(f"#- {path}" for path in paths)
|
|
733
|
+
return (
|
|
734
|
+
f"(CASE WHEN %s THEN payload ELSE (payload - 'query' - 'body') {cleared} END)"
|
|
735
|
+
f" - '{ORIGIN_PAYLOAD_KEY}'"
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# Clears the call from the payload unless the first parameter is true (TRACE_CAPTURE=full).
|
|
740
|
+
_CLEARED_PAYLOAD = _cleared_payload()
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _record_from_row(row: Mapping[str, Any]) -> ApprovalRecord:
|
|
744
|
+
return ApprovalRecord(
|
|
745
|
+
approval_id=row["approval_id"],
|
|
746
|
+
thread_id=row["thread_id"],
|
|
747
|
+
run_id=row["run_id"],
|
|
748
|
+
interrupt_id=row["interrupt_id"],
|
|
749
|
+
requester_hash=row["requester_hash"],
|
|
750
|
+
requester_context=_decode(row.get("requester_context")) or {},
|
|
751
|
+
api=row["api"],
|
|
752
|
+
method=row["method"],
|
|
753
|
+
path=row["path"],
|
|
754
|
+
operation_id=row.get("operation_id"),
|
|
755
|
+
call_hash=row["call_hash"],
|
|
756
|
+
tool_call_id=row.get("tool_call_id"),
|
|
757
|
+
message_id=row.get("message_id"),
|
|
758
|
+
approvers=list(_decode(row.get("approvers")) or []),
|
|
759
|
+
payload=dict(_decode(row.get("payload")) or {}),
|
|
760
|
+
status=row["status"],
|
|
761
|
+
decided_by=row.get("decided_by"),
|
|
762
|
+
decided_at=_as_datetime(row.get("decided_at")),
|
|
763
|
+
comment=row.get("comment"),
|
|
764
|
+
used_at=_as_datetime(row.get("used_at")),
|
|
765
|
+
created_at=_as_datetime(row.get("created_at")) or utcnow(),
|
|
766
|
+
expires_at=_as_datetime(row.get("expires_at")) or utcnow(),
|
|
767
|
+
requester_actor=row.get("requester_actor") or "",
|
|
768
|
+
decide_with=(
|
|
769
|
+
row["decide_with"] if row.get("decide_with") in DECIDE_WITH_VALUES else DECIDE_DIRECT
|
|
770
|
+
),
|
|
771
|
+
relayers=[str(r) for r in _decode(row.get("relayers")) or []],
|
|
772
|
+
decided_via=row.get("decided_via"),
|
|
773
|
+
display_digest=row.get("display_digest"),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _row_of(record: ApprovalRecord) -> dict[str, Any]:
|
|
778
|
+
"""A record as the approvals file keeps it (the table's columns, times in ISO form)."""
|
|
779
|
+
row = asdict(record)
|
|
780
|
+
for key in _DATETIME_FIELDS:
|
|
781
|
+
row[key] = _iso(row[key])
|
|
782
|
+
return row
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
class ApprovalStore:
|
|
786
|
+
"""The approvals table under postgres, an in-process dict otherwise.
|
|
787
|
+
|
|
788
|
+
Also the ledger `api_client` marks approvals used in (`consume`). Times
|
|
789
|
+
are this process's clock (`clock`), for the expiry and the decision alike.
|
|
790
|
+
Without a database, `path` (`dev_ledger_path`, under `langgraph dev`) is
|
|
791
|
+
the file the records are kept in: call `load` once before use.
|
|
792
|
+
"""
|
|
793
|
+
|
|
794
|
+
def __init__(
|
|
795
|
+
self, db: Database, *, clock: Any = utcnow, path: str | Path | None = None
|
|
796
|
+
) -> None:
|
|
797
|
+
self.db = db
|
|
798
|
+
self.table = db.approvals_table
|
|
799
|
+
self._clock = clock
|
|
800
|
+
self._memory: OrderedDict[str, ApprovalRecord] = OrderedDict()
|
|
801
|
+
self.path = Path(path) if path is not None and not db.is_postgres else None
|
|
802
|
+
# The records, which graph runs may reach from other threads.
|
|
803
|
+
self._lock = threading.Lock()
|
|
804
|
+
# One file write at a time; a write older than the file's is skipped.
|
|
805
|
+
self._file_lock = threading.Lock()
|
|
806
|
+
self._changes = 0 # changes numbered as they are written out
|
|
807
|
+
self._saved = 0 # the newest change the file holds
|
|
808
|
+
self._failed = 0 # the newest change whose write failed
|
|
809
|
+
|
|
810
|
+
def now(self) -> datetime:
|
|
811
|
+
return self._clock()
|
|
812
|
+
|
|
813
|
+
# -- the file (langgraph dev) ------------------------------------------------
|
|
814
|
+
|
|
815
|
+
async def load(self) -> int:
|
|
816
|
+
"""Read the records the file keeps (none without a file); how many.
|
|
817
|
+
|
|
818
|
+
Raises `LedgerUnavailable` when the file exists but cannot be read:
|
|
819
|
+
the server must not start without the approvals that bind the tool
|
|
820
|
+
calls of its threads.
|
|
821
|
+
"""
|
|
822
|
+
if self.path is None:
|
|
823
|
+
return 0
|
|
824
|
+
records = await asyncio.to_thread(self._read)
|
|
825
|
+
with self._lock:
|
|
826
|
+
self._memory = OrderedDict((r.approval_id, r) for r in records)
|
|
827
|
+
logger.info("approvals kept in %s: %d loaded", self.path, len(records))
|
|
828
|
+
return len(records)
|
|
829
|
+
|
|
830
|
+
def _read(self) -> list[ApprovalRecord]:
|
|
831
|
+
assert self.path is not None
|
|
832
|
+
try:
|
|
833
|
+
raw = self.path.read_bytes()
|
|
834
|
+
except FileNotFoundError:
|
|
835
|
+
return []
|
|
836
|
+
except OSError as exc:
|
|
837
|
+
raise self._unreadable(exc) from exc
|
|
838
|
+
try:
|
|
839
|
+
data = json.loads(raw)
|
|
840
|
+
if not isinstance(data, dict) or data.get("version") not in LEDGER_FILE_VERSIONS:
|
|
841
|
+
raise ValueError(f"not a version {LEDGER_FILE_VERSION} approvals file")
|
|
842
|
+
return [_record_from_row(row) for row in data["approvals"]]
|
|
843
|
+
except Exception as exc:
|
|
844
|
+
raise self._unreadable(exc) from exc
|
|
845
|
+
|
|
846
|
+
def _unreadable(self, exc: BaseException) -> LedgerUnavailable:
|
|
847
|
+
return LedgerUnavailable(
|
|
848
|
+
f"the approvals file {self.path} cannot be read ({type(exc).__name__}). It binds "
|
|
849
|
+
"the tool calls of this server's threads to their approvals: without it, calls "
|
|
850
|
+
"rejected, pending or sent already could be sent again. Restore it, or delete "
|
|
851
|
+
f"{self.path.parent if self.path else DEV_STATE_DIR}/ to reset the dev server's "
|
|
852
|
+
"threads and approvals together."
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
def _dump(self) -> bytes:
|
|
856
|
+
"""The records as the file keeps them (call with `_lock` held)."""
|
|
857
|
+
rows = [_row_of(r) for r in self._memory.values()]
|
|
858
|
+
return json.dumps({"version": LEDGER_FILE_VERSION, "approvals": rows}, default=str).encode()
|
|
859
|
+
|
|
860
|
+
def _write(self, change: int, data: bytes) -> None:
|
|
861
|
+
"""Replace the file with `data` (atomically, mode 0600), unless it holds a newer change."""
|
|
862
|
+
assert self.path is not None
|
|
863
|
+
with self._file_lock:
|
|
864
|
+
if change <= self._saved:
|
|
865
|
+
return
|
|
866
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
867
|
+
partial = self.path.with_name(f".{self.path.name}.{os.getpid()}.partial")
|
|
868
|
+
try:
|
|
869
|
+
fd = os.open(partial, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
870
|
+
with os.fdopen(fd, "wb") as out:
|
|
871
|
+
out.write(data)
|
|
872
|
+
out.flush()
|
|
873
|
+
os.fsync(out.fileno())
|
|
874
|
+
os.replace(partial, self.path)
|
|
875
|
+
except BaseException:
|
|
876
|
+
with contextlib.suppress(OSError):
|
|
877
|
+
os.unlink(partial)
|
|
878
|
+
raise
|
|
879
|
+
self._saved = change
|
|
880
|
+
|
|
881
|
+
async def _save(self) -> None:
|
|
882
|
+
"""Write the records out, after a change and before it takes effect (no file: nothing).
|
|
883
|
+
|
|
884
|
+
A failed write raises `LedgerUnavailable`, and the store answers
|
|
885
|
+
nothing until a later write succeeds (`_check_saved`).
|
|
886
|
+
"""
|
|
887
|
+
if self.path is None:
|
|
888
|
+
return
|
|
889
|
+
with self._lock:
|
|
890
|
+
self._changes += 1
|
|
891
|
+
change = self._changes
|
|
892
|
+
data = self._dump()
|
|
893
|
+
try:
|
|
894
|
+
await asyncio.to_thread(self._write, change, data)
|
|
895
|
+
except Exception as exc:
|
|
896
|
+
self._failed = max(self._failed, change)
|
|
897
|
+
logger.error(
|
|
898
|
+
"the approvals file %s could not be written (%s): gated API calls and runs "
|
|
899
|
+
"on threads with approvals are refused until it is",
|
|
900
|
+
self.path,
|
|
901
|
+
type(exc).__name__,
|
|
902
|
+
)
|
|
903
|
+
raise LedgerUnavailable(
|
|
904
|
+
f"the approvals file could not be written ({type(exc).__name__})"
|
|
905
|
+
) from exc
|
|
906
|
+
|
|
907
|
+
async def _check_saved(self) -> None:
|
|
908
|
+
"""Before any use of the records: they are all in the file (a failed write is retried)."""
|
|
909
|
+
if self.path is not None and self._saved < self._failed:
|
|
910
|
+
await self._save()
|
|
911
|
+
|
|
912
|
+
# -- writes -----------------------------------------------------------------
|
|
913
|
+
|
|
914
|
+
async def add(self, record: ApprovalRecord) -> tuple[ApprovalRecord, bool, int]:
|
|
915
|
+
"""Record a pending approval: `(record, created, superseded)`.
|
|
916
|
+
|
|
917
|
+
A tool asked again for the same interrupt (the run resumed for another
|
|
918
|
+
approval, so this tool call ran again) keeps the pending approval of the
|
|
919
|
+
same request instead of a second one (`created` False). A pending
|
|
920
|
+
approval of that interrupt for a different request, or past its expiry,
|
|
921
|
+
is marked expired (`superseded` counts them).
|
|
922
|
+
"""
|
|
923
|
+
now = self.now()
|
|
924
|
+
if not self.db.is_postgres:
|
|
925
|
+
await self._check_saved()
|
|
926
|
+
with self._lock:
|
|
927
|
+
superseded = 0
|
|
928
|
+
kept = None
|
|
929
|
+
for existing in list(self._memory.values()):
|
|
930
|
+
if (
|
|
931
|
+
existing.thread_id != record.thread_id
|
|
932
|
+
or existing.interrupt_id != record.interrupt_id
|
|
933
|
+
or existing.status != PENDING
|
|
934
|
+
):
|
|
935
|
+
continue
|
|
936
|
+
if existing.call_hash == record.call_hash and existing.expires_at > now:
|
|
937
|
+
kept = existing
|
|
938
|
+
break
|
|
939
|
+
self._close(existing, EXPIRED, now)
|
|
940
|
+
superseded += 1
|
|
941
|
+
if kept is None:
|
|
942
|
+
self._memory[record.approval_id] = record
|
|
943
|
+
self._evict()
|
|
944
|
+
if kept is not None and not superseded:
|
|
945
|
+
return kept, False, 0
|
|
946
|
+
await self._save()
|
|
947
|
+
return (kept, False, superseded) if kept is not None else (record, True, superseded)
|
|
948
|
+
rows = await self.db.fetchall(
|
|
949
|
+
f"""
|
|
950
|
+
UPDATE {self.table}
|
|
951
|
+
SET status = %s, decided_at = %s, payload = {_CLEARED_PAYLOAD}
|
|
952
|
+
WHERE thread_id = %s AND interrupt_id = %s AND status = %s
|
|
953
|
+
AND (call_hash <> %s OR expires_at <= %s)
|
|
954
|
+
RETURNING approval_id
|
|
955
|
+
""",
|
|
956
|
+
(
|
|
957
|
+
EXPIRED,
|
|
958
|
+
now,
|
|
959
|
+
capture_full(),
|
|
960
|
+
record.thread_id,
|
|
961
|
+
record.interrupt_id,
|
|
962
|
+
PENDING,
|
|
963
|
+
record.call_hash,
|
|
964
|
+
now,
|
|
965
|
+
),
|
|
966
|
+
)
|
|
967
|
+
existing = await self.db.fetchone(
|
|
968
|
+
f"""
|
|
969
|
+
SELECT {_COLUMNS} FROM {self.table}
|
|
970
|
+
WHERE thread_id = %s AND interrupt_id = %s AND status = %s AND call_hash = %s
|
|
971
|
+
AND expires_at > %s
|
|
972
|
+
ORDER BY created_at DESC LIMIT 1
|
|
973
|
+
""",
|
|
974
|
+
(record.thread_id, record.interrupt_id, PENDING, record.call_hash, now),
|
|
975
|
+
)
|
|
976
|
+
if existing is not None:
|
|
977
|
+
return _record_from_row(existing), False, len(rows)
|
|
978
|
+
await self.db.execute(
|
|
979
|
+
f"""
|
|
980
|
+
INSERT INTO {self.table} ({_COLUMNS})
|
|
981
|
+
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s::jsonb,
|
|
982
|
+
%s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s)
|
|
983
|
+
""",
|
|
984
|
+
(
|
|
985
|
+
record.approval_id,
|
|
986
|
+
record.thread_id,
|
|
987
|
+
record.run_id,
|
|
988
|
+
record.interrupt_id,
|
|
989
|
+
record.requester_hash,
|
|
990
|
+
_json(record.requester_context),
|
|
991
|
+
record.api,
|
|
992
|
+
record.method,
|
|
993
|
+
record.path,
|
|
994
|
+
record.operation_id,
|
|
995
|
+
record.call_hash,
|
|
996
|
+
record.tool_call_id,
|
|
997
|
+
record.message_id,
|
|
998
|
+
_json(record.approvers),
|
|
999
|
+
_json(record.payload),
|
|
1000
|
+
record.status,
|
|
1001
|
+
record.decided_by,
|
|
1002
|
+
record.decided_at,
|
|
1003
|
+
record.comment,
|
|
1004
|
+
record.used_at,
|
|
1005
|
+
record.created_at,
|
|
1006
|
+
record.expires_at,
|
|
1007
|
+
record.requester_actor,
|
|
1008
|
+
record.decide_with,
|
|
1009
|
+
_json(record.relayers),
|
|
1010
|
+
record.decided_via,
|
|
1011
|
+
record.display_digest,
|
|
1012
|
+
),
|
|
1013
|
+
)
|
|
1014
|
+
return record, True, len(rows)
|
|
1015
|
+
|
|
1016
|
+
async def decide(
|
|
1017
|
+
self,
|
|
1018
|
+
approval_id: str,
|
|
1019
|
+
status: str,
|
|
1020
|
+
decided_by: str,
|
|
1021
|
+
comment: str | None,
|
|
1022
|
+
decided_via: str | None = None,
|
|
1023
|
+
) -> ApprovalRecord | None:
|
|
1024
|
+
"""Decide a pending, unexpired approval (`approved` or `rejected`), atomically.
|
|
1025
|
+
|
|
1026
|
+
None when it is not pending any more (decided, expired, gone): of two
|
|
1027
|
+
concurrent decisions exactly one gets the record. `decided_via` is the
|
|
1028
|
+
agent that relayed the decision (None: decided directly).
|
|
1029
|
+
"""
|
|
1030
|
+
if status not in (APPROVED, REJECTED):
|
|
1031
|
+
raise ValueError(f"not a decision: {status!r}")
|
|
1032
|
+
now = self.now()
|
|
1033
|
+
comment = (comment or "").strip()[:COMMENT_MAX_CHARS] or None
|
|
1034
|
+
if not self.db.is_postgres:
|
|
1035
|
+
await self._check_saved()
|
|
1036
|
+
with self._lock:
|
|
1037
|
+
record = self._memory.get(approval_id)
|
|
1038
|
+
if record is None or not record.is_pending(now):
|
|
1039
|
+
return None
|
|
1040
|
+
self._close(record, status, now, decided_by=decided_by, comment=comment)
|
|
1041
|
+
record.decided_via = decided_via
|
|
1042
|
+
await self._save()
|
|
1043
|
+
return record
|
|
1044
|
+
row = await self.db.fetchone(
|
|
1045
|
+
f"""
|
|
1046
|
+
UPDATE {self.table}
|
|
1047
|
+
SET status = %s, decided_by = %s, decided_at = %s, comment = %s,
|
|
1048
|
+
decided_via = %s, payload = {_CLEARED_PAYLOAD}
|
|
1049
|
+
WHERE approval_id = %s AND status = %s AND expires_at > %s
|
|
1050
|
+
RETURNING {_COLUMNS}
|
|
1051
|
+
""",
|
|
1052
|
+
(
|
|
1053
|
+
status,
|
|
1054
|
+
decided_by,
|
|
1055
|
+
now,
|
|
1056
|
+
comment,
|
|
1057
|
+
decided_via,
|
|
1058
|
+
capture_full(),
|
|
1059
|
+
approval_id,
|
|
1060
|
+
PENDING,
|
|
1061
|
+
now,
|
|
1062
|
+
),
|
|
1063
|
+
)
|
|
1064
|
+
return _record_from_row(row) if row is not None else None
|
|
1065
|
+
|
|
1066
|
+
async def consume(
|
|
1067
|
+
self, approval_id: str, call_hash: str, thread_id: str | None = None
|
|
1068
|
+
) -> str | None:
|
|
1069
|
+
"""The ledger: mark an approved approval used, once. None when it may be sent now.
|
|
1070
|
+
|
|
1071
|
+
It must be approved, for exactly the request `call_hash`, on thread
|
|
1072
|
+
`thread_id` (when given), and never used before; else the reason.
|
|
1073
|
+
"""
|
|
1074
|
+
now = self.now()
|
|
1075
|
+
if not self.db.is_postgres:
|
|
1076
|
+
await self._check_saved()
|
|
1077
|
+
with self._lock:
|
|
1078
|
+
record = self._memory.get(approval_id)
|
|
1079
|
+
problem = self._unusable(record, call_hash, thread_id)
|
|
1080
|
+
if problem is None and record is not None:
|
|
1081
|
+
record.used_at = now
|
|
1082
|
+
if problem is None:
|
|
1083
|
+
await self._save() # before the call is sent
|
|
1084
|
+
return problem
|
|
1085
|
+
row = await self.db.fetchone(
|
|
1086
|
+
f"""
|
|
1087
|
+
UPDATE {self.table} SET used_at = %s
|
|
1088
|
+
WHERE approval_id = %s AND status = %s AND used_at IS NULL AND call_hash = %s
|
|
1089
|
+
AND (%s::text IS NULL OR thread_id = %s::text)
|
|
1090
|
+
RETURNING approval_id
|
|
1091
|
+
""",
|
|
1092
|
+
(now, approval_id, APPROVED, call_hash, thread_id, thread_id),
|
|
1093
|
+
)
|
|
1094
|
+
if row is not None:
|
|
1095
|
+
return None
|
|
1096
|
+
return self._unusable(await self.get(approval_id), call_hash, thread_id) or (
|
|
1097
|
+
"the approval is not usable"
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
@staticmethod
|
|
1101
|
+
def _unusable(
|
|
1102
|
+
record: ApprovalRecord | None, call_hash: str, thread_id: str | None
|
|
1103
|
+
) -> str | None:
|
|
1104
|
+
if record is None:
|
|
1105
|
+
return "no such approval"
|
|
1106
|
+
if thread_id is not None and record.thread_id != thread_id:
|
|
1107
|
+
return "the approval belongs to another thread"
|
|
1108
|
+
if record.status != APPROVED:
|
|
1109
|
+
return f"the approval is {record.effective_status()}, not approved"
|
|
1110
|
+
if record.call_hash != call_hash:
|
|
1111
|
+
return "the approval is for a different request"
|
|
1112
|
+
if record.used_at is not None:
|
|
1113
|
+
return "the approval was already used"
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
async def expire(self, approval_id: str) -> ApprovalRecord | None:
|
|
1117
|
+
"""Mark one pending approval `expired` (its run no longer waits for it); None if not pending."""
|
|
1118
|
+
now = self.now()
|
|
1119
|
+
if not self.db.is_postgres:
|
|
1120
|
+
await self._check_saved()
|
|
1121
|
+
with self._lock:
|
|
1122
|
+
record = self._memory.get(approval_id)
|
|
1123
|
+
if record is None or record.status != PENDING:
|
|
1124
|
+
return None
|
|
1125
|
+
self._close(record, EXPIRED, now)
|
|
1126
|
+
await self._save()
|
|
1127
|
+
return record
|
|
1128
|
+
row = await self.db.fetchone(
|
|
1129
|
+
f"""
|
|
1130
|
+
UPDATE {self.table}
|
|
1131
|
+
SET status = %s, decided_at = %s, payload = {_CLEARED_PAYLOAD}
|
|
1132
|
+
WHERE approval_id = %s AND status = %s
|
|
1133
|
+
RETURNING {_COLUMNS}
|
|
1134
|
+
""",
|
|
1135
|
+
(EXPIRED, now, capture_full(), approval_id, PENDING),
|
|
1136
|
+
)
|
|
1137
|
+
return _record_from_row(row) if row is not None else None
|
|
1138
|
+
|
|
1139
|
+
async def expire_due(self) -> list[ApprovalRecord]:
|
|
1140
|
+
"""Mark every pending approval past its expiry `expired`; return them (the sweep).
|
|
1141
|
+
|
|
1142
|
+
Also retries a failed write of the approvals file.
|
|
1143
|
+
"""
|
|
1144
|
+
now = self.now()
|
|
1145
|
+
if not self.db.is_postgres:
|
|
1146
|
+
await self._check_saved()
|
|
1147
|
+
with self._lock:
|
|
1148
|
+
due = [
|
|
1149
|
+
r for r in self._memory.values() if r.status == PENDING and r.expires_at <= now
|
|
1150
|
+
]
|
|
1151
|
+
for record in due:
|
|
1152
|
+
self._close(record, EXPIRED, now)
|
|
1153
|
+
if due:
|
|
1154
|
+
await self._save()
|
|
1155
|
+
return due
|
|
1156
|
+
rows = await self.db.fetchall(
|
|
1157
|
+
f"""
|
|
1158
|
+
UPDATE {self.table}
|
|
1159
|
+
SET status = %s, decided_at = %s, payload = {_CLEARED_PAYLOAD}
|
|
1160
|
+
WHERE status = %s AND expires_at <= %s
|
|
1161
|
+
RETURNING {_COLUMNS}
|
|
1162
|
+
""",
|
|
1163
|
+
(EXPIRED, now, capture_full(), PENDING, now),
|
|
1164
|
+
)
|
|
1165
|
+
return [_record_from_row(r) for r in rows]
|
|
1166
|
+
|
|
1167
|
+
async def delete_for_thread(self, thread_id: str) -> int:
|
|
1168
|
+
"""Delete every approval of a thread (the thread was deleted); how many."""
|
|
1169
|
+
if not self.db.is_postgres:
|
|
1170
|
+
await self._check_saved()
|
|
1171
|
+
with self._lock:
|
|
1172
|
+
gone = [k for k, r in self._memory.items() if r.thread_id == thread_id]
|
|
1173
|
+
for key in gone:
|
|
1174
|
+
del self._memory[key]
|
|
1175
|
+
if gone:
|
|
1176
|
+
await self._save()
|
|
1177
|
+
return len(gone)
|
|
1178
|
+
rows = await self.db.fetchall(
|
|
1179
|
+
f"DELETE FROM {self.table} WHERE thread_id = %s RETURNING approval_id", (thread_id,)
|
|
1180
|
+
)
|
|
1181
|
+
return len(rows)
|
|
1182
|
+
|
|
1183
|
+
# -- reads ------------------------------------------------------------------
|
|
1184
|
+
|
|
1185
|
+
async def _records(self) -> list[ApprovalRecord]:
|
|
1186
|
+
"""The records kept without a database, in the order they were added."""
|
|
1187
|
+
await self._check_saved()
|
|
1188
|
+
with self._lock:
|
|
1189
|
+
return list(self._memory.values())
|
|
1190
|
+
|
|
1191
|
+
async def bound_approvals(
|
|
1192
|
+
self, *, tool_call: tuple[str, str] | None = None, interrupt_id: str | None = None
|
|
1193
|
+
) -> list[BoundApproval]:
|
|
1194
|
+
"""The ledger's answer for a tool call that runs again without a decision.
|
|
1195
|
+
|
|
1196
|
+
Every approval asked by the tool call `(message id, tool call id)` or by
|
|
1197
|
+
the interrupt `interrupt_id`, newest first, on any thread: a copy of a
|
|
1198
|
+
thread keeps its messages, so a tool call there is the same tool call
|
|
1199
|
+
(message ids are unique, so another thread's tool call never matches).
|
|
1200
|
+
`api_client` refuses the call when one of them was asked for it.
|
|
1201
|
+
"""
|
|
1202
|
+
now = self.now()
|
|
1203
|
+
message_id, call_id = tool_call or (None, None)
|
|
1204
|
+
if not self.db.is_postgres:
|
|
1205
|
+
records = [
|
|
1206
|
+
r
|
|
1207
|
+
for r in await self._records()
|
|
1208
|
+
if (message_id and r.message_id == message_id and r.tool_call_id == call_id)
|
|
1209
|
+
or (interrupt_id and r.interrupt_id == interrupt_id)
|
|
1210
|
+
]
|
|
1211
|
+
else:
|
|
1212
|
+
rows = await self.db.fetchall(
|
|
1213
|
+
f"""
|
|
1214
|
+
SELECT {_COLUMNS} FROM {self.table}
|
|
1215
|
+
WHERE (message_id = %s::text AND tool_call_id = %s::text)
|
|
1216
|
+
OR interrupt_id = %s::text
|
|
1217
|
+
""",
|
|
1218
|
+
(message_id, call_id, interrupt_id),
|
|
1219
|
+
)
|
|
1220
|
+
records = [_record_from_row(r) for r in rows]
|
|
1221
|
+
records.sort(key=lambda r: r.created_at, reverse=True)
|
|
1222
|
+
return [
|
|
1223
|
+
BoundApproval(
|
|
1224
|
+
api=r.api,
|
|
1225
|
+
method=r.method,
|
|
1226
|
+
path=r.path,
|
|
1227
|
+
status=r.effective_status(now),
|
|
1228
|
+
used=r.used_at is not None,
|
|
1229
|
+
rpc_method=r.rpc().get(RPC_METHOD_KEY),
|
|
1230
|
+
a2a_operation=r.rpc().get(A2A_OPERATION_KEY),
|
|
1231
|
+
)
|
|
1232
|
+
for r in records
|
|
1233
|
+
]
|
|
1234
|
+
|
|
1235
|
+
async def get(self, approval_id: str) -> ApprovalRecord | None:
|
|
1236
|
+
if not self.db.is_postgres:
|
|
1237
|
+
await self._check_saved()
|
|
1238
|
+
with self._lock:
|
|
1239
|
+
return self._memory.get(approval_id)
|
|
1240
|
+
row = await self.db.fetchone(
|
|
1241
|
+
f"SELECT {_COLUMNS} FROM {self.table} WHERE approval_id = %s", (approval_id,)
|
|
1242
|
+
)
|
|
1243
|
+
return _record_from_row(row) if row is not None else None
|
|
1244
|
+
|
|
1245
|
+
async def for_thread(self, thread_id: str) -> list[ApprovalRecord]:
|
|
1246
|
+
"""Every approval of a thread, newest first."""
|
|
1247
|
+
if not self.db.is_postgres:
|
|
1248
|
+
return sorted(
|
|
1249
|
+
(r for r in await self._records() if r.thread_id == thread_id),
|
|
1250
|
+
key=lambda r: r.created_at,
|
|
1251
|
+
reverse=True,
|
|
1252
|
+
)
|
|
1253
|
+
rows = await self.db.fetchall(
|
|
1254
|
+
f"SELECT {_COLUMNS} FROM {self.table} WHERE thread_id = %s "
|
|
1255
|
+
"ORDER BY created_at DESC, approval_id",
|
|
1256
|
+
(thread_id,),
|
|
1257
|
+
)
|
|
1258
|
+
return [_record_from_row(r) for r in rows]
|
|
1259
|
+
|
|
1260
|
+
async def pending_for_thread(self, thread_id: str) -> list[ApprovalRecord]:
|
|
1261
|
+
"""The thread's pending approvals that have not expired, oldest first."""
|
|
1262
|
+
now = self.now()
|
|
1263
|
+
return sorted(
|
|
1264
|
+
(r for r in await self.for_thread(thread_id) if r.is_pending(now)),
|
|
1265
|
+
key=lambda r: r.created_at,
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
async def visible(
|
|
1269
|
+
self,
|
|
1270
|
+
principal: Principal,
|
|
1271
|
+
*,
|
|
1272
|
+
status: str | None = None,
|
|
1273
|
+
limit: int = 50,
|
|
1274
|
+
offset: int = 0,
|
|
1275
|
+
) -> list[ApprovalRecord]:
|
|
1276
|
+
"""Approvals the caller may see across threads, newest first (`GET /approvals`).
|
|
1277
|
+
|
|
1278
|
+
Their own (requested on their threads), the ones a role of theirs may
|
|
1279
|
+
decide, and every one for a read-across role. A delegated principal
|
|
1280
|
+
sees only the ones requested under its own subject and actor. `status`
|
|
1281
|
+
filters (a pending approval past its expiry counts as `expired`).
|
|
1282
|
+
"""
|
|
1283
|
+
now = self.now()
|
|
1284
|
+
own = principal.hashed_id()
|
|
1285
|
+
actor = principal.actor.id if principal.actor is not None else None
|
|
1286
|
+
across = reads_across(principal)
|
|
1287
|
+
# A delegated principal's roles never make it an approver.
|
|
1288
|
+
roles = (
|
|
1289
|
+
set() if actor is not None else {f"{ROLE_APPROVER_PREFIX}{r}" for r in principal.roles}
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
def mine(r: ApprovalRecord) -> bool:
|
|
1293
|
+
return r.requester_hash == own and (actor is None or r.requester_actor == actor)
|
|
1294
|
+
|
|
1295
|
+
if not self.db.is_postgres:
|
|
1296
|
+
rows = [
|
|
1297
|
+
r
|
|
1298
|
+
for r in sorted(await self._records(), key=lambda r: r.created_at, reverse=True)
|
|
1299
|
+
if (across or mine(r) or roles & set(r.approvers))
|
|
1300
|
+
and (status is None or r.effective_status(now) == status)
|
|
1301
|
+
]
|
|
1302
|
+
return rows[offset : offset + limit]
|
|
1303
|
+
conditions = [
|
|
1304
|
+
"(%s OR (requester_hash = %s AND (%s::text IS NULL OR requester_actor = %s::text)) "
|
|
1305
|
+
"OR approvers ?| %s::text[])"
|
|
1306
|
+
]
|
|
1307
|
+
params: list[Any] = [across, own, actor, actor, sorted(roles)]
|
|
1308
|
+
if status == PENDING:
|
|
1309
|
+
conditions.append("status = %s AND expires_at > %s")
|
|
1310
|
+
params += [PENDING, now]
|
|
1311
|
+
elif status == EXPIRED:
|
|
1312
|
+
conditions.append("(status = %s OR (status = %s AND expires_at <= %s))")
|
|
1313
|
+
params += [EXPIRED, PENDING, now]
|
|
1314
|
+
elif status is not None:
|
|
1315
|
+
conditions.append("status = %s")
|
|
1316
|
+
params.append(status)
|
|
1317
|
+
rows = await self.db.fetchall(
|
|
1318
|
+
f"SELECT {_COLUMNS} FROM {self.table} WHERE {' AND '.join(conditions)} "
|
|
1319
|
+
"ORDER BY created_at DESC, approval_id LIMIT %s OFFSET %s",
|
|
1320
|
+
(*params, limit, offset),
|
|
1321
|
+
)
|
|
1322
|
+
return [_record_from_row(r) for r in rows]
|
|
1323
|
+
|
|
1324
|
+
# -- memory -----------------------------------------------------------------
|
|
1325
|
+
|
|
1326
|
+
@staticmethod
|
|
1327
|
+
def _close(
|
|
1328
|
+
record: ApprovalRecord,
|
|
1329
|
+
status: str,
|
|
1330
|
+
now: datetime,
|
|
1331
|
+
*,
|
|
1332
|
+
decided_by: str | None = None,
|
|
1333
|
+
comment: str | None = None,
|
|
1334
|
+
) -> None:
|
|
1335
|
+
record.status = status
|
|
1336
|
+
record.decided_at = now
|
|
1337
|
+
if decided_by is not None:
|
|
1338
|
+
record.decided_by = decided_by
|
|
1339
|
+
if comment is not None:
|
|
1340
|
+
record.comment = comment
|
|
1341
|
+
record.payload = _without_call(record.payload)
|
|
1342
|
+
|
|
1343
|
+
def _evict(self) -> None:
|
|
1344
|
+
while len(self._memory) > MEMORY_APPROVALS_CAP:
|
|
1345
|
+
victim = next(
|
|
1346
|
+
(k for k, r in self._memory.items() if r.status != PENDING),
|
|
1347
|
+
next(iter(self._memory)),
|
|
1348
|
+
)
|
|
1349
|
+
del self._memory[victim]
|