queue-aiops 0.5.0__tar.gz → 0.7.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.5.0 → queue_aiops-0.7.0}/CHANGELOG.md +14 -0
  2. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/PKG-INFO +1 -1
  3. queue_aiops-0.7.0/docs/VERIFICATION.md +97 -0
  4. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/undo.py +30 -2
  5. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/pyproject.toml +1 -1
  6. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/connection.py +27 -2
  7. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/overview.py +4 -0
  8. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/rabbit_reads.py +9 -3
  9. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/redis_reads.py +36 -2
  10. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/writes.py +4 -4
  11. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/server.json +2 -2
  12. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_connection.py +53 -0
  13. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_reads.py +42 -0
  14. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_undo_executor.py +49 -0
  15. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_writes.py +36 -0
  16. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/uv.lock +1 -1
  17. queue_aiops-0.5.0/docs/VERIFICATION.md +0 -34
  18. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/.github/workflows/mcp-publish.yml +0 -0
  19. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/.github/workflows/publish.yml +0 -0
  20. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/.gitignore +0 -0
  21. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/Dockerfile +0 -0
  22. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/LICENSE +0 -0
  23. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/README.md +0 -0
  24. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/RELEASE_NOTES.md +0 -0
  25. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/SECURITY.md +0 -0
  26. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/glama.json +0 -0
  27. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/__init__.py +0 -0
  28. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/_shared.py +0 -0
  29. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/server.py +0 -0
  30. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/__init__.py +0 -0
  31. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/analysis.py +0 -0
  32. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/overview.py +0 -0
  33. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/rabbit_reads.py +0 -0
  34. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/redis_reads.py +0 -0
  35. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/mcp_server/tools/writes.py +0 -0
  36. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/__init__.py +0 -0
  37. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/__init__.py +0 -0
  38. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/_common.py +0 -0
  39. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/_root.py +0 -0
  40. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/analyze.py +0 -0
  41. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/doctor.py +0 -0
  42. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/init.py +0 -0
  43. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/overview.py +0 -0
  44. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/rabbit_cmds.py +0 -0
  45. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/redis_cmds.py +0 -0
  46. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/secret.py +0 -0
  47. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/cli/undo.py +0 -0
  48. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/config.py +0 -0
  49. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/doctor.py +0 -0
  50. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/__init__.py +0 -0
  51. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/audit.py +0 -0
  52. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/budget.py +0 -0
  53. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/decorators.py +0 -0
  54. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/outcome.py +0 -0
  55. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/paths.py +0 -0
  56. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/patterns.py +0 -0
  57. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/policy.py +0 -0
  58. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/sanitize.py +0 -0
  59. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/governance/undo.py +0 -0
  60. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/__init__.py +0 -0
  61. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/_util.py +0 -0
  62. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/ops/analysis.py +0 -0
  63. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/platform.py +0 -0
  64. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/queue_aiops/secretstore.py +0 -0
  65. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/SKILL.md +0 -0
  66. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
  67. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/capabilities.md +0 -0
  68. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/cli-reference.md +0 -0
  69. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/skills/queue-aiops/references/setup-guide.md +0 -0
  70. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/smithery.yaml +0 -0
  71. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/conftest.py +0 -0
  72. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/fakes.py +0 -0
  73. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_analysis.py +0 -0
  74. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_rabbit_writes.py +0 -0
  75. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_reads.py +0 -0
  76. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_secret.py +0 -0
  77. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_cli_writes.py +0 -0
  78. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_doctor.py +0 -0
  79. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_audit.py +0 -0
  80. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_decorators.py +0 -0
  81. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_patterns.py +0 -0
  82. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_gov_policy.py +0 -0
  83. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_governance_persistence.py +0 -0
  84. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_init.py +0 -0
  85. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_int_fidelity.py +0 -0
  86. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_mcp_reads.py +0 -0
  87. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_optional_fields.py +0 -0
  88. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_platform.py +0 -0
  89. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_secretstore.py +0 -0
  90. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_self_kill.py +0 -0
  91. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_self_lockout.py +0 -0
  92. {queue_aiops-0.5.0 → queue_aiops-0.7.0}/tests/test_smoke.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.7.0 — 2026-08-10
