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