refactor(database): squash schema migrations to v56 baseline
This commit is contained in:
1 parent
67ccfc1160
commit
1f4af89be0
29 files changed
+1245
-2537
No files matched your search
@@ -0,0 +1,261 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export const BASELINE_SCHEMA_VERSION = 56;
|
||||
export const BASELINE_SCHEMA_NAME = "baseline_v56";
|
||||
|
||||
export const BASELINE_TABLE_SQL = [
|
||||
"CREATE TABLE activity (id text primary key, company_id text not null, actor text not null, action text not null, entity_type text not null, entity_id text not null, detail text not null, created_at text not null)",
|
||||
"CREATE TABLE agent_config_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, revision_number INTEGER NOT NULL, changed_keys TEXT NOT NULL DEFAULT '[]', config_before TEXT NOT NULL, config_after TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'patch', rolled_back_from_revision_id TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE agent_message_mentions (\n message_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n company_id TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (message_id, agent_id)\n )",
|
||||
"CREATE TABLE agent_message_projections (\n message_id TEXT NOT NULL,\n session_id TEXT NOT NULL,\n projected_at TEXT NOT NULL,\n PRIMARY KEY (message_id, session_id)\n )",
|
||||
"CREATE TABLE agent_message_reads (message_id text not null, agent_id text not null, read_at text not null, primary key (message_id, agent_id))",
|
||||
"CREATE TABLE agent_messages (id text primary key, company_id text not null, from_agent_id text not null, to_agent_id text, channel text not null default 'direct', channel_target_id text, subject text not null, body text not null, priority text not null default 'normal', parent_message_id text, attachments_json text not null default '[]', created_at text not null, sender_kind TEXT NOT NULL DEFAULT 'agent')",
|
||||
"CREATE TABLE agent_skill_assignments (agent_id TEXT NOT NULL, skill_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (agent_id, skill_id), FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, FOREIGN KEY (skill_id) REFERENCES company_skills(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE agents (id text primary key, company_id text not null, name text not null, role text not null, title text not null, status text not null, reports_to text, connector_id text not null, workspace_id text, model text, capabilities text not null, budget_monthly_usd real not null default 0, spent_monthly_usd real not null default 0, metadata_json text not null default '{}', created_at text not null, updated_at text not null, department text, permissions_json TEXT NOT NULL DEFAULT '{}', heartbeat_policy_json TEXT, runtime_state_json TEXT, role_level TEXT NOT NULL DEFAULT 'ic', session_id TEXT, model_strategy TEXT NOT NULL DEFAULT 'explicit', heartbeat_enabled integer not null default 0, heartbeat_interval_sec integer not null default 120, last_heartbeat_at text)",
|
||||
"CREATE TABLE approval_comments (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, approval_id TEXT NOT NULL, author_agent_id TEXT, author_user_id TEXT, body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE approvals (id text primary key, company_id text not null, related_task_id text, requested_by_agent_id text, type text not null, payload_summary text not null, impact_summary text not null, payload_json text, decision_note text not null default '', state text not null, created_at text not null, updated_at text not null, related_agent_id text)",
|
||||
"CREATE TABLE automation_log (id text primary key, company_id text not null, rule_id text not null, rule_name text not null, trigger text not null, action text not null, detail text not null default '', executed_at text not null)",
|
||||
"CREATE TABLE automation_rules (id text primary key, company_id text not null, name text not null, description text not null default '', trigger text not null, conditions_json text not null default '{}', action text not null, action_config_json text not null default '{}', source_department text, target_department text, priority integer not null default 50, status text not null default 'active', execution_count integer not null default 0, last_executed_at text, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE browser_actions (id text primary key, company_id text not null references companies(id) on delete cascade, social_account_id text not null references social_accounts(id) on delete cascade, agent_id text not null references agents(id) on delete cascade, task_id text, action_type text not null default 'custom_script', payload_json text not null default '{}', status text not null default 'queued', result_summary text not null default '', screenshot_path text, approval_id text, error_message text, started_at text, finished_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')))",
|
||||
"CREATE TABLE budget_incidents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, policy_id TEXT NOT NULL REFERENCES budget_policies(id), scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL, window_kind TEXT NOT NULL, window_start TEXT NOT NULL, window_end TEXT NOT NULL, threshold_type TEXT NOT NULL, amount_limit INTEGER NOT NULL, amount_observed INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'open', resolved_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
"CREATE TABLE budget_policies (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, scope_type TEXT NOT NULL, scope_id TEXT NOT NULL, metric TEXT NOT NULL DEFAULT 'billed_cents', window_kind TEXT NOT NULL DEFAULT 'calendar_month_utc', amount INTEGER NOT NULL DEFAULT 0, warn_percent INTEGER NOT NULL DEFAULT 80, hard_stop_enabled INTEGER NOT NULL DEFAULT 1, notify_enabled INTEGER NOT NULL DEFAULT 1, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
"CREATE TABLE companies (id text primary key, name text not null, description text not null, status text not null, created_at text not null, updated_at text not null, auto_approve_hires integer not null default 0, review_deliverables integer not null default 0, execution_mode text not null default 'ai_ceo')",
|
||||
"CREATE TABLE \"company_skills\" (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, key TEXT NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, description TEXT, source_type TEXT NOT NULL DEFAULT 'local_path', source_locator TEXT, source_ref TEXT, trust_level TEXT NOT NULL DEFAULT 'markdown_only', compatibility TEXT NOT NULL DEFAULT 'compatible', file_inventory TEXT NOT NULL DEFAULT '[]', metadata TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)",
|
||||
"CREATE TABLE connector_model_pool_settings (\n company_id TEXT NOT NULL,\n connector_id TEXT NOT NULL,\n disabled_models_json TEXT NOT NULL DEFAULT '[]',\n updated_at TEXT NOT NULL,\n PRIMARY KEY (company_id, connector_id),\n FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE\n )",
|
||||
"CREATE TABLE connectors (id text primary key, label text not null, description text not null, status text not null, command text not null, version text, auth_state text not null, last_checked_at text, last_error text, model text, env_binding_text text not null, notes text not null, updated_at text, capability_json text)",
|
||||
"CREATE TABLE conversation_events (\n id TEXT PRIMARY KEY,\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n actor_id TEXT NOT NULL,\n actor_type TEXT NOT NULL,\n event_type TEXT NOT NULL,\n body TEXT NOT NULL,\n metadata_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE conversation_participants (\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n participant_id TEXT NOT NULL,\n participant_type TEXT NOT NULL,\n role TEXT NOT NULL DEFAULT 'member',\n joined_at TEXT NOT NULL,\n PRIMARY KEY (thread_id, participant_id)\n )",
|
||||
"CREATE TABLE conversation_threads (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n title TEXT NOT NULL,\n type TEXT NOT NULL DEFAULT 'task',\n related_entity_type TEXT,\n related_entity_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE cost_entries (id text primary key, company_id text not null, run_id text not null, task_id text, agent_id text, connector_id text not null, amount_usd real, unattributed integer not null default 0, created_at text not null, project_id TEXT)",
|
||||
"CREATE TABLE credentials (\n connector_id TEXT PRIMARY KEY,\n kind TEXT NOT NULL,\n backend TEXT NOT NULL,\n ciphertext BLOB NOT NULL,\n display_hint TEXT NOT NULL DEFAULT '',\n status TEXT NOT NULL DEFAULT 'active',\n expires_at TEXT,\n last_refreshed_at TEXT,\n last_error TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE document_lifecycle_events (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n document_id TEXT NOT NULL,\n hook_type TEXT NOT NULL,\n status TEXT NOT NULL,\n attempts INTEGER NOT NULL DEFAULT 0,\n last_error TEXT,\n payload_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n completed_at TEXT\n )",
|
||||
"CREATE TABLE document_revisions (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, document_id TEXT NOT NULL, revision_number INTEGER NOT NULL, body TEXT NOT NULL, change_summary TEXT, created_by_agent_id TEXT, created_by_user_id TEXT, created_at TEXT NOT NULL, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE documents (id text primary key, company_id text not null, type text not null, title text not null, content text not null default '', author_agent_id text, reviewer_agent_id text, project_id text, goal_id text, parent_doc_id text, version integer not null default 1, status text not null default 'draft', tags_json text not null default '[]', created_at text not null, updated_at text not null, latest_revision_id TEXT, source_run_id TEXT)",
|
||||
"CREATE TABLE goals (id text primary key, company_id text not null, title text not null, description text not null, parent_id text, owner_agent_id text, status text not null, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE knowledge_base (id text primary key, company_id text not null, category text not null, topic text not null, content text not null, author_agent_id text, importance text not null default 'medium', tags_json text not null default '[]', referenced_entity_type text, referenced_entity_id text, created_at text not null, updated_at text not null, source_run_id TEXT)",
|
||||
"CREATE TABLE meetings (id text primary key, company_id text not null, type text not null, title text not null, organizer_agent_id text, participant_agent_ids text not null default '[]', scheduled_at text not null, duration_minutes integer not null default 30, agenda_json text not null default '[]', notes_json text not null default '[]', decisions_json text not null default '[]', action_items_json text not null default '[]', status text not null default 'scheduled', created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE model_capabilities (\n provider TEXT NOT NULL,\n model_id TEXT NOT NULL,\n reasoning INTEGER NOT NULL,\n coding INTEGER NOT NULL,\n writing INTEGER NOT NULL,\n tier TEXT NOT NULL,\n cost_per_mtok_out REAL NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (provider, model_id)\n )",
|
||||
"CREATE TABLE projects (id text primary key, company_id text not null, goal_id text, name text not null, description text not null, lead_agent_id text, status text not null, target_date text, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE run_events (id INTEGER PRIMARY KEY AUTOINCREMENT, company_id TEXT NOT NULL, run_id TEXT NOT NULL, agent_id TEXT NOT NULL, seq INTEGER NOT NULL, event_type TEXT NOT NULL, stream TEXT, level TEXT, message TEXT, payload TEXT, created_at TEXT NOT NULL, FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE \"runs\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n task_id TEXT,\n agent_id TEXT NOT NULL,\n workspace_id TEXT,\n connector_id TEXT NOT NULL,\n status TEXT NOT NULL,\n summary TEXT NOT NULL,\n error_message TEXT,\n exit_code INTEGER,\n signal TEXT,\n model TEXT,\n cost_usd REAL,\n unattributed_cost INTEGER NOT NULL DEFAULT 0,\n actor_type TEXT,\n actor_id TEXT,\n started_at TEXT,\n finished_at TEXT,\n log_path TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n , usage_status TEXT NOT NULL DEFAULT 'pre_first_message')",
|
||||
"CREATE TABLE schema_migrations (version integer primary key, name text not null, applied_at text not null)",
|
||||
"CREATE TABLE secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name))",
|
||||
"CREATE TABLE session_entries (\n id TEXT PRIMARY KEY,\n task_session_id TEXT NOT NULL,\n sequence INTEGER NOT NULL,\n kind TEXT NOT NULL,\n role TEXT,\n payload_json TEXT NOT NULL,\n is_superseded INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE settings (key text primary key, value text not null)",
|
||||
"CREATE TABLE social_accounts (id text primary key, company_id text not null references companies(id) on delete cascade, platform text not null default 'other', account_name text not null, display_name text not null default '', profile_url text not null default '', credential_secret_id text, status text not null default 'login_required', require_approval integer not null default 1, metadata_json text not null default '{}', last_used_at text, created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')))",
|
||||
"CREATE TABLE sprints (id text primary key, company_id text not null, name text not null, goal text not null default '', start_date text not null, end_date text not null, status text not null default 'planning', retrospective_notes text not null default '', velocity_points integer not null default 0, completed_points integer not null default 0, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE task_approval_links (task_id TEXT NOT NULL, approval_id TEXT NOT NULL, company_id TEXT NOT NULL, linked_by_agent_id TEXT, created_at TEXT NOT NULL, PRIMARY KEY (task_id, approval_id), FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (approval_id) REFERENCES approvals(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE task_comments (id text primary key, company_id text not null, task_id text not null, author_agent_id text, author_name text not null, body text not null, created_at text not null)",
|
||||
"CREATE TABLE task_documents (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, task_id TEXT NOT NULL, document_id TEXT NOT NULL, key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE)",
|
||||
"CREATE TABLE \"task_sessions\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n adapter_type TEXT NOT NULL,\n last_run_id TEXT,\n last_error TEXT,\n run_count INTEGER NOT NULL DEFAULT 0,\n total_input_tokens INTEGER NOT NULL DEFAULT 0,\n total_output_tokens INTEGER NOT NULL DEFAULT 0,\n rotated_at TEXT,\n rotation_reason TEXT,\n handoff_markdown TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n )",
|
||||
"CREATE TABLE \"tasks\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n project_id TEXT,\n goal_id TEXT,\n parent_id TEXT,\n title TEXT NOT NULL,\n description TEXT NOT NULL,\n assignee_agent_id TEXT,\n workspace_id TEXT,\n status TEXT NOT NULL,\n priority TEXT NOT NULL,\n active_run_id TEXT,\n cost_usd REAL NOT NULL DEFAULT 0,\n task_type TEXT NOT NULL DEFAULT 'general',\n requires_user_review INTEGER NOT NULL DEFAULT 0,\n retry_count INTEGER NOT NULL DEFAULT 0,\n last_failure_at TEXT,\n checkout_run_id TEXT,\n execution_locked_at TEXT,\n started_at TEXT,\n completed_at TEXT,\n cancelled_at TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n , requires_filesystem INTEGER NOT NULL DEFAULT 0, active_session_id text)",
|
||||
"CREATE TABLE thread_subscriptions (\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n subscriber_id TEXT NOT NULL,\n subscriber_type TEXT NOT NULL,\n notify_on TEXT NOT NULL DEFAULT 'all',\n subscribed_at TEXT NOT NULL,\n PRIMARY KEY (thread_id, subscriber_id)\n )",
|
||||
"CREATE TABLE thread_wait_states (\n id TEXT PRIMARY KEY,\n thread_id TEXT NOT NULL REFERENCES conversation_threads(id) ON DELETE CASCADE,\n company_id TEXT NOT NULL,\n waiting_for TEXT NOT NULL,\n related_approval_id TEXT,\n reason TEXT NOT NULL,\n created_at TEXT NOT NULL,\n resolved_at TEXT\n )",
|
||||
"CREATE TABLE wakeup_requests (id TEXT PRIMARY KEY, company_id TEXT NOT NULL, agent_id TEXT NOT NULL, source TEXT NOT NULL, trigger_detail TEXT, reason TEXT, payload TEXT, status TEXT NOT NULL DEFAULT 'queued', coalesced_count INTEGER NOT NULL DEFAULT 0, requested_by_actor_type TEXT, requested_by_actor_id TEXT, idempotency_key TEXT, run_id TEXT, requested_at TEXT NOT NULL, claimed_at TEXT, finished_at TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, context_snapshot TEXT)",
|
||||
"CREATE TABLE \"work_products\" (\n id TEXT PRIMARY KEY,\n company_id TEXT NOT NULL,\n project_id TEXT,\n task_id TEXT,\n type TEXT NOT NULL,\n provider TEXT NOT NULL,\n external_id TEXT,\n title TEXT NOT NULL,\n url TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n review_state TEXT NOT NULL DEFAULT 'none',\n is_primary INTEGER NOT NULL DEFAULT 0,\n summary TEXT,\n metadata_json TEXT,\n created_by_run_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL, workspace_id TEXT, last_touched_by_run_id TEXT,\n FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE\n )",
|
||||
"CREATE TABLE workflow_pipelines (id text primary key, company_id text not null, name text not null, description text not null default '', steps_json text not null default '[]', trigger_type text not null, trigger_config_json text not null default '{}', status text not null default 'draft', current_step_index integer not null default 0, run_count integer not null default 0, last_run_at text, created_at text not null, updated_at text not null)",
|
||||
"CREATE TABLE workspaces (id text primary key, company_id text not null, name text not null, local_path text not null, repo_url text not null, repo_ref text not null, project_id text, state text not null, is_primary integer not null default 0, created_at text not null, updated_at text not null, lock_holder_run_id text, lock_acquired_at text)",
|
||||
] as const;
|
||||
|
||||
export const BASELINE_INDEX_SQL = [
|
||||
"CREATE INDEX idx_activity_company on activity(company_id)",
|
||||
"CREATE INDEX idx_activity_company_actor_action ON activity(company_id, actor, action)",
|
||||
"CREATE INDEX idx_activity_company_created_at on activity(company_id, created_at desc)",
|
||||
"CREATE INDEX idx_agent_config_rev_agent ON agent_config_revisions(company_id, agent_id, created_at)",
|
||||
"CREATE UNIQUE INDEX idx_agent_config_rev_num ON agent_config_revisions(agent_id, revision_number)",
|
||||
"CREATE INDEX idx_agent_message_mentions_agent\n ON agent_message_mentions(company_id, agent_id, created_at)\n ",
|
||||
"CREATE INDEX idx_agent_message_mentions_message\n ON agent_message_mentions(message_id)\n ",
|
||||
"CREATE INDEX idx_agent_message_projections_session\n ON agent_message_projections(session_id, projected_at)\n ",
|
||||
"CREATE INDEX idx_agent_message_reads_agent on agent_message_reads(agent_id)",
|
||||
"CREATE INDEX idx_agent_messages_channel on agent_messages(channel)",
|
||||
"CREATE INDEX idx_agent_messages_company on agent_messages(company_id)",
|
||||
"CREATE INDEX idx_agent_messages_company_created_at on agent_messages(company_id, created_at desc)",
|
||||
"CREATE INDEX idx_agent_messages_from on agent_messages(from_agent_id)",
|
||||
"CREATE INDEX idx_agent_messages_to on agent_messages(to_agent_id)",
|
||||
"CREATE INDEX idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)",
|
||||
"CREATE INDEX idx_agents_company on agents(company_id)",
|
||||
"CREATE INDEX idx_agents_company_status on agents(company_id, status)",
|
||||
"CREATE INDEX idx_agents_department on agents(company_id, department)",
|
||||
"CREATE INDEX idx_agents_reports_to on agents(reports_to)",
|
||||
"CREATE INDEX idx_agents_session_id ON agents(session_id)",
|
||||
"CREATE INDEX idx_approval_comments_approval ON approval_comments(approval_id, created_at)",
|
||||
"CREATE INDEX idx_approvals_company on approvals(company_id)",
|
||||
"CREATE INDEX idx_approvals_company_created_at ON approvals(company_id, created_at DESC)",
|
||||
"CREATE INDEX idx_approvals_company_state on approvals(company_id, state)",
|
||||
"CREATE INDEX idx_approvals_company_state_type ON approvals(company_id, state, type)",
|
||||
"CREATE INDEX idx_approvals_related_task ON approvals(related_task_id)",
|
||||
"CREATE INDEX idx_automation_log_company on automation_log(company_id)",
|
||||
"CREATE INDEX idx_automation_rules_company on automation_rules(company_id)",
|
||||
"CREATE INDEX idx_automation_rules_company_status on automation_rules(company_id, status)",
|
||||
"CREATE INDEX idx_automation_rules_trigger on automation_rules(trigger)",
|
||||
"CREATE INDEX idx_browser_actions_account on browser_actions(social_account_id)",
|
||||
"CREATE INDEX idx_browser_actions_company on browser_actions(company_id)",
|
||||
"CREATE INDEX idx_budget_incidents_company_scope ON budget_incidents(company_id, scope_type, scope_id, status)",
|
||||
"CREATE INDEX idx_budget_incidents_company_status ON budget_incidents(company_id, status)",
|
||||
"CREATE INDEX idx_budget_incidents_policy ON budget_incidents(policy_id, window_start, threshold_type)",
|
||||
"CREATE INDEX idx_budget_policies_company_scope ON budget_policies(company_id, scope_type, scope_id, is_active)",
|
||||
"CREATE UNIQUE INDEX idx_budget_policies_unique ON budget_policies(company_id, scope_type, scope_id, metric, window_kind)",
|
||||
"CREATE INDEX idx_comments_company on task_comments(company_id)",
|
||||
"CREATE INDEX idx_comments_task on task_comments(task_id)",
|
||||
"CREATE UNIQUE INDEX idx_company_skills_company_key ON company_skills(company_id, key)",
|
||||
"CREATE INDEX idx_company_skills_company_name ON company_skills(company_id, name)",
|
||||
"CREATE INDEX idx_connector_model_pool_company\n ON connector_model_pool_settings(company_id)",
|
||||
"CREATE INDEX idx_conv_events_thread ON conversation_events(thread_id, created_at)",
|
||||
"CREATE INDEX idx_conv_participants_participant ON conversation_participants(participant_id)",
|
||||
"CREATE INDEX idx_conv_threads_company ON conversation_threads(company_id)",
|
||||
"CREATE INDEX idx_conv_threads_entity ON conversation_threads(related_entity_type, related_entity_id)",
|
||||
"CREATE INDEX idx_cost_entries_agent_created_at ON cost_entries(agent_id, created_at DESC)",
|
||||
"CREATE INDEX idx_cost_entries_company on cost_entries(company_id)",
|
||||
"CREATE INDEX idx_cost_entries_company_created_at on cost_entries(company_id, created_at desc)",
|
||||
"CREATE INDEX idx_cost_entries_project ON cost_entries(project_id)",
|
||||
"CREATE INDEX idx_cost_entries_run on cost_entries(run_id)",
|
||||
"CREATE INDEX idx_credentials_status ON credentials(status)",
|
||||
"CREATE INDEX idx_doc_rev_company ON document_revisions(company_id, document_id, created_at)",
|
||||
"CREATE UNIQUE INDEX idx_doc_rev_doc_num ON document_revisions(document_id, revision_number)",
|
||||
"CREATE INDEX idx_document_lifecycle_events_company\n ON document_lifecycle_events(company_id, status, updated_at)\n ",
|
||||
"CREATE INDEX idx_document_lifecycle_events_document\n ON document_lifecycle_events(document_id, hook_type)\n ",
|
||||
"CREATE INDEX idx_documents_author on documents(author_agent_id)",
|
||||
"CREATE INDEX idx_documents_company on documents(company_id)",
|
||||
"CREATE INDEX idx_documents_company_updated_at ON documents(company_id, updated_at DESC)",
|
||||
"CREATE INDEX idx_documents_project on documents(project_id)",
|
||||
"CREATE INDEX idx_goals_company on goals(company_id)",
|
||||
"CREATE INDEX idx_knowledge_category on knowledge_base(category)",
|
||||
"CREATE INDEX idx_knowledge_company on knowledge_base(company_id)",
|
||||
"CREATE INDEX idx_meetings_company on meetings(company_id)",
|
||||
"CREATE INDEX idx_meetings_company_scheduled_at ON meetings(company_id, scheduled_at DESC)",
|
||||
"CREATE INDEX idx_meetings_organizer on meetings(organizer_agent_id)",
|
||||
"CREATE INDEX idx_messages_channel on agent_messages(channel, channel_target_id)",
|
||||
"CREATE INDEX idx_messages_company on agent_messages(company_id)",
|
||||
"CREATE INDEX idx_model_capabilities_tier ON model_capabilities(tier)",
|
||||
"CREATE INDEX idx_projects_company on projects(company_id)",
|
||||
"CREATE INDEX idx_run_events_company ON run_events(company_id, run_id)",
|
||||
"CREATE INDEX idx_run_events_run ON run_events(run_id, seq)",
|
||||
"CREATE UNIQUE INDEX idx_run_events_run_seq_unique ON run_events(run_id, seq)",
|
||||
"CREATE INDEX idx_runs_agent ON runs(agent_id)",
|
||||
"CREATE INDEX idx_runs_agent_created_at ON runs(agent_id, created_at DESC)",
|
||||
"CREATE INDEX idx_runs_company ON runs(company_id)",
|
||||
"CREATE INDEX idx_runs_company_created_at ON runs(company_id, created_at DESC)",
|
||||
"CREATE INDEX idx_runs_company_status ON runs(company_id, status)",
|
||||
"CREATE INDEX idx_runs_task ON runs(task_id)",
|
||||
"CREATE INDEX idx_secrets_company on secrets(company_id)",
|
||||
"CREATE INDEX idx_session_entries_active ON session_entries(task_session_id, is_superseded, sequence)",
|
||||
"CREATE INDEX idx_session_entries_session ON session_entries(task_session_id, sequence)",
|
||||
"CREATE INDEX idx_social_accounts_company on social_accounts(company_id)",
|
||||
"CREATE INDEX idx_sprints_company on sprints(company_id)",
|
||||
"CREATE INDEX idx_sprints_company_start_date ON sprints(company_id, start_date DESC)",
|
||||
"CREATE INDEX idx_task_approval_links_approval ON task_approval_links(approval_id)",
|
||||
"CREATE INDEX idx_task_approval_links_task ON task_approval_links(task_id)",
|
||||
"CREATE UNIQUE INDEX idx_task_documents_doc ON task_documents(document_id)",
|
||||
"CREATE UNIQUE INDEX idx_task_documents_task_key ON task_documents(company_id, task_id, key)",
|
||||
"CREATE INDEX idx_task_sessions_company_agent\n ON task_sessions(company_id, agent_id, updated_at)",
|
||||
"CREATE INDEX idx_task_sessions_lookup\n ON task_sessions(company_id, agent_id, adapter_type)",
|
||||
"CREATE INDEX idx_tasks_assignee ON tasks(assignee_agent_id)",
|
||||
"CREATE INDEX idx_tasks_company ON tasks(company_id)",
|
||||
"CREATE INDEX idx_tasks_company_status ON tasks(company_id, status)",
|
||||
"CREATE INDEX idx_tasks_project ON tasks(project_id)",
|
||||
"CREATE INDEX idx_thread_subs_subscriber ON thread_subscriptions(subscriber_id)",
|
||||
"CREATE INDEX idx_thread_wait_company ON thread_wait_states(company_id, resolved_at)",
|
||||
"CREATE INDEX idx_thread_wait_thread ON thread_wait_states(thread_id)",
|
||||
"CREATE INDEX idx_wakeup_requests_agent ON wakeup_requests(agent_id, company_id, status)",
|
||||
"CREATE INDEX idx_wakeup_requests_agent_requested ON wakeup_requests(agent_id, requested_at)",
|
||||
"CREATE INDEX idx_wakeup_requests_company_agent_status ON wakeup_requests(company_id, agent_id, status)",
|
||||
"CREATE INDEX idx_wakeup_requests_company_requested ON wakeup_requests(company_id, requested_at)",
|
||||
"CREATE UNIQUE INDEX idx_work_products_identity\n ON work_products(company_id, workspace_id, provider, external_id)\n WHERE workspace_id IS NOT NULL AND external_id IS NOT NULL\n ",
|
||||
"CREATE INDEX idx_work_products_last_touched\n ON work_products(company_id, last_touched_by_run_id)\n ",
|
||||
"CREATE UNIQUE INDEX idx_work_products_primary_active\n ON work_products(company_id, task_id, type)\n WHERE task_id IS NOT NULL\n AND is_primary = 1\n AND status <> 'archived'",
|
||||
"CREATE INDEX idx_work_products_task\n ON work_products(company_id, task_id, type)",
|
||||
"CREATE INDEX idx_work_products_updated\n ON work_products(company_id, updated_at)",
|
||||
"CREATE INDEX idx_workflows_company on workflow_pipelines(company_id)",
|
||||
"CREATE INDEX idx_workspaces_company on workspaces(company_id)",
|
||||
"CREATE UNIQUE INDEX secrets_company_name_idx on secrets(company_id, name)",
|
||||
] as const;
|
||||
|
||||
function quoteIdentifier(name: string): string {
|
||||
return `"${name.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
function parseSchemaName(sql: string, prefix: RegExp, kind: string): string {
|
||||
const match = prefix.exec(sql);
|
||||
if (!match) throw new Error(`Cannot parse baseline ${kind} name from SQL: ${sql}`);
|
||||
return match[1] ?? match[2]!;
|
||||
}
|
||||
|
||||
export function parseBaselineTableName(sql: string): string {
|
||||
return parseSchemaName(
|
||||
sql,
|
||||
/^CREATE TABLE(?: IF NOT EXISTS)? (?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/,
|
||||
"table",
|
||||
);
|
||||
}
|
||||
|
||||
export function parseBaselineIndexName(sql: string): string {
|
||||
return parseSchemaName(
|
||||
sql,
|
||||
/^CREATE (?:UNIQUE )?INDEX(?: IF NOT EXISTS)? (?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))/,
|
||||
"index",
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeSchemaSql(sql: string): string {
|
||||
return sql
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^CREATE TABLE "([^"]+)"/, "CREATE TABLE $1")
|
||||
.replace(/^CREATE INDEX "([^"]+)"/, "CREATE INDEX $1")
|
||||
.replace(/^CREATE UNIQUE INDEX "([^"]+)"/, "CREATE UNIQUE INDEX $1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function ifNotExists(sql: string): string {
|
||||
return sql
|
||||
.replace(/^CREATE TABLE /, "CREATE TABLE IF NOT EXISTS ")
|
||||
.replace(/^CREATE INDEX /, "CREATE INDEX IF NOT EXISTS ")
|
||||
.replace(/^CREATE UNIQUE INDEX /, "CREATE UNIQUE INDEX IF NOT EXISTS ");
|
||||
}
|
||||
|
||||
export function installBaselineTables(db: DatabaseSync): void {
|
||||
db.exec(BASELINE_TABLE_SQL.map(ifNotExists).join(";\n"));
|
||||
}
|
||||
|
||||
export function installBaselineIndexes(db: DatabaseSync): void {
|
||||
db.exec(BASELINE_INDEX_SQL.map(ifNotExists).join(";\n"));
|
||||
}
|
||||
|
||||
export function installCanonicalBaselineIndexes(db: DatabaseSync): void {
|
||||
for (const sql of BASELINE_INDEX_SQL) {
|
||||
db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(parseBaselineIndexName(sql))}`);
|
||||
}
|
||||
db.exec(BASELINE_INDEX_SQL.join(";\n"));
|
||||
}
|
||||
|
||||
export function installBaselineSchema(db: DatabaseSync): void {
|
||||
installBaselineTables(db);
|
||||
installBaselineIndexes(db);
|
||||
}
|
||||
|
||||
export function assertBaselineSchema(db: DatabaseSync): void {
|
||||
assertSchemaEntries(
|
||||
db,
|
||||
"table",
|
||||
BASELINE_TABLE_SQL.map((sql) => [parseBaselineTableName(sql), normalizeSchemaSql(sql)] as const),
|
||||
);
|
||||
assertSchemaEntries(
|
||||
db,
|
||||
"index",
|
||||
BASELINE_INDEX_SQL.map((sql) => [parseBaselineIndexName(sql), normalizeSchemaSql(sql)] as const),
|
||||
);
|
||||
}
|
||||
|
||||
function assertSchemaEntries(
|
||||
db: DatabaseSync,
|
||||
type: "table" | "index",
|
||||
expectedEntries: ReadonlyArray<readonly [string, string]>,
|
||||
): void {
|
||||
const selectSql = db.prepare("select sql from sqlite_master where type = ? and name = ?");
|
||||
for (const [name, expectedSql] of expectedEntries) {
|
||||
const row = selectSql.get(type, name) as { sql: string | null } | undefined;
|
||||
const actualSql = row?.sql ? normalizeSchemaSql(row.sql) : null;
|
||||
if (actualSql !== expectedSql) {
|
||||
throw new Error(`Baseline v56 ${type} drift for ${name}: expected ${expectedSql}; actual ${actualSql ?? "missing"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
BASELINE_SCHEMA_NAME,
|
||||
BASELINE_SCHEMA_VERSION,
|
||||
BASELINE_TABLE_SQL,
|
||||
installCanonicalBaselineIndexes,
|
||||
installBaselineTables,
|
||||
normalizeSchemaSql,
|
||||
parseBaselineTableName,
|
||||
} from "./baseline-v56";
|
||||
import {
|
||||
columnExists,
|
||||
nowIso,
|
||||
tableExists,
|
||||
type ReadAll,
|
||||
} from "./types";
|
||||
|
||||
type ColumnInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
};
|
||||
|
||||
type LegacyDeps = {
|
||||
db: DatabaseSync;
|
||||
readAll: ReadAll;
|
||||
};
|
||||
|
||||
const BASELINE_TABLE_NAMES = BASELINE_TABLE_SQL.map((sql) => parseTableName(sql));
|
||||
const EXPECTED_DROPPED_COLUMNS: Readonly<Record<string, ReadonlySet<string>>> = {
|
||||
agent_messages: new Set(["read_at"]),
|
||||
company_skills: new Set(["markdown"]),
|
||||
runs: new Set(["context_snapshot_json", "session_display_id"]),
|
||||
tasks: new Set(["is_heartbeat", "phase", "session_display_id", "session_json"]),
|
||||
task_sessions: new Set(["session_display_id", "session_params_json", "task_id"]),
|
||||
};
|
||||
|
||||
export function markBaselineSchema(db: DatabaseSync): void {
|
||||
db.prepare("delete from schema_migrations where version <> ?").run(BASELINE_SCHEMA_VERSION);
|
||||
db
|
||||
.prepare("insert or replace into schema_migrations (version, name, applied_at) values (?, ?, ?)")
|
||||
.run(BASELINE_SCHEMA_VERSION, BASELINE_SCHEMA_NAME, nowIso());
|
||||
}
|
||||
|
||||
export function upgradeLegacyProfileToBaselineV56({ db, readAll }: LegacyDeps): void {
|
||||
installBaselineTables(db);
|
||||
migrateLegacySecrets(db);
|
||||
migratePendingWakes(db, readAll);
|
||||
migrateAgentBudgets(db, readAll);
|
||||
|
||||
for (const tableName of BASELINE_TABLE_NAMES) {
|
||||
if (tableName === "schema_migrations") continue;
|
||||
if (tableName === "runs") {
|
||||
backfillRunsUsageStatus(db);
|
||||
}
|
||||
if (tableName === "tasks") {
|
||||
migrateTaskSessions(db, readAll);
|
||||
backfillRequiresFilesystem(db);
|
||||
removeSyntheticHeartbeatTasks(db);
|
||||
}
|
||||
if (tableName === "work_products") {
|
||||
detachDanglingWorkProducts(db);
|
||||
backfillWorkProductProvenance(db);
|
||||
normalizeWorkProductPrimaries(db);
|
||||
}
|
||||
rebuildTableToBaseline(db, tableName);
|
||||
}
|
||||
|
||||
db.exec("drop table if exists pending_wakes");
|
||||
db.exec("drop table if exists task_leases");
|
||||
db.exec("drop trigger if exists trg_tasks_phase_from_status_insert");
|
||||
db.exec("drop trigger if exists trg_tasks_phase_from_status_update");
|
||||
installCanonicalBaselineIndexes(db);
|
||||
assertNoForeignKeyViolations(db);
|
||||
markBaselineSchema(db);
|
||||
}
|
||||
|
||||
function parseTableName(sql: string): string {
|
||||
return parseBaselineTableName(sql);
|
||||
}
|
||||
|
||||
function quoteIdentifier(name: string): string {
|
||||
return `"${name.replaceAll("\"", "\"\"")}"`;
|
||||
}
|
||||
|
||||
function tableSql(tableName: string): string {
|
||||
const sql = BASELINE_TABLE_SQL.find((entry) => parseTableName(entry) === tableName);
|
||||
if (!sql) throw new Error(`Missing baseline SQL for table ${tableName}`);
|
||||
return sql;
|
||||
}
|
||||
|
||||
function tableColumns(db: DatabaseSync, tableName: string): ColumnInfo[] {
|
||||
return db.prepare(`pragma table_info(${quoteIdentifier(tableName)})`).all() as ColumnInfo[];
|
||||
}
|
||||
|
||||
function createSqlForTable(sql: string, tableName: string): string {
|
||||
return sql.replace(/^CREATE TABLE (?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)/, `CREATE TABLE ${quoteIdentifier(tableName)}`);
|
||||
}
|
||||
|
||||
function rebuildTableToBaseline(db: DatabaseSync, tableName: string): void {
|
||||
if (!tableExists(db, tableName)) return;
|
||||
if (isBaselineTableDefinition(db, tableName)) return;
|
||||
|
||||
const tempTableName = `__baseline_${tableName}_${process.pid}`;
|
||||
db.exec(`drop table if exists ${quoteIdentifier(tempTableName)}`);
|
||||
db.exec(createSqlForTable(tableSql(tableName), tempTableName));
|
||||
|
||||
const beforeCount = (db.prepare(`select count(*) as count from ${quoteIdentifier(tableName)}`).get() as { count: number }).count;
|
||||
const sourceColumns = new Set(tableColumns(db, tableName).map((column) => column.name));
|
||||
const targetColumns = tableColumns(db, tempTableName);
|
||||
assertDroppedColumnsAreExpected(tableName, sourceColumns, new Set(targetColumns.map((column) => column.name)));
|
||||
const insertColumns = targetColumns.map((column) => quoteIdentifier(column.name));
|
||||
const selectExpressions = targetColumns.map((column) => {
|
||||
if (sourceColumns.has(column.name)) return quoteIdentifier(column.name);
|
||||
if (column.dflt_value !== null) return column.dflt_value;
|
||||
if (column.notnull === 0) return "NULL";
|
||||
if (beforeCount === 0) return "NULL";
|
||||
throw new Error(`Cannot rebuild ${tableName}: missing required column ${column.name}`);
|
||||
});
|
||||
|
||||
db.exec(`insert into ${quoteIdentifier(tempTableName)} (${insertColumns.join(", ")}) select ${selectExpressions.join(", ")} from ${quoteIdentifier(tableName)}`);
|
||||
const afterCount = (db.prepare(`select count(*) as count from ${quoteIdentifier(tempTableName)}`).get() as { count: number }).count;
|
||||
if (afterCount !== beforeCount) {
|
||||
throw new Error(`Cannot rebuild ${tableName}: copied ${afterCount} of ${beforeCount} rows`);
|
||||
}
|
||||
|
||||
db.exec(`drop table ${quoteIdentifier(tableName)}`);
|
||||
db.exec(`alter table ${quoteIdentifier(tempTableName)} rename to ${quoteIdentifier(tableName)}`);
|
||||
}
|
||||
|
||||
function isBaselineTableDefinition(db: DatabaseSync, tableName: string): boolean {
|
||||
const row = db
|
||||
.prepare("select sql from sqlite_master where type = 'table' and name = ?")
|
||||
.get(tableName) as { sql: string | null } | undefined;
|
||||
if (!row?.sql) return false;
|
||||
return normalizeSchemaSql(row.sql) === normalizeSchemaSql(tableSql(tableName));
|
||||
}
|
||||
|
||||
function assertDroppedColumnsAreExpected(tableName: string, sourceColumns: Set<string>, targetColumns: Set<string>): void {
|
||||
const allowed = EXPECTED_DROPPED_COLUMNS[tableName] ?? new Set<string>();
|
||||
const unexpected = [...sourceColumns]
|
||||
.filter((column) => !targetColumns.has(column))
|
||||
.filter((column) => !allowed.has(column));
|
||||
if (unexpected.length > 0) {
|
||||
throw new Error(`Cannot rebuild ${tableName}: unexpected legacy columns would be dropped: ${unexpected.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacySecrets(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "secrets") || columnExists(db, "secrets", "company_id")) return;
|
||||
|
||||
const currentCompany = db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined;
|
||||
const firstCompany = db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined;
|
||||
const companyId = currentCompany?.value ?? firstCompany?.id;
|
||||
if (!companyId) {
|
||||
throw new Error("Cannot migrate legacy secrets table: no company row exists.");
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
alter table secrets rename to secrets_legacy;
|
||||
create table secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name));
|
||||
`);
|
||||
db
|
||||
.prepare("insert into secrets (id, company_id, name, description, backend, ciphertext, updated_at, created_at) select id, ?, name, description, backend, ciphertext, updated_at, created_at from secrets_legacy")
|
||||
.run(companyId);
|
||||
db.exec("drop table secrets_legacy");
|
||||
}
|
||||
|
||||
function migratePendingWakes(db: DatabaseSync, readAll: ReadAll): void {
|
||||
if (!tableExists(db, "pending_wakes")) return;
|
||||
const rows = readAll<{ id: string; companyId: string; agentId: string; triggerReason: string; createdAt: string }>(
|
||||
"select id, company_id as companyId, agent_id as agentId, trigger_reason as triggerReason, created_at as createdAt from pending_wakes",
|
||||
);
|
||||
const insert = db.prepare(`
|
||||
insert or ignore into wakeup_requests (id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)
|
||||
`);
|
||||
const ts = nowIso();
|
||||
for (const row of rows) {
|
||||
const detail = JSON.stringify([{ trigger: row.triggerReason, at: row.createdAt, detail: row.triggerReason }]);
|
||||
insert.run(row.id, row.companyId, row.agentId, row.triggerReason, detail, row.createdAt, ts, ts);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateAgentBudgets(db: DatabaseSync, readAll: ReadAll): void {
|
||||
if (!tableExists(db, "agents") || !tableExists(db, "budget_policies")) return;
|
||||
if (!columnExists(db, "agents", "budget_monthly_usd")) return;
|
||||
const agents = readAll<{ id: string; companyId: string; budgetMonthlyUsd: number }>(
|
||||
"select id, company_id as companyId, budget_monthly_usd as budgetMonthlyUsd from agents where budget_monthly_usd > 0",
|
||||
);
|
||||
const existing = db.prepare("select id from budget_policies where company_id = ? and scope_type = 'agent' and scope_id = ? and metric = 'billed_cents' and window_kind = 'calendar_month_utc'");
|
||||
const insert = db.prepare(`
|
||||
insert into budget_policies (id, company_id, scope_type, scope_id, metric, window_kind, amount, warn_percent, hard_stop_enabled, notify_enabled, is_active, created_at, updated_at)
|
||||
values (?, ?, 'agent', ?, 'billed_cents', 'calendar_month_utc', ?, 80, 1, 1, 1, ?, ?)
|
||||
`);
|
||||
const ts = nowIso();
|
||||
for (const agent of agents) {
|
||||
if (existing.get(agent.companyId, agent.id)) continue;
|
||||
insert.run(randomUUID(), agent.companyId, agent.id, Math.round(agent.budgetMonthlyUsd * 100), ts, ts);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateTaskSessions(db: DatabaseSync, readAll: ReadAll): void {
|
||||
if (!tableExists(db, "tasks") || !columnExists(db, "tasks", "session_json")) return;
|
||||
if (!columnExists(db, "tasks", "active_session_id")) {
|
||||
db.exec("alter table tasks add column active_session_id text");
|
||||
}
|
||||
|
||||
const rows = readAll<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
assigneeAgentId: string | null;
|
||||
sessionJson: string;
|
||||
sessionDisplayId: string | null;
|
||||
updatedAt: string;
|
||||
connectorId: string | null;
|
||||
}>(`
|
||||
select t.id,
|
||||
t.company_id as companyId,
|
||||
t.assignee_agent_id as assigneeAgentId,
|
||||
t.session_json as sessionJson,
|
||||
${columnExists(db, "tasks", "session_display_id") ? "t.session_display_id" : "NULL"} as sessionDisplayId,
|
||||
t.updated_at as updatedAt,
|
||||
a.connector_id as connectorId
|
||||
from tasks t
|
||||
left join agents a on a.id = t.assignee_agent_id
|
||||
where t.session_json is not null
|
||||
and (t.active_session_id is null or t.active_session_id = '')
|
||||
`);
|
||||
const sessionColumns = new Set(tableColumns(db, "task_sessions").map((column) => column.name));
|
||||
const updateTask = db.prepare("update tasks set active_session_id = ? where id = ?");
|
||||
const existingSessionByTask = sessionColumns.has("task_id")
|
||||
? db.prepare("select id from task_sessions where task_id = ? order by updated_at desc limit 1")
|
||||
: null;
|
||||
const insertColumns = [
|
||||
"id",
|
||||
"company_id",
|
||||
"agent_id",
|
||||
"adapter_type",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
...(sessionColumns.has("task_id") ? ["task_id"] : []),
|
||||
...(sessionColumns.has("session_params_json") ? ["session_params_json"] : []),
|
||||
...(sessionColumns.has("session_display_id") ? ["session_display_id"] : []),
|
||||
];
|
||||
const insert = db.prepare(`
|
||||
insert into task_sessions (${insertColumns.map(quoteIdentifier).join(", ")})
|
||||
values (${insertColumns.map(() => "?").join(", ")})
|
||||
`);
|
||||
for (const row of rows) {
|
||||
JSON.parse(row.sessionJson);
|
||||
const existingSession = existingSessionByTask?.get(row.id) as { id: string } | undefined;
|
||||
if (existingSession) {
|
||||
updateTask.run(existingSession.id, row.id);
|
||||
continue;
|
||||
}
|
||||
if (!row.assigneeAgentId) {
|
||||
throw new Error(`Cannot migrate task session for task ${row.id}: assignee_agent_id is missing.`);
|
||||
}
|
||||
if (!row.connectorId) {
|
||||
throw new Error(`Cannot migrate task session for task ${row.id}: assigned agent connector_id is missing.`);
|
||||
}
|
||||
const sessionId = randomUUID();
|
||||
const values = [
|
||||
sessionId,
|
||||
row.companyId,
|
||||
row.assigneeAgentId,
|
||||
row.connectorId,
|
||||
row.updatedAt,
|
||||
row.updatedAt,
|
||||
...(sessionColumns.has("task_id") ? [row.id] : []),
|
||||
...(sessionColumns.has("session_params_json") ? [row.sessionJson] : []),
|
||||
...(sessionColumns.has("session_display_id") ? [row.sessionDisplayId] : []),
|
||||
];
|
||||
insert.run(...values);
|
||||
updateTask.run(sessionId, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function backfillRequiresFilesystem(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "tasks")) return;
|
||||
if (!columnExists(db, "tasks", "requires_filesystem")) {
|
||||
db.exec("alter table tasks add column requires_filesystem integer not null default 0");
|
||||
}
|
||||
if (columnExists(db, "tasks", "active_run_id")) {
|
||||
db.exec(`
|
||||
update tasks
|
||||
set requires_filesystem = 1
|
||||
where status = 'in_progress'
|
||||
and active_run_id is not null
|
||||
`);
|
||||
}
|
||||
if (columnExists(db, "tasks", "assignee_agent_id")) {
|
||||
db.exec(`
|
||||
update tasks
|
||||
set requires_filesystem = 1
|
||||
where status in ('todo', 'backlog')
|
||||
and assignee_agent_id is not null
|
||||
and exists (
|
||||
select 1 from agents a
|
||||
where a.id = tasks.assignee_agent_id
|
||||
and (
|
||||
a.role like '%cto%'
|
||||
or a.role like '%engineer%'
|
||||
or a.role like '%developer%'
|
||||
or a.role like '%devops%'
|
||||
or a.role like '%sre%'
|
||||
or a.department = 'engineering'
|
||||
)
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function removeSyntheticHeartbeatTasks(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "tasks") || !columnExists(db, "tasks", "is_heartbeat")) return;
|
||||
if (tableExists(db, "runs")) {
|
||||
db.exec("update runs set task_id = null where task_id in (select id from tasks where is_heartbeat = 1)");
|
||||
}
|
||||
db.exec("delete from tasks where is_heartbeat = 1");
|
||||
}
|
||||
|
||||
function backfillRunsUsageStatus(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "runs")) return;
|
||||
if (!columnExists(db, "runs", "usage_status")) {
|
||||
db.exec("alter table runs add column usage_status text not null default 'pre_first_message'");
|
||||
}
|
||||
if (!columnExists(db, "runs", "unattributed_cost") || !columnExists(db, "runs", "status")) return;
|
||||
db.exec(`
|
||||
update runs
|
||||
set usage_status = case
|
||||
when unattributed_cost = 0 then 'ok'
|
||||
when status in ('cancelled', 'interrupted') then 'aborted_before_usage'
|
||||
else 'pre_first_message'
|
||||
end
|
||||
where usage_status = 'pre_first_message'
|
||||
`);
|
||||
}
|
||||
|
||||
function normalizeWorkProductPrimaries(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "work_products")) return;
|
||||
db.exec(`
|
||||
update work_products
|
||||
set is_primary = 0,
|
||||
updated_at = datetime('now')
|
||||
where id in (
|
||||
select id from (
|
||||
select id,
|
||||
row_number() over (
|
||||
partition by company_id, task_id, type
|
||||
order by updated_at desc, created_at desc, id desc
|
||||
) as rank_in_group
|
||||
from work_products
|
||||
where task_id is not null
|
||||
and is_primary = 1
|
||||
and status <> 'archived'
|
||||
)
|
||||
where rank_in_group > 1
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function backfillWorkProductProvenance(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "work_products")) return;
|
||||
if (!columnExists(db, "work_products", "workspace_id")) {
|
||||
db.exec("alter table work_products add column workspace_id text");
|
||||
}
|
||||
if (!columnExists(db, "work_products", "last_touched_by_run_id")) {
|
||||
db.exec("alter table work_products add column last_touched_by_run_id text");
|
||||
}
|
||||
if (tableExists(db, "tasks") && columnExists(db, "work_products", "task_id") && columnExists(db, "tasks", "workspace_id")) {
|
||||
db.exec(`
|
||||
update work_products
|
||||
set workspace_id = (
|
||||
select tasks.workspace_id from tasks
|
||||
where tasks.id = work_products.task_id
|
||||
)
|
||||
where workspace_id is null
|
||||
and task_id is not null
|
||||
`);
|
||||
}
|
||||
if (tableExists(db, "runs") && columnExists(db, "work_products", "created_by_run_id") && columnExists(db, "runs", "workspace_id")) {
|
||||
db.exec(`
|
||||
update work_products
|
||||
set workspace_id = (
|
||||
select runs.workspace_id from runs
|
||||
where runs.id = work_products.created_by_run_id
|
||||
)
|
||||
where workspace_id is null
|
||||
and created_by_run_id is not null
|
||||
`);
|
||||
}
|
||||
if (columnExists(db, "work_products", "created_by_run_id")) {
|
||||
db.exec(`
|
||||
update work_products
|
||||
set last_touched_by_run_id = created_by_run_id
|
||||
where last_touched_by_run_id is null
|
||||
and created_by_run_id is not null
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
function detachDanglingWorkProducts(db: DatabaseSync): void {
|
||||
if (!tableExists(db, "work_products") || !tableExists(db, "tasks") || !columnExists(db, "work_products", "task_id")) return;
|
||||
db.exec(`
|
||||
update work_products
|
||||
set task_id = null
|
||||
where task_id is not null
|
||||
and not exists (
|
||||
select 1 from tasks
|
||||
where tasks.id = work_products.task_id
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function assertNoForeignKeyViolations(db: DatabaseSync): void {
|
||||
const violations = db.prepare("pragma foreign_key_check").all() as Array<{
|
||||
table: string;
|
||||
rowid: number | null;
|
||||
parent: string;
|
||||
fkid: number;
|
||||
}>;
|
||||
if (violations.length === 0) return;
|
||||
|
||||
const sample = violations
|
||||
.slice(0, 5)
|
||||
.map((row) => `${row.table}:${row.rowid ?? "unknown"}->${row.parent}#${row.fkid}`)
|
||||
.join(", ");
|
||||
throw new Error(`Cannot migrate legacy profile: ${violations.length} foreign key violations after v56 rebuild (${sample}).`);
|
||||
}
|
||||
@@ -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`);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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'`,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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)`,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { columnExists, tableExists } from "./types";
|
||||
|
||||
export function createMigrationsV35V37(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
// Rebuild runs table with task_id NULLABLE. Heartbeat runs (agent
|
||||
// wake-ups with no real task) no longer need to synthesize a fake
|
||||
// task row; they simply insert a run with task_id = NULL. The old
|
||||
// workaround (is_heartbeat flag + acquireHeartbeatTask + cleanup
|
||||
// sweeps) becomes dead code in v36.
|
||||
//
|
||||
// SQLite can't ALTER a column's NOT NULL constraint, so we rebuild:
|
||||
// 1. Snapshot row count
|
||||
// 2. Discover actual column set via PRAGMA (drift safe)
|
||||
// 3. CREATE runs_new with the same columns + task_id nullable
|
||||
// 4. Copy every row
|
||||
// 5. Assert copied count == snapshot count (abort before DROP
|
||||
// if any row went missing)
|
||||
// 6. DROP old, RENAME new
|
||||
// 7. Recreate every index on runs
|
||||
//
|
||||
// FK from run_events(run_id) references runs(id); PRAGMA foreign_keys
|
||||
// OFF during the rebuild (we're inside a transaction already, so we
|
||||
// toggle the defer_foreign_keys flag instead — SQLite forbids
|
||||
// changing foreign_keys mid-transaction).
|
||||
version: 35, name: "make_runs_task_id_nullable",
|
||||
fn: () => {
|
||||
const beforeCount = (db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM runs")
|
||||
.get() as { cnt: number }).cnt;
|
||||
|
||||
const existingCols = (db
|
||||
.prepare("PRAGMA table_info(runs)")
|
||||
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
|
||||
if (existingCols.length === 0) {
|
||||
throw new Error("runs table is missing; refusing to migrate");
|
||||
}
|
||||
const colNames = existingCols.map((c) => c.name);
|
||||
|
||||
// Declare the known v34 column set. If the live DB has drift (extra
|
||||
// columns we did not declare), preserve those verbatim using the
|
||||
// column metadata from PRAGMA.
|
||||
const knownCols = new Set([
|
||||
"id", "company_id", "task_id", "agent_id", "workspace_id",
|
||||
"connector_id", "status", "summary", "error_message", "exit_code",
|
||||
"signal", "model", "session_display_id", "cost_usd",
|
||||
"unattributed_cost", "started_at", "finished_at", "log_path",
|
||||
"created_at", "updated_at", "context_snapshot_json",
|
||||
"actor_type", "actor_id",
|
||||
]);
|
||||
const driftCols = existingCols.filter((c) => !knownCols.has(c.name));
|
||||
|
||||
// Defer FK checks within this transaction.
|
||||
db.exec("PRAGMA defer_foreign_keys = ON");
|
||||
|
||||
// Build CREATE TABLE runs_new. task_id is nullable. Other columns
|
||||
// reuse the declared types / constraints. For drift columns we
|
||||
// emit a best-effort column definition (type, NULL, default).
|
||||
const declaredDdl = [
|
||||
"id TEXT PRIMARY KEY",
|
||||
"company_id TEXT NOT NULL",
|
||||
"task_id TEXT", // NULLABLE (was NOT NULL)
|
||||
"agent_id TEXT NOT NULL",
|
||||
"workspace_id TEXT",
|
||||
"connector_id TEXT NOT NULL",
|
||||
"status TEXT NOT NULL",
|
||||
"summary TEXT NOT NULL",
|
||||
"error_message TEXT",
|
||||
"exit_code INTEGER",
|
||||
"signal TEXT",
|
||||
"model TEXT",
|
||||
"session_display_id TEXT",
|
||||
"cost_usd REAL",
|
||||
"unattributed_cost INTEGER NOT NULL DEFAULT 0",
|
||||
"started_at TEXT",
|
||||
"finished_at TEXT",
|
||||
"log_path TEXT",
|
||||
"created_at TEXT NOT NULL",
|
||||
"updated_at TEXT NOT NULL",
|
||||
"context_snapshot_json TEXT",
|
||||
"actor_type TEXT",
|
||||
"actor_id TEXT",
|
||||
];
|
||||
for (const drift of driftCols) {
|
||||
const type = drift.type || "TEXT";
|
||||
declaredDdl.push(`${drift.name} ${type}`);
|
||||
}
|
||||
|
||||
db.exec(`CREATE TABLE runs_new (${declaredDdl.join(", ")})`);
|
||||
|
||||
// Copy preserving every column present in the old table.
|
||||
const copyCols = colNames.join(", ");
|
||||
db.exec(`INSERT INTO runs_new (${copyCols}) SELECT ${copyCols} FROM runs`);
|
||||
|
||||
const afterCount = (db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM runs_new")
|
||||
.get() as { cnt: number }).cnt;
|
||||
if (afterCount !== beforeCount) {
|
||||
throw new Error(
|
||||
`runs copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`,
|
||||
);
|
||||
}
|
||||
|
||||
db.exec("DROP TABLE runs");
|
||||
db.exec("ALTER TABLE runs_new RENAME TO runs");
|
||||
|
||||
// Recreate every index on runs.
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company ON runs(company_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_task ON runs(task_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(agent_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_status ON runs(company_id, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_company_created_at ON runs(company_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_runs_agent_created_at ON runs(agent_id, created_at DESC)");
|
||||
|
||||
log.info(`[migration v35] runs table rebuilt with nullable task_id (${afterCount} rows preserved)`);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Drop the `tasks.is_heartbeat` column and all synthetic heartbeat
|
||||
// task rows. Prerequisite: v35 must have made runs.task_id nullable
|
||||
// so we can null-out runs that pointed at heartbeat tasks before
|
||||
// deleting those task rows.
|
||||
version: 36, name: "drop_tasks_is_heartbeat_and_synthetic_rows",
|
||||
fn: () => {
|
||||
const hasColumn = columnExists(db, "tasks", "is_heartbeat");
|
||||
if (!hasColumn) {
|
||||
log.info("[migration v36] tasks.is_heartbeat already absent; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. NULL-out runs that pointed at heartbeat tasks.
|
||||
const updated = db
|
||||
.prepare(
|
||||
"UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)",
|
||||
)
|
||||
.run();
|
||||
const runsNulledOut = Number(updated.changes);
|
||||
|
||||
// 2. Delete synthetic heartbeat tasks. Cost entries referring to
|
||||
// these tasks remain with their task_id pointing at now-deleted
|
||||
// ids — acceptable, cost_entries.task_id has no FK constraint.
|
||||
// task_comments and approvals attached to these tasks are
|
||||
// already cascaded by the task row delete or would remain
|
||||
// orphaned; heartbeat tasks carry neither in practice.
|
||||
const beforeHeartbeatCount = (db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM tasks WHERE is_heartbeat = 1")
|
||||
.get() as { cnt: number }).cnt;
|
||||
db.exec("DELETE FROM tasks WHERE is_heartbeat = 1");
|
||||
|
||||
// 3. Rebuild `tasks` without the is_heartbeat column.
|
||||
const beforeTasksCount = (db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM tasks")
|
||||
.get() as { cnt: number }).cnt;
|
||||
|
||||
const existingCols = (db
|
||||
.prepare("PRAGMA table_info(tasks)")
|
||||
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
|
||||
const keepCols = existingCols.filter((c) => c.name !== "is_heartbeat");
|
||||
|
||||
db.exec("PRAGMA defer_foreign_keys = ON");
|
||||
|
||||
// Declared v20 + v13 + v38+v39/v20 column set for `tasks`. Drift
|
||||
// columns (any column we haven't declared here) are preserved
|
||||
// verbatim using PRAGMA metadata — same drift-safe shape as v35.
|
||||
const declaredCols = new Map<string, string>([
|
||||
["id", "TEXT PRIMARY KEY"],
|
||||
["company_id", "TEXT NOT NULL"],
|
||||
["project_id", "TEXT"],
|
||||
["goal_id", "TEXT"],
|
||||
["parent_id", "TEXT"],
|
||||
["title", "TEXT NOT NULL"],
|
||||
["description", "TEXT NOT NULL"],
|
||||
["assignee_agent_id", "TEXT"],
|
||||
["workspace_id", "TEXT"],
|
||||
["status", "TEXT NOT NULL"],
|
||||
["priority", "TEXT NOT NULL"],
|
||||
["active_run_id", "TEXT"],
|
||||
["session_json", "TEXT"],
|
||||
["session_display_id", "TEXT"],
|
||||
["cost_usd", "REAL NOT NULL DEFAULT 0"],
|
||||
["created_at", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL"],
|
||||
["task_type", "TEXT NOT NULL DEFAULT 'general'"],
|
||||
["requires_user_review", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["retry_count", "INTEGER NOT NULL DEFAULT 0"],
|
||||
["last_failure_at", "TEXT"],
|
||||
["checkout_run_id", "TEXT"],
|
||||
["execution_locked_at", "TEXT"],
|
||||
["active_session_id", "TEXT"],
|
||||
["started_at", "TEXT"],
|
||||
["completed_at", "TEXT"],
|
||||
["cancelled_at", "TEXT"],
|
||||
]);
|
||||
|
||||
const ddl = keepCols.map((c) => {
|
||||
const declared = declaredCols.get(c.name);
|
||||
if (declared) return `${c.name} ${declared}`;
|
||||
return `${c.name} ${c.type || "TEXT"}`;
|
||||
});
|
||||
|
||||
db.exec(`CREATE TABLE tasks_new (${ddl.join(", ")})`);
|
||||
|
||||
const copyCols = keepCols.map((c) => c.name).join(", ");
|
||||
db.exec(`INSERT INTO tasks_new (${copyCols}) SELECT ${copyCols} FROM tasks`);
|
||||
|
||||
const afterTasksCount = (db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM tasks_new")
|
||||
.get() as { cnt: number }).cnt;
|
||||
if (afterTasksCount !== beforeTasksCount) {
|
||||
throw new Error(
|
||||
`tasks copy row count mismatch: before=${beforeTasksCount} after=${afterTasksCount}; aborting before DROP`,
|
||||
);
|
||||
}
|
||||
|
||||
db.exec("DROP TABLE tasks");
|
||||
db.exec("ALTER TABLE tasks_new RENAME TO tasks");
|
||||
|
||||
// Recreate every index on tasks.
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company ON tasks(company_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee_agent_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_status ON tasks(company_id, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_created_at ON tasks(company_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_updated_at ON tasks(company_id, updated_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_company_assignee_status ON tasks(company_id, assignee_agent_id, status)");
|
||||
|
||||
log.info(
|
||||
`[migration v36] removed tasks.is_heartbeat; deleted ${beforeHeartbeatCount} synthetic heartbeat task row(s); nulled task_id on ${runsNulledOut} run(s); preserved ${afterTasksCount} task row(s)`,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Drop the `markdown` column from company_skills. SKILL.md on disk
|
||||
// (profile/skills/{companyId}/{slug}/SKILL.md) is the authoritative
|
||||
// source of truth read by pi-coding-agent's loadSkills. The DB column
|
||||
// duplicated that content. UI now loads the body live via IPC.
|
||||
//
|
||||
// SQLite can't DROP COLUMN cleanly pre-3.35 and even the supported path
|
||||
// is implemented as an invisible rebuild, so we do an explicit
|
||||
// drift-safe rebuild mirroring v35/v36:
|
||||
// 1. Snapshot row count
|
||||
// 2. PRAGMA table_info to discover the live column set
|
||||
// 3. CREATE company_skills_new without `markdown`
|
||||
// 4. Copy every kept column
|
||||
// 5. Assert copied count matches
|
||||
// 6. DROP old, RENAME new, recreate indexes
|
||||
//
|
||||
// FK from agent_skill_assignments(skill_id) -> company_skills(id) must
|
||||
// survive. PRAGMA defer_foreign_keys = ON keeps checks deferred until
|
||||
// transaction commit (by which time RENAME has restored the table).
|
||||
version: 37, name: "drop_company_skills_markdown",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "company_skills")) {
|
||||
log.info("[migration v37] company_skills table absent; skipping");
|
||||
return;
|
||||
}
|
||||
if (!columnExists(db, "company_skills", "markdown")) {
|
||||
log.info("[migration v37] company_skills.markdown already absent; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeCount = (db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM company_skills")
|
||||
.get() as { cnt: number }).cnt;
|
||||
|
||||
const existingCols = (db
|
||||
.prepare("PRAGMA table_info(company_skills)")
|
||||
.all() as Array<{ name: string; type: string; notnull: number; dflt_value: unknown; pk: number }>);
|
||||
const keepCols = existingCols.filter((c) => c.name !== "markdown");
|
||||
|
||||
db.exec("PRAGMA defer_foreign_keys = ON");
|
||||
|
||||
// Declared v14 column set (minus markdown). Drift columns preserved
|
||||
// verbatim from PRAGMA metadata.
|
||||
const declaredCols = new Map<string, string>([
|
||||
["id", "TEXT PRIMARY KEY"],
|
||||
["company_id", "TEXT NOT NULL"],
|
||||
["key", "TEXT NOT NULL"],
|
||||
["slug", "TEXT NOT NULL"],
|
||||
["name", "TEXT NOT NULL"],
|
||||
["description", "TEXT"],
|
||||
["source_type", "TEXT NOT NULL DEFAULT 'local_path'"],
|
||||
["source_locator", "TEXT"],
|
||||
["source_ref", "TEXT"],
|
||||
["trust_level", "TEXT NOT NULL DEFAULT 'markdown_only'"],
|
||||
["compatibility", "TEXT NOT NULL DEFAULT 'compatible'"],
|
||||
["file_inventory", "TEXT NOT NULL DEFAULT '[]'"],
|
||||
["metadata", "TEXT"],
|
||||
["created_at", "TEXT NOT NULL"],
|
||||
["updated_at", "TEXT NOT NULL"],
|
||||
]);
|
||||
|
||||
const ddl = keepCols.map((c) => {
|
||||
const declared = declaredCols.get(c.name);
|
||||
if (declared) return `${c.name} ${declared}`;
|
||||
return `${c.name} ${c.type || "TEXT"}`;
|
||||
});
|
||||
|
||||
db.exec(`CREATE TABLE company_skills_new (${ddl.join(", ")})`);
|
||||
|
||||
const copyCols = keepCols.map((c) => c.name).join(", ");
|
||||
db.exec(`INSERT INTO company_skills_new (${copyCols}) SELECT ${copyCols} FROM company_skills`);
|
||||
|
||||
const afterCount = (db
|
||||
.prepare("SELECT COUNT(*) as cnt FROM company_skills_new")
|
||||
.get() as { cnt: number }).cnt;
|
||||
if (afterCount !== beforeCount) {
|
||||
throw new Error(
|
||||
`company_skills copy row count mismatch: before=${beforeCount} after=${afterCount}; aborting before DROP`,
|
||||
);
|
||||
}
|
||||
|
||||
db.exec("DROP TABLE company_skills");
|
||||
db.exec("ALTER TABLE company_skills_new RENAME TO company_skills");
|
||||
|
||||
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_company_skills_company_key ON company_skills(company_id, key)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_company_skills_company_name ON company_skills(company_id, name)");
|
||||
|
||||
log.info(`[migration v37] dropped company_skills.markdown; preserved ${afterCount} row(s)`);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { tableExists } from "./types";
|
||||
|
||||
/**
|
||||
* Company-scope the connector model-pool settings.
|
||||
*
|
||||
* "Model pool" is the per-connector list of models a user has
|
||||
* enabled/disabled during onboarding (Settings → Model pool). The
|
||||
* original storage put it in the key-value `settings` table under
|
||||
* `disabled_models:${connectorId}` — global, not tied to any
|
||||
* company. That meant deleting a company left the pool config behind,
|
||||
* and creating a new company inherited the previous company's
|
||||
* enabled/disabled choices.
|
||||
*
|
||||
* This migration moves the data to a real relational table with a
|
||||
* cascade FK. Company deletion now naturally wipes the settings —
|
||||
* no manual cleanup in `deleteCompany()`, no risk of drift.
|
||||
*
|
||||
* Existing global entries cannot be assigned to a single company
|
||||
* (the constraint they were written under had none), so the
|
||||
* migration drops them. Users re-select their model pool on the
|
||||
* next onboarding wake. Acceptable: this data is a one-click UI
|
||||
* preference, not business-critical state, and the bug report was
|
||||
* literally "these carry over wrongly — clear them."
|
||||
*/
|
||||
export function createMigrationsV42V42(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
version: 42,
|
||||
name: "scope_connector_model_pool_by_company",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "connector_model_pool_settings")) {
|
||||
db.exec(`
|
||||
CREATE TABLE connector_model_pool_settings (
|
||||
company_id TEXT NOT NULL,
|
||||
connector_id TEXT NOT NULL,
|
||||
disabled_models_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (company_id, connector_id),
|
||||
FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_connector_model_pool_company
|
||||
ON connector_model_pool_settings(company_id);
|
||||
`);
|
||||
log.info("[migration v42] created connector_model_pool_settings");
|
||||
}
|
||||
|
||||
// Purge stale global keys. Prefix scan is enough — both the
|
||||
// key format (`disabled_models:<connectorId>`) and the
|
||||
// semantics (one per connector) mean dropping them all is the
|
||||
// correct reset.
|
||||
const deleted = db
|
||||
.prepare("DELETE FROM settings WHERE key LIKE 'disabled_models:%'")
|
||||
.run();
|
||||
const changes = Number(deleted.changes);
|
||||
if (changes > 0) {
|
||||
log.info(`[migration v42] dropped ${changes} legacy disabled_models:* key(s) from settings`);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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'
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user