4
+
5
+ ### Fixed
6
+ - **`purge_queue` could never have worked against a real RabbitMQ.** The management-API client sent `Accept: application/json`, but the purge endpoint (`DELETE /api/queues/{vhost}/{name}/contents`) answers *204 No Content* and offers no JSON representation, so content negotiation failed and the broker returned **406 with an empty body** — on every purge, on every server. Accept is now `application/json, */*`; every other endpoint (GETs, PUT declare, DELETE queue, DELETE policy) was enumerated against a live broker and behaves identically either way. Verified on RabbitMQ 3.13.7 by purging a real 9-message backlog.
7
+ - **A 406 now explains itself.** The broker sends no body with a 406, so the generic branch produced a message with nothing in it — and, because the platform label already ends in "API", it read "RabbitMQ management API **API error**". There is now a 406 branch naming content negotiation as the cause, and the generic branch no longer doubles the word.
8
+ - **Two more integer quantities stayed integers** (bug class #2): a node's `memUsedBytes` / `memLimitBytes` came back as floats (`161222656.0`) while the `diskFreeBytes` beside them was an int — one payload disagreeing with itself — and the per-peer `channels` count rendered as `1.0` for one channel, from a `0.0` accumulator seed. Both found on a live broker; regression tests assert the type.
9
+ - **Write-path integer quantities stay integers** (bug class #2/#4). `ops/writes.py` still rendered whole-second and count fields through the float helper `num()` — `kill_client`'s `priorState.ageSeconds` came back as `49.0`, and the RabbitMQ purge/policy captures rendered `messages` and `priority` as floats too. It was missed in the earlier read-path sweep (the module did not even import `as_int`) and was inconsistent with `list_clients`, which already rendered the identical CLIENT LIST `age` field as an `int`. Now uses `as_int`; a regression test asserts the *type* (equality cannot catch `49 == 49.0`). Found live while verifying `kill-client` against a real blocked `BLPOP` client on Redis 7.4.
10
+
11
+ ## v0.6.0 — 2026-08-03
12
+
13
+ ### Fixed
14
+ - **A cluster node's key count no longer poses as the whole dataset.** `INFO keyspace` on a Redis Cluster node answers for that node's slots only, so `keyspace` / `overview` reported `totalKeys: 100` for a 3-master cluster actually holding 300 — a dataset-wide claim wrong by the shard count, with nothing in the payload to say otherwise. Both now carry `scope` (`node` vs `server`), `clusterMode`, and on a cluster a note explaining that the figure covers one node. Measured on a real 3-master Redis 7.4 cluster (100 / 92 / 108).
15
+ - **`undo apply` replays against the target the original write ran on.** It dispatched the inverse against whatever target the *caller* named — in practice the config's first entry — while the write's own target sat unused in the undo record. On a multi-target config the inverse therefore ran against the wrong host; it only looks harmless because the resource usually is not there, but two hosts holding the same name and the inverse **succeeds on the wrong one, silently**. An explicitly named target still wins. Line-wide: all 24 copies had the identical defect. Caught live in container-host-aiops, where a stop recorded against a Podman target replayed against a Portainer one.
16
+
3
17
  ## v0.5.0 — 2026-08-02
4
18
 
5
19
  ### Changed (BREAKING)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queue-aiops
3
- Version: 0.5.0
3
+ Version: 0.7.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
5
  Author-email: Wei Zhou <zhouwei008@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,97 @@
1
+ # Live verification status
2
+
3
+ This document records what has and has not been validated against real Redis /
4
+ RabbitMQ servers, so the maturity claim is auditable rather than a vibe.
5
+
6
+ ## Already live-verified ✅ — Redis 7.4.9, 2026-07-19
7
+
8
+ Exercised end-to-end against a real **Redis 7.4.9** server (Docker) seeded with
9
+ 202 keys and a list-backed job queue:
10
+
11
+ - `doctor` against a live server: PING OK, no-AUTH lab path reported clearly.
12
+ - Every Redis read cross-checked against `redis-cli` ground truth:
13
+ `overview`, `redis info/memory/clients/slowlog/keyspace/bigkeys`
14
+ (key count matched `DBSIZE` exactly).
15
+ - All four analyses ran clean: `analyze memory/latency/backlog/churn`.
16
+ - Governance loop end-to-end: `redis_config_set` really changed
17
+ `maxmemory-policy`, captured `noeviction` as `priorState`, wrote an audit row,
18
+ and `undo_apply` restored the prior value on the live server.
19
+
20
+ **A real defect was found and fixed by this run**: integer quantities were
21
+ rendered as floats — `202.0` keys, `1.0` connected clients, float byte counts —
22
+ because they were routed through `num()`. Values were arithmetically right but
23
+ semantically wrong, and equality assertions could not catch it
24
+ (`202 == 202.0`). Fixed with `as_int()` plus a regression test that asserts the
25
+ *type*, keeping genuine ratios (`hitRatePct`, `usedPctOfMax`, `opsPerSec`) as
26
+ floats. ⚠️ That round's claim of a fix "across the Redis and RabbitMQ reads"
27
+ was **overstated**: the 2026-08-10 RabbitMQ run found `memUsedBytes` /
28
+ `memLimitBytes` and the per-peer `channels` count still floats, and an earlier
29
+ 2026-08-03 round found the whole *write* path untouched. The sweep was never as
30
+ wide as the sentence claimed — treat "fixed line-wide" claims as needing their
31
+ own enumeration.
32
+
33
+ ## Not yet live-verified ⚠️
34
+
35
+ - ~~**RabbitMQ** — the entire `rabbitmq` command group and its management-API
36
+ shapes are unit-tested only. This is now the largest gap in this repo.~~
37
+ **Closed 2026-08-10 against a real RabbitMQ 3.13.7** (management plugin), with
38
+ a seeded estate: 3 queues, a real backlog, a live pika consumer holding a
39
+ connection/channel, and a policy.
40
+ - All 7 reads cross-checked against `rabbitmqctl` / `rabbitmqadmin`:
41
+ `overview` (33 ready messages, cluster name, node count), `queues`
42
+ (per-queue counts and durability exact), `queue` detail, `connections`,
43
+ `channels` (unacked and consumer counts), `policies`, `nodes`.
44
+ - All 5 writes exercised end-to-end with their undo: `declare-queue` →
45
+ `undo_apply` deleted it; `set-policy` → `undo_apply` removed it leaving the
46
+ pre-existing policy untouched; `delete-queue` → `undo_apply` re-declared it
47
+ with the captured `durable=false`; `purge` destroyed a real 9-message
48
+ backlog and correctly recorded **no** undo.
49
+ - 🔴 **`purge_queue` had never worked against a real broker.** The HTTP client
50
+ sent `Accept: application/json`, and RabbitMQ's purge endpoint
51
+ (`DELETE /api/queues/{vhost}/{name}/contents`) answers *204 No Content* with
52
+ no JSON representation, so content negotiation failed and the broker
53
+ returned **406 with an empty body** — every purge, on every server. The
54
+ other endpoints were enumerated against the live broker and behave
55
+ identically under either Accept value, so the fix is `application/json, */*`.
56
+ The 406 message was also useless (no body to quote, and the label already
57
+ ended in "API" so it read "…management API API error"); both fixed.
58
+ - 🔴 Two integer quantities rendered as floats (bug class #2): a node's
59
+ `memUsedBytes` / `memLimitBytes` (`161222656.0` sitting next to an integer
60
+ `diskFreeBytes` — one payload disagreeing with itself), and the per-peer
61
+ `channels` count (`1.0` for one channel, from a `0.0` accumulator seed).
62
+ - Still untested: clustered RabbitMQ, quorum/stream queues, and TLS (AMQPS /
63
+ HTTPS management).
64
+ - ~~Redis **cluster / sentinel** topologies (only standalone was exercised).~~
65
+ **Both exercised 2026-08-03**, and the cluster one found a defect: a node's
66
+ `totalKeys` was reported as the dataset total (100 on a 3-master cluster
67
+ holding 300). Reads, `overview` and the RCAs otherwise behaved correctly on a
68
+ cluster node, on a Sentinel-fronted primary, and on its replica (`role:
69
+ slave`). Still untested: an actual **failover** through Sentinel, and any
70
+ cluster-wide aggregate — this tool talks to one endpoint by design, which is
71
+ exactly why the scope marker matters.
72
+ - ~~AUTH-enabled Redis and TLS connections.~~ **Both verified 2026-08-03**
73
+ against a real Redis 7.4 (Docker). AUTH: without the password every read
74
+ fails with a clear `Authentication required` envelope (nulls + `errors`, not
75
+ a silent empty); with the password via the encrypted-store/env fallback,
76
+ `overview`/`keyspace` matched `DBSIZE` exactly. TLS: `use_tls` with
77
+ `verify_ssl: false` connects over a `rediss://` socket to a self-signed
78
+ instance and returns the right key count; `verify_ssl: true` against the same
79
+ self-signed cert fails with `CERTIFICATE_VERIFY_FAILED` — TLS verification is
80
+ really enforced, not silently downgraded.
81
+ - ~~`kill-client` against a real blocked client.~~ **Verified 2026-08-03**: a
82
+ client genuinely blocked on `BLPOP` was listed by `redis clients` (with the
83
+ tool's own connection correctly excluded), the `--dry-run` preview recorded a
84
+ `wouldKill` audit row *without* killing, the real kill removed it server-side
85
+ (confirmed via `CLIENT LIST`), and its CLIENT LIST row was captured as
86
+ `priorState`. Both the preview and the real call wrote audit rows through the
87
+ same governed twin.
88
+
89
+ **A real defect was found and fixed by this run** (bug class #2/#4): the write
90
+ path rendered integer quantities as floats — `ageSeconds: 49.0` in
91
+ `kill_client`'s `priorState`, and likewise `messages`/`priority` in the
92
+ RabbitMQ purge/policy captures — because `ops/writes.py` still routed them
93
+ through `num()`. It was missed in the earlier read-path sweep (it did not even
94
+ import `as_int`), and was inconsistent with `list_clients`, which already
95
+ rendered the identical CLIENT LIST `age` field as an `int`. Fixed with
96
+ `as_int()` and a type-asserting regression test (equality cannot catch it:
97
+ `49 == 49.0`); `ageSeconds` re-confirmed as `4` (int) on a live re-kill.
@@ -87,6 +87,32 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
87
87
  }
88
88
 
89
89
 
90
+ def _origin_target(rec: dict) -> Optional[str]:
91
+ """The target the ORIGINAL write ran against, read from its recorded params.
92
+
93
+ Without this the replay goes to whatever target the caller names — in
94
+ practice the config's first entry — so on a multi-target config the inverse
95
+ runs against the wrong host. It only *looks* harmless because the resource
96
+ usually does not exist there; when two hosts both hold a resource with the
97
+ same name the inverse succeeds on the wrong one, silently.
98
+
99
+ Caught live on 2026-08-03 in container-host-aiops: a ``stop_container``
100
+ recorded against a Podman target replayed against a Portainer target.
101
+
102
+ A ``target`` in ``orig_params`` also means the original call accepted one,
103
+ so the fallback cannot hand a keyword to a tool that has no such parameter.
104
+ """
105
+ raw = rec.get("orig_params") or ""
106
+ try:
107
+ orig = json.loads(raw) if raw else {}
108
+ except (ValueError, TypeError):
109
+ return None # unreadable: fall back to the caller/default, as before
110
+ if not isinstance(orig, dict):
111
+ return None
112
+ value = orig.get("target")
113
+ return value if isinstance(value, str) and value else None
114
+
115
+
90
116
  @mcp.tool()
91
117
  @governed_tool(risk_level="medium")
92
118
  @tool_errors("dict")
@@ -150,8 +176,10 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
150
176
  }
151
177
 
152
178
  call_params = dict(params)
153
- if target is not None and "target" not in call_params:
154
- call_params["target"] = target
179
+ if "target" not in call_params:
180
+ chosen = target if target is not None else _origin_target(rec)
181
+ if chosen is not None:
182
+ call_params["target"] = chosen
155
183
  result = fn(**call_params)
156
184
 
157
185
  # Only mark applied when the inverse did not itself return an error dict.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "queue-aiops"
7
- version = "0.5.0"
7
+ version = "0.7.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"
@@ -63,12 +63,25 @@ def _teaching_message(status: int, path: str, body: str, label: str) -> str:
63
63
  f"Bad request (400) on {label} {path}. The broker rejected the "
64
64
  f"request — check required fields and value formats. {snippet}"
65
65
  )
66
+ if status == 406:
67
+ # Content negotiation, and the broker sends NO body with it — so the
68
+ # generic branch produced a message that explained nothing at all
69
+ # (measured while purge_queue was failing on RabbitMQ 3.13.7).
70
+ return (
71
+ f"Content negotiation failed (406) on {label} {path}. The endpoint "
72
+ f"has no representation matching the request's Accept header — "
73
+ f"endpoints that answer '204 No Content' need '*/*' in Accept, not "
74
+ f"'application/json' alone. The broker sends no body with a 406, so "
75
+ f"there is nothing further to quote. {snippet}"
76
+ ).strip()
66
77
  if status in (500, 502, 503, 504):
67
78
  return (
68
79
  f"{label} server error ({status}) on {path}. The broker may be "
69
80
  f"busy; retry shortly. {snippet}"
70
81
  )
71
- return f"{label} API error ({status}) on {path}. {snippet}"
82
+ # `label` already ends in "API" for rabbitmq ("RabbitMQ management API"),
83
+ # so the old "{label} API error" read "…management API API error".
84
+ return f"{label} error ({status}) on {path}. {snippet}".strip()
72
85
 
73
86
 
74
87
  class QueueConnection:
@@ -102,7 +115,19 @@ class QueueConnection:
102
115
  verify=target.verify_ssl,
103
116
  timeout=_TIMEOUT,
104
117
  auth=httpx.BasicAuth(target.username, target.secret),
105
- headers={"Accept": "application/json", "Content-Type": "application/json"},
118
+ # `*/*` must stay in Accept. RabbitMQ's purge endpoint
119
+ # (DELETE /api/queues/{vhost}/{name}/contents) answers 204 with no
120
+ # body and offers no JSON representation, so a bare
121
+ # "Accept: application/json" fails content negotiation and the
122
+ # server returns **406 with an empty body** — measured on 3.13.7.
123
+ # purge_queue could therefore never succeed against a real broker.
124
+ # Every other endpoint (GETs, PUT declare, DELETE queue, DELETE
125
+ # policy) behaves identically under either value; enumerated, not
126
+ # assumed.
127
+ headers={
128
+ "Accept": "application/json, */*",
129
+ "Content-Type": "application/json",
130
+ },
106
131
  )
107
132
 
108
133
  @property
@@ -54,6 +54,10 @@ def _redis_overview(conn: Any) -> dict:
54
54
  "maxmemoryPolicy": mem.get("maxmemoryPolicy"),
55
55
  "fragmentationRatio": mem.get("fragmentationRatio"),
56
56
  "totalKeys": keys.get("totalKeys"),
57
+ # On a cluster node totalKeys covers this node's slots only — the scope
58
+ # marker has to travel with the number, not be left in a sibling call.
59
+ "keyScope": keys.get("scope"),
60
+ "clusterMode": keys.get("clusterMode"),
57
61
  "errors": errors,
58
62
  }
