postgres-aiops 0.7.0__tar.gz → 0.9.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.
Files changed (106) hide show
  1. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/CHANGELOG.md +16 -0
  2. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/PKG-INFO +1 -1
  3. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/docs/VERIFICATION.md +16 -2
  4. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/remediation.py +33 -9
  5. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/undo.py +30 -2
  6. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/_common.py +47 -0
  7. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/query.py +2 -1
  8. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/remediate.py +25 -15
  9. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/undo.py +2 -1
  10. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/remediation.py +39 -6
  11. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/pyproject.toml +1 -1
  12. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/server.json +2 -2
  13. postgres_aiops-0.9.0/tests/test_cli_governed_exit.py +167 -0
  14. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_mcp_tools.py +35 -4
  15. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_undo_executor.py +49 -0
  16. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/uv.lock +1 -1
  17. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/.github/workflows/mcp-publish.yml +0 -0
  18. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/.github/workflows/publish.yml +0 -0
  19. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/.gitignore +0 -0
  20. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/Dockerfile +0 -0
  21. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/LICENSE +0 -0
  22. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/README.md +0 -0
  23. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/RELEASE_NOTES.md +0 -0
  24. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/SECURITY.md +0 -0
  25. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/glama.json +0 -0
  26. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/__init__.py +0 -0
  27. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/_shared.py +0 -0
  28. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/server.py +0 -0
  29. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/__init__.py +0 -0
  30. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/activity.py +0 -0
  31. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/analysis.py +0 -0
  32. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/indexes.py +0 -0
  33. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/queries.py +0 -0
  34. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/replication.py +0 -0
  35. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/server.py +0 -0
  36. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/mcp_server/tools/tables.py +0 -0
  37. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/__init__.py +0 -0
  38. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/__init__.py +0 -0
  39. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/_root.py +0 -0
  40. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/activity.py +0 -0
  41. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/analyze.py +0 -0
  42. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/doctor.py +0 -0
  43. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/index.py +0 -0
  44. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/init.py +0 -0
  45. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/overview.py +0 -0
  46. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/replication.py +0 -0
  47. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/secret.py +0 -0
  48. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/server.py +0 -0
  49. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/cli/table.py +0 -0
  50. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/config.py +0 -0
  51. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/connection.py +0 -0
  52. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/doctor.py +0 -0
  53. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/__init__.py +0 -0
  54. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/audit.py +0 -0
  55. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/budget.py +0 -0
  56. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/decorators.py +0 -0
  57. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/outcome.py +0 -0
  58. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/paths.py +0 -0
  59. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/patterns.py +0 -0
  60. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/policy.py +0 -0
  61. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/sanitize.py +0 -0
  62. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/governance/undo.py +0 -0
  63. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/__init__.py +0 -0
  64. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/_util.py +0 -0
  65. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/activity.py +0 -0
  66. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/analysis.py +0 -0
  67. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/indexes.py +0 -0
  68. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/overview.py +0 -0
  69. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/queries.py +0 -0
  70. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/replication.py +0 -0
  71. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/server.py +0 -0
  72. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/ops/tables.py +0 -0
  73. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/postgres_aiops/secretstore.py +0 -0
  74. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/SKILL.md +0 -0
  75. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
  76. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/capabilities.md +0 -0
  77. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
  78. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
  79. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/smithery.yaml +0 -0
  80. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/conftest.py +0 -0
  81. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_analysis.py +0 -0
  82. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_reads.py +0 -0
  83. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_remediate.py +0 -0
  84. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_secret.py +0 -0
  85. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_cli_writes.py +0 -0
  86. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_config.py +0 -0
  87. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_connection.py +0 -0
  88. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_connection_lost.py +0 -0
  89. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_connection_more.py +0 -0
  90. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_doctor.py +0 -0
  91. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_audit.py +0 -0
  92. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_decorators.py +0 -0
  93. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_patterns.py +0 -0
  94. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_gov_policy.py +0 -0
  95. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_governance_persistence.py +0 -0
  96. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_init.py +0 -0
  97. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_ops_more.py +0 -0
  98. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_optional_fields.py +0 -0
  99. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_reads.py +0 -0
  100. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_secretstore.py +0 -0
  101. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_secretstore_more.py +0 -0
  102. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_self_lockout.py +0 -0
  103. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_smoke.py +0 -0
  104. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_truncation.py +0 -0
  105. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_undo_replay.py +0 -0
  106. {postgres_aiops-0.7.0 → postgres_aiops-0.9.0}/tests/test_writes.py +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.9.0 — 2026-08-10
