cfa-kernel 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.
- cfa/__init__.py +39 -0
- cfa/_lazy.py +39 -0
- cfa/adapters/__init__.py +104 -0
- cfa/adapters/autogen.py +19 -0
- cfa/adapters/crewai.py +19 -0
- cfa/adapters/dspy.py +19 -0
- cfa/adapters/langgraph.py +19 -0
- cfa/adapters/openai_agents.py +19 -0
- cfa/audit/__init__.py +15 -0
- cfa/audit/context.py +205 -0
- cfa/audit/hashing.py +41 -0
- cfa/audit/trail.py +194 -0
- cfa/backends/__init__.py +132 -0
- cfa/backends/dbt.py +338 -0
- cfa/backends/pyspark.py +240 -0
- cfa/backends/sql.py +270 -0
- cfa/behavior/__init__.py +49 -0
- cfa/behavior/llm.py +244 -0
- cfa/behavior/spec.py +235 -0
- cfa/behavior/systematizer.py +222 -0
- cfa/cli/__init__.py +296 -0
- cfa/cli/__main__.py +6 -0
- cfa/cli/_helpers.py +109 -0
- cfa/cli/core/__init__.py +0 -0
- cfa/cli/core/evaluate.py +72 -0
- cfa/cli/core/validate.py +29 -0
- cfa/cli/formatters.py +280 -0
- cfa/cli/governance/__init__.py +0 -0
- cfa/cli/governance/audit.py +65 -0
- cfa/cli/governance/catalog.py +28 -0
- cfa/cli/governance/policy.py +119 -0
- cfa/cli/governance/rules.py +42 -0
- cfa/cli/governance/signature.py +31 -0
- cfa/cli/infrastructure/__init__.py +0 -0
- cfa/cli/infrastructure/backend_list.py +24 -0
- cfa/cli/infrastructure/storage.py +87 -0
- cfa/cli/project/__init__.py +0 -0
- cfa/cli/project/init.py +73 -0
- cfa/cli/project/lifecycle.py +92 -0
- cfa/cli/project/status.py +75 -0
- cfa/cli/project/taxonomy.py +38 -0
- cfa/cli/reporting/__init__.py +0 -0
- cfa/cli/reporting/report.py +109 -0
- cfa/cli/reporting/serve.py +43 -0
- cfa/config.py +103 -0
- cfa/core/__init__.py +19 -0
- cfa/core/codegen.py +65 -0
- cfa/core/conditions.py +129 -0
- cfa/core/kernel.py +224 -0
- cfa/core/phases/__init__.py +0 -0
- cfa/core/phases/runner.py +477 -0
- cfa/core/planner.py +290 -0
- cfa/execution/__init__.py +12 -0
- cfa/execution/partial.py +339 -0
- cfa/execution/state_projection.py +216 -0
- cfa/governance/__init__.py +76 -0
- cfa/lifecycle/__init__.py +51 -0
- cfa/mcp/__init__.py +347 -0
- cfa/mcp/__main__.py +4 -0
- cfa/normalizer/__init__.py +15 -0
- cfa/normalizer/base.py +441 -0
- cfa/normalizer/llm.py +426 -0
- cfa/observability/__init__.py +14 -0
- cfa/observability/indices.py +177 -0
- cfa/observability/metrics.py +91 -0
- cfa/observability/notify.py +79 -0
- cfa/observability/otel.py +81 -0
- cfa/observability/promotion.py +367 -0
- cfa/policy/__init__.py +12 -0
- cfa/policy/bundle.py +317 -0
- cfa/policy/catalog.py +117 -0
- cfa/policy/engine.py +306 -0
- cfa/reporting/__init__.py +42 -0
- cfa/reporting/charts.py +223 -0
- cfa/reporting/engine.py +456 -0
- cfa/resolution/__init__.py +62 -0
- cfa/runtime/__init__.py +13 -0
- cfa/runtime/gate.py +287 -0
- cfa/sandbox/__init__.py +189 -0
- cfa/sandbox/executor.py +92 -0
- cfa/sandbox/mock.py +89 -0
- cfa/sandbox/panic.py +52 -0
- cfa/storage/__init__.py +591 -0
- cfa/testing/__init__.py +60 -0
- cfa/testing/asserts.py +77 -0
- cfa/testing/evaluate.py +168 -0
- cfa/testing/fixtures.py +89 -0
- cfa/testing/markers.py +36 -0
- cfa/types.py +489 -0
- cfa/validation/__init__.py +14 -0
- cfa/validation/runtime.py +285 -0
- cfa/validation/signature.py +146 -0
- cfa/validation/static.py +252 -0
- cfa_kernel-0.1.0.dist-info/METADATA +32 -0
- cfa_kernel-0.1.0.dist-info/RECORD +98 -0
- cfa_kernel-0.1.0.dist-info/WHEEL +4 -0
- cfa_kernel-0.1.0.dist-info/entry_points.txt +3 -0
- cfa_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
cfa/sandbox/panic.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Panic sandbox backend — simulates environmental faults for resilience testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from cfa.core.planner import ExecutionStep
|
|
8
|
+
from cfa.types import Fault, FaultFamily, FaultSeverity, PolicyAction
|
|
9
|
+
|
|
10
|
+
from . import SandboxCapabilities, StepOutcome, StepResult
|
|
11
|
+
from .mock import MockSandboxBackend
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PanicSandboxBackend(MockSandboxBackend):
|
|
15
|
+
"""Backend that simulates an environmental fault mid-execution."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, panic_on_step: str, panic_reason: str = "cluster_node_lost", **kwargs: Any):
|
|
18
|
+
super().__init__(**kwargs)
|
|
19
|
+
self._panic_step = panic_on_step
|
|
20
|
+
self._panic_reason = panic_reason
|
|
21
|
+
|
|
22
|
+
def get_capabilities(self) -> SandboxCapabilities:
|
|
23
|
+
return SandboxCapabilities(
|
|
24
|
+
backend_name="panic",
|
|
25
|
+
backend_version="panic-1.0",
|
|
26
|
+
execution_mode="simulation",
|
|
27
|
+
supports_rollback=True,
|
|
28
|
+
supports_metrics=True,
|
|
29
|
+
supports_environment_check=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def execute_step(
|
|
33
|
+
self, step: ExecutionStep, code: str, context: dict[str, Any]
|
|
34
|
+
) -> StepResult:
|
|
35
|
+
if step.id == self._panic_step:
|
|
36
|
+
return StepResult(
|
|
37
|
+
step_id=step.id,
|
|
38
|
+
outcome=StepOutcome.INTERRUPTED,
|
|
39
|
+
error=f"Environmental fault: {self._panic_reason}",
|
|
40
|
+
faults=[
|
|
41
|
+
Fault(
|
|
42
|
+
code="ENVIRONMENTAL_PANIC",
|
|
43
|
+
family=FaultFamily.ENVIRONMENT,
|
|
44
|
+
severity=FaultSeverity.CRITICAL,
|
|
45
|
+
stage="sandbox",
|
|
46
|
+
message=f"Environmental fault during {step.id}: {self._panic_reason}",
|
|
47
|
+
mandatory_action=PolicyAction.BLOCK,
|
|
48
|
+
detected_before_execution=False,
|
|
49
|
+
)
|
|
50
|
+
],
|
|
51
|
+
)
|
|
52
|
+
return super().execute_step(step, code, context)
|
cfa/storage/__init__.py
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CFA Storage
|
|
3
|
+
===========
|
|
4
|
+
Portable storage for all CFA governance data.
|
|
5
|
+
|
|
6
|
+
Backends:
|
|
7
|
+
SqliteStorage — SQLite (stdlib), recommended for production
|
|
8
|
+
JsonLinesStorage — JSONL files, zero-dependency alternative
|
|
9
|
+
|
|
10
|
+
Both backends share the same management interface: stats(), cleanup(), vacuum().
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from cfa.audit.context import ContextStorageBackend
|
|
24
|
+
from cfa.audit.trail import AuditEvent, AuditStorageBackend
|
|
25
|
+
|
|
26
|
+
SCHEMA_VERSION = 1
|
|
27
|
+
|
|
28
|
+
_DDL = """
|
|
29
|
+
CREATE TABLE IF NOT EXISTS _schema (
|
|
30
|
+
version INTEGER PRIMARY KEY,
|
|
31
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
intent_id TEXT NOT NULL,
|
|
37
|
+
stage TEXT NOT NULL,
|
|
38
|
+
event_type TEXT NOT NULL,
|
|
39
|
+
outcome TEXT NOT NULL,
|
|
40
|
+
policy_bundle_version TEXT NOT NULL DEFAULT '',
|
|
41
|
+
details_json TEXT NOT NULL DEFAULT '{}',
|
|
42
|
+
timestamp TEXT NOT NULL,
|
|
43
|
+
event_hash TEXT NOT NULL DEFAULT '',
|
|
44
|
+
previous_hash TEXT NOT NULL DEFAULT ''
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_audit_intent ON audit_events(intent_id);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_events(timestamp);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS execution_records (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
signature_hash TEXT NOT NULL,
|
|
53
|
+
timestamp TEXT NOT NULL,
|
|
54
|
+
success INTEGER NOT NULL DEFAULT 1,
|
|
55
|
+
replanned INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
cost_dbu REAL NOT NULL DEFAULT 0.0,
|
|
57
|
+
duration_seconds REAL NOT NULL DEFAULT 0.0,
|
|
58
|
+
faults_json TEXT NOT NULL DEFAULT '[]',
|
|
59
|
+
schema_match INTEGER NOT NULL DEFAULT 1,
|
|
60
|
+
pii_exposure INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
policy_compliant INTEGER NOT NULL DEFAULT 1,
|
|
62
|
+
layer_adherent INTEGER NOT NULL DEFAULT 1,
|
|
63
|
+
max_expected_duration REAL NOT NULL DEFAULT 300.0,
|
|
64
|
+
max_expected_cost REAL NOT NULL DEFAULT 50.0
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_exec_sig_hash ON execution_records(signature_hash);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_exec_timestamp ON execution_records(timestamp);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS skill_records (
|
|
71
|
+
signature_hash TEXT PRIMARY KEY,
|
|
72
|
+
state TEXT NOT NULL DEFAULT 'candidate',
|
|
73
|
+
generation_metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
74
|
+
last_evaluation TEXT,
|
|
75
|
+
demotion_reason TEXT NOT NULL DEFAULT '',
|
|
76
|
+
consecutive_inactive_windows INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
history_json TEXT NOT NULL DEFAULT '[]'
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE TABLE IF NOT EXISTS context_state (
|
|
81
|
+
key TEXT PRIMARY KEY,
|
|
82
|
+
value_json TEXT NOT NULL DEFAULT '{}',
|
|
83
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE IF NOT EXISTS metrics (
|
|
87
|
+
metric_key TEXT PRIMARY KEY,
|
|
88
|
+
metric_type TEXT NOT NULL DEFAULT 'counter',
|
|
89
|
+
value REAL NOT NULL DEFAULT 0.0,
|
|
90
|
+
labels_json TEXT NOT NULL DEFAULT '{}',
|
|
91
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
92
|
+
);
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SqliteStorage:
|
|
97
|
+
"""Unified SQLite storage for all CFA governance data.
|
|
98
|
+
|
|
99
|
+
Usage::
|
|
100
|
+
|
|
101
|
+
store = SqliteStorage("cfa.db")
|
|
102
|
+
store.ensure_schema()
|
|
103
|
+
|
|
104
|
+
# Audit
|
|
105
|
+
store.audit_append(event)
|
|
106
|
+
events = store.audit_load_all()
|
|
107
|
+
|
|
108
|
+
# Execution records (lifecycle)
|
|
109
|
+
store.execution_append(record)
|
|
110
|
+
records = store.execution_load_by_hash(signature_hash)
|
|
111
|
+
|
|
112
|
+
# Skills
|
|
113
|
+
store.skill_upsert(signature_hash, skill_data)
|
|
114
|
+
skills = store.skill_load_all()
|
|
115
|
+
|
|
116
|
+
# Context
|
|
117
|
+
store.context_save(state_dict)
|
|
118
|
+
state = store.context_load()
|
|
119
|
+
|
|
120
|
+
# Metrics
|
|
121
|
+
store.metric_upsert("cfa_policy_evaluations_total", 1, {"decision": "approved"})
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
125
|
+
self._path = Path(db_path)
|
|
126
|
+
self._local = threading.local()
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def _conn(self) -> sqlite3.Connection:
|
|
130
|
+
if not hasattr(self._local, "conn") or self._local.conn is None:
|
|
131
|
+
self._local.conn = sqlite3.connect(str(self._path))
|
|
132
|
+
self._local.conn.execute("PRAGMA journal_mode=WAL")
|
|
133
|
+
self._local.conn.execute("PRAGMA foreign_keys=ON")
|
|
134
|
+
self._local.conn.row_factory = sqlite3.Row
|
|
135
|
+
return self._local.conn
|
|
136
|
+
|
|
137
|
+
# ── Schema ────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def ensure_schema(self) -> None:
|
|
140
|
+
conn = self._conn
|
|
141
|
+
conn.executescript(_DDL)
|
|
142
|
+
current = conn.execute(
|
|
143
|
+
"SELECT MAX(version) FROM _schema"
|
|
144
|
+
).fetchone()[0] or 0
|
|
145
|
+
if current < SCHEMA_VERSION:
|
|
146
|
+
conn.execute(
|
|
147
|
+
"INSERT OR REPLACE INTO _schema (version) VALUES (?)",
|
|
148
|
+
(SCHEMA_VERSION,),
|
|
149
|
+
)
|
|
150
|
+
conn.commit()
|
|
151
|
+
|
|
152
|
+
# ── Audit ─────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def audit_append(self, event: AuditEvent) -> None:
|
|
155
|
+
conn = self._conn
|
|
156
|
+
conn.execute(
|
|
157
|
+
"""INSERT INTO audit_events
|
|
158
|
+
(intent_id, stage, event_type, outcome, policy_bundle_version,
|
|
159
|
+
details_json, timestamp, event_hash, previous_hash)
|
|
160
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
161
|
+
(
|
|
162
|
+
event.intent_id, event.stage, event.event_type, event.outcome,
|
|
163
|
+
event.policy_bundle_version,
|
|
164
|
+
json.dumps(event.details, default=str),
|
|
165
|
+
event.timestamp, event.event_hash, event.previous_hash,
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
conn.commit()
|
|
169
|
+
|
|
170
|
+
def audit_load_all(self) -> list[AuditEvent]:
|
|
171
|
+
rows = self._conn.execute(
|
|
172
|
+
"SELECT * FROM audit_events ORDER BY id"
|
|
173
|
+
).fetchall()
|
|
174
|
+
return [_row_to_audit_event(r) for r in rows]
|
|
175
|
+
|
|
176
|
+
def audit_load_by_intent(self, intent_id: str) -> list[AuditEvent]:
|
|
177
|
+
rows = self._conn.execute(
|
|
178
|
+
"SELECT * FROM audit_events WHERE intent_id = ? ORDER BY id",
|
|
179
|
+
(intent_id,),
|
|
180
|
+
).fetchall()
|
|
181
|
+
return [_row_to_audit_event(r) for r in rows]
|
|
182
|
+
|
|
183
|
+
def audit_count(self) -> int:
|
|
184
|
+
return self._conn.execute(
|
|
185
|
+
"SELECT COUNT(*) FROM audit_events"
|
|
186
|
+
).fetchone()[0]
|
|
187
|
+
|
|
188
|
+
# ── Execution Records (Lifecycle) ─────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def execution_append(self, record_dict: dict[str, Any]) -> None:
|
|
191
|
+
conn = self._conn
|
|
192
|
+
conn.execute(
|
|
193
|
+
"""INSERT INTO execution_records
|
|
194
|
+
(signature_hash, timestamp, success, replanned, cost_dbu,
|
|
195
|
+
duration_seconds, faults_json, schema_match, pii_exposure,
|
|
196
|
+
policy_compliant, layer_adherent, max_expected_duration,
|
|
197
|
+
max_expected_cost)
|
|
198
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
199
|
+
(
|
|
200
|
+
record_dict.get("signature_hash", ""),
|
|
201
|
+
record_dict.get("timestamp", ""),
|
|
202
|
+
int(record_dict.get("success", True)),
|
|
203
|
+
int(record_dict.get("replanned", False)),
|
|
204
|
+
float(record_dict.get("cost_dbu", 0.0)),
|
|
205
|
+
float(record_dict.get("duration_seconds", 0.0)),
|
|
206
|
+
json.dumps(record_dict.get("faults", [])),
|
|
207
|
+
int(record_dict.get("schema_match", True)),
|
|
208
|
+
int(record_dict.get("pii_exposure", False)),
|
|
209
|
+
int(record_dict.get("policy_compliant", True)),
|
|
210
|
+
int(record_dict.get("layer_adherent", True)),
|
|
211
|
+
float(record_dict.get("max_expected_duration", 300.0)),
|
|
212
|
+
float(record_dict.get("max_expected_cost", 50.0)),
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
conn.commit()
|
|
216
|
+
|
|
217
|
+
def execution_load_all(self) -> list[dict[str, Any]]:
|
|
218
|
+
rows = self._conn.execute(
|
|
219
|
+
"SELECT * FROM execution_records ORDER BY id"
|
|
220
|
+
).fetchall()
|
|
221
|
+
return [_row_to_exec_dict(r) for r in rows]
|
|
222
|
+
|
|
223
|
+
def execution_load_by_hash(self, signature_hash: str) -> list[dict[str, Any]]:
|
|
224
|
+
rows = self._conn.execute(
|
|
225
|
+
"SELECT * FROM execution_records WHERE signature_hash = ? ORDER BY id",
|
|
226
|
+
(signature_hash,),
|
|
227
|
+
).fetchall()
|
|
228
|
+
return [_row_to_exec_dict(r) for r in rows]
|
|
229
|
+
|
|
230
|
+
def execution_count_by_outcome(self) -> dict[str, int]:
|
|
231
|
+
rows = self._conn.execute(
|
|
232
|
+
"SELECT outcome, COUNT(*) as cnt FROM audit_events WHERE event_type='policy_evaluation' GROUP BY outcome"
|
|
233
|
+
).fetchall()
|
|
234
|
+
return {r["outcome"]: r["cnt"] for r in rows}
|
|
235
|
+
|
|
236
|
+
# ── Skills ────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def skill_upsert(self, signature_hash: str, skill_data: dict[str, Any]) -> None:
|
|
239
|
+
conn = self._conn
|
|
240
|
+
conn.execute(
|
|
241
|
+
"""INSERT OR REPLACE INTO skill_records
|
|
242
|
+
(signature_hash, state, generation_metadata_json,
|
|
243
|
+
last_evaluation, demotion_reason, consecutive_inactive_windows, history_json)
|
|
244
|
+
VALUES (?,?,?,?,?,?,?)""",
|
|
245
|
+
(
|
|
246
|
+
signature_hash,
|
|
247
|
+
skill_data.get("state", "candidate"),
|
|
248
|
+
json.dumps(skill_data.get("generation_metadata", {})),
|
|
249
|
+
skill_data.get("last_evaluation", ""),
|
|
250
|
+
skill_data.get("demotion_reason", ""),
|
|
251
|
+
skill_data.get("consecutive_inactive_windows", 0),
|
|
252
|
+
json.dumps(skill_data.get("history", [])),
|
|
253
|
+
),
|
|
254
|
+
)
|
|
255
|
+
conn.commit()
|
|
256
|
+
|
|
257
|
+
def skill_load_all(self) -> list[dict[str, Any]]:
|
|
258
|
+
rows = self._conn.execute(
|
|
259
|
+
"SELECT * FROM skill_records ORDER BY signature_hash"
|
|
260
|
+
).fetchall()
|
|
261
|
+
return [_row_to_skill_dict(r) for r in rows]
|
|
262
|
+
|
|
263
|
+
def skill_load(self, signature_hash: str) -> dict[str, Any] | None:
|
|
264
|
+
row = self._conn.execute(
|
|
265
|
+
"SELECT * FROM skill_records WHERE signature_hash = ?",
|
|
266
|
+
(signature_hash,),
|
|
267
|
+
).fetchone()
|
|
268
|
+
return _row_to_skill_dict(row) if row else None
|
|
269
|
+
|
|
270
|
+
# ── Context ───────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
def context_save(self, state: dict[str, Any]) -> None:
|
|
273
|
+
conn = self._conn
|
|
274
|
+
for key, value in state.items():
|
|
275
|
+
conn.execute(
|
|
276
|
+
"""INSERT OR REPLACE INTO context_state (key, value_json)
|
|
277
|
+
VALUES (?,?)""",
|
|
278
|
+
(key, json.dumps(value, default=str)),
|
|
279
|
+
)
|
|
280
|
+
conn.commit()
|
|
281
|
+
|
|
282
|
+
def context_load(self) -> dict[str, Any]:
|
|
283
|
+
rows = self._conn.execute(
|
|
284
|
+
"SELECT key, value_json FROM context_state"
|
|
285
|
+
).fetchall()
|
|
286
|
+
result: dict[str, Any] = {}
|
|
287
|
+
for r in rows:
|
|
288
|
+
try:
|
|
289
|
+
result[r["key"]] = json.loads(r["value_json"])
|
|
290
|
+
except (json.JSONDecodeError, TypeError):
|
|
291
|
+
result[r["key"]] = r["value_json"]
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
def context_save_snapshot(self, version_id: str, state: dict[str, Any]) -> None:
|
|
295
|
+
self._conn.execute(
|
|
296
|
+
"""INSERT OR REPLACE INTO context_state (key, value_json)
|
|
297
|
+
VALUES (?,?)""",
|
|
298
|
+
(f"_snapshot_{version_id}", json.dumps(state, default=str)),
|
|
299
|
+
)
|
|
300
|
+
self._conn.commit()
|
|
301
|
+
|
|
302
|
+
def context_load_snapshot(self, version_id: str) -> dict[str, Any] | None:
|
|
303
|
+
row = self._conn.execute(
|
|
304
|
+
"SELECT value_json FROM context_state WHERE key = ?",
|
|
305
|
+
(f"_snapshot_{version_id}",),
|
|
306
|
+
).fetchone()
|
|
307
|
+
if row:
|
|
308
|
+
try:
|
|
309
|
+
return json.loads(row["value_json"])
|
|
310
|
+
except (json.JSONDecodeError, TypeError):
|
|
311
|
+
pass
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
def context_list_snapshots(self) -> list[str]:
|
|
315
|
+
rows = self._conn.execute(
|
|
316
|
+
"SELECT key FROM context_state WHERE key LIKE '_snapshot_%'"
|
|
317
|
+
).fetchall()
|
|
318
|
+
return [r["key"].replace("_snapshot_", "") for r in rows]
|
|
319
|
+
|
|
320
|
+
# ── Metrics ───────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
def metric_upsert(
|
|
323
|
+
self, key: str, delta: float = 0.0, metric_type: str = "counter",
|
|
324
|
+
labels: dict[str, str] | None = None,
|
|
325
|
+
) -> None:
|
|
326
|
+
conn = self._conn
|
|
327
|
+
labels_json = json.dumps(labels or {})
|
|
328
|
+
existing = conn.execute(
|
|
329
|
+
"SELECT value FROM metrics WHERE metric_key = ?", (key,)
|
|
330
|
+
).fetchone()
|
|
331
|
+
if existing:
|
|
332
|
+
conn.execute(
|
|
333
|
+
"UPDATE metrics SET value = value + ?, updated_at = datetime('now') WHERE metric_key = ?",
|
|
334
|
+
(delta, key),
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
conn.execute(
|
|
338
|
+
"""INSERT INTO metrics (metric_key, metric_type, value, labels_json)
|
|
339
|
+
VALUES (?,?,?,?)""",
|
|
340
|
+
(key, metric_type, delta, labels_json),
|
|
341
|
+
)
|
|
342
|
+
conn.commit()
|
|
343
|
+
|
|
344
|
+
def metric_get_all(self) -> dict[str, dict[str, Any]]:
|
|
345
|
+
rows = self._conn.execute("SELECT * FROM metrics").fetchall()
|
|
346
|
+
result: dict[str, dict[str, Any]] = {}
|
|
347
|
+
for r in rows:
|
|
348
|
+
result[r["metric_key"]] = {
|
|
349
|
+
"type": r["metric_type"],
|
|
350
|
+
"value": r["value"],
|
|
351
|
+
"labels": json.loads(r["labels_json"]) if r["labels_json"] else {},
|
|
352
|
+
}
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
# ── Maintenance ───────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
def close(self) -> None:
|
|
358
|
+
if hasattr(self._local, "conn") and self._local.conn:
|
|
359
|
+
try:
|
|
360
|
+
self._local.conn.execute("PRAGMA journal_mode=DELETE")
|
|
361
|
+
self._local.conn.commit()
|
|
362
|
+
except Exception:
|
|
363
|
+
pass
|
|
364
|
+
self._local.conn.close()
|
|
365
|
+
self._local.conn = None
|
|
366
|
+
|
|
367
|
+
def vacuum(self) -> None:
|
|
368
|
+
self._conn.execute("VACUUM")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── SQLite-backed storage adapters ──────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class SqliteAuditStorage(AuditStorageBackend):
|
|
375
|
+
"""AuditStorageBackend backed by SqliteStorage."""
|
|
376
|
+
|
|
377
|
+
def __init__(self, store: SqliteStorage) -> None:
|
|
378
|
+
self._store = store
|
|
379
|
+
|
|
380
|
+
def append(self, event: AuditEvent) -> None:
|
|
381
|
+
self._store.audit_append(event)
|
|
382
|
+
|
|
383
|
+
def load_all(self) -> list[AuditEvent]:
|
|
384
|
+
return self._store.audit_load_all()
|
|
385
|
+
|
|
386
|
+
def load_by_intent(self, intent_id: str) -> list[AuditEvent]:
|
|
387
|
+
return self._store.audit_load_by_intent(intent_id)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class SqliteContextStorage(ContextStorageBackend):
|
|
391
|
+
"""ContextStorageBackend backed by SqliteStorage."""
|
|
392
|
+
|
|
393
|
+
def __init__(self, store: SqliteStorage) -> None:
|
|
394
|
+
self._store = store
|
|
395
|
+
|
|
396
|
+
def load(self) -> dict[str, Any]:
|
|
397
|
+
return self._store.context_load()
|
|
398
|
+
|
|
399
|
+
def save(self, state: dict[str, Any]) -> None:
|
|
400
|
+
self._store.context_save(state)
|
|
401
|
+
|
|
402
|
+
def save_snapshot(self, version_id: str, state: dict[str, Any]) -> None:
|
|
403
|
+
self._store.context_save_snapshot(version_id, state)
|
|
404
|
+
|
|
405
|
+
def load_snapshot(self, version_id: str) -> dict[str, Any] | None:
|
|
406
|
+
return self._store.context_load_snapshot(version_id)
|
|
407
|
+
|
|
408
|
+
def list_snapshots(self) -> list[str]:
|
|
409
|
+
return self._store.context_list_snapshots()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ── Helpers ─────────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _row_to_audit_event(row: sqlite3.Row) -> AuditEvent:
|
|
416
|
+
return AuditEvent(
|
|
417
|
+
intent_id=row["intent_id"],
|
|
418
|
+
stage=row["stage"],
|
|
419
|
+
event_type=row["event_type"],
|
|
420
|
+
outcome=row["outcome"],
|
|
421
|
+
policy_bundle_version=row["policy_bundle_version"],
|
|
422
|
+
details=json.loads(row["details_json"]) if row["details_json"] else {},
|
|
423
|
+
timestamp=row["timestamp"],
|
|
424
|
+
event_hash=row["event_hash"],
|
|
425
|
+
previous_hash=row["previous_hash"],
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _row_to_exec_dict(row: sqlite3.Row) -> dict[str, Any]:
|
|
430
|
+
return {
|
|
431
|
+
"signature_hash": row["signature_hash"],
|
|
432
|
+
"timestamp": row["timestamp"],
|
|
433
|
+
"success": bool(row["success"]),
|
|
434
|
+
"replanned": bool(row["replanned"]),
|
|
435
|
+
"cost_dbu": row["cost_dbu"],
|
|
436
|
+
"duration_seconds": row["duration_seconds"],
|
|
437
|
+
"faults": json.loads(row["faults_json"]) if row["faults_json"] else [],
|
|
438
|
+
"schema_match": bool(row["schema_match"]),
|
|
439
|
+
"pii_exposure": bool(row["pii_exposure"]),
|
|
440
|
+
"policy_compliant": bool(row["policy_compliant"]),
|
|
441
|
+
"layer_adherent": bool(row["layer_adherent"]),
|
|
442
|
+
"max_expected_duration": row["max_expected_duration"],
|
|
443
|
+
"max_expected_cost": row["max_expected_cost"],
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _row_to_skill_dict(row: sqlite3.Row) -> dict[str, Any]:
|
|
448
|
+
return {
|
|
449
|
+
"signature_hash": row["signature_hash"],
|
|
450
|
+
"state": row["state"],
|
|
451
|
+
"generation_metadata": json.loads(row["generation_metadata_json"]) if row["generation_metadata_json"] else {},
|
|
452
|
+
"last_evaluation": row["last_evaluation"],
|
|
453
|
+
"demotion_reason": row["demotion_reason"],
|
|
454
|
+
"consecutive_inactive_windows": row["consecutive_inactive_windows"],
|
|
455
|
+
"history": json.loads(row["history_json"]) if row["history_json"] else [],
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ── Storage Stats ───────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@dataclass
|
|
463
|
+
class StorageStats:
|
|
464
|
+
backend: str = ""
|
|
465
|
+
path: str = ""
|
|
466
|
+
file_size_bytes: int = 0
|
|
467
|
+
audit_events_count: int = 0
|
|
468
|
+
execution_records_count: int = 0
|
|
469
|
+
skill_records_count: int = 0
|
|
470
|
+
metrics_count: int = 0
|
|
471
|
+
oldest_record: str = ""
|
|
472
|
+
newest_record: str = ""
|
|
473
|
+
|
|
474
|
+
def to_dict(self) -> dict[str, Any]:
|
|
475
|
+
return {
|
|
476
|
+
"backend": self.backend,
|
|
477
|
+
"path": self.path,
|
|
478
|
+
"file_size_bytes": self.file_size_bytes,
|
|
479
|
+
"audit_events_count": self.audit_events_count,
|
|
480
|
+
"execution_records_count": self.execution_records_count,
|
|
481
|
+
"skill_records_count": self.skill_records_count,
|
|
482
|
+
"metrics_count": self.metrics_count,
|
|
483
|
+
"oldest_record": self.oldest_record,
|
|
484
|
+
"newest_record": self.newest_record,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ── SqliteStorage management methods ────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _sqlite_storage_stats(store: SqliteStorage) -> StorageStats:
|
|
492
|
+
conn = store._conn
|
|
493
|
+
stats = StorageStats(backend="sqlite", path=str(store._path))
|
|
494
|
+
if store._path.exists():
|
|
495
|
+
stats.file_size_bytes = store._path.stat().st_size
|
|
496
|
+
stats.audit_events_count = conn.execute("SELECT COUNT(*) FROM audit_events").fetchone()[0]
|
|
497
|
+
stats.execution_records_count = conn.execute("SELECT COUNT(*) FROM execution_records").fetchone()[0]
|
|
498
|
+
stats.skill_records_count = conn.execute("SELECT COUNT(*) FROM skill_records").fetchone()[0]
|
|
499
|
+
stats.metrics_count = conn.execute("SELECT COUNT(*) FROM metrics").fetchone()[0]
|
|
500
|
+
oldest = conn.execute("SELECT MIN(timestamp) FROM audit_events").fetchone()[0]
|
|
501
|
+
newest = conn.execute("SELECT MAX(timestamp) FROM audit_events").fetchone()[0]
|
|
502
|
+
stats.oldest_record = oldest or ""
|
|
503
|
+
stats.newest_record = newest or ""
|
|
504
|
+
return stats
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _sqlite_storage_cleanup(store: SqliteStorage, before: str) -> int:
|
|
508
|
+
conn = store._conn
|
|
509
|
+
total = 0
|
|
510
|
+
for table in ("audit_events", "execution_records"):
|
|
511
|
+
assert table in ("audit_events", "execution_records") # whitelist
|
|
512
|
+
result = conn.execute(f"DELETE FROM {table} WHERE timestamp < ?", (before,))
|
|
513
|
+
total += result.rowcount
|
|
514
|
+
if total > 0:
|
|
515
|
+
conn.commit()
|
|
516
|
+
return total
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ── JsonLines Storage ───────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class JsonLinesStorage:
|
|
523
|
+
"""JSONL-file storage with the same management interface as SqliteStorage.
|
|
524
|
+
|
|
525
|
+
Each domain writes to a separate .jsonl file inside a directory.
|
|
526
|
+
No schema migrations needed — the format is append-only JSON lines.
|
|
527
|
+
|
|
528
|
+
This is the zero-dependency alternative to SQLite.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
def __init__(self, directory: str | Path) -> None:
|
|
532
|
+
self._dir = Path(directory)
|
|
533
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
534
|
+
|
|
535
|
+
@property
|
|
536
|
+
def path(self) -> Path:
|
|
537
|
+
return self._dir
|
|
538
|
+
|
|
539
|
+
def stats(self) -> StorageStats:
|
|
540
|
+
stats = StorageStats(backend="jsonl", path=str(self._dir))
|
|
541
|
+
total_size = 0
|
|
542
|
+
for pattern, count_attr in [
|
|
543
|
+
("audit_*.jsonl", "audit_events_count"),
|
|
544
|
+
("execution_*.jsonl", "execution_records_count"),
|
|
545
|
+
("skills_*.jsonl", "skill_records_count"),
|
|
546
|
+
("metrics_*.jsonl", "metrics_count"),
|
|
547
|
+
]:
|
|
548
|
+
count = 0
|
|
549
|
+
for f in self._dir.glob(pattern):
|
|
550
|
+
total_size += f.stat().st_size
|
|
551
|
+
count += sum(1 for _ in _read_jsonl_lines(f))
|
|
552
|
+
setattr(stats, count_attr, count)
|
|
553
|
+
stats.file_size_bytes = total_size
|
|
554
|
+
return stats
|
|
555
|
+
|
|
556
|
+
def cleanup(self, before: str) -> int:
|
|
557
|
+
total = 0
|
|
558
|
+
before_dt = datetime.fromisoformat(before)
|
|
559
|
+
for pattern in ("audit_*.jsonl", "execution_*.jsonl"):
|
|
560
|
+
for f in self._dir.glob(pattern):
|
|
561
|
+
lines = list(_read_jsonl_lines(f))
|
|
562
|
+
kept = []
|
|
563
|
+
for line in lines:
|
|
564
|
+
try:
|
|
565
|
+
ts = json.loads(line).get("timestamp", "")
|
|
566
|
+
if ts:
|
|
567
|
+
try:
|
|
568
|
+
record_dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
569
|
+
if record_dt < before_dt:
|
|
570
|
+
total += 1
|
|
571
|
+
continue
|
|
572
|
+
except (ValueError, TypeError):
|
|
573
|
+
pass
|
|
574
|
+
except json.JSONDecodeError:
|
|
575
|
+
pass
|
|
576
|
+
kept.append(line)
|
|
577
|
+
if len(kept) < len(lines):
|
|
578
|
+
f.write_text("\n".join(kept) + ("\n" if kept else ""), encoding="utf-8")
|
|
579
|
+
return total
|
|
580
|
+
|
|
581
|
+
def vacuum(self) -> None:
|
|
582
|
+
pass
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _read_jsonl_lines(path: Path):
|
|
586
|
+
if not path.exists():
|
|
587
|
+
return
|
|
588
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
589
|
+
line = line.strip()
|
|
590
|
+
if line:
|
|
591
|
+
yield line
|