""" Kin — SQLite database schema and connection management. All tables from DESIGN.md section 3.5 State Management. """ import sqlite3 from pathlib import Path DB_PATH = Path(__file__).parent.parent / "kin.db" SCHEMA = """ -- Проекты (центральный реестр) CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL, tech_stack JSON, status TEXT DEFAULT 'active', priority INTEGER DEFAULT 5, pm_prompt TEXT, claude_md_path TEXT, forgejo_repo TEXT, language TEXT DEFAULT 'ru', execution_mode TEXT NOT NULL DEFAULT 'review', deploy_command TEXT, project_type TEXT DEFAULT 'development', ssh_host TEXT, ssh_user TEXT, ssh_key_path TEXT, ssh_proxy_jump TEXT, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Задачи (привязаны к проекту) CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), title TEXT NOT NULL, status TEXT DEFAULT 'pending', priority INTEGER DEFAULT 5, assigned_role TEXT, parent_task_id TEXT REFERENCES tasks(id), brief JSON, spec JSON, review JSON, test_result JSON, security_result JSON, forgejo_issue_id INTEGER, execution_mode TEXT, blocked_reason TEXT, blocked_at DATETIME, blocked_agent_role TEXT, blocked_pipeline_step TEXT, dangerously_skipped BOOLEAN DEFAULT 0, revise_comment TEXT, category TEXT DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Решения и грабли (внешняя память PM-агента) CREATE TABLE IF NOT EXISTS decisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id), task_id TEXT REFERENCES tasks(id), type TEXT NOT NULL, category TEXT, title TEXT NOT NULL, description TEXT NOT NULL, tags JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Логи агентов (дебаг, обучение, cost tracking) CREATE TABLE IF NOT EXISTS agent_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id), task_id TEXT REFERENCES tasks(id), agent_role TEXT NOT NULL, session_id TEXT, action TEXT NOT NULL, input_summary TEXT, output_summary TEXT, tokens_used INTEGER, model TEXT, cost_usd REAL, success BOOLEAN, error_message TEXT, duration_seconds INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Модули проекта (карта для PM) CREATE TABLE IF NOT EXISTS modules ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, type TEXT NOT NULL, path TEXT NOT NULL, description TEXT, owner_role TEXT, dependencies JSON, UNIQUE(project_id, name) ); -- Фазы исследования нового проекта (research workflow KIN-059) CREATE TABLE IF NOT EXISTS project_phases ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id), role TEXT NOT NULL, phase_order INTEGER NOT NULL, status TEXT DEFAULT 'pending', task_id TEXT REFERENCES tasks(id), revise_count INTEGER DEFAULT 0, revise_comment TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_phases_project ON project_phases(project_id, phase_order); -- Pipelines (история запусков) CREATE TABLE IF NOT EXISTS pipelines ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(id), project_id TEXT NOT NULL REFERENCES projects(id), route_type TEXT NOT NULL, steps JSON NOT NULL, status TEXT DEFAULT 'running', total_cost_usd REAL, total_tokens INTEGER, total_duration_seconds INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME ); -- Post-pipeline хуки CREATE TABLE IF NOT EXISTS hooks ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, event TEXT NOT NULL, trigger_module_path TEXT, trigger_module_type TEXT, command TEXT NOT NULL, working_dir TEXT, timeout_seconds INTEGER DEFAULT 120, enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')) ); -- Лог выполнений хуков CREATE TABLE IF NOT EXISTS hook_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, hook_id INTEGER NOT NULL REFERENCES hooks(id), project_id TEXT NOT NULL REFERENCES projects(id), task_id TEXT, success INTEGER NOT NULL, exit_code INTEGER, output TEXT, error TEXT, duration_seconds REAL, created_at TEXT DEFAULT (datetime('now')) ); -- Аудит-лог опасных операций (dangerously-skip-permissions) CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, task_id TEXT REFERENCES tasks(id), step_id TEXT, event_type TEXT NOT NULL DEFAULT 'dangerous_skip', reason TEXT, project_id TEXT REFERENCES projects(id) ); CREATE INDEX IF NOT EXISTS idx_audit_log_task ON audit_log(task_id); CREATE INDEX IF NOT EXISTS idx_audit_log_event ON audit_log(event_type, timestamp); -- Кросс-проектные зависимости CREATE TABLE IF NOT EXISTS project_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_project TEXT NOT NULL REFERENCES projects(id), to_project TEXT NOT NULL REFERENCES projects(id), type TEXT NOT NULL, description TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- Тикеты от пользователей CREATE TABLE IF NOT EXISTS support_tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id), source TEXT NOT NULL, client_id TEXT, client_message TEXT NOT NULL, classification TEXT, guard_result TEXT, guard_reason TEXT, anamnesis JSON, task_id TEXT REFERENCES tasks(id), response TEXT, response_approved BOOLEAN DEFAULT FALSE, status TEXT DEFAULT 'new', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, resolved_at DATETIME ); -- Настройки бота для каждого проекта CREATE TABLE IF NOT EXISTS support_bot_config ( project_id TEXT PRIMARY KEY REFERENCES projects(id), telegram_bot_token TEXT, welcome_message TEXT, faq JSON, auto_reply BOOLEAN DEFAULT FALSE, require_approval BOOLEAN DEFAULT TRUE, brand_voice TEXT, forbidden_topics JSON, escalation_keywords JSON ); -- Индексы CREATE INDEX IF NOT EXISTS idx_tasks_project_status ON tasks(project_id, status); CREATE INDEX IF NOT EXISTS idx_decisions_project ON decisions(project_id); CREATE INDEX IF NOT EXISTS idx_decisions_tags ON decisions(tags); CREATE INDEX IF NOT EXISTS idx_agent_logs_project ON agent_logs(project_id, created_at); CREATE INDEX IF NOT EXISTS idx_agent_logs_cost ON agent_logs(project_id, cost_usd); CREATE INDEX IF NOT EXISTS idx_tickets_project ON support_tickets(project_id, status); CREATE INDEX IF NOT EXISTS idx_tickets_client ON support_tickets(client_id); """ def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection: conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row return conn def _migrate(conn: sqlite3.Connection): """Run migrations for existing databases.""" # Check if language column exists on projects proj_cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()} if "language" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN language TEXT DEFAULT 'ru'") conn.commit() if "execution_mode" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'review'") conn.commit() # Check if execution_mode column exists on tasks task_cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()} if "execution_mode" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN execution_mode TEXT") conn.commit() if "blocked_reason" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN blocked_reason TEXT") conn.commit() if "autocommit_enabled" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN autocommit_enabled INTEGER DEFAULT 0") conn.commit() if "dangerously_skipped" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN dangerously_skipped BOOLEAN DEFAULT 0") conn.commit() if "revise_comment" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN revise_comment TEXT") conn.commit() if "category" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN category TEXT DEFAULT NULL") conn.commit() if "blocked_at" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN blocked_at DATETIME") conn.commit() if "blocked_agent_role" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN blocked_agent_role TEXT") conn.commit() if "blocked_pipeline_step" not in task_cols: conn.execute("ALTER TABLE tasks ADD COLUMN blocked_pipeline_step TEXT") conn.commit() if "obsidian_vault_path" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT") conn.commit() if "deploy_command" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN deploy_command TEXT") conn.commit() if "project_type" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN project_type TEXT DEFAULT 'development'") conn.commit() if "ssh_host" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN ssh_host TEXT") conn.commit() if "ssh_user" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN ssh_user TEXT") conn.commit() if "ssh_key_path" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN ssh_key_path TEXT") conn.commit() if "ssh_proxy_jump" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN ssh_proxy_jump TEXT") conn.commit() if "description" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN description TEXT") conn.commit() # Migrate audit_log + project_phases tables existing_tables = {r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall()} if "project_phases" not in existing_tables: conn.executescript(""" CREATE TABLE IF NOT EXISTS project_phases ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL REFERENCES projects(id), role TEXT NOT NULL, phase_order INTEGER NOT NULL, status TEXT DEFAULT 'pending', task_id TEXT REFERENCES tasks(id), revise_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_phases_project ON project_phases(project_id, phase_order); """) conn.commit() # Migrate project_phases columns (table may already exist without revise_comment) phase_cols = {r[1] for r in conn.execute("PRAGMA table_info(project_phases)").fetchall()} if "revise_comment" not in phase_cols: conn.execute("ALTER TABLE project_phases ADD COLUMN revise_comment TEXT") conn.commit() if "audit_log" not in existing_tables: conn.executescript(""" CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, task_id TEXT REFERENCES tasks(id), step_id TEXT, event_type TEXT NOT NULL DEFAULT 'dangerous_skip', reason TEXT, project_id TEXT REFERENCES projects(id) ); CREATE INDEX IF NOT EXISTS idx_audit_log_task ON audit_log(task_id); CREATE INDEX IF NOT EXISTS idx_audit_log_event ON audit_log(event_type, timestamp); """) conn.commit() # Rename legacy 'auto' → 'auto_complete' (KIN-063) conn.execute( "UPDATE projects SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" ) conn.execute( "UPDATE tasks SET execution_mode = 'auto_complete' WHERE execution_mode = 'auto'" ) conn.commit() def _seed_default_hooks(conn: sqlite3.Connection): """Seed default hooks for the kin project (idempotent). Creates rebuild-frontend hook only when: - project 'kin' exists in the projects table - the hook doesn't already exist (no duplicate) """ kin_exists = conn.execute( "SELECT 1 FROM projects WHERE id = 'kin'" ).fetchone() if not kin_exists: return exists = conn.execute( "SELECT 1 FROM hooks" " WHERE project_id = 'kin'" " AND name = 'rebuild-frontend'" " AND event = 'pipeline_completed'" ).fetchone() if not exists: conn.execute( """INSERT INTO hooks (project_id, name, event, command, enabled) VALUES ('kin', 'rebuild-frontend', 'pipeline_completed', 'cd /Users/grosfrumos/projects/kin/web/frontend && npm run build', 1)""" ) conn.commit() # Enable autocommit for kin project (opt-in, idempotent) conn.execute( "UPDATE projects SET autocommit_enabled=1 WHERE id='kin' AND autocommit_enabled=0" ) conn.commit() def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection: conn = get_connection(db_path) conn.executescript(SCHEMA) conn.commit() _migrate(conn) _seed_default_hooks(conn) return conn if __name__ == "__main__": conn = init_db() tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" ).fetchall() print(f"Initialized {len(tables)} tables:") for t in tables: print(f" - {t['name']}") conn.close()