queue-aiops 0.9.2__tar.gz → 0.9.4__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.9.2 → queue_aiops-0.9.4}/.claude-plugin/plugin.json +1 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/.mcp.json +1 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/CHANGELOG.md +31 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/PKG-INFO +1 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/openclaw.plugin.json +1 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/package.json +1 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/pyproject.toml +1 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/_common.py +40 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/analyze.py +5 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/overview.py +2 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/rabbit_cmds.py +8 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/redis_cmds.py +8 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/ops/analysis.py +5 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/server.json +2 -2
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/skills/queue-aiops/SKILL.md +2 -2
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/skills/queue-aiops/references/agent-guardrails.md +9 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_analysis.py +68 -0
- queue_aiops-0.9.4/tests/test_cli_audited.py +131 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/uv.lock +1 -1
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/.github/workflows/mcp-publish.yml +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/.github/workflows/publish.yml +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/.gitignore +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/Dockerfile +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/LICENSE +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/README.md +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/RELEASE_NOTES.md +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/SECURITY.md +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/docs/VERIFICATION.md +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/glama.json +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/__init__.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/_shared.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/server.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/tools/__init__.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/tools/analysis.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/tools/overview.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/tools/rabbit_reads.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/tools/redis_reads.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/tools/undo.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/mcp_server/tools/writes.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/__init__.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/__init__.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/_root.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/doctor.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/init.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/secret.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/cli/undo.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/config.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/connection.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/doctor.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/__init__.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/audit.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/budget.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/decorators.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/outcome.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/paths.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/patterns.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/policy.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/sanitize.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/governance/undo.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/ops/__init__.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/ops/_util.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/ops/overview.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/ops/rabbit_reads.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/ops/redis_reads.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/ops/writes.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/platform.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/queue_aiops/secretstore.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/skills/queue-aiops/references/capabilities.md +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/skills/queue-aiops/references/cli-reference.md +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/skills/queue-aiops/references/setup-guide.md +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/smithery.yaml +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/conftest.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/fakes.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_cli_governed_exit.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_cli_rabbit_writes.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_cli_reads.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_cli_secret.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_cli_writes.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_connection.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_doctor.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_gov_audit.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_gov_decorators.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_gov_patterns.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_gov_policy.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_governance_persistence.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_init.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_int_fidelity.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_mcp_reads.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_optional_fields.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_platform.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_reads.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_secretstore.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_self_kill.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_self_lockout.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_smoke.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_undo_executor.py +0 -0
- {queue_aiops-0.9.2 → queue_aiops-0.9.4}/tests/test_writes.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "queue-aiops",
|
|
4
4
|
"displayName": "Queue AIops",
|
|
5
|
-
"version": "0.9.
|
|
5
|
+
"version": "0.9.4",
|
|
6
6
|
"description": "Governed redis + rabbitmq ops: memory/latency/backlog/churn RCA, policies. 28 tools.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Wei Zhou",
|
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.9.4 — 2026-09-16
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `redis_memory_pressure_rca`'s top-level `pressure` flag no longer decides by
|
|
7
|
+
comparing another finding's `cause` against the literal sentence "Healthy —
|
|
8
|
+
within thresholds". The flag is behaviourally unchanged, but rewording that
|
|
9
|
+
sentence — or normalising its em dash in a style sweep — would have reported
|
|
10
|
+
pressure on a healthy broker. It now reads the measured condition captured
|
|
11
|
+
before the healthy placeholder is appended, and an AST-based structural test
|
|
12
|
+
pins the whole package against a decision being taken on a finding's prose —
|
|
13
|
+
either operand order, the literal hoisted into a constant, `.get()`, `in`,
|
|
14
|
+
`startswith`, slicing and `.lower()` are all rejected.
|
|
15
|
+
- `agent-guardrails.md` and `SKILL.md` claimed the four RCAs "rank findings
|
|
16
|
+
worst-first" and that "priority is in the payload, not implied by list
|
|
17
|
+
position". Neither was ever true here: there is no `rank` key, and findings are
|
|
18
|
+
appended in the order the checks run. The claim sat in the table headed "what
|
|
19
|
+
the tool enforces — do not waste prompt budget on these", so it actively told
|
|
20
|
+
operators to drop the prompt that would have compensated. A broker at 12 % of
|
|
21
|
+
`maxmemory` with 35 GiB of oversized keys reports the fragmentation finding
|
|
22
|
+
first, purely because fragmentation is checked earlier. The docs now say order
|
|
23
|
+
is not severity, and point at the `evidence` number each finding does carry.
|
|
24
|
+
|
|
25
|
+
## v0.9.3 — 2026-09-15
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- CLI commands that read the engine without calling an MCP tool now write an
|
|
29
|
+
audit row, as every MCP call does: they run through the same `@governed_tool`
|
|
30
|
+
harness, budget and runaway guard included. Before, an operator's CLI reads
|
|
31
|
+
left no trace in `audit.db`, contrary to the documented guarantee that MCP
|
|
32
|
+
and CLI are audited alike. A test fails if any engine command escapes it.
|
|
33
|
+
|
|
3
34
|
## v0.9.2 — 2026-09-12
|
|
4
35
|
|
|
5
36
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: queue-aiops
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.4
|
|
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
5
|
Author-email: Wei Zhou <zhouwei008@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "queue-aiops"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.4"
|
|
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"
|
|
@@ -153,3 +153,43 @@ def double_confirm(action: str, resource: str) -> None:
|
|
|
153
153
|
f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
|
|
154
154
|
abort=True,
|
|
155
155
|
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def audited(fn: Callable) -> Callable:
|
|
159
|
+
"""Audit a CLI command that reaches the engine without calling an MCP tool.
|
|
160
|
+
|
|
161
|
+
MCP tools write their audit row through ``@governed_tool``; a CLI command that
|
|
162
|
+
called the ops layer directly used to write none, so an operator's CLI reads
|
|
163
|
+
left no trail. This runs the command through the same harness: one audit row per
|
|
164
|
+
invocation (tool = the command function, params = its options), plus the budget
|
|
165
|
+
and runaway guard. ``typer.Exit`` is how a command ends: exit code 0 is audited
|
|
166
|
+
``ok``, a non-zero code ``error``, and the exit is re-raised unchanged. Any other
|
|
167
|
+
exception is audited ``error`` and propagates to ``cli_errors`` as before.
|
|
168
|
+
"""
|
|
169
|
+
import functools as _functools
|
|
170
|
+
|
|
171
|
+
from queue_aiops.governance import governed_tool
|
|
172
|
+
|
|
173
|
+
@_functools.wraps(fn)
|
|
174
|
+
def body(*args: Any, **kwargs: Any) -> Any:
|
|
175
|
+
try:
|
|
176
|
+
fn(*args, **kwargs)
|
|
177
|
+
except typer.Exit as exc:
|
|
178
|
+
code = exc.exit_code
|
|
179
|
+
outcome: dict[str, Any] = {"_exitCode": code}
|
|
180
|
+
if code:
|
|
181
|
+
outcome["error"] = f"command exited with code {code}"
|
|
182
|
+
return outcome
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
governed = governed_tool(risk_level="low")(body)
|
|
186
|
+
|
|
187
|
+
@_functools.wraps(fn)
|
|
188
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
189
|
+
result = governed(*args, **kwargs)
|
|
190
|
+
if isinstance(result, dict) and "_exitCode" in result:
|
|
191
|
+
raise typer.Exit(result["_exitCode"])
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
wrapper._is_audited_cli = True # type: ignore[attr-defined]
|
|
195
|
+
return wrapper
|
|
@@ -7,7 +7,7 @@ from typing import Annotated
|
|
|
7
7
|
|
|
8
8
|
import typer
|
|
9
9
|
|
|
10
|
-
from queue_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
10
|
+
from queue_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
11
11
|
|
|
12
12
|
analyze_app = typer.Typer(
|
|
13
13
|
name="analyze",
|
|
@@ -19,6 +19,7 @@ analyze_app = typer.Typer(
|
|
|
19
19
|
|
|
20
20
|
@analyze_app.command("memory")
|
|
21
21
|
@cli_errors
|
|
22
|
+
@audited
|
|
22
23
|
def analyze_memory(
|
|
23
24
|
used_pct: Annotated[
|
|
24
25
|
float, typer.Option("--used-pct", help="used/maxmemory %% pressure threshold")
|
|
@@ -41,6 +42,7 @@ def analyze_memory(
|
|
|
41
42
|
|
|
42
43
|
@analyze_app.command("latency")
|
|
43
44
|
@cli_errors
|
|
45
|
+
@audited
|
|
44
46
|
def analyze_latency(
|
|
45
47
|
slow_us: Annotated[
|
|
46
48
|
float, typer.Option("--slow-us", help="Slowlog threshold in microseconds")
|
|
@@ -57,6 +59,7 @@ def analyze_latency(
|
|
|
57
59
|
|
|
58
60
|
@analyze_app.command("backlog")
|
|
59
61
|
@cli_errors
|
|
62
|
+
@audited
|
|
60
63
|
def analyze_backlog(
|
|
61
64
|
vhost: Annotated[
|
|
62
65
|
str | None, typer.Option("--vhost", help="Restrict to one vhost")
|
|
@@ -76,6 +79,7 @@ def analyze_backlog(
|
|
|
76
79
|
|
|
77
80
|
@analyze_app.command("churn")
|
|
78
81
|
@cli_errors
|
|
82
|
+
@audited
|
|
79
83
|
def analyze_churn(target: TargetOption = None) -> None:
|
|
80
84
|
"""Connection-churn analysis (both platforms): counts, rates, by source."""
|
|
81
85
|
from queue_aiops.ops import analysis as ops
|
|
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
-
from queue_aiops.cli._common import TargetOption, cli_errors, console, get_connection
|
|
7
|
+
from queue_aiops.cli._common import TargetOption, audited, cli_errors, console, get_connection
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
@cli_errors
|
|
11
|
+
@audited
|
|
11
12
|
def overview_cmd(target: TargetOption = None) -> None:
|
|
12
13
|
"""One-shot summary: platform + version + backlog/memory/client health."""
|
|
13
14
|
from queue_aiops.ops import overview as ops
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from queue_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
audited,
|
|
13
14
|
checked,
|
|
14
15
|
cli_errors,
|
|
15
16
|
console,
|
|
@@ -32,6 +33,7 @@ VhostOption = Annotated[
|
|
|
32
33
|
|
|
33
34
|
@rabbitmq_app.command("overview")
|
|
34
35
|
@cli_errors
|
|
36
|
+
@audited
|
|
35
37
|
def rabbit_overview(target: TargetOption = None) -> None:
|
|
36
38
|
"""Broker identity + totals (queues, messages, connections, rates)."""
|
|
37
39
|
from queue_aiops.ops import rabbit_reads as ops
|
|
@@ -42,6 +44,7 @@ def rabbit_overview(target: TargetOption = None) -> None:
|
|
|
42
44
|
|
|
43
45
|
@rabbitmq_app.command("queues")
|
|
44
46
|
@cli_errors
|
|
47
|
+
@audited
|
|
45
48
|
def rabbit_queues(
|
|
46
49
|
vhost: Annotated[
|
|
47
50
|
str | None, typer.Option("--vhost", help="Restrict to one vhost")
|
|
@@ -63,6 +66,7 @@ def rabbit_queues(
|
|
|
63
66
|
|
|
64
67
|
@rabbitmq_app.command("queue")
|
|
65
68
|
@cli_errors
|
|
69
|
+
@audited
|
|
66
70
|
def rabbit_queue(
|
|
67
71
|
name: Annotated[str, typer.Argument(help="Queue name (from 'queues')")],
|
|
68
72
|
vhost: VhostOption = "/",
|
|
@@ -77,6 +81,7 @@ def rabbit_queue(
|
|
|
77
81
|
|
|
78
82
|
@rabbitmq_app.command("connections")
|
|
79
83
|
@cli_errors
|
|
84
|
+
@audited
|
|
80
85
|
def rabbit_connections(target: TargetOption = None) -> None:
|
|
81
86
|
"""Client connections grouped by peer host."""
|
|
82
87
|
from queue_aiops.ops import rabbit_reads as ops
|
|
@@ -93,6 +98,7 @@ def rabbit_connections(target: TargetOption = None) -> None:
|
|
|
93
98
|
|
|
94
99
|
@rabbitmq_app.command("channels")
|
|
95
100
|
@cli_errors
|
|
101
|
+
@audited
|
|
96
102
|
def rabbit_channels(target: TargetOption = None) -> None:
|
|
97
103
|
"""Channels with unacked/prefetch pressure."""
|
|
98
104
|
from queue_aiops.ops import rabbit_reads as ops
|
|
@@ -109,6 +115,7 @@ def rabbit_channels(target: TargetOption = None) -> None:
|
|
|
109
115
|
|
|
110
116
|
@rabbitmq_app.command("policies")
|
|
111
117
|
@cli_errors
|
|
118
|
+
@audited
|
|
112
119
|
def rabbit_policies(
|
|
113
120
|
vhost: Annotated[
|
|
114
121
|
str | None, typer.Option("--vhost", help="Restrict to one vhost")
|
|
@@ -130,6 +137,7 @@ def rabbit_policies(
|
|
|
130
137
|
|
|
131
138
|
@rabbitmq_app.command("nodes")
|
|
132
139
|
@cli_errors
|
|
140
|
+
@audited
|
|
133
141
|
def rabbit_nodes(target: TargetOption = None) -> None:
|
|
134
142
|
"""Node memory/disk/fd posture + watermark alarms."""
|
|
135
143
|
from queue_aiops.ops import rabbit_reads as ops
|
|
@@ -10,6 +10,7 @@ import typer
|
|
|
10
10
|
from queue_aiops.cli._common import (
|
|
11
11
|
DryRunOption,
|
|
12
12
|
TargetOption,
|
|
13
|
+
audited,
|
|
13
14
|
checked,
|
|
14
15
|
cli_errors,
|
|
15
16
|
console,
|
|
@@ -28,6 +29,7 @@ redis_app = typer.Typer(
|
|
|
28
29
|
|
|
29
30
|
@redis_app.command("info")
|
|
30
31
|
@cli_errors
|
|
32
|
+
@audited
|
|
31
33
|
def redis_info(target: TargetOption = None) -> None:
|
|
32
34
|
"""Server identity + health basics (version, role, clients, ops/sec)."""
|
|
33
35
|
from queue_aiops.ops import redis_reads as ops
|
|
@@ -38,6 +40,7 @@ def redis_info(target: TargetOption = None) -> None:
|
|
|
38
40
|
|
|
39
41
|
@redis_app.command("memory")
|
|
40
42
|
@cli_errors
|
|
43
|
+
@audited
|
|
41
44
|
def redis_memory(target: TargetOption = None) -> None:
|
|
42
45
|
"""Memory posture: used vs maxmemory, policy, fragmentation."""
|
|
43
46
|
from queue_aiops.ops import redis_reads as ops
|
|
@@ -48,6 +51,7 @@ def redis_memory(target: TargetOption = None) -> None:
|
|
|
48
51
|
|
|
49
52
|
@redis_app.command("clients")
|
|
50
53
|
@cli_errors
|
|
54
|
+
@audited
|
|
51
55
|
def redis_clients(target: TargetOption = None) -> None:
|
|
52
56
|
"""Connected clients grouped by source address."""
|
|
53
57
|
from queue_aiops.ops import redis_reads as ops
|
|
@@ -64,6 +68,7 @@ def redis_clients(target: TargetOption = None) -> None:
|
|
|
64
68
|
|
|
65
69
|
@redis_app.command("slowlog")
|
|
66
70
|
@cli_errors
|
|
71
|
+
@audited
|
|
67
72
|
def redis_slowlog(
|
|
68
73
|
count: Annotated[int, typer.Option("--count", "-n", help="Max entries")] = 128,
|
|
69
74
|
target: TargetOption = None,
|
|
@@ -83,6 +88,7 @@ def redis_slowlog(
|
|
|
83
88
|
|
|
84
89
|
@redis_app.command("config-get")
|
|
85
90
|
@cli_errors
|
|
91
|
+
@audited
|
|
86
92
|
def redis_config_get(
|
|
87
93
|
pattern: Annotated[str, typer.Argument(help="Config-name glob, e.g. 'maxmemory*'")] = "*",
|
|
88
94
|
target: TargetOption = None,
|
|
@@ -96,6 +102,7 @@ def redis_config_get(
|
|
|
96
102
|
|
|
97
103
|
@redis_app.command("keyspace")
|
|
98
104
|
@cli_errors
|
|
105
|
+
@audited
|
|
99
106
|
def redis_keyspace(target: TargetOption = None) -> None:
|
|
100
107
|
"""Per-db key counts and expiry coverage."""
|
|
101
108
|
from queue_aiops.ops import redis_reads as ops
|
|
@@ -106,6 +113,7 @@ def redis_keyspace(target: TargetOption = None) -> None:
|
|
|
106
113
|
|
|
107
114
|
@redis_app.command("bigkeys")
|
|
108
115
|
@cli_errors
|
|
116
|
+
@audited
|
|
109
117
|
def redis_bigkeys(
|
|
110
118
|
top: Annotated[int, typer.Option("--top", help="Rows to return, largest first")] = 20,
|
|
111
119
|
target: TargetOption = None,
|
|
@@ -126,6 +126,10 @@ def redis_memory_pressure_rca(
|
|
|
126
126
|
"evidence": {"bigKeys": big[:10]},
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
+
# Captured before the healthy placeholder is appended. The flag reports the measured
|
|
130
|
+
# condition — did any check fire — and is never derived from another finding's prose:
|
|
131
|
+
# a reworded sentence would otherwise report pressure on a healthy broker.
|
|
132
|
+
pressure = bool(findings)
|
|
129
133
|
if not findings:
|
|
130
134
|
findings.append({
|
|
131
135
|
"cause": "Healthy — within thresholds",
|
|
@@ -133,7 +137,7 @@ def redis_memory_pressure_rca(
|
|
|
133
137
|
"evidence": {"usedPctOfMax": used_of_max, "fragmentationRatio": frag},
|
|
134
138
|
})
|
|
135
139
|
return {
|
|
136
|
-
"pressure":
|
|
140
|
+
"pressure": pressure,
|
|
137
141
|
"usedPctOfMax": used_of_max,
|
|
138
142
|
"maxmemoryPolicy": policy,
|
|
139
143
|
"fragmentationRatio": frag,
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/AIops-tools/Queue-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.9.
|
|
10
|
+
"version": "0.9.4",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "queue-aiops",
|
|
15
|
-
"version": "0.9.
|
|
15
|
+
"version": "0.9.4",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -121,8 +121,8 @@ skill.
|
|
|
121
121
|
works before you read numbers off it.
|
|
122
122
|
2. `queue-aiops redis memory` → used vs `maxmemory`, the eviction policy in force, and
|
|
123
123
|
the fragmentation ratio, straight from `INFO memory`.
|
|
124
|
-
3. `queue-aiops analyze memory --used-pct 85` →
|
|
125
|
-
number: **noeviction near the limit** (the dangerous one — writes will start failing
|
|
124
|
+
3. `queue-aiops analyze memory --used-pct 85` → findings in check order, each citing its measured
|
|
125
|
+
number (order is not severity — weigh them all): **noeviction near the limit** (the dangerous one — writes will start failing
|
|
126
126
|
with OOM rather than evicting), active eviction in progress, fragmentation versus real
|
|
127
127
|
swapping, and oversized keys.
|
|
128
128
|
4. `queue-aiops redis bigkeys --count 500` → a SCAN-budgeted sample of the largest keys,
|
|
@@ -31,7 +31,7 @@ What the tool *does* guarantee is that you can always see what happened:
|
|
|
31
31
|
|---|---|
|
|
32
32
|
| "Don't invent a value when a field is missing" | RabbitMQ omits `idle_since` for an active queue; a Redis primary reports no `master_link_status`; an unnamed client has no name. Those come back as `null`, never as `""`. |
|
|
33
33
|
| "Tell me if the output was cut off" | `redis_slowlog`, `redis_clients`, `redis_big_keys`, `list_queues`, `list_connections`, `list_channels` and `list_policies` all return `{"<items>": [...], "returned": N, "limit": L, "truncated": true/false}`. Truncation is measured — one extra entry is requested from Redis — not guessed from a length coincidence. |
|
|
34
|
-
| "
|
|
34
|
+
| "Make it show the number it judged on" | Every finding from `rabbitmq_queue_backlog_rca`, `redis_memory_pressure_rca`, `redis_latency_rca` and `connection_churn_analysis` carries the measured value it was based on under `evidence`, so a claim can always be checked against a number rather than taken on the model's word. |
|
|
35
35
|
| "Don't run KEYS * on production" | `redis_big_keys` uses SCAN under a hard key budget and sizes only an evenly-spaced subset with MEMORY USAGE. There is no code path that can issue `KEYS *`. `coveragePct` reports how partial the walk was. |
|
|
36
36
|
| "Confirm before anything destructive" | `delete_queue` and `purge_queue` require a `--dry-run`-able preview plus double confirmation at the CLI. |
|
|
37
37
|
| "Log what you did" | Every governed call is audited to `~/.queue-aiops/audit.db` regardless of what the model says it did — and the CLI writes the same row the MCP path does, so there is no unaudited entry point. |
|
|
@@ -40,6 +40,14 @@ What the tool *does* guarantee is that you can always see what happened:
|
|
|
40
40
|
## What still needs a prompt
|
|
41
41
|
|
|
42
42
|
These are model-behaviour problems the harness cannot fix from the outside.
|
|
43
|
+
|
|
44
|
+
⚠️ **Finding order is not priority.** These RCAs append findings in the order the checks
|
|
45
|
+
run, not worst-first, and there is no `rank` key in the payload. A broker at 12 % of
|
|
46
|
+
`maxmemory` with 35 GiB of oversized keys reports the fragmentation finding first purely
|
|
47
|
+
because fragmentation is checked before big keys. Do not let the model treat `findings[0]`
|
|
48
|
+
as the headline — make it weigh every finding's `evidence` and say which one it acted on.
|
|
49
|
+
(Earlier versions of this file claimed these lists were ranked worst-first. They never were.)
|
|
50
|
+
|
|
43
51
|
Copy this into your agent's system prompt:
|
|
44
52
|
|
|
45
53
|
```text
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import ast
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
5
8
|
import pytest
|
|
6
9
|
|
|
7
10
|
from queue_aiops.ops import analysis as ops
|
|
@@ -200,3 +203,68 @@ def test_churn_healthy_reports_metrics():
|
|
|
200
203
|
})
|
|
201
204
|
assert out["findings"][0]["action"] == "No action needed."
|
|
202
205
|
assert out["metrics"]["channels"] == 8
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.unit
|
|
209
|
+
def test_no_decision_is_derived_from_a_findings_display_text():
|
|
210
|
+
"""A rule's input is never another rule's display text.
|
|
211
|
+
|
|
212
|
+
`pressure` used to be `any(f["cause"] != "Healthy — within thresholds" ...)`, so
|
|
213
|
+
rewording that sentence — or normalising its em dash in a style sweep — would have
|
|
214
|
+
reported pressure on a healthy broker. The check is AST-based on purpose: a regex on
|
|
215
|
+
the source missed the shape that produced the original incident in another repo
|
|
216
|
+
(`"critical blocker" in f["signal"]`, literal on the left), and also fired on prose
|
|
217
|
+
inside comments and docstrings. Walking the tree catches either operand order, the
|
|
218
|
+
literal hoisted into a constant, `.get()`, `.lower()`, slicing and `startswith`, and
|
|
219
|
+
ignores comments entirely.
|
|
220
|
+
"""
|
|
221
|
+
offenders = []
|
|
222
|
+
for path in _scanned_sources():
|
|
223
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
224
|
+
for node in ast.walk(tree):
|
|
225
|
+
if isinstance(node, ast.Compare):
|
|
226
|
+
operands = [node.left, *node.comparators]
|
|
227
|
+
if any(_reads_display_text(o) for o in operands) and not _is_none_check(node):
|
|
228
|
+
offenders.append(f"{path.name}:{node.lineno}: {ast.unparse(node)}")
|
|
229
|
+
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \
|
|
230
|
+
and node.func.attr in _TEXT_PREDICATES and _reads_display_text(node.func.value):
|
|
231
|
+
offenders.append(f"{path.name}:{node.lineno}: {ast.unparse(node)}")
|
|
232
|
+
assert offenders == [], ("a decision is being derived from a finding's prose:\n"
|
|
233
|
+
+ "\n".join(offenders))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
_DISPLAY_KEYS = {"cause", "action", "signal", "note", "summary"}
|
|
237
|
+
_TEXT_PREDICATES = {"startswith", "endswith", "count", "find", "index"}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _scanned_sources():
|
|
241
|
+
"""Every module that could post-process a finding — not just the one that builds them.
|
|
242
|
+
|
|
243
|
+
`governance/` is excluded: it is vendored verbatim across the line and held byte-identical
|
|
244
|
+
by the drift gate, so it is not this repo's to edit.
|
|
245
|
+
"""
|
|
246
|
+
root = Path(ops.__file__).parents[2]
|
|
247
|
+
found = sorted(root.glob("queue_aiops/**/*.py")) + sorted(root.glob("mcp_server/**/*.py"))
|
|
248
|
+
files = [p for p in found if "governance" not in p.parts]
|
|
249
|
+
assert len(files) > 10, f"source sweep found only {len(files)} modules — the glob is wrong"
|
|
250
|
+
return files
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _reads_display_text(node):
|
|
254
|
+
"""True if evaluating this expression reads a finding's display field, however wrapped."""
|
|
255
|
+
for sub in ast.walk(node):
|
|
256
|
+
if isinstance(sub, ast.Subscript) and isinstance(sub.slice, ast.Constant) \
|
|
257
|
+
and sub.slice.value in _DISPLAY_KEYS:
|
|
258
|
+
return True
|
|
259
|
+
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) \
|
|
260
|
+
and sub.func.attr == "get" and sub.args \
|
|
261
|
+
and isinstance(sub.args[0], ast.Constant) \
|
|
262
|
+
and sub.args[0].value in _DISPLAY_KEYS:
|
|
263
|
+
return True
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _is_none_check(node):
|
|
268
|
+
"""`f["cause"] is None` is a presence test, not a decision taken on the prose."""
|
|
269
|
+
return all(isinstance(op, (ast.Is, ast.IsNot)) for op in node.ops) and all(
|
|
270
|
+
isinstance(c, ast.Constant) and c.value is None for c in node.comparators)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Every CLI command that reaches the engine is audited.
|
|
2
|
+
|
|
3
|
+
MCP tools write their audit row through ``@governed_tool``. CLI commands that called
|
|
4
|
+
the ops layer directly wrote none: on 2026-09-15 a read command of every tool in the
|
|
5
|
+
line, run in a scratch home, left no audit.db while the harness itself worked. Each
|
|
6
|
+
such command is now wrapped in ``audited`` (``cli/_common.py``); commands that call an
|
|
7
|
+
MCP tool are audited by that tool. This pins the helper's behaviour and that no
|
|
8
|
+
engine command escapes it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import ast
|
|
14
|
+
import inspect
|
|
15
|
+
import sqlite3
|
|
16
|
+
import textwrap
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
import typer
|
|
20
|
+
from typer.main import get_command
|
|
21
|
+
|
|
22
|
+
from queue_aiops.cli import app
|
|
23
|
+
from queue_aiops.cli._common import audited
|
|
24
|
+
|
|
25
|
+
pytestmark = pytest.mark.unit
|
|
26
|
+
|
|
27
|
+
#: Local-only commands: they never reach the engine (``undo`` goes through MCP tools).
|
|
28
|
+
EXEMPT = {"init", "doctor", "secret", "mcp", "undo"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _leaves() -> list[tuple[list[str], object]]:
|
|
32
|
+
found: list[tuple[list[str], object]] = []
|
|
33
|
+
|
|
34
|
+
def walk(cmd, path):
|
|
35
|
+
if hasattr(cmd, "list_commands"):
|
|
36
|
+
for name, sub in cmd.commands.items():
|
|
37
|
+
walk(sub, path + [name])
|
|
38
|
+
else:
|
|
39
|
+
found.append((path, cmd))
|
|
40
|
+
|
|
41
|
+
walk(get_command(app), [])
|
|
42
|
+
return found
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _chain(fn):
|
|
46
|
+
seen = []
|
|
47
|
+
while fn is not None and fn not in seen:
|
|
48
|
+
seen.append(fn)
|
|
49
|
+
fn = getattr(fn, "__wrapped__", None)
|
|
50
|
+
return seen
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _calls_an_mcp_tool(fn) -> bool:
|
|
54
|
+
source = inspect.getsource(fn)
|
|
55
|
+
if "mcp_server" in source:
|
|
56
|
+
return True
|
|
57
|
+
names = set()
|
|
58
|
+
for node in ast.parse(open(inspect.getsourcefile(fn)).read()).body:
|
|
59
|
+
if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("mcp_server"):
|
|
60
|
+
names |= {a.asname or a.name for a in node.names}
|
|
61
|
+
used = {n.id for n in ast.walk(ast.parse(textwrap.dedent(source))) if isinstance(n, ast.Name)}
|
|
62
|
+
return bool(names & used)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_every_engine_command_is_audited():
|
|
66
|
+
commands = [(path, cmd) for path, cmd in _leaves() if path and path[0] not in EXEMPT]
|
|
67
|
+
assert commands, "no commands found: the walker is broken, not the CLI clean"
|
|
68
|
+
missing = [
|
|
69
|
+
" ".join(path) for path, cmd in commands
|
|
70
|
+
if not any(getattr(f, "_is_audited_cli", False) for f in _chain(cmd.callback))
|
|
71
|
+
and not _calls_an_mcp_tool(inspect.unwrap(cmd.callback))
|
|
72
|
+
]
|
|
73
|
+
assert not missing, f"CLI commands that leave no audit row: {missing}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def home(tmp_path, monkeypatch):
|
|
78
|
+
import queue_aiops.governance.audit as audit_mod
|
|
79
|
+
import queue_aiops.governance.budget as budget_mod
|
|
80
|
+
|
|
81
|
+
monkeypatch.setenv("QUEUE_AIOPS_HOME", str(tmp_path))
|
|
82
|
+
audit_mod.reset_engine()
|
|
83
|
+
budget_mod.reset_budget()
|
|
84
|
+
yield tmp_path
|
|
85
|
+
audit_mod.reset_engine()
|
|
86
|
+
budget_mod.reset_budget()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rows(home) -> list[tuple[str, str]]:
|
|
90
|
+
con = sqlite3.connect(home / "audit.db")
|
|
91
|
+
try:
|
|
92
|
+
return con.execute("SELECT tool, status FROM audit_log ORDER BY id").fetchall()
|
|
93
|
+
finally:
|
|
94
|
+
con.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_audited_records_success_exit_codes_and_exceptions(home):
|
|
98
|
+
@audited
|
|
99
|
+
def cmd_ok(target=None):
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@audited
|
|
103
|
+
def cmd_exit_zero(target=None):
|
|
104
|
+
raise typer.Exit()
|
|
105
|
+
|
|
106
|
+
@audited
|
|
107
|
+
def cmd_exit_one(target=None):
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
@audited
|
|
111
|
+
def cmd_raises(target=None):
|
|
112
|
+
raise RuntimeError("engine unreachable")
|
|
113
|
+
|
|
114
|
+
cmd_ok()
|
|
115
|
+
with pytest.raises(typer.Exit) as zero:
|
|
116
|
+
cmd_exit_zero()
|
|
117
|
+
assert zero.value.exit_code == 0
|
|
118
|
+
with pytest.raises(typer.Exit) as one:
|
|
119
|
+
cmd_exit_one()
|
|
120
|
+
assert one.value.exit_code == 1
|
|
121
|
+
with pytest.raises(RuntimeError, match="engine unreachable"):
|
|
122
|
+
cmd_raises()
|
|
123
|
+
assert _rows(home) == [("cmd_ok", "ok"), ("cmd_exit_zero", "ok"),
|
|
124
|
+
("cmd_exit_one", "error"), ("cmd_raises", "error")]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_audited_keeps_the_command_signature_for_typer():
|
|
128
|
+
def cmd(limit: int = 5, target: str | None = None) -> None:
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
assert list(inspect.signature(audited(cmd)).parameters) == ["limit", "target"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|