refactor(database): squash schema migrations to v56 baseline

This commit is contained in:
ZacharyZhang-NY committed 2026-04-24 00:00:13 -04:00
1 parent 67ccfc1160
commit 1f4af89be0
29 files changed
+1245 -2537

No files matched your search

@@ -0,0 +1,261 @@
import type { DatabaseSync } from "node:sqlite";
export const BASELINE_SCHEMA_VERSION = 56;
export const BASELINE_SCHEMA_NAME = "baseline_v56";
export const BASELINE_TABLE_SQL = [
"CREATE TABLE activity (id text primary key, company_id text not null, actor text not null, action text not null, entity_type text not null, entity_id text not null, detail text not null, created_at text not null)",
"CREATE TABLE 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 TABLE agent_message_mentions (\n message_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n company_id TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (message_id, agent_id)\n )",
"CREATE TABLE agent_message_projections (\n message_id TEXT NOT NULL,\n session_id TEXT NOT NULL,\n projected_at TEXT NOT NULL,\n PRIMARY KEY (message_id, session_id)\n )",
"CREATE TABLE 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 TABLE 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', parent_message_id text, attachments_json text not null default '[]', created_at text not null, sender_kind TEXT NOT NULL DEFAULT 'agent')",
"CREATE TABLE 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 TABLE agents (id text primary key, company_id text not null, name text not null, role text not null, title text not null, status text not null, reports_to text, connector_id text not null, workspace_id text, model text, capabilities text not null, budget_monthly_usd real not null default 0, spent_monthly_usd real not null default 0, metadata_json text not null default '{}', created_at text not null, updated_at text not null, department text, permissions_json TEXT NOT NULL DEFAULT '{}', heartbeat_policy_json TEXT, runtime_state_json TEXT, role_level TEXT NOT NULL DEFAULT 'ic', session_id TEXT, model_strategy TEXT NOT NULL DEFAULT 'explicit', heartbeat_enabled integer not null default 0, heartbeat_interval_sec integer not null default 120, last_heartbeat_at text)",
"CREATE TABLE 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 TABLE approvals (id text primary key, company_id text not null, related_task_id text, requested_by_agent_id text, type text not null, payload_summary text not null, impact_summary text not null, payload_json text, decision_note text not null default '', state text not null, created_at text not null, updated_at text not null, related_agent_id text)",
"CREATE TABLE 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 TABLE 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 TABLE browser_actions (id text primary key, company_id text not null references companies(id) on delete cascade, social_account_id text not null references social_accounts(id) on delete cascade, agent_id text not null references agents(id) on delete cascade, task_id text, action_type text not null default 'custom_script', payload_json text not null default '{}', status text not null default 'queued', result_summary text not null default '', screenshot_path text, approval_id text, error_message text, started_at text, finished_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')))",
"CREATE TABLE 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 TABLE 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 TABLE companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null, auto_approve_hires integer not null default 0, review_deliverables integer not null default 0, execution_mode text not null default 'ai_ceo')",
"CREATE TABLE \"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, 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 TABLE connector_model_pool_settings (\n company_id TEXT NOT NULL,\n connector_id TEXT NOT NULL,\n disabled_models_json TEXT NOT NULL DEFAULT '[]',\n updated_at TEXT NOT NULL,\n PRIMARY KEY (company_id, connector_id),\n FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE\n )",
"CREATE TABLE connectors (id text primary key, label text not null, description text not null, status text not null, command text not null, version text, auth_state text not null, last_checked_at text, last_error text, model text, env_binding_text text not null, notes text not null, updated_at text, capability_json text)",
"CREATE TABLE conversation_events (\n id TEXT PRIMARY KEY,\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n actor_id TEXT NOT NULL,\n actor_type TEXT NOT NULL,\n event_type TEXT NOT NULL,\n body TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL\n )",
"CREATE TABLE conversation_participants (\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n participant_id TEXT NOT NULL,\n participant_type TEXT NOT NULL,\n role TEXT NOT NULL DEFAULT 'member',\n joined_at TEXT NOT NULL,\n PRIMARY KEY (thread_id, participant_id)\n )",
"CREATE TABLE conversation_threads (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n title TEXT NOT NULL,\n type TEXT NOT NULL DEFAULT 'task',\n related_entity_type TEXT,\n related_entity_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
"CREATE TABLE cost_entries (id text primary key, company_id text not null, run_id text not null, task_id text, agent_id text, connector_id text not null, amount_usd real, unattributed integer not null default 0, created_at text not null, project_id TEXT)",
"CREATE TABLE credentials (\n connector_id TEXT PRIMARY KEY,\n kind TEXT NOT NULL,\n backend TEXT NOT NULL,\n ciphertext BLOB NOT NULL,\n display_hint TEXT NOT NULL DEFAULT '',\n status TEXT NOT NULL DEFAULT 'active',\n expires_at TEXT,\n last_refreshed_at TEXT,\n last_error TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
"CREATE TABLE document_lifecycle_events (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n document_id TEXT NOT NULL,\n hook_type TEXT NOT NULL,\n status TEXT NOT NULL,\n attempts INTEGER NOT NULL DEFAULT 0,\n last_error TEXT,\n payload_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n completed_at TEXT\n )",
"CREATE TABLE 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 TABLE 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, latest_revision_id TEXT, source_run_id TEXT)",
"CREATE TABLE goals (id text primary key, company_id text not null, title text not null, description text not null, parent_id text, owner_agent_id text, status text not null, created_at text not null, updated_at text not null)",
"CREATE TABLE 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, source_run_id TEXT)",
"CREATE TABLE 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 TABLE model_capabilities (\n provider TEXT NOT NULL,\n model_id TEXT NOT NULL,\n reasoning INTEGER NOT NULL,\n coding INTEGER NOT NULL,\n writing INTEGER NOT NULL,\n tier TEXT NOT NULL,\n cost_per_mtok_out REAL NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (provider, model_id)\n )",
"CREATE TABLE projects (id text primary key, company_id text not null, goal_id text, name text not null, description text not null, lead_agent_id text, status text not null, target_date text, created_at text not null, updated_at text not null)",
"CREATE TABLE 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 TABLE \"runs\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n task_id TEXT,\n agent_id TEXT NOT NULL,\n workspace_id TEXT,\n connector_id TEXT NOT NULL,\n status TEXT NOT NULL,\n summary TEXT NOT NULL,\n error_message TEXT,\n exit_code INTEGER,\n signal TEXT,\n model TEXT,\n cost_usd REAL,\n unattributed_cost INTEGER NOT NULL DEFAULT 0,\n actor_type TEXT,\n actor_id TEXT,\n started_at TEXT,\n finished_at TEXT,\n log_path TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n , usage_status TEXT NOT NULL DEFAULT 'pre_first_message')",
"CREATE TABLE schema_migrations (version integer primary key, name text not null, applied_at text not null)",
"CREATE TABLE secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name))",
"CREATE TABLE session_entries (\n id TEXT PRIMARY KEY,\n task_session_id TEXT NOT NULL,\n sequence INTEGER NOT NULL,\n kind TEXT NOT NULL,\n role TEXT,\n payload_json TEXT NOT NULL,\n is_superseded INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL\n )",
"CREATE TABLE settings (key text primary key, value text not null)",
"CREATE TABLE social_accounts (id text primary key, company_id text not null references companies(id) on delete cascade, platform text not null default 'other', account_name text not null, display_name text not null default '', profile_url text not null default '', credential_secret_id text, status text not null default 'login_required', require_approval integer not null default 1, metadata_json text not null default '{}', last_used_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')))",
"CREATE TABLE 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 TABLE 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 TABLE task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null)",
"CREATE TABLE 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 TABLE \"task_sessions\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n adapter_type TEXT NOT NULL,\n last_run_id TEXT,\n last_error TEXT,\n run_count INTEGER NOT NULL DEFAULT 0,\n total_input_tokens INTEGER NOT NULL DEFAULT 0,\n total_output_tokens INTEGER NOT NULL DEFAULT 0,\n rotated_at TEXT,\n rotation_reason TEXT,\n handoff_markdown TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
"CREATE TABLE \"tasks\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n project_id TEXT,\n goal_id TEXT,\n parent_id TEXT,\n title TEXT NOT NULL,\n description TEXT NOT NULL,\n assignee_agent_id TEXT,\n workspace_id TEXT,\n status TEXT NOT NULL,\n priority TEXT NOT NULL,\n active_run_id TEXT,\n cost_usd REAL NOT NULL DEFAULT 0,\n task_type TEXT NOT NULL DEFAULT 'general',\n requires_user_review INTEGER NOT NULL DEFAULT 0,\n retry_count INTEGER NOT NULL DEFAULT 0,\n last_failure_at TEXT,\n checkout_run_id TEXT,\n execution_locked_at TEXT,\n started_at TEXT,\n completed_at TEXT,\n cancelled_at TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n , requires_filesystem INTEGER NOT NULL DEFAULT 0, active_session_id text)",
"CREATE TABLE thread_subscriptions (\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n subscriber_id TEXT NOT NULL,\n subscriber_type TEXT NOT NULL,\n notify_on TEXT NOT NULL DEFAULT 'all',\n subscribed_at TEXT NOT NULL,\n PRIMARY KEY (thread_id, subscriber_id)\n )",
"CREATE TABLE thread_wait_states (\n id TEXT PRIMARY KEY,\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n company_id TEXT NOT NULL,\n waiting_for TEXT NOT NULL,\n related_approval_id TEXT,\n reason TEXT NOT NULL,\n created_at TEXT NOT NULL,\n resolved_at TEXT\n )",
"CREATE TABLE 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, context_snapshot TEXT)",
"CREATE TABLE \"work_products\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n project_id TEXT,\n task_id TEXT,\n type TEXT NOT NULL,\n provider TEXT NOT NULL,\n external_id TEXT,\n title TEXT NOT NULL,\n url TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n review_state TEXT NOT NULL DEFAULT 'none',\n is_primary INTEGER NOT NULL DEFAULT 0,\n summary TEXT,\n metadata_json TEXT,\n created_by_run_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL, workspace_id TEXT, last_touched_by_run_id TEXT,\n FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE\n )",
"CREATE TABLE 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 TABLE workspaces (id text primary key, company_id text not null, name text not null, local_path text not null, repo_url text not null, repo_ref text not null, project_id text, state text not null, is_primary integer not null default 0, created_at text not null, updated_at text not null, lock_holder_run_id text, lock_acquired_at text)",
] as const;
export const BASELINE_INDEX_SQL = [
"CREATE INDEX idx_activity_company on activity(company_id)",
"CREATE INDEX idx_activity_company_actor_action ON activity(company_id, actor, action)",
"CREATE INDEX idx_activity_company_created_at on activity(company_id, created_at desc)",
"CREATE INDEX idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at)",
"CREATE UNIQUE INDEX idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number)",
"CREATE INDEX idx_agent_message_mentions_agent\n ON agent_message_mentions(company_id, agent_id, created_at)\n ",
"CREATE INDEX idx_agent_message_mentions_message\n ON agent_message_mentions(message_id)\n ",
"CREATE INDEX idx_agent_message_projections_session\n ON agent_message_projections(session_id, projected_at)\n ",
"CREATE INDEX idx_agent_message_reads_agent on agent_message_reads(agent_id)",
"CREATE INDEX idx_agent_messages_channel on agent_messages(channel)",
"CREATE INDEX idx_agent_messages_company on agent_messages(company_id)",
"CREATE INDEX idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc)",
"CREATE INDEX idx_agent_messages_from on agent_messages(from_agent_id)",
"CREATE INDEX idx_agent_messages_to on agent_messages(to_agent_id)",
"CREATE INDEX idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)",
"CREATE INDEX idx_agents_company on agents(company_id)",
"CREATE INDEX idx_agents_company_status on agents(company_id, status)",
"CREATE INDEX idx_agents_department on agents(company_id, department)",
"CREATE INDEX idx_agents_reports_to on agents(reports_to)",
"CREATE INDEX idx_agents_session_id ON agents(session_id)",
"CREATE INDEX idx_approval_comments_approval ON approval_comments(approval_id, created_at)",
"CREATE INDEX idx_approvals_company on approvals(company_id)",
"CREATE INDEX idx_approvals_company_created_at ON approvals(company_id, created_at DESC)",
"CREATE INDEX idx_approvals_company_state on approvals(company_id, state)",
"CREATE INDEX idx_approvals_company_state_type ON approvals(company_id, state, type)",
"CREATE INDEX idx_approvals_related_task ON approvals(related_task_id)",
"CREATE INDEX idx_automation_log_company on automation_log(company_id)",
"CREATE INDEX idx_automation_rules_company on automation_rules(company_id)",
"CREATE INDEX idx_automation_rules_company_status on automation_rules(company_id, status)",
"CREATE INDEX idx_automation_rules_trigger on automation_rules(trigger)",
"CREATE INDEX idx_browser_actions_account on browser_actions(social_account_id)",
"CREATE INDEX idx_browser_actions_company on browser_actions(company_id)",
"CREATE INDEX idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status)",
"CREATE INDEX idx_budget_incidents_company_status ON budget_incidents(company_id, status)",
"CREATE INDEX idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type)",
"CREATE INDEX idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active)",
"CREATE UNIQUE INDEX idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind)",
"CREATE INDEX idx_comments_company on task_comments(company_id)",
"CREATE INDEX idx_comments_task on task_comments(task_id)",
"CREATE UNIQUE INDEX idx_company_skills_company_key ON company_skills(company_id, key)",
"CREATE INDEX idx_company_skills_company_name ON company_skills(company_id, name)",
"CREATE INDEX idx_connector_model_pool_company\n ON connector_model_pool_settings(company_id)",
"CREATE INDEX idx_conv_events_thread ON conversation_events(thread_id, created_at)",
"CREATE INDEX idx_conv_participants_participant ON conversation_participants(participant_id)",
"CREATE INDEX idx_conv_threads_company ON conversation_threads(company_id)",
"CREATE INDEX idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id)",
"CREATE INDEX idx_cost_entries_agent_created_at ON cost_entries(agent_id, created_at DESC)",
"CREATE INDEX idx_cost_entries_company on cost_entries(company_id)",
"CREATE INDEX idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc)",
"CREATE INDEX idx_cost_entries_project ON cost_entries(project_id)",
"CREATE INDEX idx_cost_entries_run on cost_entries(run_id)",
"CREATE INDEX idx_credentials_status ON credentials(status)",
"CREATE INDEX idx_doc_rev_company ON document_revisions(company_id, document_id, created_at)",
"CREATE UNIQUE INDEX idx_doc_rev_doc_num ON document_revisions(document_id, revision_number)",
"CREATE INDEX idx_document_lifecycle_events_company\n ON document_lifecycle_events(company_id, status, updated_at)\n ",
"CREATE INDEX idx_document_lifecycle_events_document\n ON document_lifecycle_events(document_id, hook_type)\n ",
"CREATE INDEX idx_documents_author on documents(author_agent_id)",
"CREATE INDEX idx_documents_company on documents(company_id)",
"CREATE INDEX idx_documents_company_updated_at ON documents(company_id, updated_at DESC)",
"CREATE INDEX idx_documents_project on documents(project_id)",
"CREATE INDEX idx_goals_company on goals(company_id)",
"CREATE INDEX idx_knowledge_category on knowledge_base(category)",
"CREATE INDEX idx_knowledge_company on knowledge_base(company_id)",
"CREATE INDEX idx_meetings_company on meetings(company_id)",
"CREATE INDEX idx_meetings_company_scheduled_at ON meetings(company_id, scheduled_at DESC)",
"CREATE INDEX idx_meetings_organizer on meetings(organizer_agent_id)",
"CREATE INDEX idx_messages_channel on agent_messages(channel, channel_target_id)",
"CREATE INDEX idx_messages_company on agent_messages(company_id)",
"CREATE INDEX idx_model_capabilities_tier ON model_capabilities(tier)",
"CREATE INDEX idx_projects_company on projects(company_id)",
"CREATE INDEX idx_run_events_company ON run_events(company_id, run_id)",
"CREATE INDEX idx_run_events_run ON run_events(run_id, seq)",
"CREATE UNIQUE INDEX idx_run_events_run_seq_unique ON run_events(run_id, seq)",
"CREATE INDEX idx_runs_agent ON runs(agent_id)",
"CREATE INDEX idx_runs_agent_created_at ON runs(agent_id, created_at DESC)",
"CREATE INDEX idx_runs_company ON runs(company_id)",
"CREATE INDEX idx_runs_company_created_at ON runs(company_id, created_at DESC)",
"CREATE INDEX idx_runs_company_status ON runs(company_id, status)",
"CREATE INDEX idx_runs_task ON runs(task_id)",
"CREATE INDEX idx_secrets_company on secrets(company_id)",
"CREATE INDEX idx_session_entries_active ON session_entries(task_session_id, is_superseded, sequence)",
"CREATE INDEX idx_session_entries_session ON session_entries(task_session_id, sequence)",
"CREATE INDEX idx_social_accounts_company on social_accounts(company_id)",
"CREATE INDEX idx_sprints_company on sprints(company_id)",
"CREATE INDEX idx_sprints_company_start_date ON sprints(company_id, start_date DESC)",
"CREATE INDEX idx_task_approval_links_approval ON task_approval_links(approval_id)",
"CREATE INDEX idx_task_approval_links_task ON task_approval_links(task_id)",
"CREATE UNIQUE INDEX idx_task_documents_doc ON task_documents(document_id)",
"CREATE UNIQUE INDEX idx_task_documents_task_key ON task_documents(company_id, task_id, key)",
"CREATE INDEX idx_task_sessions_company_agent\n ON task_sessions(company_id, agent_id, updated_at)",
"CREATE INDEX idx_task_sessions_lookup\n ON task_sessions(company_id, agent_id, adapter_type)",
"CREATE INDEX idx_tasks_assignee ON tasks(assignee_agent_id)",
"CREATE INDEX idx_tasks_company ON tasks(company_id)",
"CREATE INDEX idx_tasks_company_status ON tasks(company_id, status)",
"CREATE INDEX idx_tasks_project ON tasks(project_id)",
"CREATE INDEX idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)",
"CREATE INDEX idx_thread_wait_company ON thread_wait_states(company_id, resolved_at)",
"CREATE INDEX idx_thread_wait_thread ON thread_wait_states(thread_id)",
"CREATE INDEX idx_wakeup_requests_agent ON wakeup_requests(agent_id, company_id, status)",
"CREATE INDEX idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)",
"CREATE INDEX idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status)",
"CREATE INDEX idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at)",
"CREATE UNIQUE INDEX idx_work_products_identity\n ON work_products(company_id, workspace_id, provider, external_id)\n WHERE workspace_id IS NOT NULL AND external_id IS NOT NULL\n ",
"CREATE INDEX idx_work_products_last_touched\n ON work_products(company_id, last_touched_by_run_id)\n ",
"CREATE UNIQUE INDEX idx_work_products_primary_active\n ON work_products(company_id, task_id, type)\n WHERE task_id IS NOT NULL\n AND is_primary = 1\n AND status <> 'archived'",
"CREATE INDEX idx_work_products_task\n ON work_products(company_id, task_id, type)",
"CREATE INDEX idx_work_products_updated\n ON work_products(company_id, updated_at)",
"CREATE INDEX idx_workflows_company on workflow_pipelines(company_id)",
"CREATE INDEX idx_workspaces_company on workspaces(company_id)",
"CREATE UNIQUE INDEX secrets_company_name_idx on secrets(company_id, name)",
] as const;
function quoteIdentifier(name: string): string {
return `"${name.replaceAll("\"", "\"\"")}"`;
}
function parseSchemaName(sql: string, prefix: RegExp, kind: string): string {
const match = prefix.exec(sql);
if (!match) throw new Error(`Cannot parse baseline ${kind} name from SQL: ${sql}`);
return match[1] ?? match[2]!;
}
export function parseBaselineTableName(sql: string): string {
return parseSchemaName(
sql,
/^CREATE TABLE(?: IF NOT EXISTS)? (?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/,
"table",
);
}
export function parseBaselineIndexName(sql: string): string {
return parseSchemaName(
sql,
/^CREATE (?:UNIQUE )?INDEX(?: IF NOT EXISTS)? (?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/,
"index",
);
}
export function normalizeSchemaSql(sql: string): string {
return sql
.replace(/\s+/g, " ")
.replace(/^CREATE TABLE "([^"]+)"/, "CREATE TABLE $1")
.replace(/^CREATE INDEX "([^"]+)"/, "CREATE INDEX $1")
.replace(/^CREATE UNIQUE INDEX "([^"]+)"/, "CREATE UNIQUE INDEX $1")
.trim();
}
function ifNotExists(sql: string): string {
return sql
.replace(/^CREATE TABLE /, "CREATE TABLE IF NOT EXISTS ")
.replace(/^CREATE INDEX /, "CREATE INDEX IF NOT EXISTS ")
.replace(/^CREATE UNIQUE INDEX /, "CREATE UNIQUE INDEX IF NOT EXISTS ");
}
export function installBaselineTables(db: DatabaseSync): void {
db.exec(BASELINE_TABLE_SQL.map(ifNotExists).join(";\n"));
}
export function installBaselineIndexes(db: DatabaseSync): void {
db.exec(BASELINE_INDEX_SQL.map(ifNotExists).join(";\n"));
}
export function installCanonicalBaselineIndexes(db: DatabaseSync): void {
for (const sql of BASELINE_INDEX_SQL) {
db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(parseBaselineIndexName(sql))}`);
}
db.exec(BASELINE_INDEX_SQL.join(";\n"));
}
export function installBaselineSchema(db: DatabaseSync): void {
installBaselineTables(db);
installBaselineIndexes(db);
}
export function assertBaselineSchema(db: DatabaseSync): void {
assertSchemaEntries(
db,
"table",
BASELINE_TABLE_SQL.map((sql) => [parseBaselineTableName(sql), normalizeSchemaSql(sql)] as const),
);
assertSchemaEntries(
db,
"index",
BASELINE_INDEX_SQL.map((sql) => [parseBaselineIndexName(sql), normalizeSchemaSql(sql)] as const),
);
}
function assertSchemaEntries(
db: DatabaseSync,
type: "table" | "index",
expectedEntries: ReadonlyArray<readonly [string, string]>,
): void {
const selectSql = db.prepare("select sql from sqlite_master where type = ? and name = ?");
for (const [name, expectedSql] of expectedEntries) {
const row = selectSql.get(type, name) as { sql: string | null } | undefined;
const actualSql = row?.sql ? normalizeSchemaSql(row.sql) : null;
if (actualSql !== expectedSql) {
throw new Error(`Baseline v56 ${type} drift for ${name}: expected ${expectedSql}; actual ${actualSql ?? "missing"}`);
}
}
}
@@ -0,0 +1,433 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import {
BASELINE_SCHEMA_NAME,
BASELINE_SCHEMA_VERSION,
BASELINE_TABLE_SQL,
installCanonicalBaselineIndexes,
installBaselineTables,
normalizeSchemaSql,
parseBaselineTableName,
} from "./baseline-v56";
import {
columnExists,
nowIso,
tableExists,
type ReadAll,
} from "./types";
type ColumnInfo = {
name: string;
type: string;
notnull: number;
dflt_value: string | null;
pk: number;
};
type LegacyDeps = {
db: DatabaseSync;
readAll: ReadAll;
};
const BASELINE_TABLE_NAMES = BASELINE_TABLE_SQL.map((sql) => parseTableName(sql));
const EXPECTED_DROPPED_COLUMNS: Readonly<Record<string, ReadonlySet<string>>> = {
agent_messages: new Set(["read_at"]),
company_skills: new Set(["markdown"]),
runs: new Set(["context_snapshot_json", "session_display_id"]),
tasks: new Set(["is_heartbeat", "phase", "session_display_id", "session_json"]),
task_sessions: new Set(["session_display_id", "session_params_json", "task_id"]),
};
export function markBaselineSchema(db: DatabaseSync): void {
db.prepare("delete from schema_migrations where version <> ?").run(BASELINE_SCHEMA_VERSION);
db
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
.run(BASELINE_SCHEMA_VERSION, BASELINE_SCHEMA_NAME, nowIso());
}
export function upgradeLegacyProfileToBaselineV56({ db, readAll }: LegacyDeps): void {
installBaselineTables(db);
migrateLegacySecrets(db);
migratePendingWakes(db, readAll);
migrateAgentBudgets(db, readAll);
for (const tableName of BASELINE_TABLE_NAMES) {
if (tableName === "schema_migrations") continue;
if (tableName === "runs") {
backfillRunsUsageStatus(db);
}
if (tableName === "tasks") {
migrateTaskSessions(db, readAll);
backfillRequiresFilesystem(db);
removeSyntheticHeartbeatTasks(db);
}
if (tableName === "work_products") {
detachDanglingWorkProducts(db);
backfillWorkProductProvenance(db);
normalizeWorkProductPrimaries(db);
}
rebuildTableToBaseline(db, tableName);
}
db.exec("drop table if exists pending_wakes");
db.exec("drop table if exists task_leases");
db.exec("drop trigger if exists trg_tasks_phase_from_status_insert");
db.exec("drop trigger if exists trg_tasks_phase_from_status_update");
installCanonicalBaselineIndexes(db);
assertNoForeignKeyViolations(db);
markBaselineSchema(db);
}
function parseTableName(sql: string): string {
return parseBaselineTableName(sql);
}
function quoteIdentifier(name: string): string {
return `"${name.replaceAll("\"", "\"\"")}"`;
}
function tableSql(tableName: string): string {
const sql = BASELINE_TABLE_SQL.find((entry) => parseTableName(entry) === tableName);
if (!sql) throw new Error(`Missing baseline SQL for table ${tableName}`);
return sql;
}
function tableColumns(db: DatabaseSync, tableName: string): ColumnInfo[] {
return db.prepare(`pragma table_info(${quoteIdentifier(tableName)})`).all() as ColumnInfo[];
}
function createSqlForTable(sql: string, tableName: string): string {
return sql.replace(/^CREATE TABLE (?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)/, `CREATE TABLE ${quoteIdentifier(tableName)}`);
}
function rebuildTableToBaseline(db: DatabaseSync, tableName: string): void {
if (!tableExists(db, tableName)) return;
if (isBaselineTableDefinition(db, tableName)) return;
const tempTableName = `__baseline_${tableName}_${process.pid}`;
db.exec(`drop table if exists ${quoteIdentifier(tempTableName)}`);
db.exec(createSqlForTable(tableSql(tableName), tempTableName));
const beforeCount = (db.prepare(`select count(*) as count from ${quoteIdentifier(tableName)}`).get() as { count: number }).count;
const sourceColumns = new Set(tableColumns(db, tableName).map((column) => column.name));
const targetColumns = tableColumns(db, tempTableName);
assertDroppedColumnsAreExpected(tableName, sourceColumns, new Set(targetColumns.map((column) => column.name)));
const insertColumns = targetColumns.map((column) => quoteIdentifier(column.name));
const selectExpressions = targetColumns.map((column) => {
if (sourceColumns.has(column.name)) return quoteIdentifier(column.name);
if (column.dflt_value !== null) return column.dflt_value;
if (column.notnull === 0) return "NULL";
if (beforeCount === 0) return "NULL";
throw new Error(`Cannot rebuild ${tableName}: missing required column ${column.name}`);
});
db.exec(`insert into ${quoteIdentifier(tempTableName)} (${insertColumns.join(", ")}) select ${selectExpressions.join(", ")} from ${quoteIdentifier(tableName)}`);
const afterCount = (db.prepare(`select count(*) as count from ${quoteIdentifier(tempTableName)}`).get() as { count: number }).count;
if (afterCount !== beforeCount) {
throw new Error(`Cannot rebuild ${tableName}: copied ${afterCount} of ${beforeCount} rows`);
}
db.exec(`drop table ${quoteIdentifier(tableName)}`);
db.exec(`alter table ${quoteIdentifier(tempTableName)} rename to ${quoteIdentifier(tableName)}`);
}
function isBaselineTableDefinition(db: DatabaseSync, tableName: string): boolean {
const row = db
.prepare("select sql from sqlite_master where type = 'table' and name = ?")
.get(tableName) as { sql: string | null } | undefined;
if (!row?.sql) return false;
return normalizeSchemaSql(row.sql) === normalizeSchemaSql(tableSql(tableName));
}
function assertDroppedColumnsAreExpected(tableName: string, sourceColumns: Set<string>, targetColumns: Set<string>): void {
const allowed = EXPECTED_DROPPED_COLUMNS[tableName] ?? new Set<string>();
const unexpected = [...sourceColumns]
.filter((column) => !targetColumns.has(column))
.filter((column) => !allowed.has(column));
if (unexpected.length > 0) {
throw new Error(`Cannot rebuild ${tableName}: unexpected legacy columns would be dropped: ${unexpected.join(", ")}`);
}
}
function migrateLegacySecrets(db: DatabaseSync): void {
if (!tableExists(db, "secrets") || columnExists(db, "secrets", "company_id")) return;
const currentCompany = db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined;
const firstCompany = db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined;
const companyId = currentCompany?.value ?? firstCompany?.id;
if (!companyId) {
throw new Error("Cannot migrate legacy secrets table: no company row exists.");
}
db.exec(`
alter table secrets rename to secrets_legacy;
create table secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name));
`);
db
.prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) select id, ?, name, description, backend, ciphertext, updated_at, created_at from secrets_legacy")
.run(companyId);
db.exec("drop table secrets_legacy");
}
function migratePendingWakes(db: DatabaseSync, readAll: ReadAll): void {
if (!tableExists(db, "pending_wakes")) return;
const rows = 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 insert = 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, ?, ?, ?)
`);
const ts = nowIso();
for (const row of rows) {
const detail = JSON.stringify([{ trigger: row.triggerReason, at: row.createdAt, detail: row.triggerReason }]);
insert.run(row.id, row.companyId, row.agentId, row.triggerReason, detail, row.createdAt, ts, ts);
}
}
function migrateAgentBudgets(db: DatabaseSync, readAll: ReadAll): void {
if (!tableExists(db, "agents") || !tableExists(db, "budget_policies")) return;
if (!columnExists(db, "agents", "budget_monthly_usd")) return;
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 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'");
const insert = 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, ?, ?)
`);
const ts = nowIso();
for (const agent of agents) {
if (existing.get(agent.companyId, agent.id)) continue;
insert.run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts);
}
}
function migrateTaskSessions(db: DatabaseSync, readAll: ReadAll): void {
if (!tableExists(db, "tasks") || !columnExists(db, "tasks", "session_json")) return;
if (!columnExists(db, "tasks", "active_session_id")) {
db.exec("alter table tasks add column active_session_id text");
}
const rows = readAll<{
id: string;
companyId: string;
assigneeAgentId: string | null;
sessionJson: string;
sessionDisplayId: string | null;
updatedAt: string;
connectorId: string | null;
}>(`
select t.id,
t.company_id as companyId,
t.assignee_agent_id as assigneeAgentId,
t.session_json as sessionJson,
${columnExists(db, "tasks", "session_display_id") ? "t.session_display_id" : "NULL"} 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.active_session_id is null or t.active_session_id = '')
`);
const sessionColumns = new Set(tableColumns(db, "task_sessions").map((column) => column.name));
const updateTask = db.prepare("update tasks set active_session_id = ? where id = ?");
const existingSessionByTask = sessionColumns.has("task_id")
? db.prepare("select id from task_sessions where task_id = ? order by updated_at desc limit 1")
: null;
const insertColumns = [
"id",
"company_id",
"agent_id",
"adapter_type",
"created_at",
"updated_at",
...(sessionColumns.has("task_id") ? ["task_id"] : []),
...(sessionColumns.has("session_params_json") ? ["session_params_json"] : []),
...(sessionColumns.has("session_display_id") ? ["session_display_id"] : []),
];
const insert = db.prepare(`
insert into task_sessions (${insertColumns.map(quoteIdentifier).join(", ")})
values (${insertColumns.map(() => "?").join(", ")})
`);
for (const row of rows) {
JSON.parse(row.sessionJson);
const existingSession = existingSessionByTask?.get(row.id) as { id: string } | undefined;
if (existingSession) {
updateTask.run(existingSession.id, row.id);
continue;
}
if (!row.assigneeAgentId) {
throw new Error(`Cannot migrate task session for task ${row.id}: assignee_agent_id is missing.`);
}
if (!row.connectorId) {
throw new Error(`Cannot migrate task session for task ${row.id}: assigned agent connector_id is missing.`);
}
const sessionId = randomUUID();
const values = [
sessionId,
row.companyId,
row.assigneeAgentId,
row.connectorId,
row.updatedAt,
row.updatedAt,
...(sessionColumns.has("task_id") ? [row.id] : []),
...(sessionColumns.has("session_params_json") ? [row.sessionJson] : []),
...(sessionColumns.has("session_display_id") ? [row.sessionDisplayId] : []),
];
insert.run(...values);
updateTask.run(sessionId, row.id);
}
}
function backfillRequiresFilesystem(db: DatabaseSync): void {
if (!tableExists(db, "tasks")) return;
if (!columnExists(db, "tasks", "requires_filesystem")) {
db.exec("alter table tasks add column requires_filesystem integer not null default 0");
}
if (columnExists(db, "tasks", "active_run_id")) {
db.exec(`
update tasks
set requires_filesystem = 1
where status = 'in_progress'
and active_run_id is not null
`);
}
if (columnExists(db, "tasks", "assignee_agent_id")) {
db.exec(`
update tasks
set requires_filesystem = 1
where status in ('todo', 'backlog')
and assignee_agent_id is not null
and exists (
select 1 from agents a
where a.id = tasks.assignee_agent_id
and (
a.role like '%cto%'
or a.role like '%engineer%'
or a.role like '%developer%'
or a.role like '%devops%'
or a.role like '%sre%'
or a.department = 'engineering'
)
)
`);
}
}
function removeSyntheticHeartbeatTasks(db: DatabaseSync): void {
if (!tableExists(db, "tasks") || !columnExists(db, "tasks", "is_heartbeat")) return;
if (tableExists(db, "runs")) {
db.exec("update runs set task_id = null where task_id in (select id from tasks where is_heartbeat = 1)");
}
db.exec("delete from tasks where is_heartbeat = 1");
}
function backfillRunsUsageStatus(db: DatabaseSync): void {
if (!tableExists(db, "runs")) return;
if (!columnExists(db, "runs", "usage_status")) {
db.exec("alter table runs add column usage_status text not null default 'pre_first_message'");
}
if (!columnExists(db, "runs", "unattributed_cost") || !columnExists(db, "runs", "status")) return;
db.exec(`
update runs
set usage_status = case
when unattributed_cost = 0 then 'ok'
when status in ('cancelled', 'interrupted') then 'aborted_before_usage'
else 'pre_first_message'
end
where usage_status = 'pre_first_message'
`);
}
function normalizeWorkProductPrimaries(db: DatabaseSync): void {
if (!tableExists(db, "work_products")) return;
db.exec(`
update work_products
set is_primary = 0,
updated_at = datetime('now')
where id in (
select id from (
select id,
row_number() over (
partition by company_id, task_id, type
order by updated_at desc, created_at desc, id desc
) as rank_in_group
from work_products
where task_id is not null
and is_primary = 1
and status <> 'archived'
)
where rank_in_group > 1
)
`);
}
function backfillWorkProductProvenance(db: DatabaseSync): void {
if (!tableExists(db, "work_products")) return;
if (!columnExists(db, "work_products", "workspace_id")) {
db.exec("alter table work_products add column workspace_id text");
}
if (!columnExists(db, "work_products", "last_touched_by_run_id")) {
db.exec("alter table work_products add column last_touched_by_run_id text");
}
if (tableExists(db, "tasks") && columnExists(db, "work_products", "task_id") && columnExists(db, "tasks", "workspace_id")) {
db.exec(`
update work_products
set workspace_id = (
select tasks.workspace_id from tasks
where tasks.id = work_products.task_id
)
where workspace_id is null
and task_id is not null
`);
}
if (tableExists(db, "runs") && columnExists(db, "work_products", "created_by_run_id") && columnExists(db, "runs", "workspace_id")) {
db.exec(`
update work_products
set workspace_id = (
select runs.workspace_id from runs
where runs.id = work_products.created_by_run_id
)
where workspace_id is null
and created_by_run_id is not null
`);
}
if (columnExists(db, "work_products", "created_by_run_id")) {
db.exec(`
update work_products
set last_touched_by_run_id = created_by_run_id
where last_touched_by_run_id is null
and created_by_run_id is not null
`);
}
}
function detachDanglingWorkProducts(db: DatabaseSync): void {
if (!tableExists(db, "work_products") || !tableExists(db, "tasks") || !columnExists(db, "work_products", "task_id")) return;
db.exec(`
update work_products
set task_id = null
where task_id is not null
and not exists (
select 1 from tasks
where tasks.id = work_products.task_id
)
`);
}
function assertNoForeignKeyViolations(db: DatabaseSync): void {
const violations = db.prepare("pragma foreign_key_check").all() as Array<{
table: string;
rowid: number | null;
parent: string;
fkid: number;
}>;
if (violations.length === 0) return;
const sample = violations
.slice(0, 5)
.map((row) => `${row.table}:${row.rowid ?? "unknown"}->${row.parent}#${row.fkid}`)
.join(", ");
throw new Error(`Cannot migrate legacy profile: ${violations.length} foreign key violations after v56 rebuild (${sample}).`);
}
-50
View File
@@ -1,50 +0,0 @@
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`);
},
},
];
}
-157
View File
@@ -1,157 +0,0 @@
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'`,
},
];
}
-158
View File
@@ -1,158 +0,0 @@
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)`,
},
];
}
-326
View File
@@ -1,326 +0,0 @@
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<string, string>([
["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<string, string>([
["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)`);
},
},
];
}
-75
View File
@@ -1,75 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
export function createMigrationsV38V39(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Model capability table. Maps (provider, model_id) to 1..5 scores across
// three domains (reasoning / coding / writing), a tier enum used as a
// hard lower bound for task-profile matching, and a cost-per-Mtok-output
// figure used to pick the cheapest qualifying model.
//
// Scores, tier, and cost are data — editable at runtime via IPC. Seed
// rows are inserted by the main process on startup when the table is
// empty so a fresh install boots with a sensible matrix covering the
// providers in the pi-ai catalog and the curated Fireworks list.
version: 38,
name: "create_model_capabilities",
fn: () => {
if (tableExists(db, "model_capabilities")) {
log.info("[migration v38] model_capabilities already exists; skipping");
return;
}
db.exec(
`CREATE TABLE model_capabilities (
provider TEXT NOT NULL,
model_id TEXT NOT NULL,
reasoning INTEGER NOT NULL,
coding INTEGER NOT NULL,
writing INTEGER NOT NULL,
tier TEXT NOT NULL,
cost_per_mtok_out REAL NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (provider, model_id)
)`,
);
db.exec("CREATE INDEX idx_model_capabilities_tier ON model_capabilities(tier)");
log.info("[migration v38] created model_capabilities table");
},
},
{
// agents.model_strategy drives run-time model dispatch.
//
// 'explicit' — use agent.connector_id and agent.model verbatim
// (the pre-v39 behavior — kept for existing rows so there
// are no regressions on upgrade).
// 'auto' — ignore agent.connector_id/model; at run dispatch time
// pick the cheapest model whose capability vector meets
// the agent's inferred task profile.
//
// New agents default to 'auto' at the application layer. Existing rows
// on upgrade are forced to 'explicit' because the user previously picked
// their model manually and we must not silently re-route them.
version: 39,
name: "add_agents_model_strategy",
fn: () => {
if (!tableExists(db, "agents")) {
throw new Error("agents table missing; refusing to migrate v39");
}
if (columnExists(db, "agents", "model_strategy")) {
log.info("[migration v39] agents.model_strategy already exists; skipping");
return;
}
// Default at schema level = 'explicit' so existing rows keep their
// hand-picked model. Application code uses 'auto' as the default for
// freshly created agents.
db.exec(
"ALTER TABLE agents ADD COLUMN model_strategy TEXT NOT NULL DEFAULT 'explicit'",
);
log.info("[migration v39] added agents.model_strategy column");
},
},
];
}
-89
View File
@@ -1,89 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { tableExists } from "./types";
export function createMigrationsV40V40(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Relax work_products.task_id from NOT NULL to NULL-allowed.
//
// Background: every file that an agent writes via pi's write/edit tool
// should be registered as a work product so the desktop UI surfaces
// it. Heartbeat (taskless) runs — CEO team-building scratch memos,
// research exploration — legitimately produce files with no owning
// task. Forcing task_id NOT NULL made the artifact projector skip
// these runs, which in turn made the UI look empty while the
// workspace filesystem filled up with unregistered output.
//
// SQLite has no ALTER COLUMN, so we rebuild the table. The operation
// is done inside a transaction; PRAGMA foreign_keys is disabled for
// the rebuild window and restored afterward so child rows do not
// cascade-delete when the old table is dropped mid-swap.
version: 40,
name: "relax_work_products_task_id",
fn: () => {
if (!tableExists(db, "work_products")) {
log.info("[migration v40] work_products table missing; skipping");
return;
}
const info = db.prepare("PRAGMA table_info(work_products)").all() as Array<{
name: string;
notnull: number;
}>;
const taskIdRow = info.find((row) => row.name === "task_id");
if (!taskIdRow) {
throw new Error("work_products.task_id column missing; refusing to migrate v40");
}
if (taskIdRow.notnull === 0) {
log.info("[migration v40] work_products.task_id already nullable; skipping");
return;
}
db.exec("PRAGMA foreign_keys = OFF");
try {
db.exec(`
CREATE TABLE work_products_new (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
project_id TEXT,
task_id TEXT,
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
);
INSERT INTO work_products_new (
id, company_id, project_id, task_id, type, provider, external_id,
title, url, status, review_state, is_primary, summary, metadata_json,
created_by_run_id, created_at, updated_at
)
SELECT id, company_id, project_id, task_id, type, provider, external_id,
title, url, status, review_state, is_primary, summary, metadata_json,
created_by_run_id, created_at, updated_at
FROM work_products;
DROP TABLE work_products;
ALTER TABLE work_products_new RENAME TO work_products;
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);
`);
} finally {
db.exec("PRAGMA foreign_keys = ON");
}
log.info("[migration v40] relaxed work_products.task_id to nullable");
},
},
];
}
-173
View File
@@ -1,173 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
/**
* Drop dead columns left behind by the agent-scoped session refactor.
*
* Pre-v34, a session belonged to a task; the schema reflected that
* with `tasks.active_session_id`, `tasks.session_json`,
* `tasks.session_display_id`, and a `task_sessions.task_id` FK back to
* tasks. Since v34, sessions are agent-scoped — `agents.session_id`
* is the canonical pointer, and every runtime read goes through that
* or through the session's primary key. The four columns have been
* dead storage ever since, and every touch of session code had to
* pretend they were still meaningful.
*
* This migration rebuilds both tables without those columns. SQLite
* has no `ALTER TABLE DROP COLUMN` that drops multiple columns
* atomically and preserves every constraint; the "copy to new table,
* swap, reindex" pattern is the only safe route. `PRAGMA foreign_keys`
* is disabled for the swap window and restored afterward so child
* tables are not cascade-deleted mid-rebuild.
*
* The migration is idempotent: each table rebuild checks whether the
* dead columns are still present and skips the work otherwise.
*/
export function createMigrationsV41V41(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 41,
name: "drop_dead_session_columns",
fn: () => {
if (!tableExists(db, "tasks") || !tableExists(db, "task_sessions")) {
log.info("[migration v41] tasks or task_sessions table missing; skipping");
return;
}
const tasksHasDeadCols =
columnExists(db, "tasks", "session_json") ||
columnExists(db, "tasks", "session_display_id") ||
columnExists(db, "tasks", "active_session_id");
const sessionsHasDeadCol = columnExists(db, "task_sessions", "task_id");
if (!tasksHasDeadCols && !sessionsHasDeadCol) {
log.info("[migration v41] dead columns already absent; skipping");
return;
}
// The `ensureOptionalColumns` helper in schema.ts normally
// backfills `task_type`, `requires_user_review`, `retry_count`,
// `last_failure_at` on existing installs — but that runs AFTER
// migrations, not before. Rebuild the table here with the same
// shape as the post-ensureOptionalColumns schema so the old data
// is preserved and the new table stays consistent whether
// initializeDatabase has reached ensureOptionalColumns yet or not.
const ensureTaskCol = (column: string, typeSql: string) => {
if (!columnExists(db, "tasks", column)) {
db.prepare(`ALTER TABLE tasks ADD COLUMN ${column} ${typeSql}`).run();
}
};
ensureTaskCol("task_type", "TEXT NOT NULL DEFAULT 'general'");
ensureTaskCol("requires_user_review", "INTEGER NOT NULL DEFAULT 0");
ensureTaskCol("retry_count", "INTEGER NOT NULL DEFAULT 0");
ensureTaskCol("last_failure_at", "TEXT");
ensureTaskCol("checkout_run_id", "TEXT");
ensureTaskCol("execution_locked_at", "TEXT");
ensureTaskCol("started_at", "TEXT");
ensureTaskCol("completed_at", "TEXT");
ensureTaskCol("cancelled_at", "TEXT");
db.exec("PRAGMA foreign_keys = OFF");
try {
if (tasksHasDeadCols) {
db.exec(`
CREATE TABLE tasks_new (
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,
cost_usd REAL NOT NULL DEFAULT 0,
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,
started_at TEXT,
completed_at TEXT,
cancelled_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO tasks_new (
id, company_id, project_id, goal_id, parent_id, title, description,
assignee_agent_id, workspace_id, status, priority, active_run_id,
cost_usd, task_type, requires_user_review, retry_count, last_failure_at,
checkout_run_id, execution_locked_at, started_at, completed_at,
cancelled_at, created_at, updated_at
)
SELECT
id, company_id, project_id, goal_id, parent_id, title, description,
assignee_agent_id, workspace_id, status, priority, active_run_id,
cost_usd, task_type, requires_user_review, retry_count, last_failure_at,
checkout_run_id, execution_locked_at, started_at, completed_at,
cancelled_at, created_at, updated_at
FROM tasks;
DROP TABLE tasks;
ALTER TABLE tasks_new RENAME TO tasks;
CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status);
`);
log.info("[migration v41] rebuilt tasks without session_json/session_display_id/active_session_id");
}
if (sessionsHasDeadCol) {
db.exec(`
CREATE TABLE task_sessions_new (
id TEXT PRIMARY KEY,
company_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
);
INSERT INTO task_sessions_new (
id, company_id, agent_id, adapter_type, session_params_json,
session_display_id, last_run_id, last_error, run_count,
total_input_tokens, total_output_tokens, rotated_at, rotation_reason,
handoff_markdown, created_at, updated_at
)
SELECT
id, company_id, agent_id, adapter_type, session_params_json,
session_display_id, last_run_id, last_error, run_count,
total_input_tokens, total_output_tokens, rotated_at, rotation_reason,
handoff_markdown, created_at, updated_at
FROM task_sessions;
DROP TABLE task_sessions;
ALTER TABLE task_sessions_new RENAME TO task_sessions;
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_lookup
ON task_sessions(company_id, agent_id, adapter_type);
`);
log.info("[migration v41] rebuilt task_sessions without task_id");
}
} finally {
db.exec("PRAGMA foreign_keys = ON");
}
},
},
];
}
-64
View File
@@ -1,64 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { tableExists } from "./types";
/**
* Company-scope the connector model-pool settings.
*
* "Model pool" is the per-connector list of models a user has
* enabled/disabled during onboarding (Settings → Model pool). The
* original storage put it in the key-value `settings` table under
* `disabled_models:${connectorId}` — global, not tied to any
* company. That meant deleting a company left the pool config behind,
* and creating a new company inherited the previous company's
* enabled/disabled choices.
*
* This migration moves the data to a real relational table with a
* cascade FK. Company deletion now naturally wipes the settings —
* no manual cleanup in `deleteCompany()`, no risk of drift.
*
* Existing global entries cannot be assigned to a single company
* (the constraint they were written under had none), so the
* migration drops them. Users re-select their model pool on the
* next onboarding wake. Acceptable: this data is a one-click UI
* preference, not business-critical state, and the bug report was
* literally "these carry over wrongly — clear them."
*/
export function createMigrationsV42V42(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 42,
name: "scope_connector_model_pool_by_company",
fn: () => {
if (!tableExists(db, "connector_model_pool_settings")) {
db.exec(`
CREATE TABLE connector_model_pool_settings (
company_id TEXT NOT NULL,
connector_id TEXT NOT NULL,
disabled_models_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
PRIMARY KEY (company_id, connector_id),
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_connector_model_pool_company
ON connector_model_pool_settings(company_id);
`);
log.info("[migration v42] created connector_model_pool_settings");
}
// Purge stale global keys. Prefix scan is enough — both the
// key format (`disabled_models:<connectorId>`) and the
// semantics (one per connector) mean dropping them all is the
// correct reset.
const deleted = db
.prepare("DELETE FROM settings WHERE key LIKE 'disabled_models:%'")
.run();
const changes = Number(deleted.changes);
if (changes > 0) {
log.info(`[migration v42] dropped ${changes} legacy disabled_models:* key(s) from settings`);
}
},
},
];
}
-143
View File
@@ -1,143 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
/**
* Drop the Pi Mono-dead session carry-forward columns.
*
* The `task_sessions.session_params_json` / `task_sessions.session_display_id`
* columns and the matching `runs.session_display_id` column were meaningful
* in the CLI-adapter era: Gemini's resume id and provider-specific session
* params rode the run-finished message back into the session row so the next
* turn could resume. Pi Mono dispatches every provider through `pi-ai` and
* emits neither value — the worker has been writing literal `null` on every
* finish and `rotateSession` has been carrying `null` forward.
*
* Three columns of unused storage plus a hot path that writes them every
* run. Drop them.
*
* The migration mirrors v41's "copy to new table, swap, reindex" shape —
* SQLite still has no multi-column `DROP COLUMN`, and the task_sessions
* index topology plus the runs secondary indexes need to be restored
* identically. `PRAGMA foreign_keys` is disabled across the swap window
* so child rows are not cascade-deleted mid-rebuild.
*/
export function createMigrationsV43V43(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 43,
name: "drop_dead_pi_mono_session_columns",
fn: () => {
const sessionsHasDeadCols =
tableExists(db, "task_sessions") &&
(columnExists(db, "task_sessions", "session_params_json") ||
columnExists(db, "task_sessions", "session_display_id"));
const runsHasDeadCol =
tableExists(db, "runs") && columnExists(db, "runs", "session_display_id");
if (!sessionsHasDeadCols && !runsHasDeadCol) {
log.info("[migration v43] dead columns already absent; skipping");
return;
}
db.exec("PRAGMA foreign_keys = OFF");
try {
if (sessionsHasDeadCols) {
db.exec(`
CREATE TABLE task_sessions_new (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
adapter_type TEXT NOT NULL,
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
);
INSERT INTO task_sessions_new (
id, company_id, agent_id, adapter_type, last_run_id, last_error,
run_count, total_input_tokens, total_output_tokens, rotated_at,
rotation_reason, handoff_markdown, created_at, updated_at
)
SELECT
id, company_id, agent_id, adapter_type, last_run_id, last_error,
run_count, total_input_tokens, total_output_tokens, rotated_at,
rotation_reason, handoff_markdown, created_at, updated_at
FROM task_sessions;
DROP TABLE task_sessions;
ALTER TABLE task_sessions_new RENAME TO task_sessions;
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_lookup
ON task_sessions(company_id, agent_id, adapter_type);
`);
log.info("[migration v43] rebuilt task_sessions without session_params_json/session_display_id");
}
if (runsHasDeadCol) {
// `context_snapshot_json` rides through this rebuild untouched
// so the single-concern invariant holds: v43 drops only
// `session_display_id`. v44 is the one that drops the snapshot
// column.
db.exec(`
CREATE TABLE runs_new (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
task_id TEXT,
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,
cost_usd REAL,
unattributed_cost INTEGER NOT NULL DEFAULT 0,
context_snapshot_json TEXT,
actor_type TEXT,
actor_id TEXT,
started_at TEXT,
finished_at TEXT,
log_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO runs_new (
id, company_id, task_id, agent_id, workspace_id, connector_id,
status, summary, error_message, exit_code, signal, model,
cost_usd, unattributed_cost, context_snapshot_json, actor_type,
actor_id, started_at, finished_at, log_path, created_at, updated_at
)
SELECT
id, company_id, task_id, agent_id, workspace_id, connector_id,
status, summary, error_message, exit_code, signal, model,
cost_usd, unattributed_cost, context_snapshot_json, actor_type,
actor_id, started_at, finished_at, log_path, created_at, updated_at
FROM runs;
DROP TABLE runs;
ALTER TABLE runs_new RENAME TO runs;
CREATE INDEX IF NOT EXISTS idx_runs_company ON runs(company_id);
CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id);
CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id);
CREATE INDEX IF NOT EXISTS idx_runs_company_status ON runs(company_id, status);
CREATE INDEX IF NOT EXISTS idx_runs_company_created_at ON runs(company_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC);
`);
log.info("[migration v43] rebuilt runs without session_display_id");
}
} finally {
db.exec("PRAGMA foreign_keys = ON");
}
},
},
];
}
-83
View File
@@ -1,83 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
/**
* Drop `runs.context_snapshot_json` — dead since its introduction.
*
* v16-v25 added the column with the intent to snapshot the turn context
* for post-run forensics. Nothing ever wrote to it, nothing ever read it,
* and the Pi Mono migration removed the last plausible use case (the
* turn snapshot now lives in `task_sessions` / `session_entries`).
*
* Same table-rebuild pattern as v43 — `runs_new` without the column,
* copy the real data, swap, reindex. Idempotent: if the column is
* already absent the migration is a no-op.
*/
export function createMigrationsV44V44(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 44,
name: "drop_runs_context_snapshot_json",
fn: () => {
if (!tableExists(db, "runs") || !columnExists(db, "runs", "context_snapshot_json")) {
log.info("[migration v44] runs.context_snapshot_json already absent; skipping");
return;
}
db.exec("PRAGMA foreign_keys = OFF");
try {
db.exec(`
CREATE TABLE runs_new (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
task_id TEXT,
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,
cost_usd REAL,
unattributed_cost INTEGER NOT NULL DEFAULT 0,
actor_type TEXT,
actor_id TEXT,
started_at TEXT,
finished_at TEXT,
log_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO runs_new (
id, company_id, task_id, agent_id, workspace_id, connector_id,
status, summary, error_message, exit_code, signal, model,
cost_usd, unattributed_cost, actor_type, actor_id,
started_at, finished_at, log_path, created_at, updated_at
)
SELECT
id, company_id, task_id, agent_id, workspace_id, connector_id,
status, summary, error_message, exit_code, signal, model,
cost_usd, unattributed_cost, actor_type, actor_id,
started_at, finished_at, log_path, created_at, updated_at
FROM runs;
DROP TABLE runs;
ALTER TABLE runs_new RENAME TO runs;
CREATE INDEX IF NOT EXISTS idx_runs_company ON runs(company_id);
CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id);
CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id);
CREATE INDEX IF NOT EXISTS idx_runs_company_status ON runs(company_id, status);
CREATE INDEX IF NOT EXISTS idx_runs_company_created_at ON runs(company_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC);
`);
log.info("[migration v44] rebuilt runs without context_snapshot_json");
} finally {
db.exec("PRAGMA foreign_keys = ON");
}
},
},
];
}
-88
View File
@@ -1,88 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
export function createMigrationsV45V45(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Promote work_products identity from `(company_id, provider, external_id)`
// to `(company_id, workspace_id, provider, external_id)` and replace
// the write-once `created_by_run_id` provenance with an append-only
// `last_touched_by_run_id` so the deliverable judgment, the thread
// projection, and the DocumentCenter open link all stay correct as
// a file is rewritten across multiple runs.
//
// Pre-v45 behaviour silently collapsed two workspaces' files at the
// same relative path into one row, and locked provenance at the
// first run that touched the path — later edits were invisible to
// `runProducedDeliverable`, so the task that produced the edit
// could stay pinned in-progress.
//
// Backfill strategy: derive `workspace_id` per row via `task_id ->
// tasks.workspace_id` first, then via `created_by_run_id ->
// runs.workspace_id`. Rows with neither binding (historically the
// exception, not the rule) remain NULL so nothing is dropped; the
// new uniqueness constraint is scoped to rows with a workspace so
// legacy NULL rows stay live without triggering conflicts.
// `last_touched_by_run_id` is seeded to `created_by_run_id` so the
// deliverable signal reads the same provenance pre- and post-
// migration until the next write arrives.
version: 45,
name: "work_products_workspace_identity",
fn: () => {
if (!tableExists(db, "work_products")) {
log.info("[migration v45] work_products table missing; skipping");
return;
}
if (!columnExists(db, "work_products", "workspace_id")) {
db.exec("ALTER TABLE work_products ADD COLUMN workspace_id TEXT");
}
if (!columnExists(db, "work_products", "last_touched_by_run_id")) {
db.exec("ALTER TABLE work_products ADD COLUMN last_touched_by_run_id TEXT");
}
db.exec(`
UPDATE work_products
SET workspace_id = (
SELECT tasks.workspace_id FROM tasks
WHERE tasks.id = work_products.task_id
)
WHERE workspace_id IS NULL
AND task_id IS NOT NULL
`);
db.exec(`
UPDATE work_products
SET workspace_id = (
SELECT runs.workspace_id FROM runs
WHERE runs.id = work_products.created_by_run_id
)
WHERE workspace_id IS NULL
AND created_by_run_id IS NOT NULL
`);
db.exec(`
UPDATE work_products
SET last_touched_by_run_id = created_by_run_id
WHERE last_touched_by_run_id IS NULL
AND created_by_run_id IS NOT NULL
`);
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_products_identity
ON work_products(company_id, workspace_id, provider, external_id)
WHERE workspace_id IS NOT NULL AND external_id IS NOT NULL
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_work_products_last_touched
ON work_products(company_id, last_touched_by_run_id)
`);
log.info("[migration v45] work_products identity + provenance upgraded");
},
},
];
}
-44
View File
@@ -1,44 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
export function createMigrationsV46V46(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Ledger for `prepareRun` inbox → session-shadow projection.
//
// Before v46 the projection sequence — append to the session
// shadow, write the thread event, mark the inbox row delivered —
// ran as three independent SQL writes. A crash or worker restart
// between the append and the delivered mark caused the next run
// to replay the same inbox row, duplicating transcript turns and
// thread events.
//
// The ledger is keyed by the natural identity of a projection
// attempt: the combination of the inbox message and the session
// shadow it lands on. `INSERT OR IGNORE` against that key is the
// idempotent gate — the first insert wins, a replay silently
// skips. Callers wrap the ledger insert, the shadow append, the
// thread-event write, and the delivered-mark in a single
// `BEGIN IMMEDIATE` transaction so the four writes commit
// together or roll back together.
version: 46,
name: "agent_message_projections_ledger",
fn: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS agent_message_projections (
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
projected_at TEXT NOT NULL,
PRIMARY KEY (message_id, session_id)
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_agent_message_projections_session
ON agent_message_projections(session_id, projected_at)
`);
log.info("[migration v46] agent_message_projections ledger installed");
},
},
];
}
-53
View File
@@ -1,53 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
export function createMigrationsV47V47(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Durable fan-out ledger for document-creation side effects.
//
// Before v47 `createDocumentWithLifecycle` wrapped every downstream
// hook (automation dispatch, thread integration, checkpoint engine,
// reviewer wake) in an individual `try/catch` and always returned
// success. A failure in any single hook left the document row live
// with a silently incomplete lifecycle — thread views, approvals,
// or reviewer wakes disappeared without trace.
//
// The ledger is the tracked-state surface the operator reads to
// reason about lifecycle coverage: one row per hook per document
// is inserted inside the same transaction as the document write.
// The processor advances `status` from `pending` to `succeeded` or
// `failed`, stamps `last_error` on failure, and `attempts` lets the
// sweeper retry within a bounded policy.
version: 47,
name: "document_lifecycle_events_ledger",
fn: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS document_lifecycle_events (
id TEXT PRIMARY KEY,
company_id TEXT NOT NULL,
document_id TEXT NOT NULL,
hook_type TEXT NOT NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_document_lifecycle_events_company
ON document_lifecycle_events(company_id, status, updated_at)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_document_lifecycle_events_document
ON document_lifecycle_events(document_id, hook_type)
`);
log.info("[migration v47] document_lifecycle_events ledger installed");
},
},
];
}
-46
View File
@@ -1,46 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
export function createMigrationsV48V48(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
// Persist @mention fan-out so inbox visibility stops depending on
// a runtime-only regex against every message body.
//
// Before v48 `dispatchAgentMessage` woke mentioned agents via
// `findMentionedAgentIds`, but `listAgentInboxUnread` filtered
// purely on `channel`, `to_agent_id`, and department/project
// membership. A mid-chat `@ceo update please` targeted at
// 张工程师 therefore woke CEO with an empty inbox — CEO's
// heartbeat fired, saw nothing new, and stayed silent while the
// DM recipient replied and the operator assumed CEO had ignored
// them. The join table makes "mentioned in a message" a first-
// class visibility path: persisted once at dispatch, joined on
// read, so every consumer (inbox projection, read-receipts,
// search) sees a single source of truth.
version: 48,
name: "agent_message_mentions",
fn: () => {
db.exec(`
CREATE TABLE IF NOT EXISTS agent_message_mentions (
message_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
company_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, agent_id)
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_agent_message_mentions_agent
ON agent_message_mentions(company_id, agent_id, created_at)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_agent_message_mentions_message
ON agent_message_mentions(message_id)
`);
log.info("[migration v48] agent_message_mentions join table installed");
},
},
];
}
-40
View File
@@ -1,40 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
/**
* Drop the unused `agent_messages.read_at` column.
*
* The column was added in v11 when reads were tracked per-message on a
* single shared receipt. v12 moved read tracking to the
* `agent_message_reads` join table, which is now the sole source of
* truth: every consumer that surfaces `readAt` (renderer snapshot,
* per-agent inbox, board receipts) resolves it from that table.
* `agent_messages.read_at` has not been written by any production path
* for multiple releases; leaving it around just forced every SELECT to
* carry a NULL column through wire-shape layers and invited the next
* maintainer to accidentally read stale data from it.
*/
export function createMigrationsV49V49(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 49,
name: "drop_agent_messages_read_at",
fn: () => {
if (!tableExists(db, "agent_messages")) {
log.info("[migration v49] agent_messages table missing; skipping");
return;
}
if (!columnExists(db, "agent_messages", "read_at")) {
log.info("[migration v49] agent_messages.read_at already absent; skipping");
return;
}
db.exec("ALTER TABLE agent_messages DROP COLUMN read_at");
log.info(
"[migration v49] dropped unused agent_messages.read_at column — reads are tracked per-reader in agent_message_reads",
);
},
},
];
}
-262
View File
@@ -1,262 +0,0 @@
import log from "electron-log/main.js";
import { STALE_LOCK_TIMEOUT_MS } from "../../constants";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, nowIso, tableExists } from "./types";
/**
* B2 Phase 1 step 1/5 — dual-write substrate.
*
* This migration installs the new `tasks.phase` column and the new
* `task_leases` table **without** switching the runtime over. Existing
* reads and writes continue against `tasks.status` /
* `tasks.checkout_run_id` / `tasks.execution_locked_at`; the new shapes
* ship as a shadow surface that later phases (2/5 onward) populate in
* parallel so we can verify the state machine before flipping reads.
*
* The migration is transactional by virtue of `runMigrations` wrapping
* every `migration.fn` in `BEGIN IMMEDIATE`. Backfill and invariant
* assertion therefore commit atomically with the schema change: a
* failure at any step rolls the entire migration back, and the
* pre-migration automatic snapshot (taken by `runMigrations` before
* applying pending migrations) stays available as the last-resort
* recovery point.
*
* Phase enum follows the user-supplied canonical set:
* 'todo' | 'backlog' | 'in_progress' | 'blocked' | 'completed' | 'cancelled'
*
* The backfill maps the six legacy `tasks.status` values 1:1 — with
* `done → completed` and `in_review → in_progress` (a reviewable task
* is still mid-flight, and the review stage is orthogonal to phase).
* Any post-backfill row with `phase IS NULL` represents data the map
* did not anticipate; the assertion at the end of the migration throws
* rather than silently leaving the shadow column inconsistent.
*
* `task_leases` mirrors the lease component of the current lock
* metadata (`checkout_run_id` + `execution_locked_at`). Only rows whose
* task currently owns an `active_run_id` OR has a live `checkout_run_id`
* get a seeded lease; `expires_at` uses the existing lease timestamp
* plus the runtime stale-lock timeout when present, otherwise a 15-minute
* default from `now()` so the stale-lock sweep still applies to seeded
* rows. The seed is best-effort: missing runs (referenced by a stale
* `checkout_run_id` but deleted from `runs`) are skipped rather than
* throwing, because the FK would reject them and the existing lock was
* already orphaned pre-migration.
*/
export function createMigrationsV50V50(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 50,
name: "tasks_phase_and_leases_dual_write",
fn: () => {
if (!tableExists(db, "tasks")) {
log.info("[migration v50] tasks table missing; nothing to do");
return;
}
// ── 1. Add `tasks.phase` (nullable at add time so the ALTER
// succeeds on an existing non-empty table). The CHECK
// constraint enforces the enum for new writes; legacy rows
// populate via the backfill two steps down.
if (!columnExists(db, "tasks", "phase")) {
db.exec(
`ALTER TABLE tasks ADD COLUMN phase TEXT CHECK (
phase IS NULL OR phase IN (
'todo', 'backlog', 'in_progress', 'blocked', 'completed', 'cancelled'
)
)`,
);
log.info("[migration v50] tasks.phase column added");
} else {
log.info("[migration v50] tasks.phase already present; reusing");
}
// ── 2. Create `task_leases`. The UNIQUE(task_id, run_id) pair
// matches the M7 design — a task can hold at most one active
// lease (PRIMARY KEY task_id), and that lease cannot belong
// to two different runs simultaneously.
db.exec(
`CREATE TABLE IF NOT EXISTS task_leases (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
run_id TEXT NOT NULL REFERENCES runs(id),
acquired_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
checkout_run_id TEXT,
UNIQUE(task_id, run_id)
)`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_task_leases_expires_at ON task_leases(expires_at)`,
);
log.info("[migration v50] task_leases table ensured");
// ── 3. Backfill `phase` from the legacy `status` enum.
// Mapping:
// backlog → backlog
// todo → todo
// in_progress → in_progress
// blocked → blocked
// done → completed
// cancelled → cancelled
// in_review → in_progress (reviewable task is still mid-flight)
db.prepare(
`UPDATE tasks
SET phase = CASE status
WHEN 'backlog' THEN 'backlog'
WHEN 'todo' THEN 'todo'
WHEN 'in_progress' THEN 'in_progress'
WHEN 'blocked' THEN 'blocked'
WHEN 'done' THEN 'completed'
WHEN 'cancelled' THEN 'cancelled'
WHEN 'in_review' THEN 'in_progress'
END
WHERE phase IS NULL`,
).run();
log.info("[migration v50] phase backfilled from status");
// ── 3b. Triggers that maintain the phase/status invariant for
// writes that still go through the legacy columns. Step 2/5
// introduces an explicit `advanceTaskPhase` helper and step
// 3/5 wires it into the production call sites, but until
// then any `INSERT INTO tasks` or `UPDATE tasks SET status
// = …` that bypasses the helper must still yield a row with
// a consistent `phase` column — otherwise dual-read
// observability in step 4/5 would surface false drift the
// moment any task row is created.
//
// The triggers are deliberately one-directional (status →
// phase). `advanceTaskPhase` writes phase directly; the
// legacy-column writers that coexist during dual-write
// continue to set `status` and now have their shadow phase
// kept in sync automatically. Once step 3/5 lands and every
// call site dual-writes both columns explicitly, these
// triggers become a belt-and-braces backstop rather than
// the primary invariant.
db.exec(
`DROP TRIGGER IF EXISTS trg_tasks_phase_from_status_insert;
DROP TRIGGER IF EXISTS trg_tasks_phase_from_status_update;`,
);
db.exec(
`CREATE TRIGGER trg_tasks_phase_from_status_insert
AFTER INSERT ON tasks
WHEN NEW.phase IS NULL AND NEW.status IS NOT NULL
BEGIN
UPDATE tasks SET phase = CASE NEW.status
WHEN 'backlog' THEN 'backlog'
WHEN 'todo' THEN 'todo'
WHEN 'in_progress' THEN 'in_progress'
WHEN 'blocked' THEN 'blocked'
WHEN 'done' THEN 'completed'
WHEN 'cancelled' THEN 'cancelled'
WHEN 'in_review' THEN 'in_progress'
END
WHERE id = NEW.id;
END;`,
);
db.exec(
`CREATE TRIGGER trg_tasks_phase_from_status_update
AFTER UPDATE OF status ON tasks
WHEN NEW.status IS NOT OLD.status
BEGIN
UPDATE tasks SET phase = CASE NEW.status
WHEN 'backlog' THEN 'backlog'
WHEN 'todo' THEN 'todo'
WHEN 'in_progress' THEN 'in_progress'
WHEN 'blocked' THEN 'blocked'
WHEN 'done' THEN 'completed'
WHEN 'cancelled' THEN 'cancelled'
WHEN 'in_review' THEN 'in_progress'
END
WHERE id = NEW.id;
END;`,
);
log.info("[migration v50] status → phase mirroring triggers installed");
// ── 4. Seed `task_leases` from the current lock columns.
// A task currently holds a runtime lease when `active_run_id`
// is set (run is live) or when `checkout_run_id` is set
// (run is acquiring). Both map to the same lease row.
//
// `expires_at` uses `execution_locked_at + STALE_LOCK_TIMEOUT_MS`
// when present; otherwise it falls back to `now() + 15 minutes`
// so the stale-lock sweep keeps working on seeded rows.
//
// The seed cannot attribute to a run that no longer exists
// (FK violation on `run_id`). Such rows are skipped — they
// are already orphaned state in the legacy columns, and the
// migration is not the right place to clean them up. A
// follow-up observability pass can surface them.
const nowMs = Date.now();
const ts = nowIso();
const seedRows = db.prepare(
`SELECT t.id AS taskId,
COALESCE(t.active_run_id, t.checkout_run_id) AS runId,
t.active_run_id AS activeRunId,
t.checkout_run_id AS checkoutRunId,
t.execution_locked_at AS executionLockedAt
FROM tasks t
WHERE (t.active_run_id IS NOT NULL OR t.checkout_run_id IS NOT NULL)`,
).all() as Array<{
taskId: string;
runId: string | null;
activeRunId: string | null;
checkoutRunId: string | null;
executionLockedAt: string | null;
}>;
const runExistsStmt = db.prepare(`SELECT 1 AS present FROM runs WHERE id = ?`);
const insertLeaseStmt = db.prepare(
`INSERT OR IGNORE INTO task_leases
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
VALUES (?, ?, ?, ?, ?)`,
);
let seeded = 0;
let skipped = 0;
for (const row of seedRows) {
const runId = row.runId;
if (!runId) continue;
const runExists = runExistsStmt.get(runId) as { present: number } | undefined;
if (!runExists) {
skipped += 1;
continue;
}
const acquiredAt = row.executionLockedAt ?? ts;
const lockedAtMs = row.executionLockedAt
? Date.parse(row.executionLockedAt)
: NaN;
const expiresBaseMs = Number.isFinite(lockedAtMs) ? lockedAtMs : nowMs;
const expiresAt = new Date(expiresBaseMs + STALE_LOCK_TIMEOUT_MS).toISOString();
insertLeaseStmt.run(row.taskId, runId, acquiredAt, expiresAt, row.checkoutRunId ?? null);
seeded += 1;
}
log.info(
`[migration v50] task_leases seed complete: ${seeded} seeded, ${skipped} skipped (run missing)`,
);
// ── 5. Invariant assertion: every task row must now carry a
// phase value. If the backfill missed a status we do not
// know about, fail the migration so the problem is visible
// at upgrade time rather than days later when a checkoutTask
// query silently excludes the row.
const missingRow = db.prepare(
`SELECT COUNT(*) AS count FROM tasks WHERE phase IS NULL`,
).get() as { count: number } | undefined;
const missingCount = missingRow?.count ?? 0;
if (missingCount > 0) {
const sample = db.prepare(
`SELECT id, status FROM tasks WHERE phase IS NULL LIMIT 5`,
).all() as Array<{ id: string; status: string }>;
const sampleText = sample
.map((row) => `${row.id}=${row.status}`)
.join(", ");
throw new Error(
`[migration v50] phase backfill left ${missingCount} rows NULL — unknown status values: ${sampleText}`,
);
}
log.info("[migration v50] invariant check passed: every task has a phase");
},
},
];
}
-89
View File
@@ -1,89 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { tableExists } from "./types";
/**
* B2 Phase 1 hotfix — relax `task_leases.run_id` FK to `runs(id)`.
*
* v50 created `task_leases.run_id TEXT NOT NULL REFERENCES runs(id)`.
* That FK was a well-intentioned tightening over the legacy
* `tasks.checkout_run_id` column (which had no FK at all), but it
* broke the orchestration sequence: `heartbeat.ts` and `task-runner.ts`
* generate a fresh `runId = randomUUID()` and call `checkoutTask` on
* the task *before* the corresponding row is inserted into `runs`.
* With the FK on, `writeCheckoutLease` → `INSERT INTO task_leases`
* fails with `FOREIGN KEY constraint failed`, the whole CAS rolls
* back, `checkoutTask` returns false, and the heartbeat loops on
* `checkout_race_lost` forever — the exact orphan symptom B2 was
* supposed to *eliminate*.
*
* The legacy `tasks.checkout_run_id` was always "dangling allowed"
* (the run row is saved later by the task-runner), and during B2
* Phase 1 the lease row is the shadow mirror of that same legacy
* intent. It must mirror the legacy's loose semantics until Phase 2
* tightens the whole contract in one coordinated step.
*
* Fix: recreate `task_leases` without the `run_id` FK. `task_id`
* keeps its FK + ON DELETE CASCADE (you cannot lease a task that
* does not exist) and the UNIQUE(task_id, run_id) pair survives
* (still meaningful, just no longer FK-checked). Existing rows are
* preserved verbatim via INSERT SELECT.
*
* PRAGMA foreign_keys cannot be toggled inside an open transaction
* (SQLite restriction), so we follow the pattern used by v40 / v41 /
* v43 / v44 — but those migrations run their own rebuilds *without*
* nesting inside the outer transaction boundary. In our case the
* outer `runMigrations` wrapping in `schema.ts` already opens
* BEGIN IMMEDIATE, so we use `PRAGMA defer_foreign_keys = ON`
* instead, which *is* valid inside a transaction and defers all FK
* checks until COMMIT. Since we are removing an FK rather than
* relying on one, the defer_foreign_keys toggle simply prevents a
* spurious check during the INSERT SELECT window.
*/
export function createMigrationsV51V51(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 51,
name: "task_leases_drop_run_id_fk",
fn: () => {
if (!tableExists(db, "task_leases")) {
log.info("[migration v51] task_leases table missing; nothing to do");
return;
}
db.exec("PRAGMA defer_foreign_keys = ON");
db.exec(`
CREATE TABLE task_leases_new (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
run_id TEXT NOT NULL,
acquired_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
checkout_run_id TEXT,
UNIQUE(task_id, run_id)
)
`);
db.exec(`
INSERT INTO task_leases_new
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
SELECT task_id, run_id, acquired_at, expires_at, checkout_run_id
FROM task_leases
`);
db.exec("DROP INDEX IF EXISTS idx_task_leases_expires_at");
db.exec("DROP TABLE task_leases");
db.exec("ALTER TABLE task_leases_new RENAME TO task_leases");
db.exec(
"CREATE INDEX IF NOT EXISTS idx_task_leases_expires_at ON task_leases(expires_at)",
);
log.info(
"[migration v51] task_leases recreated without run_id FK; task_id FK + UNIQUE(task_id, run_id) preserved",
);
},
},
];
}
-150
View File
@@ -1,150 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
/**
* Per-task filesystem lock policy — `tasks.requires_filesystem`.
*
* Background: before v52 `resolveWorkspaceWithLockMode` walked a four-
* step fallback chain (task → agent → project → any company workspace)
* and unconditionally marked the resolved row `exclusive`. In a
* multi-agent company every heartbeat collapsed onto the single
* company workspace row, every run claimed the same exclusive lock,
* and only one agent ever executed at a time — the "parallel C-suite"
* promise degraded silently into serial execution.
*
* Root cause is a schema one: the per-task shape of the question
* "does this task actually touch the filesystem?" had no column to
* live in, so the orchestrator defaulted to yes on everything. v52
* gives that question its own column.
*
* Invariants after this migration:
* • Every task row carries `requires_filesystem` ∈ {0, 1}.
* • `0` is the safe default — purely cognitive tasks (strategy,
* planning, document drafting, financial analysis) never take
* a workspace lock and run fully in parallel.
* • `1` is opt-in — explicitly signals "this task mutates the
* working tree, hold the workspace exclusive for the run".
*
* Backfill strategy (conservative — we only set `1` when we have
* positive evidence of filesystem use):
*
* a) In-flight tasks (`status = 'in_progress'` AND `active_run_id
* IS NOT NULL`): these currently hold a lease on a workspace;
* changing their flag mid-run would desynchronise the live
* dispatcher from the backing row. Mark `1` to preserve the
* exclusive semantics already in effect.
*
* b) Actionable tasks (`status IN ('todo','backlog')`) whose
* assignee is an engineering-flavoured role: CTO / engineer /
* developer / devops / SRE, or department = 'engineering'.
* These are the roles that `createCodingTools()` equips; every
* other role's task bag is cognitive by default.
*
* c) Everything else (done / cancelled / blocked / in_review
* non-engineering / etc.): `0`. Completed or cancelled tasks
* will never run again so the column value is advisory;
* blocked tasks will re-enter the dispatcher through an update
* path that will set the flag explicitly when it matters.
*
* Assertion: after backfill, zero NULL rows (the DEFAULT 0 on the
* ALTER TABLE guarantees this; the assertion is belt-and-braces).
*/
export function createMigrationsV52V52(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 52,
name: "tasks_requires_filesystem",
fn: () => {
if (!tableExists(db, "tasks")) {
log.info("[migration v52] tasks table missing; nothing to do");
return;
}
// ── 1. Add the column. NOT NULL DEFAULT 0 so existing rows
// get a deterministic value at ALTER time — no NULL window
// between the ALTER and the backfill UPDATE.
if (!columnExists(db, "tasks", "requires_filesystem")) {
db.exec(
`ALTER TABLE tasks ADD COLUMN requires_filesystem INTEGER NOT NULL DEFAULT 0`,
);
log.info("[migration v52] tasks.requires_filesystem column added");
} else {
log.info("[migration v52] tasks.requires_filesystem already present; reusing");
}
// ── 2. Backfill rule (a): in-flight rows preserve the lease
// the dispatcher is already holding. Turning these off
// would leave a running lease pointing at a task whose
// current policy says "no lease needed".
const inflightUpdate = db.prepare(
`UPDATE tasks
SET requires_filesystem = 1
WHERE status = 'in_progress'
AND active_run_id IS NOT NULL`,
).run();
log.info(
`[migration v52] backfill (a) inflight: ${Number(inflightUpdate.changes)} rows set to 1`,
);
// ── 3. Backfill rule (b): actionable tasks for coding roles.
// LIKE comparisons are case-insensitive in SQLite by
// default (NOCASE collation on text literal compares).
// We match both the free-text `agents.role` (where the
// UI lets users write "CTO", "Senior Engineer", etc.) and
// the structured `agents.department` column where present.
const codingRoleUpdate = db.prepare(
`UPDATE tasks
SET requires_filesystem = 1
WHERE status IN ('todo', 'backlog')
AND assignee_agent_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM agents a
WHERE a.id = tasks.assignee_agent_id
AND (
a.role LIKE '%cto%'
OR a.role LIKE '%engineer%'
OR a.role LIKE '%developer%'
OR a.role LIKE '%devops%'
OR a.role LIKE '%sre%'
OR a.department = 'engineering'
)
)`,
).run();
log.info(
`[migration v52] backfill (b) coding role actionable: ${Number(codingRoleUpdate.changes)} rows set to 1`,
);
// ── 4. Invariant assertion. DEFAULT 0 combined with the two
// targeted UPDATEs above means the column is never NULL,
// but the assertion makes the post-condition explicit so
// a future migration that removes the DEFAULT cannot
// silently violate it.
const nullCountRow = db.prepare(
`SELECT COUNT(*) AS count FROM tasks WHERE requires_filesystem IS NULL`,
).get() as { count: number } | undefined;
const nullCount = nullCountRow?.count ?? 0;
if (nullCount > 0) {
throw new Error(
`[migration v52] invariant violated: ${nullCount} tasks row(s) have requires_filesystem IS NULL after backfill`,
);
}
// Diagnostic counts — read back the final distribution so the
// upgrade log carries evidence that backfill matched the
// expected order of magnitude.
const totals = db.prepare(
`SELECT
SUM(CASE WHEN requires_filesystem = 1 THEN 1 ELSE 0 END) AS fsOn,
SUM(CASE WHEN requires_filesystem = 0 THEN 1 ELSE 0 END) AS fsOff
FROM tasks`,
).get() as { fsOn: number | null; fsOff: number | null } | undefined;
log.info(
`[migration v52] final distribution: requires_filesystem=1 → ${totals?.fsOn ?? 0}, requires_filesystem=0 → ${totals?.fsOff ?? 0}`,
);
},
},
];
}
-77
View File
@@ -1,77 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { nowIso, tableExists } from "./types";
export function createMigrationsV53V53(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 53,
name: "task_leases_restore_run_fk",
fn: () => {
if (!tableExists(db, "task_leases")) {
log.info("[migration v53] task_leases table missing; nothing to do");
return;
}
const danglingLeases = db.prepare(
`SELECT l.task_id AS taskId, l.run_id AS runId
FROM task_leases l
LEFT JOIN runs r ON r.id = l.run_id
WHERE r.id IS NULL`,
).all() as Array<{ taskId: string; runId: string }>;
if (danglingLeases.length > 0) {
const ts = nowIso();
const repairTask = db.prepare(
`UPDATE tasks
SET active_run_id = CASE WHEN active_run_id = ? THEN NULL ELSE active_run_id END,
checkout_run_id = CASE WHEN checkout_run_id = ? THEN NULL ELSE checkout_run_id END,
execution_locked_at = CASE
WHEN active_run_id = ? OR checkout_run_id = ? THEN NULL
ELSE execution_locked_at
END,
updated_at = ?
WHERE id = ?`,
);
for (const lease of danglingLeases) {
repairTask.run(lease.runId, lease.runId, lease.runId, lease.runId, ts, lease.taskId);
}
db.prepare(
`DELETE FROM task_leases
WHERE run_id NOT IN (SELECT id FROM runs)`,
).run();
log.warn(
`[migration v53] removed ${danglingLeases.length} dangling task lease(s) before restoring run_id FK`,
);
}
db.exec("PRAGMA defer_foreign_keys = ON");
db.exec(`
CREATE TABLE task_leases_v53 (
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
acquired_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
checkout_run_id TEXT,
UNIQUE(task_id, run_id)
)
`);
db.exec(`
INSERT INTO task_leases_v53
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
SELECT task_id, run_id, acquired_at, expires_at, checkout_run_id
FROM task_leases
`);
db.exec("DROP INDEX IF EXISTS idx_task_leases_expires_at");
db.exec("DROP TABLE task_leases");
db.exec("ALTER TABLE task_leases_v53 RENAME TO task_leases");
db.exec(
"CREATE INDEX IF NOT EXISTS idx_task_leases_expires_at ON task_leases(expires_at)",
);
log.info("[migration v53] task_leases.run_id FK restored");
},
},
];
}
-34
View File
@@ -1,34 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
export function createMigrationsV54V54(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 54,
name: "runs_usage_status",
fn: () => {
if (!tableExists(db, "runs")) {
log.info("[migration v54] runs table missing; nothing to do");
return;
}
if (!columnExists(db, "runs", "usage_status")) {
db.exec("ALTER TABLE runs ADD COLUMN usage_status TEXT NOT NULL DEFAULT 'pre_first_message'");
log.info("[migration v54] runs.usage_status column added");
}
db.exec(`
UPDATE runs
SET usage_status = CASE
WHEN unattributed_cost = 0 THEN 'ok'
WHEN status IN ('cancelled', 'interrupted') THEN 'aborted_before_usage'
ELSE 'pre_first_message'
END
WHERE usage_status = 'pre_first_message'
`);
},
},
];
}
-29
View File
@@ -1,29 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { columnExists, tableExists } from "./types";
export function createMigrationsV55V55(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 55,
name: "tasks_single_row_state",
fn: () => {
db.exec(`
DROP TRIGGER IF EXISTS trg_tasks_phase_from_status_insert;
DROP TRIGGER IF EXISTS trg_tasks_phase_from_status_update;
`);
if (tableExists(db, "task_leases")) {
db.exec("DROP TABLE task_leases");
log.info("[migration v55] task_leases shadow table dropped");
}
if (tableExists(db, "tasks") && columnExists(db, "tasks", "phase")) {
db.exec("ALTER TABLE tasks DROP COLUMN phase");
log.info("[migration v55] tasks.phase shadow column dropped");
}
},
},
];
}
-48
View File
@@ -1,48 +0,0 @@
import log from "electron-log/main.js";
import type { Migration, MigrationDeps } from "./types";
import { tableExists } from "./types";
export function createMigrationsV56V56(deps: MigrationDeps): Migration[] {
const { db } = deps;
return [
{
version: 56,
name: "work_products_primary_invariant",
fn: () => {
if (!tableExists(db, "work_products")) {
log.info("[migration v56] work_products table missing; skipping");
return;
}
db.exec(`
UPDATE work_products
SET is_primary = 0,
updated_at = datetime('now')
WHERE id IN (
SELECT id FROM (
SELECT
id,
row_number() OVER (
PARTITION BY company_id, task_id, type
ORDER BY updated_at DESC, created_at DESC, id DESC
) AS rank_in_group
FROM work_products
WHERE task_id IS NOT NULL
AND is_primary = 1
AND status <> 'archived'
)
WHERE rank_in_group > 1
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_products_primary_active
ON work_products(company_id, task_id, type)
WHERE task_id IS NOT NULL
AND is_primary = 1
AND status <> 'archived';
`);
log.info("[migration v56] work_products active primary invariant installed");
},
},
];
}
+60 -226
View File
@@ -1,185 +1,62 @@
import type { DatabaseSync } from "node:sqlite";
import log from "electron-log/main.js";
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 { createMigrationsV38V39 } from "./migrations/v38-v39";
import { createMigrationsV40V40 } from "./migrations/v40-v40";
import { createMigrationsV41V41 } from "./migrations/v41-v41";
import { createMigrationsV42V42 } from "./migrations/v42-v42";
import { createMigrationsV43V43 } from "./migrations/v43-v43";
import { createMigrationsV44V44 } from "./migrations/v44-v44";
import { createMigrationsV45V45 } from "./migrations/v45-v45";
import { createMigrationsV46V46 } from "./migrations/v46-v46";
import { createMigrationsV47V47 } from "./migrations/v47-v47";
import { createMigrationsV48V48 } from "./migrations/v48-v48";
import { createMigrationsV49V49 } from "./migrations/v49-v49";
import { createMigrationsV50V50 } from "./migrations/v50-v50";
import { createMigrationsV51V51 } from "./migrations/v51-v51";
import { createMigrationsV52V52 } from "./migrations/v52-v52";
import { createMigrationsV53V53 } from "./migrations/v53-v53";
import { createMigrationsV54V54 } from "./migrations/v54-v54";
import { createMigrationsV55V55 } from "./migrations/v55-v55";
import { createMigrationsV56V56 } from "./migrations/v56-v56";
import {
columnExists,
nowIso,
tableExists,
type Migration,
BASELINE_SCHEMA_VERSION,
assertBaselineSchema,
installBaselineSchema,
} from "./migrations/baseline-v56";
import {
markBaselineSchema,
upgradeLegacyProfileToBaselineV56,
} from "./migrations/legacy-to-v56";
import {
type ReadAll,
type Transaction,
} from "./migrations/types";
export function ensureOptionalColumns(db: DatabaseSync) {
const addColumnIfMissing = (table: string, column: string, sql: string) => {
if (!columnExists(db, table, column)) db.prepare(sql).run();
};
addColumnIfMissing("connectors", "updated_at", "alter table connectors add column updated_at text");
addColumnIfMissing("approvals", "payload_json", "alter table approvals add column payload_json text");
addColumnIfMissing("agents", "heartbeat_enabled", "alter table agents add column heartbeat_enabled integer not null default 0");
addColumnIfMissing("agents", "heartbeat_interval_sec", "alter table agents add column heartbeat_interval_sec integer not null default 120");
addColumnIfMissing("agents", "last_heartbeat_at", "alter table agents add column last_heartbeat_at text");
addColumnIfMissing("agents", "session_id", "alter table agents add column session_id text");
db.exec("create index if not exists idx_agents_session_id on agents(session_id)");
addColumnIfMissing("companies", "auto_approve_hires", "alter table companies add column auto_approve_hires integer not null default 0");
addColumnIfMissing("companies", "review_deliverables", "alter table companies add column review_deliverables integer not null default 0");
addColumnIfMissing("companies", "execution_mode", "alter table companies add column execution_mode text not null default 'ai_ceo'");
addColumnIfMissing("tasks", "task_type", "alter table tasks add column task_type text not null default 'general'");
addColumnIfMissing("tasks", "requires_user_review", "alter table tasks add column requires_user_review integer not null default 0");
addColumnIfMissing("tasks", "retry_count", "alter table tasks add column retry_count integer not null default 0");
addColumnIfMissing("tasks", "last_failure_at", "alter table tasks add column last_failure_at text");
addColumnIfMissing("tasks", "checkout_run_id", "alter table tasks add column checkout_run_id text");
addColumnIfMissing("tasks", "execution_locked_at", "alter table tasks add column execution_locked_at text");
addColumnIfMissing("tasks", "active_session_id", "alter table tasks add column active_session_id text");
addColumnIfMissing("runs", "usage_status", "alter table runs add column usage_status text not null default 'pre_first_message'");
addColumnIfMissing("cost_entries", "project_id", "alter table cost_entries add column project_id text");
db.exec(`
create table if not exists task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null);
create index if not exists idx_comments_task on task_comments(task_id);
create index if not exists idx_comments_company on task_comments(company_id);
`);
db.exec(`
create table if not exists social_accounts (id text primary key, company_id text not null references companies(id) on delete cascade, platform text not null default 'other', account_name text not null, display_name text not null default '', profile_url text not null default '', credential_secret_id text, status text not null default 'login_required', require_approval integer not null default 1, metadata_json text not null default '{}', last_used_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')));
create index if not exists idx_social_accounts_company on social_accounts(company_id);
`);
db.exec(`
create table if not exists browser_actions (id text primary key, company_id text not null references companies(id) on delete cascade, social_account_id text not null references social_accounts(id) on delete cascade, agent_id text not null references agents(id) on delete cascade, task_id text, action_type text not null default 'custom_script', payload_json text not null default '{}', status text not null default 'queued', result_summary text not null default '', screenshot_path text, approval_id text, error_message text, started_at text, finished_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')));
create index if not exists idx_browser_actions_company on browser_actions(company_id);
create index if not exists idx_browser_actions_account on browser_actions(social_account_id);
`);
if (tableExists(db, "agent_messages")) {
db.exec(`
create index if not exists idx_messages_company on agent_messages(company_id);
create index if not exists idx_messages_channel on agent_messages(channel, channel_target_id);
`);
}
if (tableExists(db, "automation_rules")) {
db.exec(`create index if not exists idx_automation_rules_company_status on automation_rules(company_id, status);`);
}
if (tableExists(db, "automation_log")) {
db.exec(`create index if not exists idx_automation_log_company on automation_log(company_id);`);
}
}
export function migrateSecretsTable(db: DatabaseSync, readAll: ReadAll) {
const columns = readAll<{ name: string }>("pragma table_info(secrets)");
if (columns.some((column) => column.name === "company_id")) {
db.prepare("create unique index if not exists secrets_company_name_idx on secrets(company_id, name)").run();
return;
}
// Legacy profiles predate the per-company secrets table. Rebind each
// row to the current company; if neither `current_company_id` nor any
// company row exists we refuse rather than attach secrets to a
// synthetic zero UUID that references nothing — that would leak
// ciphertext with no deletion path and no UI visibility.
const currentCompany = db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined;
const firstCompany = db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined;
const fallbackCompanyId = currentCompany?.value ?? firstCompany?.id;
if (!fallbackCompanyId) {
throw new Error(
"Cannot migrate legacy secrets table: no current_company_id is set and no company rows exist. Create a company before upgrading, or drop the legacy secrets table if the rows are stale.",
);
}
db.exec(`
alter table secrets rename to secrets_legacy;
create table secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name));
`);
db.prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) select id, ?, name, description, backend, ciphertext, updated_at, created_at from secrets_legacy").run(fallbackCompanyId);
db.exec("drop table secrets_legacy;");
}
export function initializeDatabase(
db: DatabaseSync,
ra: ReadAll,
tx: Transaction,
createAutomaticBackup: (reason: string) => string,
): void {
db.exec(`
create table if not exists settings (key text primary key, value text not null);
create table if not exists companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null);
create table if not exists connectors (id text primary key, label text not null, description text not null, status text not null, command text not null, version text, auth_state text not null, last_checked_at text, last_error text, model text, env_binding_text text not null, notes text not null, updated_at text);
create table if not exists workspaces (id text primary key, company_id text not null, name text not null, local_path text not null, repo_url text not null, repo_ref text not null, project_id text, state text not null, is_primary integer not null default 0, created_at text not null, updated_at text not null);
create table if not exists agents (id text primary key, company_id text not null, name text not null, role text not null, title text not null, status text not null, reports_to text, connector_id text not null, workspace_id text, model text, capabilities text not null, budget_monthly_usd real not null default 0, spent_monthly_usd real not null default 0, metadata_json text not null default '{}', created_at text not null, updated_at text not null);
create table if not exists goals (id text primary key, company_id text not null, title text not null, description text not null, parent_id text, owner_agent_id text, status text not null, created_at text not null, updated_at text not null);
create table if not exists projects (id text primary key, company_id text not null, goal_id text, name text not null, description text not null, lead_agent_id text, status text not null, target_date text, created_at text not null, updated_at text not null);
create table if not exists tasks (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);
create table if not exists approvals (id text primary key, company_id text not null, related_task_id text, requested_by_agent_id text, type text not null, payload_summary text not null, impact_summary text not null, payload_json text, decision_note text not null default '', state text not null, created_at text not null, updated_at text not null);
create table if not exists runs (id text primary key, company_id text not null, task_id text 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, usage_status text not null default 'pre_first_message', started_at text, finished_at text, log_path text, created_at text not null, updated_at text not null);
create table if not exists cost_entries (id text primary key, company_id text not null, run_id text not null, task_id text, agent_id text, connector_id text not null, amount_usd real, unattributed integer not null default 0, created_at text not null);
create table if not exists activity (id text primary key, company_id text not null, actor text not null, action text not null, entity_type text not null, entity_id text not null, detail text not null, created_at text not null);
create table if not exists secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name));
`);
db.exec("create table if not exists schema_migrations (version integer primary key, name text not null, applied_at text not null)");
db.exec(`
create index if not exists idx_workspaces_company on workspaces(company_id);
create index if not exists idx_agents_company on agents(company_id);
create index if not exists idx_goals_company on goals(company_id);
create index if not exists idx_projects_company on projects(company_id);
create index if not exists idx_tasks_company on tasks(company_id);
create index if not exists idx_tasks_assignee on tasks(assignee_agent_id);
create index if not exists idx_tasks_project on tasks(project_id);
create index if not exists idx_approvals_company on approvals(company_id);
create index if not exists idx_runs_company on runs(company_id);
create index if not exists idx_runs_task on runs(task_id);
create index if not exists idx_runs_agent on runs(agent_id);
create index if not exists idx_cost_entries_company on cost_entries(company_id);
create index if not exists idx_cost_entries_run on cost_entries(run_id);
create index if not exists idx_activity_company on activity(company_id);
create index if not exists idx_tasks_company_status on tasks(company_id, status);
create index if not exists idx_agents_company_status on agents(company_id, status);
create index if not exists idx_agents_reports_to on agents(reports_to);
create index if not exists idx_runs_company_status on runs(company_id, status);
create index if not exists idx_approvals_company_state on approvals(company_id, state);
`);
const currentVersion = currentSchemaVersion(db);
if (currentVersion > BASELINE_SCHEMA_VERSION) {
throw new Error(`Profile schema version ${currentVersion} is newer than this app baseline ${BASELINE_SCHEMA_VERSION}.`);
}
db.exec(`
create table if not exists pending_wakes (id text primary key, company_id text not null, agent_id text not null, trigger_reason text not null, created_at text not null);
create index if not exists idx_pending_wakes_agent on pending_wakes(agent_id);
`);
if (currentVersion === BASELINE_SCHEMA_VERSION) {
tx(() => {
installBaselineSchema(db);
markBaselineSchema(db);
seedSettings(db);
assertBaselineSchema(db);
});
return;
}
db.exec(`
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_agent ON wakeup_requests(agent_id, company_id, status);
`);
if (profileHasApplicationTables(db)) {
const backupPath = createAutomaticBackup(`pre-migration-v${BASELINE_SCHEMA_VERSION}`);
log.info(`[schema] pre-migration snapshot for v${BASELINE_SCHEMA_VERSION} at ${backupPath}`);
withForeignKeysPaused(db, tx, () => {
upgradeLegacyProfileToBaselineV56({ db, readAll: ra });
seedSettings(db);
assertBaselineSchema(db);
});
return;
}
migrateSecretsTable(db, ra);
db.exec(`create index if not exists idx_secrets_company on secrets(company_id);`);
runMigrations(db, ra, tx, createAutomaticBackup);
ensureOptionalColumns(db);
db.exec(`
create index if not exists idx_agents_department on agents(company_id, department);
create index if not exists idx_runs_company_created_at on runs(company_id, created_at desc);
create index if not exists idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc);
create index if not exists idx_activity_company_created_at on activity(company_id, created_at desc);
create index if not exists idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc);
`);
seedSettings(db);
tx(() => {
installBaselineSchema(db);
markBaselineSchema(db);
seedSettings(db);
assertBaselineSchema(db);
});
}
export function seedSettings(db: DatabaseSync) {
export function seedSettings(db: DatabaseSync): void {
const getSetting = (key: string): string | null => {
const row = db.prepare("select value from settings where key = ?").get(key) as { value: string } | undefined;
return row?.value ?? null;
@@ -192,70 +69,27 @@ export function seedSettings(db: DatabaseSync) {
if (!getSetting("log_retention_days")) setSetting("log_retention_days", "90");
}
export function runMigrations(
db: DatabaseSync,
readAll: ReadAll,
transaction: Transaction,
createAutomaticBackup: (reason: string) => string,
) {
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));
function currentSchemaVersion(db: DatabaseSync): number {
const row = db
.prepare("select coalesce(max(version), 0) as version from schema_migrations")
.get() as { version: number } | undefined;
return row?.version ?? 0;
}
const deps = { db, readAll };
const migrations: Migration[] = [
...createMigrationsV1V15(deps),
...createMigrationsV16V25(deps),
...createMigrationsV26V34(deps),
...createMigrationsV35V37(deps),
...createMigrationsV38V39(deps),
...createMigrationsV40V40(deps),
...createMigrationsV41V41(deps),
...createMigrationsV42V42(deps),
...createMigrationsV43V43(deps),
...createMigrationsV44V44(deps),
...createMigrationsV45V45(deps),
...createMigrationsV46V46(deps),
...createMigrationsV47V47(deps),
...createMigrationsV48V48(deps),
...createMigrationsV49V49(deps),
...createMigrationsV50V50(deps),
...createMigrationsV51V51(deps),
...createMigrationsV52V52(deps),
...createMigrationsV53V53(deps),
...createMigrationsV54V54(deps),
...createMigrationsV55V55(deps),
...createMigrationsV56V56(deps),
];
function profileHasApplicationTables(db: DatabaseSync): boolean {
const rows = db
.prepare("select name from sqlite_master where type = 'table' and name not like 'sqlite_%'")
.all() as Array<{ name: string }>;
return rows.some((row) => row.name !== "schema_migrations" && row.name !== "settings");
}
// Pre-migration snapshot. If any migration is pending we take a full
// profile snapshot before touching a single row. A migration crash
// then yields a well-labelled recovery point instead of a half-
// upgraded profile. The snapshot itself must succeed — failure here
// aborts the run with the raw error so the caller can surface it to
// the operator rather than silently upgrade in-place.
const pending = migrations.filter((m) => !applied.has(m.version));
if (pending.length > 0) {
const highestTargetVersion = pending.reduce((max, m) => Math.max(max, m.version), 0);
const path = createAutomaticBackup(`pre-migration-v${highestTargetVersion}`);
log.info(`[schema] pre-migration snapshot for v${highestTargetVersion} at ${path}`);
}
for (const migration of migrations) {
if (applied.has(migration.version)) continue;
try {
transaction(() => {
if (migration.fn) {
migration.fn();
} else if (migration.sql) {
for (const stmt of migration.sql.split(";").map((s) => s.trim()).filter(Boolean)) {
db.prepare(stmt).run();
}
}
db.prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)").run(migration.version, migration.name, nowIso());
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Migration ${migration.version} (${migration.name}) failed: ${message}`);
}
function withForeignKeysPaused<T>(db: DatabaseSync, tx: Transaction, op: () => T): T {
const row = db.prepare("pragma foreign_keys").get() as { foreign_keys: number } | undefined;
const previous = row?.foreign_keys === 1 ? "ON" : "OFF";
db.exec("pragma foreign_keys = OFF");
try {
return tx(op);
} finally {
db.exec(`pragma foreign_keys = ${previous}`);
}
}
@@ -100,6 +100,16 @@ describe("work product primary invariant", () => {
create table settings (key text primary key, value text not null);
create table companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null);
create table agents (id text primary key, company_id text not null, status text not null, reports_to text, department text, connector_id text not null default 'openai-codex', updated_at text not null);
create table tasks (
id text primary key,
company_id text not null,
title text not null,
description text not null,
status text not null,
priority text not null,
created_at text not null,
updated_at text not null
);
create table workspaces (id text primary key, company_id text not null, local_path text not null, state text not null, lock_holder_run_id text, lock_acquired_at text, updated_at text not null);
create table agent_messages (id text primary key, company_id text not null, channel text not null, channel_target_id text, created_at text not null);
create table model_capabilities (
@@ -132,7 +142,8 @@ describe("work product primary invariant", () => {
created_by_run_id TEXT,
last_touched_by_run_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
create table schema_migrations (version integer primary key, name text not null, applied_at text not null);
`);
@@ -144,6 +155,9 @@ describe("work product primary invariant", () => {
rawDb
.prepare("insert into companies (id, name, description, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?)")
.run("company-1", "Legacy Co", "", "active", ts, ts);
rawDb
.prepare("insert into tasks (id, company_id, title, description, status, priority, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-1", "company-1", "Legacy task", "", "in_progress", "medium", ts, ts);
rawDb
.prepare("insert into work_products (id, company_id, task_id, type, provider, external_id, title, status, review_state, is_primary, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("older-primary", "company-1", "task-1", "file", "filesystem", "older.md", "Older", "active", "none", 1, "2026-04-24T00:00:01.000Z", "2026-04-24T00:00:01.000Z");
+9 -7
View File
@@ -462,18 +462,20 @@ describe("AppDatabase", () => {
expect(db.core.agents.isAgentPendingApproval(pendingAgentId)).toBe(true);
});
it("runs forward-only migrations and creates schema_migrations table", () => {
// The migration system runs during init() — verify the tracking table exists
const snapshot = db.projection.snapshot.listSnapshot();
expect(snapshot).toBeTruthy();
it("installs the v56 baseline once on a fresh profile", () => {
const migrations = db.queryAll<{ version: number; name: string }>(
"select version, name from schema_migrations order by version",
);
expect(migrations).toEqual([{ version: 56, name: "baseline_v56" }]);
// Verify we can re-init without errors (migrations are idempotent)
db.close();
db = new AppDatabase(tempDir);
db.init();
const snapshot2 = db.projection.snapshot.listSnapshot();
expect(snapshot2).toBeTruthy();
const migrationsAfterReinit = db.queryAll<{ version: number; name: string }>(
"select version, name from schema_migrations order by version",
);
expect(migrationsAfterReinit).toEqual([{ version: 56, name: "baseline_v56" }]);
});
it("acquires and releases workspace locks", () => {
@@ -0,0 +1,368 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
beforeAll(() => {
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
});
vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
const tempDirs: string[] = [];
const ts = "2026-04-24T00:00:00.000Z";
async function createTempDir(prefix: string) {
const dir = await mkdtemp(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
function insertLegacyMigrationRows(db: DatabaseSync, maxVersion: number): void {
db.exec("create table schema_migrations (version integer primary key, name text not null, applied_at text not null)");
for (let version = 1; version <= maxVersion; version += 1) {
db
.prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)")
.run(version, `legacy-${version}`, ts);
}
}
function seedLegacyOperationalProfile(db: DatabaseSync): void {
insertLegacyMigrationRows(db, 55);
db.exec(`
create table settings (key text primary key, value text not null);
create table companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null);
create table agents (
id text primary key,
company_id text not null,
name text not null,
role text not null,
title text not null,
status text not null,
reports_to text,
connector_id text not null,
workspace_id text,
model text,
capabilities text not null,
budget_monthly_usd real not null default 0,
spent_monthly_usd real not null default 0,
metadata_json text not null default '{}',
created_at text not null,
updated_at text not null,
department text
);
create table tasks (
id text primary key,
company_id text not null,
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,
phase text,
is_heartbeat integer not null default 0,
created_at text not null,
updated_at text not null
);
create table runs (
id text primary key,
company_id text not null,
task_id text,
agent_id text not null,
workspace_id text,
connector_id text not null,
status text not null,
summary text not null,
unattributed_cost integer not null default 0,
session_display_id text,
context_snapshot_json text,
created_at text not null,
updated_at text not null
);
create table work_products (
id text primary key,
company_id text not null,
project_id text,
task_id text,
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 table pending_wakes (id text primary key, company_id text not null, agent_id text not null, trigger_reason text not null, created_at text not null);
create table secrets (id text primary key, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null);
`);
db.prepare("insert into settings (key, value) values ('current_company_id', 'company-1')").run();
db
.prepare("insert into companies (id, name, description, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?)")
.run("company-1", "Legacy Co", "", "active", ts, ts);
db
.prepare("insert into agents (id, company_id, name, role, title, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at, department) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("agent-1", "company-1", "Engineer", "Engineer", "Engineer", "active", null, "openai-codex", null, null, "code", 12.34, 0, "{}", ts, ts, "engineering");
db
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, workspace_id, status, priority, active_run_id, session_json, session_display_id, phase, is_heartbeat, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-1", "company-1", "Legacy task", "", "agent-1", "workspace-task", "todo", "medium", null, "{\"provider\":\"legacy\"}", "display-1", "todo", 0, ts, ts);
db
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, workspace_id, status, priority, active_run_id, session_json, session_display_id, phase, is_heartbeat, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("heartbeat-task", "company-1", "[Heartbeat] agent-1", "", "agent-1", "workspace-task", "in_progress", "low", "run-heartbeat", null, null, "in_progress", 1, ts, ts);
db
.prepare("insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, session_display_id, context_snapshot_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("run-heartbeat", "company-1", "heartbeat-task", "agent-1", "workspace-task", "openai-codex", "running", "", 0, "run-display", "{}", ts, ts);
db
.prepare("insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, session_display_id, context_snapshot_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("run-product", "company-1", null, "agent-1", "workspace-run", "openai-codex", "done", "", 0, "run-display-2", "{}", ts, ts);
db
.prepare("insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, unattributed_cost, session_display_id, context_snapshot_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("run-cancelled", "company-1", null, "agent-1", "workspace-run", "openai-codex", "cancelled", "", 1, "run-display-3", "{}", ts, ts);
db
.prepare("insert into work_products (id, company_id, task_id, type, provider, external_id, title, status, review_state, is_primary, created_by_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("wp-task", "company-1", "task-1", "file", "filesystem", "task.md", "Task file", "active", "none", 1, null, ts, ts);
db
.prepare("insert into work_products (id, company_id, task_id, type, provider, external_id, title, status, review_state, is_primary, created_by_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("wp-run", "company-1", null, "file", "filesystem", "run.md", "Run file", "active", "none", 0, "run-product", ts, ts);
db
.prepare("insert into pending_wakes (id, company_id, agent_id, trigger_reason, created_at) values (?, ?, ?, ?, ?)")
.run("wake-1", "company-1", "agent-1", "manual", ts);
db
.prepare("insert into secrets (id, name, description, backend, ciphertext, updated_at, created_at) values (?, ?, ?, ?, ?, ?, ?)")
.run("secret-1", "api", "API key", "safeStorage", Buffer.from("encrypted"), ts, ts);
}
describe("legacy to v56 baseline migration", () => {
it("migrates legacy data helpers and compresses schema history", async () => {
const profileDir = await createTempDir("agentcompany-legacy-baseline-");
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
try {
seedLegacyOperationalProfile(rawDb);
} finally {
rawDb.close();
}
let appDb = new AppDatabase(profileDir);
appDb.init();
try {
expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([
{ version: 56, name: "baseline_v56" },
]);
expect(appDb.queryAll<{ companyId: string }>("select company_id as companyId from secrets where id = 'secret-1'")[0])
.toEqual({ companyId: "company-1" });
expect(appDb.queryAll<{ source: string; triggerDetail: string }>("select source, trigger_detail as triggerDetail from wakeup_requests where id = 'wake-1'")[0])
.toEqual({ source: "manual", triggerDetail: JSON.stringify([{ trigger: "manual", at: ts, detail: "manual" }]) });
expect(appDb.queryAll<{ amount: number }>("select amount from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'")[0])
.toEqual({ amount: 1234 });
expect(appDb.queryAll<{ agentId: string; adapterType: string }>("select agent_id as agentId, adapter_type as adapterType from task_sessions")[0])
.toEqual({ agentId: "agent-1", adapterType: "openai-codex" });
expect(appDb.queryAll("select id from tasks where id = 'heartbeat-task'")).toEqual([]);
expect(appDb.queryAll<{ taskId: string | null }>("select task_id as taskId from runs where id = 'run-heartbeat'")[0])
.toEqual({ taskId: null });
expect(appDb.queryAll<{ id: string; usageStatus: string }>("select id, usage_status as usageStatus from runs where id in ('run-product', 'run-cancelled') order by id"))
.toEqual([
{ id: "run-cancelled", usageStatus: "aborted_before_usage" },
{ id: "run-product", usageStatus: "ok" },
]);
expect(appDb.queryAll<{ id: string; workspaceId: string | null; lastTouchedByRunId: string | null }>("select id, workspace_id as workspaceId, last_touched_by_run_id as lastTouchedByRunId from work_products order by id"))
.toEqual([
{ id: "wp-run", workspaceId: "workspace-run", lastTouchedByRunId: "run-product" },
{ id: "wp-task", workspaceId: "workspace-task", lastTouchedByRunId: null },
]);
} finally {
appDb.close();
}
appDb = new AppDatabase(profileDir);
appDb.init();
try {
expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([
{ version: 56, name: "baseline_v56" },
]);
expect(appDb.queryAll("select id from budget_policies where scope_type = 'agent' and scope_id = 'agent-1'"))
.toHaveLength(1);
} finally {
appDb.close();
}
});
it("rolls back when a legacy table would drop an unknown column", async () => {
const profileDir = await createTempDir("agentcompany-legacy-rollback-");
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
try {
insertLegacyMigrationRows(rawDb, 1);
rawDb.exec(`
create table companies (
id text primary key,
name text not null,
description text not null,
status text not null,
created_at text not null,
updated_at text not null,
rogue_payload text not null
);
`);
rawDb
.prepare("insert into companies (id, name, description, status, created_at, updated_at, rogue_payload) values (?, ?, ?, ?, ?, ?, ?)")
.run("company-1", "Legacy Co", "", "active", ts, ts, "keep");
} finally {
rawDb.close();
}
const appDb = new AppDatabase(profileDir);
try {
expect(() => appDb.init()).toThrow(/unexpected legacy columns/);
expect(appDb.queryAll<{ name: string }>("pragma table_info(companies)").map((row) => row.name))
.toContain("rogue_payload");
expect(appDb.queryAll("select version from schema_migrations")).toEqual([{ version: 1 }]);
} finally {
appDb.close();
}
});
it("rolls back v56 compression when baseline drift is detected", async () => {
const profileDir = await createTempDir("agentcompany-v56-drift-rollback-");
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
try {
rawDb.exec(`
create table schema_migrations (version integer primary key, name text not null, applied_at text not null);
insert into schema_migrations (version, name, applied_at) values (55, 'legacy-55', '${ts}');
insert into schema_migrations (version, name, applied_at) values (56, 'legacy-56', '${ts}');
create table companies (id text primary key, name text not null);
`);
} finally {
rawDb.close();
}
const appDb = new AppDatabase(profileDir);
try {
expect(() => appDb.init()).toThrow(/Baseline v56 table drift/);
expect(appDb.queryAll("select version, name from schema_migrations order by version")).toEqual([
{ version: 55, name: "legacy-55" },
{ version: 56, name: "legacy-56" },
]);
expect(appDb.queryAll("select name from sqlite_master where type = 'table' and name = 'agents'")).toEqual([]);
} finally {
appDb.close();
}
});
it("migrates task session rows through the old task-scoped table shape", async () => {
const profileDir = await createTempDir("agentcompany-old-task-sessions-");
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
try {
insertLegacyMigrationRows(rawDb, 40);
rawDb.exec(`
create table settings (key text primary key, value text not null);
create table companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null);
create table agents (
id text primary key,
company_id text not null,
name text not null,
role text not null,
title text not null,
status text not null,
reports_to text,
connector_id text not null,
workspace_id text,
model text,
capabilities text not null,
budget_monthly_usd real not null default 0,
spent_monthly_usd real not null default 0,
metadata_json text not null default '{}',
created_at text not null,
updated_at text not null
);
create table tasks (
id text primary key,
company_id text not null,
title text not null,
description text not null,
assignee_agent_id text,
status text not null,
priority text not null,
session_json text,
session_display_id text,
active_session_id text,
created_at text not null,
updated_at text not null
);
create table 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,
created_at text not null,
updated_at text not null
);
`);
rawDb
.prepare("insert into companies (id, name, description, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?)")
.run("company-1", "Legacy Co", "", "active", ts, ts);
rawDb
.prepare("insert into agents (id, company_id, name, role, title, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("agent-1", "company-1", "Engineer", "Engineer", "Engineer", "active", null, "openai-codex", null, null, "code", 0, 0, "{}", ts, ts);
rawDb
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, status, priority, session_json, session_display_id, active_session_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-1", "company-1", "Legacy task", "", "agent-1", "todo", "medium", "{\"provider\":\"legacy\"}", "display-1", null, ts, ts);
} finally {
rawDb.close();
}
const appDb = new AppDatabase(profileDir);
appDb.init();
try {
expect(appDb.queryAll<{ name: string }>("pragma table_info(task_sessions)").map((row) => row.name))
.toEqual([
"id",
"company_id",
"agent_id",
"adapter_type",
"last_run_id",
"last_error",
"run_count",
"total_input_tokens",
"total_output_tokens",
"rotated_at",
"rotation_reason",
"handoff_markdown",
"created_at",
"updated_at",
]);
expect(appDb.queryAll<{ agentId: string; adapterType: string }>("select agent_id as agentId, adapter_type as adapterType from task_sessions"))
.toEqual([{ agentId: "agent-1", adapterType: "openai-codex" }]);
expect(appDb.queryAll("select version, name from schema_migrations")).toEqual([
{ version: 56, name: "baseline_v56" },
]);
} finally {
appDb.close();
}
});
});
@@ -1,50 +1,124 @@
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createMigrationsV52V52 } from "../../src/main/database/migrations/v52-v52";
beforeAll(() => {
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
});
describe("v52 requires_filesystem migration", () => {
it("materializes legacy role evidence into the task column", () => {
const db = new DatabaseSync(":memory:");
vi.mock("electron", () => ({
safeStorage: {
isEncryptionAvailable: () => false,
getSelectedStorageBackend: () => "basic_text",
encryptString: (value: string) => Buffer.from(value, "utf8"),
decryptString: (value: Buffer) => value.toString("utf8"),
},
}));
import { AppDatabase } from "../../src/main/database";
const tempDirs: string[] = [];
async function createTempDir(prefix: string) {
const dir = await mkdtemp(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("legacy to v56 baseline migration", () => {
it("materializes legacy role evidence into the task filesystem policy", async () => {
const profileDir = await createTempDir("agentcompany-requires-fs-legacy-");
const rawDb = new DatabaseSync(join(profileDir, "profile.sqlite"));
const ts = "2026-04-24T00:00:00.000Z";
try {
db.exec(`
rawDb.exec(`
create table schema_migrations (version integer primary key, name text not null, applied_at text not null);
create table settings (key text primary key, value text not null);
create table companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null);
create table agents (
id text primary key,
company_id text not null,
name text not null,
role text not null,
title text not null,
status text not null,
reports_to text,
connector_id text not null,
workspace_id text,
model text,
capabilities text not null,
budget_monthly_usd real not null default 0,
spent_monthly_usd real not null default 0,
metadata_json text not null default '{}',
created_at text not null,
updated_at text not null,
department text
);
create table tasks (
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,
assignee_agent_id text
cost_usd real not null default 0,
created_at text not null,
updated_at text not null
);
insert into agents (id, role, department) values
('agent-cto', 'CTO', 'engineering'),
('agent-cmo', 'CMO', 'marketing');
insert into tasks (id, status, active_run_id, assignee_agent_id) values
('task-coding', 'todo', null, 'agent-cto'),
('task-cognitive', 'todo', null, 'agent-cmo'),
('task-inflight', 'in_progress', 'run-1', 'agent-cmo');
`);
for (let version = 1; version <= 51; version += 1) {
rawDb
.prepare("insert into schema_migrations (version, name, applied_at) values (?, ?, ?)")
.run(version, `legacy-${version}`, ts);
}
rawDb
.prepare("insert into companies (id, name, description, status, created_at, updated_at) values (?, ?, ?, ?, ?, ?)")
.run("company-1", "Legacy Co", "", "active", ts, ts);
rawDb
.prepare("insert into agents (id, company_id, name, role, title, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at, department) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("agent-cto", "company-1", "CTO", "CTO", "CTO", "active", null, "openai-codex", null, null, "code", 0, 0, "{}", ts, ts, "engineering");
rawDb
.prepare("insert into agents (id, company_id, name, role, title, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, metadata_json, created_at, updated_at, department) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("agent-cmo", "company-1", "CMO", "CMO", "CMO", "active", null, "openai-codex", null, null, "market", 0, 0, "{}", ts, ts, "marketing");
rawDb
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, status, priority, active_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-coding", "company-1", "Coding", "", "agent-cto", "todo", "medium", null, ts, ts);
rawDb
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, status, priority, active_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-cognitive", "company-1", "Strategy", "", "agent-cmo", "todo", "medium", null, ts, ts);
rawDb
.prepare("insert into tasks (id, company_id, title, description, assignee_agent_id, status, priority, active_run_id, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("task-inflight", "company-1", "Running", "", "agent-cmo", "in_progress", "medium", "run-1", ts, ts);
} finally {
rawDb.close();
}
const migration = createMigrationsV52V52({
db,
readAll: <T>(sql: string) => db.prepare(sql).all() as T[],
})[0];
migration.fn?.();
const rows = db.prepare(
"select id, requires_filesystem as requiresFilesystem from tasks order by id",
).all() as Array<{ id: string; requiresFilesystem: number }>;
const appDb = new AppDatabase(profileDir);
appDb.init();
try {
const rows = appDb
.queryAll<{ id: string; requiresFilesystem: number }>("select id, requires_filesystem as requiresFilesystem from tasks order by id");
expect(rows).toEqual([
{ id: "task-coding", requiresFilesystem: 1 },
{ id: "task-cognitive", requiresFilesystem: 0 },
{ id: "task-inflight", requiresFilesystem: 1 },
]);
const migration = appDb.queryAll<{ version: number }>("select version from schema_migrations where version = 56")[0];
expect(migration?.version).toBe(56);
} finally {
db.close();
appDb.close();
}
});
});