postgres-aiops 0.4.0__tar.gz → 0.5.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.
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/CHANGELOG.md +11 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/PKG-INFO +2 -2
- postgres_aiops-0.5.0/RELEASE_NOTES.md +50 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/_shared.py +26 -4
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/remediation.py +18 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/undo.py +17 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/_common.py +22 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/remediate.py +23 -12
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/connection.py +23 -1
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/__init__.py +10 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/decorators.py +65 -9
- postgres_aiops-0.5.0/postgres_aiops/governance/outcome.py +104 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/sanitize.py +11 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/undo.py +52 -13
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/remediation.py +114 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/pyproject.toml +2 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/server.json +2 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_remediate.py +54 -4
- postgres_aiops-0.5.0/tests/test_connection_lost.py +69 -0
- postgres_aiops-0.5.0/tests/test_governance_persistence.py +362 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_optional_fields.py +4 -1
- postgres_aiops-0.5.0/tests/test_self_lockout.py +195 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_smoke.py +2 -2
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/uv.lock +1 -1
- postgres_aiops-0.4.0/RELEASE_NOTES.md +0 -48
- postgres_aiops-0.4.0/tests/test_governance_persistence.py +0 -170
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/.github/workflows/mcp-publish.yml +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/.github/workflows/publish.yml +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/.gitignore +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/LICENSE +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/README.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/SECURITY.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/docs/VERIFICATION.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/server.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/activity.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/analysis.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/indexes.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/queries.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/replication.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/server.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/mcp_server/tools/tables.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/_root.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/activity.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/analyze.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/doctor.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/index.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/init.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/overview.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/query.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/replication.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/secret.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/server.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/table.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/cli/undo.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/config.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/doctor.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/audit.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/budget.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/paths.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/patterns.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/policy.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/governance/readonly.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/__init__.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/_util.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/activity.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/analysis.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/indexes.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/overview.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/queries.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/replication.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/server.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/ops/tables.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/postgres_aiops/secretstore.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/SKILL.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/capabilities.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/smithery.yaml +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/conftest.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_analysis.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_reads.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_secret.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_cli_writes.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_config.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_connection.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_connection_more.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_doctor.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_audit.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_decorators.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_patterns.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_gov_policy.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_init.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_mcp_tools.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_ops_more.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_readonly.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_reads.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_secretstore.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_secretstore_more.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_truncation.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_undo_executor.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_undo_replay.py +0 -0
- {postgres_aiops-0.4.0 → postgres_aiops-0.5.0}/tests/test_writes.py +0 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.5.0 — 2026-07-20
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`terminate_backend` / `cancel_query` refuse this tool's own backend**, and `update_setting` refuses the postmaster settings that would strand the undo at the next restart (`listen_addresses`, `port`, `max_connections`, `ssl`, `hba_file`, …).
|
|
7
|
+
- A connection lost **mid-statement** now raises a dedicated error and is audited as `unknown`, not `error`..
|
|
8
|
+
- 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).
|
|
9
|
+
- Harness: a dry-run no longer records an undo token, and no longer requires a named approver. Guards now run on the preview path.
|
|
10
|
+
- Truncated strings end in an ellipsis instead of being cut silently; error messages are capped at 800 chars, not 300.
|
|
11
|
+
|
|
12
|
+
See RELEASE_NOTES.md for the full detail.
|
|
13
|
+
|
|
3
14
|
## v0.3.0 — 2026-07-17
|
|
4
15
|
|
|
5
16
|
### Added
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: postgres-aiops
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis 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,50 @@
|
|
|
1
|
+
# Release notes — postgres-aiops 0.5.0
|
|
2
|
+
|
|
3
|
+
Previous release: 0.4.0.
|
|
4
|
+
|
|
5
|
+
## In this tool
|
|
6
|
+
|
|
7
|
+
- **`terminate_backend` / `cancel_query` refuse this tool's own backend**, and `update_setting` refuses the postmaster settings that would strand the undo at the next restart (`listen_addresses`, `port`, `max_connections`, `ssl`, `hba_file`, …). The read path already filtered `pg_backend_pid()`; the writes did not.
|
|
8
|
+
- A connection lost **mid-statement** now raises a dedicated error and is audited as `unknown`, not `error`.
|
|
9
|
+
|
|
10
|
+
## Every tool in the line: previews and undetermined outcomes
|
|
11
|
+
|
|
12
|
+
This release fixes three harness defects that were silently degrading the audit
|
|
13
|
+
trail and the undo store.
|
|
14
|
+
|
|
15
|
+
**A write that loses its response is no longer recorded as a failure.** The
|
|
16
|
+
harness assumed a sanitized error meant nothing had happened. That assumption is
|
|
17
|
+
false in exactly the case that matters most: when a write severs its own
|
|
18
|
+
connection, the request has already landed, the response cannot come back, and
|
|
19
|
+
the operation was recorded as `status=error` with **no undo token created at
|
|
20
|
+
all**. Transport-level failures are now audited as `status=unknown`, the result
|
|
21
|
+
says plainly that the operation may have taken effect and should be verified
|
|
22
|
+
before retrying, and a write that stashed its before-state has its inverse
|
|
23
|
+
recorded anyway — flagged `effectVerified: false`, which `undo_list` and
|
|
24
|
+
`undo_apply` both surface. Existing `undo.db` files are migrated in place; their
|
|
25
|
+
rows read as verified, which is accurate, since the old code only ever recorded
|
|
26
|
+
on the confirmed path.
|
|
27
|
+
|
|
28
|
+
**A dry-run no longer writes an undo token.** Previews were recording inverses
|
|
29
|
+
built from a before-state they never had: the undo callback's permissive default
|
|
30
|
+
filled the gap with a guess, producing a real, applicable token for an operation
|
|
31
|
+
that never happened.
|
|
32
|
+
|
|
33
|
+
**A dry-run no longer demands a named approver.** Requiring an approval in order
|
|
34
|
+
to ask whether something needs approval inverts what a preview is for. The tier
|
|
35
|
+
is still computed and still audited, so the preview can tell you an approver
|
|
36
|
+
will be needed; it just no longer refuses to answer. The write itself is gated
|
|
37
|
+
exactly as before.
|
|
38
|
+
|
|
39
|
+
The invariant, now stated: **a dry_run may read; it must never write.** Guards
|
|
40
|
+
run on the preview path, which means a preview can and does report that an
|
|
41
|
+
operation would be refused.
|
|
42
|
+
|
|
43
|
+
## Also line-wide
|
|
44
|
+
|
|
45
|
+
- **Truncated text now ends in an ellipsis** instead of being cut silently. This
|
|
46
|
+
line already treats a silent cut as a defect for lists; it was doing exactly
|
|
47
|
+
that to strings.
|
|
48
|
+
- **Error messages are capped at 800 characters, not 300.** These messages end
|
|
49
|
+
with what to do instead, so the cap was removing the most useful sentence of
|
|
50
|
+
every long refusal.
|
|
@@ -20,14 +20,30 @@ from typing import Any, Optional
|
|
|
20
20
|
from mcp.server.fastmcp import FastMCP
|
|
21
21
|
|
|
22
22
|
from postgres_aiops.config import load_config
|
|
23
|
-
from postgres_aiops.connection import ConnectionManager, PgError
|
|
24
|
-
from postgres_aiops.governance import sanitize
|
|
23
|
+
from postgres_aiops.connection import ConnectionManager, PgConnectionLostError, PgError
|
|
24
|
+
from postgres_aiops.governance import mark_unknown, sanitize
|
|
25
25
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
27
27
|
|
|
28
28
|
_DOCTOR_HINT = "Run 'postgres-aiops doctor' to verify connectivity and credentials."
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
# Long enough to carry the remediation sentence. These messages teach the
|
|
32
|
+
# caller what to do instead, and that clause comes last — a 300-char cap cut
|
|
33
|
+
# it off silently on every refusal long enough to need one.
|
|
34
|
+
_ERROR_MAX = 800
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Failures that leave the statement's fate genuinely undetermined. Raised
|
|
38
|
+
# only from the statement-executing path, so it means an ESTABLISHED link
|
|
39
|
+
# died mid-statement — not that the server was unreachable. The driver gives
|
|
40
|
+
# both the same class, so the connection layer discriminates by position and
|
|
41
|
+
# raises a dedicated class; this layer only has to recognise it.
|
|
42
|
+
# PostgreSQL rolls back on backend termination, so usually nothing landed —
|
|
43
|
+
# but a COMMIT whose acknowledgement was lost did land.
|
|
44
|
+
_UNDETERMINED_ERRORS = (PgConnectionLostError,)
|
|
45
|
+
|
|
46
|
+
|
|
31
47
|
def _safe_error(exc: Exception, tool: str) -> str:
|
|
32
48
|
"""Return an agent-safe error string; log full detail server-side only."""
|
|
33
49
|
logger.error("Tool %s failed", tool, exc_info=True)
|
|
@@ -41,7 +57,7 @@ def _safe_error(exc: Exception, tool: str) -> str:
|
|
|
41
57
|
PgError,
|
|
42
58
|
)
|
|
43
59
|
if isinstance(exc, _passthrough):
|
|
44
|
-
return sanitize(str(exc),
|
|
60
|
+
return sanitize(str(exc), _ERROR_MAX)
|
|
45
61
|
return f"{type(exc).__name__}: operation failed."
|
|
46
62
|
|
|
47
63
|
|
|
@@ -65,7 +81,13 @@ def tool_errors(shape: str = "dict") -> Callable:
|
|
|
65
81
|
return [{"error": msg, "hint": _DOCTOR_HINT}]
|
|
66
82
|
if shape == "str":
|
|
67
83
|
return f"Error: {msg} {_DOCTOR_HINT}"
|
|
68
|
-
|
|
84
|
+
payload = {"error": msg, "hint": _DOCTOR_HINT}
|
|
85
|
+
# Flatten the exception into a dict and its type is gone
|
|
86
|
+
# for good — so classify here, while it is still known,
|
|
87
|
+
# whether the operation may nonetheless have taken effect.
|
|
88
|
+
if isinstance(e, _UNDETERMINED_ERRORS):
|
|
89
|
+
return mark_unknown(payload)
|
|
90
|
+
return payload
|
|
69
91
|
|
|
70
92
|
return wrapper
|
|
71
93
|
|
|
@@ -73,12 +73,18 @@ def terminate_backend(pid: int, dry_run: bool = False, target: Optional[str] = N
|
|
|
73
73
|
Captures the backend's pid + query for the audit trail; a terminate cannot be
|
|
74
74
|
undone, so no undo is offered. Pass dry_run=True to preview.
|
|
75
75
|
|
|
76
|
+
Refuses this tool's own backend pid — including under dry_run, which must
|
|
77
|
+
report a refusal rather than preview a call that will be refused.
|
|
78
|
+
|
|
76
79
|
Args:
|
|
77
80
|
pid: Backend process id (from list_activity).
|
|
78
81
|
dry_run: If True, preview without terminating.
|
|
79
82
|
target: Target name from config; omit for the default.
|
|
80
83
|
"""
|
|
81
84
|
conn = _get_connection(target)
|
|
85
|
+
# Ahead of the dry_run return: a preview whose real call would be refused
|
|
86
|
+
# must say so, or the caller reads the refusal as transient and retries.
|
|
87
|
+
ops.guard_terminate_backend(conn, pid, "terminate_backend")
|
|
82
88
|
if dry_run:
|
|
83
89
|
return {"dryRun": True, "wouldTerminate": {"pid": pid}}
|
|
84
90
|
return ops.terminate_backend(conn, pid)
|
|
@@ -93,12 +99,16 @@ def cancel_query(pid: int, dry_run: bool = False, target: Optional[str] = None)
|
|
|
93
99
|
Captures the backend's pid + query for audit; a cancel has no undo. Pass
|
|
94
100
|
dry_run=True to preview.
|
|
95
101
|
|
|
102
|
+
Refuses this tool's own backend pid — the query it would cancel is this very
|
|
103
|
+
call. Enforced under dry_run too.
|
|
104
|
+
|
|
96
105
|
Args:
|
|
97
106
|
pid: Backend process id (from list_activity).
|
|
98
107
|
dry_run: If True, preview without cancelling.
|
|
99
108
|
target: Target name from config; omit for the default.
|
|
100
109
|
"""
|
|
101
110
|
conn = _get_connection(target)
|
|
111
|
+
ops.guard_terminate_backend(conn, pid, "cancel_query")
|
|
102
112
|
if dry_run:
|
|
103
113
|
return {"dryRun": True, "wouldCancel": {"pid": pid}}
|
|
104
114
|
return ops.cancel_query(conn, pid)
|
|
@@ -283,6 +293,11 @@ def update_setting(
|
|
|
283
293
|
performed automatically. The prior value is captured so the harness records an
|
|
284
294
|
undo that sets it back. Pass dry_run=True to preview.
|
|
285
295
|
|
|
296
|
+
Refuses the connection-affecting postmaster settings (listen_addresses,
|
|
297
|
+
port, max_connections, superuser_reserved_connections, ssl, hba_file):
|
|
298
|
+
nothing breaks now, but the operator's next restart applies them and strands
|
|
299
|
+
the undo. Enforced under dry_run too.
|
|
300
|
+
|
|
286
301
|
Args:
|
|
287
302
|
name: The configuration parameter name (e.g. work_mem).
|
|
288
303
|
value: The new value (as a string).
|
|
@@ -290,6 +305,9 @@ def update_setting(
|
|
|
290
305
|
target: Target name from config; omit for the default.
|
|
291
306
|
"""
|
|
292
307
|
conn = _get_connection(target)
|
|
308
|
+
# Static denylist, so this costs nothing and cannot diverge from the real
|
|
309
|
+
# call — a preview of a refused setting must report the refusal.
|
|
310
|
+
ops.guard_update_setting(name)
|
|
293
311
|
if dry_run:
|
|
294
312
|
return {"dryRun": True, "wouldSet": {"name": name, "value": value}}
|
|
295
313
|
return ops.update_setting(conn, name, value)
|
|
@@ -36,6 +36,11 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
36
36
|
Each entry names the original tool, the inverse tool that ``undo_apply``
|
|
37
37
|
would run, and a human note. Use the ``undoId`` with ``undo_apply``.
|
|
38
38
|
|
|
39
|
+
Each entry carries ``effectVerified``. False means the original write
|
|
40
|
+
lost its response, so the change it reverses is PROBABLE, not confirmed —
|
|
41
|
+
check the live state before applying, and do not report the result as a
|
|
42
|
+
restore of a state that may never have been reached.
|
|
43
|
+
|
|
39
44
|
Args:
|
|
40
45
|
limit: Max rows to return (default 50).
|
|
41
46
|
target: Unused (undo state is host-local); accepted for CLI uniformity.
|
|
@@ -52,6 +57,7 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
|
|
|
52
57
|
"originalTool": r["tool"],
|
|
53
58
|
"inverseTool": r["undo_tool"],
|
|
54
59
|
"note": r.get("note", ""),
|
|
60
|
+
"effectVerified": bool(r.get("effect_verified", 1)),
|
|
55
61
|
}
|
|
56
62
|
for r in rows
|
|
57
63
|
],
|
|
@@ -99,10 +105,12 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
99
105
|
f"Inverse tool '{inverse_tool}' is not registered on this server; cannot apply."
|
|
100
106
|
)
|
|
101
107
|
|
|
108
|
+
effect_verified = bool(rec.get("effect_verified", 1))
|
|
102
109
|
if dry_run:
|
|
103
110
|
return {
|
|
104
111
|
"dryRun": True,
|
|
105
112
|
"undoId": undo_id,
|
|
113
|
+
"effectVerified": effect_verified,
|
|
106
114
|
"wouldApply": {"tool": inverse_tool, "params": params},
|
|
107
115
|
}
|
|
108
116
|
|
|
@@ -121,6 +129,15 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
|
|
|
121
129
|
return {
|
|
122
130
|
"undoId": undo_id,
|
|
123
131
|
"applied": applied,
|
|
132
|
+
"effectVerified": effect_verified,
|
|
124
133
|
"inverseTool": inverse_tool,
|
|
125
134
|
"result": result,
|
|
135
|
+
"note": (
|
|
136
|
+
""
|
|
137
|
+
if effect_verified
|
|
138
|
+
else "The original write lost its response, so it was never confirmed "
|
|
139
|
+
"to have taken effect. This inverse ran regardless; report the "
|
|
140
|
+
"CURRENT server state, not a restore that may have had nothing "
|
|
141
|
+
"to restore."
|
|
142
|
+
),
|
|
126
143
|
}
|
|
@@ -95,6 +95,28 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
|
|
|
95
95
|
console.print("[magenta] Run without --dry-run to execute.[/]\n")
|
|
96
96
|
|
|
97
97
|
|
|
98
|
+
def dry_run_preview(
|
|
99
|
+
preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Render a GOVERNED dry-run result as the human-readable DRY-RUN banner.
|
|
102
|
+
|
|
103
|
+
``preview`` must come from calling the governed tool with ``dry_run=True``,
|
|
104
|
+
so every guard it carries has already run against the real target. A refusal
|
|
105
|
+
arrives as ``{"error": ...}`` (``tool_errors`` flattens the exception) — it is
|
|
106
|
+
printed like any other CLI error and exits non-zero, exactly as the real
|
|
107
|
+
write would. Printing a green banner for a call that is about to be refused
|
|
108
|
+
is the preview being wrong, not merely incomplete.
|
|
109
|
+
|
|
110
|
+
On the allowed path the banner is byte-for-byte what it always was: routing
|
|
111
|
+
through the governed call buys the guard and the audit row, not a new
|
|
112
|
+
serialization.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(preview, dict) and preview.get("error"):
|
|
115
|
+
console.print(f"[red]Error: {preview['error']}[/]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
dry_run_print(operation=operation, api_call=api_call, parameters=parameters)
|
|
118
|
+
|
|
119
|
+
|
|
98
120
|
def double_confirm(action: str, resource: str) -> None:
|
|
99
121
|
"""Require two confirmations for a destructive operation."""
|
|
100
122
|
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
@@ -13,6 +13,7 @@ from postgres_aiops.cli._common import (
|
|
|
13
13
|
cli_errors,
|
|
14
14
|
console,
|
|
15
15
|
double_confirm,
|
|
16
|
+
dry_run_preview,
|
|
16
17
|
dry_run_print,
|
|
17
18
|
)
|
|
18
19
|
|
|
@@ -31,13 +32,17 @@ def remediate_terminate(
|
|
|
31
32
|
dry_run: DryRunOption = False,
|
|
32
33
|
) -> None:
|
|
33
34
|
"""Terminate a backend (no undo; dry-run + confirm)."""
|
|
35
|
+
from mcp_server.tools import remediation as gov
|
|
36
|
+
|
|
34
37
|
if dry_run:
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
# Through the governed call: terminate_backend refuses this tool's own
|
|
39
|
+
# pid, so a preview must report that rather than a green banner.
|
|
40
|
+
dry_run_preview(
|
|
41
|
+
gov.terminate_backend(pid=pid, dry_run=True, target=target),
|
|
42
|
+
operation="terminate_backend",
|
|
43
|
+
api_call="SELECT pg_terminate_backend(pid)", parameters={"pid": pid})
|
|
37
44
|
return
|
|
38
45
|
double_confirm("terminate backend", str(pid))
|
|
39
|
-
from mcp_server.tools import remediation as gov
|
|
40
|
-
|
|
41
46
|
console.print_json(json.dumps(gov.terminate_backend(pid=pid, target=target)))
|
|
42
47
|
|
|
43
48
|
|
|
@@ -49,13 +54,15 @@ def remediate_cancel(
|
|
|
49
54
|
dry_run: DryRunOption = False,
|
|
50
55
|
) -> None:
|
|
51
56
|
"""Cancel a backend's running query (no undo; dry-run + confirm)."""
|
|
57
|
+
from mcp_server.tools import remediation as gov
|
|
58
|
+
|
|
52
59
|
if dry_run:
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
dry_run_preview(
|
|
61
|
+
gov.cancel_query(pid=pid, dry_run=True, target=target),
|
|
62
|
+
operation="cancel_query",
|
|
63
|
+
api_call="SELECT pg_cancel_backend(pid)", parameters={"pid": pid})
|
|
55
64
|
return
|
|
56
65
|
double_confirm("cancel query on backend", str(pid))
|
|
57
|
-
from mcp_server.tools import remediation as gov
|
|
58
|
-
|
|
59
66
|
console.print_json(json.dumps(gov.cancel_query(pid=pid, target=target)))
|
|
60
67
|
|
|
61
68
|
|
|
@@ -169,11 +176,15 @@ def remediate_set(
|
|
|
169
176
|
dry_run: DryRunOption = False,
|
|
170
177
|
) -> None:
|
|
171
178
|
"""ALTER SYSTEM SET a parameter (reversible; dry-run + confirm)."""
|
|
179
|
+
from mcp_server.tools import remediation as gov
|
|
180
|
+
|
|
172
181
|
if dry_run:
|
|
173
|
-
|
|
174
|
-
|
|
182
|
+
# Through the governed call: update_setting refuses the
|
|
183
|
+
# connection-affecting postmaster settings, so a preview must report it.
|
|
184
|
+
dry_run_preview(
|
|
185
|
+
gov.update_setting(name=name, value=value, dry_run=True, target=target),
|
|
186
|
+
operation="update_setting",
|
|
187
|
+
api_call=f"ALTER SYSTEM SET {name} = ...", parameters={"value": value})
|
|
175
188
|
return
|
|
176
189
|
double_confirm(f"ALTER SYSTEM SET {name} =", value)
|
|
177
|
-
from mcp_server.tools import remediation as gov
|
|
178
|
-
|
|
179
190
|
console.print_json(json.dumps(gov.update_setting(name=name, value=value, target=target)))
|
|
@@ -41,6 +41,24 @@ class PgError(Exception):
|
|
|
41
41
|
super().__init__(message)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
class PgConnectionLostError(PgError):
|
|
45
|
+
"""An ESTABLISHED connection dropped while a statement was running.
|
|
46
|
+
|
|
47
|
+
Distinct from an ordinary failure because the outcome is genuinely
|
|
48
|
+
undetermined: the statement may have committed before the link died.
|
|
49
|
+
PostgreSQL rolls back on backend termination, so usually nothing landed —
|
|
50
|
+
but a COMMIT whose acknowledgement was lost did land, and from here the two
|
|
51
|
+
are indistinguishable. The MCP layer maps this to ``status=unknown`` rather
|
|
52
|
+
than asserting a failure it cannot vouch for.
|
|
53
|
+
|
|
54
|
+
Note the discriminator is WHERE it was raised, not the class or sqlstate:
|
|
55
|
+
psycopg reports both "could not connect" and "server closed the connection
|
|
56
|
+
unexpectedly" as ``OperationalError``, and a client-side detection of a
|
|
57
|
+
dropped link carries no sqlstate at all — so position is the only reliable
|
|
58
|
+
signal.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
44
62
|
def _teaching_message(exc: psycopg.Error, target: TargetConfig) -> str:
|
|
45
63
|
"""Map a psycopg error to an actionable, teaching message."""
|
|
46
64
|
sqlstate = getattr(exc, "sqlstate", None)
|
|
@@ -124,7 +142,11 @@ class PgConnection:
|
|
|
124
142
|
cur.execute(sql, params)
|
|
125
143
|
return getattr(cur, "statusmessage", "") or "OK"
|
|
126
144
|
except psycopg.Error as exc:
|
|
127
|
-
|
|
145
|
+
# Reached only on an established connection, so an OperationalError
|
|
146
|
+
# here means the link died mid-statement — not that we failed to
|
|
147
|
+
# reach the server.
|
|
148
|
+
cls = PgConnectionLostError if isinstance(exc, psycopg.OperationalError) else PgError
|
|
149
|
+
raise cls(
|
|
128
150
|
_teaching_message(exc, self._target),
|
|
129
151
|
sqlstate=getattr(exc, "sqlstate", None),
|
|
130
152
|
) from exc
|
|
@@ -16,6 +16,12 @@ State lives under ``ops_home()`` (default ``~/.postgres-aiops``).
|
|
|
16
16
|
from postgres_aiops.governance.audit import AuditEngine, get_engine
|
|
17
17
|
from postgres_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
|
|
18
18
|
from postgres_aiops.governance.decorators import PolicyDenied, governed_tool
|
|
19
|
+
from postgres_aiops.governance.outcome import (
|
|
20
|
+
UNKNOWN_FIELD,
|
|
21
|
+
capture_prior_state,
|
|
22
|
+
is_unknown,
|
|
23
|
+
mark_unknown,
|
|
24
|
+
)
|
|
19
25
|
from postgres_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
|
|
20
26
|
from postgres_aiops.governance.policy import TierDecision, get_policy_engine
|
|
21
27
|
from postgres_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 postgres_aiops.governance.audit import detect_agent, get_engine
|
|
36
36
|
from postgres_aiops.governance.budget import BudgetExceeded, get_budget
|
|
37
|
+
from postgres_aiops.governance.outcome import (
|
|
38
|
+
clear_prior_state,
|
|
39
|
+
is_unknown,
|
|
40
|
+
take_prior_state,
|
|
41
|
+
)
|
|
37
42
|
from postgres_aiops.governance.patterns import PatternMatch, get_pattern_engine
|
|
38
43
|
from postgres_aiops.governance.policy import PolicyResult, get_policy_engine
|
|
39
44
|
from postgres_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:`postgres_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)
|