kin/core/db.py

869 lines
33 KiB
Python
Raw Normal View History

"""
Kin SQLite database schema and connection management.
All tables from DESIGN.md section 3.5 State Management.
"""
2026-03-18 22:28:16 +02:00
import os
import sqlite3
from pathlib import Path
2026-03-18 22:28:16 +02:00
DB_PATH = Path(os.environ.get("KIN_DB_PATH") or (Path.home() / ".kin" / "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,
2026-03-17 17:26:31 +02:00
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,
2026-03-17 19:30:15 +02:00
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,
2026-03-18 22:27:06 +02:00
pending_steps JSON DEFAULT NULL,
labels JSON,
category TEXT DEFAULT NULL,
telegram_sent BOOLEAN DEFAULT 0,
acceptance_criteria TEXT,
2026-03-18 22:11:14 +02:00
smoke_test_result JSON 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,
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
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,
2026-03-17 14:03:53 +02:00
parent_pipeline_id INTEGER REFERENCES pipelines(id),
department TEXT,
2026-03-17 16:50:44 +02:00
pid INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);
2026-03-17 14:03:53 +02:00
-- Межотдельные 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,
2026-03-17 18:31:33 +02:00
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);
2026-03-17 17:26:31 +02:00
-- 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()
2026-03-18 22:27:06 +02:00
if "pending_steps" not in task_cols:
conn.execute("ALTER TABLE tasks ADD COLUMN pending_steps JSON 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()
2026-03-17 17:26:31 +02:00
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()
kin: KIN-059 Workflow new_project с выбором команды. При создании нового проекта через GUI или CLI директор описывает проект свободным текстом и выбирает галочками какие этапы research нужны: ☐ Business analyst (бизнес-модель, аудитория, монетизация) ☐ Market researcher (конкуренты, ниша, отзывы, сильные/слабые стороны) ☐ Legal researcher (юрисдикция, лицензии, KYC/AML, GDPR) ☐ Tech researcher (API, ограничения, стоимость, альтернативы) ☐ UX designer (анализ UX конкурентов, user journey, wireframes) ☐ Marketer (стратегия продвижения, SEO, conversion-паттерны) ☐ Architect (blueprint на основе одобренных research'ей) — всегда последний Architect включается автоматически если выбран хотя бы один researcher. Каждый выбранный этап — отдельная задача на review. Директор одобряет, отклоняет, или просит доисследовать (Revise). Следующий этап только после approve предыдущего. GUI: форма 'New Project' с описанием + чекбоксы ролей + кнопка 'Start Research'. CLI: kin new-project 'описание' --roles 'business,market,tech,architect'
2026-03-16 09:30:00 +02:00
# 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()
2026-03-17 17:26:31 +02:00
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()
2026-03-17 14:03:53 +02:00
# 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()
2026-03-17 14:03:53 +02:00
# 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()
2026-03-17 19:30:15 +02:00
# Add test_command column to projects (KIN-ARCH-008); NULL = auto-detect (KIN-101)
2026-03-17 15:40:31 +02:00
projects_cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")}
if "test_command" not in projects_cols:
2026-03-17 19:30:15 +02:00
conn.execute("ALTER TABLE projects ADD COLUMN test_command TEXT DEFAULT NULL")
2026-03-17 15:40:31 +02:00
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()
2026-03-17 16:02:47 +02:00
# 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()
2026-03-17 16:02:47 +02:00
# 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;
""")
2026-03-18 17:34:33 +02:00
# 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()
2026-03-18 22:11:14 +02:00
# KIN-128: Add smoke_test_result to tasks — stores smoke_tester agent output
task_cols_final2 = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
if "smoke_test_result" not in task_cols_final2:
conn.execute("ALTER TABLE tasks ADD COLUMN smoke_test_result JSON 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()