postgres-aiops 0.11.0__tar.gz → 0.13.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 (115) hide show
  1. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.claude-plugin/plugin.json +1 -1
  2. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.mcp.json +1 -1
  3. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/CHANGELOG.md +21 -0
  4. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/PKG-INFO +4 -2
  5. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/README.md +3 -1
  6. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/SECURITY.md +19 -2
  7. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/docs/VERIFICATION.md +4 -0
  8. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/openclaw.plugin.json +1 -1
  9. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/package.json +1 -1
  10. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_root.py +1 -1
  11. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/query.py +1 -1
  12. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/remediate.py +8 -8
  13. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/undo.py +3 -3
  14. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/audit.py +178 -34
  15. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/decorators.py +78 -14
  16. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/_shared.py +2 -2
  17. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/server.py +4 -4
  18. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/activity.py +1 -1
  19. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/analysis.py +1 -1
  20. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/indexes.py +1 -1
  21. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/queries.py +1 -1
  22. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/remediation.py +1 -1
  23. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/replication.py +1 -1
  24. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/server.py +1 -1
  25. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/tables.py +1 -1
  26. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/undo.py +4 -4
  27. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/pyproject.toml +4 -4
  28. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/server.json +2 -2
  29. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/SKILL.md +3 -1
  30. postgres_aiops-0.13.0/tests/test_audit_guardrails.py +209 -0
  31. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_audited.py +3 -2
  32. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_governed_exit.py +1 -1
  33. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_remediate.py +3 -3
  34. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_writes.py +4 -4
  35. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_connection_lost.py +1 -1
  36. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_audit.py +1 -1
  37. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_decorators.py +4 -6
  38. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_patterns.py +1 -1
  39. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_governance_persistence.py +1 -1
  40. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_mcp_tools.py +18 -18
  41. postgres_aiops-0.13.0/tests/test_package_layout.py +62 -0
  42. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_smoke.py +15 -15
  43. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_tool_annotations.py +17 -6
  44. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_truncation.py +2 -2
  45. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_undo_executor.py +5 -5
  46. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_undo_replay.py +1 -1
  47. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/uv.lock +1 -1
  48. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.github/workflows/mcp-publish.yml +0 -0
  49. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.github/workflows/publish.yml +0 -0
  50. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/.gitignore +0 -0
  51. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/Dockerfile +0 -0
  52. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/LICENSE +0 -0
  53. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/RELEASE_NOTES.md +0 -0
  54. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/glama.json +0 -0
  55. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/__init__.py +0 -0
  56. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/__init__.py +0 -0
  57. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/_common.py +0 -0
  58. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/activity.py +0 -0
  59. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/analyze.py +0 -0
  60. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/doctor.py +0 -0
  61. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/index.py +0 -0
  62. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/init.py +0 -0
  63. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/overview.py +0 -0
  64. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/replication.py +0 -0
  65. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/secret.py +0 -0
  66. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/server.py +0 -0
  67. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/cli/table.py +0 -0
  68. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/config.py +0 -0
  69. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/connection.py +0 -0
  70. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/doctor.py +0 -0
  71. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/__init__.py +0 -0
  72. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/budget.py +0 -0
  73. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/outcome.py +0 -0
  74. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/paths.py +0 -0
  75. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/patterns.py +0 -0
  76. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/policy.py +0 -0
  77. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/sanitize.py +0 -0
  78. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/governance/undo.py +0 -0
  79. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/__init__.py +0 -0
  80. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/_annotations.py +0 -0
  81. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0/postgres_aiops}/mcp_server/tools/__init__.py +0 -0
  82. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/__init__.py +0 -0
  83. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/_util.py +0 -0
  84. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/activity.py +0 -0
  85. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/analysis.py +0 -0
  86. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/indexes.py +0 -0
  87. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/overview.py +0 -0
  88. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/queries.py +0 -0
  89. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/remediation.py +0 -0
  90. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/replication.py +0 -0
  91. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/server.py +0 -0
  92. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/ops/tables.py +0 -0
  93. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/postgres_aiops/secretstore.py +0 -0
  94. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/agent-guardrails.md +0 -0
  95. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/capabilities.md +0 -0
  96. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/cli-reference.md +0 -0
  97. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/skills/postgres-aiops/references/setup-guide.md +0 -0
  98. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/smithery.yaml +0 -0
  99. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/conftest.py +0 -0
  100. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_analysis.py +0 -0
  101. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_reads.py +0 -0
  102. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_cli_secret.py +0 -0
  103. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_config.py +0 -0
  104. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_connection.py +0 -0
  105. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_connection_more.py +0 -0
  106. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_doctor.py +0 -0
  107. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_gov_policy.py +0 -0
  108. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_init.py +0 -0
  109. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_ops_more.py +0 -0
  110. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_optional_fields.py +0 -0
  111. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_reads.py +0 -0
  112. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_secretstore.py +0 -0
  113. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_secretstore_more.py +0 -0
  114. {postgres_aiops-0.11.0 → postgres_aiops-0.13.0}/tests/test_self_lockout.py +0 -0
  115. {postgres_aiops-0.11.0 → postgres_aiops-0.13.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.11.0",
5
+ "version": "0.13.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.11.0",
8
+ "postgres-aiops==0.13.0",
9
9
  "postgres-aiops-mcp"
10
10
  ]
