diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index 3cbfb21..4038a55 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -31,6 +31,9 @@ function getExpectedSchemaMigrations(): Array<{ { version: 4, name: 'message_seq_and_work_item_indexes' }, { version: 5, name: 'sessions_composite_key' }, { version: 6, name: 'chat_channel_metadata' }, + { version: 7, name: 'runtime_service_metadata' }, + { version: 8, name: 'paired_task_schema_cleanup' }, + { version: 9, name: 'paired_workspace_project_schema_cleanup' }, ]; } diff --git a/src/db/migrations/007_runtime-service-metadata.ts b/src/db/migrations/007_runtime-service-metadata.ts new file mode 100644 index 0000000..61ac6bb --- /dev/null +++ b/src/db/migrations/007_runtime-service-metadata.ts @@ -0,0 +1,380 @@ +import type { Database } from 'bun:sqlite'; + +import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../../config.js'; +import { resolveRoleServiceShadow } from '../../role-service-shadow.js'; +import { + fillCanonicalChannelOwnerLeaseMetadata, + fillCanonicalPairedTaskMetadata, + fillCanonicalServiceHandoffMetadata, +} from '../canonical-role-metadata.js'; +import { normalizeStoredAgentType } from '../room-registration.js'; +import { tableHasColumn, tryExecMigration } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +interface StoredPairedTaskServiceRow { + rowid: number; + id: string; + owner_service_id?: string | null; + reviewer_service_id?: string | null; + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; +} + +interface StoredChannelOwnerLeaseServiceRow { + rowid: number; + chat_jid: string; + owner_service_id?: string | null; + reviewer_service_id?: string | null; + arbiter_service_id?: string | null; + owner_agent_type?: string | null; + reviewer_agent_type?: string | null; + arbiter_agent_type?: string | null; +} + +interface StoredServiceHandoffServiceRow { + id: number; + chat_jid: string; + group_folder: string; + source_service_id?: string | null; + target_service_id?: string | null; + source_role?: string | null; + target_role?: string | null; + source_agent_type?: string | null; + target_agent_type?: string | null; + intended_role?: string | null; +} + +interface StoredWorkItemServiceRow { + id: number; + agent_type?: string | null; + service_id?: string | null; + delivery_role?: string | null; +} + +function normalizePairedRole( + role: string | null | undefined, +): 'owner' | 'reviewer' | 'arbiter' | null { + return role === 'owner' || role === 'reviewer' || role === 'arbiter' + ? role + : null; +} + +function backfillCanonicalPairedTaskServiceIds(database: Database): void { + if ( + !tableHasColumn(database, 'paired_tasks', 'owner_service_id') || + !tableHasColumn(database, 'paired_tasks', 'reviewer_service_id') + ) { + return; + } + + const rows = database + .prepare( + ` + SELECT + rowid, + id, + owner_service_id, + reviewer_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type + FROM paired_tasks + `, + ) + .all() as StoredPairedTaskServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + ` + UPDATE paired_tasks + SET owner_service_id = ?, + reviewer_service_id = ?, + owner_agent_type = ?, + reviewer_agent_type = ?, + arbiter_agent_type = ? + WHERE rowid = ? + `, + ); + const tx = database.transaction((taskRows: StoredPairedTaskServiceRow[]) => { + for (const row of taskRows) { + const { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + } = fillCanonicalPairedTaskMetadata({ + id: row.id, + owner_service_id: row.owner_service_id, + reviewer_service_id: row.reviewer_service_id, + owner_agent_type: row.owner_agent_type, + reviewer_agent_type: row.reviewer_agent_type, + arbiter_agent_type: row.arbiter_agent_type, + }); + + if ( + row.owner_service_id === ownerServiceId && + row.reviewer_service_id === reviewerServiceId && + row.owner_agent_type === ownerAgentType && + row.reviewer_agent_type === reviewerAgentType && + row.arbiter_agent_type === arbiterAgentType + ) { + continue; + } + + update.run( + ownerServiceId, + reviewerServiceId, + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + row.rowid, + ); + } + }); + + tx(rows); +} + +function backfillCanonicalChannelOwnerServiceIds(database: Database): void { + if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) { + return; + } + + const rows = database + .prepare( + ` + SELECT + rowid, + chat_jid, + owner_service_id, + reviewer_service_id, + arbiter_service_id, + owner_agent_type, + reviewer_agent_type, + arbiter_agent_type + FROM channel_owner + `, + ) + .all() as StoredChannelOwnerLeaseServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + ` + UPDATE channel_owner + SET owner_service_id = ?, + reviewer_service_id = ?, + arbiter_service_id = ?, + owner_agent_type = ?, + reviewer_agent_type = ?, + arbiter_agent_type = ? + WHERE rowid = ? + `, + ); + const tx = database.transaction( + (leaseRows: StoredChannelOwnerLeaseServiceRow[]) => { + for (const row of leaseRows) { + const { + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + ownerServiceId, + reviewerServiceId, + arbiterServiceId, + } = fillCanonicalChannelOwnerLeaseMetadata({ + chat_jid: row.chat_jid, + owner_service_id: row.owner_service_id, + reviewer_service_id: row.reviewer_service_id, + arbiter_service_id: row.arbiter_service_id, + owner_agent_type: row.owner_agent_type, + reviewer_agent_type: row.reviewer_agent_type, + arbiter_agent_type: row.arbiter_agent_type, + }); + + if ( + row.owner_service_id === ownerServiceId && + row.reviewer_service_id === reviewerServiceId && + row.arbiter_service_id === arbiterServiceId && + row.owner_agent_type === ownerAgentType && + row.reviewer_agent_type === reviewerAgentType && + row.arbiter_agent_type === arbiterAgentType + ) { + continue; + } + + update.run( + ownerServiceId, + reviewerServiceId, + arbiterServiceId, + ownerAgentType, + reviewerAgentType, + arbiterAgentType, + row.rowid, + ); + } + }, + ); + + tx(rows); +} + +function backfillCanonicalServiceHandoffServiceIds(database: Database): void { + if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) { + return; + } + + const rows = database + .prepare( + ` + SELECT + id, + chat_jid, + group_folder, + source_service_id, + target_service_id, + source_role, + target_role, + source_agent_type, + target_agent_type, + intended_role + FROM service_handoffs + `, + ) + .all() as StoredServiceHandoffServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + ` + UPDATE service_handoffs + SET source_service_id = ?, + target_service_id = ?, + source_agent_type = ?, + target_agent_type = ?, + source_role = ?, + target_role = ? + WHERE id = ? + `, + ); + const tx = database.transaction( + (handoffRows: StoredServiceHandoffServiceRow[]) => { + for (const row of handoffRows) { + const { + sourceRole, + targetRole, + sourceAgentType, + targetAgentType, + sourceServiceId, + targetServiceId, + } = fillCanonicalServiceHandoffMetadata({ + id: row.id, + chat_jid: row.chat_jid, + source_service_id: row.source_service_id, + target_service_id: row.target_service_id, + source_role: row.source_role, + target_role: row.target_role, + intended_role: row.intended_role, + source_agent_type: row.source_agent_type, + target_agent_type: row.target_agent_type, + }); + + if ( + row.source_service_id === sourceServiceId && + row.target_service_id === targetServiceId && + row.source_agent_type === (sourceAgentType ?? null) && + row.target_agent_type === targetAgentType && + row.source_role === sourceRole && + row.target_role === targetRole + ) { + continue; + } + + update.run( + sourceServiceId, + targetServiceId, + sourceAgentType, + targetAgentType, + sourceRole, + targetRole, + row.id, + ); + } + }, + ); + + tx(rows); +} + +function backfillCanonicalWorkItemServiceIds(database: Database): void { + if (!tableHasColumn(database, 'work_items', 'service_id')) { + return; + } + + const rows = database + .prepare( + `SELECT id, agent_type, service_id, delivery_role + FROM work_items`, + ) + .all() as StoredWorkItemServiceRow[]; + if (rows.length === 0) { + return; + } + + const update = database.prepare( + `UPDATE work_items + SET service_id = ? + WHERE id = ?`, + ); + const tx = database.transaction( + (workItemRows: StoredWorkItemServiceRow[]) => { + for (const row of workItemRows) { + const agentType = + normalizeStoredAgentType(row.agent_type) ?? 'claude-code'; + const deliveryRole = normalizePairedRole(row.delivery_role) ?? 'owner'; + const serviceId = + (row.service_id ? normalizeServiceId(row.service_id) : null) ?? + resolveRoleServiceShadow(deliveryRole, agentType) ?? + SERVICE_SESSION_SCOPE; + + if (row.service_id === serviceId) { + continue; + } + + update.run(serviceId, row.id); + } + }, + ); + + tx(rows); +} + +export const RUNTIME_SERVICE_METADATA_MIGRATION = { + version: 7, + name: 'runtime_service_metadata', + apply(database) { + for (const column of [ + 'owner_service_id', + 'reviewer_service_id', + 'arbiter_service_id', + 'owner_agent_type', + 'reviewer_agent_type', + 'arbiter_agent_type', + ]) { + tryExecMigration( + database, + `ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`, + ); + } + + backfillCanonicalPairedTaskServiceIds(database); + backfillCanonicalChannelOwnerServiceIds(database); + backfillCanonicalServiceHandoffServiceIds(database); + backfillCanonicalWorkItemServiceIds(database); + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/008_paired-task-schema-cleanup.ts b/src/db/migrations/008_paired-task-schema-cleanup.ts new file mode 100644 index 0000000..1fbec3d --- /dev/null +++ b/src/db/migrations/008_paired-task-schema-cleanup.ts @@ -0,0 +1,124 @@ +import type { SchemaMigrationDefinition } from './types.js'; +import { getTableColumns, tryExecMigration } from './helpers.js'; + +export const PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION = { + version: 8, + name: 'paired_task_schema_cleanup', + apply(database) { + const pairedTasksSqlRow = database + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`, + ) + .get() as { sql?: string } | undefined; + const pairedTasksSql = pairedTasksSqlRow?.sql || ''; + const pairedTasksNeedsRebuild = + pairedTasksSql && + (pairedTasksSql.includes('task_policy') || + !pairedTasksSql.includes('round_trip_count')); + if (pairedTasksNeedsRebuild) { + database.exec(`DROP TABLE IF EXISTS paired_tasks`); + database.exec(` + CREATE TABLE IF NOT EXISTS paired_tasks ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT NOT NULL, + reviewer_service_id TEXT NOT NULL, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) + ); + CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status + ON paired_tasks(chat_jid, status, updated_at); + `); + } + + const ptSqlRow = database + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`, + ) + .get() as { sql?: string } | undefined; + const ptSql = ptSqlRow?.sql || ''; + if (ptSql && !ptSql.includes('arbiter_requested')) { + const pairedTaskCols = getTableColumns(database, 'paired_tasks'); + const selectPairedTaskColumn = (columnName: string): string => + pairedTaskCols.includes(columnName) + ? columnName + : `NULL AS ${columnName}`; + database.exec(` + CREATE TABLE paired_tasks_new ( + id TEXT PRIMARY KEY, + chat_jid TEXT NOT NULL, + group_folder TEXT NOT NULL, + owner_service_id TEXT, + reviewer_service_id TEXT, + owner_agent_type TEXT, + reviewer_agent_type TEXT, + arbiter_agent_type TEXT, + title TEXT, + source_ref TEXT, + plan_notes TEXT, + review_requested_at TEXT, + round_trip_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + arbiter_verdict TEXT, + arbiter_requested_at TEXT, + completion_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), + CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), + CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) + ); + INSERT INTO paired_tasks_new ( + id, chat_jid, group_folder, owner_service_id, reviewer_service_id, + owner_agent_type, reviewer_agent_type, + arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, + round_trip_count, status, created_at, updated_at + ) + SELECT + id, chat_jid, group_folder, + ${selectPairedTaskColumn('owner_service_id')}, + ${selectPairedTaskColumn('reviewer_service_id')}, + owner_agent_type, reviewer_agent_type, + arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, + round_trip_count, status, created_at, updated_at + FROM paired_tasks; + DROP TABLE paired_tasks; + ALTER TABLE paired_tasks_new RENAME TO paired_tasks; + CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status + ON paired_tasks(chat_jid, status, updated_at); + `); + } + + tryExecMigration( + database, + `ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`, + ); + + for (const table of [ + 'paired_executions', + 'paired_approvals', + 'paired_artifacts', + 'paired_events', + ]) { + database.exec(`DROP TABLE IF EXISTS ${table}`); + } + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/009_paired-workspace-project-schema-cleanup.ts b/src/db/migrations/009_paired-workspace-project-schema-cleanup.ts new file mode 100644 index 0000000..6e54270 --- /dev/null +++ b/src/db/migrations/009_paired-workspace-project-schema-cleanup.ts @@ -0,0 +1,54 @@ +import type { SchemaMigrationDefinition } from './types.js'; + +export const PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION = { + version: 9, + name: 'paired_workspace_project_schema_cleanup', + apply(database) { + const pairedWsSqlRow = database + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`, + ) + .get() as { sql?: string } | undefined; + const pairedWsSql = pairedWsSqlRow?.sql || ''; + if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) { + database.exec(`DROP TABLE IF EXISTS paired_workspaces`); + database.exec(` + CREATE TABLE IF NOT EXISTS paired_workspaces ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + role TEXT NOT NULL, + workspace_dir TEXT NOT NULL, + snapshot_source_dir TEXT, + snapshot_ref TEXT, + status TEXT NOT NULL DEFAULT 'ready', + snapshot_refreshed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (role IN ('owner', 'reviewer')), + CHECK (status IN ('ready', 'stale')) + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role + ON paired_workspaces(task_id, role); + `); + } + + const pairedProjSqlRow = database + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`, + ) + .get() as { sql?: string } | undefined; + const pairedProjSql = pairedProjSqlRow?.sql || ''; + if (pairedProjSql && pairedProjSql.includes('workspace_topology')) { + database.exec(`DROP TABLE IF EXISTS paired_projects`); + database.exec(` + CREATE TABLE IF NOT EXISTS paired_projects ( + chat_jid TEXT PRIMARY KEY, + group_folder TEXT NOT NULL, + canonical_work_dir TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + } + }, +} satisfies SchemaMigrationDefinition; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 44fd457..30ceecc 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -6,6 +6,9 @@ import { ROOM_REGISTRATION_CANONICAL_COLUMNS_MIGRATION } from './003_room-regist import { MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION } from './004_message-seq-and-work-item-indexes.js'; import { SESSIONS_COMPOSITE_KEY_MIGRATION } from './005_sessions-composite-key.js'; import { CHAT_CHANNEL_METADATA_MIGRATION } from './006_chat-channel-metadata.js'; +import { RUNTIME_SERVICE_METADATA_MIGRATION } from './007_runtime-service-metadata.js'; +import { PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION } from './008_paired-task-schema-cleanup.js'; +import { PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION } from './009_paired-workspace-project-schema-cleanup.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -20,6 +23,9 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ MESSAGE_SEQ_AND_WORK_ITEM_INDEXES_MIGRATION, SESSIONS_COMPOSITE_KEY_MIGRATION, CHAT_CHANNEL_METADATA_MIGRATION, + RUNTIME_SERVICE_METADATA_MIGRATION, + PAIRED_TASK_SCHEMA_CLEANUP_MIGRATION, + PAIRED_WORKSPACE_PROJECT_SCHEMA_CLEANUP_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { @@ -68,6 +74,7 @@ export function applyVersionedSchemaMigrations( continue; } + // Fail fast: a partially-applied migration must not be recorded as complete. migration.apply(database, args); if (!alreadyApplied) { recordAppliedSchemaMigration(database, migration); diff --git a/src/db/schema.ts b/src/db/schema.ts index 90d5880..6ec4b23 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,13 +1,6 @@ import { Database } from 'bun:sqlite'; -import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js'; -import { resolveRoleServiceShadow } from '../role-service-shadow.js'; -import { - fillCanonicalChannelOwnerLeaseMetadata, - fillCanonicalPairedTaskMetadata, - fillCanonicalServiceHandoffMetadata, - inferExecutionLeaseServiceIdFromCanonicalMetadata, -} from './canonical-role-metadata.js'; +import { inferExecutionLeaseServiceIdFromCanonicalMetadata } from './canonical-role-metadata.js'; import { backfillPairedTurnAttemptsFromTurns, buildPairedTurnAttemptId, @@ -17,7 +10,6 @@ import { tableHasColumn, tryExecMigration, } from './migrations/helpers.js'; -import { normalizeStoredAgentType } from './room-registration.js'; // Legacy monolithic migration bundle kept for compatibility with databases // that predate ordered schema migration tracking. New schema changes belong in @@ -167,42 +159,6 @@ interface LegacyExecutionLeaseServiceRow { arbiter_agent_type?: string | null; } -interface StoredPairedTaskServiceRow { - rowid: number; - id: string; - chat_jid: string; - group_folder: string; - owner_service_id?: string | null; - reviewer_service_id?: string | null; - owner_agent_type?: string | null; - reviewer_agent_type?: string | null; - arbiter_agent_type?: string | null; -} - -interface StoredChannelOwnerLeaseServiceRow { - rowid: number; - chat_jid: string; - owner_service_id?: string | null; - reviewer_service_id?: string | null; - arbiter_service_id?: string | null; - owner_agent_type?: string | null; - reviewer_agent_type?: string | null; - arbiter_agent_type?: string | null; -} - -interface StoredServiceHandoffServiceRow { - id: number; - chat_jid: string; - group_folder: string; - source_service_id?: string | null; - target_service_id?: string | null; - source_role?: string | null; - target_role?: string | null; - source_agent_type?: string | null; - target_agent_type?: string | null; - intended_role?: string | null; -} - interface PairedTurnAttemptTimingRow { turn_id: string; attempt_no: number; @@ -2113,27 +2069,6 @@ function applyPairedTurnAttemptProvenanceConstraints(database: Database): void { } } -interface StoredWorkItemServiceRow { - id: number; - agent_type?: string | null; - service_id?: string | null; - delivery_role?: string | null; -} - -function normalizePairedRole( - role: string | null | undefined, -): 'owner' | 'reviewer' | 'arbiter' | null { - return role === 'owner' || role === 'reviewer' || role === 'arbiter' - ? role - : null; -} - -function inferLegacyExecutionLeaseServiceId( - row: LegacyExecutionLeaseServiceRow, -): string | null { - return inferExecutionLeaseServiceIdFromCanonicalMetadata(row); -} - function backfillLegacyExecutionLeaseServiceShadows(database: Database): void { if ( !tableHasColumn( @@ -2200,7 +2135,8 @@ function backfillLegacyExecutionLeaseServiceShadows(database: Database): void { const tx = database.transaction( (leaseRows: LegacyExecutionLeaseServiceRow[]) => { for (const row of leaseRows) { - const claimedServiceId = inferLegacyExecutionLeaseServiceId(row); + const claimedServiceId = + inferExecutionLeaseServiceIdFromCanonicalMetadata(row); if (!claimedServiceId) { continue; } @@ -2212,302 +2148,6 @@ function backfillLegacyExecutionLeaseServiceShadows(database: Database): void { tx(rows); } -function backfillCanonicalPairedTaskServiceIds(database: Database): void { - if ( - !tableHasColumn(database, 'paired_tasks', 'owner_service_id') || - !tableHasColumn(database, 'paired_tasks', 'reviewer_service_id') - ) { - return; - } - - const rows = database - .prepare( - ` - SELECT - rowid, - id, - chat_jid, - group_folder, - owner_service_id, - reviewer_service_id, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type - FROM paired_tasks - `, - ) - .all() as StoredPairedTaskServiceRow[]; - if (rows.length === 0) { - return; - } - - const update = database.prepare( - ` - UPDATE paired_tasks - SET owner_service_id = ?, - reviewer_service_id = ?, - owner_agent_type = ?, - reviewer_agent_type = ?, - arbiter_agent_type = ? - WHERE rowid = ? - `, - ); - const tx = database.transaction((taskRows: StoredPairedTaskServiceRow[]) => { - for (const row of taskRows) { - const { - ownerAgentType, - reviewerAgentType, - arbiterAgentType, - ownerServiceId, - reviewerServiceId, - } = fillCanonicalPairedTaskMetadata({ - id: row.id, - owner_service_id: row.owner_service_id, - reviewer_service_id: row.reviewer_service_id, - owner_agent_type: row.owner_agent_type, - reviewer_agent_type: row.reviewer_agent_type, - arbiter_agent_type: row.arbiter_agent_type, - }); - - if ( - row.owner_service_id === ownerServiceId && - row.reviewer_service_id === reviewerServiceId && - row.owner_agent_type === ownerAgentType && - row.reviewer_agent_type === reviewerAgentType && - row.arbiter_agent_type === arbiterAgentType - ) { - continue; - } - - update.run( - ownerServiceId, - reviewerServiceId, - ownerAgentType, - reviewerAgentType, - arbiterAgentType, - row.rowid, - ); - } - }); - - tx(rows); -} - -function backfillCanonicalChannelOwnerServiceIds(database: Database): void { - if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) { - return; - } - - const rows = database - .prepare( - ` - SELECT - rowid, - chat_jid, - owner_service_id, - reviewer_service_id, - arbiter_service_id, - owner_agent_type, - reviewer_agent_type, - arbiter_agent_type - FROM channel_owner - `, - ) - .all() as StoredChannelOwnerLeaseServiceRow[]; - if (rows.length === 0) { - return; - } - - const update = database.prepare( - ` - UPDATE channel_owner - SET owner_service_id = ?, - reviewer_service_id = ?, - arbiter_service_id = ?, - owner_agent_type = ?, - reviewer_agent_type = ?, - arbiter_agent_type = ? - WHERE rowid = ? - `, - ); - const tx = database.transaction( - (leaseRows: StoredChannelOwnerLeaseServiceRow[]) => { - for (const row of leaseRows) { - const { - ownerAgentType, - reviewerAgentType, - arbiterAgentType, - ownerServiceId, - reviewerServiceId, - arbiterServiceId, - } = fillCanonicalChannelOwnerLeaseMetadata({ - chat_jid: row.chat_jid, - owner_service_id: row.owner_service_id, - reviewer_service_id: row.reviewer_service_id, - arbiter_service_id: row.arbiter_service_id, - owner_agent_type: row.owner_agent_type, - reviewer_agent_type: row.reviewer_agent_type, - arbiter_agent_type: row.arbiter_agent_type, - }); - - if ( - row.owner_service_id === ownerServiceId && - row.reviewer_service_id === reviewerServiceId && - row.arbiter_service_id === arbiterServiceId && - row.owner_agent_type === ownerAgentType && - row.reviewer_agent_type === reviewerAgentType && - row.arbiter_agent_type === arbiterAgentType - ) { - continue; - } - - update.run( - ownerServiceId, - reviewerServiceId, - arbiterServiceId, - ownerAgentType, - reviewerAgentType, - arbiterAgentType, - row.rowid, - ); - } - }, - ); - - tx(rows); -} - -function backfillCanonicalServiceHandoffServiceIds(database: Database): void { - if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) { - return; - } - - const rows = database - .prepare( - ` - SELECT - id, - chat_jid, - group_folder, - source_service_id, - target_service_id, - source_role, - target_role, - source_agent_type, - target_agent_type, - intended_role - FROM service_handoffs - `, - ) - .all() as StoredServiceHandoffServiceRow[]; - if (rows.length === 0) { - return; - } - - const update = database.prepare( - ` - UPDATE service_handoffs - SET source_service_id = ?, - target_service_id = ?, - source_agent_type = ?, - target_agent_type = ?, - source_role = ?, - target_role = ? - WHERE id = ? - `, - ); - const tx = database.transaction( - (handoffRows: StoredServiceHandoffServiceRow[]) => { - for (const row of handoffRows) { - const { - sourceRole, - targetRole, - sourceAgentType, - targetAgentType, - sourceServiceId, - targetServiceId, - } = fillCanonicalServiceHandoffMetadata({ - id: row.id, - chat_jid: row.chat_jid, - source_service_id: row.source_service_id, - target_service_id: row.target_service_id, - source_role: row.source_role, - target_role: row.target_role, - intended_role: row.intended_role, - source_agent_type: row.source_agent_type, - target_agent_type: row.target_agent_type, - }); - - if ( - row.source_service_id === sourceServiceId && - row.target_service_id === targetServiceId && - row.source_agent_type === (sourceAgentType ?? null) && - row.target_agent_type === targetAgentType && - row.source_role === sourceRole && - row.target_role === targetRole - ) { - continue; - } - - update.run( - sourceServiceId, - targetServiceId, - sourceAgentType, - targetAgentType, - sourceRole, - targetRole, - row.id, - ); - } - }, - ); - - tx(rows); -} - -function backfillCanonicalWorkItemServiceIds(database: Database): void { - if (!tableHasColumn(database, 'work_items', 'service_id')) { - return; - } - - const rows = database - .prepare( - `SELECT id, agent_type, service_id, delivery_role - FROM work_items`, - ) - .all() as StoredWorkItemServiceRow[]; - if (rows.length === 0) { - return; - } - - const update = database.prepare( - `UPDATE work_items - SET service_id = ? - WHERE id = ?`, - ); - const tx = database.transaction( - (workItemRows: StoredWorkItemServiceRow[]) => { - for (const row of workItemRows) { - const agentType = - normalizeStoredAgentType(row.agent_type) ?? 'claude-code'; - const deliveryRole = normalizePairedRole(row.delivery_role) ?? 'owner'; - const serviceId = - (row.service_id ? normalizeServiceId(row.service_id) : null) ?? - resolveRoleServiceShadow(deliveryRole, agentType) ?? - SERVICE_SESSION_SCOPE; - - if (row.service_id === serviceId) { - continue; - } - - update.run(serviceId, row.id); - } - }, - ); - - tx(rows); -} - export function applyLegacySchemaMigrations( database: Database, _args: { @@ -2651,7 +2291,6 @@ export function applyLegacySchemaMigrations( CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_expires_at ON paired_task_execution_leases(expires_at) `); - tryExecMigration( database, `ALTER TABLE work_items ADD COLUMN delivery_role TEXT`, @@ -2763,190 +2402,6 @@ export function applyLegacySchemaMigrations( rebuildPairedTurnsWithoutLegacyScratchColumns(database); rebuildPairedTurnAttemptForeignKeyTables(database); applyPairedTurnAttemptProvenanceConstraints(database); - - for (const column of [ - 'owner_service_id', - 'reviewer_service_id', - 'arbiter_service_id', - 'owner_agent_type', - 'reviewer_agent_type', - 'arbiter_agent_type', - ]) { - tryExecMigration( - database, - `ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`, - ); - } - - backfillCanonicalPairedTaskServiceIds(database); - backfillCanonicalChannelOwnerServiceIds(database); - backfillCanonicalServiceHandoffServiceIds(database); - backfillCanonicalWorkItemServiceIds(database); - - const pairedTasksSqlRow = database - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`, - ) - .get() as { sql?: string } | undefined; - const pairedTasksSql = pairedTasksSqlRow?.sql || ''; - const pairedTasksNeedsRebuild = - pairedTasksSql && - (pairedTasksSql.includes('task_policy') || - !pairedTasksSql.includes('round_trip_count')); - if (pairedTasksNeedsRebuild) { - database.exec(`DROP TABLE IF EXISTS paired_tasks`); - database.exec(` - CREATE TABLE IF NOT EXISTS paired_tasks ( - id TEXT PRIMARY KEY, - chat_jid TEXT NOT NULL, - group_folder TEXT NOT NULL, - owner_service_id TEXT NOT NULL, - reviewer_service_id TEXT NOT NULL, - owner_agent_type TEXT, - reviewer_agent_type TEXT, - arbiter_agent_type TEXT, - title TEXT, - source_ref TEXT, - plan_notes TEXT, - review_requested_at TEXT, - round_trip_count INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'active', - arbiter_verdict TEXT, - arbiter_requested_at TEXT, - completion_reason TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), - CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), - CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), - CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) - ); - CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status - ON paired_tasks(chat_jid, status, updated_at); - `); - } - - { - const ptSqlRow = database - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`, - ) - .get() as { sql?: string } | undefined; - const ptSql = ptSqlRow?.sql || ''; - if (ptSql && !ptSql.includes('arbiter_requested')) { - const pairedTaskCols = getTableColumns(database, 'paired_tasks'); - const selectPairedTaskColumn = (columnName: string): string => - pairedTaskCols.includes(columnName) - ? columnName - : `NULL AS ${columnName}`; - database.exec(` - CREATE TABLE paired_tasks_new ( - id TEXT PRIMARY KEY, - chat_jid TEXT NOT NULL, - group_folder TEXT NOT NULL, - owner_service_id TEXT, - reviewer_service_id TEXT, - owner_agent_type TEXT, - reviewer_agent_type TEXT, - arbiter_agent_type TEXT, - title TEXT, - source_ref TEXT, - plan_notes TEXT, - review_requested_at TEXT, - round_trip_count INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'active', - arbiter_verdict TEXT, - arbiter_requested_at TEXT, - completion_reason TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')), - CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL), - CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL), - CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL) - ); - INSERT INTO paired_tasks_new ( - id, chat_jid, group_folder, owner_service_id, reviewer_service_id, - owner_agent_type, reviewer_agent_type, - arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, - round_trip_count, status, created_at, updated_at - ) - SELECT - id, chat_jid, group_folder, - ${selectPairedTaskColumn('owner_service_id')}, - ${selectPairedTaskColumn('reviewer_service_id')}, - owner_agent_type, reviewer_agent_type, - arbiter_agent_type, title, source_ref, plan_notes, review_requested_at, - round_trip_count, status, created_at, updated_at - FROM paired_tasks; - DROP TABLE paired_tasks; - ALTER TABLE paired_tasks_new RENAME TO paired_tasks; - CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status - ON paired_tasks(chat_jid, status, updated_at); - `); - } - } - - tryExecMigration( - database, - `ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`, - ); - - for (const table of [ - 'paired_executions', - 'paired_approvals', - 'paired_artifacts', - 'paired_events', - ]) { - database.exec(`DROP TABLE IF EXISTS ${table}`); - } - - const pairedWsSqlRow = database - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`, - ) - .get() as { sql?: string } | undefined; - const pairedWsSql = pairedWsSqlRow?.sql || ''; - if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) { - database.exec(`DROP TABLE IF EXISTS paired_workspaces`); - database.exec(` - CREATE TABLE IF NOT EXISTS paired_workspaces ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - role TEXT NOT NULL, - workspace_dir TEXT NOT NULL, - snapshot_source_dir TEXT, - snapshot_ref TEXT, - status TEXT NOT NULL DEFAULT 'ready', - snapshot_refreshed_at TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - CHECK (role IN ('owner', 'reviewer')), - CHECK (status IN ('ready', 'stale')) - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role - ON paired_workspaces(task_id, role); - `); - } - - const pairedProjSqlRow = database - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`, - ) - .get() as { sql?: string } | undefined; - const pairedProjSql = pairedProjSqlRow?.sql || ''; - if (pairedProjSql && pairedProjSql.includes('workspace_topology')) { - database.exec(`DROP TABLE IF EXISTS paired_projects`); - database.exec(` - CREATE TABLE IF NOT EXISTS paired_projects ( - chat_jid TEXT PRIMARY KEY, - group_folder TEXT NOT NULL, - canonical_work_dir TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - `); - } } /** @deprecated New schema changes should be added under src/db/migrations/*. */