kin/core/db.py

592 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 CHECK (path IS NOT NULL OR project_type = 'operations'),
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,
autocommit_enabled INTEGER DEFAULT 0,
obsidian_vault_path 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,
telegram_sent BOOLEAN DEFAULT 0,
acceptance_criteria TEXT,
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
);
-- Среды развёртывания проекта (prod/dev серверы)
CREATE TABLE IF NOT EXISTS project_environments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 22,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
auth_value TEXT,
is_installed INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, name)
);
CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id);
-- Индексы
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);
-- Чат-сообщения (KIN-OBS-012)
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
message_type TEXT DEFAULT 'text',
task_id TEXT REFERENCES tasks(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_project ON chat_messages(project_id, created_at);
"""
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 "telegram_sent" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN telegram_sent BOOLEAN DEFAULT 0")
conn.commit()
if "acceptance_criteria" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN acceptance_criteria 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_environments" not in existing_tables:
conn.executescript("""
CREATE TABLE IF NOT EXISTS project_environments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 22,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
auth_value TEXT,
is_installed INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_id, name)
);
CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id);
""")
conn.commit()
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()
# Migrate columns that must exist before table recreation (KIN-UI-002)
# These columns are referenced in the INSERT SELECT below but were not added
# by any prior ALTER TABLE in this chain — causing OperationalError on minimal schemas.
if "tech_stack" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN tech_stack JSON DEFAULT NULL")
conn.commit()
if "priority" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN priority INTEGER DEFAULT 5")
conn.commit()
if "pm_prompt" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN pm_prompt TEXT DEFAULT NULL")
conn.commit()
if "claude_md_path" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN claude_md_path TEXT DEFAULT NULL")
conn.commit()
if "forgejo_repo" not in proj_cols:
conn.execute("ALTER TABLE projects ADD COLUMN forgejo_repo TEXT DEFAULT NULL")
conn.commit()
if "created_at" not in proj_cols:
# SQLite ALTER TABLE does not allow non-constant defaults like CURRENT_TIMESTAMP
conn.execute("ALTER TABLE projects ADD COLUMN created_at DATETIME DEFAULT NULL")
conn.commit()
# Migrate projects.path from NOT NULL to nullable (KIN-ARCH-003)
# SQLite doesn't support ALTER COLUMN, so we recreate the table.
path_col_rows = conn.execute("PRAGMA table_info(projects)").fetchall()
path_col = next((r for r in path_col_rows if r[1] == "path"), None)
if path_col and path_col[3] == 1: # notnull == 1, migration needed
conn.executescript("""
PRAGMA foreign_keys=OFF;
CREATE TABLE projects_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT CHECK (path IS NOT NULL OR project_type = 'operations'),
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,
autocommit_enabled INTEGER DEFAULT 0,
obsidian_vault_path TEXT
);
INSERT INTO projects_new
SELECT id, name, path, tech_stack, status, priority,
pm_prompt, claude_md_path, forgejo_repo, language,
execution_mode, deploy_command, project_type,
ssh_host, ssh_user, ssh_key_path, ssh_proxy_jump,
description, created_at, autocommit_enabled, obsidian_vault_path
FROM projects;
DROP TABLE projects;
ALTER TABLE projects_new RENAME TO projects;
PRAGMA foreign_keys=ON;
""")
if "chat_messages" not in existing_tables:
conn.executescript("""
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL REFERENCES projects(id),
role TEXT NOT NULL,
content TEXT NOT NULL,
message_type TEXT DEFAULT 'text',
task_id TEXT REFERENCES tasks(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_project ON chat_messages(project_id, created_at);
""")
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)
Also updates existing hooks to the correct command/config if outdated.
"""
kin_row = conn.execute(
"SELECT path FROM projects WHERE id = 'kin'"
).fetchone()
if not kin_row or not kin_row["path"]:
return
_PROJECT_PATH = kin_row["path"].rstrip("/")
_REBUILD_SCRIPT = f"{_PROJECT_PATH}/scripts/rebuild-frontend.sh"
_REBUILD_TRIGGER = "web/frontend/*"
_REBUILD_WORKDIR = _PROJECT_PATH
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, trigger_module_path, command,
working_dir, timeout_seconds, enabled)
VALUES ('kin', 'rebuild-frontend', 'pipeline_completed',
?, ?, ?, 300, 1)""",
(_REBUILD_TRIGGER, _REBUILD_SCRIPT, _REBUILD_WORKDIR),
)
else:
# Migrate existing hook: set trigger_module_path, correct command, working_dir
conn.execute(
"""UPDATE hooks
SET trigger_module_path = ?,
command = ?,
working_dir = ?,
timeout_seconds = 300
WHERE project_id = 'kin' AND name = 'rebuild-frontend'""",
(_REBUILD_TRIGGER, _REBUILD_SCRIPT, _REBUILD_WORKDIR),
)
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()