59
63
 
@@ -132,7 +132,9 @@ def list_connections(conn: Any) -> dict:
132
132
  by_host: dict[str, dict] = {}
133
133
  for r in rows:
134
134
  bucket = by_host.setdefault(
135
- r["peerHost"], {"peerHost": r["peerHost"], "connections": 0, "channels": 0.0}
135
+ # A channel count seeded as 0.0 makes the whole sum a float —
136
+ # "1.0 channels" for one channel. Both members are counts.
137
+ r["peerHost"], {"peerHost": r["peerHost"], "connections": 0, "channels": 0}
136
138
  )
137
139
  bucket["connections"] += 1
138
140
  bucket["channels"] += r["channels"]
@@ -211,8 +213,12 @@ def node_health(conn: Any) -> dict:
211
213
  try:
212
214
  rows = []
213
215
  for n in conn.platform.rows(conn.get(conn.platform.path("nodes"))):
214
- mem_used = num(n.get("mem_used"))
215
- mem_limit = num(n.get("mem_limit"))
216
+ # Byte counts, not ratios — every sibling byte field below already
217
+ # used as_int, so routing these two through the float helper made a
218
+ # single payload disagree with itself (memUsedBytes 161222656.0
219
+ # next to diskFreeBytes 36616110080).
220
+ mem_used = as_int(n.get("mem_used"))
221
+ mem_limit = as_int(n.get("mem_limit"))
216
222
  rows.append(
217
223
  {
218
224
  "name": opt(n.get("name"), 64),
@@ -190,8 +190,38 @@ def config_get(conn: Any, pattern: str = "*") -> dict:
190
190
  return {"error": s(exc, 200)}
191
191
 
192
192
 
193
+ def _scope(conn: Any) -> dict:
194
+ """Whether the counts that follow describe the whole dataset or one shard.
195
+
196
+ A Redis Cluster node answers ``INFO keyspace`` for **its own slots only**, so
197
+ on a three-master cluster holding 300 keys this reports 100 — a number that
198
+ is wrong by the shard count and, called ``totalKeys`` with nothing else in
199
+ the payload, reads as the whole dataset. Measured on a real 3-master Redis
200
+ 7.4 cluster. The marker travels with the counts so a consumer can tell a
201
+ complete answer from a partial one.
202
+ """
203
+ try:
204
+ mode = str(as_obj(conn.redis_info("server")).get("redis_mode") or "")
205
+ except Exception: # noqa: BLE001 — scope is an annotation, never the answer
206
+ return {"scope": "server", "clusterMode": None}
207
+ if mode != "cluster":
208
+ return {"scope": "server", "clusterMode": False}
209
+ return {
210
+ "scope": "node",
211
+ "clusterMode": True,
212
+ "note": (
213
+ "Redis Cluster: these counts cover THIS node's slots only, not the "
214
+ "whole cluster. Query each master, or use a cluster-aware client, "
215
+ "for a dataset-wide figure."
216
+ ),
217
+ }
218
+
219
+
193
220
  def keyspace(conn: Any) -> dict:
194
- """[READ] Per-db key counts + expiry coverage from INFO keyspace."""
221
+ """[READ] Per-db key counts + expiry coverage from INFO keyspace.
222
+
223
+ On a cluster node the counts are per-shard; the returned ``scope`` says so.
224
+ """
195
225
  try:
196
226
  info = as_obj(conn.redis_info("keyspace"))
197
227
  dbs = []
@@ -208,7 +238,11 @@ def keyspace(conn: Any) -> dict:
208
238
  "avgTtlMs": as_int(cell.get("avg_ttl")),
209
239
  }
210
240
  )
