queue-aiops 0.5.0__tar.gz → 0.6.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 (91) hide show
  1. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/CHANGELOG.md +6 -0
  2. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/PKG-INFO +1 -1
  3. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/docs/VERIFICATION.md +8 -1
  4. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/tools/undo.py +30 -2
  5. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/pyproject.toml +1 -1
  6. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/ops/overview.py +4 -0
  7. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/ops/redis_reads.py +36 -2
  8. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/server.json +2 -2
  9. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_reads.py +33 -0
  10. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_undo_executor.py +49 -0
  11. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/uv.lock +1 -1
  12. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/.github/workflows/mcp-publish.yml +0 -0
  13. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/.github/workflows/publish.yml +0 -0
  14. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/.gitignore +0 -0
  15. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/Dockerfile +0 -0
  16. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/LICENSE +0 -0
  17. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/README.md +0 -0
  18. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/RELEASE_NOTES.md +0 -0
  19. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/SECURITY.md +0 -0
  20. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/glama.json +0 -0
  21. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/__init__.py +0 -0
  22. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/_shared.py +0 -0
  23. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/server.py +0 -0
  24. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/tools/__init__.py +0 -0
  25. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/tools/analysis.py +0 -0
  26. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/tools/overview.py +0 -0
  27. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/tools/rabbit_reads.py +0 -0
  28. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/tools/redis_reads.py +0 -0
  29. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/mcp_server/tools/writes.py +0 -0
  30. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/__init__.py +0 -0
  31. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/__init__.py +0 -0
  32. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/_common.py +0 -0
  33. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/_root.py +0 -0
  34. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/analyze.py +0 -0
  35. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/doctor.py +0 -0
  36. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/init.py +0 -0
  37. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/overview.py +0 -0
  38. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/rabbit_cmds.py +0 -0
  39. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/redis_cmds.py +0 -0
  40. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/secret.py +0 -0
  41. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/cli/undo.py +0 -0
  42. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/config.py +0 -0
  43. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/connection.py +0 -0
  44. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/doctor.py +0 -0
  45. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/__init__.py +0 -0
  46. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/audit.py +0 -0
  47. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/budget.py +0 -0
  48. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/decorators.py +0 -0
  49. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/outcome.py +0 -0
  50. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/paths.py +0 -0
  51. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/patterns.py +0 -0
  52. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/policy.py +0 -0
  53. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/sanitize.py +0 -0
  54. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/governance/undo.py +0 -0
  55. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/ops/__init__.py +0 -0
  56. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/ops/_util.py +0 -0
  57. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/ops/analysis.py +0 -0
  58. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/ops/rabbit_reads.py +0 -0
  59. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/ops/writes.py +0 -0
  60. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/platform.py +0 -0
  61. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/queue_aiops/secretstore.py +0 -0
  62. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/skills/queue-aiops/SKILL.md +0 -0
  63. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/skills/queue-aiops/references/agent-guardrails.md +0 -0
  64. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/skills/queue-aiops/references/capabilities.md +0 -0
  65. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/skills/queue-aiops/references/cli-reference.md +0 -0
  66. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/skills/queue-aiops/references/setup-guide.md +0 -0
  67. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/smithery.yaml +0 -0
  68. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/conftest.py +0 -0
  69. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/fakes.py +0 -0
  70. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_analysis.py +0 -0
  71. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_cli_rabbit_writes.py +0 -0
  72. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_cli_reads.py +0 -0
  73. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_cli_secret.py +0 -0
  74. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_cli_writes.py +0 -0
  75. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_connection.py +0 -0
  76. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_doctor.py +0 -0
  77. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_gov_audit.py +0 -0
  78. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_gov_decorators.py +0 -0
  79. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_gov_patterns.py +0 -0
  80. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_gov_policy.py +0 -0
  81. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_governance_persistence.py +0 -0
  82. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_init.py +0 -0
  83. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_int_fidelity.py +0 -0
  84. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_mcp_reads.py +0 -0
  85. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_optional_fields.py +0 -0
  86. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_platform.py +0 -0
  87. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_secretstore.py +0 -0
  88. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_self_kill.py +0 -0
  89. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_self_lockout.py +0 -0
  90. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_smoke.py +0 -0
  91. {queue_aiops-0.5.0 → queue_aiops-0.6.0}/tests/test_writes.py +0 -0
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.6.0 — 2026-08-03
4
+
5
+ ### Fixed
6
+ - **A cluster node's key count no longer poses as the whole dataset.** `INFO keyspace` on a Redis Cluster node answers for that node's slots only, so `keyspace` / `overview` reported `totalKeys: 100` for a 3-master cluster actually holding 300 — a dataset-wide claim wrong by the shard count, with nothing in the payload to say otherwise. Both now carry `scope` (`node` vs `server`), `clusterMode`, and on a cluster a note explaining that the figure covers one node. Measured on a real 3-master Redis 7.4 cluster (100 / 92 / 108).
7
+ - **`undo apply` replays against the target the original write ran on.** It dispatched the inverse against whatever target the *caller* named — in practice the config's first entry — while the write's own target sat unused in the undo record. On a multi-target config the inverse therefore ran against the wrong host; it only looks harmless because the resource usually is not there, but two hosts holding the same name and the inverse **succeeds on the wrong one, silently**. An explicitly named target still wins. Line-wide: all 24 copies had the identical defect. Caught live in container-host-aiops, where a stop recorded against a Podman target replayed against a Portainer one.
8
+
3
9
  ## v0.5.0 — 2026-08-02
4
10
 
5
11
  ### Changed (BREAKING)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queue-aiops
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -29,6 +29,13 @@ plus a regression test that asserts the *type*, keeping genuine ratios
29
29
 
30
30
  - **RabbitMQ** — the entire `rabbitmq` command group and its management-API
31
31
  shapes are unit-tested only. This is now the largest gap in this repo.
32
- - Redis **cluster / sentinel** topologies (only standalone was exercised).
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.
33
40
  - AUTH-enabled Redis and TLS connections.
34
41
  - `kill-client` against a real blocked client.
@@ -87,6 +87,32 @@ def undo_list(limit: int = 50, target: Optional[str] = None) -> dict:
87
87
  }
88
88
 
89
89
 
90
+ def _origin_target(rec: dict) -> Optional[str]:
91
+ """The target the ORIGINAL write ran against, read from its recorded params.
92
+
93
+ Without this the replay goes to whatever target the caller names — in
94
+ practice the config's first entry — so on a multi-target config the inverse
95
+ runs against the wrong host. It only *looks* harmless because the resource
96
+ usually does not exist there; when two hosts both hold a resource with the
97
+ same name the inverse succeeds on the wrong one, silently.
98
+
99
+ Caught live on 2026-08-03 in container-host-aiops: a ``stop_container``
100
+ recorded against a Podman target replayed against a Portainer target.
101
+
102
+ A ``target`` in ``orig_params`` also means the original call accepted one,
103
+ so the fallback cannot hand a keyword to a tool that has no such parameter.
104
+ """
105
+ raw = rec.get("orig_params") or ""
106
+ try:
107
+ orig = json.loads(raw) if raw else {}
108
+ except (ValueError, TypeError):
109
+ return None # unreadable: fall back to the caller/default, as before
110
+ if not isinstance(orig, dict):
111
+ return None
112
+ value = orig.get("target")
113
+ return value if isinstance(value, str) and value else None
114
+
115
+
90
116
  @mcp.tool()
91
117
  @governed_tool(risk_level="medium")
92
118
  @tool_errors("dict")
@@ -150,8 +176,10 @@ def undo_apply(undo_id: str, dry_run: bool = False, target: Optional[str] = None
150
176
  }
151
177
 
152
178
  call_params = dict(params)
153
- if target is not None and "target" not in call_params:
154
- call_params["target"] = target
179
+ if "target" not in call_params:
180
+ chosen = target if target is not None else _origin_target(rec)
181
+ if chosen is not None:
182
+ call_params["target"] = chosen
155
183
  result = fn(**call_params)
156
184
 
157
185
  # Only mark applied when the inverse did not itself return an error dict.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "queue-aiops"
7
- version = "0.5.0"
7
+ version = "0.6.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"
@@ -54,6 +54,10 @@ def _redis_overview(conn: Any) -> dict:
54
54
  "maxmemoryPolicy": mem.get("maxmemoryPolicy"),
55
55
  "fragmentationRatio": mem.get("fragmentationRatio"),
56
56
  "totalKeys": keys.get("totalKeys"),
57
+ # On a cluster node totalKeys covers this node's slots only — the scope
58
+ # marker has to travel with the number, not be left in a sibling call.
59
+ "keyScope": keys.get("scope"),
60
+ "clusterMode": keys.get("clusterMode"),
57
61
  "errors": errors,
58
62
  }
59
63
 
@@ -190,8 +190,38 @@ def config_get(conn: Any, pattern: str = "*") -> dict:
190
190
  return {"error": s(exc, 200)}
191
191
 
192
192
 
193
+ def _scope(conn: Any) -> dict:
194
+ """Whether the counts that follow describe the whole dataset or one shard.
195
+
196
+ A Redis Cluster node answers ``INFO keyspace`` for **its own slots only**, so
197
+ on a three-master cluster holding 300 keys this reports 100 — a number that
198
+ is wrong by the shard count and, called ``totalKeys`` with nothing else in
199
+ the payload, reads as the whole dataset. Measured on a real 3-master Redis
200
+ 7.4 cluster. The marker travels with the counts so a consumer can tell a
201
+ complete answer from a partial one.
202
+ """
203
+ try:
204
+ mode = str(as_obj(conn.redis_info("server")).get("redis_mode") or "")
205
+ except Exception: # noqa: BLE001 — scope is an annotation, never the answer
206
+ return {"scope": "server", "clusterMode": None}
207
+ if mode != "cluster":
208
+ return {"scope": "server", "clusterMode": False}
209
+ return {
210
+ "scope": "node",
211
+ "clusterMode": True,
212
+ "note": (
213
+ "Redis Cluster: these counts cover THIS node's slots only, not the "
214
+ "whole cluster. Query each master, or use a cluster-aware client, "
215
+ "for a dataset-wide figure."
216
+ ),
217
+ }
218
+
219
+
193
220
  def keyspace(conn: Any) -> dict:
194
- """[READ] Per-db key counts + expiry coverage from INFO keyspace."""
221
+ """[READ] Per-db key counts + expiry coverage from INFO keyspace.
222
+
223
+ On a cluster node the counts are per-shard; the returned ``scope`` says so.
224
+ """
195
225
  try:
196
226
  info = as_obj(conn.redis_info("keyspace"))
197
227
  dbs = []
@@ -208,7 +238,11 @@ def keyspace(conn: Any) -> dict:
208
238
  "avgTtlMs": as_int(cell.get("avg_ttl")),
209
239
  }
210
240
  )
211
- return {"databases": dbs, "totalKeys": sum(d["keys"] for d in dbs)}
241
+ return {
242
+ "databases": dbs,
243
+ "totalKeys": sum(d["keys"] for d in dbs),
244
+ **_scope(conn),
245
+ }
212
246
  except Exception as exc: # noqa: BLE001 — report as partial
213
247
  return {"error": s(exc, 200)}
214
248
 
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Queue-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.5.0",
10
+ "version": "0.6.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "queue-aiops",
15
- "version": "0.5.0",
15
+ "version": "0.6.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -308,3 +308,36 @@ def test_queue_overview_rabbitmq_shape_degrades_partially():
308
308
  assert out["platform"] == "rabbitmq"
309
309
  assert out["queues"] == 12
310
310
  assert any("nodes:" in e for e in out["errors"])
311
+
312
+
313
+ @pytest.mark.unit
314
+ def test_keyspace_marks_a_cluster_node_count_as_partial():
315
+ """A cluster node's key count is per-shard and must say so.
316
+
317
+ ``INFO keyspace`` on a Redis Cluster node answers for its own slots only, so
318
+ a 3-master cluster holding 300 keys reports 100 here. Emitting that as
319
+ ``totalKeys`` with nothing else in the payload states a dataset-wide figure
320
+ that is wrong by the shard count. Measured on a real 3-master Redis 7.4
321
+ cluster: 100 / 92 / 108.
322
+ """
323
+ conn = redis_conn(info={
324
+ "keyspace": {"db0": "keys=100,expires=0,avg_ttl=0"},
325
+ "server": {"redis_mode": "cluster"},
326
+ })
327
+ out = redis_reads.keyspace(conn)
328
+ assert out["totalKeys"] == 100
329
+ assert out["scope"] == "node"
330
+ assert out["clusterMode"] is True
331
+ assert "THIS node" in out["note"]
332
+
333
+
334
+ @pytest.mark.unit
335
+ def test_keyspace_on_a_standalone_server_is_scoped_whole():
336
+ conn = redis_conn(info={
337
+ "keyspace": {"db0": "keys=7,expires=0,avg_ttl=0"},
338
+ "server": {"redis_mode": "standalone"},
339
+ })
340
+ out = redis_reads.keyspace(conn)
341
+ assert out["scope"] == "server"
342
+ assert out["clusterMode"] is False
343
+ assert "note" not in out
@@ -21,18 +21,21 @@ from mcp_server.tools import undo as gov
21
21
  from queue_aiops.governance import governed_tool
22
22
 
23
23
  _CALLS: list[dict] = []
24
+ _TARGETS: list = []
24
25
 
25
26
 
26
27
  @governed_tool(risk_level="low")
27
28
  def _undo_probe(value: str = "", target=None) -> dict:
28
29
  """Synthetic inverse target used only by the undo-executor tests."""
29
30
  _CALLS.append({"value": value})
31
+ _TARGETS.append(target)
30
32
  return {"ok": True, "value": value}
31
33
 
32
34
 
33
35
  @pytest.fixture
34
36
  def gov_home(tmp_path, monkeypatch):
35
37
  _CALLS.clear()
38
+ _TARGETS.clear()
36
39
  # Register the probe only for the duration of these tests so it never
37
40
  # pollutes the real tool registry (which exact-count smoke tests assert on).
38
41
  mcp.add_tool(_undo_probe, name="_undo_probe")
@@ -77,6 +80,52 @@ def test_undo_apply_dispatches_inverse_and_marks_applied(gov_home):
77
80
  assert uid not in {u["undoId"] for u in gov.undo_list()["undos"]}
78
81
 
79
82
 
83
+ @pytest.mark.unit
84
+ def test_undo_apply_replays_against_the_target_the_write_ran_on(gov_home):
85
+ """A replay must go to the host the original write went to.
86
+
87
+ Without this the inverse ran against whatever target the caller named — in
88
+ practice the config's first entry — so on a multi-target config it hit the
89
+ wrong host. It only looked harmless because the resource usually does not
90
+ exist there; two hosts holding the same name and the inverse succeeds on the
91
+ wrong one, silently. Caught live on 2026-08-03 in container-host-aiops: a
92
+ stop recorded against a Podman target replayed against a Portainer target.
93
+ """
94
+ uid = undo_mod.get_undo_store().record(
95
+ skill="probe",
96
+ tool="orig_op",
97
+ undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
98
+ orig_params={"value": "v1", "target": "second-host"},
99
+ )
100
+ result = gov.undo_apply(undo_id=uid)
101
+ assert result["applied"] is True
102
+ assert _TARGETS == ["second-host"]
103
+
104
+
105
+ @pytest.mark.unit
106
+ def test_undo_apply_prefers_an_explicit_caller_target(gov_home):
107
+ """An explicitly named target still wins — the fallback is only a default."""
108
+ uid = undo_mod.get_undo_store().record(
109
+ skill="probe",
110
+ tool="orig_op",
111
+ undo_descriptor={"tool": "_undo_probe", "params": {"value": "v1"}},
112
+ orig_params={"value": "v1", "target": "second-host"},
113
+ )
114
+ gov.undo_apply(undo_id=uid, target="chosen-host")
115
+ assert _TARGETS == ["chosen-host"]
116
+
117
+
118
+ @pytest.mark.unit
119
+ def test_undo_apply_survives_unreadable_orig_params(gov_home):
120
+ """Corrupt orig_params must not break the replay — just lose the fallback."""
121
+ uid = _record(params={"value": "v1"})
122
+ with sqlite3.connect(gov_home / "undo.db") as db:
123
+ db.execute("UPDATE undo_log SET orig_params = ? WHERE undo_id = ?",
124
+ ("{not json", uid))
125
+ assert gov.undo_apply(undo_id=uid)["applied"] is True
126
+ assert _TARGETS == [None]
127
+
128
+
80
129
  @pytest.mark.unit
81
130
  def test_undo_apply_dry_run_previews_without_running(gov_home):
82
131
  uid = _record()
@@ -812,7 +812,7 @@ wheels = [
812
812
 
813
813
  [[package]]
814
814
  name = "queue-aiops"
815
- version = "0.5.0"
815
+ version = "0.6.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