queue-aiops 0.6.0__tar.gz → 0.8.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 (93) hide show
  1. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/CHANGELOG.md +14 -0
  2. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/PKG-INFO +1 -1
  3. queue_aiops-0.8.0/docs/VERIFICATION.md +97 -0
  4. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/pyproject.toml +1 -1
  5. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/_common.py +47 -0
  6. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/rabbit_cmds.py +10 -7
  7. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/redis_cmds.py +3 -2
  8. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/undo.py +2 -1
  9. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/connection.py +27 -2
  10. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/rabbit_reads.py +9 -3
  11. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/writes.py +4 -4
  12. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/server.json +2 -2
  13. queue_aiops-0.8.0/tests/test_cli_governed_exit.py +167 -0
  14. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_connection.py +53 -0
  15. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_reads.py +9 -0
  16. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_writes.py +36 -0
  17. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/uv.lock +1 -1
  18. queue_aiops-0.6.0/docs/VERIFICATION.md +0 -41
  19. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/.github/workflows/mcp-publish.yml +0 -0
  20. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/.github/workflows/publish.yml +0 -0
  21. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/.gitignore +0 -0
  22. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/Dockerfile +0 -0
  23. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/LICENSE +0 -0
  24. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/README.md +0 -0
  25. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/RELEASE_NOTES.md +0 -0
  26. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/SECURITY.md +0 -0
  27. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/glama.json +0 -0
  28. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/__init__.py +0 -0
  29. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/_shared.py +0 -0
  30. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/server.py +0 -0
  31. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/__init__.py +0 -0
  32. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/analysis.py +0 -0
  33. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/overview.py +0 -0
  34. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/rabbit_reads.py +0 -0
  35. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/redis_reads.py +0 -0
  36. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/undo.py +0 -0
  37. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/mcp_server/tools/writes.py +0 -0
  38. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/__init__.py +0 -0
  39. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/__init__.py +0 -0
  40. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/_root.py +0 -0
  41. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/analyze.py +0 -0
  42. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/doctor.py +0 -0
  43. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/init.py +0 -0
  44. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/overview.py +0 -0
  45. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/cli/secret.py +0 -0
  46. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/config.py +0 -0
  47. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/doctor.py +0 -0
  48. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/__init__.py +0 -0
  49. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/audit.py +0 -0
  50. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/budget.py +0 -0
  51. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/decorators.py +0 -0
  52. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/outcome.py +0 -0
  53. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/paths.py +0 -0
  54. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/patterns.py +0 -0
  55. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/policy.py +0 -0
  56. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/sanitize.py +0 -0
  57. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/governance/undo.py +0 -0
  58. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/__init__.py +0 -0
  59. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/_util.py +0 -0
  60. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/analysis.py +0 -0
  61. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/overview.py +0 -0
  62. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/ops/redis_reads.py +0 -0
  63. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/platform.py +0 -0
  64. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/queue_aiops/secretstore.py +0 -0
  65. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/SKILL.md +0 -0
  66. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
  67. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/capabilities.md +0 -0
  68. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/cli-reference.md +0 -0
  69. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/setup-guide.md +0 -0
  70. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/smithery.yaml +0 -0
  71. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/conftest.py +0 -0
  72. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/fakes.py +0 -0
  73. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_analysis.py +0 -0
  74. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_rabbit_writes.py +0 -0
  75. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_reads.py +0 -0
  76. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_secret.py +0 -0
  77. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_cli_writes.py +0 -0
  78. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_doctor.py +0 -0
  79. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_audit.py +0 -0
  80. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_decorators.py +0 -0
  81. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_patterns.py +0 -0
  82. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_gov_policy.py +0 -0
  83. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_governance_persistence.py +0 -0
  84. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_init.py +0 -0
  85. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_int_fidelity.py +0 -0
  86. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_mcp_reads.py +0 -0
  87. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_optional_fields.py +0 -0
  88. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_platform.py +0 -0
  89. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_secretstore.py +0 -0
  90. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_self_kill.py +0 -0
  91. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_self_lockout.py +0 -0
  92. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_smoke.py +0 -0
  93. {queue_aiops-0.6.0 → queue_aiops-0.8.0}/tests/test_undo_executor.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.8.0 — 2026-08-10
