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,794 @@
|
|
|
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 API calls under the langgraph-server runtime, end to end.
|
|
16
|
+
|
|
17
|
+
A real LangGraph dev server (`langgraph dev`, a dev dependency of every
|
|
18
|
+
project) serves this app as its custom app, the app's auth handler, and the
|
|
19
|
+
graph of `approval_graph.py`: the agent with one tool whose call the test
|
|
20
|
+
policy gates. The upstream API is a local HTTP server that records every
|
|
21
|
+
request. Principals are JWTs signed with a key made here (`AUTH_POLICY=jwt`).
|
|
22
|
+
The run pauses and resumes through the server's own interrupt and resume.
|
|
23
|
+
Skipped when the LangGraph CLI and its in-memory server are not installed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import importlib.util
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import signal
|
|
32
|
+
import socket
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
from contextlib import contextmanager
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
import httpx
|
|
46
|
+
import jwt
|
|
47
|
+
import pytest
|
|
48
|
+
from cryptography.hazmat.primitives import serialization
|
|
49
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
50
|
+
|
|
51
|
+
PROJECT = Path(__file__).resolve().parents[2]
|
|
52
|
+
AGENT_DIR = PROJECT / "{{cookiecutter.agent_directory}}"
|
|
53
|
+
LANGGRAPH = Path(sys.executable).with_name("langgraph")
|
|
54
|
+
pytestmark = pytest.mark.skipif(
|
|
55
|
+
not LANGGRAPH.exists() or importlib.util.find_spec("langgraph_api") is None,
|
|
56
|
+
reason="needs the LangGraph CLI with its in-memory server (langgraph-cli[inmem])",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
ISSUER = "https://issuer.test/"
|
|
60
|
+
AUDIENCE = "agent-api"
|
|
61
|
+
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
62
|
+
A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
|
|
63
|
+
TIMEOUT_S = 30 # the policy's approval.timeout_s (its minimum)
|
|
64
|
+
BODY = {"reason": "customer asked"}
|
|
65
|
+
|
|
66
|
+
POLICY = """
|
|
67
|
+
apis:
|
|
68
|
+
shop:
|
|
69
|
+
base_url_env: SHOP_API_BASE_URL
|
|
70
|
+
auth: none
|
|
71
|
+
allowed_methods: [GET, POST]
|
|
72
|
+
approval:
|
|
73
|
+
required_for:
|
|
74
|
+
operations:
|
|
75
|
+
- operationId: cancelOrder
|
|
76
|
+
path: /orders/{order_id}/cancel
|
|
77
|
+
methods: [POST]
|
|
78
|
+
approvers: [requester, "role:ops"]
|
|
79
|
+
timeout_s: TIMEOUT
|
|
80
|
+
""".replace("TIMEOUT", str(TIMEOUT_S))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def token(user: str, *roles: str) -> dict[str, str]:
|
|
84
|
+
now = int(time.time())
|
|
85
|
+
claims = {
|
|
86
|
+
"sub": user,
|
|
87
|
+
"iss": ISSUER,
|
|
88
|
+
"aud": AUDIENCE,
|
|
89
|
+
"iat": now,
|
|
90
|
+
"nbf": now,
|
|
91
|
+
"exp": now + 600,
|
|
92
|
+
"roles": list(roles or ("user",)),
|
|
93
|
+
}
|
|
94
|
+
return {"Authorization": f"Bearer {jwt.encode(claims, KEY, algorithm='RS256')}"}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Upstream(ThreadingHTTPServer):
|
|
98
|
+
"""The API the tool calls: records every request, answers 200."""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self.received: list[tuple[str, str, Any]] = []
|
|
102
|
+
self.lock = threading.Lock()
|
|
103
|
+
upstream = self
|
|
104
|
+
|
|
105
|
+
class Handler(BaseHTTPRequestHandler):
|
|
106
|
+
def do_PATCH(self) -> None:
|
|
107
|
+
self.do_POST()
|
|
108
|
+
|
|
109
|
+
def do_POST(self) -> None:
|
|
110
|
+
length = int(self.headers.get("content-length") or 0)
|
|
111
|
+
body = json.loads(self.rfile.read(length) or b"null")
|
|
112
|
+
with upstream.lock:
|
|
113
|
+
upstream.received.append((self.command, self.path, body))
|
|
114
|
+
payload = json.dumps({"cancelled": self.path}).encode()
|
|
115
|
+
self.send_response(200)
|
|
116
|
+
self.send_header("content-type", "application/json")
|
|
117
|
+
self.send_header("content-length", str(len(payload)))
|
|
118
|
+
self.end_headers()
|
|
119
|
+
self.wfile.write(payload)
|
|
120
|
+
|
|
121
|
+
def log_message(self, *args: Any) -> None:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
super().__init__(("127.0.0.1", 0), Handler)
|
|
125
|
+
|
|
126
|
+
def sent_to(self, path: str) -> list[Any]:
|
|
127
|
+
with self.lock:
|
|
128
|
+
return [body for _, p, body in self.received if p == path]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class Server:
|
|
133
|
+
url: str
|
|
134
|
+
upstream: Upstream
|
|
135
|
+
body_file: Path
|
|
136
|
+
log: Path
|
|
137
|
+
policy: Path
|
|
138
|
+
expiring: dict[str, Any] = field(default_factory=dict)
|
|
139
|
+
expiring_native: dict[str, Any] = field(default_factory=dict)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _free_port() -> int:
|
|
143
|
+
with socket.socket() as sock:
|
|
144
|
+
sock.bind(("127.0.0.1", 0))
|
|
145
|
+
return int(sock.getsockname()[1])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_sse(text: str) -> list[tuple[str, dict[str, Any]]]:
|
|
149
|
+
events: list[tuple[str, dict[str, Any]]] = []
|
|
150
|
+
event = None
|
|
151
|
+
for line in text.splitlines():
|
|
152
|
+
if line.startswith("event:"):
|
|
153
|
+
event = line[6:].strip()
|
|
154
|
+
elif line.startswith("data:") and event:
|
|
155
|
+
events.append((event, json.loads(line[5:].strip())))
|
|
156
|
+
event = None
|
|
157
|
+
return events
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def post(
|
|
161
|
+
server: Server, path: str, body: dict[str, Any], headers: dict[str, str]
|
|
162
|
+
) -> httpx.Response:
|
|
163
|
+
return httpx.post(f"{server.url}{path}", json=body, headers=headers, timeout=60)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def pause(server: Server, order: str, user: str = "alice") -> dict[str, Any]:
|
|
167
|
+
"""A run on a new thread that pauses before the gated call; its `message.end`."""
|
|
168
|
+
return pause_on(server, f"Cancel the order for {order}", user)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pause_on(server: Server, message: str, user: str = "alice") -> dict[str, Any]:
|
|
172
|
+
"""A run on a new thread for `message` that pauses before a gated call; its `message.end`."""
|
|
173
|
+
r = post(server, "/chat", {"message": message}, token(user))
|
|
174
|
+
assert r.status_code == 200, r.text
|
|
175
|
+
events = parse_sse(r.text)
|
|
176
|
+
assert events[-1][0] == "message.end", events
|
|
177
|
+
end = events[-1][1]
|
|
178
|
+
assert end["status"] == "awaiting_approval", end
|
|
179
|
+
return end
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def decide(
|
|
183
|
+
server: Server, end: dict[str, Any], decision: str, headers: dict[str, str]
|
|
184
|
+
) -> httpx.Response:
|
|
185
|
+
approval = end["approval"]
|
|
186
|
+
return post(
|
|
187
|
+
server,
|
|
188
|
+
f"/threads/{end['thread_id']}/approvals/{approval['approval_id']}",
|
|
189
|
+
{"decision": decision},
|
|
190
|
+
headers,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _write_config(work: Path) -> Path:
|
|
195
|
+
"""The `langgraph.json` a test server runs with: this project, the test graph."""
|
|
196
|
+
project_config = json.loads((PROJECT / "langgraph.json").read_text(encoding="utf-8"))
|
|
197
|
+
config = work / "langgraph.json"
|
|
198
|
+
config.write_text(
|
|
199
|
+
json.dumps(
|
|
200
|
+
{
|
|
201
|
+
"dependencies": [str(PROJECT)],
|
|
202
|
+
"graphs": {"agent": f"{PROJECT / 'tests/integration/approval_graph.py'}:graph"},
|
|
203
|
+
"http": {"app": f"{AGENT_DIR / 'fast_api_app.py'}:app"},
|
|
204
|
+
"auth": {"path": f"{AGENT_DIR / 'app_utils/auth.py'}:auth"},
|
|
205
|
+
"python_version": project_config.get("python_version", "3.12"),
|
|
206
|
+
}
|
|
207
|
+
),
|
|
208
|
+
encoding="utf-8",
|
|
209
|
+
)
|
|
210
|
+
return config
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _server_env(policy: Path, body_file: Path, upstream: Upstream) -> dict[str, str]:
|
|
214
|
+
pem = (
|
|
215
|
+
KEY.public_key()
|
|
216
|
+
.public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
|
|
217
|
+
.decode()
|
|
218
|
+
)
|
|
219
|
+
return {
|
|
220
|
+
**os.environ,
|
|
221
|
+
"MODEL_PROVIDER": "fake",
|
|
222
|
+
"MODEL_NAME": "fake",
|
|
223
|
+
"AUTH_POLICY": "jwt",
|
|
224
|
+
"AUTH_JWT_PUBLIC_KEY": pem,
|
|
225
|
+
"AUTH_JWT_ISSUER": ISSUER,
|
|
226
|
+
"AUTH_JWT_AUDIENCE": AUDIENCE,
|
|
227
|
+
"APP_ENV": "dev",
|
|
228
|
+
"RUNTIME": "langgraph-server",
|
|
229
|
+
"API_POLICY_PATH": str(policy),
|
|
230
|
+
"SHOP_API_BASE_URL": f"http://127.0.0.1:{upstream.server_address[1]}",
|
|
231
|
+
"TEST_APPROVAL_BODY_FILE": str(body_file),
|
|
232
|
+
"TRACING_ENABLED": "false",
|
|
233
|
+
"LANGSMITH_TRACING": "false",
|
|
234
|
+
"LANGGRAPH_CLI_NO_ANALYTICS": "1",
|
|
235
|
+
"PYTHON_DOTENV_DISABLED": "1",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _start_dev(
|
|
240
|
+
work: Path, env: dict[str, str], port: int, log: Path, *, reload: bool = False
|
|
241
|
+
) -> subprocess.Popen[bytes]:
|
|
242
|
+
"""`langgraph dev` in `work` (where it keeps its threads, in `.langgraph_api/`), once up.
|
|
243
|
+
|
|
244
|
+
With `reload`, a change to a `.py` file under `work` restarts it (a hot reload).
|
|
245
|
+
"""
|
|
246
|
+
args = [str(LANGGRAPH), "dev", "--config", str(work / "langgraph.json")]
|
|
247
|
+
args += ["--port", str(port), "--no-browser"] + ([] if reload else ["--no-reload"])
|
|
248
|
+
with log.open("ab") as out:
|
|
249
|
+
proc = subprocess.Popen(
|
|
250
|
+
args, cwd=work, env=env, stdout=out, stderr=subprocess.STDOUT, start_new_session=True
|
|
251
|
+
)
|
|
252
|
+
try:
|
|
253
|
+
_wait_up(proc, f"http://127.0.0.1:{port}", log)
|
|
254
|
+
except BaseException:
|
|
255
|
+
_stop(proc)
|
|
256
|
+
raise
|
|
257
|
+
return proc
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _wait_up(proc: subprocess.Popen[bytes], url: str, log: Path, timeout_s: float = 120) -> None:
|
|
261
|
+
deadline = time.monotonic() + timeout_s
|
|
262
|
+
while True:
|
|
263
|
+
if proc.poll() is not None:
|
|
264
|
+
pytest.fail(f"langgraph dev exited:\n{log.read_text()[-4000:]}")
|
|
265
|
+
try:
|
|
266
|
+
if httpx.get(f"{url}/health", timeout=2).status_code == 200:
|
|
267
|
+
return
|
|
268
|
+
except httpx.HTTPError:
|
|
269
|
+
pass
|
|
270
|
+
if time.monotonic() > deadline:
|
|
271
|
+
pytest.fail(f"langgraph dev did not start:\n{log.read_text()[-4000:]}")
|
|
272
|
+
time.sleep(0.5)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@pytest.fixture(scope="module")
|
|
276
|
+
def server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Server]:
|
|
277
|
+
work = tmp_path_factory.mktemp("approvals-server")
|
|
278
|
+
upstream = Upstream()
|
|
279
|
+
threading.Thread(target=upstream.serve_forever, daemon=True).start()
|
|
280
|
+
body_file = work / "body.json"
|
|
281
|
+
body_file.write_text(json.dumps(BODY), encoding="utf-8")
|
|
282
|
+
policy = work / "api-policy.yaml"
|
|
283
|
+
policy.write_text(POLICY, encoding="utf-8")
|
|
284
|
+
_write_config(work)
|
|
285
|
+
port = _free_port()
|
|
286
|
+
log = work / "server.log"
|
|
287
|
+
proc = None
|
|
288
|
+
try:
|
|
289
|
+
proc = _start_dev(work, _server_env(policy, body_file, upstream), port, log)
|
|
290
|
+
url = f"http://127.0.0.1:{port}"
|
|
291
|
+
running = Server(url=url, upstream=upstream, body_file=body_file, log=log, policy=policy)
|
|
292
|
+
# Paused first, so they have expired by the time the last tests use them.
|
|
293
|
+
running.expiring = pause(running, "90")
|
|
294
|
+
running.expiring_native = pause(running, "91")
|
|
295
|
+
yield running
|
|
296
|
+
finally:
|
|
297
|
+
if proc is not None:
|
|
298
|
+
_stop(proc)
|
|
299
|
+
upstream.shutdown()
|
|
300
|
+
upstream.server_close()
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def native_run(server: Server, thread: str, body: dict[str, Any]) -> httpx.Response:
|
|
304
|
+
"""A run through the server's own API (not the app's routes), as the thread's owner."""
|
|
305
|
+
return httpx.post(
|
|
306
|
+
f"{server.url}/threads/{thread}/runs/wait",
|
|
307
|
+
json={"assistant_id": "agent", **body},
|
|
308
|
+
headers=token("alice"),
|
|
309
|
+
timeout=60,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def paused_checkpoint(server: Server, thread: str) -> str:
|
|
314
|
+
"""The checkpoint where the thread's run paused for the gated call."""
|
|
315
|
+
r = post(server, f"/threads/{thread}/history", {"limit": 20}, token("alice"))
|
|
316
|
+
assert r.status_code == 200, r.text
|
|
317
|
+
return next(
|
|
318
|
+
h["checkpoint"]["checkpoint_id"]
|
|
319
|
+
for h in r.json()
|
|
320
|
+
if any(task.get("interrupts") for task in h.get("tasks") or [])
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def rerun_paused_step(server: Server, thread: str, checkpoint: str) -> list[httpx.Response]:
|
|
325
|
+
"""The native runs that run a paused step again: without input, and from its checkpoint."""
|
|
326
|
+
return [
|
|
327
|
+
native_run(server, thread, {"input": None}),
|
|
328
|
+
native_run(server, thread, {"checkpoint_id": checkpoint}),
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def assert_refused_at_the_server(server: Server, thread: str, checkpoint: str) -> None:
|
|
333
|
+
"""Native runs that would run the paused step again, and a copy (which would carry its
|
|
334
|
+
tool call without the approval), are refused on a thread with approvals."""
|
|
335
|
+
for r in rerun_paused_step(server, thread, checkpoint):
|
|
336
|
+
assert r.status_code == 403, r.text
|
|
337
|
+
assert "the thread has approvals of gated API calls" in r.json()["detail"]
|
|
338
|
+
r = httpx.post(f"{server.url}/threads/{thread}/copy", headers=token("alice"), timeout=30)
|
|
339
|
+
assert r.status_code == 403, r.text
|
|
340
|
+
assert "cannot be copied" in r.json()["detail"]
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@contextmanager
|
|
344
|
+
def gate_removed(server: Server) -> Iterator[None]:
|
|
345
|
+
"""A new policy that no longer gates the call (a new image, a typo)."""
|
|
346
|
+
server.policy.write_text(POLICY.split(" approval:")[0], encoding="utf-8")
|
|
347
|
+
try:
|
|
348
|
+
yield
|
|
349
|
+
finally:
|
|
350
|
+
server.policy.write_text(POLICY, encoding="utf-8")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _stop(proc: subprocess.Popen[bytes]) -> None:
|
|
354
|
+
"""Stop the server and everything it started (its own process group)."""
|
|
355
|
+
if proc.poll() is not None:
|
|
356
|
+
return
|
|
357
|
+
try:
|
|
358
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
359
|
+
proc.wait(timeout=20)
|
|
360
|
+
except subprocess.TimeoutExpired:
|
|
361
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
362
|
+
proc.wait(timeout=10)
|
|
363
|
+
except ProcessLookupError:
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_the_server_pauses_a_gated_call_and_sends_it_once_approved(server: Server) -> None:
|
|
368
|
+
end = pause(server, "11")
|
|
369
|
+
approval = end["approval"]
|
|
370
|
+
assert (approval["method"], approval["path"]) == ("POST", "/orders/11/cancel")
|
|
371
|
+
assert approval["body"] == BODY and approval["approvers"] == ["requester", "role:ops"]
|
|
372
|
+
assert server.upstream.sent_to("/orders/11/cancel") == []
|
|
373
|
+
thread = end["thread_id"]
|
|
374
|
+
# Pending: no new message on the thread.
|
|
375
|
+
r = post(server, "/chat", {"message": "hello", "thread_id": thread}, token("alice"))
|
|
376
|
+
assert r.status_code == 409 and r.json()["code"] == "approval_pending"
|
|
377
|
+
# Not an approver.
|
|
378
|
+
r = decide(server, end, "approve", token("bob"))
|
|
379
|
+
assert r.status_code == 403 and r.json()["code"] == "not_an_approver"
|
|
380
|
+
# The server's native API cannot resume the run around the approval route.
|
|
381
|
+
r = httpx.post(
|
|
382
|
+
f"{server.url}/threads/{thread}/runs/wait",
|
|
383
|
+
json={
|
|
384
|
+
"assistant_id": "agent",
|
|
385
|
+
"command": {"resume": {"x": {"type": "api_approval_decision", "decision": "approve"}}},
|
|
386
|
+
},
|
|
387
|
+
headers=token("alice"),
|
|
388
|
+
timeout=30,
|
|
389
|
+
)
|
|
390
|
+
assert r.status_code == 403, r.text
|
|
391
|
+
# Nor start a new run on it (which would abandon the pending approval): 409, as /chat.
|
|
392
|
+
r = httpx.post(
|
|
393
|
+
f"{server.url}/threads/{thread}/runs/wait",
|
|
394
|
+
json={
|
|
395
|
+
"assistant_id": "agent",
|
|
396
|
+
"input": {"messages": [{"role": "user", "content": "hello"}]},
|
|
397
|
+
},
|
|
398
|
+
headers=token("alice"),
|
|
399
|
+
timeout=30,
|
|
400
|
+
)
|
|
401
|
+
assert r.status_code == 409, r.text
|
|
402
|
+
assert "approval_pending" in r.json()["detail"]
|
|
403
|
+
assert server.upstream.sent_to("/orders/11/cancel") == []
|
|
404
|
+
# The requester approves: the run resumes and the call is sent, once.
|
|
405
|
+
r = decide(server, end, "approve", token("alice"))
|
|
406
|
+
assert r.status_code == 200, r.text
|
|
407
|
+
events = parse_sse(r.text)
|
|
408
|
+
assert events[0][1]["approval_id"] == approval["approval_id"]
|
|
409
|
+
result = next(d for e, d in events if e == "tool.result")
|
|
410
|
+
assert result["is_error"] is False
|
|
411
|
+
assert json.loads(result["result"])["acting_as"] == "alice"
|
|
412
|
+
assert events[-1][1]["status"] == "ok"
|
|
413
|
+
assert server.upstream.sent_to("/orders/11/cancel") == [BODY]
|
|
414
|
+
again = decide(server, end, "approve", token("alice"))
|
|
415
|
+
assert again.status_code == 409 and again.json()["code"] == "approval_not_pending"
|
|
416
|
+
assert server.upstream.sent_to("/orders/11/cancel") == [BODY]
|
|
417
|
+
listed = httpx.get(f"{server.url}/threads/{thread}/approvals", headers=token("alice"))
|
|
418
|
+
assert listed.json()[0]["status"] == "approved"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_the_server_sends_nothing_on_reject(server: Server) -> None:
|
|
422
|
+
end = pause(server, "12")
|
|
423
|
+
r = decide(server, end, "reject", token("carol", "ops"))
|
|
424
|
+
assert r.status_code == 200, r.text
|
|
425
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
426
|
+
assert result["is_error"] is True and "was not approved" in result["result"]
|
|
427
|
+
assert server.upstream.sent_to("/orders/12/cancel") == []
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_a_role_approver_decides_and_the_run_acts_as_the_requester(server: Server) -> None:
|
|
431
|
+
end = pause(server, "13")
|
|
432
|
+
r = decide(server, end, "approve", token("carol", "ops"))
|
|
433
|
+
assert r.status_code == 200, r.text
|
|
434
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
435
|
+
assert json.loads(result["result"])["acting_as"] == "alice"
|
|
436
|
+
assert server.upstream.sent_to("/orders/13/cancel") == [BODY]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# One API, other approvers for other calls: the requester confirms updates and
|
|
440
|
+
# cancellations, and a second person holding role:admin approves new orders.
|
|
441
|
+
RULES_POLICY = """
|
|
442
|
+
apis:
|
|
443
|
+
shop:
|
|
444
|
+
base_url_env: SHOP_API_BASE_URL
|
|
445
|
+
auth: none
|
|
446
|
+
allowed_methods: [GET, POST, PATCH]
|
|
447
|
+
approval:
|
|
448
|
+
- required_for:
|
|
449
|
+
operations:
|
|
450
|
+
- {operationId: updateOrder, path: "/orders/{order_id}", methods: [PATCH]}
|
|
451
|
+
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
|
|
452
|
+
approvers: [requester]
|
|
453
|
+
- required_for:
|
|
454
|
+
operations:
|
|
455
|
+
- {operationId: createOrder, path: /orders, methods: [POST]}
|
|
456
|
+
approvers: ["role:admin"]
|
|
457
|
+
timeout_s: 3600
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@contextmanager
|
|
462
|
+
def approval_rules(server: Server) -> Iterator[None]:
|
|
463
|
+
"""The test policy with a list of approval rules, for the time of one test."""
|
|
464
|
+
server.policy.write_text(RULES_POLICY, encoding="utf-8")
|
|
465
|
+
try:
|
|
466
|
+
yield
|
|
467
|
+
finally:
|
|
468
|
+
server.policy.write_text(POLICY, encoding="utf-8")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_each_call_waits_for_the_approvers_of_the_rule_that_gates_it(server: Server) -> None:
|
|
472
|
+
admin = token("root", "admin")
|
|
473
|
+
with approval_rules(server):
|
|
474
|
+
# An update: the requester's rule. Neither another user nor an admin decides it.
|
|
475
|
+
end = pause_on(server, "Amend the gift note for 61")
|
|
476
|
+
approval = end["approval"]
|
|
477
|
+
assert (approval["operation_id"], approval["approvers"]) == ("updateOrder", ["requester"])
|
|
478
|
+
for headers in (token("bob"), admin):
|
|
479
|
+
r = decide(server, end, "approve", headers)
|
|
480
|
+
assert r.status_code == 403 and r.json()["code"] == "not_an_approver", r.text
|
|
481
|
+
r = decide(server, end, "approve", token("alice"))
|
|
482
|
+
assert r.status_code == 200, r.text
|
|
483
|
+
assert server.upstream.sent_to("/orders/61") == [{"note": "gift"}]
|
|
484
|
+
|
|
485
|
+
# A cancellation: the requester's rule too; an admin may not decide it either.
|
|
486
|
+
end = pause_on(server, "Cancel the order for 62")
|
|
487
|
+
assert end["approval"]["approvers"] == ["requester"]
|
|
488
|
+
assert decide(server, end, "approve", admin).status_code == 403
|
|
489
|
+
assert decide(server, end, "approve", token("alice")).status_code == 200
|
|
490
|
+
assert server.upstream.sent_to("/orders/62/cancel") == [BODY]
|
|
491
|
+
|
|
492
|
+
# A new order: role:admin's rule, with its own expiry. The requester may not
|
|
493
|
+
# approve it, even holding the role (four eyes); another admin may.
|
|
494
|
+
end = pause_on(server, "Place a new one for GADGET-63")
|
|
495
|
+
approval = end["approval"]
|
|
496
|
+
assert (approval["operation_id"], approval["approvers"]) == ("createOrder", ["role:admin"])
|
|
497
|
+
assert approval["body"] == {"item": "GADGET-63"}
|
|
498
|
+
lifetime = datetime.fromisoformat(approval["expires_at"]) - datetime.fromisoformat(
|
|
499
|
+
approval["created_at"]
|
|
500
|
+
)
|
|
501
|
+
assert lifetime.total_seconds() == 3600
|
|
502
|
+
for headers in (token("alice"), token("alice", "admin"), token("bob", "ops")):
|
|
503
|
+
r = decide(server, end, "approve", headers)
|
|
504
|
+
assert r.status_code == 403, r.text
|
|
505
|
+
assert server.upstream.sent_to("/orders") == []
|
|
506
|
+
r = decide(server, end, "approve", admin)
|
|
507
|
+
assert r.status_code == 200, r.text
|
|
508
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
509
|
+
assert json.loads(result["result"])["acting_as"] == "alice"
|
|
510
|
+
assert server.upstream.sent_to("/orders") == [{"item": "GADGET-63"}]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def test_the_server_refuses_a_request_changed_after_approval(server: Server) -> None:
|
|
514
|
+
end = pause(server, "14")
|
|
515
|
+
server.body_file.write_text(json.dumps({"reason": "changed"}), encoding="utf-8")
|
|
516
|
+
try:
|
|
517
|
+
r = decide(server, end, "approve", token("alice"))
|
|
518
|
+
finally:
|
|
519
|
+
server.body_file.write_text(json.dumps(BODY), encoding="utf-8")
|
|
520
|
+
assert r.status_code == 200, r.text
|
|
521
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
522
|
+
assert result["is_error"] is True
|
|
523
|
+
assert "differs from the request that was approved" in result["result"]
|
|
524
|
+
assert server.upstream.sent_to("/orders/14/cancel") == []
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def test_the_server_binds_a_decision_to_its_call_when_the_gate_is_removed(
|
|
528
|
+
server: Server,
|
|
529
|
+
) -> None:
|
|
530
|
+
"""A new policy without the gate while two calls wait: neither decision sends."""
|
|
531
|
+
rejected, approved = pause(server, "17"), pause(server, "18")
|
|
532
|
+
server.policy.write_text(POLICY.split(" approval:")[0], encoding="utf-8")
|
|
533
|
+
try:
|
|
534
|
+
r = decide(server, rejected, "reject", token("alice"))
|
|
535
|
+
assert r.status_code == 200, r.text
|
|
536
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
537
|
+
assert result["is_error"] is True and "an approver rejected it" in result["result"]
|
|
538
|
+
r = decide(server, approved, "approve", token("carol", "ops"))
|
|
539
|
+
assert r.status_code == 200, r.text
|
|
540
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
541
|
+
assert result["is_error"] is True
|
|
542
|
+
assert "approval gate the policy no longer has" in result["result"]
|
|
543
|
+
finally:
|
|
544
|
+
server.policy.write_text(POLICY, encoding="utf-8")
|
|
545
|
+
assert server.upstream.sent_to("/orders/17/cancel") == []
|
|
546
|
+
assert server.upstream.sent_to("/orders/18/cancel") == []
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def test_a_native_replay_does_not_send_a_rejected_call_once_its_gate_is_removed(
|
|
550
|
+
server: Server,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Rejected, then un-gated: continuing or replaying the paused step sends nothing."""
|
|
553
|
+
end = pause(server, "20")
|
|
554
|
+
thread = end["thread_id"]
|
|
555
|
+
checkpoint = paused_checkpoint(server, thread)
|
|
556
|
+
with gate_removed(server):
|
|
557
|
+
r = decide(server, end, "reject", token("alice"))
|
|
558
|
+
assert r.status_code == 200, r.text
|
|
559
|
+
assert_refused_at_the_server(server, thread, checkpoint)
|
|
560
|
+
assert server.upstream.sent_to("/orders/20/cancel") == []
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def test_a_native_replay_does_not_send_an_approved_call_a_second_time(server: Server) -> None:
|
|
564
|
+
"""Approved and sent, then un-gated: a replay of the paused step does not send it again."""
|
|
565
|
+
end = pause(server, "21")
|
|
566
|
+
thread = end["thread_id"]
|
|
567
|
+
checkpoint = paused_checkpoint(server, thread)
|
|
568
|
+
r = decide(server, end, "approve", token("alice"))
|
|
569
|
+
assert r.status_code == 200, r.text
|
|
570
|
+
assert server.upstream.sent_to("/orders/21/cancel") == [BODY]
|
|
571
|
+
with gate_removed(server):
|
|
572
|
+
assert_refused_at_the_server(server, thread, checkpoint)
|
|
573
|
+
assert server.upstream.sent_to("/orders/21/cancel") == [BODY]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def test_a_thread_without_gated_calls_is_still_copied_and_continued(server: Server) -> None:
|
|
577
|
+
r = post(server, "/chat", {"message": "hello"}, token("alice"))
|
|
578
|
+
assert r.status_code == 200, r.text
|
|
579
|
+
thread = parse_sse(r.text)[-1][1]["thread_id"]
|
|
580
|
+
r = httpx.post(f"{server.url}/threads/{thread}/copy", headers=token("alice"), timeout=30)
|
|
581
|
+
assert r.status_code == 200, r.text
|
|
582
|
+
assert native_run(server, thread, {"input": None}).status_code == 200
|
|
583
|
+
assert native_run(server, r.json()["thread_id"], {"input": None}).status_code == 200
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def test_deleting_the_thread_on_the_server_deletes_its_approvals(server: Server) -> None:
|
|
587
|
+
end = pause(server, "15")
|
|
588
|
+
thread = end["thread_id"]
|
|
589
|
+
r = httpx.delete(f"{server.url}/threads/{thread}", headers=token("alice"), timeout=30)
|
|
590
|
+
assert r.status_code in (200, 204), r.text
|
|
591
|
+
r = httpx.get(f"{server.url}/threads/{thread}/approvals", headers=token("alice"))
|
|
592
|
+
assert r.status_code == 404
|
|
593
|
+
listed = httpx.get(f"{server.url}/approvals", headers=token("alice")).json()
|
|
594
|
+
assert end["approval"]["approval_id"] not in [a["approval_id"] for a in listed]
|
|
595
|
+
assert decide(server, end, "approve", token("alice")).status_code == 404
|
|
596
|
+
assert server.upstream.sent_to("/orders/15/cancel") == []
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _rpc(server: Server, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
600
|
+
r = httpx.post(
|
|
601
|
+
f"{server.url}{A2A_PATH}",
|
|
602
|
+
json={"jsonrpc": "2.0", "id": "1", "method": method, "params": params},
|
|
603
|
+
headers={**token("alice"), "A2A-Version": "1.0"},
|
|
604
|
+
timeout=60,
|
|
605
|
+
)
|
|
606
|
+
return r.json()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_the_server_a2a_task_waits_for_input_and_resumes(server: Server) -> None:
|
|
610
|
+
sent = _rpc(
|
|
611
|
+
server,
|
|
612
|
+
"SendMessage",
|
|
613
|
+
{
|
|
614
|
+
"message": {
|
|
615
|
+
"messageId": "m-1",
|
|
616
|
+
"role": "ROLE_USER",
|
|
617
|
+
"parts": [{"text": "Cancel the order for 16"}],
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
)
|
|
621
|
+
task = sent["result"]["task"]
|
|
622
|
+
assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
|
|
623
|
+
data = next(p["data"] for p in task["status"]["message"]["parts"] if "data" in p)
|
|
624
|
+
approval = data["approval"]
|
|
625
|
+
assert approval["path"] == "/orders/16/cancel"
|
|
626
|
+
done = _rpc(
|
|
627
|
+
server,
|
|
628
|
+
"SendMessage",
|
|
629
|
+
{
|
|
630
|
+
"message": {
|
|
631
|
+
"messageId": "m-2",
|
|
632
|
+
"role": "ROLE_USER",
|
|
633
|
+
"taskId": task["id"],
|
|
634
|
+
"contextId": task["contextId"],
|
|
635
|
+
"parts": [
|
|
636
|
+
{"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
|
|
637
|
+
],
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
)
|
|
641
|
+
assert done["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", done
|
|
642
|
+
assert server.upstream.sent_to("/orders/16/cancel") == [BODY]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_a_tool_output_holding_a_lone_surrogate_completes_the_run(server: Server) -> None:
|
|
646
|
+
"""UTF-8 cannot encode a lone surrogate: the reply holds U+FFFD in its place.
|
|
647
|
+
|
|
648
|
+
The server could not send the tool's update on its own stream: the run
|
|
649
|
+
failed (`run_failed`) after the tool had acted, over /chat and over A2A.
|
|
650
|
+
"""
|
|
651
|
+
r = post(server, "/chat", {"message": "Check the gauge for Bergen"}, token("alice"))
|
|
652
|
+
assert r.status_code == 200, r.text
|
|
653
|
+
events = parse_sse(r.text)
|
|
654
|
+
assert events[-1][0] == "message.end" and events[-1][1]["status"] == "ok", events
|
|
655
|
+
assert [d["result"] for e, d in events if e == "tool.result"] == ["gauge at Bergen:\ufffd42"]
|
|
656
|
+
sent = _rpc(
|
|
657
|
+
server,
|
|
658
|
+
"SendMessage",
|
|
659
|
+
{
|
|
660
|
+
"message": {
|
|
661
|
+
"messageId": "m-gauge",
|
|
662
|
+
"role": "ROLE_USER",
|
|
663
|
+
"parts": [{"text": "Check the gauge for Oslo"}],
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
)
|
|
667
|
+
task = sent["result"]["task"]
|
|
668
|
+
assert task["status"]["state"] == "TASK_STATE_COMPLETED", task
|
|
669
|
+
reply = "".join(p.get("text", "") for a in task["artifacts"] for p in a["parts"])
|
|
670
|
+
assert "gauge at Oslo:\ufffd42" in reply
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _wait_until_expired(end: dict[str, Any]) -> None:
|
|
674
|
+
expires = datetime.fromisoformat(end["approval"]["expires_at"])
|
|
675
|
+
wait = expires.timestamp() - time.time() + 1
|
|
676
|
+
if wait > 0:
|
|
677
|
+
time.sleep(wait)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def test_a_native_run_does_not_send_an_expired_call_once_its_gate_is_removed(
|
|
681
|
+
server: Server,
|
|
682
|
+
) -> None:
|
|
683
|
+
"""Near last: an approval paused when the server started has expired by now."""
|
|
684
|
+
end = server.expiring_native
|
|
685
|
+
_wait_until_expired(end)
|
|
686
|
+
thread = end["thread_id"]
|
|
687
|
+
checkpoint = paused_checkpoint(server, thread)
|
|
688
|
+
with gate_removed(server):
|
|
689
|
+
assert decide(server, end, "approve", token("alice")).status_code == 410
|
|
690
|
+
assert_refused_at_the_server(server, thread, checkpoint)
|
|
691
|
+
assert server.upstream.sent_to("/orders/91/cancel") == []
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def test_an_expired_approval_on_the_server_sends_nothing(server: Server) -> None:
|
|
695
|
+
"""Last: the approval paused when the server started has expired by now."""
|
|
696
|
+
end = server.expiring
|
|
697
|
+
_wait_until_expired(end)
|
|
698
|
+
r = decide(server, end, "approve", token("alice"))
|
|
699
|
+
assert r.status_code == 410 and r.json()["code"] == "approval_expired"
|
|
700
|
+
thread = end["thread_id"]
|
|
701
|
+
# Expired = rejected: the thread takes a new message, and the paused call's
|
|
702
|
+
# result says it was not approved.
|
|
703
|
+
r = post(server, "/chat", {"message": "hello", "thread_id": thread}, token("alice"))
|
|
704
|
+
assert r.status_code == 200, r.text
|
|
705
|
+
assert parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
706
|
+
messages = httpx.get(f"{server.url}/threads/{thread}/messages", headers=token("alice")).json()
|
|
707
|
+
tool = next(m for m in messages if m["role"] == "tool")
|
|
708
|
+
assert "approval request expired" in tool["content"]
|
|
709
|
+
assert server.upstream.sent_to("/orders/90/cancel") == []
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
# The app's log line each time the server starts it (at startup and on a reload).
|
|
713
|
+
APP_STARTED = "chat runtime started"
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _reloaded(proc: subprocess.Popen[bytes], url: str, log: Path, starts: int) -> None:
|
|
717
|
+
"""Wait until the server has started its app once more than `starts` times, and is up."""
|
|
718
|
+
deadline = time.monotonic() + 120
|
|
719
|
+
while log.read_text(errors="replace").count(APP_STARTED) <= starts:
|
|
720
|
+
if proc.poll() is not None or time.monotonic() > deadline:
|
|
721
|
+
pytest.fail(f"langgraph dev did not reload:\n{log.read_text()[-4000:]}")
|
|
722
|
+
time.sleep(0.5)
|
|
723
|
+
_wait_up(proc, url, log)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def test_the_dev_server_keeps_the_approvals_with_its_threads(tmp_path: Path) -> None:
|
|
727
|
+
"""`langgraph dev` keeps its threads (in `.langgraph_api/`) across a hot reload and a
|
|
728
|
+
restart, and the approvals that bind their tool calls with them: once the gate is
|
|
729
|
+
removed, a run without input or a replay of the paused step sends none of a pending,
|
|
730
|
+
a rejected, a sent, or an approved but refused call."""
|
|
731
|
+
upstream = Upstream()
|
|
732
|
+
threading.Thread(target=upstream.serve_forever, daemon=True).start()
|
|
733
|
+
body_file = tmp_path / "body.json"
|
|
734
|
+
body_file.write_text(json.dumps(BODY), encoding="utf-8")
|
|
735
|
+
policy = tmp_path / "api-policy.yaml"
|
|
736
|
+
gated = POLICY.replace(f"timeout_s: {TIMEOUT_S}", "timeout_s: 3600") # nothing expires
|
|
737
|
+
policy.write_text(gated, encoding="utf-8")
|
|
738
|
+
_write_config(tmp_path)
|
|
739
|
+
env = _server_env(policy, body_file, upstream)
|
|
740
|
+
port = _free_port()
|
|
741
|
+
url = f"http://127.0.0.1:{port}"
|
|
742
|
+
log = tmp_path / "server.log"
|
|
743
|
+
proc = _start_dev(tmp_path, env, port, log, reload=True)
|
|
744
|
+
try:
|
|
745
|
+
server = Server(url=url, upstream=upstream, body_file=body_file, log=log, policy=policy)
|
|
746
|
+
waiting = pause(server, "81")
|
|
747
|
+
rejected = pause(server, "82")
|
|
748
|
+
assert decide(server, rejected, "reject", token("alice")).status_code == 200
|
|
749
|
+
sent = pause(server, "83")
|
|
750
|
+
assert decide(server, sent, "approve", token("alice")).status_code == 200
|
|
751
|
+
refused = pause(server, "84")
|
|
752
|
+
denied = " denied_operations:\n - path: /orders/84/cancel\n approval:"
|
|
753
|
+
policy.write_text(gated.replace(" approval:", denied), encoding="utf-8")
|
|
754
|
+
r = decide(server, refused, "approve", token("alice"))
|
|
755
|
+
policy.write_text(gated, encoding="utf-8")
|
|
756
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
757
|
+
assert result["is_error"] is True and "denied_operations" in result["result"]
|
|
758
|
+
paused = {
|
|
759
|
+
end["thread_id"]: (end, paused_checkpoint(server, end["thread_id"]))
|
|
760
|
+
for end in (waiting, rejected, sent, refused)
|
|
761
|
+
}
|
|
762
|
+
expected = {"81": [], "82": [], "83": [BODY], "84": []}
|
|
763
|
+
|
|
764
|
+
def assert_still_bound() -> None:
|
|
765
|
+
for thread, (end, _) in paused.items():
|
|
766
|
+
listed = httpx.get(f"{url}/threads/{thread}/approvals", headers=token("alice"))
|
|
767
|
+
assert listed.status_code == 200, listed.text
|
|
768
|
+
ids = [a["approval_id"] for a in listed.json()]
|
|
769
|
+
assert end["approval"]["approval_id"] in ids, listed.text
|
|
770
|
+
with gate_removed(server):
|
|
771
|
+
for thread, (_, checkpoint) in paused.items():
|
|
772
|
+
for r in rerun_paused_step(server, thread, checkpoint):
|
|
773
|
+
assert r.status_code in (403, 409), r.text
|
|
774
|
+
message = {"message": "hi", "thread_id": waiting["thread_id"]}
|
|
775
|
+
r = post(server, "/chat", message, token("alice"))
|
|
776
|
+
assert r.status_code == 409 and r.json()["code"] == "approval_pending"
|
|
777
|
+
for order, bodies in expected.items():
|
|
778
|
+
assert upstream.sent_to(f"/orders/{order}/cancel") == bodies, order
|
|
779
|
+
|
|
780
|
+
# A hot reload: a code change in the directory the server watches.
|
|
781
|
+
starts = log.read_text(errors="replace").count(APP_STARTED)
|
|
782
|
+
(tmp_path / "changed.py").write_text("CHANGED = True\n", encoding="utf-8")
|
|
783
|
+
_reloaded(proc, url, log, starts)
|
|
784
|
+
assert_still_bound()
|
|
785
|
+
# A restart.
|
|
786
|
+
_stop(proc)
|
|
787
|
+
proc = _start_dev(tmp_path, env, port, log)
|
|
788
|
+
assert_still_bound()
|
|
789
|
+
ledger = tmp_path / ".langgraph_api" / "agent_approvals.json"
|
|
790
|
+
assert ledger.stat().st_mode & 0o777 == 0o600
|
|
791
|
+
finally:
|
|
792
|
+
_stop(proc)
|
|
793
|
+
upstream.shutdown()
|
|
794
|
+
upstream.server_close()
|