postgres-aiops 0.1.0__py3-none-any.whl

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 (57) hide show
  1. mcp_server/__init__.py +1 -0
  2. mcp_server/_shared.py +101 -0
  3. mcp_server/server.py +37 -0
  4. mcp_server/tools/__init__.py +1 -0
  5. mcp_server/tools/activity.py +52 -0
  6. mcp_server/tools/analysis.py +89 -0
  7. mcp_server/tools/indexes.py +65 -0
  8. mcp_server/tools/queries.py +60 -0
  9. mcp_server/tools/remediation.py +281 -0
  10. mcp_server/tools/replication.py +43 -0
  11. mcp_server/tools/server.py +86 -0
  12. mcp_server/tools/tables.py +46 -0
  13. postgres_aiops/__init__.py +9 -0
  14. postgres_aiops/cli/__init__.py +9 -0
  15. postgres_aiops/cli/_common.py +78 -0
  16. postgres_aiops/cli/_root.py +68 -0
  17. postgres_aiops/cli/activity.py +52 -0
  18. postgres_aiops/cli/analyze.py +53 -0
  19. postgres_aiops/cli/doctor.py +21 -0
  20. postgres_aiops/cli/index.py +55 -0
  21. postgres_aiops/cli/init.py +112 -0
  22. postgres_aiops/cli/overview.py +16 -0
  23. postgres_aiops/cli/query.py +70 -0
  24. postgres_aiops/cli/remediate.py +186 -0
  25. postgres_aiops/cli/replication.py +45 -0
  26. postgres_aiops/cli/secret.py +103 -0
  27. postgres_aiops/cli/server.py +69 -0
  28. postgres_aiops/cli/table.py +45 -0
  29. postgres_aiops/config.py +154 -0
  30. postgres_aiops/connection.py +178 -0
  31. postgres_aiops/doctor.py +84 -0
  32. postgres_aiops/governance/__init__.py +40 -0
  33. postgres_aiops/governance/audit.py +377 -0
  34. postgres_aiops/governance/budget.py +225 -0
  35. postgres_aiops/governance/decorators.py +474 -0
  36. postgres_aiops/governance/paths.py +23 -0
  37. postgres_aiops/governance/patterns.py +378 -0
  38. postgres_aiops/governance/policy.py +411 -0
  39. postgres_aiops/governance/sanitize.py +39 -0
  40. postgres_aiops/governance/undo.py +218 -0
  41. postgres_aiops/ops/__init__.py +1 -0
  42. postgres_aiops/ops/_util.py +102 -0
  43. postgres_aiops/ops/activity.py +193 -0
  44. postgres_aiops/ops/analysis.py +263 -0
  45. postgres_aiops/ops/indexes.py +211 -0
  46. postgres_aiops/ops/overview.py +51 -0
  47. postgres_aiops/ops/queries.py +123 -0
  48. postgres_aiops/ops/remediation.py +237 -0
  49. postgres_aiops/ops/replication.py +144 -0
  50. postgres_aiops/ops/server.py +151 -0
  51. postgres_aiops/ops/tables.py +146 -0
  52. postgres_aiops/secretstore.py +302 -0
  53. postgres_aiops-0.1.0.dist-info/METADATA +119 -0
  54. postgres_aiops-0.1.0.dist-info/RECORD +57 -0
  55. postgres_aiops-0.1.0.dist-info/WHEEL +4 -0
  56. postgres_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  57. postgres_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,411 @@
