queue-aiops 0.2.2__tar.gz → 0.3.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 (92) hide show
  1. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/CHANGELOG.md +11 -0
  2. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/PKG-INFO +2 -2
  3. queue_aiops-0.3.0/RELEASE_NOTES.md +50 -0
  4. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/_shared.py +38 -3
  5. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/tools/undo.py +17 -0
  6. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/tools/writes.py +17 -0
  7. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/pyproject.toml +2 -2
  8. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/_common.py +22 -0
  9. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/redis_cmds.py +15 -5
  10. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/connection.py +17 -0
  11. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/__init__.py +10 -0
  12. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/decorators.py +65 -9
  13. queue_aiops-0.3.0/queue_aiops/governance/outcome.py +104 -0
  14. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/sanitize.py +11 -2
  15. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/undo.py +52 -13
  16. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/ops/redis_reads.py +16 -2
  17. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/ops/writes.py +131 -1
  18. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/server.json +2 -2
  19. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/fakes.py +8 -0
  20. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_cli_writes.py +11 -2
  21. queue_aiops-0.3.0/tests/test_governance_persistence.py +367 -0
  22. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_optional_fields.py +4 -1
  23. queue_aiops-0.3.0/tests/test_self_kill.py +165 -0
  24. queue_aiops-0.3.0/tests/test_self_lockout.py +222 -0
  25. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_writes.py +9 -2
  26. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/uv.lock +1 -1
  27. queue_aiops-0.2.2/RELEASE_NOTES.md +0 -29
  28. queue_aiops-0.2.2/tests/test_governance_persistence.py +0 -175
  29. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/.github/workflows/mcp-publish.yml +0 -0
  30. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/.github/workflows/publish.yml +0 -0
  31. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/.gitignore +0 -0
  32. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/LICENSE +0 -0
  33. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/README.md +0 -0
  34. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/SECURITY.md +0 -0
  35. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/docs/VERIFICATION.md +0 -0
  36. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/__init__.py +0 -0
  37. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/server.py +0 -0
  38. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/tools/__init__.py +0 -0
  39. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/tools/analysis.py +0 -0
  40. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/tools/overview.py +0 -0
  41. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/tools/rabbit_reads.py +0 -0
  42. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/mcp_server/tools/redis_reads.py +0 -0
  43. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/__init__.py +0 -0
  44. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/__init__.py +0 -0
  45. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/_root.py +0 -0
  46. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/analyze.py +0 -0
  47. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/doctor.py +0 -0
  48. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/init.py +0 -0
  49. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/overview.py +0 -0
  50. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/rabbit_cmds.py +0 -0
  51. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/secret.py +0 -0
  52. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/cli/undo.py +0 -0
  53. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/config.py +0 -0
  54. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/doctor.py +0 -0
  55. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/audit.py +0 -0
  56. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/budget.py +0 -0
  57. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/paths.py +0 -0
  58. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/patterns.py +0 -0
  59. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/policy.py +0 -0
  60. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/governance/readonly.py +0 -0
  61. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/ops/__init__.py +0 -0
  62. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/ops/_util.py +0 -0
  63. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/ops/analysis.py +0 -0
  64. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/ops/overview.py +0 -0
  65. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/ops/rabbit_reads.py +0 -0
  66. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/platform.py +0 -0
  67. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/queue_aiops/secretstore.py +0 -0
  68. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/skills/queue-aiops/SKILL.md +0 -0
  69. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
  70. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/skills/queue-aiops/references/capabilities.md +0 -0
  71. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/skills/queue-aiops/references/cli-reference.md +0 -0
  72. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/skills/queue-aiops/references/setup-guide.md +0 -0
  73. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/smithery.yaml +0 -0
  74. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/conftest.py +0 -0
  75. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_analysis.py +0 -0
  76. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_cli_rabbit_writes.py +0 -0
  77. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_cli_reads.py +0 -0
  78. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_cli_secret.py +0 -0
  79. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_connection.py +0 -0
  80. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_doctor.py +0 -0
  81. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_gov_audit.py +0 -0
  82. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_gov_decorators.py +0 -0
  83. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_gov_patterns.py +0 -0
  84. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_gov_policy.py +0 -0
  85. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_init.py +0 -0
  86. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_mcp_reads.py +0 -0
  87. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_platform.py +0 -0
  88. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_readonly.py +0 -0
  89. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_reads.py +0 -0
  90. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_secretstore.py +0 -0
  91. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_smoke.py +0 -0
  92. {queue_aiops-0.2.2 → queue_aiops-0.3.0}/tests/test_undo_executor.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.0 — 2026-07-20