4
+
5
+ ### Fixed
6
+ - **An undetermined outcome no longer exits as a plain failure.** A write whose response was lost carries *both* `error` and `outcomeUnknown`, and the harness deliberately judges unknown first when writing the audit row — the change may have taken effect, so a blind retry could apply it twice. The CLI guard judged `error` first, so the audit said "may have taken effect" while the exit status told a script it had not happened. The two layers now agree (exit 2, not 1), and a test pins the ordering so it cannot silently flip back.
7
+ - **The CLI reported a refused or failed governed write as a success.** 10 write call sites printed the governed twin's payload and exited **0** whatever it said — and `@tool_errors` flattens every refusal, guard rejection and upstream failure into `{"error": ...}` rather than raising, so nothing downstream of a `&&` chain or a CI step could tell a blocked write from a landed one. The dry-run path already exited non-zero, which made the asymmetry worse: the preview was stricter than the write it previews. Results now route through a `checked()` helper — exit 1 on an error payload, exit 2 on an undetermined outcome, unchanged on success. This defect class had been fixed repo-by-repo several times and kept coming back; an audit across the whole line found it live in **18 of the 24 tools at once (87 call sites)**, so each tool now carries an invariant test that fails if any future CLI command prints a governed result without checking it.
8
+
9
+ ## v0.8.0 — 2026-08-03
10
+
11
+ ### Fixed
12
+ - **`undo apply` replays against the target the original write ran on.** It dispatched the inverse against whatever target the *caller* named — in practice the config's first entry — while the write's own target sat unused in the undo record. On a multi-target config the inverse therefore ran against the wrong host; it only looks harmless because the resource usually is not there, but two hosts holding the same name and the inverse **succeeds on the wrong one, silently**. An explicitly named target still wins. Line-wide: all 24 copies had the identical defect. Caught live in container-host-aiops, where a stop recorded against a Podman target replayed against a Portainer one.
13
+ - **Undoing an `ALTER SYSTEM SET` no longer leaves the parameter pinned.** The inverse wrote the prior *value* back with another `ALTER SYSTEM SET`, so a parameter that had **no** `postgresql.auto.conf` entry beforehand ended up with one. The number was right, but `postgresql.auto.conf` overrides `postgresql.conf`, so the "undo" silently shadowed the operator's config file from then on — a later edit there would have no effect. `update_setting` now records where the value came from (`pg_settings.sourcefile`), and the inverse is `ALTER SYSTEM RESET` unless the parameter was already carried by auto.conf. Live-verified on PostgreSQL 16.14 in both directions, checking `postgresql.auto.conf` itself after each undo.
14
+
15
+ ### Added
16
+ - `update_setting(reset=True)` / `remediate set <name> --reset` — `ALTER SYSTEM RESET` a parameter, dropping its `postgresql.auto.conf` entry so `postgresql.conf` or the built-in default applies again. This is what the corrected undo replays.
17
+ - `priorState` on a setting change now carries `source` and `pinnedInAutoConf`, so a caller can see whether a value was explicitly pinned or inherited.
18
+
3
19
  ## v0.7.0 — 2026-08-02
4
20
 
5
21
  ### Changed (BREAKING)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: postgres-aiops
3
- Version: 0.7.0
3
+ Version: 0.9.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
5
  Author-email: Wei Zhou <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
@@ -103,8 +103,22 @@ are open — record them as gaps rather than silently passing.
103
103
  - [x] ✅ `postgres-aiops undo apply <id>` → replayed correctly and recreated the
104
104
  index from the captured definition. (A replay bug found in an earlier
105
105
  round was fixed and is now covered by a regression test.)
106
- - [ ] `remediate set <guc> <value>` then `undo apply` → the prior value restored
107
- (**open gap** — `update_setting` was not exercised live).
106
+ - [x] ✅ `remediate set <guc> <value>` then `undo apply` — **closed 2026-08-03,
107
+ and it found a bug.** Both directions were run against PostgreSQL 16.14
108
+ with `postgresql.auto.conf` inspected after each step:
109
+ - a parameter with **no** prior auto.conf entry (`source = default`) →
110
+ undo now issues `ALTER SYSTEM RESET` and the file is left clean. It
111
+ used to write the prior *value* back as a new entry: the number was
112
+ right, but auto.conf **overrides** postgresql.conf, so the "undo"
113
+ silently pinned a parameter that had been inherited, and any later edit
114
+ to postgresql.conf would have had no effect.
115
+ - a parameter that **was** already pinned (`ALTER SYSTEM SET work_mem =
116
+ '2MB'` first) → undo restores the value and keeps the entry.
117
+ The discriminator is `pg_settings.sourcefile`: `source` reads
118
+ `configuration file` for postgresql.conf *and* auto.conf alike, so only
119
+ the filename separates them. Note the pin is **latent** — until someone
120
+ runs `pg_reload_conf()`, `source` still reads `default`, so the damage is
121
+ invisible at the moment it is done.
108
122
 
109
123
  ### 5. Irreversible writes are honest about it
110
124
  - [x] ✅ `remediate vacuum <table> --analyze` → the dead tuples were actually
@@ -50,12 +50,27 @@ def _drop_index_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
50
50
  def _update_setting_undo(params: dict[str, Any], result: Any) -> Optional[dict]:
51
51
  if not isinstance(result, dict):
52
52
  return None
53
- prior = (result.get("priorState") or {}).get("value")
54
- if prior is None or prior == "":
53
+ prior = result.get("priorState") or {}
54
+ if not prior.get("pinnedInAutoConf"):
55
+ # The parameter had no postgresql.auto.conf entry, so putting the prior
56
+ # VALUE back would restore the number while leaving behind a pin that
57
+ # shadows postgresql.conf (auto.conf wins) for good. RESET removes the
58
+ # entry and lets the previous source apply again.
59
+ return {
60
+ "tool": "update_setting",
61
+ "params": {"name": params.get("name"), "reset": True},
62
+ "skill": "postgres-aiops",
63
+ "note": (
64
+ "Inverse of update_setting: ALTER SYSTEM RESET, because the "
65
+ "parameter had no postgresql.auto.conf entry beforehand."
66
+ ),
67
+ }
68
+ value = prior.get("value")
69
+ if value is None or value == "":
55
70
  return None
