skillpool 4.3.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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""SkillPool Freeze Detector — Detect and recover from frozen/stalled operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FreezeStatus(str, Enum):
|
|
15
|
+
"""Status of a freeze check."""
|
|
16
|
+
|
|
17
|
+
HEALTHY = "healthy"
|
|
18
|
+
FROZEN = "frozen"
|
|
19
|
+
STALE = "stale"
|
|
20
|
+
RECOVERING = "recovering"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FreezeReport(BaseModel):
|
|
24
|
+
"""Report from a freeze detection check."""
|
|
25
|
+
|
|
26
|
+
status: FreezeStatus
|
|
27
|
+
frozen_operations: list[str] = []
|
|
28
|
+
stale_threshold_seconds: int = 300
|
|
29
|
+
last_heartbeat: str = ""
|
|
30
|
+
timestamp: str = ""
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def _set_timestamp(self) -> FreezeReport:
|
|
34
|
+
if not self.timestamp:
|
|
35
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FreezeDetector:
|
|
40
|
+
"""Detect and recover from frozen/stalled operations.
|
|
41
|
+
|
|
42
|
+
Monitors heartbeat files to detect operations that have stalled
|
|
43
|
+
beyond a configurable threshold. Provides recovery actions.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, state_dir: Path, stale_threshold: int = 300) -> None:
|
|
47
|
+
self._state_dir = state_dir / "freeze_state"
|
|
48
|
+
self._heartbeats_dir = self._state_dir / "heartbeats"
|
|
49
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
self._heartbeats_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._stale_threshold = stale_threshold
|
|
52
|
+
|
|
53
|
+
def heartbeat(self, operation_id: str) -> None:
|
|
54
|
+
"""Record a heartbeat for an ongoing operation."""
|
|
55
|
+
hb_file = self._heartbeats_dir / f"{operation_id}.json"
|
|
56
|
+
data = {
|
|
57
|
+
"operation_id": operation_id,
|
|
58
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
59
|
+
}
|
|
60
|
+
hb_file.write_text(json.dumps(data), encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
def complete_operation(self, operation_id: str) -> None:
|
|
63
|
+
"""Mark an operation as completed (remove heartbeat)."""
|
|
64
|
+
hb_file = self._heartbeats_dir / f"{operation_id}.json"
|
|
65
|
+
if hb_file.exists():
|
|
66
|
+
hb_file.unlink()
|
|
67
|
+
|
|
68
|
+
def check(self) -> FreezeReport:
|
|
69
|
+
"""Check for frozen/stalled operations."""
|
|
70
|
+
frozen_ops: list[str] = []
|
|
71
|
+
now = datetime.now(timezone.utc)
|
|
72
|
+
last_hb = ""
|
|
73
|
+
|
|
74
|
+
for hb_file in self._heartbeats_dir.iterdir():
|
|
75
|
+
if not hb_file.suffix == ".json":
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(hb_file.read_text(encoding="utf-8"))
|
|
79
|
+
ts_str = data.get("timestamp", "")
|
|
80
|
+
if ts_str:
|
|
81
|
+
last_hb = ts_str
|
|
82
|
+
ts = datetime.fromisoformat(ts_str)
|
|
83
|
+
age = (now - ts).total_seconds()
|
|
84
|
+
if age > self._stale_threshold:
|
|
85
|
+
frozen_ops.append(data.get("operation_id", hb_file.stem))
|
|
86
|
+
except (json.JSONDecodeError, Exception):
|
|
87
|
+
frozen_ops.append(hb_file.stem)
|
|
88
|
+
|
|
89
|
+
if frozen_ops:
|
|
90
|
+
return FreezeReport(
|
|
91
|
+
status=FreezeStatus.FROZEN,
|
|
92
|
+
frozen_operations=frozen_ops,
|
|
93
|
+
stale_threshold_seconds=self._stale_threshold,
|
|
94
|
+
last_heartbeat=last_hb,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return FreezeReport(
|
|
98
|
+
status=FreezeStatus.HEALTHY,
|
|
99
|
+
stale_threshold_seconds=self._stale_threshold,
|
|
100
|
+
last_heartbeat=last_hb,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def recover(self, operation_id: str) -> bool:
|
|
104
|
+
"""Attempt to recover a frozen operation.
|
|
105
|
+
|
|
106
|
+
Removes the heartbeat file and returns True if recovery was possible.
|
|
107
|
+
"""
|
|
108
|
+
hb_file = self._heartbeats_dir / f"{operation_id}.json"
|
|
109
|
+
if hb_file.exists():
|
|
110
|
+
hb_file.unlink()
|
|
111
|
+
return True
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def recover_all(self) -> list[str]:
|
|
115
|
+
"""Recover all frozen operations."""
|
|
116
|
+
recovered: list[str] = []
|
|
117
|
+
for hb_file in list(self._heartbeats_dir.iterdir()):
|
|
118
|
+
if hb_file.suffix == ".json":
|
|
119
|
+
recovered.append(hb_file.stem)
|
|
120
|
+
hb_file.unlink()
|
|
121
|
+
return recovered
|
|
122
|
+
|
|
123
|
+
def list_active_operations(self) -> list[dict[str, Any]]:
|
|
124
|
+
"""List all active (heartbeat-present) operations."""
|
|
125
|
+
ops: list[dict[str, Any]] = []
|
|
126
|
+
for hb_file in self._heartbeats_dir.iterdir():
|
|
127
|
+
if hb_file.suffix != ".json":
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
data = json.loads(hb_file.read_text(encoding="utf-8"))
|
|
131
|
+
ops.append(data)
|
|
132
|
+
except (json.JSONDecodeError, Exception):
|
|
133
|
+
ops.append({"operation_id": hb_file.stem, "timestamp": "unknown"})
|
|
134
|
+
return ops
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""SkillPool Maintenance Cron — Periodic maintenance tasks for registry health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from .freeze_detector import FreezeDetector
|
|
13
|
+
from .wal_manager import WALManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MaintenanceResult(BaseModel):
|
|
17
|
+
"""Result of a maintenance run."""
|
|
18
|
+
|
|
19
|
+
tasks_run: list[str] = []
|
|
20
|
+
wal_compacted: int = 0
|
|
21
|
+
frozen_recovered: list[str] = []
|
|
22
|
+
errors: list[str] = []
|
|
23
|
+
timestamp: str = ""
|
|
24
|
+
|
|
25
|
+
def __init__(self, **data: Any) -> None:
|
|
26
|
+
super().__init__(**data)
|
|
27
|
+
if not self.timestamp:
|
|
28
|
+
object.__setattr__(self, "timestamp", datetime.now(timezone.utc).isoformat())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MaintenanceCron:
|
|
32
|
+
"""Periodic maintenance tasks for SkillPool registry health.
|
|
33
|
+
|
|
34
|
+
Tasks:
|
|
35
|
+
- WAL compaction: remove committed WAL entries
|
|
36
|
+
- Freeze recovery: recover frozen operations
|
|
37
|
+
- State cleanup: remove stale temporary files
|
|
38
|
+
- Health report: generate system health summary
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, state_dir: Path, stale_threshold: int = 300) -> None:
|
|
42
|
+
self._state_dir = state_dir
|
|
43
|
+
self._wal_dir = state_dir / "wal"
|
|
44
|
+
self._wal_manager = WALManager(self._wal_dir)
|
|
45
|
+
self._freeze_detector = FreezeDetector(state_dir, stale_threshold=stale_threshold)
|
|
46
|
+
self._report_dir = state_dir / "maintenance_reports"
|
|
47
|
+
self._report_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
def run_all(self) -> MaintenanceResult:
|
|
50
|
+
"""Run all maintenance tasks."""
|
|
51
|
+
result = MaintenanceResult()
|
|
52
|
+
result.tasks_run.append("wal_compact")
|
|
53
|
+
try:
|
|
54
|
+
result.wal_compacted = self._wal_manager.compact()
|
|
55
|
+
except Exception as e:
|
|
56
|
+
result.errors.append(f"wal_compact: {e}")
|
|
57
|
+
|
|
58
|
+
result.tasks_run.append("freeze_check")
|
|
59
|
+
try:
|
|
60
|
+
report = self._freeze_detector.check()
|
|
61
|
+
if report.frozen_operations:
|
|
62
|
+
result.frozen_recovered = self._freeze_detector.recover_all()
|
|
63
|
+
result.tasks_run.append("freeze_recovery")
|
|
64
|
+
except Exception as e:
|
|
65
|
+
result.errors.append(f"freeze_check: {e}")
|
|
66
|
+
|
|
67
|
+
result.tasks_run.append("state_cleanup")
|
|
68
|
+
try:
|
|
69
|
+
self._cleanup_temp_files()
|
|
70
|
+
except Exception as e:
|
|
71
|
+
result.errors.append(f"state_cleanup: {e}")
|
|
72
|
+
|
|
73
|
+
self._save_report(result)
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def _cleanup_temp_files(self) -> int:
|
|
77
|
+
"""Remove stale .tmp files older than 1 hour."""
|
|
78
|
+
removed = 0
|
|
79
|
+
cutoff = datetime.now(timezone.utc).timestamp() - 3600
|
|
80
|
+
for tmp_file in self._state_dir.rglob("*.tmp"):
|
|
81
|
+
try:
|
|
82
|
+
if tmp_file.stat().st_mtime < cutoff:
|
|
83
|
+
tmp_file.unlink()
|
|
84
|
+
removed += 1
|
|
85
|
+
except OSError:
|
|
86
|
+
continue
|
|
87
|
+
return removed
|
|
88
|
+
|
|
89
|
+
def _save_report(self, result: MaintenanceResult) -> None:
|
|
90
|
+
"""Save maintenance report to disk."""
|
|
91
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
92
|
+
report_path = self._report_dir / f"maintenance_{ts}.json"
|
|
93
|
+
report_path.write_text(result.model_dump_json(), encoding="utf-8")
|
|
94
|
+
# Keep only last 10 reports
|
|
95
|
+
reports = sorted(self._report_dir.glob("maintenance_*.json"))
|
|
96
|
+
for old in reports[:-10]:
|
|
97
|
+
old.unlink()
|
|
98
|
+
|
|
99
|
+
def get_last_report(self) -> dict[str, Any] | None:
|
|
100
|
+
"""Get the most recent maintenance report."""
|
|
101
|
+
reports = sorted(self._report_dir.glob("maintenance_*.json"))
|
|
102
|
+
if not reports:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
return dict(json.loads(reports[-1].read_text(encoding="utf-8")))
|
|
106
|
+
except (json.JSONDecodeError, Exception):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def get_health_summary(self) -> dict[str, Any]:
|
|
110
|
+
"""Generate a health summary of the SkillPool system."""
|
|
111
|
+
wal_stats = self._wal_manager.get_stats()
|
|
112
|
+
freeze_report = self._freeze_detector.check()
|
|
113
|
+
last_report = self.get_last_report()
|
|
114
|
+
return {
|
|
115
|
+
"wal": wal_stats,
|
|
116
|
+
"freeze_status": freeze_report.status.value,
|
|
117
|
+
"frozen_operations": freeze_report.frozen_operations,
|
|
118
|
+
"last_maintenance": last_report,
|
|
119
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""SkillPool WAL Manager — Write-Ahead Log for atomic registry operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WALEntryType(str, Enum):
|
|
15
|
+
"""Types of WAL operations."""
|
|
16
|
+
|
|
17
|
+
REGISTER = "register"
|
|
18
|
+
UPDATE = "update"
|
|
19
|
+
DELETE = "delete"
|
|
20
|
+
CHECKPOINT = "checkpoint"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class WALEntry(BaseModel):
|
|
24
|
+
"""A single WAL entry."""
|
|
25
|
+
|
|
26
|
+
entry_type: WALEntryType
|
|
27
|
+
skill_name: str
|
|
28
|
+
data: dict[str, Any] = {}
|
|
29
|
+
timestamp: str = ""
|
|
30
|
+
txn_id: str = ""
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def _set_timestamp(self) -> WALEntry:
|
|
34
|
+
if not self.timestamp:
|
|
35
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WALManager:
|
|
40
|
+
"""Write-Ahead Log manager for atomic registry operations.
|
|
41
|
+
|
|
42
|
+
Ensures crash-recovery by logging all mutations before applying them.
|
|
43
|
+
On startup, replays any uncommitted entries to restore consistency.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, wal_dir: Path) -> None:
|
|
47
|
+
self._wal_dir = wal_dir
|
|
48
|
+
self._wal_file = wal_dir / "wal.jsonl"
|
|
49
|
+
self._checkpoint_file = wal_dir / "checkpoint.json"
|
|
50
|
+
self._wal_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._txn_counter = 0
|
|
52
|
+
|
|
53
|
+
def _next_txn_id(self) -> str:
|
|
54
|
+
self._txn_counter += 1
|
|
55
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
|
|
56
|
+
return f"txn-{ts}-{self._txn_counter:04d}"
|
|
57
|
+
|
|
58
|
+
def append(self, entry_type: WALEntryType, skill_name: str, data: dict[str, Any] | None = None) -> WALEntry:
|
|
59
|
+
"""Append a new WAL entry."""
|
|
60
|
+
entry = WALEntry(
|
|
61
|
+
entry_type=entry_type,
|
|
62
|
+
skill_name=skill_name,
|
|
63
|
+
data=data or {},
|
|
64
|
+
txn_id=self._next_txn_id(),
|
|
65
|
+
)
|
|
66
|
+
with open(self._wal_file, "a", encoding="utf-8") as f:
|
|
67
|
+
f.write(entry.model_dump_json() + "\n")
|
|
68
|
+
return entry
|
|
69
|
+
|
|
70
|
+
def read_uncommitted(self) -> list[WALEntry]:
|
|
71
|
+
"""Read WAL entries since last checkpoint."""
|
|
72
|
+
checkpoint_ts = self._load_checkpoint_timestamp()
|
|
73
|
+
entries: list[WALEntry] = []
|
|
74
|
+
if not self._wal_file.exists():
|
|
75
|
+
return entries
|
|
76
|
+
with open(self._wal_file, encoding="utf-8") as f:
|
|
77
|
+
for line in f:
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
entry = WALEntry(**json.loads(line))
|
|
83
|
+
if checkpoint_ts and entry.timestamp <= checkpoint_ts:
|
|
84
|
+
continue
|
|
85
|
+
entries.append(entry)
|
|
86
|
+
except (json.JSONDecodeError, Exception):
|
|
87
|
+
continue
|
|
88
|
+
return entries
|
|
89
|
+
|
|
90
|
+
def checkpoint(self) -> str:
|
|
91
|
+
"""Create a checkpoint marking all current WAL entries as committed."""
|
|
92
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
93
|
+
self._checkpoint_file.write_text(
|
|
94
|
+
json.dumps({"timestamp": ts, "entries": len(self.read_uncommitted())}),
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
)
|
|
97
|
+
self._wal_file.write_text("", encoding="utf-8")
|
|
98
|
+
return ts
|
|
99
|
+
|
|
100
|
+
def _load_checkpoint_timestamp(self) -> str | None:
|
|
101
|
+
"""Load the last checkpoint timestamp."""
|
|
102
|
+
if not self._checkpoint_file.exists():
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
data = json.loads(self._checkpoint_file.read_text(encoding="utf-8"))
|
|
106
|
+
return str(data.get("timestamp")) if data.get("timestamp") is not None else None
|
|
107
|
+
except (json.JSONDecodeError, Exception):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def recover(self) -> list[WALEntry]:
|
|
111
|
+
"""Recover uncommitted entries after a crash."""
|
|
112
|
+
return self.read_uncommitted()
|
|
113
|
+
|
|
114
|
+
def get_stats(self) -> dict[str, Any]:
|
|
115
|
+
"""Get WAL statistics."""
|
|
116
|
+
uncommitted = self.read_uncommitted()
|
|
117
|
+
checkpoint_ts = self._load_checkpoint_timestamp()
|
|
118
|
+
wal_size = self._wal_file.stat().st_size if self._wal_file.exists() else 0
|
|
119
|
+
return {
|
|
120
|
+
"uncommitted_count": len(uncommitted),
|
|
121
|
+
"last_checkpoint": checkpoint_ts,
|
|
122
|
+
"wal_file_size_bytes": wal_size,
|
|
123
|
+
"wal_file_path": str(self._wal_file),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
def compact(self) -> int:
|
|
127
|
+
"""Compact the WAL by removing committed entries."""
|
|
128
|
+
uncommitted = self.read_uncommitted()
|
|
129
|
+
if not self._wal_file.exists():
|
|
130
|
+
return 0
|
|
131
|
+
with open(self._wal_file, encoding="utf-8") as f:
|
|
132
|
+
total = sum(1 for line in f if line.strip())
|
|
133
|
+
with open(self._wal_file, "w", encoding="utf-8") as f:
|
|
134
|
+
for entry in uncommitted:
|
|
135
|
+
f.write(entry.model_dump_json() + "\n")
|
|
136
|
+
return total - len(uncommitted)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""ClawMem integration — dual-write for blind spots, upgrades, and audit events.
|
|
2
|
+
|
|
3
|
+
Provides ClawMemClient that wraps the ClawMem diary_write API (CLI or HTTP).
|
|
4
|
+
When ClawMem is unavailable, writes are silently skipped (graceful degradation).
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
client = ClawMemClient()
|
|
8
|
+
client.write_blindspot("D3 score below 7.0", dimension="D3", severity="P1")
|
|
9
|
+
client.write_upgrade("S09 MINOR upgrade", upgrade_type="MINOR")
|
|
10
|
+
client.write_audit("cost_record accepted", trace_id="abc123")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClawMemStatus(StrEnum):
|
|
25
|
+
AVAILABLE = "available"
|
|
26
|
+
UNAVAILABLE = "unavailable"
|
|
27
|
+
DEGRADED = "degraded"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ClawMemWriteResult:
|
|
32
|
+
"""Result of a ClawMem write attempt."""
|
|
33
|
+
|
|
34
|
+
success: bool
|
|
35
|
+
entry_id: str = ""
|
|
36
|
+
error: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ClawMemClient:
|
|
40
|
+
"""Client for ClawMem diary_write integration.
|
|
41
|
+
|
|
42
|
+
Supports two transport modes:
|
|
43
|
+
- CLI: calls `clawmem diary write` subprocess
|
|
44
|
+
- HTTP: calls ClawMem HTTP API on port 7438 (preferred when available)
|
|
45
|
+
|
|
46
|
+
When ClawMem is unavailable, writes are silently skipped.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
http_url: str = "http://127.0.0.1:7438",
|
|
52
|
+
use_http: bool = True,
|
|
53
|
+
timeout_seconds: float = 5.0,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._http_url = http_url
|
|
56
|
+
self._use_http = use_http
|
|
57
|
+
self._timeout = timeout_seconds
|
|
58
|
+
self._status = ClawMemStatus.AVAILABLE
|
|
59
|
+
self._pending_writes: list[dict] = []
|
|
60
|
+
|
|
61
|
+
def write_blindspot(
|
|
62
|
+
self,
|
|
63
|
+
description: str,
|
|
64
|
+
dimension: str = "",
|
|
65
|
+
severity: str = "P2",
|
|
66
|
+
skill_id: str = "",
|
|
67
|
+
) -> ClawMemWriteResult:
|
|
68
|
+
"""Write a blind spot to ClawMem with appropriate tags."""
|
|
69
|
+
tags = ["blindspot-report", severity.lower()]
|
|
70
|
+
if dimension:
|
|
71
|
+
tags.append(dimension)
|
|
72
|
+
if skill_id:
|
|
73
|
+
tags.append(skill_id)
|
|
74
|
+
return self._write(description, tags)
|
|
75
|
+
|
|
76
|
+
def write_upgrade(
|
|
77
|
+
self,
|
|
78
|
+
description: str,
|
|
79
|
+
upgrade_type: str = "PATCH",
|
|
80
|
+
skill_id: str = "",
|
|
81
|
+
) -> ClawMemWriteResult:
|
|
82
|
+
"""Write an upgrade event to ClawMem."""
|
|
83
|
+
tags = ["skill-upgrade", upgrade_type.lower()]
|
|
84
|
+
if skill_id:
|
|
85
|
+
tags.append(skill_id)
|
|
86
|
+
return self._write(description, tags)
|
|
87
|
+
|
|
88
|
+
def write_audit(
|
|
89
|
+
self,
|
|
90
|
+
description: str,
|
|
91
|
+
trace_id: str = "",
|
|
92
|
+
) -> ClawMemWriteResult:
|
|
93
|
+
"""Write an audit event to ClawMem."""
|
|
94
|
+
tags = ["audit-event"]
|
|
95
|
+
if trace_id:
|
|
96
|
+
tags.append(f"trace:{trace_id[:8]}")
|
|
97
|
+
return self._write(description, tags)
|
|
98
|
+
|
|
99
|
+
def get_status(self) -> ClawMemStatus:
|
|
100
|
+
"""Get current ClawMem availability status."""
|
|
101
|
+
return self._status
|
|
102
|
+
|
|
103
|
+
def get_pending_writes(self) -> list[dict]:
|
|
104
|
+
"""Get writes that failed and are pending retry."""
|
|
105
|
+
return list(self._pending_writes)
|
|
106
|
+
|
|
107
|
+
def _write(self, entry: str, tags: list[str]) -> ClawMemWriteResult:
|
|
108
|
+
"""Write an entry to ClawMem diary."""
|
|
109
|
+
if self._status == ClawMemStatus.UNAVAILABLE:
|
|
110
|
+
self._pending_writes.append({"entry": entry, "tags": tags})
|
|
111
|
+
return ClawMemWriteResult(success=False, error="ClawMem unavailable")
|
|
112
|
+
|
|
113
|
+
if self._use_http:
|
|
114
|
+
result = self._write_http(entry, tags)
|
|
115
|
+
else:
|
|
116
|
+
result = self._write_cli(entry, tags)
|
|
117
|
+
|
|
118
|
+
if not result.success:
|
|
119
|
+
self._pending_writes.append({"entry": entry, "tags": tags})
|
|
120
|
+
if self._status == ClawMemStatus.AVAILABLE:
|
|
121
|
+
self._status = ClawMemStatus.DEGRADED
|
|
122
|
+
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
def _write_http(self, entry: str, tags: list[str]) -> ClawMemWriteResult:
|
|
126
|
+
"""Write via ClawMem HTTP API."""
|
|
127
|
+
try:
|
|
128
|
+
import urllib.request
|
|
129
|
+
|
|
130
|
+
url = f"{self._http_url}/diary/write"
|
|
131
|
+
data = json.dumps({"entry": entry, "tags": tags}).encode()
|
|
132
|
+
req = urllib.request.Request(
|
|
133
|
+
url,
|
|
134
|
+
data=data,
|
|
135
|
+
headers={"Content-Type": "application/json"},
|
|
136
|
+
)
|
|
137
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
138
|
+
if resp.status == 200:
|
|
139
|
+
body = json.loads(resp.read())
|
|
140
|
+
return ClawMemWriteResult(
|
|
141
|
+
success=True,
|
|
142
|
+
entry_id=body.get("entry_id", ""),
|
|
143
|
+
)
|
|
144
|
+
return ClawMemWriteResult(success=False, error=f"HTTP {resp.status}")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
return ClawMemWriteResult(success=False, error=str(e))
|
|
147
|
+
|
|
148
|
+
def _write_cli(self, entry: str, tags: list[str]) -> ClawMemWriteResult:
|
|
149
|
+
"""Write via ClawMem CLI subprocess."""
|
|
150
|
+
try:
|
|
151
|
+
cmd = ["clawmem", "diary", "write", entry]
|
|
152
|
+
for tag in tags:
|
|
153
|
+
cmd.extend(["-t", tag])
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
cmd,
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
timeout=self._timeout,
|
|
159
|
+
)
|
|
160
|
+
if result.returncode == 0:
|
|
161
|
+
return ClawMemWriteResult(success=True, entry_id=result.stdout.strip())
|
|
162
|
+
return ClawMemWriteResult(
|
|
163
|
+
success=False,
|
|
164
|
+
error=f"CLI exit {result.returncode}: {result.stderr.strip()}",
|
|
165
|
+
)
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
self._status = ClawMemStatus.UNAVAILABLE
|
|
168
|
+
return ClawMemWriteResult(success=False, error="clawmem CLI not found")
|
|
169
|
+
except subprocess.TimeoutExpired:
|
|
170
|
+
return ClawMemWriteResult(success=False, error="CLI timeout")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
return ClawMemWriteResult(success=False, error=str(e))
|
|
173
|
+
|
|
174
|
+
def mark_available(self) -> None:
|
|
175
|
+
"""Mark ClawMem as available (e.g., after recovery)."""
|
|
176
|
+
self._status = ClawMemStatus.AVAILABLE
|