kin/core/db.py
2026-03-17 19:30:15 +02:00

842 lines
32 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,
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()
# 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;
""")
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()