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.
Files changed (92) hide show
  1. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/CHANGELOG.md +6 -0
  2. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/PKG-INFO +1 -1
  3. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/pyproject.toml +1 -1
  4. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/_common.py +47 -0
  5. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/rabbit_cmds.py +10 -7
  6. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/redis_cmds.py +3 -2
  7. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/undo.py +2 -1
  8. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/server.json +2 -2
  9. queue_aiops-0.8.0/tests/test_cli_governed_exit.py +167 -0
  10. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/uv.lock +1 -1
  11. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/.github/workflows/mcp-publish.yml +0 -0
  12. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/.github/workflows/publish.yml +0 -0
  13. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/.gitignore +0 -0
  14. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/Dockerfile +0 -0
  15. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/LICENSE +0 -0
  16. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/README.md +0 -0
  17. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/RELEASE_NOTES.md +0 -0
  18. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/SECURITY.md +0 -0
  19. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/docs/VERIFICATION.md +0 -0
  20. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/glama.json +0 -0
  21. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/__init__.py +0 -0
  22. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/_shared.py +0 -0
  23. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/server.py +0 -0
  24. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/__init__.py +0 -0
  25. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/analysis.py +0 -0
  26. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/overview.py +0 -0
  27. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/rabbit_reads.py +0 -0
  28. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/redis_reads.py +0 -0
  29. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/undo.py +0 -0
  30. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/mcp_server/tools/writes.py +0 -0
  31. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/__init__.py +0 -0
  32. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/__init__.py +0 -0
  33. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/_root.py +0 -0
  34. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/analyze.py +0 -0
  35. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/doctor.py +0 -0
  36. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/init.py +0 -0
  37. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/overview.py +0 -0
  38. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/cli/secret.py +0 -0
  39. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/config.py +0 -0
  40. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/connection.py +0 -0
  41. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/doctor.py +0 -0
  42. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/__init__.py +0 -0
  43. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/audit.py +0 -0
  44. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/budget.py +0 -0
  45. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/decorators.py +0 -0
  46. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/outcome.py +0 -0
  47. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/paths.py +0 -0
  48. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/patterns.py +0 -0
  49. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/policy.py +0 -0
  50. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/sanitize.py +0 -0
  51. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/governance/undo.py +0 -0
  52. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/__init__.py +0 -0
  53. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/_util.py +0 -0
  54. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/analysis.py +0 -0
  55. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/overview.py +0 -0
  56. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/rabbit_reads.py +0 -0
  57. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/redis_reads.py +0 -0
  58. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/ops/writes.py +0 -0
  59. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/platform.py +0 -0
  60. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/queue_aiops/secretstore.py +0 -0
  61. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/SKILL.md +0 -0
  62. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
  63. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/capabilities.md +0 -0
  64. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/cli-reference.md +0 -0
  65. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/skills/queue-aiops/references/setup-guide.md +0 -0
  66. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/smithery.yaml +0 -0
  67. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/conftest.py +0 -0
  68. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/fakes.py +0 -0
  69. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_analysis.py +0 -0
  70. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_rabbit_writes.py +0 -0
  71. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_reads.py +0 -0
  72. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_secret.py +0 -0
  73. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_cli_writes.py +0 -0
  74. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_connection.py +0 -0
  75. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_doctor.py +0 -0
  76. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_audit.py +0 -0
  77. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_decorators.py +0 -0
  78. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_patterns.py +0 -0
  79. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_gov_policy.py +0 -0
  80. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_governance_persistence.py +0 -0
  81. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_init.py +0 -0
  82. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_int_fidelity.py +0 -0
  83. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_mcp_reads.py +0 -0
  84. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_optional_fields.py +0 -0
  85. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_platform.py +0 -0
  86. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_reads.py +0 -0
  87. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_secretstore.py +0 -0
  88. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_self_kill.py +0 -0
  89. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_self_lockout.py +0 -0
  90. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_smoke.py +0 -0
  91. {queue_aiops-0.7.0 → queue_aiops-0.8.0}/tests/test_undo_executor.py +0 -0
  92. {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.7.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.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))))
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Queue-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.7.0",
10
+ "version": "0.8.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "queue-aiops",
15
- "version": "0.7.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
@@ -812,7 +812,7 @@ wheels = [
812
812
 
813
813
  [[package]]
814
814
  name = "queue-aiops"
815
- version = "0.7.0"
815
+ version = "0.8.0"
816
816
  source = { editable = "." }
817
817
  dependencies = [
818
818
  { name = "cryptography" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes