diff --git a/src/main/database/migrations/baseline-v56.ts b/src/main/database/migrations/baseline-v56.ts new file mode 100644 index 00000000..0a7a4783 --- /dev/null +++ b/src/main/database/migrations/baseline-v56.ts @@ -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, +): 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"}`); + } + } +} diff --git a/src/main/database/migrations/legacy-to-v56.ts b/src/main/database/migrations/legacy-to-v56.ts new file mode 100644 index 00000000..2b7f8ee1 --- /dev/null +++ b/src/main/database/migrations/legacy-to-v56.ts @@ -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>> = { + 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, targetColumns: Set): void { + const allowed = EXPECTED_DROPPED_COLUMNS[tableName] ?? new Set(); + 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}).`); +} diff --git a/src/main/database/migrations/v1-v15.ts b/src/main/database/migrations/v1-v15.ts deleted file mode 100644 index 0d20b32e..00000000 --- a/src/main/database/migrations/v1-v15.ts +++ /dev/null @@ -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`); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v16-v25.ts b/src/main/database/migrations/v16-v25.ts deleted file mode 100644 index b04bb8c7..00000000 --- a/src/main/database/migrations/v16-v25.ts +++ /dev/null @@ -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'`, - }, - ]; -} diff --git a/src/main/database/migrations/v26-v34.ts b/src/main/database/migrations/v26-v34.ts deleted file mode 100644 index acd27933..00000000 --- a/src/main/database/migrations/v26-v34.ts +++ /dev/null @@ -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)`, - }, - ]; -} diff --git a/src/main/database/migrations/v35-v37.ts b/src/main/database/migrations/v35-v37.ts deleted file mode 100644 index 88d0a991..00000000 --- a/src/main/database/migrations/v35-v37.ts +++ /dev/null @@ -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([ - ["id", "TEXT PRIMARY KEY"], - ["company_id", "TEXT NOT NULL"], - ["project_id", "TEXT"], - ["goal_id", "TEXT"], - ["parent_id", "TEXT"], - ["title", "TEXT NOT NULL"], - ["description", "TEXT NOT NULL"], - ["assignee_agent_id", "TEXT"], - ["workspace_id", "TEXT"], - ["status", "TEXT NOT NULL"], - ["priority", "TEXT NOT NULL"], - ["active_run_id", "TEXT"], - ["session_json", "TEXT"], - ["session_display_id", "TEXT"], - ["cost_usd", "REAL NOT NULL DEFAULT 0"], - ["created_at", "TEXT NOT NULL"], - ["updated_at", "TEXT NOT NULL"], - ["task_type", "TEXT NOT NULL DEFAULT 'general'"], - ["requires_user_review", "INTEGER NOT NULL DEFAULT 0"], - ["retry_count", "INTEGER NOT NULL DEFAULT 0"], - ["last_failure_at", "TEXT"], - ["checkout_run_id", "TEXT"], - ["execution_locked_at", "TEXT"], - ["active_session_id", "TEXT"], - ["started_at", "TEXT"], - ["completed_at", "TEXT"], - ["cancelled_at", "TEXT"], - ]); - - const ddl = keepCols.map((c) => { - const declared = declaredCols.get(c.name); - if (declared) return `${c.name} ${declared}`; - return `${c.name} ${c.type || "TEXT"}`; - }); - - db.exec(`CREATE TABLE tasks_new (${ddl.join(", ")})`); - - const copyCols = keepCols.map((c) => c.name).join(", "); - db.exec(`INSERT INTO tasks_new (${copyCols}) SELECT ${copyCols} FROM tasks`); - - const afterTasksCount = (db - .prepare("SELECT COUNT(*) as cnt FROM tasks_new") - .get() as { cnt: number }).cnt; - if (afterTasksCount !== beforeTasksCount) { - throw new Error( - `tasks copy row count mismatch: before=${beforeTasksCount} after=${afterTasksCount}; aborting before DROP`, - ); - } - - db.exec("DROP TABLE tasks"); - db.exec("ALTER TABLE tasks_new RENAME TO tasks"); - - // Recreate every index on tasks. - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)"); - - log.info( - `[migration v36] removed tasks.is_heartbeat; deleted ${beforeHeartbeatCount} synthetic heartbeat task row(s); nulled task_id on ${runsNulledOut} run(s); preserved ${afterTasksCount} task row(s)`, - ); - }, - }, - { - // Drop the `markdown` column from company_skills. SKILL.md on disk - // (profile/skills/{companyId}/{slug}/SKILL.md) is the authoritative - // source of truth read by pi-coding-agent's loadSkills. The DB column - // duplicated that content. UI now loads the body live via IPC. - // - // SQLite can't DROP COLUMN cleanly pre-3.35 and even the supported path - // is implemented as an invisible rebuild, so we do an explicit - // drift-safe rebuild mirroring v35/v36: - // 1. Snapshot row count - // 2. PRAGMA table_info to discover the live column set - // 3. CREATE company_skills_new without `markdown` - // 4. Copy every kept column - // 5. Assert copied count matches - // 6. DROP old, RENAME new, recreate indexes - // - // FK from agent_skill_assignments(skill_id) -> company_skills(id) must - // survive. PRAGMA defer_foreign_keys = ON keeps checks deferred until - // transaction commit (by which time RENAME has restored the table). - version: 37, name: "drop_company_skills_markdown", - fn: () => { - if (!tableExists(db, "company_skills")) { - log.info("[migration v37] company_skills table absent; skipping"); - return; - } - if (!columnExists(db, "company_skills", "markdown")) { - log.info("[migration v37] company_skills.markdown already absent; skipping"); - return; - } - - const beforeCount = (db - .prepare("SELECT COUNT(*) as cnt FROM company_skills") - .get() as { cnt: number }).cnt; - - const existingCols = (db - .prepare("PRAGMA table_info(company_skills)") - .all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>); - const keepCols = existingCols.filter((c) => c.name !== "markdown"); - - db.exec("PRAGMA defer_foreign_keys = ON"); - - // Declared v14 column set (minus markdown). Drift columns preserved - // verbatim from PRAGMA metadata. - const declaredCols = new Map([ - ["id", "TEXT PRIMARY KEY"], - ["company_id", "TEXT NOT NULL"], - ["key", "TEXT NOT NULL"], - ["slug", "TEXT NOT NULL"], - ["name", "TEXT NOT NULL"], - ["description", "TEXT"], - ["source_type", "TEXT NOT NULL DEFAULT 'local_path'"], - ["source_locator", "TEXT"], - ["source_ref", "TEXT"], - ["trust_level", "TEXT NOT NULL DEFAULT 'markdown_only'"], - ["compatibility", "TEXT NOT NULL DEFAULT 'compatible'"], - ["file_inventory", "TEXT NOT NULL DEFAULT '[]'"], - ["metadata", "TEXT"], - ["created_at", "TEXT NOT NULL"], - ["updated_at", "TEXT NOT NULL"], - ]); - - const ddl = keepCols.map((c) => { - const declared = declaredCols.get(c.name); - if (declared) return `${c.name} ${declared}`; - return `${c.name} ${c.type || "TEXT"}`; - }); - - db.exec(`CREATE TABLE company_skills_new (${ddl.join(", ")})`); - - const copyCols = keepCols.map((c) => c.name).join(", "); - db.exec(`INSERT INTO company_skills_new (${copyCols}) SELECT ${copyCols} FROM company_skills`); - - const afterCount = (db - .prepare("SELECT COUNT(*) as cnt FROM company_skills_new") - .get() as { cnt: number }).cnt; - if (afterCount !== beforeCount) { - throw new Error( - `company_skills copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`, - ); - } - - db.exec("DROP TABLE company_skills"); - db.exec("ALTER TABLE company_skills_new RENAME TO company_skills"); - - db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key)"); - db.exec("CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name)"); - - log.info(`[migration v37] dropped company_skills.markdown; preserved ${afterCount} row(s)`); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v38-v39.ts b/src/main/database/migrations/v38-v39.ts deleted file mode 100644 index 2cf654e1..00000000 --- a/src/main/database/migrations/v38-v39.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v40-v40.ts b/src/main/database/migrations/v40-v40.ts deleted file mode 100644 index 8fabe5cf..00000000 --- a/src/main/database/migrations/v40-v40.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v41-v41.ts b/src/main/database/migrations/v41-v41.ts deleted file mode 100644 index e6500c13..00000000 --- a/src/main/database/migrations/v41-v41.ts +++ /dev/null @@ -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"); - } - }, - }, - ]; -} diff --git a/src/main/database/migrations/v42-v42.ts b/src/main/database/migrations/v42-v42.ts deleted file mode 100644 index dc1a6f0a..00000000 --- a/src/main/database/migrations/v42-v42.ts +++ /dev/null @@ -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:`) 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`); - } - }, - }, - ]; -} diff --git a/src/main/database/migrations/v43-v43.ts b/src/main/database/migrations/v43-v43.ts deleted file mode 100644 index 737a7f64..00000000 --- a/src/main/database/migrations/v43-v43.ts +++ /dev/null @@ -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"); - } - }, - }, - ]; -} diff --git a/src/main/database/migrations/v44-v44.ts b/src/main/database/migrations/v44-v44.ts deleted file mode 100644 index e53ca384..00000000 --- a/src/main/database/migrations/v44-v44.ts +++ /dev/null @@ -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"); - } - }, - }, - ]; -} diff --git a/src/main/database/migrations/v45-v45.ts b/src/main/database/migrations/v45-v45.ts deleted file mode 100644 index 9ea1d51b..00000000 --- a/src/main/database/migrations/v45-v45.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v46-v46.ts b/src/main/database/migrations/v46-v46.ts deleted file mode 100644 index 26908f51..00000000 --- a/src/main/database/migrations/v46-v46.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v47-v47.ts b/src/main/database/migrations/v47-v47.ts deleted file mode 100644 index b46e2a8d..00000000 --- a/src/main/database/migrations/v47-v47.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v48-v48.ts b/src/main/database/migrations/v48-v48.ts deleted file mode 100644 index 9bf6ef97..00000000 --- a/src/main/database/migrations/v48-v48.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v49-v49.ts b/src/main/database/migrations/v49-v49.ts deleted file mode 100644 index 2e35ba1c..00000000 --- a/src/main/database/migrations/v49-v49.ts +++ /dev/null @@ -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", - ); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v50-v50.ts b/src/main/database/migrations/v50-v50.ts deleted file mode 100644 index 910f246b..00000000 --- a/src/main/database/migrations/v50-v50.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v51-v51.ts b/src/main/database/migrations/v51-v51.ts deleted file mode 100644 index 66953c46..00000000 --- a/src/main/database/migrations/v51-v51.ts +++ /dev/null @@ -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", - ); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v52-v52.ts b/src/main/database/migrations/v52-v52.ts deleted file mode 100644 index d53d34f5..00000000 --- a/src/main/database/migrations/v52-v52.ts +++ /dev/null @@ -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}`, - ); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v53-v53.ts b/src/main/database/migrations/v53-v53.ts deleted file mode 100644 index 83bf3131..00000000 --- a/src/main/database/migrations/v53-v53.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v54-v54.ts b/src/main/database/migrations/v54-v54.ts deleted file mode 100644 index ec58877e..00000000 --- a/src/main/database/migrations/v54-v54.ts +++ /dev/null @@ -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' - `); - }, - }, - ]; -} diff --git a/src/main/database/migrations/v55-v55.ts b/src/main/database/migrations/v55-v55.ts deleted file mode 100644 index c0e72a46..00000000 --- a/src/main/database/migrations/v55-v55.ts +++ /dev/null @@ -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"); - } - }, - }, - ]; -} diff --git a/src/main/database/migrations/v56-v56.ts b/src/main/database/migrations/v56-v56.ts deleted file mode 100644 index ad8c588a..00000000 --- a/src/main/database/migrations/v56-v56.ts +++ /dev/null @@ -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"); - }, - }, - ]; -} diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index ef39d7e2..6f0f1673 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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(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}`); } } diff --git a/tests/integration/work-product-primary-invariant.spec.ts b/tests/integration/work-product-primary-invariant.spec.ts index 133c3ad6..2c96e23f 100644 --- a/tests/integration/work-product-primary-invariant.spec.ts +++ b/tests/integration/work-product-primary-invariant.spec.ts @@ -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"); diff --git a/tests/unit/database.test.ts b/tests/unit/database.test.ts index 77800b8c..a8672f70 100644 --- a/tests/unit/database.test.ts +++ b/tests/unit/database.test.ts @@ -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", () => { diff --git a/tests/unit/legacy-baseline-migration.test.ts b/tests/unit/legacy-baseline-migration.test.ts new file mode 100644 index 00000000..4e0d0210 --- /dev/null +++ b/tests/unit/legacy-baseline-migration.test.ts @@ -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(); + } + }); +}); diff --git a/tests/unit/requires-filesystem-migration.test.ts b/tests/unit/requires-filesystem-migration.test.ts index 921620c9..457ddb34 100644 --- a/tests/unit/requires-filesystem-migration.test.ts +++ b/tests/unit/requires-filesystem-migration.test.ts @@ -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: (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(); } }); });