proxy-aiops 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/CHANGELOG.md +10 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/PKG-INFO +2 -2
- proxy_aiops-0.4.0/RELEASE_NOTES.md +49 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/docs/VERIFICATION.md +8 -4
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/_shared.py +33 -3
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/undo.py +17 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/writes.py +25 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/_common.py +22 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/configcmd.py +13 -5
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/connection.py +23 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/__init__.py +10 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/decorators.py +65 -9
- proxy_aiops-0.4.0/proxy_aiops/governance/outcome.py +104 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/sanitize.py +11 -2
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/undo.py +52 -13
- proxy_aiops-0.4.0/proxy_aiops/ops/writes.py +333 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/platform.py +18 -13
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/pyproject.toml +2 -2
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/server.json +2 -2
- proxy_aiops-0.4.0/tests/test_dpapi_version.py +56 -0
- proxy_aiops-0.4.0/tests/test_governance_persistence.py +379 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_optional_fields.py +4 -1
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_platform.py +4 -2
- proxy_aiops-0.4.0/tests/test_self_lockout.py +389 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_writes.py +11 -2
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/uv.lock +1 -1
- proxy_aiops-0.3.0/RELEASE_NOTES.md +0 -27
- proxy_aiops-0.3.0/proxy_aiops/ops/writes.py +0 -174
- proxy_aiops-0.3.0/tests/test_governance_persistence.py +0 -187
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/.github/workflows/mcp-publish.yml +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/.github/workflows/publish.yml +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/.gitignore +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/LICENSE +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/README.md +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/SECURITY.md +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/__init__.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/server.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/__init__.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/analysis.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/certs.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/configread.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/routes.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/services.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/status.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/mcp_server/tools/traffic.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/__init__.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/__init__.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/_root.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/analyze.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/certs.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/doctor.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/init.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/overview.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/routes.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/secret.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/server.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/services.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/cli/undo.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/config.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/doctor.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/audit.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/budget.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/paths.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/patterns.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/policy.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/governance/readonly.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/__init__.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/_util.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/analysis.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/certs.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/configread.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/overview.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/routes.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/services.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/status.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/ops/traffic.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/proxy_aiops/secretstore.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/skills/proxy-aiops/SKILL.md +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/agent-guardrails.md +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/capabilities.md +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/cli-reference.md +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/skills/proxy-aiops/references/setup-guide.md +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/smithery.yaml +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/conftest.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_analysis.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_cli_reads.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_cli_secret.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_cli_writes.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_connection.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_doctor.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_gov_audit.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_gov_decorators.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_gov_patterns.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_gov_policy.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_init.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_int_fidelity.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_mcp_read_tools.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_ops_extra.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_readonly.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_reads.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_secretstore.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_smoke.py +0 -0
- {proxy_aiops-0.3.0 → proxy_aiops-0.4.0}/tests/test_undo_executor.py +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.4.0 — 2026-07-20
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Caddy config writes refuse the `admin` subtree.** The Caddy admin API is this tool's own transport, and `admin` is an ordinary top-level key in the config tree — so `set_config_value("admin/disabled", true)` tore down the listener mid-request and left the undo with nowhere to go.
|
|
7
|
+
- Harness: a write whose response is lost is audited `status=unknown`, not `error` — it may have taken effect. Undo tokens gain `effectVerified` (undo.db migrated in place).
|
|
8
|
+
- Harness: a dry-run no longer records an undo token, and no longer requires a named approver. Guards now run on the preview path.
|
|
9
|
+
- Truncated strings end in an ellipsis instead of being cut silently; error messages are capped at 800 chars, not 300.
|
|
10
|
+
|
|
11
|
+
See RELEASE_NOTES.md for the full detail.
|
|
12
|
+
|
|
3
13
|
All notable changes to proxy-aiops are documented here. This project adheres
|
|
4
14
|
to [Semantic Versioning](https://semver.org/).
|
|
5
15
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxy-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Governed AI-ops for Traefik + Caddy + HAProxy reverse proxies / load balancers: routes, services, upstream health, TLS cert expiry sweep, 5xx error-rate RCA, route conflict analysis, and governed writes (caddy config, haproxy server state/weight) with a built-in governance harness (audit, budget, undo, risk tiers)
|
|
5
|
-
Author-email:
|
|
5
|
+
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.11
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Release notes — proxy-aiops 0.4.0
|
|
2
|
+
|
|
3
|
+
Previous release: 0.3.1.
|
|
4
|
+
|
|
5
|
+
## In this tool
|
|
6
|
+
|
|
7
|
+
- **Caddy config writes refuse the `admin` subtree.** The Caddy admin API is this tool's own transport, and `admin` is an ordinary top-level key in the config tree — so `set_config_value("admin/disabled", true)` tore down the listener mid-request and left the undo with nowhere to go. `load_config` likewise refuses a config that disables admin or moves its listener. The tool's own troubleshooting guide already listed this as a terminal state; it just did not stop itself causing it.
|
|
8
|
+
|
|
9
|
+
## Every tool in the line: previews and undetermined outcomes
|
|
10
|
+
|
|
11
|
+
This release fixes three harness defects that were silently degrading the audit
|
|
12
|
+
trail and the undo store.
|
|
13
|
+
|
|
14
|
+
**A write that loses its response is no longer recorded as a failure.** The
|
|
15
|
+
harness assumed a sanitized error meant nothing had happened. That assumption is
|
|
16
|
+
false in exactly the case that matters most: when a write severs its own
|
|
17
|
+
connection, the request has already landed, the response cannot come back, and
|
|
18
|
+
the operation was recorded as `status=error` with **no undo token created at
|
|
19
|
+
all**. Transport-level failures are now audited as `status=unknown`, the result
|
|
20
|
+
says plainly that the operation may have taken effect and should be verified
|
|
21
|
+
before retrying, and a write that stashed its before-state has its inverse
|
|
22
|
+
recorded anyway — flagged `effectVerified: false`, which `undo_list` and
|
|
23
|
+
`undo_apply` both surface. Existing `undo.db` files are migrated in place; their
|
|
24
|
+
rows read as verified, which is accurate, since the old code only ever recorded
|
|
25
|
+
on the confirmed path.
|
|
26
|
+
|
|
27
|
+
**A dry-run no longer writes an undo token.** Previews were recording inverses
|
|
28
|
+
built from a before-state they never had: the undo callback's permissive default
|
|
29
|
+
filled the gap with a guess, producing a real, applicable token for an operation
|
|
30
|
+
that never happened.
|
|
31
|
+
|
|
32
|
+
**A dry-run no longer demands a named approver.** Requiring an approval in order
|
|
33
|
+
to ask whether something needs approval inverts what a preview is for. The tier
|
|
34
|
+
is still computed and still audited, so the preview can tell you an approver
|
|
35
|
+
will be needed; it just no longer refuses to answer. The write itself is gated
|
|
36
|
+
exactly as before.
|
|
37
|
+
|
|
38
|
+
The invariant, now stated: **a dry_run may read; it must never write.** Guards
|
|
39
|
+
run on the preview path, which means a preview can and does report that an
|
|
40
|
+
operation would be refused.
|
|
41
|
+
|
|
42
|
+
## Also line-wide
|
|
43
|
+
|
|
44
|
+
- **Truncated text now ends in an ellipsis** instead of being cut silently. This
|
|
45
|
+
line already treats a silent cut as a defect for lists; it was doing exactly
|
|
46
|
+
that to strings.
|
|
47
|
+
- **Error messages are capped at 800 characters, not 300.** These messages end
|
|
48
|
+
with what to do instead, so the cap was removing the most useful sentence of
|
|
49
|
+
every long refusal.
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
This document records what has and has not been validated against real reverse
|
|
4
4
|
proxies, so the maturity claim is auditable rather than a vibe.
|
|
5
5
|
|
|
6
|
-
## Already live-verified ✅ —
|
|
6
|
+
## Already live-verified ✅ — all three platforms (2026-07-20)
|
|
7
|
+
|
|
8
|
+
Traefik 3.2.5, Caddy 2, and HAProxy 3.0.25 (Data Plane API v3.0.21).
|
|
7
9
|
|
|
8
10
|
- `doctor` against both live endpoints (unauthenticated admin APIs, as the tool
|
|
9
11
|
documents for traefik/caddy).
|
|
@@ -22,9 +24,11 @@ that never round-trips through float64.
|
|
|
22
24
|
|
|
23
25
|
## Not yet live-verified ⚠️
|
|
24
26
|
|
|
25
|
-
- **HAProxy
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
- **HAProxy is now verified** — and that run found the branch was **entirely
|
|
28
|
+
broken**: every path was hardcoded to Data Plane API v2, but HAProxy 3.x serves
|
|
29
|
+
only `/v3`, so the first probe 404'd. The connection now detects the API
|
|
30
|
+
generation and supports both. Still untested there: runtime server state changes
|
|
31
|
+
and the stats-derived traffic analyses under real load.
|
|
28
32
|
- **TLS / certificate expiry** (`certs`) against real certificates — both verified
|
|
29
33
|
instances served plaintext on a lab port.
|
|
30
34
|
- **Guarded config writes** (`config set/delete`) and their undo paths.
|
|
@@ -17,11 +17,12 @@ from collections.abc import Callable
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
from typing import Any, Optional
|
|
19
19
|
|
|
20
|
+
import httpx
|
|
20
21
|
from mcp.server.fastmcp import FastMCP
|
|
21
22
|
|
|
22
23
|
from proxy_aiops.config import load_config
|
|
23
24
|
from proxy_aiops.connection import ConnectionManager, ProxyApiError
|
|
24
|
-
from proxy_aiops.governance import sanitize
|
|
25
|
+
from proxy_aiops.governance import mark_unknown, sanitize
|
|
25
26
|
from proxy_aiops.platform import UnsupportedOperation
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
@@ -29,6 +30,29 @@ logger = logging.getLogger(__name__)
|
|
|
29
30
|
_DOCTOR_HINT = "Run 'proxy-aiops doctor' to verify connectivity and credentials."
|
|
30
31
|
|
|
31
32
|
|
|
33
|
+
# Failures that leave the request's fate genuinely undetermined: the bytes
|
|
34
|
+
# went out and either the response or the rest of the connection was lost. A
|
|
35
|
+
# write that hits one of these MAY have taken effect on the server.
|
|
36
|
+
#
|
|
37
|
+
# Deliberately narrow. Connect errors and pool timeouts mean the request never
|
|
38
|
+
# left this process, and an API error carrying a status means the server
|
|
39
|
+
# answered — all are ordinary failures where nothing, or a known something,
|
|
40
|
+
# happened. Marking them 'unknown' would cry wolf on every unreachable host.
|
|
41
|
+
_UNDETERMINED_ERRORS = (
|
|
42
|
+
httpx.ReadTimeout,
|
|
43
|
+
httpx.WriteTimeout,
|
|
44
|
+
httpx.ReadError,
|
|
45
|
+
httpx.WriteError,
|
|
46
|
+
httpx.RemoteProtocolError,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Long enough to carry the remediation sentence. These messages teach the
|
|
51
|
+
# caller what to do instead, and that clause comes last — a 300-char cap cut
|
|
52
|
+
# it off silently on every refusal long enough to need one.
|
|
53
|
+
_ERROR_MAX = 800
|
|
54
|
+
|
|
55
|
+
|
|
32
56
|
def _safe_error(exc: Exception, tool: str) -> str:
|
|
33
57
|
"""Return an agent-safe error string; log full detail server-side only."""
|
|
34
58
|
logger.error("Tool %s failed", tool, exc_info=True)
|
|
@@ -43,7 +67,7 @@ def _safe_error(exc: Exception, tool: str) -> str:
|
|
|
43
67
|
UnsupportedOperation,
|
|
44
68
|
)
|
|
45
69
|
if isinstance(exc, _passthrough):
|
|
46
|
-
return sanitize(str(exc),
|
|
70
|
+
return sanitize(str(exc), _ERROR_MAX)
|
|
47
71
|
return f"{type(exc).__name__}: operation failed."
|
|
48
72
|
|
|
49
73
|
|
|
@@ -67,7 +91,13 @@ def tool_errors(shape: str = "dict") -> Callable:
|
|
|
67
91
|
return [{"error": msg, "hint": _DOCTOR_HINT}]
|
|
68
92
|
if shape == "str":
|
|
69
93
|
return f"Error: {msg} {_DOCTOR_HINT}"
|
|
70
|
-
|
|
94
|
+
payload = {"error": msg, "hint": _DOCTOR_HINT}
|
|
95
|
+
# Flatten the exception into a dict and its type is gone
|
|
96
|
+
# for good — so classify here, while it is still known,
|
|
97
|
+
# whether the operation may nonetheless have taken effect.
|
|
98
|
+
if isinstance(e, _UNDETERMINED_ERRORS):
|
|
99
|
+
return mark_unknown(payload)
|
|
100
|
+
return payload
|
|
71
101
|
|
|
72
102
|
return wrapper
|
|
73
103
|
|
|
@@ -41,6 +41,11 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
41
41
|
length coincidence: when it is true there are MORE tokens than shown, so
|
|
42
42
|
re-run with a higher ``limit`` rather than reporting the list as complete.
|
|
43
43
|
|
|
44
|
+
Each entry carries ``effectVerified``. False means the original write lost
|
|
45
|
+
its response, so the change it reverses is PROBABLE, not confirmed — check
|
|
46
|
+
the live state before applying, and do not report the result as a restore
|
|
47
|
+
of a state that may never have been reached.
|
|
48
|
+
|
|
44
49
|
Args:
|
|
45
50
|
limit: Max rows to return (default 50, capped at 500).
|
|
46
51
|
target: Unused (undo state is host-local); accepted for CLI uniformity.
|
|
@@ -61,6 +66,7 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
61
66
|
"originalTool": r["tool"],
|
|
62
67
|
"inverseTool": r["undo_tool"],
|
|
63
68
|
"note": r.get("note", ""),
|
|
69
|
+
"effectVerified": bool(r.get("effect_verified", 1)),
|
|
64
70
|
}
|
|
65
71
|
for r in rows
|
|
66
72
|
],
|
|
@@ -105,10 +111,12 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
105
111
|
f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
|
|
106
112
|
)
|
|
107
113
|
|
|
114
|
+
effect_verified = bool(rec.get("effect_verified", 1))
|
|
108
115
|
if dry_run:
|
|
109
116
|
return {
|
|
110
117
|
"dryRun": True,
|
|
111
118
|
"undoId": undo_id,
|
|
119
|
+
"effectVerified": effect_verified,
|
|
112
120
|
"wouldApply": {"tool": inverse_tool, "params": params},
|
|
113
121
|
}
|
|
114
122
|
|
|
@@ -127,6 +135,15 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
127
135
|
return {
|
|
128
136
|
"undoId": undo_id,
|
|
129
137
|
"applied": applied,
|
|
138
|
+
"effectVerified": effect_verified,
|
|
130
139
|
"inverseTool": inverse_tool,
|
|
131
140
|
"result": result,
|
|
141
|
+
"note": (
|
|
142
|
+
""
|
|
143
|
+
if effect_verified
|
|
144
|
+
else "The original write lost its response, so it was never confirmed "
|
|
145
|
+
"to have taken effect. This inverse ran regardless; report the "
|
|
146
|
+
"CURRENT server state, not a restore that may have had nothing "
|
|
147
|
+
"to restore."
|
|
148
|
+
),
|
|
132
149
|
}
|
|
@@ -131,6 +131,12 @@ def set_config_value(
|
|
|
131
131
|
Caddy applies the change immediately. On traefik/haproxy this raises the
|
|
132
132
|
support matrix's teaching error. Pass dry_run=True to preview.
|
|
133
133
|
|
|
134
|
+
Refuses the 'admin' subtree: that configures the admin API this tool speaks
|
|
135
|
+
to, so disabling or moving it would end the connection the undo needs.
|
|
136
|
+
Change the admin block in caddy's own config file and reload locally. The
|
|
137
|
+
refusal applies under dry_run too — a preview whose real call would be
|
|
138
|
+
refused must report that, not a green 'wouldSet'.
|
|
139
|
+
|
|
134
140
|
Args:
|
|
135
141
|
path: Slash-separated config path (from search_config / list_routes,
|
|
136
142
|
e.g. apps/http/servers/srv0/routes/0/handle/0/upstreams).
|
|
@@ -139,6 +145,9 @@ def set_config_value(
|
|
|
139
145
|
target: Proxy target name from config; omit for the default.
|
|
140
146
|
"""
|
|
141
147
|
conn = _get_connection(target)
|
|
148
|
+
# Ahead of the dry_run return: a preview whose real call would be refused
|
|
149
|
+
# must say so, or the caller reads the refusal as transient and retries.
|
|
150
|
+
ops.guard_set_config_value(conn, path, value)
|
|
142
151
|
if dry_run:
|
|
143
152
|
return {"dryRun": True, "wouldSet": {"path": path, "value": value}}
|
|
144
153
|
return ops.set_config_value(conn, path, value)
|
|
@@ -158,12 +167,20 @@ def delete_config_path(
|
|
|
158
167
|
Requires an approver (PROXY_AUDIT_APPROVED_BY) under the
|
|
159
168
|
graduated-autonomy policy. Pass dry_run=True to preview.
|
|
160
169
|
|
|
170
|
+
Refuses the 'admin' subtree and the config root: both remove the admin API
|
|
171
|
+
this tool speaks to, leaving the undo with no way to reach the server. The
|
|
172
|
+
refusal applies under dry_run too, which must report it rather than preview
|
|
173
|
+
a call that will be refused.
|
|
174
|
+
|
|
161
175
|
Args:
|
|
162
176
|
path: Slash-separated config path to delete.
|
|
163
177
|
dry_run: If True, preview without deleting.
|
|
164
178
|
target: Proxy target name from config; omit for the default.
|
|
165
179
|
"""
|
|
166
180
|
conn = _get_connection(target)
|
|
181
|
+
# Ahead of the dry_run return: a preview whose real call would be refused
|
|
182
|
+
# must say so, or the caller reads the refusal as transient and retries.
|
|
183
|
+
ops.guard_delete_config_path(path)
|
|
167
184
|
if dry_run:
|
|
168
185
|
return {"dryRun": True, "wouldDelete": {"path": path}}
|
|
169
186
|
return ops.delete_config_path(conn, path)
|
|
@@ -183,12 +200,20 @@ def load_config(
|
|
|
183
200
|
Requires an approver (PROXY_AUDIT_APPROVED_BY) under the
|
|
184
201
|
graduated-autonomy policy. Pass dry_run=True to preview.
|
|
185
202
|
|
|
203
|
+
Refuses a config that disables the admin API or moves admin.listen off the
|
|
204
|
+
configured base_url — the undo re-POSTs the snapshot over that same API.
|
|
205
|
+
Send the admin block unchanged, or omit it. The refusal applies under
|
|
206
|
+
dry_run too, which must report it rather than preview a refused call.
|
|
207
|
+
|
|
186
208
|
Args:
|
|
187
209
|
config: The full config tree to load (a JSON object).
|
|
188
210
|
dry_run: If True, preview without loading.
|
|
189
211
|
target: Proxy target name from config; omit for the default.
|
|
190
212
|
"""
|
|
191
213
|
conn = _get_connection(target)
|
|
214
|
+
# Ahead of the dry_run return: costs the same config_root GET the real call
|
|
215
|
+
# makes, and in exchange the preview can never contradict it.
|
|
216
|
+
ops.guard_load_config(conn, config)
|
|
192
217
|
if dry_run:
|
|
193
218
|
return {"dryRun": True, "wouldLoad": {"topLevelKeys": sorted(config or {})}}
|
|
194
219
|
return ops.load_config(conn, config)
|
|
@@ -68,6 +68,28 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
|
|
|
68
68
|
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def dry_run_preview(
|
|
72
|
+
preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Render a GOVERNED dry-run result as the human-readable DRY-RUN banner.
|
|
75
|
+
|
|
76
|
+
``preview`` must come from calling the governed tool with ``dry_run=True``,
|
|
77
|
+
so every guard it carries has already run against the real target. A refusal
|
|
78
|
+
arrives as ``{"error": ...}`` (``tool_errors`` flattens the exception) — it is
|
|
79
|
+
printed like any other CLI error and exits non-zero, exactly as the real
|
|
80
|
+
write would. Printing a green banner for a call that is about to be refused
|
|
81
|
+
is the preview being wrong, not merely incomplete.
|
|
82
|
+
|
|
83
|
+
On the allowed path the banner is byte-for-byte what it always was: routing
|
|
84
|
+
through the governed call buys the guard and the audit row, not a new
|
|
85
|
+
serialization.
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(preview, dict) and preview.get("error"):
|
|
88
|
+
console.print(f"[red]Error: {preview['error']}[/]")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
dry_run_print(operation=operation, api_call=api_call, parameters=parameters)
|
|
91
|
+
|
|
92
|
+
|
|
71
93
|
def double_confirm(action: str, resource: str) -> None:
|
|
72
94
|
"""Require two confirmations for a destructive operation."""
|
|
73
95
|
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
@@ -18,7 +18,7 @@ from proxy_aiops.cli._common import (
|
|
|
18
18
|
cli_errors,
|
|
19
19
|
console,
|
|
20
20
|
double_confirm,
|
|
21
|
-
|
|
21
|
+
dry_run_preview,
|
|
22
22
|
get_connection,
|
|
23
23
|
)
|
|
24
24
|
|
|
@@ -88,8 +88,12 @@ def config_set(
|
|
|
88
88
|
except json.JSONDecodeError as exc:
|
|
89
89
|
raise ValueError(f"value must be valid JSON: {exc}") from exc
|
|
90
90
|
if dry_run:
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
# Through the governed call: set_config_value refuses the admin subtree,
|
|
92
|
+
# so a preview must report that rather than a green banner.
|
|
93
|
+
dry_run_preview(
|
|
94
|
+
gov.set_config_value(path=path, value=parsed, dry_run=True, target=target),
|
|
95
|
+
operation="set_config_value", api_call=f"PATCH /config/{path}",
|
|
96
|
+
parameters={"path": path, "value": value})
|
|
93
97
|
return
|
|
94
98
|
double_confirm("set config value at", path)
|
|
95
99
|
console.print_json(json.dumps(gov.set_config_value(path=path, value=parsed, target=target)))
|
|
@@ -106,8 +110,12 @@ def config_delete(
|
|
|
106
110
|
from mcp_server.tools import writes as gov
|
|
107
111
|
|
|
108
112
|
if dry_run:
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
# Through the governed call: delete_config_path refuses the admin subtree
|
|
114
|
+
# and the config root, so a preview must report that.
|
|
115
|
+
dry_run_preview(
|
|
116
|
+
gov.delete_config_path(path=path, dry_run=True, target=target),
|
|
117
|
+
operation="delete_config_path", api_call=f"DELETE /config/{path}",
|
|
118
|
+
parameters={"path": path})
|
|
111
119
|
return
|
|
112
120
|
double_confirm("delete config path", path)
|
|
113
121
|
console.print_json(json.dumps(gov.delete_config_path(path=path, target=target)))
|
|
@@ -84,6 +84,8 @@ class ProxyConnection:
|
|
|
84
84
|
auth=self._build_auth(target),
|
|
85
85
|
headers={"Accept": "application/json"},
|
|
86
86
|
)
|
|
87
|
+
# HAProxy Data Plane API generation, resolved lazily by _haproxy_path.
|
|
88
|
+
self._dpapi_prefix: str | None = None
|
|
87
89
|
|
|
88
90
|
@staticmethod
|
|
89
91
|
def _build_auth(target: TargetConfig) -> Any | None:
|
|
@@ -104,8 +106,29 @@ class ProxyConnection:
|
|
|
104
106
|
def platform(self) -> Any:
|
|
105
107
|
return self._target.platform_obj
|
|
106
108
|
|
|
109
|
+
def _haproxy_path(self, path: str) -> str:
|
|
110
|
+
"""Rewrite a ``/v3/`` Data Plane API path to ``/v2/`` on an older server.
|
|
111
|
+
|
|
112
|
+
HAProxy 3.x ships Data Plane API v3, which serves **only** ``/v3`` — every
|
|
113
|
+
``/v2`` path 404s. Older installs are the mirror image. The registry holds
|
|
114
|
+
the current (v3) paths and this probes ``/v3/info`` once per connection,
|
|
115
|
+
falling back to ``/v2`` so both generations work. Probed lazily and cached:
|
|
116
|
+
a v3 server costs one extra request per connection, a v2 server two.
|
|
117
|
+
"""
|
|
118
|
+
if not path.startswith("/v3/"):
|
|
119
|
+
return path
|
|
120
|
+
if self._dpapi_prefix is None:
|
|
121
|
+
try:
|
|
122
|
+
resp = self._client.request("GET", "/v3/info")
|
|
123
|
+
self._dpapi_prefix = "/v3/" if resp.status_code < 400 else "/v2/"
|
|
124
|
+
except httpx.HTTPError:
|
|
125
|
+
self._dpapi_prefix = "/v3/" # unreachable: let the real call report it
|
|
126
|
+
return self._dpapi_prefix + path[4:]
|
|
127
|
+
|
|
107
128
|
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
108
129
|
label = self._target.platform_obj.label
|
|
130
|
+
if self._target.platform == "haproxy":
|
|
131
|
+
path = self._haproxy_path(path)
|
|
109
132
|
try:
|
|
110
133
|
resp = self._client.request(method, path, **kwargs)
|
|
111
134
|
except httpx.HTTPError as exc:
|
|
@@ -16,6 +16,12 @@ State lives under ``ops_home()`` (default ``~/.proxy-aiops``).
|
|
|
16
16
|
from proxy_aiops.governance.audit import AuditEngine, get_engine
|
|
17
17
|
from proxy_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
|
|
18
18
|
from proxy_aiops.governance.decorators import PolicyDenied, governed_tool
|
|
19
|
+
from proxy_aiops.governance.outcome import (
|
|
20
|
+
UNKNOWN_FIELD,
|
|
21
|
+
capture_prior_state,
|
|
22
|
+
is_unknown,
|
|
23
|
+
mark_unknown,
|
|
24
|
+
)
|
|
19
25
|
from proxy_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
|
|
20
26
|
from proxy_aiops.governance.policy import TierDecision, get_policy_engine
|
|
21
27
|
from proxy_aiops.governance.readonly import READ_ONLY_ENV, is_read_only
|
|
@@ -28,6 +34,10 @@ __all__ = [
|
|
|
28
34
|
"opt_str",
|
|
29
35
|
"is_read_only",
|
|
30
36
|
"READ_ONLY_ENV",
|
|
37
|
+
"capture_prior_state",
|
|
38
|
+
"mark_unknown",
|
|
39
|
+
"is_unknown",
|
|
40
|
+
"UNKNOWN_FIELD",
|
|
31
41
|
"PolicyDenied",
|
|
32
42
|
"get_engine",
|
|
33
43
|
"AuditEngine",
|
|
@@ -34,6 +34,11 @@ from typing import Any
|
|
|
34
34
|
|
|
35
35
|
from proxy_aiops.governance.audit import detect_agent, get_engine
|
|
36
36
|
from proxy_aiops.governance.budget import BudgetExceeded, get_budget
|
|
37
|
+
from proxy_aiops.governance.outcome import (
|
|
38
|
+
clear_prior_state,
|
|
39
|
+
is_unknown,
|
|
40
|
+
take_prior_state,
|
|
41
|
+
)
|
|
37
42
|
from proxy_aiops.governance.patterns import PatternMatch, get_pattern_engine
|
|
38
43
|
from proxy_aiops.governance.policy import PolicyResult, get_policy_engine
|
|
39
44
|
from proxy_aiops.governance.readonly import is_read_only, read_only_denial
|
|
@@ -155,7 +160,7 @@ class _CallState:
|
|
|
155
160
|
"skill", "tool_name", "agent", "start", "status", "result",
|
|
156
161
|
"policy_result", "pattern_match", "audit", "policy",
|
|
157
162
|
"safe_params", "env", "risk_level", "timeout_seconds",
|
|
158
|
-
"rationale", "approved_by", "risk_tier", "undo",
|
|
163
|
+
"rationale", "approved_by", "risk_tier", "undo", "dry_run",
|
|
159
164
|
)
|
|
160
165
|
|
|
161
166
|
def __init__(
|
|
@@ -169,6 +174,10 @@ class _CallState:
|
|
|
169
174
|
timeout_seconds: int,
|
|
170
175
|
undo: Any = None,
|
|
171
176
|
) -> None:
|
|
177
|
+
# A previous call on this thread/task may have captured before-state
|
|
178
|
+
# that its own wrapper never consumed (it raised past _annotate_result).
|
|
179
|
+
# Start clean so one call's prior state can never be recorded as another's.
|
|
180
|
+
clear_prior_state()
|
|
172
181
|
self.undo = undo
|
|
173
182
|
self.skill = _infer_skill(func)
|
|
174
183
|
self.tool_name = func.__name__
|
|
@@ -187,6 +196,10 @@ class _CallState:
|
|
|
187
196
|
# log and participate in env scoping (previously only kwargs did).
|
|
188
197
|
params = _bind_params(signature, args, kwargs)
|
|
189
198
|
self.safe_params = _redact(params, sensitive)
|
|
199
|
+
# Previews are governed like any other call — audited, policy-checked,
|
|
200
|
+
# budget-counted — but they change nothing, so two things must not apply
|
|
201
|
+
# to them: the named-approver gate, and undo recording.
|
|
202
|
+
self.dry_run = bool(params.get("dry_run", False))
|
|
190
203
|
env = params.get("target", params.get("env", ""))
|
|
191
204
|
self.env = str(env) if env else ""
|
|
192
205
|
|
|
@@ -269,7 +282,15 @@ def _pre_check(state: _CallState) -> None:
|
|
|
269
282
|
params=state.safe_params,
|
|
270
283
|
)
|
|
271
284
|
state.risk_tier = tier.tier
|
|
272
|
-
|
|
285
|
+
# A preview is not the operation. Requiring a named human approver before a
|
|
286
|
+
# caller may ask "would this even be permitted?" inverts what a preview is
|
|
287
|
+
# for — you would have to obtain the approval in order to learn whether the
|
|
288
|
+
# thing needs one, or would be refused outright. The tier is still computed
|
|
289
|
+
# and still audited, so the preview can TELL the caller an approver will be
|
|
290
|
+
# needed; it just does not demand one to answer. Deny rules, budget, the
|
|
291
|
+
# read-only switch and every self-lockout guard still apply, and the write
|
|
292
|
+
# itself is gated normally.
|
|
293
|
+
if tier.requires_approver and not state.approved_by and not state.dry_run:
|
|
273
294
|
reason = (
|
|
274
295
|
f"Operation '{state.tool_name}' on '{state.env or 'target'}' requires "
|
|
275
296
|
f"'{tier.tier}' approval (rule: {tier.rule}) but no approver is recorded. "
|
|
@@ -322,17 +343,51 @@ def _annotate_result(state: _CallState, result: Any) -> Any:
|
|
|
322
343
|
|
|
323
344
|
|
|
324
345
|
def _record_undo(state: _CallState, result: Any) -> None:
|
|
325
|
-
"""Compute and persist the inverse descriptor for a
|
|
346
|
+
"""Compute and persist the inverse descriptor for a write.
|
|
347
|
+
|
|
348
|
+
Two paths:
|
|
326
349
|
|
|
327
|
-
|
|
328
|
-
|
|
350
|
+
* **Confirmed** — the tool returned normally, so ``result`` carries the
|
|
351
|
+
before-state its undo callable needs. Recorded ``effect_verified=True``.
|
|
352
|
+
* **Undetermined** — the response was lost (see
|
|
353
|
+
:mod:`proxy_aiops.governance.outcome`). The change may be live, so an
|
|
354
|
+
inverse still matters, but ``result`` is only an error payload. An
|
|
355
|
+
inverse is recorded ONLY when the write explicitly stashed its
|
|
356
|
+
before-state via ``capture_prior_state``; synthesizing one from an
|
|
357
|
+
empty result would produce a *wrong* inverse, which is worse than none.
|
|
358
|
+
|
|
359
|
+
Best-effort throughout: a broken undo callable or store must never fail
|
|
360
|
+
the call. Attaches ``_undo_id`` to dict results for later reference.
|
|
329
361
|
"""
|
|
330
362
|
if state.undo is None:
|
|
331
363
|
return
|
|
332
|
-
if
|
|
333
|
-
|
|
364
|
+
if state.dry_run:
|
|
365
|
+
# A preview changed nothing, so there is nothing to invert — and the
|
|
366
|
+
# descriptor it would produce is actively dangerous. An undo callable
|
|
367
|
+
# reads the before-state out of the result, and a preview has none, so
|
|
368
|
+
# permissive defaults (``(result.get("priorState") or {}).get("running",
|
|
369
|
+
# True)``) fill the gap with a guess. That yielded a REAL, applicable
|
|
370
|
+
# start_container token for a stop that never happened.
|
|
371
|
+
return
|
|
372
|
+
|
|
373
|
+
verified = True
|
|
374
|
+
undo_input = result
|
|
375
|
+
if is_unknown(result):
|
|
376
|
+
prior = take_prior_state()
|
|
377
|
+
if prior is None:
|
|
378
|
+
_log.warning(
|
|
379
|
+
"%s.%s outcome undetermined and no prior state captured — "
|
|
380
|
+
"no inverse recorded; the change may be live and unrecorded",
|
|
381
|
+
state.skill, state.tool_name,
|
|
382
|
+
)
|
|
383
|
+
return
|
|
384
|
+
verified = False
|
|
385
|
+
undo_input = {"priorState": prior, "outcomeUnknown": True}
|
|
386
|
+
elif isinstance(result, dict) and result.get("error"):
|
|
387
|
+
return # definite failure — no change happened, so no inverse
|
|
388
|
+
|
|
334
389
|
try:
|
|
335
|
-
descriptor = state.undo(state.safe_params,
|
|
390
|
+
descriptor = state.undo(state.safe_params, undo_input)
|
|
336
391
|
except Exception: # noqa: BLE001 — undo computation must not fail the call
|
|
337
392
|
_log.warning("undo callable for %s.%s raised", state.skill, state.tool_name,
|
|
338
393
|
exc_info=True)
|
|
@@ -347,6 +402,7 @@ def _record_undo(state: _CallState, result: Any) -> None:
|
|
|
347
402
|
tool=state.tool_name,
|
|
348
403
|
undo_descriptor=descriptor,
|
|
349
404
|
orig_params=state.safe_params,
|
|
405
|
+
effect_verified=verified,
|
|
350
406
|
)
|
|
351
407
|
if undo_id and isinstance(result, dict):
|
|
352
408
|
result.setdefault("_undo_id", undo_id)
|
|
@@ -374,7 +430,7 @@ def _finalize(state: _CallState) -> None:
|
|
|
374
430
|
# dicts BEFORE this harness sees them) must not be audited as success —
|
|
375
431
|
# compliance exception reports are built from this status.
|
|
376
432
|
if state.status == "ok" and isinstance(state.result, dict) and state.result.get("error"):
|
|
377
|
-
state.status = "error"
|
|
433
|
+
state.status = "unknown" if is_unknown(state.result) else "error"
|
|
378
434
|
|
|
379
435
|
duration = int((time.time() - state.start) * 1000)
|
|
380
436
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Tell "the write did not happen" apart from "we cannot tell whether it happened".
|
|
2
|
+
|
|
3
|
+
A governed write that loses its response is not a failed write. The request may
|
|
4
|
+
already have landed; the only thing that certainly failed is our knowledge of
|
|
5
|
+
the outcome. The harness used to collapse both into ``status='error'`` with no
|
|
6
|
+
undo token — and it did so precisely where the operation was most dangerous,
|
|
7
|
+
because the two coincide:
|
|
8
|
+
|
|
9
|
+
a write whose target is the very thing serving this tool's API
|
|
10
|
+
→ the change takes effect
|
|
11
|
+
→ the response never comes back, because what would have sent it is gone
|
|
12
|
+
→ sanitized to {"error": ...} → audited as a FAILURE → no inverse recorded
|
|
13
|
+
|
|
14
|
+
The audit trail then reports that the destructive call failed while the change
|
|
15
|
+
is live and unrecorded. For a line whose product is "governed and reversible",
|
|
16
|
+
a row that confidently states the wrong outcome is worse than a missing one.
|
|
17
|
+
|
|
18
|
+
Two primitives fix that:
|
|
19
|
+
|
|
20
|
+
``mark_unknown`` / ``is_unknown``
|
|
21
|
+
The MCP layer's ``tool_errors`` classifies the exception it caught. Only
|
|
22
|
+
errors that leave the request's fate genuinely undetermined (a read
|
|
23
|
+
timeout, a half-closed socket, a protocol error mid-response) are marked.
|
|
24
|
+
A connect error or a pool timeout means the request never left, and an HTTP
|
|
25
|
+
status error means the server answered — both are ordinary failures and are
|
|
26
|
+
NOT marked. The distinction is per-tool knowledge, so each tool's
|
|
27
|
+
``mcp_server/_shared.py`` owns the exception tuple; this module only carries
|
|
28
|
+
the marker.
|
|
29
|
+
|
|
30
|
+
``capture_prior_state`` / ``take_prior_state``
|
|
31
|
+
An undo callable receives ``(params, result)``. When the write raises, the
|
|
32
|
+
before-state that was read moments earlier dies with the exception and no
|
|
33
|
+
inverse can be computed. A write that wants its undo to survive a lost
|
|
34
|
+
response stashes the before-state *before* issuing the mutating request::
|
|
35
|
+
|
|
36
|
+
before = conn.get(...)
|
|
37
|
+
capture_prior_state({"running": before["running"]})
|
|
38
|
+
conn.post(...) # may never return
|
|
39
|
+
|
|
40
|
+
The harness then records the inverse even on the unknown path, flagged
|
|
41
|
+
``effect_verified=False`` so nobody mistakes it for a confirmed change.
|
|
42
|
+
Opt-in by design: fabricating a descriptor from an empty result would
|
|
43
|
+
produce a *wrong* inverse, which is worse than none.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from contextvars import ContextVar
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
# Public result field. Explicit rather than inferred: a caller must never have
|
|
52
|
+
# to guess from the error text whether the operation may have taken effect.
|
|
53
|
+
UNKNOWN_FIELD = "outcomeUnknown"
|
|
54
|
+
|
|
55
|
+
_UNKNOWN_NOTE = (
|
|
56
|
+
"The request was sent but no usable response came back, so this operation "
|
|
57
|
+
"MAY have taken effect on the server. Verify the current state before "
|
|
58
|
+
"retrying — a blind retry could apply it twice."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Set by a write immediately before it issues its mutating request; consumed by
|
|
62
|
+
# the harness. A ContextVar (not a module global) so concurrent calls and async
|
|
63
|
+
# tools never read each other's state.
|
|
64
|
+
_prior_state: ContextVar[dict[str, Any] | None] = ContextVar(
|
|
65
|
+
"governed_prior_state", default=None
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def mark_unknown(payload: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""Return a NEW error payload flagged as having an undetermined outcome."""
|
|
71
|
+
marked = dict(payload)
|
|
72
|
+
marked[UNKNOWN_FIELD] = True
|
|
73
|
+
marked["note"] = _UNKNOWN_NOTE
|
|
74
|
+
return marked
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_unknown(result: Any) -> bool:
|
|
78
|
+
"""True when ``result`` is an error payload whose effect is undetermined."""
|
|
79
|
+
return isinstance(result, dict) and result.get(UNKNOWN_FIELD) is True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def capture_prior_state(state: dict[str, Any]) -> None:
|
|
83
|
+
"""Stash before-state so the inverse survives a lost response.
|
|
84
|
+
|
|
85
|
+
Call this AFTER reading the before-state and BEFORE issuing the mutating
|
|
86
|
+
request. Overwrites any previously captured state for this call.
|
|
87
|
+
"""
|
|
88
|
+
_prior_state.set(dict(state))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def take_prior_state() -> dict[str, Any] | None:
|
|
92
|
+
"""Consume the captured before-state (harness-internal).
|
|
93
|
+
|
|
94
|
+
Consuming rather than peeking keeps one call's state from leaking into the
|
|
95
|
+
next on a reused thread or task.
|
|
96
|
+
"""
|
|
97
|
+
state = _prior_state.get()
|
|
98
|
+
_prior_state.set(None)
|
|
99
|
+
return state
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def clear_prior_state() -> None:
|
|
103
|
+
"""Drop any captured state. Called at the start of every governed call."""
|
|
104
|
+
_prior_state.set(None)
|