4
+
5
+ ### Fixed
6
+ - **`redis_config_set` refuses the parameters that lock this tool out of the server**: `requirepass`, `masterauth`, `bind`, `protected-mode`, `maxclients`, `port`, `unixsocket`, `aclfile`, and anything `tls-*`.
7
+ - `list_clients` no longer returns this tool's own connection, and `redis_kill_client` refuses it — by id or by address.
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.1.1 — 2026-07-17
4
15
 
5
16
  ### Fixed
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queue-aiops
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Governed AI-ops for redis + rabbitmq: memory-pressure/latency/queue-backlog/connection-churn RCAs, slowlog and SCAN-budgeted big-key sampling, queues/policies/nodes, and governed writes (config set, client kill, purge/delete queue, policies) with a built-in governance harness (audit, budget, undo, risk tiers)
5
- Author-email: wei <zhouwei008@gmail.com>
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 — queue-aiops 0.3.0
2
+
3
+ Previous release: 0.2.2.
4
+
5
+ ## In this tool
6
+
7
+ - **`redis_config_set` refuses the parameters that lock this tool out of the server**: `requirepass`, `masterauth`, `bind`, `protected-mode`, `maxclients`, `port`, `unixsocket`, `aclfile`, and anything `tls-*`. Setting `requirepass` invalidated the stored credential, and the recorded undo then needed a connection that could no longer authenticate. The refusal happens **before** the prior value is read, which also stops the live password being copied into `audit.db` and the undo store.
8
+ - `list_clients` no longer returns this tool's own connection, and `redis_kill_client` refuses it — by id or by address. The two sibling tools already filtered themselves out on the read path; Redis did not.
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.
@@ -17,17 +17,46 @@ from collections.abc import Callable
17
17
  from pathlib import Path
18
18
  from typing import Any, Optional
19
19
 
20
+ import httpx
21
+ import redis
20
22
  from mcp.server.fastmcp import FastMCP
21
23
 
22
24
  from queue_aiops.config import load_config
23
25
  from queue_aiops.connection import ConnectionManager, QueueApiError
24
- from queue_aiops.governance import sanitize
26
+ from queue_aiops.governance import mark_unknown, sanitize
25
27
 
26
28
  logger = logging.getLogger(__name__)
27
29
 
28
30
  _DOCTOR_HINT = "Run 'queue-aiops doctor' to verify connectivity and credentials."
29
31
 
30
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
+ # redis TimeoutError means the command went out and the reply did not come
42
+ # back. redis ConnectionError is excluded: it also covers a connection that
43
+ # was never established.
44
+ _UNDETERMINED_ERRORS = (
45
+ httpx.ReadTimeout,
46
+ httpx.WriteTimeout,
47
+ httpx.ReadError,
48
+ httpx.WriteError,
49
+ httpx.RemoteProtocolError,
50
+ redis.exceptions.TimeoutError,
51
+ )
52
+
53
+
54
+ # Long enough to carry the remediation sentence. These messages teach the
55
+ # caller what to do instead, and that clause comes last — a 300-char cap cut
56
+ # it off silently on every refusal long enough to need one.
57
+ _ERROR_MAX = 800
58
+
59
+
31
60
  def _safe_error(exc: Exception, tool: str) -> str:
32
61
  """Return an agent-safe error string; log full detail server-side only."""
33
62
  logger.error("Tool %s failed", tool, exc_info=True)
@@ -41,7 +70,7 @@ def _safe_error(exc: Exception, tool: str) -> str:
41
70
  QueueApiError,
42
71
  )
43
72
  if isinstance(exc, _passthrough):
44
- return sanitize(str(exc), 300)
73
+ return sanitize(str(exc), _ERROR_MAX)
45
74
  return f"{type(exc).__name__}: operation failed."
46
75
 
47
76
 
@@ -65,7 +94,13 @@ def tool_errors(shape: str = "dict") -> Callable:
65
94
  return [{"error": msg, "hint": _DOCTOR_HINT}]
66
95
  if shape == "str":
67
96
  return f"Error: {msg} {_DOCTOR_HINT}"
68
- return {"error": msg, "hint": _DOCTOR_HINT}
97
+ payload = {"error": msg, "hint": _DOCTOR_HINT}
98
+ # Flatten the exception into a dict and its type is gone
99
+ # for good — so classify here, while it is still known,
100
+ # whether the operation may nonetheless have taken effect.
101
+ if isinstance(e, _UNDETERMINED_ERRORS):
102
+ return mark_unknown(payload)
103
+ return payload
69
104
 
70
105
  return wrapper
71
106
 
@@ -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
  }