11
11
  }
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.13.0 — 2026-10-30
4
+
5
+ ### Security and observability (BREAKING)
6
+ - **BREAKING:** Writes now require a durable audit intent before contacting the upstream platform. If the audit database cannot accept the intent, the write fails closed; a failed final audit update leaves an unresolved `pending` intent.
7
+ - Real writes now commit a durable `pending` audit intent before the upstream call. If audit storage is unavailable, the write does not start; a failed final update leaves a visible pending intent and blocks further writes in that process.
8
+ - Audit rows retain outcome metadata instead of full business responses. `AIOPS_WORKFLOW_ID` links calls across a workflow; optional `AIOPS_TELEMETRY=1` emits metadata-only JSON events and `AuditEngine.health()` reports audit failures and pending intents.
9
+
10
+
11
+ ## v0.12.0 — 2026-10-15
12
+
13
+ ### Fixed
14
+ - The MCP server moved from a top-level `mcp_server` package to
15
+ `postgres_aiops.mcp_server`. Every AIops tool used to ship its own top-level
16
+ `mcp_server`, so installing two of them into one environment merged the two
17
+ into one package: `postgres-aiops-mcp` could start another tool's server, and
18
+ `postgres-aiops undo` wrote audit and undo rows into the other tool's home
19
+ directory. Installs through `uvx`, the Claude Code plugin or the OpenClaw
20
+ bundle were isolated and unaffected. The `postgres-aiops-mcp` command is
21
+ unchanged; only code that imported `mcp_server` directly must now import
22
+ `postgres_aiops.mcp_server`.
23
+
3
24
  ## v0.11.0 — 2026-10-15
4
25
 
5
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: postgres-aiops
3
- Version: 0.11.0
3
+ Version: 0.13.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
@@ -18,6 +18,8 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # Postgres AIops
20
20
 
21
+ > **Audit availability:** Real writes require a committed `pending` audit row and stop before contacting the platform if storage is unavailable. Reads and previews may continue with a warning; new audit rows store outcome summaries rather than full responses.
22
+
21
23
  > **Disclaimer**: Community-maintained open-source project. **Not affiliated with, endorsed by, or sponsored by the PostgreSQL Global Development Group or any vendor.** "PostgreSQL" and the elephant logo are trademarks of the PostgreSQL Community Association; all product/trademark names belong to their respective owners. MIT licensed.
22
24
 
23
25
  Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
