queue-aiops 0.7.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.
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/CHANGELOG.md +6 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/PKG-INFO +1 -1
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/pyproject.toml +1 -1
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/_common.py +47 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/rabbit_cmds.py +10 -7
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/redis_cmds.py +3 -2
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/undo.py +2 -1
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/server.json +2 -2
- queue_aiops-0.8.0/tests/test_cli_governed_exit.py +167 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/uv.lock +1 -1
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/.github/workflows/mcp-publish.yml +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/.github/workflows/publish.yml +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/.gitignore +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/Dockerfile +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/LICENSE +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/README.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/RELEASE_NOTES.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/SECURITY.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/docs/VERIFICATION.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/glama.json +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/__init__.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/_shared.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/server.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/__init__.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/analysis.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/overview.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/rabbit_reads.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/redis_reads.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/undo.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/writes.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/__init__.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/__init__.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/_root.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/analyze.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/doctor.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/init.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/overview.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/secret.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/config.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/connection.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/doctor.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/__init__.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/audit.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/budget.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/decorators.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/outcome.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/paths.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/patterns.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/policy.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/sanitize.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/undo.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/__init__.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/_util.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/analysis.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/overview.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/rabbit_reads.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/redis_reads.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/writes.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/platform.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/secretstore.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/SKILL.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/capabilities.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/cli-reference.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/setup-guide.md +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/smithery.yaml +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/conftest.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/fakes.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_analysis.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_rabbit_writes.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_reads.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_secret.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_writes.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_connection.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_doctor.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_audit.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_decorators.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_patterns.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_policy.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_governance_persistence.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_init.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_int_fidelity.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_mcp_reads.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_optional_fields.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_platform.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_reads.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_secretstore.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_self_kill.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_self_lockout.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_smoke.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_undo_executor.py +0 -0
- {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_writes.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## v0.7.0 — 2026-08-10
|
|
4
10
|
|
|
5
11
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queue-aiops
|
|
3
|
-
Version: 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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "queue-aiops"
|
|
7
|
-
version = "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(
|
|
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))))
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/AIops-tools/Queue-AIops",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.8.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "queue-aiops",
|
|
15
|
-
"version": "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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|