211
- return {"databases": dbs, "totalKeys": sum(d["keys"] for d in dbs)}
241
+ return {
242
+ "databases": dbs,
243
+ "totalKeys": sum(d["keys"] for d in dbs),
244
+ **_scope(conn),
245
+ }
212
246
  except Exception as exc: # noqa: BLE001 — report as partial
213
247
  return {"error": s(exc, 200)}
214
248
 
@@ -42,7 +42,7 @@ import re
42
42
  from typing import Any
43
43
 
44
44
  from queue_aiops.connection import QueueApiError
45
- from queue_aiops.ops._util import as_obj, num, opt, s
45
+ from queue_aiops.ops._util import as_int, as_obj, opt, s
46
46
 
47
47
  # CONFIG SET parameter names are plain words (e.g. maxmemory-policy); reject
48
48
  # anything else before it reaches the wire.
@@ -220,7 +220,7 @@ def kill_client(conn: Any, client_id: int = 0, addr: str = "") -> dict:
220
220
  "addr": opt(c.get("addr"), 64),
221
221
  "name": opt(c.get("name"), 64),
222
222
  "lastCommand": opt(c.get("cmd"), 64),
223
- "ageSeconds": num(c.get("age")),
223
+ "ageSeconds": as_int(c.get("age")),
224
224
  }