@@ -152,7 +154,7 @@ postgres-aiops-mcp
152
154
 
153
155
  Every MCP tool passes through the bundled `@governed_tool` harness:
154
156
 
155
- - **Audit** — every call (params, result, status, duration, risk tier, and any
157
+ - **Audit** — every call (params, outcome summary, status, duration, risk tier, and any
156
158
  operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
157
159
  (relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
158
160
  path does — there is no unaudited entry point.
@@ -2,6 +2,8 @@
2
2
 
3
3
  # Postgres AIops
4
4
 
5
+ > **Audit availability:** Real writes require a committed `pending` audit row and stop before contacting the platform if storage is unavailable. Reads and previews may continue with a warning; new audit rows store outcome summaries rather than full responses.
6
+
5
7
  > **Disclaimer**: Community-maintained open-source project. **Not affiliated with, endorsed by, or sponsored by the PostgreSQL Global Development Group or any vendor.** "PostgreSQL" and the elephant logo are trademarks of the PostgreSQL Community Association; all product/trademark names belong to their respective owners. MIT licensed.
6
8
 
7
9
  Governed AI-ops for **PostgreSQL DBA operations** — connecting to a server with
@@ -136,7 +138,7 @@ postgres-aiops-mcp
136
138
 
137
139
  Every MCP tool passes through the bundled `@governed_tool` harness:
138
140
 
139
- - **Audit** — every call (params, result, status, duration, risk tier, and any
141
+ - **Audit** — every call (params, outcome summary, status, duration, risk tier, and any
140
142
  operator-supplied approver/rationale) is logged to `~/.postgres-aiops/audit.db`
141
143
  (relocatable via `POSTGRES_AIOPS_HOME`). The CLI writes the same row the MCP
142
144
  path does — there is no unaudited entry point.
@@ -41,7 +41,7 @@ reports.
41
41
  ### Governed Operations
42
42
  Every MCP tool runs through the bundled `@governed_tool` harness
43
43
  (`postgres_aiops.governance`):
44
- - **Audit** — every call logged to a local SQLite DB under `~/.postgres-aiops/`
44
+ - **Audit** — calls logged to a local SQLite DB when storage is available under `~/.postgres-aiops/`
45
45
  (relocatable via `POSTGRES_AIOPS_HOME`), agent-attributed, secret-redacted.
46
46
  - **Token/runaway budget** — hard ceilings (`POSTGRES_MAX_TOOL_CALLS` /
47
47
  `POSTGRES_MAX_TOOL_SECONDS` — the env-var names the bundled harness reads) plus
@@ -82,7 +82,7 @@ connection. No post-install scripts or background services.
82
82
  ## Static Analysis
83
83
 
84
84
  ```bash
85
- uvx bandit -r postgres_aiops/ mcp_server/
85
+ uvx bandit -r postgres_aiops/
86
86
  uv run ruff check .
87
87
  ```
88
88
 
@@ -90,3 +90,20 @@ uv run ruff check .
90
90
 
91
91
  The latest released version receives security fixes. This is a preview (0.x);
92
92
  pin a version in production.
93
+
94
+ ## Audit availability and telemetry
95
+
96
+ Real writes first commit a `pending` audit intent. If audit storage is unavailable,
97
+ the upstream write does not start. If finalization fails after an upstream call,
98
+ the intent stays visible and further writes in that process are blocked. Check
99
+ that the process is no longer executing and verify server state before calling
100
+ `AuditEngine.reconcile_pending(audit_id)` to mark the intent `unknown`.
101
+
102
+ Reads and dry-run previews continue when audit storage fails, with a warning
103
+ rather than a durable row. New audit rows store outcome summaries instead of
104
+ full business responses; older rows and rotated archives are not rewritten
105
+ automatically. Parameter redaction still follows each tool's
106
+ `sensitive_params`. `AIOPS_WORKFLOW_ID` supplies a correlation ID, and optional
107
+ `AIOPS_TELEMETRY=1` emits metadata-only JSON logs. `AuditEngine.health()`
108
+ reports availability, write blocking, failure count, and pending intent count.
109
+ On POSIX the rotation lock spans processes; on Windows it is process-local.
@@ -174,3 +174,7 @@ based analysis, replication reads, session termination, `REINDEX`, and
174
174
  `pg_basebackup`) and re-run `repl status` / `repl slots` / `repl wal`.
175
175
  - The analyses also accept **injected records**, so exported rows from a cluster
176
176
  you cannot write to still exercise section 3 without any write access.
177
+
178
+ ## Governance hardening — local synthetic verification (2026-10-30)
179
+
180
+ The shared governance tests exercise a durable `pending` row before a write, refusal when the audit DB cannot persist that row, a visible pending row after finalization failure, explicit reconciliation to `unknown`, async/preview parity, result minimization, and metadata-only telemetry with `AIOPS_WORKFLOW_ID`. The tests use a temporary SQLite DB and synthetic tools; they do not claim a new live upstream write was performed. Existing audit rows and rotated archives from earlier releases are not automatically scrubbed. A pending intent from another process is never auto-reconciled. On POSIX the rotation/intent lock spans processes; on Windows it currently protects only threads in this process.
@@ -11,7 +11,7 @@
11
11
  "command": "uvx",
12
12
  "args": [
13
13
  "--from",
14
- "postgres-aiops==0.11.0",
14
+ "postgres-aiops==0.13.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.11.0",
3
+ "version": "0.13.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",
@@ -61,7 +61,7 @@ def mcp_cmd() -> None:
61
61
  )
62
62
  raise typer.Exit(2)
63
63
 
64
- from mcp_server.server import main as _mcp_main
64
+ from postgres_aiops.mcp_server.server import main as _mcp_main
65
65
 
66
66
  _mcp_main()
67
67
 
@@ -70,7 +70,7 @@ def query_reset(target: TargetOption = None, dry_run: DryRunOption = False) -> N
70
70
  ``@governed_tool``-wrapped MCP function, so each is audited on the same
71
71
  governance path as MCP calls.
72
72
  """
73
- from mcp_server.tools import queries as gov
73
+ from postgres_aiops.mcp_server.tools import queries as gov
74
74
 
75
75
  if dry_run:
76
76
  dry_run_preview(
@@ -32,7 +32,7 @@ def remediate_terminate(
32
32
  dry_run: DryRunOption = False,
33
33
  ) -> None:
34
34
  """Terminate a backend (no undo; dry-run + confirm)."""
35
- from mcp_server.tools import remediation as gov
35
+ from postgres_aiops.mcp_server.tools import remediation as gov
36
36
 
37
37
  if dry_run:
38
38
  # Through the governed call: terminate_backend refuses this tool's own
@@ -54,7 +54,7 @@ def remediate_cancel(
54
54
  dry_run: DryRunOption = False,
55
55
  ) -> None:
56
56
  """Cancel a backend's running query (no undo; dry-run + confirm)."""
57
- from mcp_server.tools import remediation as gov
57
+ from postgres_aiops.mcp_server.tools import remediation as gov
58
58
 
59
59
  if dry_run:
60
60
  dry_run_preview(
@@ -76,7 +76,7 @@ def remediate_vacuum(
76
76
  dry_run: DryRunOption = False,
77
77
  ) -> None:
78
78
  """VACUUM a table (dry-run + confirm)."""
79
- from mcp_server.tools import remediation as gov
79
+ from postgres_aiops.mcp_server.tools import remediation as gov
80
80
 
81
81
  if dry_run:
82
82
  dry_run_preview(
@@ -97,7 +97,7 @@ def remediate_analyze(
97
97
  dry_run: DryRunOption = False,
98
98
  ) -> None:
99
99
  """ANALYZE a table (dry-run + confirm)."""
100
- from mcp_server.tools import remediation as gov
100
+ from postgres_aiops.mcp_server.tools import remediation as gov
101
101
 
102
102
  if dry_run:
103
103
  dry_run_preview(
@@ -120,7 +120,7 @@ def remediate_create_index(
120
120
  dry_run: DryRunOption = False,
121
121
  ) -> None:
122
122
  """Create an index (reversible; dry-run + confirm)."""
123
- from mcp_server.tools import remediation as gov
123
+ from postgres_aiops.mcp_server.tools import remediation as gov
124
124
 
125
125
  if dry_run:
126
126
  # Through the governed call: create_index validates the table/columns vs
@@ -146,7 +146,7 @@ def remediate_drop_index(
146
146
  dry_run: DryRunOption = False,
147
147
  ) -> None:
148
148
  """Drop an index (reversible; captures the definition first; dry-run + confirm)."""
149
- from mcp_server.tools import remediation as gov
149
+ from postgres_aiops.mcp_server.tools import remediation as gov
150
150
 
151
151
  if dry_run:
152
152
  dry_run_preview(
@@ -168,7 +168,7 @@ def remediate_reindex(
168
168
  dry_run: DryRunOption = False,
169
169
  ) -> None:
170
170
  """REINDEX an index/table/schema (dry-run + confirm)."""
171
- from mcp_server.tools import remediation as gov
171
+ from postgres_aiops.mcp_server.tools import remediation as gov
172
172
 
173
173
  if dry_run:
174
174
  dry_run_preview(
@@ -195,7 +195,7 @@ def remediate_set(
195
195
  dry_run: DryRunOption = False,
196
196
  ) -> None:
197
197
  """ALTER SYSTEM SET a parameter, or --reset it (reversible; dry-run + confirm)."""
198
- from mcp_server.tools import remediation as gov
198
+ from postgres_aiops.mcp_server.tools import remediation as gov
199
199
 
200
200
  action = f"ALTER SYSTEM RESET {name}" if reset else f"ALTER SYSTEM SET {name} ="
201
201
  if dry_run:
@@ -1,7 +1,7 @@
1
1
  """``postgres-aiops undo`` — list recorded undo tokens and apply them.
2
2
 
3
3
  Real execution is delegated to the ``@governed_tool``-wrapped functions in
4
- ``mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
4
+ ``postgres_aiops.mcp_server.tools.undo`` so an applied undo is audited on the SAME governance
5
5
  path as any other write (the inverse tool it dispatches is itself re-gated).
6
6
  """
7
7
 
@@ -38,7 +38,7 @@ def undo_list_cmd(
38
38
  target: TargetOption = None,
39
39
  ) -> None:
40
40
  """List recorded, not-yet-applied undo tokens."""
41
- from mcp_server.tools import undo as gov
41
+ from postgres_aiops.mcp_server.tools import undo as gov
42
42
 
43
43
  console.print_json(json.dumps(gov.undo_list(limit=limit, target=target)))
44
44
 
@@ -49,7 +49,7 @@ def undo_apply_cmd(
49
49
  undo_id: UndoIdArg, target: TargetOption = None, dry_run: DryRunOption = False
50
50
  ) -> None:
51
51
  """Apply a recorded undo (dispatches its inverse tool; dry-run + confirm)."""
52
- from mcp_server.tools import undo as gov
52
+ from postgres_aiops.mcp_server.tools import undo as gov
53
53
 
54
54
  if dry_run:
55
55
  # undo_apply refuses an unknown or already-applied token, and refuses one
@@ -1,4 +1,4 @@
1
- """Unified audit logging engine — all PostgreSQL skills write to a single SQLite database.
1
+ """Unified audit logging engine — all Monitoring skills write to a single SQLite database.
2
2
 
3
3
  Replaces 7 per-skill JSON Lines audit loggers with one shared ``~/.postgres-aiops/audit.db``.
4
4
  Framework-agnostic: works with Claude, Codex, local agents, or any MCP client.
@@ -12,12 +12,18 @@ import logging
12
12
  import os
13
13
  import sqlite3
14
14
  import threading
15
+ from contextlib import contextmanager
15
16
  from datetime import UTC, datetime, timedelta
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
19
20
  from postgres_aiops.governance.paths import ops_path
20
21
 
22
+ try:
23
+ import fcntl
24
+ except ImportError: # Windows has no fcntl; the in-process lock still applies.
25
+ fcntl = None
26
+
21
27
  _log = logging.getLogger("postgres-aiops.audit")
22
28
 
23
29
  # Back-compat override hook. When None (default) the path resolves via
@@ -29,6 +35,26 @@ _DEFAULT_DB: Path | None = None
29
35
  _MAX_DB_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB
30
36
  _MAX_ARCHIVES = 5
31
37
  _BUSY_TIMEOUT_MS = 5000
38
+ _rotation_thread_lock = threading.RLock()
39
+
40
+
41
+ @contextmanager
42
+ def _rotation_guard(path: Path):
43
+ """Serialize rotation and intent insertion across local processes."""
44
+ with _rotation_thread_lock:
45
+ fd = os.open(path.with_name("audit.lock"), os.O_CREAT | os.O_RDWR, 0o600)
46
+ try:
47
+ if fcntl is not None:
48
+ fcntl.flock(fd, fcntl.LOCK_EX)
49
+ yield
50
+ finally:
51
+ if fcntl is not None:
52
+ fcntl.flock(fd, fcntl.LOCK_UN)
53
+ os.close(fd)
54
+
55
+
56
+ class AuditUnavailable(RuntimeError):
57
+ """A write was stopped because its durable audit intent could not be recorded."""
32
58
 
33
59
  _CREATE_TABLE = """\
34
60
  CREATE TABLE IF NOT EXISTS audit_log (
@@ -78,6 +104,8 @@ class AuditEngine:
78
104
  else:
79
105
  self._path = _DEFAULT_DB or ops_path("audit.db")
80
106
  self._ok = False
107
+ self._write_blocked = False
108
+ self._write_failures = 0
81
109
  try:
82
110
  self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
83
111
  self._init_db()
@@ -90,6 +118,7 @@ class AuditEngine:
90
118
  conn = self._connect()
91
119
  conn.execute(_CREATE_TABLE)
92
120
  conn.execute("PRAGMA journal_mode=WAL")
121
+ conn.execute("PRAGMA synchronous=FULL")
93
122
  self._migrate(conn)
94
123
  conn.commit()
95
124
  conn.close()
@@ -126,6 +155,7 @@ class AuditEngine:
126
155
  def _connect(self) -> sqlite3.Connection:
127
156
  conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
128
157
  conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
158
+ conn.execute("PRAGMA synchronous=FULL")
129
159
  return conn
130
160
 
131
161
  def log(
@@ -144,43 +174,128 @@ class AuditEngine:
144
174
  rationale: str = "",
145
175
  approved_by: str = "",
146
176
  risk_tier: str = "",
147
- ) -> None:
148
- """Write one audit record. Never raises — swallows errors to avoid
149
- disrupting the actual tool execution.
177
+ strict: bool = False,
178
+ ) -> int | None:
179
+ """Append a row. Strict mode refuses writes if the intent cannot persist.
150
180
 
151
181
  rationale / approved_by / risk_tier carry the accountability trail:
152
182
  *why* a change was made, *who* signed off, and the *approval tier* the
153
183
  policy engine assigned (none/confirm/dual/review).
154
184
  """
155
185
  if not self._ok:
156
- return
186
+ if strict:
187
+ self._write_blocked = True
188
+ self._write_failures += 1
189
+ raise AuditUnavailable("Audit storage is unavailable; write was not started")
190
+ self._write_failures += 1
191
+ return None
192
+ try:
193
+ with _rotation_guard(self._path):
194
+ self._maybe_rotate()
195
+ conn = self._connect()
196
+ try:
197
+ cursor = conn.execute(
198
+ _INSERT,
199
+ (
200
+ datetime.now(tz=UTC).isoformat(),
201
+ skill,
202
+ tool,
203
+ _safe_json(params),
204
+ _safe_json(_summarize_result(result)),
205
+ status,
206
+ duration_ms,
207
+ agent,
208
+ workflow_id,
209
+ user or _current_user(),
210
+ risk_level,
211
+ rationale,
212
+ approved_by,
213
+ risk_tier,
214
+ ),
215
+ )
216
+ conn.commit()
217
+ audit_id = cursor.lastrowid
218
+ finally:
219
+ conn.close()
220
+ _log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
221
+ return audit_id
222
+ except Exception:
223
+ self._write_failures += 1
224
+ _log.warning("Failed to write audit log", exc_info=True)
225
+ if strict:
226
+ self._write_blocked = True
227
+ raise AuditUnavailable("Audit storage is unavailable; write was not started") from None
228
+ return None
229
+
230
+ def begin_write(self, **metadata: Any) -> int:
231
+ """Commit a durable intent before the caller sends an upstream write."""
232
+ if self._write_blocked:
233
+ raise AuditUnavailable("Audit storage failed; further writes are blocked")
234
+ audit_id = self.log(status="pending", strict=True, **metadata)
235
+ assert audit_id is not None
236
+ return audit_id
237
+
238
+ def finish_write(self, audit_id: int, *, result: Any, status: str, duration_ms: int) -> None:
239
+ """Resolve a write intent; on failure leave a recoverable pending row."""
157
240
  try:
158
- self._maybe_rotate()
159
241
  conn = self._connect()
160
- conn.execute(
161
- _INSERT,
162
- (
163
- datetime.now(tz=UTC).isoformat(),
164
- skill,
165
- tool,
166
- _safe_json(params),
167
- _safe_json(result),
168
- status,
169
- duration_ms,
170
- agent,
171
- workflow_id,
172
- user or _current_user(),
173
- risk_level,
174
- rationale,
175
- approved_by,
176
- risk_tier,
177
- ),
242
+ try:
243
+ cursor = conn.execute(
244
+ "UPDATE audit_log SET result = ?, status = ?, duration_ms = ? "
245
+ "WHERE id = ? AND status = 'pending'",
246
+ (_safe_json(_summarize_result(result)), status, duration_ms, audit_id),
247
+ )
248
+ if cursor.rowcount != 1:
249
+ raise RuntimeError(f"audit intent {audit_id} is missing")
250
+ conn.commit()
251
+ finally:
252
+ conn.close()
253
+ except Exception:
254
+ self._write_blocked = True
255
+ self._write_failures += 1
256
+ _log.warning("Failed to finalize audit intent %s", audit_id, exc_info=True)
257
+ raise AuditUnavailable(
258
+ f"Audit intent {audit_id} remains pending; operation may have completed"
259
+ ) from None
260
+
261
+ def reconcile_pending(self, audit_id: int) -> bool:
262
+ """Mark an orphaned intent unknown after an operator checks the process.
263
+
264
+ Never run this automatically: another process may still be executing
265
+ the write. The row remains visible until explicitly reconciled.
266
+ """
267
+ conn = self._connect()
268
+ try:
269
+ cursor = conn.execute(
270
+ "UPDATE audit_log SET status = 'unknown', result = ? "
271
+ "WHERE id = ? AND status = 'pending'",
272
+ (_safe_json({"auditInterrupted": True}), audit_id),
178
273
  )
179
274
  conn.commit()
275
+ return cursor.rowcount == 1
276
+ finally:
180
277
  conn.close()
181
- _log.debug("[AUDIT] %s.%s -> %s (%dms)", skill, tool, status, duration_ms)
182
- except Exception:
183
- _log.warning("Failed to write audit log", exc_info=True)
278
+
279
+ def health(self) -> dict[str, Any]:
280
+ """Safe local health counters for operators and telemetry exporters."""
281
+ pending: int | None = None
282
+ if self._ok:
283
+ try:
284
+ conn = self._connect()
285
+ try:
286
+ pending = conn.execute(
287
+ "SELECT COUNT(*) FROM audit_log WHERE status = 'pending'"
288
+ ).fetchone()[0]
289
+ finally:
290
+ conn.close()
291
+ except Exception:
292
+ pass
293
+ return {
294
+ "available": self._ok and pending is not None and not self._write_blocked,
295
+ "writeBlocked": self._write_blocked,
296
+ "auditWriteFailures": self._write_failures,
297
+ "pendingWrites": pending,
298
+ }
184
299
 
185
300
  # ── Rotation ──────────────────────────────────────────────────────
186
301
 
@@ -191,20 +306,27 @@ class AuditEngine:
191
306
  return
192
307
  if self._path.stat().st_size < _MAX_DB_SIZE_BYTES:
193
308
  return
309
+ pending_conn = self._connect()
310
+ try:
311
+ if pending_conn.execute(
312
+ "SELECT 1 FROM audit_log WHERE status = 'pending' LIMIT 1"
313
+ ).fetchone():
314
+ return
315
+ finally:
316
+ pending_conn.close()
194
317
  archive_name = self._path.with_suffix(
195
318
  f".{datetime.now(tz=UTC).strftime('%Y%m%d-%H%M%S')}.db"
196
319
  )
197
320
  # Checkpoint the WAL into the main file before renaming, otherwise
198
321
  # un-checkpointed rows in audit.db-wal are silently lost (the
199
322
  # sidecars belong to the OLD path and never follow the archive).
323
+ conn = self._connect()
200
324
  try:
201
- conn = self._connect()
202
- try:
203
- conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
204
- finally:
205
- conn.close()
206
- except Exception:
207
- _log.warning("WAL checkpoint before rotation failed", exc_info=True)
325
+ checkpoint = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
326
+ if checkpoint is None or checkpoint[0]:
327
+ raise RuntimeError(f"WAL checkpoint incomplete: {checkpoint}")
328
+ finally:
329
+ conn.close()
208
330
  self._path.rename(archive_name)
209
331
  # Drop now-stale sidecars (empty after a successful checkpoint).
210
332
  for suffix in ("-wal", "-shm"):
@@ -327,6 +449,28 @@ def _safe_json(obj: Any) -> str:
327
449
  return json.dumps({"_raw": str(obj)})
328
450
 
329
451
 
452
+ def _summarize_result(result: Any) -> dict[str, Any]:
453
+ """Persist only outcome metadata; never copy a tool's business payload."""
454
+ if isinstance(result, dict):
455
+ summary: dict[str, Any] = {"kind": "object"}
456
+ if result.get("error"):
457
+ summary["error"] = True
458
+ if result.get("outcomeUnknown") is True:
459
+ summary["outcomeUnknown"] = True
460
+ if result.get("_undo_id"):
461
+ summary["undoRecorded"] = True
462
+ if result.get("_pattern_id"):
463
+ summary["patternMatched"] = True
464
+ for key in ("truncated", "returned", "limit"):
465
+ value = result.get(key)
466
+ if type(value) in (bool, int):
467
+ summary[key] = value
468
+ return summary
469
+ if isinstance(result, (list, tuple)):
470
+ return {"kind": "list", "count": len(result)}
471
+ return {"kind": type(result).__name__}
472
+
473
+
330
474
  def detect_agent() -> str:
331
475
  """Infer the calling AI agent from environment variables."""
332
476
  if os.environ.get("CLAUDE_SESSION_ID") or os.environ.get("CLAUDE_CODE"):