56
71
  return {
57
72
  "tool": "update_setting",
58
- "params": {"name": params.get("name"), "value": prior},
73
+ "params": {"name": params.get("name"), "value": value},
59
74
  "skill": "postgres-aiops",
60
75
  "note": "Inverse of update_setting: ALTER SYSTEM SET back to the prior value.",
61
76
  }
@@ -282,16 +297,22 @@ def reindex(
282
297
  @tool_errors("dict")
283
298
  def update_setting(
284
299
  name: str,
285
- value: str,
300
+ value: Optional[str] = None,
301
+ reset: bool = False,
286
302
  dry_run: bool = False,
287
303
  target: Optional[str] = None,
288
304
  ) -> dict:
289
- """[WRITE][risk=medium] ALTER SYSTEM SET a parameter. Reversible: captures prior value.
305
+ """[WRITE][risk=medium] ALTER SYSTEM SET (or RESET) a parameter. Reversible.
290
306
 
291
307
  Writes postgresql.auto.conf; most parameters need SELECT pg_reload_conf() (or
292
308
  a restart for postmaster-context settings) to take effect — reported but NOT
293
- performed automatically. The prior value is captured so the harness records an
294
- undo that sets it back. Pass dry_run=True to preview.
309
+ performed automatically. The prior state is captured so the harness records
310
+ an undo. Pass dry_run=True to preview.
311
+
312
+ reset=True issues ALTER SYSTEM RESET, dropping the parameter's auto.conf
313
+ entry so postgresql.conf or the built-in default applies again. That is the
314
+ recorded inverse when the parameter had no auto.conf entry to begin with:
315
+ writing the prior value back would leave a pin that shadows postgresql.conf.
295
316
 
296
317
  Refuses the connection-affecting postmaster settings (listen_addresses,
297
318
  port, max_connections, superuser_reserved_connections, ssl, hba_file):
@@ -300,7 +321,8 @@ def update_setting(
300
321
 
301
322
  Args:
302
323
  name: The configuration parameter name (e.g. work_mem).
303
- value: The new value (as a string).
324
+ value: The new value (as a string). Omit when reset=True.
325
+ reset: If True, ALTER SYSTEM RESET the parameter instead of setting it.
304
326
  dry_run: If True, preview without changing.
305
327
  target: Target name from config; omit for the default.
306
328
  """
@@ -309,5 +331,7 @@ def update_setting(
309
331
  # call — a preview of a refused setting must report the refusal.
310
332
  ops.guard_update_setting(name)
311
333
  if dry_run:
334
+ if reset:
335
+ return {"dryRun": True, "wouldReset": {"name": name}}
312
336
  return {"dryRun": True, "wouldSet": {"name": name, "value": value}}
313
- return ops.update_setting(conn, name, value)
337
+ return ops.update_setting(conn, name, value, reset=reset)
@@ -80,6 +80,32 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
80
80
  }
81
81
 
82
82
 
83
+ def _origin_target(rec: dict) -> Optional[str]:
84
+ """The target the ORIGINAL write ran against, read from its recorded params.
85
+
86
+ Without this the replay goes to whatever target the caller names — in
87
+ practice the config's first entry — so on a multi-target config the inverse
88
+ runs against the wrong host. It only *looks* harmless because the resource
89
+ usually does not exist there; when two hosts both hold a resource with the
90
+ same name the inverse succeeds on the wrong one, silently.
91
+
92
+ Caught live on 2026-08-03 in container-host-aiops: a ``stop_container``
93
+ recorded against a Podman target replayed against a Portainer target.
94
+
95
+ A ``target`` in ``orig_params`` also means the original call accepted one,
96
+ so the fallback cannot hand a keyword to a tool that has no such parameter.
97
+ """
98
+ raw = rec.get("orig_params") or ""
99
+ try:
100
+ orig = json.loads(raw) if raw else {}
101
+ except (ValueError, TypeError):
102
+ return None # unreadable: fall back to the caller/default, as before
103
+ if not isinstance(orig, dict):
104
+ return None
105
+ value = orig.get("target")
106
+ return value if isinstance(value, str) and value else None
107
+
108
+
83
109
  @mcp.tool()
84
110
  @governed_tool(risk_level="medium")
85
111
  @tool_errors("dict")
@@ -143,8 +169,10 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
143
169
  }
144
170
 
145
171
  call_params = dict(params)
146
- if target is not None and "target" not in call_params:
147
- call_params["target"] = target
172
+ if "target" not in call_params:
173
+ chosen = target if target is not None else _origin_target(rec)
174
+ if chosen is not None:
175
+ call_params["target"] = chosen
148
176
  result = fn(**call_params)
149
177
 
150
178
  # Only mark applied when the inverse did not itself return an error dict.
@@ -103,6 +103,53 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
103
103
  console.print("[magenta] Run without --dry-run to execute.[/]\n")
104
104
 
105
105
 
106
+ #: Exit status for a write whose outcome could not be determined. Kept distinct
107
+ #: from 0 (confirmed) and 1 (failed) on purpose: a write whose response was lost
108
+ #: is not a failure, but it is emphatically not a success either, and a script
109
+ #: must be able to tell all three apart.
110
+ EXIT_UNDETERMINED = 2
111
+
112
+
113
+ def checked(result: Any) -> Any:
114
+ """Return ``result``, or abort when it reports a failed/undetermined write.
115
+
116
+ Every CLI command that calls a governed twin MUST pass the result through
117
+ here before printing its success line.
118
+
119
+ Governed twins are wrapped in ``@tool_errors``, which flattens any exception
120
+ into ``{"error": ...}`` and **returns** it. The CLI therefore never sees the
121
+ exception, so a command that prints its result unconditionally reports a
122
+ refused or failed operation exactly like a successful one — and exits 0, so
123
+ a script cannot tell either. The dry-run path already refused with a
124
+ non-zero status, which made the asymmetry worse: the preview was stricter
125
+ than the real call.
126
+
127
+ ``outcomeUnknown`` (set by the harness when a write's response is lost) is
128
+ neither success nor failure — the change may still have landed. It gets its
129
+ own line and :data:`EXIT_UNDETERMINED`, never a silent success.
130
+ """
131
+ if not isinstance(result, dict):
132
+ return result
133
+ error = result.get("error")
134
+ # ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
135
+ # write whose response was lost carries BOTH keys, and it is audited
136
+ # `unknown` precisely because it may have taken effect. Reporting that as a
137
+ # plain failure would tell a script the change did not happen and invite the
138
+ # double-apply the payload's own note warns about.
139
+ if result.get("outcomeUnknown"):
140
+ console.print(
141
+ f"[yellow]Outcome undetermined: {result.get('note') or ''}[/]"
142
+ )
143
+ raise typer.Exit(EXIT_UNDETERMINED)
144
+ if error:
145
+ console.print(f"[red]Error: {error}[/]")
146
+ hint = result.get("hint")
147
+ if hint:
148
+ console.print(f"[dim]{hint}[/]")
149
+ raise typer.Exit(1)
150
+ return result
151
+
152
+
106
153
  def dry_run_preview(
107
154
  preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
108
155
  ) -> None:
@@ -10,6 +10,7 @@ import typer
10
10
  from postgres_aiops.cli._common import (
11
11
  DryRunOption,
12
12
  TargetOption,
13
+ checked,
13
14
  cli_errors,
14
15
  console,
15
16
  double_confirm,
@@ -74,4 +75,4 @@ def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> N
74
75
  operation="reset_query_stats", api_call="SELECT pg_stat_statements_reset()")
75
76
  return
76
77
  double_confirm("reset pg_stat_statements on", "this target")
77
- console.print_json(json.dumps(gov.reset_query_stats(target=target)))
78
+ console.print_json(json.dumps(checked(gov.reset_query_stats(target=target))))
@@ -10,6 +10,7 @@ import typer
10
10
  from postgres_aiops.cli._common import (
11
11
  DryRunOption,
12
12
  TargetOption,
13
+ checked,
13
14
  cli_errors,
14
15
  console,
15
16
  double_confirm,
@@ -42,7 +43,7 @@ def remediate_terminate(
42
43
  api_call="SELECT pg_terminate_backend(pid)", parameters={"pid": pid})
43
44
  return
44
45
  double_confirm("terminate backend", str(pid))
45
- console.print_json(json.dumps(gov.terminate_backend(pid=pid, target=target)))
46
+ console.print_json(json.dumps(checked(gov.terminate_backend(pid=pid, target=target))))
46
47
 
47
48
 
48
49
  @remediate_app.command("cancel")
@@ -62,7 +63,7 @@ def remediate_cancel(
62
63
  api_call="SELECT pg_cancel_backend(pid)", parameters={"pid": pid})
63
64
  return
64
65
  double_confirm("cancel query on backend", str(pid))
65
- console.print_json(json.dumps(gov.cancel_query(pid=pid, target=target)))
66
+ console.print_json(json.dumps(checked(gov.cancel_query(pid=pid, target=target))))
66
67
 
67
68
 
68
69
  @remediate_app.command("vacuum")
@@ -85,7 +86,7 @@ def remediate_vacuum(
85
86
  return
86
87
  double_confirm("VACUUM", table)
87
88
  console.print_json(
88
- json.dumps(gov.run_vacuum(table=table, full=full, analyze=analyze, target=target)))
89
+ json.dumps(checked(gov.run_vacuum(table=table, full=full, analyze=analyze, target=target))))
89
90
 
90
91
 
91
92
  @remediate_app.command("analyze-table")
@@ -104,7 +105,7 @@ def remediate_analyze(
104
105
  operation="run_analyze", api_call=f"ANALYZE {table}")
105
106
  return
106
107
  double_confirm("ANALYZE", table)
107
- console.print_json(json.dumps(gov.run_analyze(table=table, target=target)))
108
+ console.print_json(json.dumps(checked(gov.run_analyze(table=table, target=target))))
108
109
 
109
110
 
110
111
  @remediate_app.command("create-index")
@@ -131,8 +132,8 @@ def remediate_create_index(
131
132
  parameters={"columns": columns, "name": name, "unique": unique})
132
133
  return
133
134
  double_confirm("create index on", table)
134
- result = gov.create_index(table=table, columns=columns, name=name, unique=unique,
135
- concurrently=concurrently, target=target)
135
+ result = checked(gov.create_index(table=table, columns=columns, name=name, unique=unique,
136
+ concurrently=concurrently, target=target))
136
137
  console.print_json(json.dumps(result))
137
138
 
138
139
 
@@ -154,7 +155,7 @@ def remediate_drop_index(
154
155
  return
155
156
  double_confirm("drop index", name)
156
157
  console.print_json(
157
- json.dumps(gov.drop_index(name=name, concurrently=concurrently, target=target)))
158
+ json.dumps(checked(gov.drop_index(name=name, concurrently=concurrently, target=target))))
158
159
 
159
160
 
160
161
  @remediate_app.command("reindex")
@@ -176,28 +177,37 @@ def remediate_reindex(
176
177
  operation="reindex", api_call=f"REINDEX {kind} {target_name}")
177
178
  return
178
179
  double_confirm(f"REINDEX {kind}", target_name)
179
- console.print_json(json.dumps(gov.reindex(target_name=target_name, kind=kind,
180
- concurrently=concurrently, target=target)))
180
+ console.print_json(json.dumps(checked(gov.reindex(target_name=target_name, kind=kind,
181
+ concurrently=concurrently, target=target))))
181
182
 
182
183
 
183
184
  @remediate_app.command("set")
184
185
  @cli_errors
185
186
  def remediate_set(
186
187
  name: Annotated[str, typer.Argument(help="Parameter name (e.g. work_mem)")],
187
- value: Annotated[str, typer.Argument(help="New value")],
188
+ value: Annotated[str | None, typer.Argument(help="New value")] = None,
189
+ reset: Annotated[bool, typer.Option(
190
+ "--reset",
191
+ help="ALTER SYSTEM RESET instead: drop the parameter's "
192
+ "postgresql.auto.conf entry so postgresql.conf / the default applies",
193
+ )] = False,
188
194
  target: TargetOption = None,
189
195
  dry_run: DryRunOption = False,
190
196
  ) -> None:
191
- """ALTER SYSTEM SET a parameter (reversible; dry-run + confirm)."""
197
+ """ALTER SYSTEM SET a parameter, or --reset it (reversible; dry-run + confirm)."""
192
198
  from mcp_server.tools import remediation as gov
193
199
 
200
+ action = f"ALTER SYSTEM RESET {name}" if reset else f"ALTER SYSTEM SET {name} ="
194
201
  if dry_run:
195
202
  # Through the governed call: update_setting refuses the
196
203
  # connection-affecting postmaster settings, so a preview must report it.
197
204
  dry_run_preview(
198
- gov.update_setting(name=name, value=value, dry_run=True, target=target),
205
+ gov.update_setting(
206
+ name=name, value=value, reset=reset, dry_run=True, target=target),
199
207
  operation="update_setting",
200
- api_call=f"ALTER SYSTEM SET {name} = ...", parameters={"value": value})
208
+ api_call=f"{action} ...",
209
+ parameters={"value": value, "reset": reset})
201
210
  return
202
- double_confirm(f"ALTER SYSTEM SET {name} =", value)
203
- console.print_json(json.dumps(gov.update_setting(name=name, value=value, target=target)))
211
+ double_confirm(action, "(default)" if reset else str(value))
212
+ console.print_json(json.dumps(
213
+ checked(gov.update_setting(name=name, value=value, reset=reset, target=target))))
@@ -15,6 +15,7 @@ import typer
15
15
  from postgres_aiops.cli._common import (
16
16
  DryRunOption,
17
17
  TargetOption,
18
+ checked,
18
19
  cli_errors,
19
20
  console,
20
21
  double_confirm,
@@ -65,4 +66,4 @@ def undo_apply_cmd(
65
66
  )
66
67
  return
67
68
  double_confirm("apply undo", undo_id)
68
- console.print_json(json.dumps(gov.undo_apply(undo_id=undo_id, target=target)))
69
+ console.print_json(json.dumps(checked(gov.undo_apply(undo_id=undo_id, target=target))))
@@ -338,13 +338,33 @@ def guard_update_setting(name: str) -> None:
338
338
  )
339
339
 
340
340
 
341
- def update_setting(conn: Any, name: str, value: str) -> dict:
342
- """[WRITE] ALTER SYSTEM SET a parameter. Reversible: captures the prior value.
341
+ def _pinned_in_auto_conf(prior: dict) -> bool:
342
+ """Was this parameter already carried by ``postgresql.auto.conf``?
343
+
344
+ ``pg_settings.source`` reads ``configuration file`` for both postgresql.conf
345
+ and postgresql.auto.conf, so only ``sourcefile`` separates them. A parameter
346
+ that has never been ALTER SYSTEM'd reads ``source = default`` with a NULL
347
+ sourcefile, or points at postgresql.conf. Verified on PostgreSQL 16.14.
348
+ """
349
+ sourcefile = prior.get("sourcefile") or ""
350
+ return str(sourcefile).endswith("postgresql.auto.conf")
351
+
352
+
353
+ def update_setting(
354
+ conn: Any, name: str, value: str | None = None, reset: bool = False
355
+ ) -> dict:
356
+ """[WRITE] ALTER SYSTEM SET (or RESET) a parameter. Captures the prior state.
343
357
 
344
358
  Writes to postgresql.auto.conf; most parameters need ``SELECT pg_reload_conf()``
345
359
  (or a restart for ``pending_restart`` ones) to take effect — this is reported
346
360
  but NOT performed automatically.
347
361
 
362
+ ``reset=True`` issues ``ALTER SYSTEM RESET``, removing the parameter's
363
+ postgresql.auto.conf entry so whatever set it before (postgresql.conf, or the
364
+ built-in default) applies again. That is the correct inverse for a parameter
365
+ that had **no** auto.conf entry: writing the prior *value* back would restore
366
+ the number while leaving a pin that silently shadows postgresql.conf forever.
367
+
348
368
  **Refuses the connection-affecting postmaster settings**
349
369
  (``listen_addresses``, ``port``, ``max_connections``,
350
370
  ``superuser_reserved_connections``, ``ssl``, ``hba_file``). Those are the
@@ -353,22 +373,35 @@ def update_setting(conn: Any, name: str, value: str) -> dict:
353
373
  """
354
374
  guard_update_setting(name)
355
375
  setting_name = _validate_setting_name(name)
376
+ if not reset and value is None:
377
+ raise ValueError(
378
+ f"No value given for {setting_name!r}. Pass a value, or reset=True "
379
+ f"to drop its postgresql.auto.conf entry."
380
+ )
356
381
  prior = conn.query_one(
357
- "SELECT setting, unit, context, pending_restart FROM pg_settings WHERE name = %(n)s",
382
+ "SELECT setting, unit, context, pending_restart, source, sourcefile "
383
+ "FROM pg_settings WHERE name = %(n)s",
358
384
  {"n": setting_name},
359
385
  ) or {}
360
- literal = quote_literal(str(value))
361
- sql = f"ALTER SYSTEM SET {setting_name} = {literal}" # nosec B608 — name validated, value literal-quoted
386
+ if reset:
387
+ sql = f"ALTER SYSTEM RESET {setting_name}" # nosec B608 — name validated
388
+ else:
389
+ literal = quote_literal(str(value))
390
+ sql = f"ALTER SYSTEM SET {setting_name} = {literal}" # nosec B608 — name validated, value literal-quoted
362
391
  conn.execute(sql)
363
392
  needs_restart = bool(prior.get("pending_restart")) or prior.get("context") == "postmaster"
364
393
  return {
365
394
  "action": "update_setting",
366
395
  "setting": setting_name,
367
- "newValue": str(value),
396
+ "newValue": None if reset else str(value),
397
+ "reset": reset,
368
398
  "priorState": {
369
399
  "value": s(prior.get("setting"), 256),
370
400
  "unit": opt(prior.get("unit"), 32),
371
401
  "context": s(prior.get("context"), 32),
402
+ # Where the value came from decides what "put it back" means.
403
+ "source": opt(prior.get("source"), 32),
404
+ "pinnedInAutoConf": _pinned_in_auto_conf(prior),
372
405
  },
373
406
  "reloadRequired": True,
374
407
  "restartRequired": needs_restart,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "postgres-aiops"
7
- version = "0.7.0"
7
+ version = "0.9.0"
8
8
  description = "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)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Postgres-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.7.0",
10
+ "version": "0.9.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.7.0",
15
+ "version": "0.9.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -0,0 +1,167 @@
1
+ """The CLI must never report a failed governed write as a success.
2
+
3
+ Two regressions are pinned here.
4
+
5
+ **The behaviour** — ``checked()`` aborts non-zero on an error payload and on an
6
+ undetermined outcome. Governed twins are wrapped in ``@tool_errors``, which
7
+ flattens every exception into ``{"error": ...}`` and *returns* it, so a
8
+ command that prints its result unconditionally exits 0 for an operation that
9
+ was refused or failed.
10
+
11
+ **The invariant** — every CLI call into a governed write twin routes its result
12
+ through that helper. This is the part that matters over time: the defect was
13
+ fixed repo-by-repo several times and kept coming back, because nothing stopped
14
+ a *new* command from printing a governed result bare. A structural assertion
15
+ does. It was introduced after an audit found the preview path guarded and the
16
+ real write path unguarded in 18 of the 24 tools at once — the preview was
17
+ stricter than the write it previews.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ import pathlib
24
+
25
+ import pytest
26
+ import typer
27
+
28
+ from postgres_aiops.cli._common import checked
29
+
30
+ CLI_DIR = pathlib.Path(__file__).resolve().parents[1] / "postgres_aiops" / "cli"
31
+ TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "mcp_server" / "tools"
32
+
33
+ #: Helpers that end the command non-zero when the payload reports a failure.
34
+ CHECKERS = ['checked', 'dry_run_preview']
35
+
36
+
37
+ @pytest.mark.unit
38
+ def test_checked_aborts_on_error_payload():
39
+ with pytest.raises(typer.Exit) as exc:
40
+ checked({"error": "refused: that would lock this tool out"})
41
+ assert exc.value.exit_code == 1
42
+
43
+
44
+ @pytest.mark.unit
45
+ def test_checked_passes_a_successful_payload_through():
46
+ payload = {"action": "did_a_thing", "ok": True}
47
+ assert checked(payload) == payload
48
+
49
+
50
+ def _callee(node: ast.AST) -> str | None:
51
+ if isinstance(node, ast.Name):
52
+ return node.id
53
+ if isinstance(node, ast.Attribute):
54
+ return node.attr
55
+ return None
56
+
57
+
58
+ def _write_tool_names() -> set[str]:
59
+ names: set[str] = set()
60
+ for path in TOOLS_DIR.glob("*.py"):
61
+ tree = ast.parse(path.read_text())
62
+ for node in ast.walk(tree):
63
+ if isinstance(node, ast.FunctionDef) and "[WRITE]" in (ast.get_docstring(node) or ""):
64
+ names.add(node.name)
65
+ return names
66
+
67
+
68
+ def _aborts_on_failure(fn: ast.FunctionDef) -> bool:
69
+ """Does this function raise ``typer.Exit`` off a failure key in the payload?
70
+
71
+ Several commands guard with a locally named helper (``_require_ok``,
72
+ ``_emit``, ...) rather than the shared one. Those are correct guards, so the
73
+ invariant recognises the *shape* instead of a list of blessed names.
74
+ """
75
+ raises_exit = any(
76
+ isinstance(n, ast.Call) and _callee(n.func) == "Exit" for n in ast.walk(fn)
77
+ )
78
+ reads_failure_key = any(
79
+ isinstance(n, ast.Constant) and n.value in ("error", "outcomeUnknown")
80
+ for n in ast.walk(fn)
81
+ )
82
+ return raises_exit and reads_failure_key
83
+
84
+
85
+ def _checker_names() -> set[str]:
86
+ """CHECKERS, every local guard, and any wrapper delegating to one of them."""
87
+ funcs: dict[str, list[ast.FunctionDef]] = {}
88
+ for path in CLI_DIR.rglob("*.py"):
89
+ for node in ast.walk(ast.parse(path.read_text())):
90
+ if isinstance(node, ast.FunctionDef):
91
+ funcs.setdefault(node.name, []).append(node)
92
+ found = set(CHECKERS) | {
93
+ name for name, defs in funcs.items() if any(_aborts_on_failure(f) for f in defs)
94
+ }
95
+ changed = True
96
+ while changed:
97
+ changed = False
98
+ for name, defs in funcs.items():
99
+ if name in found:
100
+ continue
101
+ for fn in defs:
102
+ if any(isinstance(n, ast.Call) and _callee(n.func) in found for n in ast.walk(fn)):
103
+ found.add(name)
104
+ changed = True
105
+ break
106
+ return found
107
+
108
+
109
+ @pytest.mark.unit
110
+ def test_every_cli_governed_write_result_is_checked():
111
+ tools = _write_tool_names()
112
+ assert tools, "no [WRITE] tools parsed — the invariant would vacuously pass"
113
+ checkers = _checker_names()
114
+ unchecked: list[str] = []
115
+
116
+ for path in sorted(CLI_DIR.rglob("*.py")):
117
+ tree = ast.parse(path.read_text())
118
+ for func in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
119
+ parents: dict[ast.AST, ast.AST] = {}
120
+ for node in ast.walk(func):
121
+ for child in ast.iter_child_nodes(node):
122
+ parents[child] = node
123
+ guarded = {
124
+ arg.id
125
+ for node in ast.walk(func)
126
+ if isinstance(node, ast.Call) and _callee(node.func) in checkers
127
+ for arg in list(node.args) + [kw.value for kw in node.keywords]
128
+ if isinstance(arg, ast.Name)
129
+ }
130
+ for node in ast.walk(func):
131
+ if not (isinstance(node, ast.Call) and _callee(node.func) in tools):
132
+ continue
133
+ parent = parents.get(node)
134
+ if isinstance(parent, (ast.keyword, ast.Starred)):
135
+ parent = parents.get(parent)
136
+ if isinstance(parent, ast.Call) and _callee(parent.func) in checkers:
137
+ continue
138
+ if isinstance(parent, ast.Assign) and any(
139
+ isinstance(t, ast.Name) and t.id in guarded for t in parent.targets
140
+ ):
141
+ continue
142
+ unchecked.append(f"{path.name}:{node.lineno}:{_callee(node.func)}")
143
+
144
+ assert not unchecked, (
145
+ "these CLI call sites print a governed write's result without routing it "
146
+ f"through {sorted(checkers)}, so a refusal exits 0: {unchecked}"
147
+ )
148
+
149
+ @pytest.mark.unit
150
+ def test_undetermined_outcome_beats_error_and_is_not_a_plain_failure():
151
+ """A lost response carries BOTH keys — it must exit 2, not 1.
152
+
153
+ ``@tool_errors`` flattens the exception into ``{"error": ...}`` and, when
154
+ the failure is one where the write may still have landed, also sets
155
+ ``outcomeUnknown``. The harness deliberately judges unknown *before* error
156
+ when writing the audit row, for the reason its own note gives: the change
157
+ may have taken effect, so a blind retry could apply it twice. The CLI has to
158
+ agree, or the audit says "may have taken effect" while the exit status tells
159
+ a script it did not happen.
160
+ """
161
+ with pytest.raises(typer.Exit) as exc:
162
+ checked({
163
+ "error": "connection reset while awaiting the response",
164
+ "outcomeUnknown": True,
165
+ "note": "may have taken effect",
166
+ })
167
+ assert exc.value.exit_code == 2
@@ -77,17 +77,48 @@ def test_drop_index_undo_none_without_definition():
77
77
 
78
78
 
79
79
  @pytest.mark.unit
80
- def test_update_setting_undo_sets_back_prior_value():
80
+ def test_update_setting_undo_sets_back_prior_value_when_it_was_pinned():
81
+ """Only a parameter already carried by auto.conf is restored by value."""
81
82
  from mcp_server.tools.remediation import _update_setting_undo
82
83
 
83
- desc = _update_setting_undo({"name": "work_mem"}, {"priorState": {"value": "4MB"}})
84
+ desc = _update_setting_undo(
85
+ {"name": "work_mem"},
86
+ {"priorState": {"value": "4MB", "source": "configuration file",
87
+ "pinnedInAutoConf": True}},
88
+ )
84
89
  assert desc["tool"] == "update_setting"
85
90
  assert desc["params"] == {"name": "work_mem", "value": "4MB"}
86
91
 
87
92
 
88
93
  @pytest.mark.unit
89
- @pytest.mark.parametrize("result", [{"priorState": {"value": ""}}, {"priorState": {}}, "x"])
90
- def test_update_setting_undo_none_when_no_prior(result):
94
+ def test_update_setting_undo_resets_when_there_was_no_auto_conf_entry():
95
+ """Restoring the VALUE would leave a pin that shadows postgresql.conf.
96
+
97
+ Shape from a live PostgreSQL 16.14: an untouched parameter reads
98
+ ``source = default`` with a NULL sourcefile, so the inverse of setting it
99
+ must be ALTER SYSTEM RESET, not ALTER SYSTEM SET back to the old number.
100
+ """
101
+ from mcp_server.tools.remediation import _update_setting_undo
102
+
103
+ desc = _update_setting_undo(
104
+ {"name": "work_mem"},
105
+ {"priorState": {"value": "4096", "unit": "kB", "context": "user",
106
+ "source": "default", "pinnedInAutoConf": False}},
107
+ )
108
+ assert desc["params"] == {"name": "work_mem", "reset": True}
109
+ assert "RESET" in desc["note"]
110
+
111
+
112
+ @pytest.mark.unit
113
+ @pytest.mark.parametrize(
114
+ "result",
115
+ [
116
+ {"priorState": {"value": "", "pinnedInAutoConf": True}},
117
+ {"priorState": {"pinnedInAutoConf": True}},
118
+ "x",
119
+ ],
120
+ )
121
+ def test_update_setting_undo_none_when_pinned_but_no_prior_value(result):
91
122
  from mcp_server.tools.remediation import _update_setting_undo
92
123
 
93
124
  assert _update_setting_undo({"name": "work_mem"}, result) is None
@@ -21,18 +21,21 @@ from mcp_server.tools import undo as gov
21
21
  from postgres_aiops.governance import governed_tool
22
22
 
23
23
  _CALLS: list[dict] = []
24
+ _TARGETS: list = []
24
25
 
25
26
 
26
27
  @governed_tool(risk_level="low")
27
28
  def _undo_probe(value: str = "", target=None) -> dict:
28
29
  """Synthetic inverse target used only by the undo-executor tests."""
29
30
  _CALLS.append({"value": value})
31
+ _TARGETS.append(target)
30
32
  return {"ok": True, "value": value}
31
33
 
32
34
 
33
35
  @pytest.fixture
34
36
  def gov_home(tmp_path, monkeypatch):
35
37
  _CALLS.clear()
38
+ _TARGETS.clear()
36
39
  # Register the probe only for the duration of these tests so it never
37
40
  # pollutes the real tool registry (which exact-count smoke tests assert on).
38
41
  mcp.add_tool(_undo_probe, name="_undo_probe")
@@ -77,6 +80,52 @@ def test_undo_apply_dispatches_inverse_and_marks_applied(gov_home):
77
80
  assert uid not in {u["undoId"] for u in gov.undo_list()["undos"]}
78
81
 
79
82
 
83
+ @pytest.mark.unit
84
+ def test_undo_apply_replays_against_the_target_the_write_ran_on(gov_home):
85
+ """A replay must go to the host the original write went to.
86
+
87
+ Without this the inverse ran against whatever target the caller named — in
88
+ practice the config's first entry — so on a multi-target config it hit the
89
+ wrong host. It only looked harmless because the resource usually does not
90
+ exist there; two hosts holding the same name and the inverse succeeds on the
91
+ wrong one, silently. Caught live on 2026-08-03 in container-host-aiops: a
92
+ stop recorded against a Podman target replayed against a Portainer target.
93
+ """
94
+ uid = undo_mod.get_undo_store().record(
95
+ skill="probe",
96
+ tool="orig_op",
97
+ undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
98
+ orig_params={"value": "v1", "target": "second-host"},
99
+ )
100
+ result = gov.undo_apply(undo_id=uid)
101
+ assert result["applied"] is True
102
+ assert _TARGETS == ["second-host"]
103
+
104
+
105
+ @pytest.mark.unit
106
+ def test_undo_apply_prefers_an_explicit_caller_target(gov_home):
107
+ """An explicitly named target still wins — the fallback is only a default."""
108
+ uid = undo_mod.get_undo_store().record(
109
+ skill="probe",
110
+ tool="orig_op",
111
+ undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
112
+ orig_params={"value": "v1", "target": "second-host"},
113
+ )
114
+ gov.undo_apply(undo_id=uid, target="chosen-host")
115
+ assert _TARGETS == ["chosen-host"]
116
+
117
+
118
+ @pytest.mark.unit
119
+ def test_undo_apply_survives_unreadable_orig_params(gov_home):
120
+ """Corrupt orig_params must not break the replay — just lose the fallback."""
121
+ uid = _record(params={"value": "v1"})
122
+ with sqlite3.connect(gov_home / "undo.db") as db:
123
+ db.execute("UPDATE undo_log SET orig_params = ? WHERE undo_id = ?",
124
+ ("{not json", uid))
125
+ assert gov.undo_apply(undo_id=uid)["applied"] is True
126
+ assert _TARGETS == [None]
127
+
128
+
80
129
  @pytest.mark.unit
81
130
  def test_undo_apply_dry_run_previews_without_running(gov_home):
82
131
  uid = _record()
@@ -492,7 +492,7 @@ wheels = [
492
492
 
493
493
  [[package]]
494
494
  name = "postgres-aiops"
495
- version = "0.7.0"
495
+ version = "0.9.0"
496
496
  source = { editable = "." }
497
497
  dependencies = [
498
498
  { name = "cryptography" },
File without changes
File without changes