855 lines
33 KiB
Python
855 lines
33 KiB
Python
"""
|
||
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,
|
||
deploy_host TEXT,
|
||
deploy_path TEXT,
|
||
deploy_runtime TEXT,
|
||
deploy_restart_cmd TEXT,
|
||
autocommit_enabled INTEGER DEFAULT 0,
|
||
obsidian_vault_path TEXT,
|
||
worktrees_enabled INTEGER DEFAULT 0,
|
||
auto_test_enabled INTEGER DEFAULT 0,
|
||
test_command TEXT DEFAULT NULL,
|
||
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,
|
||
revise_count INTEGER DEFAULT 0,
|
||
revise_target_role TEXT DEFAULT NULL,
|
||
labels JSON,
|
||
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,
|
||
parent_pipeline_id INTEGER REFERENCES pipelines(id),
|
||
department TEXT,
|
||
pid INTEGER,
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||
completed_at DATETIME
|
||
);
|
||
|
||
-- Межотдельные handoff-ы (KIN-098)
|
||
CREATE TABLE IF NOT EXISTS department_handoffs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
pipeline_id INTEGER NOT NULL REFERENCES pipelines(id),
|
||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||
from_department TEXT NOT NULL,
|
||
to_department TEXT,
|
||
artifacts JSON,
|
||
decisions_made JSON,
|
||
blockers JSON,
|
||
status TEXT DEFAULT 'pending',
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_handoffs_pipeline ON department_handoffs(pipeline_id);
|
||
CREATE INDEX IF NOT EXISTS idx_handoffs_task ON department_handoffs(task_id);
|
||
|
||
-- 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,
|
||
UNIQUE(from_project, to_project, type)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_project_links_to ON project_links(to_project);
|
||
CREATE INDEX IF NOT EXISTS idx_project_links_from ON project_links(from_project);
|
||
|
||
-- Тикеты от пользователей
|
||
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);
|
||
|
||
-- Вложения задач (KIN-090)
|
||
CREATE TABLE IF NOT EXISTS task_attachments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||
filename TEXT NOT NULL,
|
||
path TEXT NOT NULL,
|
||
mime_type TEXT NOT NULL,
|
||
size INTEGER NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_task_attachments_task ON task_attachments(task_id);
|
||
|
||
-- Live console log (KIN-084)
|
||
CREATE TABLE IF NOT EXISTS pipeline_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
pipeline_id INTEGER NOT NULL REFERENCES pipelines(id),
|
||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||
level TEXT NOT NULL DEFAULT 'INFO',
|
||
message TEXT NOT NULL,
|
||
extra_json TEXT
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_pipeline_log_pipeline_id ON pipeline_log(pipeline_id, 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 "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 "revise_count" not in task_cols:
|
||
conn.execute("ALTER TABLE tasks ADD COLUMN revise_count INTEGER DEFAULT 0")
|
||
conn.commit()
|
||
|
||
if "labels" not in task_cols:
|
||
conn.execute("ALTER TABLE tasks ADD COLUMN labels JSON DEFAULT NULL")
|
||
conn.commit()
|
||
|
||
if "revise_target_role" not in task_cols:
|
||
conn.execute("ALTER TABLE tasks ADD COLUMN revise_target_role TEXT DEFAULT NULL")
|
||
conn.commit()
|
||
|
||
if "obsidian_vault_path" not in proj_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
|
||
conn.commit()
|
||
|
||
if "worktrees_enabled" not in proj_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN worktrees_enabled INTEGER DEFAULT 0")
|
||
conn.commit()
|
||
|
||
if "auto_test_enabled" not in proj_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN auto_test_enabled INTEGER DEFAULT 0")
|
||
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()
|
||
|
||
if "deploy_host" not in proj_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN deploy_host TEXT")
|
||
conn.commit()
|
||
|
||
if "deploy_path" not in proj_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN deploy_path TEXT")
|
||
conn.commit()
|
||
|
||
if "deploy_runtime" not in proj_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN deploy_runtime TEXT")
|
||
conn.commit()
|
||
|
||
if "deploy_restart_cmd" not in proj_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN deploy_restart_cmd 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()
|
||
|
||
# Migrate project_environments: old schema used label/login/credential,
|
||
# new schema uses name/username/auth_value (KIN-087 column rename).
|
||
env_cols = {r[1] for r in conn.execute("PRAGMA table_info(project_environments)").fetchall()}
|
||
if "name" not in env_cols and "label" in env_cols:
|
||
conn.executescript("""
|
||
PRAGMA foreign_keys=OFF;
|
||
CREATE TABLE project_environments_new (
|
||
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)
|
||
);
|
||
INSERT INTO project_environments_new
|
||
SELECT id, project_id, label, host, port, login, auth_type,
|
||
credential, is_installed, created_at, updated_at
|
||
FROM project_environments;
|
||
DROP TABLE project_environments;
|
||
ALTER TABLE project_environments_new RENAME TO project_environments;
|
||
CREATE INDEX IF NOT EXISTS idx_environments_project ON project_environments(project_id);
|
||
PRAGMA foreign_keys=ON;
|
||
""")
|
||
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()
|
||
|
||
if "task_attachments" not in existing_tables:
|
||
conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS task_attachments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||
filename TEXT NOT NULL,
|
||
path TEXT NOT NULL,
|
||
mime_type TEXT NOT NULL,
|
||
size INTEGER NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_task_attachments_task ON task_attachments(task_id);
|
||
""")
|
||
conn.commit()
|
||
|
||
if "pipeline_log" not in existing_tables:
|
||
conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS pipeline_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
pipeline_id INTEGER NOT NULL REFERENCES pipelines(id),
|
||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||
level TEXT NOT NULL DEFAULT 'INFO',
|
||
message TEXT NOT NULL,
|
||
extra_json TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_pipeline_log_pipeline_id ON pipeline_log(pipeline_id, id);
|
||
""")
|
||
conn.commit()
|
||
|
||
# Migrate pipelines: add parent_pipeline_id and department columns (KIN-098)
|
||
# Guard: table may not exist in legacy schemas without pipelines (old test fixtures)
|
||
if "pipelines" in existing_tables:
|
||
pipeline_cols = {r[1] for r in conn.execute("PRAGMA table_info(pipelines)").fetchall()}
|
||
if "parent_pipeline_id" not in pipeline_cols:
|
||
conn.execute("ALTER TABLE pipelines ADD COLUMN parent_pipeline_id INTEGER REFERENCES pipelines(id)")
|
||
conn.commit()
|
||
if "department" not in pipeline_cols:
|
||
conn.execute("ALTER TABLE pipelines ADD COLUMN department TEXT")
|
||
conn.commit()
|
||
if "pid" not in pipeline_cols:
|
||
conn.execute("ALTER TABLE pipelines ADD COLUMN pid INTEGER")
|
||
conn.commit()
|
||
|
||
# Create department_handoffs table (KIN-098)
|
||
if "department_handoffs" not in existing_tables:
|
||
conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS department_handoffs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
pipeline_id INTEGER NOT NULL REFERENCES pipelines(id),
|
||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||
from_department TEXT NOT NULL,
|
||
to_department TEXT,
|
||
artifacts JSON,
|
||
decisions_made JSON,
|
||
blockers JSON,
|
||
status TEXT DEFAULT 'pending',
|
||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_handoffs_pipeline ON department_handoffs(pipeline_id);
|
||
CREATE INDEX IF NOT EXISTS idx_handoffs_task ON department_handoffs(task_id);
|
||
""")
|
||
conn.commit()
|
||
|
||
# Add test_command column to projects (KIN-ARCH-008); NULL = auto-detect (KIN-101)
|
||
projects_cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")}
|
||
if "test_command" not in projects_cols:
|
||
conn.execute("ALTER TABLE projects ADD COLUMN test_command TEXT DEFAULT NULL")
|
||
conn.commit()
|
||
|
||
# KIN-102: Reset legacy 'make test' default — projects using old schema default get auto-detection.
|
||
# Any project with a real Makefile should have test_command set explicitly, not via this default.
|
||
conn.execute(
|
||
"UPDATE projects SET test_command = NULL WHERE test_command = 'make test'"
|
||
)
|
||
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()
|
||
|
||
# Fix orphaned dept_sub pipelines left in 'running' state due to double-create bug
|
||
# (KIN-ARCH-012): before the fix, _execute_department_head_step created a pipeline
|
||
# that was never updated, leaving ghost 'running' records in prod DB.
|
||
if "pipelines" in existing_tables:
|
||
conn.execute(
|
||
"UPDATE pipelines SET status = 'failed' WHERE route_type = 'dept_sub' AND status = 'running'"
|
||
)
|
||
conn.commit()
|
||
|
||
# Create indexes for project_links (KIN-INFRA-008).
|
||
# Guard: indexes must be created AFTER the table exists.
|
||
if "project_links" in existing_tables:
|
||
existing_indexes = {r[0] for r in conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='index'"
|
||
).fetchall()}
|
||
if "idx_project_links_to" not in existing_indexes:
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_project_links_to ON project_links(to_project)"
|
||
)
|
||
conn.commit()
|
||
if "idx_project_links_from" not in existing_indexes:
|
||
conn.execute(
|
||
"CREATE INDEX IF NOT EXISTS idx_project_links_from ON project_links(from_project)"
|
||
)
|
||
conn.commit()
|
||
|
||
# Add UNIQUE(from_project, to_project, type) to project_links (KIN-INFRA-013).
|
||
# SQLite does not support ALTER TABLE ADD CONSTRAINT — table recreation required.
|
||
if "project_links" in existing_tables:
|
||
pl_sql_row = conn.execute(
|
||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='project_links'"
|
||
).fetchone()
|
||
pl_has_unique = pl_sql_row and "UNIQUE" in (pl_sql_row[0] or "").upper()
|
||
if not pl_has_unique:
|
||
conn.executescript("""
|
||
PRAGMA foreign_keys=OFF;
|
||
CREATE TABLE project_links_new (
|
||
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,
|
||
UNIQUE(from_project, to_project, type)
|
||
);
|
||
INSERT OR IGNORE INTO project_links_new
|
||
SELECT id, from_project, to_project, type, description, created_at
|
||
FROM project_links;
|
||
DROP TABLE project_links;
|
||
ALTER TABLE project_links_new RENAME TO project_links;
|
||
CREATE INDEX IF NOT EXISTS idx_project_links_to ON project_links(to_project);
|
||
CREATE INDEX IF NOT EXISTS idx_project_links_from ON project_links(from_project);
|
||
PRAGMA foreign_keys=ON;
|
||
""")
|
||
|
||
# KIN-126: Add completed_at to tasks — set when task transitions to 'done'
|
||
task_cols_final = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
|
||
if "completed_at" not in task_cols_final:
|
||
conn.execute("ALTER TABLE tasks ADD COLUMN completed_at DATETIME DEFAULT NULL")
|
||
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()
|