Skip to content

Commit 0e1a34b

Browse files
Brian KrafftCopilot
andcommitted
feat(3.6): extract MigrationManager from monolithic SkillStore
- Extract DDL and schema initialization logic into MigrationManager - Support standalone (db_path) and embedded (conn + lock) modes - Add schema versioning with PRAGMA user_version - Add migration paths and ensure_current_schema() for app init - Remove _DDL constant and _init_db method from SkillStore - Add facade methods to SkillStore for MigrationManager API - Update __init__.py to export MigrationManager - Comprehensive tests for schema creation, versioning, migration - Tests verify SkillStore integration and facade methods - Follows same extraction pattern as Epic 3.2-3.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a8fb023 commit 0e1a34b

4 files changed

Lines changed: 761 additions & 94 deletions

File tree

openspace/skill_engine/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"LineageTracker",
2222
"AnalysisStore",
2323
"TagSearch",
24+
"MigrationManager",
2425
"ExecutionAnalyzer",
2526
"SkillEvolver",
2627
"EvolutionTrigger",
@@ -48,6 +49,7 @@
4849
"LineageTracker": (".lineage_tracker", "LineageTracker"),
4950
"AnalysisStore": (".analysis_store", "AnalysisStore"),
5051
"TagSearch": (".tag_search", "TagSearch"),
52+
"MigrationManager": (".migration_manager", "MigrationManager"),
5153
"ExecutionAnalyzer": (".analyzer", "ExecutionAnalyzer"),
5254
"SkillEvolver": (".evolver", "SkillEvolver"),
5355
"EvolutionTrigger": (".evolver", "EvolutionTrigger"),
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
"""MigrationManager — extracted DDL and schema migration logic from SkillStore.
2+
3+
Epic 3.6: Separates database schema creation and versioning concerns from the
4+
monolithic SkillStore, following the same extraction pattern as SkillRepository
5+
(Epic 3.2), LineageTracker (Epic 3.3), AnalysisStore (Epic 3.4), and TagSearch (Epic 3.5).
6+
7+
Architecture:
8+
- Accepts ``db_path`` for standalone use, or ``conn`` + ``lock`` for
9+
embedding inside SkillStore (shared write connection, shared mutex).
10+
- Handles schema creation via DDL execution (CREATE TABLE, CREATE INDEX)
11+
- Manages PRAGMA settings for optimal SQLite configuration
12+
- Idempotent: safe to run multiple times via IF NOT EXISTS
13+
- Future: schema versioning and upgrade paths
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import sqlite3
19+
import threading
20+
import time
21+
from functools import wraps
22+
from pathlib import Path
23+
from typing import Optional
24+
25+
from openspace.utils.logging import Logger
26+
27+
logger = Logger.get_logger(__name__)
28+
29+
30+
def _db_retry(
31+
max_retries: int = 5,
32+
initial_delay: float = 0.1,
33+
backoff: float = 2.0,
34+
):
35+
"""Retry on transient SQLite errors with exponential backoff."""
36+
37+
def decorator(func):
38+
@wraps(func)
39+
def wrapper(*args, **kwargs):
40+
delay = initial_delay
41+
for attempt in range(max_retries):
42+
try:
43+
return func(*args, **kwargs)
44+
except (sqlite3.OperationalError, sqlite3.DatabaseError) as exc:
45+
if attempt == max_retries - 1:
46+
logger.error(f"DB {func.__name__} failed after {max_retries} retries: {exc}")
47+
raise
48+
logger.warning(f"DB {func.__name__} retry {attempt + 1}/{max_retries}: {exc}")
49+
time.sleep(delay)
50+
delay *= backoff
51+
52+
return wrapper
53+
54+
return decorator
55+
56+
57+
_DDL = """
58+
CREATE TABLE IF NOT EXISTS skill_records (
59+
skill_id TEXT PRIMARY KEY,
60+
name TEXT NOT NULL,
61+
description TEXT NOT NULL DEFAULT '',
62+
path TEXT NOT NULL DEFAULT '',
63+
is_active INTEGER NOT NULL DEFAULT 1,
64+
category TEXT NOT NULL DEFAULT 'workflow',
65+
visibility TEXT NOT NULL DEFAULT 'private',
66+
creator_id TEXT NOT NULL DEFAULT '',
67+
lineage_origin TEXT NOT NULL DEFAULT 'imported',
68+
lineage_generation INTEGER NOT NULL DEFAULT 0,
69+
lineage_source_task_id TEXT,
70+
lineage_change_summary TEXT NOT NULL DEFAULT '',
71+
lineage_content_diff TEXT NOT NULL DEFAULT '',
72+
lineage_content_snapshot TEXT NOT NULL DEFAULT '{}',
73+
lineage_created_at TEXT NOT NULL,
74+
lineage_created_by TEXT NOT NULL DEFAULT '',
75+
total_selections INTEGER NOT NULL DEFAULT 0,
76+
total_applied INTEGER NOT NULL DEFAULT 0,
77+
total_completions INTEGER NOT NULL DEFAULT 0,
78+
total_fallbacks INTEGER NOT NULL DEFAULT 0,
79+
first_seen TEXT NOT NULL,
80+
last_updated TEXT NOT NULL
81+
);
82+
CREATE INDEX IF NOT EXISTS idx_sr_category ON skill_records(category);
83+
CREATE INDEX IF NOT EXISTS idx_sr_updated ON skill_records(last_updated);
84+
CREATE INDEX IF NOT EXISTS idx_sr_active ON skill_records(is_active);
85+
CREATE INDEX IF NOT EXISTS idx_sr_name ON skill_records(name);
86+
87+
CREATE TABLE IF NOT EXISTS skill_lineage_parents (
88+
skill_id TEXT NOT NULL
89+
REFERENCES skill_records(skill_id) ON DELETE CASCADE,
90+
parent_skill_id TEXT NOT NULL,
91+
PRIMARY KEY (skill_id, parent_skill_id)
92+
);
93+
CREATE INDEX IF NOT EXISTS idx_lp_parent
94+
ON skill_lineage_parents(parent_skill_id);
95+
96+
-- One row per task. task_id is UNIQUE (at most one analysis per task).
97+
CREATE TABLE IF NOT EXISTS execution_analyses (
98+
id INTEGER PRIMARY KEY AUTOINCREMENT,
99+
task_id TEXT NOT NULL UNIQUE,
100+
timestamp TEXT NOT NULL,
101+
task_completed INTEGER NOT NULL DEFAULT 0,
102+
execution_note TEXT NOT NULL DEFAULT '',
103+
tool_issues TEXT NOT NULL DEFAULT '[]',
104+
candidate_for_evolution INTEGER NOT NULL DEFAULT 0,
105+
evolution_suggestions TEXT NOT NULL DEFAULT '[]',
106+
analyzed_by TEXT NOT NULL DEFAULT '',
107+
analyzed_at TEXT NOT NULL
108+
);
109+
CREATE INDEX IF NOT EXISTS idx_ea_task ON execution_analyses(task_id);
110+
CREATE INDEX IF NOT EXISTS idx_ea_ts ON execution_analyses(timestamp);
111+
112+
-- Per-skill judgments within an analysis.
113+
-- FK to execution_analyses.id (CASCADE delete).
114+
-- skill_id is a plain TEXT — no FK to skill_records so that
115+
-- historical judgments survive skill deletion.
116+
CREATE TABLE IF NOT EXISTS skill_judgments (
117+
id INTEGER PRIMARY KEY AUTOINCREMENT,
118+
analysis_id INTEGER NOT NULL
119+
REFERENCES execution_analyses(id) ON DELETE CASCADE,
120+
skill_id TEXT NOT NULL,
121+
skill_applied INTEGER NOT NULL DEFAULT 0,
122+
note TEXT NOT NULL DEFAULT '',
123+
UNIQUE(analysis_id, skill_id)
124+
);
125+
CREATE INDEX IF NOT EXISTS idx_sj_skill ON skill_judgments(skill_id);
126+
CREATE INDEX IF NOT EXISTS idx_sj_analysis ON skill_judgments(analysis_id);
127+
128+
CREATE TABLE IF NOT EXISTS skill_tool_deps (
129+
skill_id TEXT NOT NULL
130+
REFERENCES skill_records(skill_id) ON DELETE CASCADE,
131+
tool_key TEXT NOT NULL,
132+
critical INTEGER NOT NULL DEFAULT 0,
133+
PRIMARY KEY (skill_id, tool_key)
134+
);
135+
CREATE INDEX IF NOT EXISTS idx_td_tool ON skill_tool_deps(tool_key);
136+
137+
CREATE TABLE IF NOT EXISTS skill_tags (
138+
skill_id TEXT NOT NULL
139+
REFERENCES skill_records(skill_id) ON DELETE CASCADE,
140+
tag TEXT NOT NULL,
141+
PRIMARY KEY (skill_id, tag)
142+
);
143+
"""
144+
145+
146+
class MigrationManager:
147+
"""Database schema creation and migration management.
148+
149+
Extracted from ``SkillStore`` (Epic 3.6) to isolate DDL and schema
150+
versioning logic.
151+
152+
Usage (standalone)::
153+
154+
manager = MigrationManager(db_path=Path("skills.db"))
155+
manager.initialize_schema()
156+
manager.close()
157+
158+
Usage (embedded in SkillStore)::
159+
160+
# SkillStore creates us internally:
161+
# store._migrations = MigrationManager(conn=store._conn, lock=store._mu)
162+
# SkillStore calls manager.initialize_schema() during __init__
163+
"""
164+
165+
def __init__(
166+
self,
167+
db_path: Optional[Path] = None,
168+
conn: Optional[sqlite3.Connection] = None,
169+
lock: Optional[threading.Lock] = None,
170+
) -> None:
171+
self._owns_conn = conn is None
172+
self._closed = False
173+
self._mu = lock if lock is not None else threading.Lock()
174+
175+
if conn is not None:
176+
self._conn = conn
177+
self._db_path = Path(":shared:")
178+
else:
179+
if db_path is None:
180+
raise ValueError("Either db_path or conn must be provided")
181+
self._db_path = Path(db_path)
182+
self._conn = self._make_connection(read_only=False)
183+
184+
logger.debug(f"MigrationManager ready at {self._db_path}")
185+
186+
# ── Connection Management ──────────────────────────────────────────
187+
188+
def _make_connection(self, *, read_only: bool) -> sqlite3.Connection:
189+
"""Create a tuned SQLite connection with optimal PRAGMA settings."""
190+
conn = sqlite3.connect(
191+
str(self._db_path),
192+
timeout=30.0,
193+
check_same_thread=False,
194+
)
195+
196+
# Performance and reliability PRAGMA settings
197+
conn.execute("PRAGMA journal_mode=WAL")
198+
conn.execute("PRAGMA busy_timeout=30000")
199+
conn.execute("PRAGMA synchronous=NORMAL")
200+
conn.execute("PRAGMA cache_size=-16000") # 16 MB
201+
conn.execute("PRAGMA temp_store=MEMORY")
202+
conn.execute("PRAGMA foreign_keys=ON")
203+
204+
if read_only:
205+
conn.execute("PRAGMA query_only=ON")
206+
207+
conn.row_factory = sqlite3.Row
208+
return conn
209+
210+
def _ensure_open(self) -> None:
211+
if self._closed:
212+
raise RuntimeError("MigrationManager is closed")
213+
214+
def close(self) -> None:
215+
"""Close the connection if we own it."""
216+
if self._closed:
217+
return
218+
219+
if self._owns_conn:
220+
self._closed = True
221+
try:
222+
self._conn.close()
223+
except Exception:
224+
pass
225+
226+
logger.debug("MigrationManager closed")
227+
228+
# ── Schema Management ───────────────────────────────────────────────
229+
230+
@_db_retry()
231+
def initialize_schema(self) -> None:
232+
"""Create all tables and indexes if they don't exist (idempotent).
233+
234+
This method executes the complete DDL script to set up the OpenSpace
235+
skill engine database schema. Safe to call multiple times due to
236+
IF NOT EXISTS clauses.
237+
"""
238+
self._ensure_open()
239+
240+
with self._mu:
241+
self._conn.executescript(_DDL)
242+
self._conn.commit()
243+
244+
logger.debug("Schema initialized successfully")
245+
246+
def get_schema_version(self) -> int:
247+
"""Get the current schema version from PRAGMA user_version.
248+
249+
Returns:
250+
Current schema version (0 if not set).
251+
"""
252+
self._ensure_open()
253+
254+
with self._mu:
255+
cursor = self._conn.execute("PRAGMA user_version")
256+
return cursor.fetchone()[0]
257+
258+
def set_schema_version(self, version: int) -> None:
259+
"""Set the schema version using PRAGMA user_version.
260+
261+
Args:
262+
version: Schema version number to set.
263+
"""
264+
self._ensure_open()
265+
266+
if version < 0:
267+
raise ValueError(f"Schema version must be non-negative, got {version}")
268+
269+
with self._mu:
270+
# Note: PRAGMA user_version does not support parameterized queries
271+
# This is safe because version is validated as an integer above
272+
self._conn.execute(f"PRAGMA user_version = {version}")
273+
self._conn.commit()
274+
275+
logger.debug(f"Schema version set to {version}")
276+
277+
def migrate_to_version(self, target_version: int) -> None:
278+
"""Migrate schema from current version to target version.
279+
280+
Args:
281+
target_version: Target schema version.
282+
283+
Raises:
284+
ValueError: If target version is lower than current version.
285+
RuntimeError: If migration path is not supported.
286+
"""
287+
self._ensure_open()
288+
289+
current_version = self.get_schema_version()
290+
291+
if target_version < current_version:
292+
raise ValueError(
293+
f"Cannot downgrade from version {current_version} to {target_version}"
294+
)
295+
296+
if target_version == current_version:
297+
logger.debug(f"Schema already at version {target_version}")
298+
return
299+
300+
# For now, we only support migration from 0 to 1
301+
# Future versions will add migration paths between versions
302+
if current_version == 0 and target_version == 1:
303+
self.initialize_schema()
304+
self.set_schema_version(1)
305+
logger.info(f"Migrated schema from {current_version} to {target_version}")
306+
else:
307+
raise RuntimeError(
308+
f"Migration from version {current_version} to {target_version} not supported"
309+
)
310+
311+
def ensure_current_schema(self, expected_version: int = 1) -> None:
312+
"""Ensure the database schema is at the expected version.
313+
314+
This is the recommended method for application initialization.
315+
It will automatically migrate if needed.
316+
317+
Args:
318+
expected_version: The version the schema should be at.
319+
"""
320+
self._ensure_open()
321+
322+
current_version = self.get_schema_version()
323+
324+
if current_version < expected_version:
325+
logger.info(f"Schema upgrade needed: {current_version}{expected_version}")
326+
self.migrate_to_version(expected_version)
327+
elif current_version > expected_version:
328+
logger.warning(
329+
f"Database schema version {current_version} is newer than expected {expected_version}"
330+
)

0 commit comments

Comments
 (0)