4
+
5
+ ### Fixed
6
+ - **An undetermined outcome no longer exits as a plain failure.** A write whose response was lost carries *both* `error` and `outcomeUnknown`, and the harness deliberately judges unknown first when writing the audit row — the change may have taken effect, so a blind retry could apply it twice. The CLI guard judged `error` first, so the audit said "may have taken effect" while the exit status told a script it had not happened. The two layers now agree (exit 2, not 1), and a test pins the ordering so it cannot silently flip back.
7
+ - **The CLI reported a refused or failed governed write as a success.** 8 write call sites printed the governed twin's payload and exited **0** whatever it said — and `@tool_errors` flattens every refusal, guard rejection and upstream failure into `{"error": ...}` rather than raising, so nothing downstream of a `&&` chain or a CI step could tell a blocked write from a landed one. The dry-run path already exited non-zero, which made the asymmetry worse: the preview was stricter than the write it previews. Results now route through a `checked()` helper — exit 1 on an error payload, exit 2 on an undetermined outcome, unchanged on success. This defect class had been fixed repo-by-repo several times and kept coming back; an audit across the whole line found it live in **18 of the 24 tools at once (87 call sites)**, so each tool now carries an invariant test that fails if any future CLI command prints a governed result without checking it.
8
+
9
+ ## v0.7.0 — 2026-08-10
10
+
11
+ ### Fixed
12
+ - **`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.
13
+ - **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.
14
+ - **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.
15
+ - **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.
16
+
3
17
  ## v0.6.0 — 2026-08-03
4
18
 
5
19
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queue-aiops
3
- Version: 0.6.0
3
+ Version: 0.8.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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "queue-aiops"
7
- version = "0.6.0"
7
+ version = "0.8.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"
@@ -76,6 +76,53 @@ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = No
76
76
  console.print("[magenta] Run without --dry-run to execute.[/]\n")
77
77
 
78
78
 
79
+ #: Exit status for a write whose outcome could not be determined. Kept distinct
80
+ #: from 0 (confirmed) and 1 (failed) on purpose: a write whose response was lost
81
+ #: is not a failure, but it is emphatically not a success either, and a script
82
+ #: must be able to tell all three apart.
83
+ EXIT_UNDETERMINED = 2
84
+
85
+
86
+ def checked(result: Any) -> Any:
87
+ """Return ``result``, or abort when it reports a failed/undetermined write.
88
+
89
+ Every CLI command that calls a governed twin MUST pass the result through
90
+ here before printing its success line.
91
+
92
+ Governed twins are wrapped in ``@tool_errors``, which flattens any exception
93
+ into ``{"error": ...}`` and **returns** it. The CLI therefore never sees the
94
+ exception, so a command that prints its result unconditionally reports a
95
+ refused or failed operation exactly like a successful one — and exits 0, so
96
+ a script cannot tell either. The dry-run path already refused with a
97
+ non-zero status, which made the asymmetry worse: the preview was stricter
98
+ than the real call.
99
+
100
+ ``outcomeUnknown`` (set by the harness when a write's response is lost) is
101
+ neither success nor failure — the change may still have landed. It gets its
102
+ own line and :data:`EXIT_UNDETERMINED`, never a silent success.
103
+ """
104
+ if not isinstance(result, dict):
105
+ return result
106
+ error = result.get("error")
107
+ # ``outcomeUnknown`` is judged BEFORE ``error``, matching the harness: a
108
+ # write whose response was lost carries BOTH keys, and it is audited
109
+ # `unknown` precisely because it may have taken effect. Reporting that as a
110
+ # plain failure would tell a script the change did not happen and invite the
111
+ # double-apply the payload's own note warns about.
112
+ if result.get("outcomeUnknown"):
113
+ console.print(
114
+ f"[yellow]Outcome undetermined: {result.get('note') or ''}[/]"
115
+ )
116
+ raise typer.Exit(EXIT_UNDETERMINED)
117
+ if error:
118
+ console.print(f"[red]Error: {error}[/]")
119
+ hint = result.get("hint")
120
+ if hint:
121
+ console.print(f"[dim]{hint}[/]")
122
+ raise typer.Exit(1)
123
+ return result
124
+
125
+
79
126
  def dry_run_preview(
80
127
  preview: Any, *, operation: str, api_call: str, parameters: dict | None = None
81
128
  ) -> None:
@@ -10,6 +10,7 @@ import typer
10
10
  from queue_aiops.cli._common import (
11
11
  DryRunOption,
12
12
  TargetOption,
13
+ checked,
13
14
  cli_errors,
14
15
  console,
15
16
  double_confirm,
@@ -157,7 +158,7 @@ def rabbit_purge(
157
158
  parameters={"vhost": vhost, "name": name})
158
159
  return
159
160
  double_confirm("purge queue", name)
160
- console.print_json(json.dumps(gov.purge_queue(vhost=vhost, name=name, target=target)))
161
+ console.print_json(json.dumps(checked(gov.purge_queue(vhost=vhost, name=name, target=target))))
161
162
 
162
163
 
163
164
  @rabbitmq_app.command("delete-queue")
@@ -180,7 +181,7 @@ def rabbit_delete_queue(
180
181
  parameters={"vhost": vhost, "name": name})
181
182
  return
182
183
  double_confirm("delete queue", name)
183
- console.print_json(json.dumps(gov.delete_queue(vhost=vhost, name=name, target=target)))
184
+ console.print_json(json.dumps(checked(gov.delete_queue(vhost=vhost, name=name, target=target))))
184
185
 
185
186
 
186
187
  @rabbitmq_app.command("declare-queue")
@@ -211,9 +212,9 @@ def rabbit_declare_queue(
211
212
  "auto_delete": auto_delete})
212
213
  return
213
214
  double_confirm("declare queue", name)
214
- console.print_json(json.dumps(gov.declare_queue(
215
+ console.print_json(json.dumps(checked(gov.declare_queue(
215
216
  vhost=vhost, name=name, durable=durable, auto_delete=auto_delete, target=target
216
- )))
217
+ ))))
217
218
 
218
219
 
219
220
  @rabbitmq_app.command("set-policy")
@@ -252,10 +253,10 @@ def rabbit_set_policy(
252
253
  "apply_to": apply_to})
253
254
  return
254
255
  double_confirm("set policy", name)
255
- console.print_json(json.dumps(gov.set_policy(
256
+ console.print_json(json.dumps(checked(gov.set_policy(
256
257
  vhost=vhost, name=name, pattern=pattern, definition=definition_obj,
257
258
  priority=priority, apply_to=apply_to, target=target,
258
- )))
259
+ ))))
259
260
 
260
261
 
261
262
  @rabbitmq_app.command("delete-policy")
@@ -278,4 +279,6 @@ def rabbit_delete_policy(
278
279
  parameters={"vhost": vhost, "name": name})
279
280
  return
280
281
  double_confirm("delete policy", name)
281
- console.print_json(json.dumps(gov.delete_policy(vhost=vhost, name=name, target=target)))
282
+ console.print_json(
283
+ json.dumps(checked(gov.delete_policy(vhost=vhost, name=name, target=target)))
284
+ )
@@ -10,6 +10,7 @@ import typer
10
10
  from queue_aiops.cli._common import (
11
11
  DryRunOption,
12
12
  TargetOption,
13
+ checked,
13
14
  cli_errors,
14
15
  console,
15
16
  double_confirm,
@@ -138,7 +139,7 @@ def redis_config_set(
138
139
  return
139
140
  double_confirm("set config parameter", parameter)
140
141
  console.print_json(
141
- json.dumps(gov.redis_config_set(parameter=parameter, value=value, target=target))
142
+ json.dumps(checked(gov.redis_config_set(parameter=parameter, value=value, target=target)))
142
143
  )
143
144
 
144
145
 
@@ -165,5 +166,5 @@ def redis_kill_client(
165
166
  return
166
167
  double_confirm("kill client", who or "(unspecified)")
167
168
  console.print_json(
168
- json.dumps(gov.redis_kill_client(client_id=client_id, addr=addr, target=target))
169
+ json.dumps(checked(gov.redis_kill_client(client_id=client_id, addr=addr, target=target)))
169
170
  )
@@ -15,6 +15,7 @@ import typer
15
15
  from queue_aiops.cli._common import (
16
16
  DryRunOption,
17
17
  TargetOption,
18
+ checked,
18
19
  cli_errors,
19
20
  console,
20
21
  double_confirm,
@@ -65,4 +66,4 @@ def undo_apply_cmd(
65
66
  )
66
67
  return
67
68
  double_confirm("apply undo", undo_id)
68
- console.print_json(json.dumps(gov.undo_apply(undo_id=undo_id, target=target)))
69
+ console.print_json(json.dumps(checked(gov.undo_apply(undo_id=undo_id, target=target))))
@@ -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
@@ -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),
@@ -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.6.0",
10
+ "version": "0.8.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "queue-aiops",
15
- "version": "0.6.0",
15
+ "version": "0.8.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -0,0 +1,167 @@
1
+ """The CLI must never report a failed governed write as a success.
2
+
3
+ Two regressions are pinned here.
4
+
5
+ **The behaviour** — ``checked()`` aborts non-zero on an error payload and on an
6
+ undetermined outcome. Governed twins are wrapped in ``@tool_errors``, which
7
+ flattens every exception into ``{"error": ...}`` and *returns* it, so a
8
+ command that prints its result unconditionally exits 0 for an operation that
9
+ was refused or failed.
10
+
11
+ **The invariant** — every CLI call into a governed write twin routes its result
12
+ through that helper. This is the part that matters over time: the defect was
13
+ fixed repo-by-repo several times and kept coming back, because nothing stopped
14
+ a *new* command from printing a governed result bare. A structural assertion
15
+ does. It was introduced after an audit found the preview path guarded and the
16
+ real write path unguarded in 18 of the 24 tools at once — the preview was
17
+ stricter than the write it previews.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+ import pathlib
24
+
25
+ import pytest
26
+ import typer
27
+
28
+ from queue_aiops.cli._common import checked
29
+
30
+ CLI_DIR = pathlib.Path(__file__).resolve().parents[1] / "queue_aiops" / "cli"
31
+ TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "mcp_server" / "tools"
32
+
33
+ #: Helpers that end the command non-zero when the payload reports a failure.
34
+ CHECKERS = ['checked', 'dry_run_preview']
35
+
36
+
37
+ @pytest.mark.unit
38
+ def test_checked_aborts_on_error_payload():
39
+ with pytest.raises(typer.Exit) as exc:
40
+ checked({"error": "refused: that would lock this tool out"})
41
+ assert exc.value.exit_code == 1
42
+
43
+
44
+ @pytest.mark.unit
45
+ def test_checked_passes_a_successful_payload_through():
46
+ payload = {"action": "did_a_thing", "ok": True}
47
+ assert checked(payload) == payload
48
+
49
+
50
+ def _callee(node: ast.AST) -> str | None:
51
+ if isinstance(node, ast.Name):
52
+ return node.id
53
+ if isinstance(node, ast.Attribute):
54
+ return node.attr
55
+ return None
56
+
57
+
58
+ def _write_tool_names() -> set[str]:
59
+ names: set[str] = set()
60
+ for path in TOOLS_DIR.glob("*.py"):
61
+ tree = ast.parse(path.read_text())
62
+ for node in ast.walk(tree):
63
+ if isinstance(node, ast.FunctionDef) and "[WRITE]" in (ast.get_docstring(node) or ""):
64
+ names.add(node.name)
65
+ return names
66
+
67
+
68
+ def _aborts_on_failure(fn: ast.FunctionDef) -> bool:
69
+ """Does this function raise ``typer.Exit`` off a failure key in the payload?
70
+
71
+ Several commands guard with a locally named helper (``_require_ok``,
72
+ ``_emit``, ...) rather than the shared one. Those are correct guards, so the
73
+ invariant recognises the *shape* instead of a list of blessed names.
74
+ """
75
+ raises_exit = any(
76
+ isinstance(n, ast.Call) and _callee(n.func) == "Exit" for n in ast.walk(fn)
77
+ )
78
+ reads_failure_key = any(
79
+ isinstance(n, ast.Constant) and n.value in ("error", "outcomeUnknown")
80
+ for n in ast.walk(fn)
81
+ )
82
+ return raises_exit and reads_failure_key
83
+
84
+
85
+ def _checker_names() -> set[str]:
86
+ """CHECKERS, every local guard, and any wrapper delegating to one of them."""
87
+ funcs: dict[str, list[ast.FunctionDef]] = {}
88
+ for path in CLI_DIR.rglob("*.py"):
89
+ for node in ast.walk(ast.parse(path.read_text())):
90
+ if isinstance(node, ast.FunctionDef):
91
+ funcs.setdefault(node.name, []).append(node)
92
+ found = set(CHECKERS) | {
93
+ name for name, defs in funcs.items() if any(_aborts_on_failure(f) for f in defs)
94
+ }
95
+ changed = True
96
+ while changed:
97
+ changed = False
98
+ for name, defs in funcs.items():
99
+ if name in found:
100
+ continue
101
+ for fn in defs:
102
+ if any(isinstance(n, ast.Call) and _callee(n.func) in found for n in ast.walk(fn)):
103
+ found.add(name)
104
+ changed = True
105
+ break
106
+ return found
107
+
108
+
109
+ @pytest.mark.unit
110
+ def test_every_cli_governed_write_result_is_checked():
111
+ tools = _write_tool_names()
112
+ assert tools, "no [WRITE] tools parsed — the invariant would vacuously pass"
113
+ checkers = _checker_names()
114
+ unchecked: list[str] = []
115
+
116
+ for path in sorted(CLI_DIR.rglob("*.py")):
117
+ tree = ast.parse(path.read_text())
118
+ for func in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
119
+ parents: dict[ast.AST, ast.AST] = {}
120
+ for node in ast.walk(func):
121
+ for child in ast.iter_child_nodes(node):
122
+ parents[child] = node
123
+ guarded = {
124
+ arg.id
125
+ for node in ast.walk(func)
126
+ if isinstance(node, ast.Call) and _callee(node.func) in checkers
127
+ for arg in list(node.args) + [kw.value for kw in node.keywords]
128
+ if isinstance(arg, ast.Name)
129
+ }
130
+ for node in ast.walk(func):
131
+ if not (isinstance(node, ast.Call) and _callee(node.func) in tools):
132
+ continue
133
+ parent = parents.get(node)
134
+ if isinstance(parent, (ast.keyword, ast.Starred)):
135
+ parent = parents.get(parent)
136
+ if isinstance(parent, ast.Call) and _callee(parent.func) in checkers:
137
+ continue
138
+ if isinstance(parent, ast.Assign) and any(
139
+ isinstance(t, ast.Name) and t.id in guarded for t in parent.targets
140
+ ):
141
+ continue
142
+ unchecked.append(f"{path.name}:{node.lineno}:{_callee(node.func)}")
143
+
144
+ assert not unchecked, (
145
+ "these CLI call sites print a governed write's result without routing it "
146
+ f"through {sorted(checkers)}, so a refusal exits 0: {unchecked}"
147
+ )
148
+
149
+ @pytest.mark.unit
150
+ def test_undetermined_outcome_beats_error_and_is_not_a_plain_failure():
151
+ """A lost response carries BOTH keys — it must exit 2, not 1.
152
+
153
+ ``@tool_errors`` flattens the exception into ``{"error": ...}`` and, when
154
+ the failure is one where the write may still have landed, also sets
155
+ ``outcomeUnknown``. The harness deliberately judges unknown *before* error
156
+ when writing the audit row, for the reason its own note gives: the change
157
+ may have taken effect, so a blind retry could apply it twice. The CLI has to
158
+ agree, or the audit says "may have taken effect" while the exit status tells
159
+ a script it did not happen.
160
+ """
161
+ with pytest.raises(typer.Exit) as exc:
162
+ checked({
163
+ "error": "connection reset while awaiting the response",
164
+ "outcomeUnknown": True,
165
+ "note": "may have taken effect",
166
+ })
167
+ assert exc.value.exit_code == 2
@@ -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
@@ -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.6.0"
815
+ version = "0.8.0"
816
816
  source = { editable = "." }
817
817
  dependencies = [
818
818
  { name = "cryptography" },
@@ -1,41 +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
- **Both exercised 2026-08-03**, and the cluster one found a defect: a node's
34
- `totalKeys` was reported as the dataset total (100 on a 3-master cluster
35
- holding 300). Reads, `overview` and the RCAs otherwise behaved correctly on a
36
- cluster node, on a Sentinel-fronted primary, and on its replica (`role:
37
- slave`). Still untested: an actual **failover** through Sentinel, and any
38
- cluster-wide aggregate — this tool talks to one endpoint by design, which is
39
- exactly why the scope marker matters.
40
- - AUTH-enabled Redis and TLS connections.
41
- - `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