From 4cfb92697132c13f744c0ad698e824f5c9c33e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 17 Apr 2026 16:45:03 -0400 Subject: [PATCH] refactor(schema): split 852-LOC migration ledger into era files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schema.ts had grown to 852 LOC — migration history is append-only, so the file gets bigger every release. Organize by era without fragmenting the history. | File | LOC | Contents | |-----------------------------------|-----|---------------------------------------| | schema.ts | 195 | helpers, runMigrations, | | | | ensureOptionalColumns, seed, init | | migrations/types.ts | 47 | Migration/Deps types, column/table | | | | helpers | | migrations/v1-v15.ts | 50 | createMigrationsV1V15 — v1..v15 | | migrations/v16-v25.ts | 157 | createMigrationsV16V25 — v16..v25 | | migrations/v26-v34.ts | 158 | createMigrationsV26V34 — v26..v34 | | migrations/v35-v37.ts | 326 | createMigrationsV35V37 — three heavy | | | | table-rebuilds (runs/tasks/skills) | Each era exports a factory `createMigrationsVxxVyy(deps)` that takes `{ db, readAll }` — migrations' `fn` bodies captured those names via closure in the original; factory pattern preserves the closure shape byte-for-byte while making the dependency flow explicit. Master `schema.ts` imports all four and concatenates them into the final ascending list. Order preserved including the quirky v31 -> v33 -> v32 -> v34 sequence that predated my changes. All 37 migrations' `version`, `name`, and `sql`/`fn` bodies are byte-for-byte identical. No behavior change; 578 tests green. `database-base.ts` (449 LOC) kept as-is. It's a thin linear pass- through facade — every method is 1–3 lines. Splitting a facade of this kind by forcing mixins or abstract classes costs more than it saves. Not a god component in the Linus sense (one responsibility: delegate); flagged here so future readers know it's deliberate. --- src/main/database/migrations/types.ts | 47 ++ src/main/database/migrations/v1-v15.ts | 50 ++ src/main/database/migrations/v16-v25.ts | 157 ++++++ src/main/database/migrations/v26-v34.ts | 158 ++++++ src/main/database/migrations/v35-v37.ts | 326 +++++++++++ src/main/database/schema.ts | 695 +----------------------- 6 files changed, 757 insertions(+), 676 deletions(-) create mode 100644 src/main/database/migrations/types.ts create mode 100644 src/main/database/migrations/v1-v15.ts create mode 100644 src/main/database/migrations/v16-v25.ts create mode 100644 src/main/database/migrations/v26-v34.ts create mode 100644 src/main/database/migrations/v35-v37.ts diff --git a/src/main/database/migrations/types.ts b/src/main/database/migrations/types.ts new file mode 100644 index 00000000..42b23bab --- /dev/null +++ b/src/main/database/migrations/types.ts @@ -0,0 +1,47 @@ +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; + +export type ReadAll = (sql: string, ...params: SQLInputValue[]) => T[]; +export type Transaction = (operation: () => T) => T; + +export type Migration = { + version: number; + name: string; + sql?: string; + fn?: () => void; +}; + +export type MigrationDeps = { + db: DatabaseSync; + readAll: ReadAll; +}; + +export function nowIso(): string { + return new Date().toISOString(); +} + +export function columnExists( + db: DatabaseSync, + table: string, + column: string, +): boolean { + const rows = db.prepare(`pragma table_info(${table})`).all() as Array<{ + name: string; + }>; + return rows.some((row) => row.name === column); +} + +export function tableExists(db: DatabaseSync, table: string): boolean { + const row = db + .prepare("select name from sqlite_master where type = 'table' and name = ?") + .get(table) as { name: string } | undefined; + return !!row; +} + +export function addColumnIfMissing( + db: DatabaseSync, + table: string, + column: string, + sql: string, +): void { + if (!columnExists(db, table, column)) db.prepare(sql).run(); +} diff --git a/src/main/database/migrations/v1-v15.ts b/src/main/database/migrations/v1-v15.ts new file mode 100644 index 00000000..0d20b32e --- /dev/null +++ b/src/main/database/migrations/v1-v15.ts @@ -0,0 +1,50 @@ +import { randomUUID } from "node:crypto"; +import log from "electron-log/main.js"; +import type { Migration, MigrationDeps } from "./types"; +import { nowIso } from "./types"; + +export function createMigrationsV1V15(deps: MigrationDeps): Migration[] { + const { db, readAll } = deps; + return [ + { version: 1, name: "add_workspace_lock_lease", sql: `alter table workspaces add column lock_holder_run_id text; alter table workspaces add column lock_acquired_at text` }, + { version: 2, name: "add_approval_related_agent_id", sql: `alter table approvals add column related_agent_id text` }, + { version: 3, name: "add_profile_metadata", sql: `insert or ignore into settings (key, value) values ('profile_version', '1'); insert or ignore into settings (key, value) values ('profile_created_at', '${nowIso()}')` }, + { version: 4, name: "add_connector_capability_snapshot", sql: `alter table connectors add column capability_json text` }, + { version: 6, name: "add_agent_department", sql: `alter table agents add column department text` }, + { version: 7, name: "add_meetings_table", sql: `create table if not exists meetings (id text primary key, company_id text not null, type text not null, title text not null, organizer_agent_id text, participant_agent_ids text not null default '[]', scheduled_at text not null, duration_minutes integer not null default 30, agenda_json text not null default '[]', notes_json text not null default '[]', decisions_json text not null default '[]', action_items_json text not null default '[]', status text not null default 'scheduled', created_at text not null, updated_at text not null); create index if not exists idx_meetings_company on meetings(company_id); create index if not exists idx_meetings_organizer on meetings(organizer_agent_id)` }, + { version: 8, name: "add_documents_table", sql: `create table if not exists documents (id text primary key, company_id text not null, type text not null, title text not null, content text not null default '', author_agent_id text, reviewer_agent_id text, project_id text, goal_id text, parent_doc_id text, version integer not null default 1, status text not null default 'draft', tags_json text not null default '[]', created_at text not null, updated_at text not null); create index if not exists idx_documents_company on documents(company_id); create index if not exists idx_documents_author on documents(author_agent_id); create index if not exists idx_documents_project on documents(project_id)` }, + { version: 9, name: "add_knowledge_base_table", sql: `create table if not exists knowledge_base (id text primary key, company_id text not null, category text not null, topic text not null, content text not null, author_agent_id text, importance text not null default 'medium', tags_json text not null default '[]', referenced_entity_type text, referenced_entity_id text, created_at text not null, updated_at text not null); create index if not exists idx_knowledge_company on knowledge_base(company_id); create index if not exists idx_knowledge_category on knowledge_base(category)` }, + { version: 10, name: "add_sprints_table", sql: `create table if not exists sprints (id text primary key, company_id text not null, name text not null, goal text not null default '', start_date text not null, end_date text not null, status text not null default 'planning', retrospective_notes text not null default '', velocity_points integer not null default 0, completed_points integer not null default 0, created_at text not null, updated_at text not null); create index if not exists idx_sprints_company on sprints(company_id)` }, + { version: 11, name: "add_automation_messages_workflows", sql: `create table if not exists automation_rules (id text primary key, company_id text not null, name text not null, description text not null default '', trigger text not null, conditions_json text not null default '{}', action text not null, action_config_json text not null default '{}', source_department text, target_department text, priority integer not null default 50, status text not null default 'active', execution_count integer not null default 0, last_executed_at text, created_at text not null, updated_at text not null); create index if not exists idx_automation_rules_company on automation_rules(company_id); create index if not exists idx_automation_rules_trigger on automation_rules(trigger); create table if not exists agent_messages (id text primary key, company_id text not null, from_agent_id text not null, to_agent_id text, channel text not null default 'direct', channel_target_id text, subject text not null, body text not null, priority text not null default 'normal', read_at text, parent_message_id text, attachments_json text not null default '[]', created_at text not null); create index if not exists idx_agent_messages_company on agent_messages(company_id); create index if not exists idx_agent_messages_to on agent_messages(to_agent_id); create index if not exists idx_agent_messages_from on agent_messages(from_agent_id); create index if not exists idx_agent_messages_channel on agent_messages(channel); create table if not exists workflow_pipelines (id text primary key, company_id text not null, name text not null, description text not null default '', steps_json text not null default '[]', trigger_type text not null, trigger_config_json text not null default '{}', status text not null default 'draft', current_step_index integer not null default 0, run_count integer not null default 0, last_run_at text, created_at text not null, updated_at text not null); create index if not exists idx_workflows_company on workflow_pipelines(company_id); create table if not exists automation_log (id text primary key, company_id text not null, rule_id text not null, rule_name text not null, trigger text not null, action text not null, detail text not null default '', executed_at text not null); create index if not exists idx_automation_log_company on automation_log(company_id)` }, + { version: 12, name: "add_agent_message_reads", sql: `create table if not exists agent_message_reads (message_id text not null, agent_id text not null, read_at text not null, primary key (message_id, agent_id)); create index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id)` }, + { version: 13, name: "add_checkout_session_budget_columns", sql: `ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT; ALTER TABLE tasks ADD COLUMN execution_locked_at TEXT; ALTER TABLE tasks ADD COLUMN active_session_id TEXT; ALTER TABLE cost_entries ADD COLUMN project_id TEXT; CREATE INDEX IF NOT EXISTS idx_cost_entries_project ON cost_entries(project_id)` }, + { version: 14, name: "create_skills_budget_wakeup_session_tables", sql: `CREATE TABLE IF NOT EXISTS company_skills (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, key TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT, markdown TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT 'local_path', source_locator TEXT, source_ref TEXT, trust_level TEXT NOT NULL DEFAULT 'markdown_only', compatibility TEXT NOT NULL DEFAULT 'compatible', file_inventory TEXT NOT NULL DEFAULT '[]', metadata TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key); CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name); CREATE TABLE IF NOT EXISTS budget_policies (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL DEFAULT 'billed_cents', window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', amount INTEGER NOT NULL DEFAULT 0, warn_percent INTEGER NOT NULL DEFAULT 80, hard_stop_enabled INTEGER NOT NULL DEFAULT 1, notify_enabled INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active); CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind); CREATE TABLE IF NOT EXISTS budget_incidents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, policy_id TEXT NOT NULL REFERENCES budget_policies(id), scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL, window_kind TEXT NOT NULL, window_start TEXT NOT NULL, window_end TEXT NOT NULL, threshold_type TEXT NOT NULL, amount_limit INTEGER NOT NULL, amount_observed INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_status ON budget_incidents(company_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type); CREATE TABLE IF NOT EXISTS task_sessions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, adapter_type TEXT NOT NULL, session_params_json TEXT, session_display_id TEXT, last_run_id TEXT, last_error TEXT, run_count INTEGER NOT NULL DEFAULT 0, total_input_tokens INTEGER NOT NULL DEFAULT 0, total_output_tokens INTEGER NOT NULL DEFAULT 0, rotated_at TEXT, rotation_reason TEXT, handoff_markdown TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup ON task_sessions(company_id, agent_id, adapter_type, task_id); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent ON task_sessions(company_id, agent_id, updated_at); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_task ON task_sessions(company_id, task_id, updated_at); CREATE TABLE IF NOT EXISTS wakeup_requests (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, trigger_detail TEXT, reason TEXT, payload TEXT, status TEXT NOT NULL DEFAULT 'queued', coalesced_count INTEGER NOT NULL DEFAULT 0, requested_by_actor_type TEXT, requested_by_actor_id TEXT, idempotency_key TEXT, run_id TEXT, requested_at TEXT NOT NULL, claimed_at TEXT, finished_at TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)` }, + { + version: 15, name: "migrate_session_data_to_task_sessions", + fn: () => { + const tasksWithSessions = readAll<{ id: string; companyId: string; assigneeAgentId: string | null; sessionJson: string | null; sessionDisplayId: string | null }>( + `SELECT id, company_id as companyId, assignee_agent_id as assigneeAgentId, session_json as sessionJson, session_display_id as sessionDisplayId FROM tasks WHERE session_json IS NOT NULL AND active_session_id IS NULL`, + ); + let migrated = 0; + let skipped = 0; + for (const task of tasksWithSessions) { + try { + if (!task.sessionJson) continue; + JSON.parse(task.sessionJson); + const agent = task.assigneeAgentId ? db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined : undefined; + const adapterType = agent?.connector_id ?? "unknown"; + const sessionId = randomUUID(); + const ts = nowIso(); + db.prepare(`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, task.companyId, task.id, task.assigneeAgentId ?? "unassigned", adapterType, task.sessionJson, task.sessionDisplayId, ts, ts); + db.prepare("UPDATE tasks SET active_session_id = ? WHERE id = ?").run(sessionId, task.id); + migrated++; + } catch (err) { + log.warn(`Session migration skipped task ${task.id}: ${err instanceof Error ? err.message : String(err)}`); + skipped++; + } + } + log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`); + }, + }, + ]; +} diff --git a/src/main/database/migrations/v16-v25.ts b/src/main/database/migrations/v16-v25.ts new file mode 100644 index 00000000..b04bb8c7 --- /dev/null +++ b/src/main/database/migrations/v16-v25.ts @@ -0,0 +1,157 @@ +import { randomUUID } from "node:crypto"; +import log from "electron-log/main.js"; +import type { Migration, MigrationDeps } from "./types"; +import { columnExists, nowIso } from "./types"; + +export function createMigrationsV16V25(deps: MigrationDeps): Migration[] { + const { db, readAll } = deps; + return [ + { version: 16, name: "create_agent_skill_assignments", sql: `CREATE TABLE IF NOT EXISTS agent_skill_assignments (agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (agent_id, skill_id), FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)` }, + { + version: 17, name: "migrate_pending_wakes_to_wakeup_requests", + fn: () => { + const existingWakes = readAll<{ id: string; companyId: string; agentId: string; triggerReason: string; createdAt: string }>( + `SELECT id, company_id AS companyId, agent_id AS agentId, trigger_reason AS triggerReason, created_at AS createdAt FROM pending_wakes`, + ); + const ts = nowIso(); + for (const wake of existingWakes) { + const triggerDetail = JSON.stringify([{ trigger: wake.triggerReason, at: wake.createdAt, detail: wake.triggerReason }]); + db.prepare(`INSERT OR IGNORE INTO wakeup_requests (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`).run(wake.id, wake.companyId, wake.agentId, wake.triggerReason, triggerDetail, wake.createdAt, ts, ts); + } + db.exec("DROP TABLE IF EXISTS pending_wakes"); + }, + }, + { + version: 18, name: "migrate_agent_budget_to_policies", + fn: () => { + const agents = readAll<{ id: string; companyId: string; budgetMonthlyUsd: number }>("SELECT id, company_id as companyId, budget_monthly_usd as budgetMonthlyUsd FROM agents WHERE budget_monthly_usd > 0"); + const ts = nowIso(); + for (const agent of agents) { + const existing = db.prepare("SELECT id FROM budget_policies WHERE company_id = ? AND scope_type = 'agent' AND scope_id = ? AND metric = 'billed_cents' AND window_kind = 'calendar_month_utc'").get(agent.companyId, agent.id); + if (!existing) { + db.prepare("INSERT INTO budget_policies (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, hard_stop_enabled, notify_enabled, is_active, created_at, updated_at) VALUES (?, ?, 'agent', ?, 'billed_cents', 'calendar_month_utc', ?, 80, 1, 1, 1, ?, ?)").run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts); + } + } + log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`); + }, + }, + { + version: 19, name: "add_document_revisions_and_approval_comments", + fn: () => { + db.exec(`CREATE TABLE IF NOT EXISTS document_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, document_id TEXT NOT NULL, revision_number INTEGER NOT NULL, body TEXT NOT NULL, change_summary TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number); CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at); CREATE TABLE IF NOT EXISTS approval_comments (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, approval_id TEXT NOT NULL, author_agent_id TEXT, author_user_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at)`); + if (!columnExists(db, "documents", "latest_revision_id")) { + db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run(); + } + }, + }, + { + version: 20, name: "add_task_timestamps_and_task_approval_links", + fn: () => { + const addCol = (table: string, column: string, sql: string) => { + if (!columnExists(db, table, column)) db.prepare(sql).run(); + }; + addCol("tasks", "started_at", "ALTER TABLE tasks ADD COLUMN started_at TEXT"); + addCol("tasks", "completed_at", "ALTER TABLE tasks ADD COLUMN completed_at TEXT"); + addCol("tasks", "cancelled_at", "ALTER TABLE tasks ADD COLUMN cancelled_at TEXT"); + db.exec(`CREATE TABLE IF NOT EXISTS task_approval_links (task_id TEXT NOT NULL, approval_id TEXT NOT NULL, company_id TEXT NOT NULL, linked_by_agent_id TEXT, created_at TEXT NOT NULL, PRIMARY KEY (task_id, approval_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id); CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id)`); + db.prepare("UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')").run(); + db.prepare("UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'").run(); + db.prepare("UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'").run(); + }, + }, + { + version: 21, name: "add_work_products_task_documents_wakeup_context", + fn: () => { + db.exec(`CREATE TABLE IF NOT EXISTS work_products (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, project_id TEXT, task_id TEXT NOT NULL, type TEXT NOT NULL, provider TEXT NOT NULL, external_id TEXT, title TEXT NOT NULL, url TEXT, status TEXT NOT NULL DEFAULT 'active', review_state TEXT NOT NULL DEFAULT 'none', is_primary INTEGER NOT NULL DEFAULT 0, summary TEXT, metadata_json TEXT, created_by_run_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type); CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at); CREATE TABLE IF NOT EXISTS task_documents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, document_id TEXT NOT NULL, key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id)`); + if (!columnExists(db, "wakeup_requests", "context_snapshot")) { + db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); + } + }, + }, + { + version: 22, name: "add_config_revisions_run_events_agent_permissions", + fn: () => { + db.exec(`CREATE TABLE IF NOT EXISTS agent_config_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, revision_number INTEGER NOT NULL, changed_keys TEXT NOT NULL DEFAULT '[]', config_before TEXT NOT NULL, config_after TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'patch', rolled_back_from_revision_id TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number); CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at); CREATE TABLE IF NOT EXISTS run_events (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id TEXT NOT NULL, run_id TEXT NOT NULL, agent_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, stream TEXT, level TEXT, message TEXT, payload TEXT, created_at TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq); CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id)`); + const addCol = (table: string, column: string, sql: string) => { + if (!columnExists(db, table, column)) db.prepare(sql).run(); + }; + addCol("agents", "permissions_json", "ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'"); + addCol("agents", "heartbeat_policy_json", "ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT"); + addCol("agents", "runtime_state_json", "ALTER TABLE agents ADD COLUMN runtime_state_json TEXT"); + addCol("runs", "context_snapshot_json", "ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT"); + addCol("runs", "actor_type", "ALTER TABLE runs ADD COLUMN actor_type TEXT"); + addCol("runs", "actor_id", "ALTER TABLE runs ADD COLUMN actor_id TEXT"); + }, + }, + { + version: 23, name: "create_thread_runtime_tables", + fn: () => { + db.exec(` + CREATE TABLE IF NOT EXISTS conversation_threads ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + title TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'task', + related_entity_type TEXT, + related_entity_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_conv_threads_company ON conversation_threads(company_id); + CREATE INDEX IF NOT EXISTS idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id); + + CREATE TABLE IF NOT EXISTS conversation_events ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + actor_id TEXT NOT NULL, + actor_type TEXT NOT NULL, + event_type TEXT NOT NULL, + body TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_conv_events_thread ON conversation_events(thread_id, created_at); + + CREATE TABLE IF NOT EXISTS thread_wait_states ( + id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + company_id TEXT NOT NULL, + waiting_for TEXT NOT NULL, + related_approval_id TEXT, + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_thread_wait_company ON thread_wait_states(company_id, resolved_at); + CREATE INDEX IF NOT EXISTS idx_thread_wait_thread ON thread_wait_states(thread_id); + + CREATE TABLE IF NOT EXISTS conversation_participants ( + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + participant_id TEXT NOT NULL, + participant_type TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + joined_at TEXT NOT NULL, + PRIMARY KEY (thread_id, participant_id) + ); + CREATE INDEX IF NOT EXISTS idx_conv_participants_participant ON conversation_participants(participant_id); + `); + }, + }, + { + version: 24, name: "create_thread_subscriptions", + sql: `CREATE TABLE IF NOT EXISTS thread_subscriptions ( + thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, + subscriber_id TEXT NOT NULL, + subscriber_type TEXT NOT NULL, + notify_on TEXT NOT NULL DEFAULT 'all', + subscribed_at TEXT NOT NULL, + PRIMARY KEY (thread_id, subscriber_id) + ); + CREATE INDEX IF NOT EXISTS idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)`, + }, + { + version: 25, name: "add_sender_kind_to_messages", + sql: `ALTER TABLE agent_messages ADD COLUMN sender_kind TEXT NOT NULL DEFAULT 'agent'`, + }, + ]; +} diff --git a/src/main/database/migrations/v26-v34.ts b/src/main/database/migrations/v26-v34.ts new file mode 100644 index 00000000..acd27933 --- /dev/null +++ b/src/main/database/migrations/v26-v34.ts @@ -0,0 +1,158 @@ +import { randomUUID } from "node:crypto"; +import type { Migration, MigrationDeps } from "./types"; +import { columnExists, tableExists } from "./types"; + +export function createMigrationsV26V34(deps: MigrationDeps): Migration[] { + const { db, readAll } = deps; + return [ + { + version: 26, name: "add_source_run_id_for_causal_binding", + sql: `ALTER TABLE documents ADD COLUMN source_run_id TEXT; ALTER TABLE knowledge_base ADD COLUMN source_run_id TEXT`, + }, + { + version: 27, name: "backfill_task_sessions_from_tasks_session_json", + fn: () => { + const legacyTasks = readAll<{ + id: string; + companyId: string; + assigneeAgentId: string | null; + sessionJson: string; + sessionDisplayId: string | null; + updatedAt: string; + connectorId: string | null; + }>( + `SELECT t.id AS id, t.company_id AS companyId, t.assignee_agent_id AS assigneeAgentId, + t.session_json AS sessionJson, t.session_display_id AS sessionDisplayId, + t.updated_at AS updatedAt, a.connector_id AS connectorId + FROM tasks t + LEFT JOIN agents a ON a.id = t.assignee_agent_id + WHERE t.session_json IS NOT NULL + AND t.id NOT IN (SELECT task_id FROM task_sessions)`, + ); + for (const task of legacyTasks) { + const sessionId = randomUUID(); + db.prepare( + `INSERT OR IGNORE INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + sessionId, + task.companyId, + task.id, + task.assigneeAgentId ?? "unassigned", + task.connectorId ?? "unknown", + task.sessionJson, + task.sessionDisplayId, + task.updatedAt, + task.updatedAt, + ); + db.prepare( + "UPDATE tasks SET active_session_id = ? WHERE id = ? AND active_session_id IS NULL", + ).run(sessionId, task.id); + } + }, + }, + { + version: 28, name: "add_snapshot_hot_path_indexes", + fn: () => { + // Some tables (task_comments / documents / meetings / sprints / agent_messages) + // are created by ensureOptionalColumns, which runs AFTER runMigrations on + // a fresh DB. Guard each CREATE INDEX with a tableExists check so the + // migration is safe on both fresh and migrated databases. + const indexPlan: Array<{ table: string; sql: string }> = [ + { table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)" }, + { table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)" }, + { table: "approvals", sql: "CREATE INDEX IF NOT EXISTS idx_approvals_company_created_at ON approvals(company_id, created_at DESC)" }, + { table: "task_comments", sql: "CREATE INDEX IF NOT EXISTS idx_task_comments_company_created_at ON task_comments(company_id, created_at)" }, + { table: "documents", sql: "CREATE INDEX IF NOT EXISTS idx_documents_company_updated_at ON documents(company_id, updated_at DESC)" }, + { table: "meetings", sql: "CREATE INDEX IF NOT EXISTS idx_meetings_company_scheduled_at ON meetings(company_id, scheduled_at DESC)" }, + { table: "sprints", sql: "CREATE INDEX IF NOT EXISTS idx_sprints_company_start_date ON sprints(company_id, start_date DESC)" }, + { table: "runs", sql: "CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)" }, + { table: "cost_entries", sql: "CREATE INDEX IF NOT EXISTS idx_cost_entries_agent_created_at ON cost_entries(agent_id, created_at DESC)" }, + { table: "activity", sql: "CREATE INDEX IF NOT EXISTS idx_activity_company_actor_action ON activity(company_id, actor, action)" }, + { table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)" }, + ]; + for (const { table, sql } of indexPlan) { + if (tableExists(db, table)) db.exec(sql); + } + }, + }, + { + version: 29, name: "add_run_events_seq_unique_index", + sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_run_events_run_seq_unique ON run_events(run_id, seq)`, + }, + { + // Supports checkpoint-engine `shouldPauseHeartbeat` join + filter on + // (company_id, state, type) -> assignee_agent_id lookup. Without this + // SQLite scans the full approvals table on every heartbeat tick. + version: 30, name: "add_checkpoint_pause_indexes", + sql: `CREATE INDEX IF NOT EXISTS idx_approvals_company_state_type ON approvals(company_id, state, type); + CREATE INDEX IF NOT EXISTS idx_approvals_related_task ON approvals(related_task_id)`, + }, + { + // Typed seniority enum replaces English keyword substring matching for + // leadership detection. Free-text `role` stays UI-editable; `role_level` + // is inferred once on insert/update via inferRoleLevel(). + version: 31, name: "add_agents_role_level", + fn: () => { + if (!columnExists(db, "agents", "role_level")) { + db.prepare("ALTER TABLE agents ADD COLUMN role_level TEXT NOT NULL DEFAULT 'ic'").run(); + } + }, + }, + { + // Explicit flag for synthesized heartbeat tasks. Replaces title-prefix + // sniffing (`title LIKE '[Heartbeat]%'`) across queries, filters, and + // automation gates. Backfills from existing `[Heartbeat]` titles so the + // new column agrees with historical data on first boot. + version: 33, name: "add_tasks_is_heartbeat", + fn: () => { + if (!columnExists(db, "tasks", "is_heartbeat")) { + db.prepare("ALTER TABLE tasks ADD COLUMN is_heartbeat INTEGER NOT NULL DEFAULT 0").run(); + } + db.prepare("UPDATE tasks SET is_heartbeat = 1 WHERE is_heartbeat = 0 AND title LIKE '[Heartbeat]%'").run(); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_is_heartbeat ON tasks(is_heartbeat)"); + }, + }, + { + // pi-mono session transcripts + credential vault. Idempotent; safe to + // re-apply on fresh DBs or DBs that already have the tables. + version: 32, name: "create_pi_mono_session_and_credentials_tables", + sql: `CREATE TABLE IF NOT EXISTS session_entries ( + id TEXT PRIMARY KEY, + task_session_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + kind TEXT NOT NULL, + role TEXT, + payload_json TEXT NOT NULL, + is_superseded INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_session_entries_session ON session_entries(task_session_id, sequence); + CREATE INDEX IF NOT EXISTS idx_session_entries_active ON session_entries(task_session_id, is_superseded, sequence); + CREATE TABLE IF NOT EXISTS credentials ( + connector_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + backend TEXT NOT NULL, + ciphertext BLOB NOT NULL, + display_hint TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + expires_at TEXT, + last_refreshed_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status)`, + }, + { + // Per-agent session scope. Agents carry a single active session across + // tasks — switching tasks becomes a "now work on X" user turn on the + // agent's ongoing transcript. Additive-only: existing task_sessions + // rows remain as historical records; new runs use the agent-scoped + // session created lazily on first post-upgrade run. + version: 34, name: "add_agents_session_id", + sql: `ALTER TABLE agents ADD COLUMN session_id TEXT; + CREATE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id)`, + }, + ]; +} diff --git a/src/main/database/migrations/v35-v37.ts b/src/main/database/migrations/v35-v37.ts new file mode 100644 index 00000000..88d0a991 --- /dev/null +++ b/src/main/database/migrations/v35-v37.ts @@ -0,0 +1,326 @@ +import log from "electron-log/main.js"; +import type { Migration, MigrationDeps } from "./types"; +import { columnExists, tableExists } from "./types"; + +export function createMigrationsV35V37(deps: MigrationDeps): Migration[] { + const { db } = deps; + return [ + { + // Rebuild runs table with task_id NULLABLE. Heartbeat runs (agent + // wake-ups with no real task) no longer need to synthesize a fake + // task row; they simply insert a run with task_id = NULL. The old + // workaround (is_heartbeat flag + acquireHeartbeatTask + cleanup + // sweeps) becomes dead code in v36. + // + // SQLite can't ALTER a column's NOT NULL constraint, so we rebuild: + // 1. Snapshot row count + // 2. Discover actual column set via PRAGMA (drift safe) + // 3. CREATE runs_new with the same columns + task_id nullable + // 4. Copy every row + // 5. Assert copied count == snapshot count (abort before DROP + // if any row went missing) + // 6. DROP old, RENAME new + // 7. Recreate every index on runs + // + // FK from run_events(run_id) references runs(id); PRAGMA foreign_keys + // OFF during the rebuild (we're inside a transaction already, so we + // toggle the defer_foreign_keys flag instead — SQLite forbids + // changing foreign_keys mid-transaction). + version: 35, name: "make_runs_task_id_nullable", + fn: () => { + const beforeCount = (db + .prepare("SELECT COUNT(*) as cnt FROM runs") + .get() as { cnt: number }).cnt; + + const existingCols = (db + .prepare("PRAGMA table_info(runs)") + .all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>); + if (existingCols.length === 0) { + throw new Error("runs table is missing; refusing to migrate"); + } + const colNames = existingCols.map((c) => c.name); + + // Declare the known v34 column set. If the live DB has drift (extra + // columns we did not declare), preserve those verbatim using the + // column metadata from PRAGMA. + const knownCols = new Set([ + "id", "company_id", "task_id", "agent_id", "workspace_id", + "connector_id", "status", "summary", "error_message", "exit_code", + "signal", "model", "session_display_id", "cost_usd", + "unattributed_cost", "started_at", "finished_at", "log_path", + "created_at", "updated_at", "context_snapshot_json", + "actor_type", "actor_id", + ]); + const driftCols = existingCols.filter((c) => !knownCols.has(c.name)); + + // Defer FK checks within this transaction. + db.exec("PRAGMA defer_foreign_keys = ON"); + + // Build CREATE TABLE runs_new. task_id is nullable. Other columns + // reuse the declared types / constraints. For drift columns we + // emit a best-effort column definition (type, NULL, default). + const declaredDdl = [ + "id TEXT PRIMARY KEY", + "company_id TEXT NOT NULL", + "task_id TEXT", // NULLABLE (was NOT NULL) + "agent_id TEXT NOT NULL", + "workspace_id TEXT", + "connector_id TEXT NOT NULL", + "status TEXT NOT NULL", + "summary TEXT NOT NULL", + "error_message TEXT", + "exit_code INTEGER", + "signal TEXT", + "model TEXT", + "session_display_id TEXT", + "cost_usd REAL", + "unattributed_cost INTEGER NOT NULL DEFAULT 0", + "started_at TEXT", + "finished_at TEXT", + "log_path TEXT", + "created_at TEXT NOT NULL", + "updated_at TEXT NOT NULL", + "context_snapshot_json TEXT", + "actor_type TEXT", + "actor_id TEXT", + ]; + for (const drift of driftCols) { + const type = drift.type || "TEXT"; + declaredDdl.push(`${drift.name} ${type}`); + } + + db.exec(`CREATE TABLE runs_new (${declaredDdl.join(", ")})`); + + // Copy preserving every column present in the old table. + const copyCols = colNames.join(", "); + db.exec(`INSERT INTO runs_new (${copyCols}) SELECT ${copyCols} FROM runs`); + + const afterCount = (db + .prepare("SELECT COUNT(*) as cnt FROM runs_new") + .get() as { cnt: number }).cnt; + if (afterCount !== beforeCount) { + throw new Error( + `runs copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`, + ); + } + + db.exec("DROP TABLE runs"); + db.exec("ALTER TABLE runs_new RENAME TO runs"); + + // Recreate every index on runs. + db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company ON runs(company_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_status ON runs(company_id, status)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_created_at ON runs(company_id, created_at DESC)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)"); + + log.info(`[migration v35] runs table rebuilt with nullable task_id (${afterCount} rows preserved)`); + }, + }, + { + // Drop the `tasks.is_heartbeat` column and all synthetic heartbeat + // task rows. Prerequisite: v35 must have made runs.task_id nullable + // so we can null-out runs that pointed at heartbeat tasks before + // deleting those task rows. + version: 36, name: "drop_tasks_is_heartbeat_and_synthetic_rows", + fn: () => { + const hasColumn = columnExists(db, "tasks", "is_heartbeat"); + if (!hasColumn) { + log.info("[migration v36] tasks.is_heartbeat already absent; skipping"); + return; + } + + // 1. NULL-out runs that pointed at heartbeat tasks. + const updated = db + .prepare( + "UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)", + ) + .run(); + const runsNulledOut = Number(updated.changes); + + // 2. Delete synthetic heartbeat tasks. Cost entries referring to + // these tasks remain with their task_id pointing at now-deleted + // ids — acceptable, cost_entries.task_id has no FK constraint. + // task_comments and approvals attached to these tasks are + // already cascaded by the task row delete or would remain + // orphaned; heartbeat tasks carry neither in practice. + const beforeHeartbeatCount = (db + .prepare("SELECT COUNT(*) as cnt FROM tasks WHERE is_heartbeat = 1") + .get() as { cnt: number }).cnt; + db.exec("DELETE FROM tasks WHERE is_heartbeat = 1"); + + // 3. Rebuild `tasks` without the is_heartbeat column. + const beforeTasksCount = (db + .prepare("SELECT COUNT(*) as cnt FROM tasks") + .get() as { cnt: number }).cnt; + + const existingCols = (db + .prepare("PRAGMA table_info(tasks)") + .all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>); + const keepCols = existingCols.filter((c) => c.name !== "is_heartbeat"); + + db.exec("PRAGMA defer_foreign_keys = ON"); + + // Declared v20 + v13 + v38+v39/v20 column set for `tasks`. Drift + // columns (any column we haven't declared here) are preserved + // verbatim using PRAGMA metadata — same drift-safe shape as v35. + const declaredCols = new Map([ + ["id", "TEXT PRIMARY KEY"], + ["company_id", "TEXT NOT NULL"], + ["project_id", "TEXT"], + ["goal_id", "TEXT"], + ["parent_id", "TEXT"], + ["title", "TEXT NOT NULL"], + ["description", "TEXT NOT NULL"], + ["assignee_agent_id", "TEXT"], + ["workspace_id", "TEXT"], + ["status", "TEXT NOT NULL"], + ["priority", "TEXT NOT NULL"], + ["active_run_id", "TEXT"], + ["session_json", "TEXT"], + ["session_display_id", "TEXT"], + ["cost_usd", "REAL NOT NULL DEFAULT 0"], + ["created_at", "TEXT NOT NULL"], + ["updated_at", "TEXT NOT NULL"], + ["task_type", "TEXT NOT NULL DEFAULT 'general'"], + ["requires_user_review", "INTEGER NOT NULL DEFAULT 0"], + ["retry_count", "INTEGER NOT NULL DEFAULT 0"], + ["last_failure_at", "TEXT"], + ["checkout_run_id", "TEXT"], + ["execution_locked_at", "TEXT"], + ["active_session_id", "TEXT"], + ["started_at", "TEXT"], + ["completed_at", "TEXT"], + ["cancelled_at", "TEXT"], + ]); + + const ddl = keepCols.map((c) => { + const declared = declaredCols.get(c.name); + if (declared) return `${c.name} ${declared}`; + return `${c.name} ${c.type || "TEXT"}`; + }); + + db.exec(`CREATE TABLE tasks_new (${ddl.join(", ")})`); + + const copyCols = keepCols.map((c) => c.name).join(", "); + db.exec(`INSERT INTO tasks_new (${copyCols}) SELECT ${copyCols} FROM tasks`); + + const afterTasksCount = (db + .prepare("SELECT COUNT(*) as cnt FROM tasks_new") + .get() as { cnt: number }).cnt; + if (afterTasksCount !== beforeTasksCount) { + throw new Error( + `tasks copy row count mismatch: before=${beforeTasksCount} after=${afterTasksCount}; aborting before DROP`, + ); + } + + db.exec("DROP TABLE tasks"); + db.exec("ALTER TABLE tasks_new RENAME TO tasks"); + + // Recreate every index on tasks. + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)"); + + log.info( + `[migration v36] removed tasks.is_heartbeat; deleted ${beforeHeartbeatCount} synthetic heartbeat task row(s); nulled task_id on ${runsNulledOut} run(s); preserved ${afterTasksCount} task row(s)`, + ); + }, + }, + { + // Drop the `markdown` column from company_skills. SKILL.md on disk + // (profile/skills/{companyId}/{slug}/SKILL.md) is the authoritative + // source of truth read by pi-coding-agent's loadSkills. The DB column + // duplicated that content. UI now loads the body live via IPC. + // + // SQLite can't DROP COLUMN cleanly pre-3.35 and even the supported path + // is implemented as an invisible rebuild, so we do an explicit + // drift-safe rebuild mirroring v35/v36: + // 1. Snapshot row count + // 2. PRAGMA table_info to discover the live column set + // 3. CREATE company_skills_new without `markdown` + // 4. Copy every kept column + // 5. Assert copied count matches + // 6. DROP old, RENAME new, recreate indexes + // + // FK from agent_skill_assignments(skill_id) -> company_skills(id) must + // survive. PRAGMA defer_foreign_keys = ON keeps checks deferred until + // transaction commit (by which time RENAME has restored the table). + version: 37, name: "drop_company_skills_markdown", + fn: () => { + if (!tableExists(db, "company_skills")) { + log.info("[migration v37] company_skills table absent; skipping"); + return; + } + if (!columnExists(db, "company_skills", "markdown")) { + log.info("[migration v37] company_skills.markdown already absent; skipping"); + return; + } + + const beforeCount = (db + .prepare("SELECT COUNT(*) as cnt FROM company_skills") + .get() as { cnt: number }).cnt; + + const existingCols = (db + .prepare("PRAGMA table_info(company_skills)") + .all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>); + const keepCols = existingCols.filter((c) => c.name !== "markdown"); + + db.exec("PRAGMA defer_foreign_keys = ON"); + + // Declared v14 column set (minus markdown). Drift columns preserved + // verbatim from PRAGMA metadata. + const declaredCols = new Map([ + ["id", "TEXT PRIMARY KEY"], + ["company_id", "TEXT NOT NULL"], + ["key", "TEXT NOT NULL"], + ["slug", "TEXT NOT NULL"], + ["name", "TEXT NOT NULL"], + ["description", "TEXT"], + ["source_type", "TEXT NOT NULL DEFAULT 'local_path'"], + ["source_locator", "TEXT"], + ["source_ref", "TEXT"], + ["trust_level", "TEXT NOT NULL DEFAULT 'markdown_only'"], + ["compatibility", "TEXT NOT NULL DEFAULT 'compatible'"], + ["file_inventory", "TEXT NOT NULL DEFAULT '[]'"], + ["metadata", "TEXT"], + ["created_at", "TEXT NOT NULL"], + ["updated_at", "TEXT NOT NULL"], + ]); + + const ddl = keepCols.map((c) => { + const declared = declaredCols.get(c.name); + if (declared) return `${c.name} ${declared}`; + return `${c.name} ${c.type || "TEXT"}`; + }); + + db.exec(`CREATE TABLE company_skills_new (${ddl.join(", ")})`); + + const copyCols = keepCols.map((c) => c.name).join(", "); + db.exec(`INSERT INTO company_skills_new (${copyCols}) SELECT ${copyCols} FROM company_skills`); + + const afterCount = (db + .prepare("SELECT COUNT(*) as cnt FROM company_skills_new") + .get() as { cnt: number }).cnt; + if (afterCount !== beforeCount) { + throw new Error( + `company_skills copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`, + ); + } + + db.exec("DROP TABLE company_skills"); + db.exec("ALTER TABLE company_skills_new RENAME TO company_skills"); + + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key)"); + db.exec("CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name)"); + + log.info(`[migration v37] dropped company_skills.markdown; preserved ${afterCount} row(s)`); + }, + }, + ]; +} diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index fe4543ef..3a4b1c2a 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -1,25 +1,16 @@ -import { randomUUID } from "node:crypto"; -import log from "electron-log/main.js"; -import type { DatabaseSync, SQLInputValue } from "node:sqlite"; - -type ReadAll = (sql: string, ...params: SQLInputValue[]) => T[]; -type Transaction = (operation: () => T) => T; - -function nowIso() { - return new Date().toISOString(); -} - -function columnExists(db: DatabaseSync, table: string, column: string): boolean { - const rows = db.prepare(`pragma table_info(${table})`).all() as Array<{ name: string }>; - return rows.some((row) => row.name === column); -} - -function tableExists(db: DatabaseSync, table: string): boolean { - const row = db - .prepare("select name from sqlite_master where type = 'table' and name = ?") - .get(table) as { name: string } | undefined; - return !!row; -} +import type { DatabaseSync } from "node:sqlite"; +import { createMigrationsV1V15 } from "./migrations/v1-v15"; +import { createMigrationsV16V25 } from "./migrations/v16-v25"; +import { createMigrationsV26V34 } from "./migrations/v26-v34"; +import { createMigrationsV35V37 } from "./migrations/v35-v37"; +import { + columnExists, + nowIso, + tableExists, + type Migration, + type ReadAll, + type Transaction, +} from "./migrations/types"; export function ensureOptionalColumns(db: DatabaseSync) { const addColumnIfMissing = (table: string, column: string, sql: string) => { @@ -175,660 +166,12 @@ export function runMigrations( db.exec(`create table if not exists schema_migrations (version integer primary key, name text not null, applied_at text not null);`); const applied = new Set(readAll<{ version: number }>("select version from schema_migrations").map((row) => row.version)); - const migrations: Array<{ version: number; name: string; sql?: string; fn?: () => void }> = [ - { version: 1, name: "add_workspace_lock_lease", sql: `alter table workspaces add column lock_holder_run_id text; alter table workspaces add column lock_acquired_at text` }, - { version: 2, name: "add_approval_related_agent_id", sql: `alter table approvals add column related_agent_id text` }, - { version: 3, name: "add_profile_metadata", sql: `insert or ignore into settings (key, value) values ('profile_version', '1'); insert or ignore into settings (key, value) values ('profile_created_at', '${nowIso()}')` }, - { version: 4, name: "add_connector_capability_snapshot", sql: `alter table connectors add column capability_json text` }, - { version: 6, name: "add_agent_department", sql: `alter table agents add column department text` }, - { version: 7, name: "add_meetings_table", sql: `create table if not exists meetings (id text primary key, company_id text not null, type text not null, title text not null, organizer_agent_id text, participant_agent_ids text not null default '[]', scheduled_at text not null, duration_minutes integer not null default 30, agenda_json text not null default '[]', notes_json text not null default '[]', decisions_json text not null default '[]', action_items_json text not null default '[]', status text not null default 'scheduled', created_at text not null, updated_at text not null); create index if not exists idx_meetings_company on meetings(company_id); create index if not exists idx_meetings_organizer on meetings(organizer_agent_id)` }, - { version: 8, name: "add_documents_table", sql: `create table if not exists documents (id text primary key, company_id text not null, type text not null, title text not null, content text not null default '', author_agent_id text, reviewer_agent_id text, project_id text, goal_id text, parent_doc_id text, version integer not null default 1, status text not null default 'draft', tags_json text not null default '[]', created_at text not null, updated_at text not null); create index if not exists idx_documents_company on documents(company_id); create index if not exists idx_documents_author on documents(author_agent_id); create index if not exists idx_documents_project on documents(project_id)` }, - { version: 9, name: "add_knowledge_base_table", sql: `create table if not exists knowledge_base (id text primary key, company_id text not null, category text not null, topic text not null, content text not null, author_agent_id text, importance text not null default 'medium', tags_json text not null default '[]', referenced_entity_type text, referenced_entity_id text, created_at text not null, updated_at text not null); create index if not exists idx_knowledge_company on knowledge_base(company_id); create index if not exists idx_knowledge_category on knowledge_base(category)` }, - { version: 10, name: "add_sprints_table", sql: `create table if not exists sprints (id text primary key, company_id text not null, name text not null, goal text not null default '', start_date text not null, end_date text not null, status text not null default 'planning', retrospective_notes text not null default '', velocity_points integer not null default 0, completed_points integer not null default 0, created_at text not null, updated_at text not null); create index if not exists idx_sprints_company on sprints(company_id)` }, - { version: 11, name: "add_automation_messages_workflows", sql: `create table if not exists automation_rules (id text primary key, company_id text not null, name text not null, description text not null default '', trigger text not null, conditions_json text not null default '{}', action text not null, action_config_json text not null default '{}', source_department text, target_department text, priority integer not null default 50, status text not null default 'active', execution_count integer not null default 0, last_executed_at text, created_at text not null, updated_at text not null); create index if not exists idx_automation_rules_company on automation_rules(company_id); create index if not exists idx_automation_rules_trigger on automation_rules(trigger); create table if not exists agent_messages (id text primary key, company_id text not null, from_agent_id text not null, to_agent_id text, channel text not null default 'direct', channel_target_id text, subject text not null, body text not null, priority text not null default 'normal', read_at text, parent_message_id text, attachments_json text not null default '[]', created_at text not null); create index if not exists idx_agent_messages_company on agent_messages(company_id); create index if not exists idx_agent_messages_to on agent_messages(to_agent_id); create index if not exists idx_agent_messages_from on agent_messages(from_agent_id); create index if not exists idx_agent_messages_channel on agent_messages(channel); create table if not exists workflow_pipelines (id text primary key, company_id text not null, name text not null, description text not null default '', steps_json text not null default '[]', trigger_type text not null, trigger_config_json text not null default '{}', status text not null default 'draft', current_step_index integer not null default 0, run_count integer not null default 0, last_run_at text, created_at text not null, updated_at text not null); create index if not exists idx_workflows_company on workflow_pipelines(company_id); create table if not exists automation_log (id text primary key, company_id text not null, rule_id text not null, rule_name text not null, trigger text not null, action text not null, detail text not null default '', executed_at text not null); create index if not exists idx_automation_log_company on automation_log(company_id)` }, - { version: 12, name: "add_agent_message_reads", sql: `create table if not exists agent_message_reads (message_id text not null, agent_id text not null, read_at text not null, primary key (message_id, agent_id)); create index if not exists idx_agent_message_reads_agent on agent_message_reads(agent_id)` }, - { version: 13, name: "add_checkout_session_budget_columns", sql: `ALTER TABLE tasks ADD COLUMN checkout_run_id TEXT; ALTER TABLE tasks ADD COLUMN execution_locked_at TEXT; ALTER TABLE tasks ADD COLUMN active_session_id TEXT; ALTER TABLE cost_entries ADD COLUMN project_id TEXT; CREATE INDEX IF NOT EXISTS idx_cost_entries_project ON cost_entries(project_id)` }, - { version: 14, name: "create_skills_budget_wakeup_session_tables", sql: `CREATE TABLE IF NOT EXISTS company_skills (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, key TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT, markdown TEXT NOT NULL DEFAULT '', source_type TEXT NOT NULL DEFAULT 'local_path', source_locator TEXT, source_ref TEXT, trust_level TEXT NOT NULL DEFAULT 'markdown_only', compatibility TEXT NOT NULL DEFAULT 'compatible', file_inventory TEXT NOT NULL DEFAULT '[]', metadata TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key); CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name); CREATE TABLE IF NOT EXISTS budget_policies (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL DEFAULT 'billed_cents', window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', amount INTEGER NOT NULL DEFAULT 0, warn_percent INTEGER NOT NULL DEFAULT 80, hard_stop_enabled INTEGER NOT NULL DEFAULT 1, notify_enabled INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active); CREATE UNIQUE INDEX IF NOT EXISTS idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind); CREATE TABLE IF NOT EXISTS budget_incidents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, policy_id TEXT NOT NULL REFERENCES budget_policies(id), scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL, window_kind TEXT NOT NULL, window_start TEXT NOT NULL, window_end TEXT NOT NULL, threshold_type TEXT NOT NULL, amount_limit INTEGER NOT NULL, amount_observed INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_status ON budget_incidents(company_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status); CREATE INDEX IF NOT EXISTS idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type); CREATE TABLE IF NOT EXISTS task_sessions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, adapter_type TEXT NOT NULL, session_params_json TEXT, session_display_id TEXT, last_run_id TEXT, last_error TEXT, run_count INTEGER NOT NULL DEFAULT 0, total_input_tokens INTEGER NOT NULL DEFAULT 0, total_output_tokens INTEGER NOT NULL DEFAULT 0, rotated_at TEXT, rotation_reason TEXT, handoff_markdown TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_task_sessions_lookup ON task_sessions(company_id, agent_id, adapter_type, task_id); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_agent ON task_sessions(company_id, agent_id, updated_at); CREATE INDEX IF NOT EXISTS idx_task_sessions_company_task ON task_sessions(company_id, task_id, updated_at); CREATE TABLE IF NOT EXISTS wakeup_requests (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, trigger_detail TEXT, reason TEXT, payload TEXT, status TEXT NOT NULL DEFAULT 'queued', coalesced_count INTEGER NOT NULL DEFAULT 0, requested_by_actor_type TEXT, requested_by_actor_id TEXT, idempotency_key TEXT, run_id TEXT, requested_at TEXT NOT NULL, claimed_at TEXT, finished_at TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at); CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)` }, - { - version: 15, name: "migrate_session_data_to_task_sessions", - fn: () => { - const tasksWithSessions = readAll<{ id: string; companyId: string; assigneeAgentId: string | null; sessionJson: string | null; sessionDisplayId: string | null }>( - `SELECT id, company_id as companyId, assignee_agent_id as assigneeAgentId, session_json as sessionJson, session_display_id as sessionDisplayId FROM tasks WHERE session_json IS NOT NULL AND active_session_id IS NULL`, - ); - let migrated = 0; - let skipped = 0; - for (const task of tasksWithSessions) { - try { - if (!task.sessionJson) continue; - JSON.parse(task.sessionJson); - const agent = task.assigneeAgentId ? db.prepare("SELECT connector_id FROM agents WHERE id = ?").get(task.assigneeAgentId) as { connector_id: string } | undefined : undefined; - const adapterType = agent?.connector_id ?? "unknown"; - const sessionId = randomUUID(); - const ts = nowIso(); - db.prepare(`INSERT INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, task.companyId, task.id, task.assigneeAgentId ?? "unassigned", adapterType, task.sessionJson, task.sessionDisplayId, ts, ts); - db.prepare("UPDATE tasks SET active_session_id = ? WHERE id = ?").run(sessionId, task.id); - migrated++; - } catch (err) { - log.warn(`Session migration skipped task ${task.id}: ${err instanceof Error ? err.message : String(err)}`); - skipped++; - } - } - log.info(`Session migration complete: ${migrated} migrated, ${skipped} skipped`); - }, - }, - { version: 16, name: "create_agent_skill_assignments", sql: `CREATE TABLE IF NOT EXISTS agent_skill_assignments (agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (agent_id, skill_id), FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)` }, - { - version: 17, name: "migrate_pending_wakes_to_wakeup_requests", - fn: () => { - const existingWakes = readAll<{ id: string; companyId: string; agentId: string; triggerReason: string; createdAt: string }>( - `SELECT id, company_id AS companyId, agent_id AS agentId, trigger_reason AS triggerReason, created_at AS createdAt FROM pending_wakes`, - ); - const ts = nowIso(); - for (const wake of existingWakes) { - const triggerDetail = JSON.stringify([{ trigger: wake.triggerReason, at: wake.createdAt, detail: wake.triggerReason }]); - db.prepare(`INSERT OR IGNORE INTO wakeup_requests (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`).run(wake.id, wake.companyId, wake.agentId, wake.triggerReason, triggerDetail, wake.createdAt, ts, ts); - } - db.exec("DROP TABLE IF EXISTS pending_wakes"); - }, - }, - { - version: 18, name: "migrate_agent_budget_to_policies", - fn: () => { - const agents = readAll<{ id: string; companyId: string; budgetMonthlyUsd: number }>("SELECT id, company_id as companyId, budget_monthly_usd as budgetMonthlyUsd FROM agents WHERE budget_monthly_usd > 0"); - const ts = nowIso(); - for (const agent of agents) { - const existing = db.prepare("SELECT id FROM budget_policies WHERE company_id = ? AND scope_type = 'agent' AND scope_id = ? AND metric = 'billed_cents' AND window_kind = 'calendar_month_utc'").get(agent.companyId, agent.id); - if (!existing) { - db.prepare("INSERT INTO budget_policies (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, hard_stop_enabled, notify_enabled, is_active, created_at, updated_at) VALUES (?, ?, 'agent', ?, 'billed_cents', 'calendar_month_utc', ?, 80, 1, 1, 1, ?, ?)").run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts); - } - } - log.info(`Budget migration: converted ${agents.length} agent budget(s) to policy rows`); - }, - }, - { - version: 19, name: "add_document_revisions_and_approval_comments", - fn: () => { - db.exec(`CREATE TABLE IF NOT EXISTS document_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, document_id TEXT NOT NULL, revision_number INTEGER NOT NULL, body TEXT NOT NULL, change_summary TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_doc_rev_doc_num ON document_revisions(document_id, revision_number); CREATE INDEX IF NOT EXISTS idx_doc_rev_company ON document_revisions(company_id, document_id, created_at); CREATE TABLE IF NOT EXISTS approval_comments (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, approval_id TEXT NOT NULL, author_agent_id TEXT, author_user_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_approval_comments_approval ON approval_comments(approval_id, created_at)`); - if (!columnExists(db, "documents", "latest_revision_id")) { - db.prepare("ALTER TABLE documents ADD COLUMN latest_revision_id TEXT").run(); - } - }, - }, - { - version: 20, name: "add_task_timestamps_and_task_approval_links", - fn: () => { - const addCol = (table: string, column: string, sql: string) => { - if (!columnExists(db, table, column)) db.prepare(sql).run(); - }; - addCol("tasks", "started_at", "ALTER TABLE tasks ADD COLUMN started_at TEXT"); - addCol("tasks", "completed_at", "ALTER TABLE tasks ADD COLUMN completed_at TEXT"); - addCol("tasks", "cancelled_at", "ALTER TABLE tasks ADD COLUMN cancelled_at TEXT"); - db.exec(`CREATE TABLE IF NOT EXISTS task_approval_links (task_id TEXT NOT NULL, approval_id TEXT NOT NULL, company_id TEXT NOT NULL, linked_by_agent_id TEXT, created_at TEXT NOT NULL, PRIMARY KEY (task_id, approval_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_task_approval_links_task ON task_approval_links(task_id); CREATE INDEX IF NOT EXISTS idx_task_approval_links_approval ON task_approval_links(approval_id)`); - db.prepare("UPDATE tasks SET started_at = updated_at WHERE started_at IS NULL AND status IN ('in_progress', 'in_review', 'done')").run(); - db.prepare("UPDATE tasks SET completed_at = updated_at WHERE completed_at IS NULL AND status = 'done'").run(); - db.prepare("UPDATE tasks SET cancelled_at = updated_at WHERE cancelled_at IS NULL AND status = 'cancelled'").run(); - }, - }, - { - version: 21, name: "add_work_products_task_documents_wakeup_context", - fn: () => { - db.exec(`CREATE TABLE IF NOT EXISTS work_products (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, project_id TEXT, task_id TEXT NOT NULL, type TEXT NOT NULL, provider TEXT NOT NULL, external_id TEXT, title TEXT NOT NULL, url TEXT, status TEXT NOT NULL DEFAULT 'active', review_state TEXT NOT NULL DEFAULT 'none', is_primary INTEGER NOT NULL DEFAULT 0, summary TEXT, metadata_json TEXT, created_by_run_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_work_products_task ON work_products(company_id, task_id, type); CREATE INDEX IF NOT EXISTS idx_work_products_updated ON work_products(company_id, updated_at); CREATE TABLE IF NOT EXISTS task_documents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, document_id TEXT NOT NULL, key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_task_key ON task_documents(company_id, task_id, key); CREATE UNIQUE INDEX IF NOT EXISTS idx_task_documents_doc ON task_documents(document_id)`); - if (!columnExists(db, "wakeup_requests", "context_snapshot")) { - db.prepare("ALTER TABLE wakeup_requests ADD COLUMN context_snapshot TEXT").run(); - } - }, - }, - { - version: 22, name: "add_config_revisions_run_events_agent_permissions", - fn: () => { - db.exec(`CREATE TABLE IF NOT EXISTS agent_config_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, revision_number INTEGER NOT NULL, changed_keys TEXT NOT NULL DEFAULT '[]', config_before TEXT NOT NULL, config_after TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'patch', rolled_back_from_revision_id TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE); CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number); CREATE INDEX IF NOT EXISTS idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at); CREATE TABLE IF NOT EXISTS run_events (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id TEXT NOT NULL, run_id TEXT NOT NULL, agent_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, stream TEXT, level TEXT, message TEXT, payload TEXT, created_at TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE); CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(run_id, seq); CREATE INDEX IF NOT EXISTS idx_run_events_company ON run_events(company_id, run_id)`); - const addCol = (table: string, column: string, sql: string) => { - if (!columnExists(db, table, column)) db.prepare(sql).run(); - }; - addCol("agents", "permissions_json", "ALTER TABLE agents ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '{}'"); - addCol("agents", "heartbeat_policy_json", "ALTER TABLE agents ADD COLUMN heartbeat_policy_json TEXT"); - addCol("agents", "runtime_state_json", "ALTER TABLE agents ADD COLUMN runtime_state_json TEXT"); - addCol("runs", "context_snapshot_json", "ALTER TABLE runs ADD COLUMN context_snapshot_json TEXT"); - addCol("runs", "actor_type", "ALTER TABLE runs ADD COLUMN actor_type TEXT"); - addCol("runs", "actor_id", "ALTER TABLE runs ADD COLUMN actor_id TEXT"); - }, - }, - { - version: 23, name: "create_thread_runtime_tables", - fn: () => { - db.exec(` - CREATE TABLE IF NOT EXISTS conversation_threads ( - id TEXT PRIMARY KEY, - company_id TEXT NOT NULL, - title TEXT NOT NULL, - type TEXT NOT NULL DEFAULT 'task', - related_entity_type TEXT, - related_entity_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_conv_threads_company ON conversation_threads(company_id); - CREATE INDEX IF NOT EXISTS idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id); - - CREATE TABLE IF NOT EXISTS conversation_events ( - id TEXT PRIMARY KEY, - thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, - actor_id TEXT NOT NULL, - actor_type TEXT NOT NULL, - event_type TEXT NOT NULL, - body TEXT NOT NULL, - metadata_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_conv_events_thread ON conversation_events(thread_id, created_at); - - CREATE TABLE IF NOT EXISTS thread_wait_states ( - id TEXT PRIMARY KEY, - thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, - company_id TEXT NOT NULL, - waiting_for TEXT NOT NULL, - related_approval_id TEXT, - reason TEXT NOT NULL, - created_at TEXT NOT NULL, - resolved_at TEXT - ); - CREATE INDEX IF NOT EXISTS idx_thread_wait_company ON thread_wait_states(company_id, resolved_at); - CREATE INDEX IF NOT EXISTS idx_thread_wait_thread ON thread_wait_states(thread_id); - - CREATE TABLE IF NOT EXISTS conversation_participants ( - thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, - participant_id TEXT NOT NULL, - participant_type TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'member', - joined_at TEXT NOT NULL, - PRIMARY KEY (thread_id, participant_id) - ); - CREATE INDEX IF NOT EXISTS idx_conv_participants_participant ON conversation_participants(participant_id); - `); - }, - }, - { - version: 24, name: "create_thread_subscriptions", - sql: `CREATE TABLE IF NOT EXISTS thread_subscriptions ( - thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE, - subscriber_id TEXT NOT NULL, - subscriber_type TEXT NOT NULL, - notify_on TEXT NOT NULL DEFAULT 'all', - subscribed_at TEXT NOT NULL, - PRIMARY KEY (thread_id, subscriber_id) - ); - CREATE INDEX IF NOT EXISTS idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)`, - }, - { - version: 25, name: "add_sender_kind_to_messages", - sql: `ALTER TABLE agent_messages ADD COLUMN sender_kind TEXT NOT NULL DEFAULT 'agent'`, - }, - { - version: 26, name: "add_source_run_id_for_causal_binding", - sql: `ALTER TABLE documents ADD COLUMN source_run_id TEXT; ALTER TABLE knowledge_base ADD COLUMN source_run_id TEXT`, - }, - { - version: 27, name: "backfill_task_sessions_from_tasks_session_json", - fn: () => { - const legacyTasks = readAll<{ - id: string; - companyId: string; - assigneeAgentId: string | null; - sessionJson: string; - sessionDisplayId: string | null; - updatedAt: string; - connectorId: string | null; - }>( - `SELECT t.id AS id, t.company_id AS companyId, t.assignee_agent_id AS assigneeAgentId, - t.session_json AS sessionJson, t.session_display_id AS sessionDisplayId, - t.updated_at AS updatedAt, a.connector_id AS connectorId - FROM tasks t - LEFT JOIN agents a ON a.id = t.assignee_agent_id - WHERE t.session_json IS NOT NULL - AND t.id NOT IN (SELECT task_id FROM task_sessions)`, - ); - for (const task of legacyTasks) { - const sessionId = randomUUID(); - db.prepare( - `INSERT OR IGNORE INTO task_sessions (id, company_id, task_id, agent_id, adapter_type, session_params_json, session_display_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - sessionId, - task.companyId, - task.id, - task.assigneeAgentId ?? "unassigned", - task.connectorId ?? "unknown", - task.sessionJson, - task.sessionDisplayId, - task.updatedAt, - task.updatedAt, - ); - db.prepare( - "UPDATE tasks SET active_session_id = ? WHERE id = ? AND active_session_id IS NULL", - ).run(sessionId, task.id); - } - }, - }, - { - version: 28, name: "add_snapshot_hot_path_indexes", - fn: () => { - // Some tables (task_comments / documents / meetings / sprints / agent_messages) - // are created by ensureOptionalColumns, which runs AFTER runMigrations on - // a fresh DB. Guard each CREATE INDEX with a tableExists check so the - // migration is safe on both fresh and migrated databases. - const indexPlan: Array<{ table: string; sql: string }> = [ - { table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)" }, - { table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)" }, - { table: "approvals", sql: "CREATE INDEX IF NOT EXISTS idx_approvals_company_created_at ON approvals(company_id, created_at DESC)" }, - { table: "task_comments", sql: "CREATE INDEX IF NOT EXISTS idx_task_comments_company_created_at ON task_comments(company_id, created_at)" }, - { table: "documents", sql: "CREATE INDEX IF NOT EXISTS idx_documents_company_updated_at ON documents(company_id, updated_at DESC)" }, - { table: "meetings", sql: "CREATE INDEX IF NOT EXISTS idx_meetings_company_scheduled_at ON meetings(company_id, scheduled_at DESC)" }, - { table: "sprints", sql: "CREATE INDEX IF NOT EXISTS idx_sprints_company_start_date ON sprints(company_id, start_date DESC)" }, - { table: "runs", sql: "CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)" }, - { table: "cost_entries", sql: "CREATE INDEX IF NOT EXISTS idx_cost_entries_agent_created_at ON cost_entries(agent_id, created_at DESC)" }, - { table: "activity", sql: "CREATE INDEX IF NOT EXISTS idx_activity_company_actor_action ON activity(company_id, actor, action)" }, - { table: "tasks", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)" }, - ]; - for (const { table, sql } of indexPlan) { - if (tableExists(db, table)) db.exec(sql); - } - }, - }, - { - version: 29, name: "add_run_events_seq_unique_index", - sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_run_events_run_seq_unique ON run_events(run_id, seq)`, - }, - { - // Supports checkpoint-engine `shouldPauseHeartbeat` join + filter on - // (company_id, state, type) -> assignee_agent_id lookup. Without this - // SQLite scans the full approvals table on every heartbeat tick. - version: 30, name: "add_checkpoint_pause_indexes", - sql: `CREATE INDEX IF NOT EXISTS idx_approvals_company_state_type ON approvals(company_id, state, type); - CREATE INDEX IF NOT EXISTS idx_approvals_related_task ON approvals(related_task_id)`, - }, - { - // Typed seniority enum replaces English keyword substring matching for - // leadership detection. Free-text `role` stays UI-editable; `role_level` - // is inferred once on insert/update via inferRoleLevel(). - version: 31, name: "add_agents_role_level", - fn: () => { - if (!columnExists(db, "agents", "role_level")) { - db.prepare("ALTER TABLE agents ADD COLUMN role_level TEXT NOT NULL DEFAULT 'ic'").run(); - } - }, - }, - { - // Explicit flag for synthesized heartbeat tasks. Replaces title-prefix - // sniffing (`title LIKE '[Heartbeat]%'`) across queries, filters, and - // automation gates. Backfills from existing `[Heartbeat]` titles so the - // new column agrees with historical data on first boot. - version: 33, name: "add_tasks_is_heartbeat", - fn: () => { - if (!columnExists(db, "tasks", "is_heartbeat")) { - db.prepare("ALTER TABLE tasks ADD COLUMN is_heartbeat INTEGER NOT NULL DEFAULT 0").run(); - } - db.prepare("UPDATE tasks SET is_heartbeat = 1 WHERE is_heartbeat = 0 AND title LIKE '[Heartbeat]%'").run(); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_is_heartbeat ON tasks(is_heartbeat)"); - }, - }, - { - // pi-mono session transcripts + credential vault. Idempotent; safe to - // re-apply on fresh DBs or DBs that already have the tables. - version: 32, name: "create_pi_mono_session_and_credentials_tables", - sql: `CREATE TABLE IF NOT EXISTS session_entries ( - id TEXT PRIMARY KEY, - task_session_id TEXT NOT NULL, - sequence INTEGER NOT NULL, - kind TEXT NOT NULL, - role TEXT, - payload_json TEXT NOT NULL, - is_superseded INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_session_entries_session ON session_entries(task_session_id, sequence); - CREATE INDEX IF NOT EXISTS idx_session_entries_active ON session_entries(task_session_id, is_superseded, sequence); - CREATE TABLE IF NOT EXISTS credentials ( - connector_id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - backend TEXT NOT NULL, - ciphertext BLOB NOT NULL, - display_hint TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - expires_at TEXT, - last_refreshed_at TEXT, - last_error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials(status)`, - }, - { - // Per-agent session scope. Agents carry a single active session across - // tasks — switching tasks becomes a "now work on X" user turn on the - // agent's ongoing transcript. Additive-only: existing task_sessions - // rows remain as historical records; new runs use the agent-scoped - // session created lazily on first post-upgrade run. - version: 34, name: "add_agents_session_id", - sql: `ALTER TABLE agents ADD COLUMN session_id TEXT; - CREATE INDEX IF NOT EXISTS idx_agents_session_id ON agents(session_id)`, - }, - { - // Rebuild runs table with task_id NULLABLE. Heartbeat runs (agent - // wake-ups with no real task) no longer need to synthesize a fake - // task row; they simply insert a run with task_id = NULL. The old - // workaround (is_heartbeat flag + acquireHeartbeatTask + cleanup - // sweeps) becomes dead code in v36. - // - // SQLite can't ALTER a column's NOT NULL constraint, so we rebuild: - // 1. Snapshot row count - // 2. Discover actual column set via PRAGMA (drift safe) - // 3. CREATE runs_new with the same columns + task_id nullable - // 4. Copy every row - // 5. Assert copied count == snapshot count (abort before DROP - // if any row went missing) - // 6. DROP old, RENAME new - // 7. Recreate every index on runs - // - // FK from run_events(run_id) references runs(id); PRAGMA foreign_keys - // OFF during the rebuild (we're inside a transaction already, so we - // toggle the defer_foreign_keys flag instead — SQLite forbids - // changing foreign_keys mid-transaction). - version: 35, name: "make_runs_task_id_nullable", - fn: () => { - const beforeCount = (db - .prepare("SELECT COUNT(*) as cnt FROM runs") - .get() as { cnt: number }).cnt; - - const existingCols = (db - .prepare("PRAGMA table_info(runs)") - .all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>); - if (existingCols.length === 0) { - throw new Error("runs table is missing; refusing to migrate"); - } - const colNames = existingCols.map((c) => c.name); - - // Declare the known v34 column set. If the live DB has drift (extra - // columns we did not declare), preserve those verbatim using the - // column metadata from PRAGMA. - const knownCols = new Set([ - "id", "company_id", "task_id", "agent_id", "workspace_id", - "connector_id", "status", "summary", "error_message", "exit_code", - "signal", "model", "session_display_id", "cost_usd", - "unattributed_cost", "started_at", "finished_at", "log_path", - "created_at", "updated_at", "context_snapshot_json", - "actor_type", "actor_id", - ]); - const driftCols = existingCols.filter((c) => !knownCols.has(c.name)); - - // Defer FK checks within this transaction. - db.exec("PRAGMA defer_foreign_keys = ON"); - - // Build CREATE TABLE runs_new. task_id is nullable. Other columns - // reuse the declared types / constraints. For drift columns we - // emit a best-effort column definition (type, NULL, default). - const declaredDdl = [ - "id TEXT PRIMARY KEY", - "company_id TEXT NOT NULL", - "task_id TEXT", // NULLABLE (was NOT NULL) - "agent_id TEXT NOT NULL", - "workspace_id TEXT", - "connector_id TEXT NOT NULL", - "status TEXT NOT NULL", - "summary TEXT NOT NULL", - "error_message TEXT", - "exit_code INTEGER", - "signal TEXT", - "model TEXT", - "session_display_id TEXT", - "cost_usd REAL", - "unattributed_cost INTEGER NOT NULL DEFAULT 0", - "started_at TEXT", - "finished_at TEXT", - "log_path TEXT", - "created_at TEXT NOT NULL", - "updated_at TEXT NOT NULL", - "context_snapshot_json TEXT", - "actor_type TEXT", - "actor_id TEXT", - ]; - for (const drift of driftCols) { - const type = drift.type || "TEXT"; - declaredDdl.push(`${drift.name} ${type}`); - } - - db.exec(`CREATE TABLE runs_new (${declaredDdl.join(", ")})`); - - // Copy preserving every column present in the old table. - const copyCols = colNames.join(", "); - db.exec(`INSERT INTO runs_new (${copyCols}) SELECT ${copyCols} FROM runs`); - - const afterCount = (db - .prepare("SELECT COUNT(*) as cnt FROM runs_new") - .get() as { cnt: number }).cnt; - if (afterCount !== beforeCount) { - throw new Error( - `runs copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`, - ); - } - - db.exec("DROP TABLE runs"); - db.exec("ALTER TABLE runs_new RENAME TO runs"); - - // Recreate every index on runs. - db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company ON runs(company_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_status ON runs(company_id, status)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_created_at ON runs(company_id, created_at DESC)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)"); - - log.info(`[migration v35] runs table rebuilt with nullable task_id (${afterCount} rows preserved)`); - }, - }, - { - // Drop the `tasks.is_heartbeat` column and all synthetic heartbeat - // task rows. Prerequisite: v35 must have made runs.task_id nullable - // so we can null-out runs that pointed at heartbeat tasks before - // deleting those task rows. - version: 36, name: "drop_tasks_is_heartbeat_and_synthetic_rows", - fn: () => { - const hasColumn = columnExists(db, "tasks", "is_heartbeat"); - if (!hasColumn) { - log.info("[migration v36] tasks.is_heartbeat already absent; skipping"); - return; - } - - // 1. NULL-out runs that pointed at heartbeat tasks. - const updated = db - .prepare( - "UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)", - ) - .run(); - const runsNulledOut = Number(updated.changes); - - // 2. Delete synthetic heartbeat tasks. Cost entries referring to - // these tasks remain with their task_id pointing at now-deleted - // ids — acceptable, cost_entries.task_id has no FK constraint. - // task_comments and approvals attached to these tasks are - // already cascaded by the task row delete or would remain - // orphaned; heartbeat tasks carry neither in practice. - const beforeHeartbeatCount = (db - .prepare("SELECT COUNT(*) as cnt FROM tasks WHERE is_heartbeat = 1") - .get() as { cnt: number }).cnt; - db.exec("DELETE FROM tasks WHERE is_heartbeat = 1"); - - // 3. Rebuild `tasks` without the is_heartbeat column. - const beforeTasksCount = (db - .prepare("SELECT COUNT(*) as cnt FROM tasks") - .get() as { cnt: number }).cnt; - - const existingCols = (db - .prepare("PRAGMA table_info(tasks)") - .all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>); - const keepCols = existingCols.filter((c) => c.name !== "is_heartbeat"); - - db.exec("PRAGMA defer_foreign_keys = ON"); - - // Declared v20 + v13 + v38+v39/v20 column set for `tasks`. Drift - // columns (any column we haven't declared here) are preserved - // verbatim using PRAGMA metadata — same drift-safe shape as v35. - const declaredCols = new Map([ - ["id", "TEXT PRIMARY KEY"], - ["company_id", "TEXT NOT NULL"], - ["project_id", "TEXT"], - ["goal_id", "TEXT"], - ["parent_id", "TEXT"], - ["title", "TEXT NOT NULL"], - ["description", "TEXT NOT NULL"], - ["assignee_agent_id", "TEXT"], - ["workspace_id", "TEXT"], - ["status", "TEXT NOT NULL"], - ["priority", "TEXT NOT NULL"], - ["active_run_id", "TEXT"], - ["session_json", "TEXT"], - ["session_display_id", "TEXT"], - ["cost_usd", "REAL NOT NULL DEFAULT 0"], - ["created_at", "TEXT NOT NULL"], - ["updated_at", "TEXT NOT NULL"], - ["task_type", "TEXT NOT NULL DEFAULT 'general'"], - ["requires_user_review", "INTEGER NOT NULL DEFAULT 0"], - ["retry_count", "INTEGER NOT NULL DEFAULT 0"], - ["last_failure_at", "TEXT"], - ["checkout_run_id", "TEXT"], - ["execution_locked_at", "TEXT"], - ["active_session_id", "TEXT"], - ["started_at", "TEXT"], - ["completed_at", "TEXT"], - ["cancelled_at", "TEXT"], - ]); - - const ddl = keepCols.map((c) => { - const declared = declaredCols.get(c.name); - if (declared) return `${c.name} ${declared}`; - return `${c.name} ${c.type || "TEXT"}`; - }); - - db.exec(`CREATE TABLE tasks_new (${ddl.join(", ")})`); - - const copyCols = keepCols.map((c) => c.name).join(", "); - db.exec(`INSERT INTO tasks_new (${copyCols}) SELECT ${copyCols} FROM tasks`); - - const afterTasksCount = (db - .prepare("SELECT COUNT(*) as cnt FROM tasks_new") - .get() as { cnt: number }).cnt; - if (afterTasksCount !== beforeTasksCount) { - throw new Error( - `tasks copy row count mismatch: before=${beforeTasksCount} after=${afterTasksCount}; aborting before DROP`, - ); - } - - db.exec("DROP TABLE tasks"); - db.exec("ALTER TABLE tasks_new RENAME TO tasks"); - - // Recreate every index on tasks. - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)"); - - log.info( - `[migration v36] removed tasks.is_heartbeat; deleted ${beforeHeartbeatCount} synthetic heartbeat task row(s); nulled task_id on ${runsNulledOut} run(s); preserved ${afterTasksCount} task row(s)`, - ); - }, - }, - { - // Drop the `markdown` column from company_skills. SKILL.md on disk - // (profile/skills/{companyId}/{slug}/SKILL.md) is the authoritative - // source of truth read by pi-coding-agent's loadSkills. The DB column - // duplicated that content. UI now loads the body live via IPC. - // - // SQLite can't DROP COLUMN cleanly pre-3.35 and even the supported path - // is implemented as an invisible rebuild, so we do an explicit - // drift-safe rebuild mirroring v35/v36: - // 1. Snapshot row count - // 2. PRAGMA table_info to discover the live column set - // 3. CREATE company_skills_new without `markdown` - // 4. Copy every kept column - // 5. Assert copied count matches - // 6. DROP old, RENAME new, recreate indexes - // - // FK from agent_skill_assignments(skill_id) -> company_skills(id) must - // survive. PRAGMA defer_foreign_keys = ON keeps checks deferred until - // transaction commit (by which time RENAME has restored the table). - version: 37, name: "drop_company_skills_markdown", - fn: () => { - if (!tableExists(db, "company_skills")) { - log.info("[migration v37] company_skills table absent; skipping"); - return; - } - if (!columnExists(db, "company_skills", "markdown")) { - log.info("[migration v37] company_skills.markdown already absent; skipping"); - return; - } - - const beforeCount = (db - .prepare("SELECT COUNT(*) as cnt FROM company_skills") - .get() as { cnt: number }).cnt; - - const existingCols = (db - .prepare("PRAGMA table_info(company_skills)") - .all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>); - const keepCols = existingCols.filter((c) => c.name !== "markdown"); - - db.exec("PRAGMA defer_foreign_keys = ON"); - - // Declared v14 column set (minus markdown). Drift columns preserved - // verbatim from PRAGMA metadata. - const declaredCols = new Map([ - ["id", "TEXT PRIMARY KEY"], - ["company_id", "TEXT NOT NULL"], - ["key", "TEXT NOT NULL"], - ["slug", "TEXT NOT NULL"], - ["name", "TEXT NOT NULL"], - ["description", "TEXT"], - ["source_type", "TEXT NOT NULL DEFAULT 'local_path'"], - ["source_locator", "TEXT"], - ["source_ref", "TEXT"], - ["trust_level", "TEXT NOT NULL DEFAULT 'markdown_only'"], - ["compatibility", "TEXT NOT NULL DEFAULT 'compatible'"], - ["file_inventory", "TEXT NOT NULL DEFAULT '[]'"], - ["metadata", "TEXT"], - ["created_at", "TEXT NOT NULL"], - ["updated_at", "TEXT NOT NULL"], - ]); - - const ddl = keepCols.map((c) => { - const declared = declaredCols.get(c.name); - if (declared) return `${c.name} ${declared}`; - return `${c.name} ${c.type || "TEXT"}`; - }); - - db.exec(`CREATE TABLE company_skills_new (${ddl.join(", ")})`); - - const copyCols = keepCols.map((c) => c.name).join(", "); - db.exec(`INSERT INTO company_skills_new (${copyCols}) SELECT ${copyCols} FROM company_skills`); - - const afterCount = (db - .prepare("SELECT COUNT(*) as cnt FROM company_skills_new") - .get() as { cnt: number }).cnt; - if (afterCount !== beforeCount) { - throw new Error( - `company_skills copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`, - ); - } - - db.exec("DROP TABLE company_skills"); - db.exec("ALTER TABLE company_skills_new RENAME TO company_skills"); - - db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name)"); - - log.info(`[migration v37] dropped company_skills.markdown; preserved ${afterCount} row(s)`); - }, - }, + const deps = { db, readAll }; + const migrations: Migration[] = [ + ...createMigrationsV1V15(deps), + ...createMigrationsV16V25(deps), + ...createMigrationsV26V34(deps), + ...createMigrationsV35V37(deps), ]; for (const migration of migrations) {