queue-aiops 0.2.1__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.
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/CHANGELOG.md +11 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/PKG-INFO +6 -5
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/README.md +4 -3
- queue_aiops-0.3.0/RELEASE_NOTES.md +50 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/_shared.py +38 -3
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/tools/undo.py +17 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/tools/writes.py +17 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/pyproject.toml +2 -2
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/_common.py +22 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/redis_cmds.py +15 -5
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/connection.py +17 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/__init__.py +10 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/decorators.py +65 -9
- queue_aiops-0.3.0/queue_aiops/governance/outcome.py +104 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/sanitize.py +11 -2
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/undo.py +52 -13
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/ops/rabbit_reads.py +15 -15
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/ops/redis_reads.py +20 -6
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/ops/writes.py +131 -1
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/server.json +2 -2
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/fakes.py +8 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_cli_writes.py +11 -2
- queue_aiops-0.3.0/tests/test_governance_persistence.py +367 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_optional_fields.py +4 -1
- queue_aiops-0.3.0/tests/test_self_kill.py +165 -0
- queue_aiops-0.3.0/tests/test_self_lockout.py +222 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_writes.py +9 -2
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/uv.lock +1 -1
- queue_aiops-0.2.1/RELEASE_NOTES.md +0 -26
- queue_aiops-0.2.1/tests/test_governance_persistence.py +0 -175
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/.github/workflows/mcp-publish.yml +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/.github/workflows/publish.yml +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/.gitignore +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/LICENSE +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/SECURITY.md +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/docs/VERIFICATION.md +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/__init__.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/server.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/tools/__init__.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/tools/analysis.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/tools/overview.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/tools/rabbit_reads.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/mcp_server/tools/redis_reads.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/__init__.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/__init__.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/_root.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/analyze.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/doctor.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/init.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/overview.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/rabbit_cmds.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/secret.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/cli/undo.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/config.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/doctor.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/audit.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/budget.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/paths.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/patterns.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/policy.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/governance/readonly.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/ops/__init__.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/ops/_util.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/ops/analysis.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/ops/overview.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/platform.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/queue_aiops/secretstore.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/skills/queue-aiops/SKILL.md +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/skills/queue-aiops/references/capabilities.md +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/skills/queue-aiops/references/cli-reference.md +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/skills/queue-aiops/references/setup-guide.md +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/smithery.yaml +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/conftest.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_analysis.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_cli_rabbit_writes.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_cli_reads.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_cli_secret.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_connection.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_doctor.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_gov_audit.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_gov_decorators.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_gov_patterns.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_gov_policy.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_init.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_mcp_reads.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_platform.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_readonly.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_reads.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_secretstore.py +0 -0
- {queue_aiops-0.2.1 → queue_aiops-0.3.0}/tests/test_smoke.py +0 -0
- {queue_aiops-0.2.1 → 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.
|
|
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:
|
|
5
|
+
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.11
|
|
@@ -192,15 +192,16 @@ queue-aiops analyze memory|latency|backlog|churn
|
|
|
192
192
|
|
|
193
193
|
## Verification status
|
|
194
194
|
|
|
195
|
-
**Live-verified against Redis 7.4.9 (2026-07-19)
|
|
195
|
+
**Live-verified against Redis 7.4.9 and RabbitMQ 3.13.7 (2026-07-19/20).**
|
|
196
196
|
Connectivity, every Redis read (cross-checked against `redis-cli` ground truth), all
|
|
197
197
|
four analyses, and the full governance loop (real `redis_config_set` → audit row →
|
|
198
198
|
undo restoring the prior value) were exercised against a real server. That run found
|
|
199
199
|
and fixed a real defect: integer quantities — key counts, client counts, byte totals —
|
|
200
200
|
were rendered as floats (`202.0` keys), which equality assertions cannot catch.
|
|
201
201
|
|
|
202
|
-
The
|
|
203
|
-
|
|
202
|
+
The RabbitMQ group is now verified end-to-end too — reads cross-checked against
|
|
203
|
+
`rabbitmqadmin`, and `set_policy` → `undo_apply` closing on the live broker. **Redis
|
|
204
|
+
cluster/sentinel topologies and AUTH/TLS connections** remain unverified.
|
|
204
205
|
|
|
205
206
|
[docs/VERIFICATION.md](docs/VERIFICATION.md) records exactly what was checked and what
|
|
206
207
|
is still open. `queue-aiops doctor` is the fastest live check.
|
|
@@ -175,15 +175,16 @@ queue-aiops analyze memory|latency|backlog|churn
|
|
|
175
175
|
|
|
176
176
|
## Verification status
|
|
177
177
|
|
|
178
|
-
**Live-verified against Redis 7.4.9 (2026-07-19)
|
|
178
|
+
**Live-verified against Redis 7.4.9 and RabbitMQ 3.13.7 (2026-07-19/20).**
|
|
179
179
|
Connectivity, every Redis read (cross-checked against `redis-cli` ground truth), all
|
|
180
180
|
four analyses, and the full governance loop (real `redis_config_set` → audit row →
|
|
181
181
|
undo restoring the prior value) were exercised against a real server. That run found
|
|
182
182
|
and fixed a real defect: integer quantities — key counts, client counts, byte totals —
|
|
183
183
|
were rendered as floats (`202.0` keys), which equality assertions cannot catch.
|
|
184
184
|
|
|
185
|
-
The
|
|
186
|
-
|
|
185
|
+
The RabbitMQ group is now verified end-to-end too — reads cross-checked against
|
|
186
|
+
`rabbitmqadmin`, and `set_policy` → `undo_apply` closing on the live broker. **Redis
|
|
187
|
+
cluster/sentinel topologies and AUTH/TLS connections** remain unverified.
|
|
187
188
|
|
|
188
189
|
[docs/VERIFICATION.md](docs/VERIFICATION.md) records exactly what was checked and what
|
|
189
190
|
is still open. `queue-aiops doctor` is the fastest live check.
|
|
@@ -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),
|
|
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
|
-
|
|
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.
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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:`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
|
|
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
|
|