225
225
  break
226
226
  killed = (
@@ -246,7 +246,7 @@ def _queue_def(conn: Any, vhost: str, name: str) -> dict:
246
246
  conn.platform.normalise(conn.get(conn.platform.path("queue", vhost=vhost, name=name)))
247
247
  )
248
248
  return {
249
- "messages": num(obj.get("messages")),
249
+ "messages": as_int(obj.get("messages")),
250
250
  "durable": bool(obj.get("durable")),
251
251
  "autoDelete": bool(obj.get("auto_delete")),
252
252
  "arguments": as_obj(obj.get("arguments")),
@@ -350,7 +350,7 @@ def _policy_or_none(conn: Any, vhost: str, name: str) -> dict | None:
350
350
  return {
351
351
  "pattern": opt(obj.get("pattern"), 128),
352
352
  "definition": as_obj(obj.get("definition")),
353
- "priority": num(obj.get("priority")),
353
+ "priority": as_int(obj.get("priority")),
354
354
  "applyTo": opt(obj.get("apply-to") or obj.get("apply_to") or "all", 32),
355
355
  }
356
356
 
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Queue-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.5.0",
10
+ "version": "0.7.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "queue-aiops",
15
- "version": "0.5.0",
15
+ "version": "0.7.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -239,3 +239,56 @@ def test_manager_unknown_target_teaches_available(fake_build):
239
239
  mgr = ConnectionManager(_cfg())
240
240
  with pytest.raises(KeyError, match="Available: cache1, broker1"):
241
241
  mgr.connect("ghost")
242
+
243
+
244
+ @pytest.mark.unit
245
+ def test_rabbitmq_accept_header_keeps_a_wildcard_for_no_content_endpoints(monkeypatch):
246
+ """Accept must not be JSON-only, or the purge endpoint 406s.
247
+
248
+ Measured on a real RabbitMQ 3.13.7: ``DELETE /api/queues/{vhost}/{name}
249
+ /contents`` answers 204 with no body and offers no JSON representation, so
250
+ a bare ``Accept: application/json`` fails content negotiation and the broker
251
+ returns **406 with an empty body** — meaning ``purge_queue`` could never
252
+ succeed against a real server. Adding ``*/*`` returns 204, and every other
253
+ endpoint (GETs, PUT declare, DELETE queue, DELETE policy) answers the same
254
+ either way; that was enumerated against the live broker, not assumed.
255
+
256
+ Nothing else pins this header, which is why the defect survived: the fake
257
+ HTTP client in the unit suite never negotiates content.
258
+ """
259
+ monkeypatch.setenv("QUEUE_BROKER1_SECRET", "pw")
260
+ client = QueueConnection._build_client(rabbit_target())
261
+ try:
262
+ accept = client.headers["accept"]
263
+ finally:
264
+ client.close()
265
+ assert "application/json" in accept
266
+ assert "*/*" in accept, (
267
+ "purge (DELETE .../contents) 406s when Accept is JSON-only — keep */*"
268
+ )
269
+
270
+
271
+ @pytest.mark.unit
272
+ def test_rabbitmq_406_explains_content_negotiation():
273
+ """A 406 carries no body, so the generic branch explained nothing.
274
+
275
+ Encountered live while purge_queue was failing: the operator got
276
+ "RabbitMQ management API API error (406) on /api/queues/%2F/q/contents. "
277
+ — a doubled word and zero diagnosis. The 406 branch now names the cause,
278
+ and the generic branch no longer repeats "API" after a label ending in it.
279
+ """
280
+ conn = rabbit_conn({("GET", "/api/x"): FakeResponse(406, text="")})
281
+ with pytest.raises(QueueApiError) as err:
282
+ conn.get("/api/x")
283
+ msg = str(err.value)
284
+ assert "Content negotiation failed (406)" in msg
285
+ assert "Accept" in msg
286
+ assert "API API error" not in msg
287
+
288
+
289
+ @pytest.mark.unit
290
+ def test_rabbitmq_unmapped_status_does_not_double_the_word_api():
291
+ conn = rabbit_conn({("GET", "/api/x"): FakeResponse(418, text="teapot")})
292
+ with pytest.raises(QueueApiError) as err:
293
+ conn.get("/api/x")
294
+ assert "API API error" not in str(err.value)
@@ -239,6 +239,9 @@ def test_list_connections_groups_by_peer_host():
239
239
  assert out["total"] == 3
240
240
  assert out["byPeerHost"][0]["peerHost"] == "10.0.0.7"
241
241
  assert out["byPeerHost"][0]["channels"] == 4
242
+ # A channel count, not a rate: seeding the accumulator at 0.0 rendered one
243
+ # channel as "1.0" on a live broker. Equality cannot catch it (4 == 4.0).
244
+ assert isinstance(out["byPeerHost"][0]["channels"], int)
242
245
 
243
246
 
244
247
  @pytest.mark.unit
@@ -274,6 +277,12 @@ def test_node_health_flags_alarms():
274
277
  out = rabbit_reads.node_health(conn)
275
278
  assert out["alarms"] == 1
276
279
  assert out["nodes"][0]["memAlarm"] is True
280
+ # Byte counts stay integers. On a live broker these two came back as
281
+ # 161222656.0 / 13462616473.0 while diskFreeBytes beside them was an int —
282
+ # one payload disagreeing with itself (bug class #2).
283
+ node = out["nodes"][0]
284
+ for field in ("memUsedBytes", "memLimitBytes", "diskFreeBytes"):
285
+ assert isinstance(node[field], int), field
277
286
 
278
287
 
279
288
  @pytest.mark.unit
@@ -308,3 +317,36 @@ def test_queue_overview_rabbitmq_shape_degrades_partially():
308
317
  assert out["platform"] == "rabbitmq"
309
318
  assert out["queues"] == 12
310
319
  assert any("nodes:" in e for e in out["errors"])
320
+
321
+
322
+ @pytest.mark.unit
323
+ def test_keyspace_marks_a_cluster_node_count_as_partial():
324
+ """A cluster node's key count is per-shard and must say so.
325
+
326
+ ``INFO keyspace`` on a Redis Cluster node answers for its own slots only, so
327
+ a 3-master cluster holding 300 keys reports 100 here. Emitting that as
328
+ ``totalKeys`` with nothing else in the payload states a dataset-wide figure
329
+ that is wrong by the shard count. Measured on a real 3-master Redis 7.4
330
+ cluster: 100 / 92 / 108.
331
+ """
332
+ conn = redis_conn(info={
333
+ "keyspace": {"db0": "keys=100,expires=0,avg_ttl=0"},
334
+ "server": {"redis_mode": "cluster"},
335
+ })
336
+ out = redis_reads.keyspace(conn)
337
+ assert out["totalKeys"] == 100
338
+ assert out["scope"] == "node"
339
+ assert out["clusterMode"] is True
340
+ assert "THIS node" in out["note"]
341
+
342
+
343
+ @pytest.mark.unit
344
+ def test_keyspace_on_a_standalone_server_is_scoped_whole():
345
+ conn = redis_conn(info={
346
+ "keyspace": {"db0": "keys=7,expires=0,avg_ttl=0"},
347
+ "server": {"redis_mode": "standalone"},
348
+ })
349
+ out = redis_reads.keyspace(conn)
350
+ assert out["scope"] == "server"
351
+ assert out["clusterMode"] is False
352
+ assert "note" not in out
@@ -21,18 +21,21 @@ from mcp_server.tools import undo as gov
21
21
  from queue_aiops.governance import governed_tool
22
22
 
23
23
  _CALLS: list[dict] = []
24
+ _TARGETS: list = []
24
25
 
25
26
 
26
27
  @governed_tool(risk_level="low")
27
28
  def _undo_probe(value: str = "", target=None) -> dict:
28
29
  """Synthetic inverse target used only by the undo-executor tests."""
29
30
  _CALLS.append({"value": value})
31
+ _TARGETS.append(target)
30
32
  return {"ok": True, "value": value}
31
33
 
32
34
 
33
35
  @pytest.fixture
34
36
  def gov_home(tmp_path, monkeypatch):
35
37
  _CALLS.clear()
38
+ _TARGETS.clear()
36
39
  # Register the probe only for the duration of these tests so it never
37
40
  # pollutes the real tool registry (which exact-count smoke tests assert on).
38
41
  mcp.add_tool(_undo_probe, name="_undo_probe")
@@ -77,6 +80,52 @@ def test_undo_apply_dispatches_inverse_and_marks_applied(gov_home):
77
80
  assert uid not in {u["undoId"] for u in gov.undo_list()["undos"]}
78
81
 
79
82
 
83
+ @pytest.mark.unit
84
+ def test_undo_apply_replays_against_the_target_the_write_ran_on(gov_home):
85
+ """A replay must go to the host the original write went to.
86
+
87
+ Without this the inverse ran against whatever target the caller named — in
88
+ practice the config's first entry — so on a multi-target config it hit the
89
+ wrong host. It only looked harmless because the resource usually does not
90
+ exist there; two hosts holding the same name and the inverse succeeds on the
91
+ wrong one, silently. Caught live on 2026-08-03 in container-host-aiops: a
92
+ stop recorded against a Podman target replayed against a Portainer target.
93
+ """
94
+ uid = undo_mod.get_undo_store().record(
95
+ skill="probe",
96
+ tool="orig_op",
97
+ undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
98
+ orig_params={"value": "v1", "target": "second-host"},
99
+ )
100
+ result = gov.undo_apply(undo_id=uid)
101
+ assert result["applied"] is True
102
+ assert _TARGETS == ["second-host"]
103
+
104
+
105
+ @pytest.mark.unit
106
+ def test_undo_apply_prefers_an_explicit_caller_target(gov_home):
107
+ """An explicitly named target still wins — the fallback is only a default."""
108
+ uid = undo_mod.get_undo_store().record(
109
+ skill="probe",
110
+ tool="orig_op",
111
+ undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
112
+ orig_params={"value": "v1", "target": "second-host"},
113
+ )
114
+ gov.undo_apply(undo_id=uid, target="chosen-host")
115
+ assert _TARGETS == ["chosen-host"]
116
+
117
+
118
+ @pytest.mark.unit
119
+ def test_undo_apply_survives_unreadable_orig_params(gov_home):
120
+ """Corrupt orig_params must not break the replay — just lose the fallback."""
121
+ uid = _record(params={"value": "v1"})
122
+ with sqlite3.connect(gov_home / "undo.db") as db:
123
+ db.execute("UPDATE undo_log SET orig_params = ? WHERE undo_id = ?",
124
+ ("{not json", uid))
125
+ assert gov.undo_apply(undo_id=uid)["applied"] is True
126
+ assert _TARGETS == [None]
127
+
128
+
80
129
  @pytest.mark.unit
81
130
  def test_undo_apply_dry_run_previews_without_running(gov_home):
82
131
  uid = _record()
@@ -71,6 +71,42 @@ def test_kill_client_requires_id_or_addr():
71
71
  ops.kill_client(conn)
72
72
 
73
73
 
74
+ @pytest.mark.unit
75
+ def test_write_prior_state_integer_quantities_stay_int():
76
+ """priorState counts/whole-seconds must be int, not float (bug class #2/#4).
77
+
78
+ CLIENT LIST ``age`` (whole seconds), a queue's ``messages`` count and a
79
+ policy ``priority`` are integer quantities. The write path rendered them
80
+ through ``num`` → ``120.0``/``1234.0``/``3.0`` — inconsistent with the read
81
+ path (``list_clients`` already uses ``as_int``) and semantically wrong. An
82
+ equality assertion cannot catch this (``120 == 120.0``); assert the *type*.
83
+ ``bool`` must not sneak through the int check (it subclasses int).
84
+ """
85
+ kc = ops.kill_client(
86
+ redis_conn(clients=[{"id": "77", "addr": "10.0.0.5:5000", "cmd": "blpop",
87
+ "age": "120"}]),
88
+ client_id=77,
89
+ )
90
+ age = kc["priorState"]["client"]["ageSeconds"]
91
+ assert isinstance(age, int) and not isinstance(age, bool) and age == 120
92
+
93
+ purge = ops.purge_queue(
94
+ rabbit_conn({("GET", "/api/queues/%2F/orders"): _Q,
95
+ ("DELETE", "/api/queues/%2F/orders/contents"): {}}),
96
+ "/", "orders",
97
+ )
98
+ msgs = purge["priorState"]["messages"]
99
+ assert isinstance(msgs, int) and not isinstance(msgs, bool) and msgs == 1234
100
+
101
+ policy = ops.set_policy(
102
+ rabbit_conn({("GET", "/api/policies/%2F/lim"): _POLICY,
103
+ ("PUT", "/api/policies/%2F/lim"): {}}),
104
+ "/", "lim", "^work\\.", {"max-length": 9000}, priority=4,
105
+ )
106
+ prio = policy["priorState"]["policy"]["priority"]
107
+ assert isinstance(prio, int) and not isinstance(prio, bool) and prio == 3
108
+
109
+
74
110
  # ── rabbitmq purge/delete prior-state capture ────────────────────────────────
75
111
 
76
112
  _Q = {"name": "orders", "vhost": "/", "messages": 1234, "durable": True,
@@ -812,7 +812,7 @@ wheels = [
812
812
 
813
813
  [[package]]
814
814
  name = "queue-aiops"
815
- version = "0.5.0"
815
+ version = "0.7.0"
816
816
  source = { editable = "." }
817
817
  dependencies = [
818
818
  { name = "cryptography" },
@@ -1,34 +0,0 @@
1
- # Live verification status
2
-
3
- This document records what has and has not been validated against real Redis /
4
- RabbitMQ servers, so the maturity claim is auditable rather than a vibe.
5
-
6
- ## Already live-verified ✅ — Redis 7.4.9, 2026-07-19
7
-
8
- Exercised end-to-end against a real **Redis 7.4.9** server (Docker) seeded with
9
- 202 keys and a list-backed job queue:
10
-
11
- - `doctor` against a live server: PING OK, no-AUTH lab path reported clearly.
12
- - Every Redis read cross-checked against `redis-cli` ground truth:
13
- `overview`, `redis info/memory/clients/slowlog/keyspace/bigkeys`
14
- (key count matched `DBSIZE` exactly).
15
- - All four analyses ran clean: `analyze memory/latency/backlog/churn`.
16
- - Governance loop end-to-end: `redis_config_set` really changed
17
- `maxmemory-policy`, captured `noeviction` as `priorState`, wrote an audit row,
18
- and `undo_apply` restored the prior value on the live server.
19
-
20
- **A real defect was found and fixed by this run**: integer quantities were
21
- rendered as floats — `202.0` keys, `1.0` connected clients, float byte counts —
22
- because they were routed through `num()`. Values were arithmetically right but
23
- semantically wrong, and equality assertions could not catch it
24
- (`202 == 202.0`). Fixed with `as_int()` across the Redis and RabbitMQ reads,
25
- plus a regression test that asserts the *type*, keeping genuine ratios
26
- (`hitRatePct`, `usedPctOfMax`, `opsPerSec`) as floats.
27
-
28
- ## Not yet live-verified ⚠️
29
-
30
- - **RabbitMQ** — the entire `rabbitmq` command group and its management-API
31
- shapes are unit-tested only. This is now the largest gap in this repo.
32
- - Redis **cluster / sentinel** topologies (only standalone was exercised).
33
- - AUTH-enabled Redis and TLS connections.
34
- - `kill-client` against a real blocked client.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes