postgres-aiops 0.10.3__tar.gz → 0.11.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 (113) hide show
  1. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/.claude-plugin/plugin.json +1 -1
  2. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/.mcp.json +1 -1
  3. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/CHANGELOG.md +20 -0
  4. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/PKG-INFO +1 -1
  5. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/RELEASE_NOTES.md +3 -0
  6. postgres_aiops-0.11.0/mcp_server/_annotations.py +66 -0
  7. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/server.py +5 -0
  8. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/openclaw.plugin.json +1 -1
  9. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/package.json +1 -1
  10. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/analysis.py +28 -8
  11. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/pyproject.toml +1 -1
  12. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/server.json +2 -2
  13. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/agent-guardrails.md +1 -1
  14. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/capabilities.md +1 -1
  15. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_analysis.py +45 -0
  16. postgres_aiops-0.11.0/tests/test_tool_annotations.py +128 -0
  17. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/uv.lock +1 -1
  18. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/.github/workflows/mcp-publish.yml +0 -0
  19. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/.github/workflows/publish.yml +0 -0
  20. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/.gitignore +0 -0
  21. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/Dockerfile +0 -0
  22. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/LICENSE +0 -0
  23. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/README.md +0 -0
  24. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/SECURITY.md +0 -0
  25. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/docs/VERIFICATION.md +0 -0
  26. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/glama.json +0 -0
  27. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/__init__.py +0 -0
  28. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/_shared.py +0 -0
  29. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/__init__.py +0 -0
  30. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/activity.py +0 -0
  31. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/analysis.py +0 -0
  32. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/indexes.py +0 -0
  33. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/queries.py +0 -0
  34. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/remediation.py +0 -0
  35. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/replication.py +0 -0
  36. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/server.py +0 -0
  37. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/tables.py +0 -0
  38. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/mcp_server/tools/undo.py +0 -0
  39. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/__init__.py +0 -0
  40. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/__init__.py +0 -0
  41. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/_common.py +0 -0
  42. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/_root.py +0 -0
  43. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/activity.py +0 -0
  44. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/analyze.py +0 -0
  45. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/doctor.py +0 -0
  46. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/index.py +0 -0
  47. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/init.py +0 -0
  48. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/overview.py +0 -0
  49. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/query.py +0 -0
  50. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/remediate.py +0 -0
  51. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/replication.py +0 -0
  52. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/secret.py +0 -0
  53. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/server.py +0 -0
  54. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/table.py +0 -0
  55. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/cli/undo.py +0 -0
  56. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/config.py +0 -0
  57. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/connection.py +0 -0
  58. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/doctor.py +0 -0
  59. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/__init__.py +0 -0
  60. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/audit.py +0 -0
  61. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/budget.py +0 -0
  62. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/decorators.py +0 -0
  63. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/outcome.py +0 -0
  64. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/paths.py +0 -0
  65. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/patterns.py +0 -0
  66. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/policy.py +0 -0
  67. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/sanitize.py +0 -0
  68. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/governance/undo.py +0 -0
  69. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/__init__.py +0 -0
  70. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/_util.py +0 -0
  71. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/activity.py +0 -0
  72. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/indexes.py +0 -0
  73. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/overview.py +0 -0
  74. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/queries.py +0 -0
  75. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/remediation.py +0 -0
  76. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/replication.py +0 -0
  77. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/server.py +0 -0
  78. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/ops/tables.py +0 -0
  79. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/postgres_aiops/secretstore.py +0 -0
  80. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/skills/postgres-aiops/SKILL.md +0 -0
  81. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
  82. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
  83. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/smithery.yaml +0 -0
  84. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/conftest.py +0 -0
  85. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_cli_audited.py +0 -0
  86. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_cli_governed_exit.py +0 -0
  87. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_cli_reads.py +0 -0
  88. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_cli_remediate.py +0 -0
  89. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_cli_secret.py +0 -0
  90. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_cli_writes.py +0 -0
  91. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_config.py +0 -0
  92. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_connection.py +0 -0
  93. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_connection_lost.py +0 -0
  94. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_connection_more.py +0 -0
  95. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_doctor.py +0 -0
  96. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_gov_audit.py +0 -0
  97. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_gov_decorators.py +0 -0
  98. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_gov_patterns.py +0 -0
  99. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_gov_policy.py +0 -0
  100. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_governance_persistence.py +0 -0
  101. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_init.py +0 -0
  102. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_mcp_tools.py +0 -0
  103. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_ops_more.py +0 -0
  104. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_optional_fields.py +0 -0
  105. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_reads.py +0 -0
  106. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_secretstore.py +0 -0
  107. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_secretstore_more.py +0 -0
  108. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_self_lockout.py +0 -0
  109. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_smoke.py +0 -0
  110. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_truncation.py +0 -0
  111. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_undo_executor.py +0 -0
  112. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_undo_replay.py +0 -0
  113. {postgres_aiops-0.10.3 → postgres_aiops-0.11.0}/tests/test_writes.py +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "postgres-aiops",
4
4
  "displayName": "Postgres AIops",
5
- "version": "0.10.3",
5
+ "version": "0.11.0",
6
6
  "description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
7
7
  "author": {
8
8
  "name": "Wei Zhou",
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "postgres-aiops==0.10.3",
8
+ "postgres-aiops==0.11.0",
9
9
  "postgres-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.11.0 — 2026-10-15
4
+
5
+ ### Added
6
+ - MCP tool annotations derived from each tool's governance `risk_level`, so
7
+ clients can gate approval from `tools/list` alone. `low` tools advertise
8
+ `readOnlyHint: true`; every other tool advertises `readOnlyHint: false` and
9
+ `destructiveHint: true`. The `medium` tier includes deletes and hard
10
+ power-offs, so a tool is never advertised as non-destructive on the strength
11
+ of its tier. `idempotentHint` only repeats an explicit declaration. The exact
12
+ level is published in `_meta` under `io.github.AIops-tools/riskLevel`. These
13
+ are hints for the client; audit and undo are unchanged.
14
+
15
+ ### Fixed
16
+ - `blocking_lock_chain_rca`: the chain root's state was dropped and a missing
17
+ query became `""`. For a backend that is `idle in transaction`, `rootQuery` is
18
+ the last statement it ran, not a running one, so the recommended
19
+ `cancel_query` had nothing to cancel. Roots now carry `rootState` and
20
+ `rootIdle` (true for any `idle*` state, including plain `idle` with session-level locks), a missing query stays `null`, and the action for an
21
+ idle root points at `terminate_backend` (which rolls the transaction back).
22
+
3
23
  ## v0.10.3 — 2026-09-15
4
24
 
5
25
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: postgres-aiops
3
- Version: 0.10.3
3
+ Version: 0.11.0
4
4
  Summary: Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis 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
@@ -1,5 +1,8 @@
1
1
  # Release notes — postgres-aiops 0.6.0
2
2
 
3
+ > Historical — this file covers the 0.6.0 release only and is not updated.
4
+ > For every release since, see [CHANGELOG.md](CHANGELOG.md) and the GitHub releases.
5
+
3
6
  Previous release: 0.5.0.
4
7
 
5
8
  ## BREAKING — the authorization layer is removed
@@ -0,0 +1,66 @@
1
+ """Derive MCP tool annotations from the governance risk level.
2
+
3
+ Every tool already declares ``risk_level`` on ``@governed_tool``; this module
4
+ publishes it to MCP clients as the standard ``ToolAnnotations`` hints plus the
5
+ exact level under ``_meta``, so a client can gate approval without reading our
6
+ docstrings.
7
+
8
+ Only the safe direction is ever asserted:
9
+
10
+ - ``low`` → ``readOnlyHint: true``. Backed by the smoke-test invariant that
11
+ ``risk_level == "low"`` exactly when the docstring says ``[READ]``.
12
+ - anything else → ``readOnlyHint: false, destructiveHint: true``. The
13
+ ``medium`` tier is NOT non-destructive — it holds deletes, hard power-offs,
14
+ state kills and stat resets — so claiming ``destructiveHint: false`` from the
15
+ tier would tell a client it may skip approval for a delete. Over-warning
16
+ costs a confirmation prompt; under-warning costs data.
17
+ - ``idempotentHint`` only echoes an explicit ``@governed_tool(idempotent=True)``
18
+ declaration; it is never inferred.
19
+ - ``openWorldHint`` is left unset (spec default ``true``): every tool talks to
20
+ an external system.
21
+
22
+ This file is identical across the AIops line; keep it that way.
23
+ """
24
+
25
+ from typing import Any
26
+
27
+ from mcp.types import ToolAnnotations
28
+
29
+ RISK_META_KEY = "io.github.AIops-tools/riskLevel"
30
+ RISK_LEVELS = frozenset({"low", "medium", "high", "critical"})
31
+
32
+
33
+ def annotations_for(risk_level: str, idempotent: bool) -> ToolAnnotations:
34
+ """Return the MCP hints that a tool of ``risk_level`` may honestly claim."""
35
+ if risk_level not in RISK_LEVELS:
36
+ raise ValueError(
37
+ f"unknown risk_level {risk_level!r}; expected one of {sorted(RISK_LEVELS)}"
38
+ )
39
+ if risk_level == "low":
40
+ return ToolAnnotations(read_only_hint=True)
41
+ return ToolAnnotations(
42
+ read_only_hint=False,
43
+ destructive_hint=True,
44
+ idempotent_hint=idempotent,
45
+ )
46
+
47
+
48
+ def apply_risk_annotations(server: Any) -> None:
49
+ """Replace every registered tool with a copy carrying its risk annotations.
50
+
51
+ Fails fast on a tool without ``@governed_tool`` metadata: an unannotated
52
+ tool would be advertised with the spec defaults, i.e. as a destructive
53
+ write with no stated risk level — wrong for a read, silent for a write.
54
+ """
55
+ tools = server._tool_manager._tools # same private registry undo.py relies on
56
+ for name, tool in list(tools.items()):
57
+ risk_level = getattr(tool.fn, "_risk_level", None)
58
+ if risk_level is None:
59
+ raise RuntimeError(f"tool {name!r} has no @governed_tool risk_level")
60
+ idempotent = bool(getattr(tool.fn, "_idempotent", False))
61
+ tools[name] = tool.model_copy(
62
+ update={
63
+ "annotations": annotations_for(risk_level, idempotent),
64
+ "meta": {**(tool.meta or {}), RISK_META_KEY: risk_level},
65
+ }
66
+ )
@@ -14,6 +14,7 @@ License: MIT
14
14
  import logging
15
15
  import os
16
16
 
17
+ from mcp_server._annotations import apply_risk_annotations
17
18
  from mcp_server._shared import _safe_error, mcp, tool_errors
18
19
 
19
20
  # Importing the tool modules registers every @mcp.tool() onto the shared
@@ -30,6 +31,10 @@ from mcp_server.tools import ( # noqa: F401 — side effects
30
31
  undo,
31
32
  )
32
33
 
34
+ # Publish each tool's governance risk level as MCP annotations. Runs after
35
+ # every tool module has registered, so no tool is advertised unannotated.
36
+ apply_risk_annotations(mcp)
37
+
33
38
  __all__ = ["mcp", "main", "_safe_error", "tool_errors"]
34
39
 
35
40
  logger = logging.getLogger(__name__)
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "postgres-aiops==0.10.3",
14
+ "postgres-aiops==0.11.0",
15
15
  "postgres-aiops-mcp"
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zw008/postgres-aiops",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Governed PostgreSQL DBA ops: slow-query RCA, bloat/vacuum & blocking-lock analysis; 35 MCP tools.",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -201,6 +201,22 @@ def _descendants(root: int, children: dict[int, list[int]]) -> set[int]:
201
201
  return seen
202
202
 
203
203
 
204
+ def _lock_root_action(pid: int, idle: bool | None) -> str:
205
+ if idle:
206
+ return (
207
+ f"Backend {pid} heads the chain but is not running a statement (see "
208
+ "rootState: an open transaction, or session-level locks): rootQuery is "
209
+ "the last statement it ran, not a running one. cancel_query has nothing "
210
+ f"to cancel; terminate_backend({pid}) ends the session, rolling back any "
211
+ "transaction and releasing its locks — confirm with its owner first."
212
+ )
213
+ return (
214
+ f"Backend {pid} is the head of the chain — investigate its transaction; "
215
+ f"cancel_query({pid}) to stop its statement, or terminate_backend({pid}) to "
216
+ "end the session and release the locks."
217
+ )
218
+
219
+
204
220
  def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
205
221
  """[READ] Build the wait-for tree from blocking pairs and name the root blocker.
206
222
 
@@ -217,7 +233,8 @@ def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
217
233
  return {"blockedBackends": 0, "roots": [], "note": "No blocking detected."}
218
234
 
219
235
  children: dict[int, list[int]] = {}
220
- blocking_query: dict[int, str] = {}
236
+ blocking_query: dict[int, str | None] = {}
237
+ blocking_state: dict[int, str | None] = {}
221
238
  blocked_pids: set[int] = set()
222
239
  blocking_pids: set[int] = set()
223
240
  for e in edges:
@@ -225,7 +242,8 @@ def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
225
242
  children.setdefault(g, []).append(b)
226
243
  blocked_pids.add(b)
227
244
  blocking_pids.add(g)
228
- blocking_query.setdefault(g, e.get("blockingQuery") or "")
245
+ blocking_query.setdefault(g, e.get("blockingQuery"))
246
+ blocking_state.setdefault(g, e.get("blockingState"))
229
247
 
230
248
  root_pids = [pid for pid in blocking_pids if pid not in blocked_pids]
231
249
  if not root_pids:
@@ -242,16 +260,18 @@ def blocking_lock_chain_rca(pairs: list[dict]) -> dict:
242
260
  roots = []
243
261
  for pid in root_pids:
244
262
  blocked = _descendants(pid, children)
263
+ state = blocking_state.get(pid)
264
+ # Any idle state means no statement is running: "idle in transaction"
265
+ # holds row locks, plain "idle" can still hold session-level advisory locks.
266
+ idle = None if state is None else str(state).startswith("idle")
245
267
  roots.append({
246
268
  "rootPid": pid,
247
269
  "blockedCount": len(blocked),
248
270
  "blockedPids": sorted(blocked),
249
- "rootQuery": blocking_query.get(pid, ""),
250
- "action": (
251
- f"Backend {pid} is the head of the chain — investigate its "
252
- "transaction; cancel_query(pid) to stop its statement, or "
253
- "terminate_backend(pid) to end the session and release the locks."
254
- ),
271
+ "rootQuery": blocking_query.get(pid),
272
+ "rootState": state,
273
+ "rootIdle": idle,
274
+ "action": _lock_root_action(pid, idle),
255
275
  })
256
276
  roots.sort(key=lambda r: r["blockedCount"], reverse=True)
257
277
  return {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "postgres-aiops"
7
- version = "0.10.3"
7
+ version = "0.11.0"
8
8
  description = "Governed AI-ops for PostgreSQL DBA operations: slow-query RCA, bloat/vacuum & blocking-lock analysis with a built-in governance harness (audit, budget, undo, risk tiers)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/AIops-tools/Postgres-AIops",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.10.3",
10
+ "version": "0.11.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "postgres-aiops",
15
- "version": "0.10.3",
15
+ "version": "0.11.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
@@ -28,7 +28,7 @@ recorded on the audit row; they are never required and never block a call.
28
28
  ## What still needs a prompt
29
29
 
30
30
  These are model-behaviour problems the harness cannot fix from the outside.
31
- Copy this into your agent's system prompt:
31
+ Recommended setup for a local model (adapt it to your agent client):
32
32
 
33
33
  ```text
34
34
  You operate a PostgreSQL server through the postgres-aiops MCP tools.
@@ -32,7 +32,7 @@
32
32
  | `wal_status` | WAL fns + `pg_stat_archiver` | currentLsn, walLevel, max/minWalSize, archiver |
33
33
  | `slow_query_rca` | pg_stat_statements + EXPLAIN | worst{}, findings[] (cited cause/action) |
34
34
  | `bloat_and_vacuum_analysis` | table-bloat rows | recommendations[] (cited reasons + action) |
35
- | `blocking_lock_chain_rca` | `pg_blocking_pids` pairs | roots[], worstRootPid, deadlockSuspected |
35
+ | `blocking_lock_chain_rca` | `pg_blocking_pids` pairs | roots[] (rootQuery, rootState, rootIdle — true for any `idle*` state; rootQuery is then the last statement run, not a running one — and action), worstRootPid, deadlockSuspected |
36
36
  | `undo_list` | local undo store | recorded, not-yet-applied reversible writes: undoId, ts, originalTool, inverseTool, note |
37
37
 
38
38
  The flagship analyses accept injected records (`statements=` / `tables=` /
@@ -82,3 +82,48 @@ def test_blocking_chain_detects_cycle():
82
82
  def test_blocking_chain_no_blocking():
83
83
  out = analysis.blocking_lock_chain_rca([])
84
84
  assert out["blockedBackends"] == 0
85
+
86
+
87
+ # ── lock-chain root that is idle inside an open transaction ──────────────────
88
+
89
+
90
+ def _pg_pairs(state, query="UPDATE t SET v=1 WHERE id=1"):
91
+ return [{"blockedPid": 201, "blockingPid": 100, "blockedQuery": "DELETE FROM t",
92
+ "blockingQuery": query, "blockingState": state}]
93
+
94
+
95
+ def test_idle_in_transaction_root_is_named_and_cancel_is_ruled_out():
96
+ from postgres_aiops.ops import analysis as a
97
+
98
+ (root,) = a.blocking_lock_chain_rca(_pg_pairs("idle in transaction"))["roots"]
99
+ assert root["rootState"] == "idle in transaction"
100
+ assert root["rootIdle"] is True
101
+ # The query is the LAST statement it ran, not a running one.
102
+ assert "last statement" in root["action"]
103
+ assert "terminate_backend(100)" in root["action"]
104
+ assert "nothing to cancel" in root["action"]
105
+
106
+
107
+ def test_active_root_offers_cancel_first():
108
+ from postgres_aiops.ops import analysis as a
109
+
110
+ (root,) = a.blocking_lock_chain_rca(_pg_pairs("active"))["roots"]
111
+ assert root["rootIdle"] is False
112
+ assert "cancel_query(100)" in root["action"]
113
+
114
+
115
+ def test_missing_root_query_stays_null():
116
+ from postgres_aiops.ops import analysis as a
117
+
118
+ (root,) = a.blocking_lock_chain_rca(_pg_pairs(None, query=None))["roots"]
119
+ assert root["rootQuery"] is None
120
+ assert root["rootState"] is None
121
+ assert root["rootIdle"] is None # state unknown
122
+
123
+
124
+ def test_plain_idle_root_with_session_locks_is_not_offered_cancel():
125
+ from postgres_aiops.ops import analysis as a
126
+
127
+ (root,) = a.blocking_lock_chain_rca(_pg_pairs("idle"))["roots"]
128
+ assert root["rootIdle"] is True
129
+ assert "terminate_backend(100)" in root["action"]
@@ -0,0 +1,128 @@
1
+ """Invariants for the MCP annotations derived from ``risk_level``.
2
+
3
+ Asserted on ``list_tools()`` — the payload a client actually receives — not on
4
+ the internal registry, so a regression in how annotations reach the wire fails
5
+ here too. This file is identical across the AIops line.
6
+ """
7
+
8
+ import asyncio
9
+ import re
10
+ from types import SimpleNamespace
11
+
12
+ import pytest
13
+ from mcp.server import MCPServer
14
+
15
+ import mcp_server.server as server
16
+ from mcp_server._annotations import (
17
+ RISK_META_KEY,
18
+ annotations_for,
19
+ apply_risk_annotations,
20
+ )
21
+
22
+
23
+ @pytest.fixture(scope="module")
24
+ def listed():
25
+ return asyncio.run(server.mcp.list_tools())
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def registry():
30
+ return server.mcp._tool_manager._tools
31
+
32
+
33
+ def test_every_tool_is_annotated(listed):
34
+ assert listed, "no tools listed"
35
+ missing = [t.name for t in listed if t.annotations is None or not t.meta]
36
+ assert not missing, f"tools advertised without annotations: {missing}"
37
+
38
+
39
+ def test_meta_carries_the_declared_risk_level(listed, registry):
40
+ wrong = [t.name for t in listed if t.meta.get(RISK_META_KEY) != registry[t.name].fn._risk_level]
41
+ assert not wrong, f"_meta risk level differs from @governed_tool: {wrong}"
42
+
43
+
44
+ def test_read_only_hint_true_exactly_for_low_risk(listed):
45
+ wrong = [
46
+ t.name for t in listed if t.annotations.read_only_hint != (t.meta[RISK_META_KEY] == "low")
47
+ ]
48
+ assert not wrong, f"readOnlyHint disagrees with risk_level: {wrong}"
49
+
50
+
51
+ def test_no_write_claims_to_be_non_destructive(listed):
52
+ # medium holds deletes and hard power-offs; the tier cannot prove safety.
53
+ wrong = [
54
+ t.name
55
+ for t in listed
56
+ if not t.annotations.read_only_hint and t.annotations.destructive_hint is not True
57
+ ]
58
+ assert not wrong, f"writes advertised as non-destructive: {wrong}"
59
+
60
+
61
+ def test_idempotent_hint_only_echoes_an_explicit_declaration(listed, registry):
62
+ wrong = [
63
+ t.name
64
+ for t in listed
65
+ if t.annotations.idempotent_hint and not registry[t.name].fn._idempotent
66
+ ]
67
+ assert not wrong, f"idempotentHint inferred without a declaration: {wrong}"
68
+
69
+
70
+ def test_open_world_hint_is_never_narrowed(listed):
71
+ assert all(t.annotations.open_world_hint in (None, True) for t in listed)
72
+
73
+
74
+ @pytest.mark.parametrize("level", ["low", "medium", "high", "critical"])
75
+ def test_mapping_per_level(level):
76
+ hints = annotations_for(level, idempotent=False)
77
+ if level == "low":
78
+ assert hints.read_only_hint is True
79
+ assert hints.destructive_hint is None
80
+ else:
81
+ assert hints.read_only_hint is False
82
+ assert hints.destructive_hint is True
83
+ assert hints.idempotent_hint is False
84
+
85
+
86
+ def test_declared_idempotent_write_is_echoed():
87
+ assert annotations_for("medium", idempotent=True).idempotent_hint is True
88
+
89
+
90
+ def test_unknown_risk_level_is_rejected():
91
+ with pytest.raises(ValueError, match="unknown risk_level"):
92
+ annotations_for("Low", idempotent=False)
93
+
94
+
95
+ def test_ungoverned_tool_fails_fast():
96
+ fake_tool = SimpleNamespace(fn=lambda: None, meta=None)
97
+ fake = SimpleNamespace(_tool_manager=SimpleNamespace(_tools={"rogue": fake_tool}))
98
+ with pytest.raises(RuntimeError, match="rogue"):
99
+ apply_risk_annotations(fake)
100
+
101
+
102
+ def test_meta_key_is_the_published_contract():
103
+ # Clients match on this literal; it must be a valid, non-reserved _meta key.
104
+ assert RISK_META_KEY == "io.github.AIops-tools/riskLevel"
105
+ prefix, name = RISK_META_KEY.split("/")
106
+ label = re.compile(r"^[A-Za-z]([A-Za-z0-9-]*[A-Za-z0-9])?$")
107
+ labels = prefix.split(".")
108
+ assert all(label.match(part) for part in labels)
109
+ assert not {"mcp", "modelcontextprotocol"} & {part.lower() for part in labels}
110
+ assert re.match(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$", name)
111
+
112
+
113
+ def test_existing_meta_survives_on_a_real_tool():
114
+ fresh = MCPServer("annotations-test")
115
+
116
+ def probe() -> str:
117
+ return "ok"
118
+
119
+ probe._risk_level = "medium"
120
+ probe._idempotent = True
121
+ fresh.tool(meta={"x": 1})(probe)
122
+ apply_risk_annotations(fresh)
123
+
124
+ (listed_tool,) = asyncio.run(fresh.list_tools())
125
+ assert listed_tool.meta == {"x": 1, RISK_META_KEY: "medium"}
126
+ assert listed_tool.annotations.destructive_hint is True
127
+ assert listed_tool.annotations.idempotent_hint is True
128
+ assert fresh._tool_manager._tools["probe"].fn is probe
@@ -492,7 +492,7 @@ wheels = [
492
492
 
493
493
  [[package]]
494
494
  name = "postgres-aiops"
495
- version = "0.10.3"
495
+ version = "0.11.0"
496
496
  source = { editable = "." }
497
497
  dependencies = [
498
498
  { name = "cryptography" },
File without changes