1
+ """Policy engine — rule-based access control for Endpoint MCP tools.
2
+
3
+ Rules are loaded from ``~/.postgres-aiops/rules.yaml`` with hot-reload on file change.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from datetime import UTC
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from postgres_aiops.governance.paths import ops_path
17
+
18
+ _log = logging.getLogger("postgres-aiops.policy")
19
+
20
+ # ── Data structures ───────────────────────────────────────────────────
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class PolicyResult:
25
+ """Outcome of a policy check."""
26
+
27
+ allowed: bool
28
+ rule: str = ""
29
+ reason: str = ""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class TierDecision:
34
+ """Graduated-autonomy outcome: the approval tier an operation needs.
35
+
36
+ tier is one of APPROVAL_TIERS. ``requires_approver`` is True for tiers that
37
+ must carry a named human approver (dual / review) — the decorator denies
38
+ such calls when no approver is recorded.
39
+ """
40
+
41
+ tier: str = "none"
42
+ rule: str = "default"
43
+ reason: str = ""
44
+
45
+ @property
46
+ def requires_approver(self) -> bool:
47
+ return self.tier in ("dual", "review")
48
+
49
+
50
+ # ── Risk levels ───────────────────────────────────────────────────────
51
+
52
+ RISK_LEVELS = ("low", "medium", "high", "critical")
53
+
54
+ # Graduated autonomy tiers, least → most oversight.
55
+ # none — no gate (dev / low-risk)
56
+ # confirm — CLI double-confirm (informational at the harness layer)
57
+ # dual — requires a named approver to be recorded (two-person rule)
58
+ # review — requires a named approver + intended for explicit human review
59
+ APPROVAL_TIERS = ("none", "confirm", "dual", "review")
60
+
61
+ # Param keys whose string values are treated as resource tags / placement for
62
+ # tier matching (e.g. a VM's folder or environment tag: prod/staging/dev).
63
+ _TAG_PARAM_KEYS = ("tag", "tags", "folder", "resource_tag", "env_tier", "environment")
64
+
65
+
66
+ def risk_requires_confirmation(risk_level: str, env: str = "") -> bool:
67
+ """Determine if a risk level requires human confirmation.
68
+
69
+ - critical: always requires confirmation + approval in production
70
+ - high: requires confirmation
71
+ - medium/low: no confirmation
72
+ """
73
+ if risk_level == "critical":
74
+ return True
75
+ if risk_level == "high":
76
+ return True
77
+ return False
78
+
79
+
80
+ # ── Rule loading with hot-reload ──────────────────────────────────────
81
+
82
+
83
+ class PolicyEngine:
84
+ """Evaluate operations against a YAML rule set.
85
+
86
+ Rules file is re-read when its mtime changes (hot-reload, no restart needed).
87
+ """
88
+
89
+ def __init__(self, rules_path: Path | str | None = None) -> None:
90
+ self._path = Path(rules_path).expanduser() if rules_path else ops_path("rules.yaml")
91
+ self._rules: dict[str, Any] = {}
92
+ self._mtime: float = 0.0
93
+ self._load_rules()
94
+
95
+ def _load_rules(self) -> None:
96
+ """Load rules from YAML file. Missing file → empty rules (allow all)."""
97
+ if not self._path.exists():
98
+ self._rules = {}
99
+ self._mtime = 0.0
100
+ return
101
+ try:
102
+ import yaml
103
+
104
+ self._mtime = self._path.stat().st_mtime
105
+ with open(self._path) as fh:
106
+ self._rules = yaml.safe_load(fh) or {}
107
+ _log.debug("Loaded %d policy rules from %s", len(self._rules), self._path)
108
+ except Exception:
109
+ _log.warning("Failed to load policy rules from %s", self._path, exc_info=True)
110
+ self._rules = {}
111
+
112
+ def _maybe_reload(self) -> None:
113
+ """Hot-reload if file changed."""
114
+ if not self._path.exists():
115
+ if self._rules:
116
+ _log.warning("Policy rules file deleted: %s — clearing rules (allow all)", self._path)
117
+ self._rules = {}
118
+ self._mtime = 0.0
119
+ return
120
+ try:
121
+ current_mtime = self._path.stat().st_mtime
122
+ if current_mtime != self._mtime:
123
+ self._load_rules()
124
+ except Exception:
125
+ _log.warning("Failed to check policy rules file: %s", self._path, exc_info=True)
126
+
127
+ def check_allowed(
128
+ self,
129
+ operation: str,
130
+ *,
131
+ env: str = "",
132
+ risk_level: str = "low",
133
+ params: dict[str, Any] | None = None,
134
+ ) -> PolicyResult:
135
+ """Check if an operation is allowed by policy.
136
+
137
+ Args:
138
+ operation: Tool function name (e.g. 'delete_segment').
139
+ env: Target environment name (e.g. 'production').
140
+ risk_level: Risk level declared by @governed_tool.
141
+ params: Operation parameters for rule evaluation.
142
+
143
+ Returns:
144
+ PolicyResult with allowed=True/False and reason.
145
+ """
146
+ # Bypass mode — log context for audit trail. Log only parameter NAMES,
147
+ # never values: param values may carry passwords/tokens, and this path
148
+ # can be reached by callers that did not pre-redact.
149
+ if os.environ.get("POSTGRES_POLICY_DISABLED") == "1":
150
+ param_names = sorted(params.keys()) if isinstance(params, dict) else []
151
+ _log.warning(
152
+ "Policy DISABLED — bypassing check: operation=%s env=%s risk=%s param_keys=%s",
153
+ operation, env, risk_level, param_names,
154
+ )
155
+ return PolicyResult(allowed=True, rule="policy_disabled")
156
+
157
+ self._maybe_reload()
158
+
159
+ # No rules file → allow everything
160
+ if not self._rules:
161
+ return PolicyResult(allowed=True, rule="no_rules")
162
+
163
+ # ── Evaluate deny rules ───────────────────────────────────────
164
+ deny_rules = self._rules.get("deny", [])
165
+ for rule in deny_rules:
166
+ if self._rule_matches(rule, operation, env, risk_level, params):
167
+ reason = rule.get("reason", f"Denied by rule: {rule.get('name', 'unnamed')}")
168
+ return PolicyResult(allowed=False, rule=rule.get("name", "deny"), reason=reason)
169
+
170
+ # ── Evaluate maintenance window ───────────────────────────────
171
+ window = self._rules.get("maintenance_window")
172
+ if window and risk_level in ("high", "critical"):
173
+ try:
174
+ in_window = self._in_maintenance_window(window)
175
+ except (ValueError, TypeError, AttributeError):
176
+ # Fail CLOSED: a malformed window must not silently allow
177
+ # high-risk operations around the clock.
178
+ _log.error(
179
+ "Malformed maintenance_window %r in %s — failing CLOSED: "
180
+ "high-risk operations are blocked until the rule is fixed. "
181
+ "Expected 'start' and 'end' as 'HH:MM' strings, e.g. "
182
+ "start: \"22:00\" / end: \"06:00\".",
183
+ window, self._path,
184
+ )
185
+ return PolicyResult(
186
+ allowed=False,
187
+ rule="maintenance_window_malformed",
188
+ reason=(
189
+ f"maintenance_window in {self._path} is malformed "
190
+ f"({window!r}). High-risk operations are blocked until it is "
191
+ "fixed. Expected 'start' and 'end' as 'HH:MM' strings, "
192
+ 'e.g. start: "22:00" / end: "06:00".'
193
+ ),
194
+ )
195
+ if not in_window:
196
+ return PolicyResult(
197
+ allowed=False,
198
+ rule="maintenance_window",
199
+ reason=f"High-risk operations only allowed during {window.get('start', '?')}-{window.get('end', '?')}",
200
+ )
201
+
202
+ # ── Evaluate change limits (reserved, not implemented) ────────
203
+ # change_limits is NOT an enforced feature: _check_limits only warns
204
+ # that configured limits are ignored (it can't compute deltas without
205
+ # before-state). Kept so misconfiguration is surfaced, not silent.
206
+ limits = self._rules.get("change_limits", {})
207
+ if params and limits:
208
+ result = self._check_limits(limits, params, operation)
209
+ if result and not result.allowed:
210
+ return result
211
+
212
+ return PolicyResult(allowed=True, rule="default_allow")
213
+
214
+ def required_approval_tier(
215
+ self,
216
+ operation: str,
217
+ *,
218
+ env: str = "",
219
+ risk_level: str = "low",
220
+ params: dict[str, Any] | None = None,
221
+ ) -> TierDecision:
222
+ """Return the approval tier this operation needs (graduated autonomy).
223
+
224
+ Evaluated from a ``risk_tiers`` list in rules.yaml — each entry matches
225
+ on operation glob / environment / resource tag / minimum risk and maps
226
+ to a tier (none/confirm/dual/review). The FIRST matching, HIGHEST tier
227
+ wins so a prod-tagged destructive op can't be down-graded by a looser
228
+ rule listed earlier. No config → tier ``none`` (backward compatible).
229
+ """
230
+ self._maybe_reload()
231
+ tiers = self._rules.get("risk_tiers") if self._rules else None
232
+ if not tiers:
233
+ return TierDecision(tier="none", rule="no_tiers")
234
+
235
+ tags = _extract_tags(params)
236
+ best: TierDecision | None = None
237
+ for rule in tiers:
238
+ tier = str(rule.get("tier", "")).lower()
239
+ if tier not in APPROVAL_TIERS:
240
+ continue
241
+ if not self._tier_rule_matches(rule, operation, env, risk_level, tags):
242
+ continue
243
+ if best is None or APPROVAL_TIERS.index(tier) > APPROVAL_TIERS.index(best.tier):
244
+ best = TierDecision(
245
+ tier=tier,
246
+ rule=str(rule.get("name", "risk_tier")),
247
+ reason=str(rule.get("reason", "")),
248
+ )
249
+ return best or TierDecision(tier="none", rule="no_tier_match")
250
+
251
+ def _tier_rule_matches(
252
+ self,
253
+ rule: dict[str, Any],
254
+ operation: str,
255
+ env: str,
256
+ risk_level: str,
257
+ tags: set[str],
258
+ ) -> bool:
259
+ """Match a risk_tiers entry against the current call."""
260
+ if "operations" in rule:
261
+ ops = rule["operations"]
262
+ if not ops or not any(self._pattern_match(op, operation) for op in ops):
263
+ return False
264
+ envs = rule.get("environments", [])
265
+ if envs and env and env not in envs:
266
+ return False
267
+ if envs and not env:
268
+ return False # rule scoped to envs but call has none → no match
269
+ rule_tags = {str(t) for t in (rule.get("tags") or [])}
270
+ if rule_tags and not (rule_tags & tags):
271
+ return False
272
+ min_risk = rule.get("min_risk_level")
273
+ if min_risk and RISK_LEVELS.index(risk_level) < RISK_LEVELS.index(min_risk):
274
+ return False
275
+ return True
276
+
277
+ def _rule_matches(
278
+ self,
279
+ rule: dict[str, Any],
280
+ operation: str,
281
+ env: str,
282
+ risk_level: str,
283
+ params: dict[str, Any] | None,
284
+ ) -> bool:
285
+ """Check if a deny rule matches the current operation."""
286
+ # Match by operation pattern
287
+ # Note: "operations" key absent → match all (no filter).
288
+ # "operations: []" → match nothing (explicit empty = no operations apply).
289
+ if "operations" in rule:
290
+ ops = rule["operations"]
291
+ if not ops or not any(self._pattern_match(op, operation) for op in ops):
292
+ return False
293
+
294
+ # Match by environment
295
+ envs = rule.get("environments", [])
296
+ if envs and env and env not in envs:
297
+ return False
298
+
299
+ # Match by risk level (minimum)
300
+ min_risk = rule.get("min_risk_level")
301
+ if min_risk:
302
+ if RISK_LEVELS.index(risk_level) < RISK_LEVELS.index(min_risk):
303
+ return False
304
+
305
+ return True
306
+
307
+ @staticmethod
308
+ def _pattern_match(pattern: str, value: str) -> bool:
309
+ """Simple glob: 'delete_*' matches 'delete_segment'."""
310
+ if pattern == "*":
311
+ return True
312
+ if pattern.endswith("*"):
313
+ return value.startswith(pattern[:-1])
314
+ return pattern == value
315
+
316
+ @staticmethod
317
+ def _in_maintenance_window(window: dict[str, str]) -> bool:
318
+ """Check if current time is within the maintenance window (UTC).
319
+
320
+ Raises ValueError/TypeError/AttributeError when the window is
321
+ malformed — the caller fails CLOSED with a teaching message.
322
+ """
323
+ from datetime import datetime
324
+
325
+ now = datetime.now(tz=UTC)
326
+ start_h, start_m = map(int, str(window.get("start", "22:00")).split(":"))
327
+ end_h, end_m = map(int, str(window.get("end", "06:00")).split(":"))
328
+
329
+ current_minutes = now.hour * 60 + now.minute
330
+ start_minutes = start_h * 60 + start_m
331
+ end_minutes = end_h * 60 + end_m
332
+
333
+ if start_minutes <= end_minutes:
334
+ return start_minutes <= current_minutes <= end_minutes
335
+ # Wraps midnight (e.g. 22:00 - 06:00)
336
+ return current_minutes >= start_minutes or current_minutes <= end_minutes
337
+
338
+ @staticmethod
339
+ def _check_limits(
340
+ limits: dict[str, Any], params: dict[str, Any], operation: str
341
+ ) -> PolicyResult | None:
342
+ """Check parameter-based limits (e.g. max CPU change %).
343
+
344
+ NOTE: Not yet implemented — requires before-state to compute deltas.
345
+ Logs a warning when limits are configured so operators know they are
346
+ not being enforced.
347
+ """
348
+ if limits:
349
+ _log.warning(
350
+ "change_limits configured for '%s' but limit enforcement is not yet "
351
+ "implemented — limits are NOT being enforced. Params: %s",
352
+ operation, list(params.keys()),
353
+ )
354
+ return None
355
+
356
+
357
+ def _extract_tags(params: dict[str, Any] | None) -> set[str]:
358
+ """Collect resource-tag-like string values from params for tier matching.
359
+
360
+ Looks at a fixed set of keys (tag/tags/folder/...) and flattens list values
361
+ so ``{"tags": ["prod", "pci"]}`` and ``{"folder": "prod"}`` both yield
362
+ ``{"prod", ...}``.
363
+ """
364
+ if not params:
365
+ return set()
366
+ out: set[str] = set()
367
+ for key in _TAG_PARAM_KEYS:
368
+ if key not in params:
369
+ continue
370
+ val = params[key]
371
+ if isinstance(val, str):
372
+ out.add(val)
373
+ elif isinstance(val, (list, tuple, set)):
374
+ out.update(str(v) for v in val)
375
+ return out
376
+
377
+
378
+ # ── Singleton ─────────────────────────────────────────────────────────
379
+
380
+ _engine: PolicyEngine | None = None
381
+ _engine_lock = threading.Lock()
382
+
383
+
384
+ def get_policy_engine(rules_path: Path | str | None = None) -> PolicyEngine:
385
+ """Return the global PolicyEngine singleton (lazy, lock-guarded).
386
+
387
+ A ``rules_path`` differing from the one the singleton was created with is
388
+ ignored with a warning — call :func:`reset_policy_engine` first to rebind.
389
+ """
390
+ global _engine
391
+ if _engine is None:
392
+ with _engine_lock:
393
+ if _engine is None:
394
+ _engine = PolicyEngine(rules_path)
395
+ return _engine
396
+ if rules_path is not None:
397
+ requested = Path(rules_path).expanduser()
398
+ if requested != _engine._path:
399
+ _log.warning(
400
+ "get_policy_engine(%s) ignored — singleton already initialized "
401
+ "with %s; call reset_policy_engine() first to rebind.",
402
+ requested, _engine._path,
403
+ )
404
+ return _engine
405
+
406
+
407
+ def reset_policy_engine() -> None:
408
+ """Reset the singleton. Mirrors patterns.reset_pattern_engine()."""
409
+ global _engine
410
+ with _engine_lock:
411
+ _engine = None
@@ -0,0 +1,39 @@
1
+ """Prompt-injection defense: strip control characters and truncate untrusted text.
2
+
3
+ Consolidated from 22 duplicate ``_sanitize()`` implementations across 7 Endpoint skills.
4
+ All skills should import from here instead of defining their own copy.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import unicodedata
11
+
12
+ # C0 control chars (except tab \x09, LF \x0a, CR \x0d) + C1 control chars
13
+ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
14
+
15
+
16
+ def sanitize(text: str | None, max_len: int = 500) -> str:
17
+ """Strip control characters, Unicode format chars, and truncate.
18
+
19
+ Removes:
20
+ - C0/C1 control characters (except newline/tab)
21
+ - Unicode Format characters (Cf): zero-width spaces, bidi overrides,
22
+ zero-width joiners — used in prompt injection attacks
23
+
24
+ Stripping happens BEFORE truncation so an attacker cannot push the real
25
+ payload past the cut-off by padding with junk control characters.
26
+ ``None`` sanitizes to ``""``.
27
+
28
+ Args:
29
+ text: Untrusted text from Endpoint SCALE API responses.
30
+ max_len: Maximum length after truncation. Default 500.
31
+
32
+ Returns:
33
+ Cleaned, truncated string safe for LLM consumption.
34
+ """
35
+ if text is None:
36
+ return ""
37
+ stripped = _CONTROL_CHAR_RE.sub("", str(text))
38
+ cleaned = "".join(c for c in stripped if unicodedata.category(c) != "Cf")
39
+ return cleaned[:max_len]
@@ -0,0 +1,218 @@
1
+ """Undo-token primitive — record the inverse of a write operation.
2
+
3
+ Reversibility is a 2026 agentic-ops requirement: an operator (or an external orchestrator)
4
+ needs to roll a change back to a known-good state. The inverse of an operation
5
+ is domain-specific (power-off ↔ power-on, create-snapshot ↔ delete-snapshot,
6
+ and some ops — like delete-vm — have NO safe inverse), so a tool *declares* its
7
+ undo via ``@governed_tool(undo=...)``.
8
+
9
+ This module only RECORDS the inverse descriptor (a replayable
10
+ ``{skill, tool, params}``) into ``~/.postgres-aiops/undo.db`` and hands back an
11
+ ``undo_id``. It deliberately does NOT execute the inverse — coordinated,
12
+ multi-step rollback is an external orchestrator's job. Keeping recording and execution
13
+ separate matches the "separate the deterministic parts from the parts where the
14
+ LLM reasons" guardrail.
15
+
16
+ A tool's ``undo`` is a callable ``(params, result) -> dict | None``:
17
+
18
+ @governed_tool(risk_level="medium", undo=lambda p, r: {
19
+ "tool": "vm_power_on", "params": {"vm_name": p["vm_name"]}})
20
+ def vm_power_off(vm_name: str, target: str = "") -> dict: ...
21
+
22
+ Returning ``None`` means "no inverse for this call" (nothing is recorded).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import sqlite3
31
+ import threading
32
+ import uuid
33
+ from datetime import UTC, datetime
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from postgres_aiops.governance.paths import ops_path
38
+
39
+ _log = logging.getLogger("postgres-aiops.undo")
40
+
41
+ _BUSY_TIMEOUT_MS = 5000
42
+
43
+ _CREATE_TABLE = """\
44
+ CREATE TABLE IF NOT EXISTS undo_log (
45
+ undo_id TEXT PRIMARY KEY,
46
+ ts TEXT NOT NULL,
47
+ skill TEXT NOT NULL DEFAULT '',
48
+ tool TEXT NOT NULL DEFAULT '',
49
+ undo_skill TEXT NOT NULL DEFAULT '',
50
+ undo_tool TEXT NOT NULL DEFAULT '',
51
+ undo_params TEXT NOT NULL DEFAULT '{}',
52
+ orig_params TEXT NOT NULL DEFAULT '{}',
53
+ status TEXT NOT NULL DEFAULT 'recorded',
54
+ workflow_id TEXT NOT NULL DEFAULT '',
55
+ note TEXT NOT NULL DEFAULT ''
56
+ )
57
+ """
58
+
59
+ _CREATE_INDEX = "CREATE INDEX IF NOT EXISTS idx_undo_status ON undo_log(status)"
60
+
61
+
62
+ class UndoStore:
63
+ """Append-and-update store for recorded undo descriptors (SQLite/WAL)."""
64
+
65
+ def __init__(self, db_path: Path | str | None = None) -> None:
66
+ self._path = Path(db_path).expanduser() if db_path else ops_path("undo.db")
67
+ self._ok = False
68
+ try:
69
+ self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
70
+ conn = self._connect()
71
+ conn.execute(_CREATE_TABLE)
72
+ conn.execute(_CREATE_INDEX)
73
+ conn.execute("PRAGMA journal_mode=WAL")
74
+ conn.commit()
75
+ conn.close()
76
+ self._harden()
77
+ self._ok = True
78
+ except Exception:
79
+ _log.warning("Cannot initialize undo DB at %s", self._path, exc_info=True)
80
+
81
+ def _connect(self) -> sqlite3.Connection:
82
+ conn = sqlite3.connect(str(self._path), timeout=_BUSY_TIMEOUT_MS / 1000)
83
+ conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
84
+ return conn
85
+
86
+ def _harden(self) -> None:
87
+ try:
88
+ os.chmod(self._path.parent, 0o700)
89
+ except OSError:
90
+ pass
91
+ for suffix in ("", "-wal", "-shm"):
92
+ candidate = self._path.with_name(self._path.name + suffix)
93
+ try:
94
+ if candidate.exists():
95
+ os.chmod(candidate, 0o600)
96
+ except OSError:
97
+ pass
98
+
99
+ def record(
100
+ self,
101
+ *,
102
+ skill: str,
103
+ tool: str,
104
+ undo_descriptor: dict[str, Any],
105
+ orig_params: dict[str, Any] | None = None,
106
+ workflow_id: str = "",
107
+ ) -> str | None:
108
+ """Persist an inverse descriptor; return its undo_id (or None on failure).
109
+
110
+ ``undo_descriptor`` must carry at least ``tool`` (the inverse tool) and
111
+ ``params``; ``skill`` defaults to the original skill. Never raises —
112
+ recording must not break the underlying tool call.
113
+ """
114
+ if not self._ok:
115
+ return None
116
+ if not isinstance(undo_descriptor, dict) or not undo_descriptor.get("tool"):
117
+ _log.debug("undo descriptor for %s.%s missing 'tool' — not recorded", skill, tool)
118
+ return None
119
+ undo_id = uuid.uuid4().hex[:16]
120
+ try:
121
+ conn = self._connect()
122
+ conn.execute(
123
+ "INSERT INTO undo_log (undo_id, ts, skill, tool, undo_skill, undo_tool, "
124
+ "undo_params, orig_params, status, workflow_id, note) "
125
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'recorded', ?, ?)",
126
+ (
127
+ undo_id,
128
+ datetime.now(tz=UTC).isoformat(),
129
+ skill,
130
+ tool,
131
+ str(undo_descriptor.get("skill", skill)),
132
+ str(undo_descriptor["tool"]),
133
+ _safe_json(undo_descriptor.get("params", {})),
134
+ _safe_json(orig_params or {}),
135
+ workflow_id,
136
+ str(undo_descriptor.get("note", "")),
137
+ ),
138
+ )
139
+ conn.commit()
140
+ conn.close()
141
+ return undo_id
142
+ except Exception:
143
+ _log.warning("Failed to record undo for %s.%s", skill, tool, exc_info=True)
144
+ return None
145
+
146
+ def get(self, undo_id: str) -> dict[str, Any] | None:
147
+ if not self._ok:
148
+ return None
149
+ conn = self._connect()
150
+ try:
151
+ conn.row_factory = sqlite3.Row
152
+ row = conn.execute("SELECT * FROM undo_log WHERE undo_id = ?", (undo_id,)).fetchone()
153
+ return dict(row) if row else None
154
+ finally:
155
+ conn.close()
156
+
157
+ def list(self, *, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
158
+ if not self._ok:
159
+ return []
160
+ conn = self._connect()
161
+ try:
162
+ conn.row_factory = sqlite3.Row
163
+ if status:
164
+ rows = conn.execute(
165
+ "SELECT * FROM undo_log WHERE status = ? ORDER BY ts DESC LIMIT ?",
166
+ (status, limit),
167
+ ).fetchall()
168
+ else:
169
+ rows = conn.execute(
170
+ "SELECT * FROM undo_log ORDER BY ts DESC LIMIT ?", (limit,)
171
+ ).fetchall()
172
+ return [dict(r) for r in rows]
173
+ finally:
174
+ conn.close()
175
+
176
+ def mark(self, undo_id: str, status: str) -> bool:
177
+ """Update an undo record's status (e.g. 'applied' / 'expired')."""
178
+ if not self._ok:
179
+ return False
180
+ conn = self._connect()
181
+ try:
182
+ cur = conn.execute(
183
+ "UPDATE undo_log SET status = ? WHERE undo_id = ?", (status, undo_id)
184
+ )
185
+ conn.commit()
186
+ return cur.rowcount > 0
187
+ finally:
188
+ conn.close()
189
+
190
+
191
+ def _safe_json(obj: Any) -> str:
192
+ try:
193
+ return json.dumps(obj, ensure_ascii=False, default=str)
194
+ except Exception:
195
+ return json.dumps({"_raw": str(obj)})
196
+
197
+
198
+ # ── Singleton ──────────────────────────────────────────────────────────
199
+
200
+ _store: UndoStore | None = None
201
+ _store_lock = threading.Lock()
202
+
203
+
204
+ def get_undo_store(db_path: Path | str | None = None) -> UndoStore:
205
+ """Return the global UndoStore singleton (lazy, lock-guarded)."""
206
+ global _store
207
+ if _store is None:
208
+ with _store_lock:
209
+ if _store is None:
210
+ _store = UndoStore(db_path)
211
+ return _store
212
+
213
+
214
+ def reset_undo_store() -> None:
215
+ """Reset the singleton. Tests use this between cases."""
216
+ global _store
217
+ with _store_lock:
218
+ _store = None
@@ -0,0 +1 @@
1
+ """Postgres AIops operations (business logic over the PostgreSQL catalogs)."""