@@ -141,6 +141,13 @@ def redis_config_set(
141
141
  restores its prior value. Runtime-only — not persisted to the config file.
142
142
  Pass dry_run=True to preview.
143
143
 
144
+ Refuses the parameters that would lock this tool out of the instance
145
+ (requirepass, masterauth, bind, port, protected-mode, maxclients,
146
+ unixsocket, aclfile, tls-*) — the undo could never be replayed over a
147
+ connection those settings break. Change those in redis.conf and restart.
148
+ The refusal applies under dry_run too: a preview whose real call would be
149
+ refused must report that, not a green 'wouldSet'.
150
+
144
151
  Args:
145
152
  parameter: Config parameter name (e.g. maxmemory-policy), from redis_config_get.
146
153
  value: New value.
@@ -148,6 +155,9 @@ def redis_config_set(
148
155
  target: redis target name from config; omit for the default.
149
156
  """
150
157
  conn = _get_connection(target)
158
+ # Ahead of the dry_run return: a preview whose real call would be refused
159
+ # must say so, or the caller reads the refusal as transient and retries.
160
+ ops.guard_config_set(parameter)
151
161
  if dry_run:
152
162
  return {"dryRun": True, "wouldSet": {"parameter": parameter, "value": value}}
153
163
  return ops.config_set(conn, parameter, value)
@@ -169,6 +179,10 @@ def redis_kill_client(
169
179
  is recorded; most clients transparently reconnect. Pass dry_run=True to
170
180
  preview.
171
181
 
182
+ Refuses this tool's own connection, by id or by addr — including under
183
+ dry_run, which must report a refusal rather than preview a call that will be
184
+ refused.
185
+
172
186
  Args:
173
187
  client_id: Client id from redis_clients (preferred).
174
188
  addr: Or the client's 'ip:port' address.
@@ -176,6 +190,9 @@ def redis_kill_client(
176
190
  target: redis target name from config; omit for the default.
177
191
  """
178
192
  conn = _get_connection(target)
193
+ # Ahead of the dry_run return: a preview whose real call would be refused
194
+ # must say so, or the caller reads the refusal as transient and retries.
195
+ ops.guard_kill_client(conn, client_id=client_id, addr=addr)
179
196
  if dry_run:
180
197
  return {"dryRun": True, "wouldKill": {"clientId": client_id, "addr": addr}}
181
198
  return ops.kill_client(conn, client_id=client_id, addr=addr)
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "queue-aiops"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Governed AI-ops for redis + rabbitmq: memory-pressure/latency/queue-backlog/connection-churn RCAs, slowlog and SCAN-budgeted big-key sampling, queues/policies/nodes, and governed writes (config set, client kill, purge/delete queue, policies) with a built-in governance harness (audit, budget, undo, risk tiers)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.11"
12
- authors = [{ name = "wei", email = "zhouwei008@gmail.com" }]
12
+ authors = [{ name = "Wei Zhou", email = "zhouwei008@gmail.com" }]
13
13
  dependencies = [
14
14
  "redis>=5.0,<7.0",
15
15
  "httpx>=0.27,<1.0",
@@ -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}'[/]")
@@ -13,7 +13,7 @@ from queue_aiops.cli._common import (
13
13
  cli_errors,
14
14
  console,
15
15
  double_confirm,
16
- dry_run_print,
16
+ dry_run_preview,
17
17
  get_connection,
18
18
  )
19
19
 
@@ -128,8 +128,13 @@ def redis_config_set(
128
128
  from mcp_server.tools import writes as gov
129
129
 
130
130
  if dry_run:
131
- dry_run_print(operation="redis_config_set", api_call="CONFIG SET",
132
- parameters={"parameter": parameter, "value": value})
131
+ # Through the governed call: redis_config_set refuses the self-affecting
132
+ # parameters, so a preview must report that rather than a green banner.
133
+ dry_run_preview(
134
+ gov.redis_config_set(parameter=parameter, value=value, dry_run=True,
135
+ target=target),
136
+ operation="redis_config_set", api_call="CONFIG SET",
137
+ parameters={"parameter": parameter, "value": value})
133
138
  return
134
139
  double_confirm("set config parameter", parameter)
135
140
  console.print_json(
@@ -150,8 +155,13 @@ def redis_kill_client(
150
155
 
151
156
  who = str(client_id) if client_id else addr
152
157
  if dry_run:
153
- dry_run_print(operation="redis_kill_client", api_call="CLIENT KILL",
154
- parameters={"client_id": client_id, "addr": addr})
158
+ # Through the governed call: redis_kill_client refuses this tool's own
159
+ # connection, so a preview must report that rather than a green banner.
160
+ dry_run_preview(
161
+ gov.redis_kill_client(client_id=client_id, addr=addr, dry_run=True,
162
+ target=target),
163
+ operation="redis_kill_client", api_call="CLIENT KILL",
164
+ parameters={"client_id": client_id, "addr": addr})
155
165
  return
156
166
  double_confirm("kill client", who or "(unspecified)")
157
167
  console.print_json(
@@ -161,6 +161,23 @@ class QueueConnection:
161
161
  out = self._redis_call("CLIENT LIST", "client_list")
162
162
  return [c for c in out if isinstance(c, dict)] if isinstance(out, list) else []
163
163
 
164
+ def redis_client_id(self) -> int | None:
165
+ """This connection's own client id (CLIENT ID), or None if undeterminable.
166
+
167
+ Used to keep the tool's own connection out of ``list_clients`` and to
168
+ refuse ``kill_client`` against it — killing the connection you are
169
+ calling through is an operation that destroys its own reversibility, and
170
+ a kill has no undo to begin with.
171
+
172
+ ``None`` means UNKNOWN and callers must treat it as such: never as
173
+ "it is me", and never as "it is not me" in a way that hides a real row.
174
+ """
175
+ try:
176
+ out = self._redis_call("CLIENT ID", "client_id")
177
+ return int(out) if out is not None else None
178
+ except Exception: # noqa: BLE001 — unknown identity, never a false "it is me"
179
+ return None
180
+
164
181
  def redis_client_kill_id(self, client_id: int) -> int:
165
182
  """CLIENT KILL ID <id>; returns the number of clients killed."""
166
183
  out = self._redis_call(
@@ -16,6 +16,12 @@ State lives under ``ops_home()`` (default ``~/.queue-aiops``).
16
16
  from queue_aiops.governance.audit import AuditEngine, get_engine
17
17
  from queue_aiops.governance.budget import BudgetExceeded, BudgetTracker, get_budget
18
18
  from queue_aiops.governance.decorators import PolicyDenied, governed_tool
19
+ from queue_aiops.governance.outcome import (
20
+ UNKNOWN_FIELD,
21
+ capture_prior_state,
22
+ is_unknown,
23
+ mark_unknown,
24
+ )
19
25
  from queue_aiops.governance.patterns import Pattern, PatternMatch, get_pattern_engine
20
26
  from queue_aiops.governance.policy import TierDecision, get_policy_engine
21
27
  from queue_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 queue_aiops.governance.audit import detect_agent, get_engine
36
36
  from queue_aiops.governance.budget import BudgetExceeded, get_budget
37
+ from queue_aiops.governance.outcome import (
38
+ clear_prior_state,
39
+ is_unknown,
40
+ take_prior_state,
41
+ )
37
42
  from queue_aiops.governance.patterns import PatternMatch, get_pattern_engine
38
43
  from queue_aiops.governance.policy import PolicyResult, get_policy_engine
39
44
  from queue_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
- if tier.requires_approver and not state.approved_by:
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 successful write.
346
+ """Compute and persist the inverse descriptor for a write.
347
+
348
+ Two paths:
326
349
 
327
- Best-effort: a broken undo callable or store must never fail the call.
328
- Attaches ``_undo_id`` to dict results so the agent / pilot can reference it.
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:`queue_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 isinstance(result, dict) and result.get("error"):
333
- return # sanitized failure — no change happened, so no inverse to record
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, result)
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)
@@ -31,18 +31,27 @@ def sanitize(text: str | None, max_len: int = 500) -> str:
31
31
  payload past the cut-off by padding with junk control characters.
32
32
  ``None`` sanitizes to ``""``.
33
33
 
34
+ A truncated result ends in an ellipsis (U+2026) so the cut is visible. This
35
+ line already treats a silent cut as a defect for lists — a capped page that
36
+ looks complete gets reported as the whole set — and a capped string is the
37
+ same failure at a smaller scale: a shortened error message reads as the
38
+ whole explanation, and its remediation sentence, which comes last, is gone
39
+ without a trace.
40
+
34
41
  Args:
35
42
  text: Untrusted text from queue API responses.
36
43
  max_len: Maximum length after truncation. Default 500.
37
44
 
38
45
  Returns:
39
- Cleaned, truncated string safe for LLM consumption.
46
+ Cleaned string, ellipsis-terminated when it had to be cut.
40
47
  """
41
48
  if text is None:
42
49
  return ""
43
50
  stripped = _CONTROL_CHAR_RE.sub("", str(text))
44
51
  cleaned = "".join(c for c in stripped if unicodedata.category(c) != "Cf")
45
- return cleaned[:max_len]
52
+ if len(cleaned) <= max_len:
53
+ return cleaned
54
+ return cleaned[: max(0, max_len - 1)] + "\u2026"
46
55
 
47
56
 
48
57
  def opt_str(value: object | None, max_len: int = 500) -> str | None: