From 921c241873a5edd5dab26ef8fc785256a0c12a3a Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 10 Apr 2026 23:12:54 +0900 Subject: [PATCH] refactor: finish legacy room cleanup and explicit migration boundary --- setup/legacy-room-registrations.ts | 545 ++++++++++++++++++++++ setup/migrate-room-registrations.test.ts | 10 + setup/migrate-room-registrations.ts | 47 +- setup/room-registration-state.ts | 2 +- src/db.test.ts | 385 ++++++++++++++-- src/db.ts | 16 +- src/db/base-schema.ts | 14 - src/db/database-lifecycle.ts | 40 +- src/db/room-registration.ts | 552 +---------------------- src/db/rooms.ts | 164 ++----- src/db/schema.ts | 131 ------ 11 files changed, 1028 insertions(+), 878 deletions(-) create mode 100644 setup/legacy-room-registrations.ts diff --git a/setup/legacy-room-registrations.ts b/setup/legacy-room-registrations.ts new file mode 100644 index 0000000..0aa3d90 --- /dev/null +++ b/setup/legacy-room-registrations.ts @@ -0,0 +1,545 @@ +import { Database } from 'bun:sqlite'; + +import { OWNER_AGENT_TYPE } from '../src/config.js'; +import type { AgentType, RegisteredGroup } from '../src/types.js'; +import { + type LegacyRoomMigrationPlan, + type RoomRegistrationSnapshot, + type RoomRoleOverrideSnapshot, + type StoredRoomSettings, + getStoredRoomSettingsRowFromDatabase, + inferOwnerAgentTypeFromRegisteredAgentTypes, + inferRoomModeFromRegisteredAgentTypes, + normalizeStoredAgentType, +} from '../src/db/room-registration.js'; + +interface StoredRoomRoleOverrideRow { + role: 'owner' | 'reviewer' | 'arbiter'; + agentType: AgentType; + agentConfig?: RegisteredGroup['agentConfig']; + createdAt: string; + updatedAt: string; +} + +function hasLegacyRegisteredGroupsTable(database: Database): boolean { + const row = database + .prepare( + `SELECT 1 + FROM sqlite_master + WHERE type = 'table' + AND name = 'registered_groups'`, + ) + .get() as { 1: number } | undefined; + return Boolean(row); +} + +export function normalizeLegacyRegisteredGroupsTable(database: Database): void { + const roomBindingsSql = ( + database + .prepare( + `SELECT sql + FROM sqlite_master + WHERE type = 'table' + AND name = 'registered_groups'`, + ) + .get() as { sql?: string } | undefined + )?.sql; + if (!roomBindingsSql) { + return; + } + + if (!roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)')) { + const legacyCols = database + .prepare('PRAGMA table_info(registered_groups)') + .all() as Array<{ name: string }>; + const hasIsMain = legacyCols.some((col) => col.name === 'is_main'); + const hasAgentType = legacyCols.some((col) => col.name === 'agent_type'); + const hasWorkDir = legacyCols.some((col) => col.name === 'work_dir'); + const hasAgentConfig = legacyCols.some((col) => col.name === 'agent_config'); + const hasContainerConfig = legacyCols.some( + (col) => col.name === 'container_config', + ); + + database.exec(` + CREATE TABLE registered_groups_new ( + jid TEXT NOT NULL, + name TEXT NOT NULL, + folder TEXT NOT NULL, + trigger_pattern TEXT NOT NULL, + added_at TEXT NOT NULL, + agent_config TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + agent_type TEXT NOT NULL DEFAULT 'claude-code', + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + database.exec(` + INSERT INTO registered_groups_new ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) + SELECT + jid, + name, + folder, + trigger_pattern, + added_at, + ${ + hasAgentConfig + ? 'agent_config' + : hasContainerConfig + ? 'container_config' + : 'NULL' + }, + requires_trigger, + ${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"}, + ${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"}, + ${hasWorkDir ? 'work_dir' : 'NULL'} + FROM registered_groups; + `); + + database.exec(` + DROP TABLE registered_groups; + ALTER TABLE registered_groups_new RENAME TO registered_groups; + `); + } + + const registeredGroupCols = database + .prepare('PRAGMA table_info(registered_groups)') + .all() as Array<{ name: string }>; + const hasAgentConfig = registeredGroupCols.some( + (col) => col.name === 'agent_config', + ); + const hasContainerConfig = registeredGroupCols.some( + (col) => col.name === 'container_config', + ); + if (!hasAgentConfig) { + database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`); + } + if (hasContainerConfig) { + database.exec( + `UPDATE registered_groups + SET agent_config = COALESCE(agent_config, container_config) + WHERE container_config IS NOT NULL`, + ); + } + database.exec( + `UPDATE registered_groups + SET is_main = 1 + WHERE folder = 'main' + AND COALESCE(is_main, 0) = 0`, + ); +} + +function getStoredRoomRoleOverrideRows( + database: Database, + jid: string, +): Map<'owner' | 'reviewer' | 'arbiter', StoredRoomRoleOverrideRow> { + let rows: Array<{ + role: 'owner' | 'reviewer' | 'arbiter'; + agent_type: string | null; + agent_config_json: string | null; + created_at: string; + updated_at: string; + }> = []; + try { + rows = database + .prepare( + `SELECT role, agent_type, agent_config_json, created_at, updated_at + FROM room_role_overrides + WHERE chat_jid = ?`, + ) + .all(jid) as Array<{ + role: 'owner' | 'reviewer' | 'arbiter'; + agent_type: string | null; + agent_config_json: string | null; + created_at: string; + updated_at: string; + }>; + } catch { + return new Map(); + } + + const result = new Map< + 'owner' | 'reviewer' | 'arbiter', + StoredRoomRoleOverrideRow + >(); + for (const row of rows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (!agentType) continue; + result.set(row.role, { + role: row.role, + agentType, + agentConfig: row.agent_config_json + ? JSON.parse(row.agent_config_json) + : undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } + return result; +} + +function normalizeAgentConfigValue( + agentConfig: RegisteredGroup['agentConfig'] | undefined, +): string { + return JSON.stringify(agentConfig ?? null); +} + +function roomRoleOverrideMatches( + actual: StoredRoomRoleOverrideRow | undefined, + expected: RoomRoleOverrideSnapshot, +): boolean { + return ( + actual?.agentType === expected.agentType && + normalizeAgentConfigValue(actual.agentConfig) === + normalizeAgentConfigValue(expected.agentConfig) + ); +} + +function storedRoomSettingsMatchesLegacySnapshot( + stored: StoredRoomSettings, + snapshot: RoomRegistrationSnapshot, +): boolean { + return ( + stored.name === snapshot.name && + stored.folder === snapshot.folder && + (stored.requiresTrigger ?? true) === snapshot.requiresTrigger && + (stored.isMain ?? false) === snapshot.isMain && + (stored.workDir ?? null) === (snapshot.workDir ?? null) + ); +} + +function collectLegacyRegisteredAgentTypes( + database: Database, + jid: string, +): AgentType[] { + if (!hasLegacyRegisteredGroupsTable(database)) { + return []; + } + + const rows = database + .prepare('SELECT agent_type FROM registered_groups WHERE jid = ?') + .all(jid) as Array<{ agent_type: string | null }>; + const types = new Set(); + for (const row of rows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (agentType) { + types.add(agentType); + } + } + return [...types]; +} + +function getLegacyGroupCapabilityMetadata( + database: Database, + jid: string, + preferredAgentType?: AgentType, +): Pick | undefined { + if (!hasLegacyRegisteredGroupsTable(database)) { + return undefined; + } + + const row = ( + preferredAgentType + ? database + .prepare( + `SELECT added_at, agent_config + FROM registered_groups + WHERE jid = ? AND agent_type = ? + LIMIT 1`, + ) + .get(jid, preferredAgentType) + : database + .prepare( + `SELECT added_at, agent_config + FROM registered_groups + WHERE jid = ? + ORDER BY CASE WHEN agent_type = ? THEN 0 ELSE 1 END, added_at + LIMIT 1`, + ) + .get(jid, OWNER_AGENT_TYPE) + ) as { added_at: string; agent_config: string | null } | undefined; + + if (!row) return undefined; + return { + added_at: row.added_at, + agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined, + }; +} + +function resolveReviewerAgentTypeFromRegisteredAgentTypes( + agentTypes: readonly AgentType[], + ownerAgentType: AgentType, +): AgentType | undefined { + return agentTypes.find((agentType) => agentType !== ownerAgentType); +} + +function collectLegacyRoomRegistrationSnapshot( + database: Database, + jid: string, + existingStored?: Pick< + StoredRoomSettings, + 'modeSource' | 'ownerAgentType' | 'trigger' + >, +): RoomRegistrationSnapshot | undefined { + if (!hasLegacyRegisteredGroupsTable(database)) { + return undefined; + } + + const rows = database + .prepare( + `SELECT name, folder, trigger_pattern, requires_trigger, is_main, agent_type, work_dir + FROM registered_groups + WHERE jid = ? + ORDER BY agent_type`, + ) + .all(jid) as Array<{ + name: string; + folder: string; + trigger_pattern: string; + requires_trigger: number | null; + is_main: number | null; + agent_type: string | null; + work_dir: string | null; + }>; + + if (rows.length === 0) return undefined; + + const first = rows[0]!; + const conflicts = new Set(); + for (const row of rows.slice(1)) { + if (row.name !== first.name) conflicts.add('name'); + if (row.folder !== first.folder) conflicts.add('folder'); + if ((row.requires_trigger ?? 1) !== (first.requires_trigger ?? 1)) { + conflicts.add('requires_trigger'); + } + if ((row.is_main ?? 0) !== (first.is_main ?? 0)) { + conflicts.add('is_main'); + } + if ((row.work_dir ?? null) !== (first.work_dir ?? null)) { + conflicts.add('work_dir'); + } + } + + if (conflicts.size > 0) { + throw new Error( + `Conflicting room-level registered_groups metadata for ${jid}: ${[ + ...conflicts, + ].join(', ')}`, + ); + } + + const agentTypes = collectLegacyRegisteredAgentTypes(database, jid); + const inferredOwnerAgentType = + inferOwnerAgentTypeFromRegisteredAgentTypes(agentTypes); + const preferExplicitTrigger = + existingStored?.modeSource === 'explicit' && existingStored.trigger; + const preferExplicitOwner = + existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType; + const preferredOwnerAgentType = preferExplicitOwner + ? existingStored.ownerAgentType + : undefined; + const preferredOwnerRow = preferredOwnerAgentType + ? rows.find( + (row) => + normalizeStoredAgentType(row.agent_type) === preferredOwnerAgentType, + ) + : undefined; + const inferredOwnerRow = + rows.find( + (row) => + normalizeStoredAgentType(row.agent_type) === inferredOwnerAgentType, + ) ?? rows[0]; + const ownerAgentType = preferredOwnerAgentType + ? preferredOwnerRow + ? preferredOwnerAgentType + : preferredOwnerAgentType + : inferredOwnerAgentType; + const ownerRow = preferredOwnerRow ?? inferredOwnerRow; + + return { + name: first.name, + folder: first.folder, + triggerPattern: preferExplicitTrigger + ? existingStored.trigger! + : preferredOwnerRow != null + ? preferredOwnerRow.trigger_pattern + : ownerRow.trigger_pattern, + requiresTrigger: (first.requires_trigger ?? 1) === 1, + isMain: (first.is_main ?? 0) === 1, + ownerAgentType, + workDir: first.work_dir ?? null, + }; +} + +export function buildLegacyRoomMigrationPlan( + database: Database, + jid: string, +): LegacyRoomMigrationPlan | undefined { + const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid); + const snapshot = collectLegacyRoomRegistrationSnapshot( + database, + jid, + existingStored, + ); + if (!snapshot) return undefined; + + const rows = database + .prepare( + `SELECT added_at + FROM registered_groups + WHERE jid = ? + ORDER BY added_at, agent_type`, + ) + .all(jid) as Array<{ added_at: string }>; + if (rows.length === 0) return undefined; + + const agentTypes = collectLegacyRegisteredAgentTypes(database, jid); + const roomMode = + existingStored?.roomMode ?? + inferRoomModeFromRegisteredAgentTypes(agentTypes); + const createdAt = rows[0]!.added_at; + const updatedAt = rows[rows.length - 1]!.added_at; + const roleOverrides: RoomRoleOverrideSnapshot[] = []; + + const ownerMetadata = getLegacyGroupCapabilityMetadata( + database, + jid, + snapshot.ownerAgentType, + ); + roleOverrides.push({ + role: 'owner', + agentType: snapshot.ownerAgentType, + agentConfig: ownerMetadata?.agentConfig, + createdAt: ownerMetadata?.added_at ?? createdAt, + updatedAt, + }); + + if (roomMode === 'tribunal') { + const reviewerAgentType = resolveReviewerAgentTypeFromRegisteredAgentTypes( + agentTypes, + snapshot.ownerAgentType, + ); + if (!reviewerAgentType) { + throw new Error( + `Missing reviewer agent type for tribunal legacy room ${jid}`, + ); + } + const reviewerMetadata = getLegacyGroupCapabilityMetadata( + database, + jid, + reviewerAgentType, + ); + roleOverrides.push({ + role: 'reviewer', + agentType: reviewerAgentType, + agentConfig: reviewerMetadata?.agentConfig, + createdAt: reviewerMetadata?.added_at ?? createdAt, + updatedAt, + }); + } + + return { + chatJid: jid, + roomMode, + createdAt, + updatedAt, + snapshot, + roleOverrides, + }; +} + +function jidRequiresLegacyRoomMigration( + database: Database, + jid: string, +): boolean { + let stored: StoredRoomSettings | undefined; + try { + stored = getStoredRoomSettingsRowFromDatabase(database, jid); + } catch { + return true; + } + if (!stored) { + return true; + } + + let plan: LegacyRoomMigrationPlan | undefined; + try { + plan = buildLegacyRoomMigrationPlan(database, jid); + } catch { + return true; + } + if (!plan) { + return false; + } + + if (!storedRoomSettingsMatchesLegacySnapshot(stored, plan.snapshot)) { + return true; + } + + const existingOverrides = getStoredRoomRoleOverrideRows(database, jid); + for (const override of plan.roleOverrides) { + if ( + !roomRoleOverrideMatches(existingOverrides.get(override.role), override) + ) { + return true; + } + } + + return false; +} + +export function getPendingLegacyRegisteredGroupJids( + database: Database, +): string[] { + if (!hasLegacyRegisteredGroupsTable(database)) { + return []; + } + + const rows = database + .prepare( + `SELECT DISTINCT jid + FROM registered_groups + ORDER BY jid`, + ) + .all() as Array<{ jid: string }>; + + return rows + .map((row) => row.jid) + .filter((jid) => jidRequiresLegacyRoomMigration(database, jid)); +} + +export function countPendingLegacyRegisteredGroupRows( + database: Database, +): number { + if (!hasLegacyRegisteredGroupsTable(database)) { + return 0; + } + + let count = 0; + const countRows = database.prepare( + `SELECT COUNT(*) AS count + FROM registered_groups + WHERE jid = ?`, + ); + + for (const jid of getPendingLegacyRegisteredGroupJids(database)) { + const row = countRows.get(jid) as { count: number }; + count += row.count; + } + + return count; +} diff --git a/setup/migrate-room-registrations.test.ts b/setup/migrate-room-registrations.test.ts index 52815bd..4eeae23 100644 --- a/setup/migrate-room-registrations.test.ts +++ b/setup/migrate-room-registrations.test.ts @@ -149,6 +149,16 @@ describe('migrate room registrations step', () => { agent_type: 'claude-code', }, ]); + expect( + migratedDb + .prepare( + `SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'registered_groups'`, + ) + .get(), + ).toBeUndefined(); expect( migratedDb .prepare( diff --git a/setup/migrate-room-registrations.ts b/setup/migrate-room-registrations.ts index 8c379d6..d5cd6f0 100644 --- a/setup/migrate-room-registrations.ts +++ b/setup/migrate-room-registrations.ts @@ -9,11 +9,14 @@ import { openPersistentDatabase, } from '../src/db/bootstrap.js'; import { - buildLegacyRoomMigrationPlan, - getPendingLegacyRegisteredGroupJids, insertStoredRoomSettingsFromMigration, upsertRoomRoleOverride, } from '../src/db/room-registration.js'; +import { + buildLegacyRoomMigrationPlan, + getPendingLegacyRegisteredGroupJids, + normalizeLegacyRegisteredGroupsTable, +} from './legacy-room-registrations.js'; import { emitStatus } from './status.js'; interface MigrationReport { @@ -42,21 +45,27 @@ function ensureLegacyBackupTable(database: Database): void { `); } -function backupPendingLegacyRows(database: Database): number { - ensureLegacyBackupTable(database); - const pendingJids = new Set(getPendingLegacyRegisteredGroupJids(database)); - if (pendingJids.size === 0) { +function backupLegacyRows(database: Database): number { + const tableExists = database + .prepare( + `SELECT 1 + FROM sqlite_master + WHERE type = 'table' + AND name = 'registered_groups'`, + ) + .get(); + if (!tableExists) { return 0; } - const rows = ( - database - .prepare( - `SELECT jid, name, folder, trigger_pattern, added_at, agent_config, - requires_trigger, is_main, agent_type, work_dir - FROM registered_groups`, - ) - .all() as Array> - ).filter((row) => pendingJids.has(String(row.jid))); + + ensureLegacyBackupTable(database); + const rows = database + .prepare( + `SELECT jid, name, folder, trigger_pattern, added_at, agent_config, + requires_trigger, is_main, agent_type, work_dir + FROM registered_groups`, + ) + .all() as Array>; const insert = database.prepare( `INSERT OR REPLACE INTO registered_groups_legacy_backup ( @@ -92,6 +101,10 @@ function backupPendingLegacyRows(database: Database): number { return rows.length; } +function dropLegacyRegisteredGroupsTable(database: Database): void { + database.exec(`DROP TABLE IF EXISTS registered_groups`); +} + function migrateLegacyRegisteredGroupsTable( database: Database, report: MigrationReport, @@ -141,8 +154,10 @@ export async function run(_args: string[]): Promise { }; database.transaction(() => { - report.backedUpLegacyRows = backupPendingLegacyRows(database); + normalizeLegacyRegisteredGroupsTable(database); + report.backedUpLegacyRows = backupLegacyRows(database); migrateLegacyRegisteredGroupsTable(database, report); + dropLegacyRegisteredGroupsTable(database); })(); database.close(); diff --git a/setup/room-registration-state.ts b/setup/room-registration-state.ts index 615e1a8..decc43b 100644 --- a/setup/room-registration-state.ts +++ b/setup/room-registration-state.ts @@ -5,7 +5,7 @@ import { Database } from 'bun:sqlite'; import { STORE_DIR } from '../src/config.js'; import { listUnexpectedDataStateFiles } from '../src/data-state-files.js'; -import { countPendingLegacyRegisteredGroupRows } from '../src/db/room-registration.js'; +import { countPendingLegacyRegisteredGroupRows } from './legacy-room-registrations.js'; export interface AssignedRoomsSummary { assignedRooms: number; diff --git a/src/db.test.ts b/src/db.test.ts index 2d5a9ab..ddd62df 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -81,9 +81,14 @@ import { buildPairedTurnAttemptParentId, } from './db/paired-turn-attempts.js'; import { - buildLegacyRoomMigrationPlan, - getPendingLegacyRegisteredGroupJids, + type RoomRegistrationSnapshot, + type RoomRoleOverrideSnapshot, + type StoredRoomSettings, + getStoredRoomSettingsRowFromDatabase, + inferOwnerAgentTypeFromRegisteredAgentTypes, + inferRoomModeFromRegisteredAgentTypes, insertStoredRoomSettingsFromMigration, + normalizeStoredAgentType, upsertRoomRoleOverride, } from './db/room-registration.js'; import { buildPairedTurnIdentity } from './paired-turn-identity.js'; @@ -171,6 +176,321 @@ function insertPairedTurnIdentityRow( ); } +type LegacyRoomMigrationPlanForTests = Parameters< + typeof insertStoredRoomSettingsFromMigration +>[1]; + +function getStoredRoleOverridesForLegacyMigration( + database: Database, + jid: string, +): Map< + 'owner' | 'reviewer' | 'arbiter', + { + role: 'owner' | 'reviewer' | 'arbiter'; + agentType: 'claude-code' | 'codex'; + agentConfig?: unknown; + createdAt: string; + updatedAt: string; + } +> { + let rows: Array<{ + role: 'owner' | 'reviewer' | 'arbiter'; + agent_type: string | null; + agent_config_json: string | null; + created_at: string; + updated_at: string; + }> = []; + try { + rows = database + .prepare( + `SELECT role, agent_type, agent_config_json, created_at, updated_at + FROM room_role_overrides + WHERE chat_jid = ?`, + ) + .all(jid) as Array<{ + role: 'owner' | 'reviewer' | 'arbiter'; + agent_type: string | null; + agent_config_json: string | null; + created_at: string; + updated_at: string; + }>; + } catch { + return new Map(); + } + + const result = new Map< + 'owner' | 'reviewer' | 'arbiter', + { + role: 'owner' | 'reviewer' | 'arbiter'; + agentType: 'claude-code' | 'codex'; + agentConfig?: unknown; + createdAt: string; + updatedAt: string; + } + >(); + for (const row of rows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (!agentType) continue; + result.set(row.role, { + role: row.role, + agentType, + agentConfig: row.agent_config_json + ? JSON.parse(row.agent_config_json) + : undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } + return result; +} + +function collectLegacyRoomRegistrationSnapshotForTests( + database: Database, + jid: string, + existingStored?: Pick< + StoredRoomSettings, + 'modeSource' | 'ownerAgentType' | 'trigger' + >, +): RoomRegistrationSnapshot | undefined { + const rows = database + .prepare( + `SELECT name, folder, trigger_pattern, requires_trigger, is_main, agent_type, work_dir + FROM registered_groups + WHERE jid = ? + ORDER BY agent_type`, + ) + .all(jid) as Array<{ + name: string; + folder: string; + trigger_pattern: string; + requires_trigger: number | null; + is_main: number | null; + agent_type: string | null; + work_dir: string | null; + }>; + if (rows.length === 0) return undefined; + + const first = rows[0]!; + const conflicts = new Set(); + for (const row of rows.slice(1)) { + if (row.name !== first.name) conflicts.add('name'); + if (row.folder !== first.folder) conflicts.add('folder'); + if ((row.requires_trigger ?? 1) !== (first.requires_trigger ?? 1)) { + conflicts.add('requires_trigger'); + } + if ((row.is_main ?? 0) !== (first.is_main ?? 0)) { + conflicts.add('is_main'); + } + if ((row.work_dir ?? null) !== (first.work_dir ?? null)) { + conflicts.add('work_dir'); + } + } + if (conflicts.size > 0) { + throw new Error( + `Conflicting room-level registered_groups metadata for ${jid}: ${[ + ...conflicts, + ].join(', ')}`, + ); + } + + const agentTypes = new Set<'claude-code' | 'codex'>(); + for (const row of rows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (agentType) { + agentTypes.add(agentType); + } + } + const inferredOwnerAgentType = inferOwnerAgentTypeFromRegisteredAgentTypes([ + ...agentTypes, + ]); + const preferExplicitTrigger = + existingStored?.modeSource === 'explicit' && existingStored.trigger; + const preferExplicitOwner = + existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType; + const preferredOwnerAgentType = preferExplicitOwner + ? existingStored.ownerAgentType + : undefined; + const preferredOwnerRow = preferredOwnerAgentType + ? rows.find( + (row) => + normalizeStoredAgentType(row.agent_type) === preferredOwnerAgentType, + ) + : undefined; + const inferredOwnerRow = + rows.find( + (row) => + normalizeStoredAgentType(row.agent_type) === inferredOwnerAgentType, + ) ?? rows[0]!; + const ownerAgentType = preferredOwnerAgentType ?? inferredOwnerAgentType; + const ownerRow = preferredOwnerRow ?? inferredOwnerRow; + + return { + name: first.name, + folder: first.folder, + triggerPattern: preferExplicitTrigger + ? existingStored.trigger! + : (preferredOwnerRow?.trigger_pattern ?? ownerRow.trigger_pattern), + requiresTrigger: (first.requires_trigger ?? 1) === 1, + isMain: (first.is_main ?? 0) === 1, + ownerAgentType, + workDir: first.work_dir ?? null, + }; +} + +function buildLegacyRoomMigrationPlanForTests( + database: Database, + jid: string, +): LegacyRoomMigrationPlanForTests | undefined { + const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid); + const snapshot = collectLegacyRoomRegistrationSnapshotForTests( + database, + jid, + existingStored, + ); + if (!snapshot) return undefined; + + const rows = database + .prepare( + `SELECT added_at + FROM registered_groups + WHERE jid = ? + ORDER BY added_at, agent_type`, + ) + .all(jid) as Array<{ added_at: string }>; + if (rows.length === 0) return undefined; + + const agentTypes = ( + database + .prepare( + `SELECT agent_type + FROM registered_groups + WHERE jid = ?`, + ) + .all(jid) as Array<{ agent_type: string | null }> + ) + .map((row) => normalizeStoredAgentType(row.agent_type)) + .filter((value): value is 'claude-code' | 'codex' => Boolean(value)); + const roomMode = + existingStored?.roomMode ?? + inferRoomModeFromRegisteredAgentTypes(agentTypes); + const createdAt = rows[0]!.added_at; + const updatedAt = rows[rows.length - 1]!.added_at; + const roleOverrides: RoomRoleOverrideSnapshot[] = []; + + const getCapabilityMetadata = ( + preferredAgentType?: 'claude-code' | 'codex', + ) => + (preferredAgentType + ? database + .prepare( + `SELECT added_at, agent_config + FROM registered_groups + WHERE jid = ? AND agent_type = ? + LIMIT 1`, + ) + .get(jid, preferredAgentType) + : database + .prepare( + `SELECT added_at, agent_config + FROM registered_groups + WHERE jid = ? + ORDER BY CASE WHEN agent_type = ? THEN 0 ELSE 1 END, added_at + LIMIT 1`, + ) + .get(jid, OWNER_AGENT_TYPE)) as + | { added_at: string; agent_config: string | null } + | undefined; + + const ownerMetadata = getCapabilityMetadata(snapshot.ownerAgentType); + roleOverrides.push({ + role: 'owner', + agentType: snapshot.ownerAgentType, + agentConfig: ownerMetadata?.agent_config + ? JSON.parse(ownerMetadata.agent_config) + : undefined, + createdAt: ownerMetadata?.added_at ?? createdAt, + updatedAt, + }); + + if (roomMode === 'tribunal') { + const reviewerAgentType = agentTypes.find( + (agentType) => agentType !== snapshot.ownerAgentType, + ); + if (!reviewerAgentType) { + throw new Error( + `Missing reviewer agent type for tribunal legacy room ${jid}`, + ); + } + const reviewerMetadata = getCapabilityMetadata(reviewerAgentType); + roleOverrides.push({ + role: 'reviewer', + agentType: reviewerAgentType, + agentConfig: reviewerMetadata?.agent_config + ? JSON.parse(reviewerMetadata.agent_config) + : undefined, + createdAt: reviewerMetadata?.added_at ?? createdAt, + updatedAt, + }); + } + + return { + chatJid: jid, + roomMode, + createdAt, + updatedAt, + snapshot, + roleOverrides, + }; +} + +function getPendingLegacyRegisteredGroupJidsForTests( + database: Database, +): string[] { + const rows = database + .prepare( + `SELECT DISTINCT jid + FROM registered_groups + ORDER BY jid`, + ) + .all() as Array<{ jid: string }>; + + return rows + .map((row) => row.jid) + .filter((jid) => { + const stored = getStoredRoomSettingsRowFromDatabase(database, jid); + if (!stored) { + return true; + } + + const plan = buildLegacyRoomMigrationPlanForTests(database, jid); + if (!plan) { + return false; + } + if ( + stored.name !== plan.snapshot.name || + stored.folder !== plan.snapshot.folder || + (stored.requiresTrigger ?? true) !== plan.snapshot.requiresTrigger || + (stored.isMain ?? false) !== plan.snapshot.isMain || + (stored.workDir ?? null) !== (plan.snapshot.workDir ?? null) + ) { + return true; + } + + const existingOverrides = getStoredRoleOverridesForLegacyMigration( + database, + jid, + ); + return plan.roleOverrides.some((override) => { + const actual = existingOverrides.get(override.role); + return ( + actual?.agentType !== override.agentType || + JSON.stringify(actual?.agentConfig ?? null) !== + JSON.stringify(override.agentConfig ?? null) + ); + }); + }); +} + function migrateLegacyRoomRegistrationsInFile(dbPath: string): { migratedRooms: number; migratedRoleOverrides: number; @@ -182,12 +502,12 @@ function migrateLegacyRoomRegistrationsInFile(dbPath: string): { try { initializeDatabaseSchema(migrationDb); migrationDb.transaction(() => { - const rows = getPendingLegacyRegisteredGroupJids(migrationDb).map( + const rows = getPendingLegacyRegisteredGroupJidsForTests(migrationDb).map( (jid) => ({ jid }), ); for (const row of rows) { - const plan = buildLegacyRoomMigrationPlan(migrationDb, row.jid); + const plan = buildLegacyRoomMigrationPlanForTests(migrationDb, row.jid); if (!plan) continue; const existing = migrationDb .prepare('SELECT 1 FROM room_settings WHERE chat_jid = ?') @@ -201,6 +521,7 @@ function migrateLegacyRoomRegistrationsInFile(dbPath: string): { migratedRoleOverrides += 1; } } + migrationDb.exec(`DROP TABLE IF EXISTS registered_groups`); })(); } finally { migrationDb.close(); @@ -2118,7 +2439,7 @@ describe('room assignment writes', () => { ).toBeUndefined(); }); - it('does not write legacy registered_groups rows when assigning a canonical room', () => { + it('does not create a legacy room projection table when assigning a canonical room', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-assign-canonical-'); const dbPath = path.join(tempDir, 'messages.db'); @@ -2131,16 +2452,17 @@ describe('room assignment writes', () => { }); const rawDb = new Database(dbPath, { readonly: true }); - const projectionRows = rawDb + const legacyTable = rawDb .prepare( - `SELECT agent_type - FROM registered_groups - WHERE jid = ?`, + `SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'registered_groups'`, ) - .all('dc:assign-no-projection'); + .get(); rawDb.close(); - expect(projectionRows).toEqual([]); + expect(legacyTable).toBeUndefined(); expect( getAllRoomBindings('claude-code')['dc:assign-no-projection'], ).toMatchObject({ @@ -2473,7 +2795,7 @@ describe('room assignment writes', () => { legacyDb.close(); const pendingDb = new Database(dbPath, { readonly: true }); - expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([ + expect(getPendingLegacyRegisteredGroupJidsForTests(pendingDb)).toEqual([ 'dc:mixed-conflict', ]); pendingDb.close(); @@ -2654,7 +2976,7 @@ describe('room assignment writes', () => { expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']); }); - it('clears stale legacy registered_groups rows after canonical room metadata changes', () => { + it('keeps canonical room metadata writable after explicit migration drops the legacy table', () => { const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-writeback-'); const dbPath = path.join(tempDir, 'messages.db'); const legacyDb = new Database(dbPath); @@ -2777,21 +3099,18 @@ describe('room assignment writes', () => { ownerAgentType: 'codex', }); - const rawDb = new Database(dbPath); - let projectionRows = rawDb + const rawDb = new Database(dbPath, { readonly: true }); + const legacyTable = rawDb .prepare( - `SELECT agent_type, name - FROM registered_groups - WHERE jid = ? - ORDER BY agent_type`, + `SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = 'registered_groups'`, ) - .all('dc:ssot-writeback') as Array<{ - agent_type: string | null; - name: string; - }>; + .get(); rawDb.close(); - expect(projectionRows).toEqual([]); + expect(legacyTable).toBeUndefined(); _initTestDatabaseFromFile(dbPath); @@ -2802,10 +3121,6 @@ describe('room assignment writes', () => { name: 'SSOT Writeback Renamed', ownerAgentType: 'codex', }); - const pendingDb = new Database(dbPath); - expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([]); - pendingDb.close(); - clearExplicitRoomMode('dc:ssot-writeback'); expect(getExplicitRoomMode('dc:ssot-writeback')).toBeUndefined(); @@ -3226,20 +3541,6 @@ describe('service handoff completion', () => { work_dir TEXT, updated_at TEXT NOT NULL ); - CREATE TABLE registered_groups ( - jid TEXT NOT NULL, - name TEXT NOT NULL, - folder TEXT NOT NULL, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - agent_config TEXT, - requires_trigger INTEGER DEFAULT 1, - is_main INTEGER DEFAULT 0, - agent_type TEXT NOT NULL DEFAULT 'claude-code', - work_dir TEXT, - PRIMARY KEY (jid, agent_type), - UNIQUE (folder, agent_type) - ); CREATE TABLE service_handoffs ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_jid TEXT NOT NULL, diff --git a/src/db.ts b/src/db.ts index fee1555..0eb352e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -142,7 +142,6 @@ import { getRegisteredAgentTypesForJidFromDatabase, getRegisteredGroupFromDatabase, getStoredRoomSettingsFromDatabase, - migrateLegacyRoomRegistrationsForTestsInDatabase, setExplicitRoomModeInDatabase, setRegisteredGroupForTestsInDatabase, setStoredRoomOwnerAgentTypeForTestsInDatabase, @@ -678,10 +677,7 @@ export function getRegisteredGroup( return getRegisteredGroupFromDatabase(db, jid, agentType); } -/** - * @internal Test/migration helper for seeding legacy capability rows. - * Runtime code must use assignRoom() so room_settings remains the explicit SSOT. - */ +/** @internal Test helper for seeding canonical room bindings. */ export function _setRegisteredGroupForTests( jid: string, group: RegisteredGroup, @@ -689,16 +685,6 @@ export function _setRegisteredGroupForTests( setRegisteredGroupForTestsInDatabase(db, jid, group); } -export function _migrateLegacyRoomRegistrationsForTests(): { - migratedRooms: number; - migratedRoleOverrides: number; -} { - if (!db) { - throw new Error('Database not initialized'); - } - return migrateLegacyRoomRegistrationsForTestsInDatabase(db); -} - export function assignRoom( chatJid: string, input: AssignRoomInput, diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 1090ce1..96c0b3d 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -97,20 +97,6 @@ export function applyBaseSchema(database: Database): void { session_id TEXT NOT NULL, PRIMARY KEY (group_folder, agent_type) ); - CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT NOT NULL, - name TEXT NOT NULL, - folder TEXT NOT NULL, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - agent_config TEXT, - requires_trigger INTEGER DEFAULT 1, - is_main INTEGER DEFAULT 0, - agent_type TEXT NOT NULL DEFAULT 'claude-code', - work_dir TEXT, - PRIMARY KEY (jid, agent_type), - UNIQUE (folder, agent_type) - ); CREATE TABLE IF NOT EXISTS paired_projects ( chat_jid TEXT PRIMARY KEY, group_folder TEXT NOT NULL, diff --git a/src/db/database-lifecycle.ts b/src/db/database-lifecycle.ts index 87ae522..d52f196 100644 --- a/src/db/database-lifecycle.ts +++ b/src/db/database-lifecycle.ts @@ -8,7 +8,6 @@ import { openPersistentDatabase, initializeDatabaseSchema, } from './bootstrap.js'; -import { countPendingLegacyRegisteredGroupRows } from './room-registration.js'; import { clearExpiredPairedTaskExecutionLeasesInDatabase, clearPairedTaskExecutionLeasesForServiceInDatabase, @@ -23,14 +22,47 @@ function finalizeDatabaseInitialization(database: Database): void { clearExpiredPairedTaskExecutionLeasesInDatabase(database); } +function getUnexpectedLegacyRoomBindingTables(database: Database): string[] { + const tableRows = database + .prepare( + `SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + ORDER BY name`, + ) + .all() as Array<{ name: string }>; + const matches: string[] = []; + + for (const row of tableRows) { + const columns = database + .prepare(`PRAGMA table_info("${row.name.replaceAll('"', '""')}")`) + .all() as Array<{ name: string }>; + const columnNames = new Set(columns.map((column) => column.name)); + const looksLikeLegacyRoomBindingTable = + columnNames.has('jid') && + columnNames.has('name') && + columnNames.has('folder') && + columnNames.has('trigger_pattern') && + columnNames.has('added_at'); + const isBackupTable = columnNames.has('backup_at'); + + if (looksLikeLegacyRoomBindingTable && !isBackupTable) { + matches.push(row.name); + } + } + + return matches; +} + function assertNoPendingLegacyRoomMigration(database: Database): void { - const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(database); - if (pendingLegacyRows === 0) { + const pendingTables = getUnexpectedLegacyRoomBindingTables(database); + if (pendingTables.length === 0) { return; } throw new Error( - `Legacy room migration required before startup (pending_rows=${pendingLegacyRows})`, + `Legacy room migration required before startup (tables=${pendingTables.join(',')})`, ); } diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index b3d0cae..12b8b04 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -30,19 +30,6 @@ export interface RoomRegistrationSnapshot { workDir: string | null; } -export interface RegisteredGroupDatabaseRow { - jid: string; - name: string; - folder: string; - trigger_pattern: string; - added_at: string; - agent_config: string | null; - requires_trigger: number | null; - is_main: number | null; - agent_type: string | null; - work_dir: string | null; -} - export interface RoomRoleOverrideSnapshot { role: 'owner' | 'reviewer' | 'arbiter'; agentType: AgentType; @@ -68,6 +55,14 @@ export interface LegacyRoomMigrationPlan { roleOverrides: RoomRoleOverrideSnapshot[]; } +export interface SyncRoomRoleOverridesOptions { + ownerAgentConfig?: RegisteredGroup['agentConfig']; + ownerCreatedAt?: string; + reviewerAgentConfig?: RegisteredGroup['agentConfig']; + reviewerCreatedAt?: string; + updatedAt?: string; +} + export function normalizeRoomModeSource( source: string | null | undefined, ): RoomModeSource | undefined { @@ -82,46 +77,6 @@ export function normalizeStoredAgentType( : undefined; } -/** - * Legacy capability-row helper for migration/write flows. - * Runtime read paths must derive capability types from canonical room tables. - */ -export function collectRegisteredAgentTypes( - database: Database, - jid: string, -): AgentType[] { - const rows = database - .prepare('SELECT agent_type FROM registered_groups WHERE jid = ?') - .all(jid) as Array<{ agent_type: string | null }>; - - const types = new Set(); - for (const row of rows) { - const agentType = normalizeStoredAgentType(row.agent_type); - if (agentType) { - types.add(agentType); - } - } - return [...types]; -} - -export function collectRegisteredAgentTypesForFolder( - database: Database, - folder: string, -): AgentType[] { - const rows = database - .prepare('SELECT agent_type FROM registered_groups WHERE folder = ?') - .all(folder) as Array<{ agent_type: string | null }>; - - const types = new Set(); - for (const row of rows) { - const agentType = normalizeStoredAgentType(row.agent_type); - if (agentType) { - types.add(agentType); - } - } - return [...types]; -} - export function inferRoomModeFromRegisteredAgentTypes( agentTypes: readonly AgentType[], ): RoomMode { @@ -138,76 +93,6 @@ export function inferOwnerAgentTypeFromRegisteredAgentTypes( return 'claude-code'; } -export function parseRegisteredGroupRow( - row: RegisteredGroupDatabaseRow | undefined, -): (RegisteredGroup & { jid: string }) | undefined { - if (!row) return undefined; - if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); - return undefined; - } - - return { - jid: row.jid, - name: row.name, - folder: row.folder, - added_at: row.added_at, - agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined, - isMain: row.is_main === 1 ? true : undefined, - agentType: normalizeStoredAgentType(row.agent_type), - workDir: row.work_dir || undefined, - }; -} - -export function getPendingLegacyRegisteredGroupJids( - database: Database, -): string[] { - const rows = database - .prepare( - `SELECT DISTINCT jid - FROM registered_groups - ORDER BY jid`, - ) - .all() as Array<{ jid: string }>; - - return rows - .map((row) => row.jid) - .filter((jid) => jidRequiresLegacyRoomMigration(database, jid)); -} - -export function countPendingLegacyRegisteredGroupRows( - database: Database, -): number { - let count = 0; - const countRows = database.prepare( - `SELECT COUNT(*) AS count - FROM registered_groups - WHERE jid = ?`, - ); - - for (const jid of getPendingLegacyRegisteredGroupJids(database)) { - const row = countRows.get(jid) as { count: number }; - count += row.count; - } - - return count; -} - -export function deleteLegacyRegisteredGroupRowsForJid( - database: Database, - jid: string, -): void { - database - .prepare( - `DELETE FROM registered_groups - WHERE jid = ?`, - ) - .run(jid); -} - function getStoredRoomRoleOverrideRows( database: Database, jid: string, @@ -257,193 +142,6 @@ function getStoredRoomRoleOverrideRows( return result; } -function normalizeAgentConfigValue( - agentConfig: RegisteredGroup['agentConfig'] | undefined, -): string { - return JSON.stringify(agentConfig ?? null); -} - -function roomRoleOverrideMatches( - actual: StoredRoomRoleOverrideRow | undefined, - expected: RoomRoleOverrideSnapshot, -): boolean { - return ( - actual?.agentType === expected.agentType && - normalizeAgentConfigValue(actual.agentConfig) === - normalizeAgentConfigValue(expected.agentConfig) - ); -} - -function storedRoomSettingsMatchesLegacySnapshot( - stored: StoredRoomSettings, - snapshot: RoomRegistrationSnapshot, -): boolean { - return ( - stored.name === snapshot.name && - stored.folder === snapshot.folder && - (stored.requiresTrigger ?? true) === snapshot.requiresTrigger && - (stored.isMain ?? false) === snapshot.isMain && - (stored.workDir ?? null) === (snapshot.workDir ?? null) - ); -} - -function getRegisteredGroupCapabilityMetadata( - database: Database, - jid: string, - preferredAgentType?: AgentType, -): Pick | undefined { - const row = ( - preferredAgentType - ? database - .prepare( - `SELECT added_at, agent_config - FROM registered_groups - WHERE jid = ? AND agent_type = ? - LIMIT 1`, - ) - .get(jid, preferredAgentType) - : database - .prepare( - `SELECT added_at, agent_config - FROM registered_groups - WHERE jid = ? - ORDER BY CASE WHEN agent_type = ? THEN 0 ELSE 1 END, added_at - LIMIT 1`, - ) - .get(jid, OWNER_AGENT_TYPE) - ) as { added_at: string; agent_config: string | null } | undefined; - - if (!row) return undefined; - return { - added_at: row.added_at, - agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined, - }; -} - -function resolveReviewerAgentTypeFromRegisteredAgentTypes( - agentTypes: readonly AgentType[], - ownerAgentType: AgentType, -): AgentType | undefined { - return agentTypes.find((agentType) => agentType !== ownerAgentType); -} - -export function buildLegacyRoomMigrationPlan( - database: Database, - jid: string, -): LegacyRoomMigrationPlan | undefined { - const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid); - const snapshot = collectRoomRegistrationSnapshot( - database, - jid, - existingStored, - ); - if (!snapshot) return undefined; - - const rows = database - .prepare( - `SELECT added_at - FROM registered_groups - WHERE jid = ? - ORDER BY added_at, agent_type`, - ) - .all(jid) as Array<{ added_at: string }>; - if (rows.length === 0) return undefined; - - const agentTypes = collectRegisteredAgentTypes(database, jid); - const roomMode = - existingStored?.roomMode ?? - inferRoomModeFromRegisteredAgentTypes(agentTypes); - const createdAt = rows[0].added_at; - const updatedAt = rows[rows.length - 1].added_at; - const roleOverrides: RoomRoleOverrideSnapshot[] = []; - - const ownerMetadata = getRegisteredGroupCapabilityMetadata( - database, - jid, - snapshot.ownerAgentType, - ); - roleOverrides.push({ - role: 'owner', - agentType: snapshot.ownerAgentType, - agentConfig: ownerMetadata?.agentConfig, - createdAt: ownerMetadata?.added_at ?? createdAt, - updatedAt, - }); - - if (roomMode === 'tribunal') { - const reviewerAgentType = resolveReviewerAgentTypeFromRegisteredAgentTypes( - agentTypes, - snapshot.ownerAgentType, - ); - if (!reviewerAgentType) { - throw new Error( - `Missing reviewer agent type for tribunal legacy room ${jid}`, - ); - } - const reviewerMetadata = getRegisteredGroupCapabilityMetadata( - database, - jid, - reviewerAgentType, - ); - roleOverrides.push({ - role: 'reviewer', - agentType: reviewerAgentType, - agentConfig: reviewerMetadata?.agentConfig, - createdAt: reviewerMetadata?.added_at ?? createdAt, - updatedAt, - }); - } - - return { - chatJid: jid, - roomMode, - createdAt, - updatedAt, - snapshot, - roleOverrides, - }; -} - -function jidRequiresLegacyRoomMigration( - database: Database, - jid: string, -): boolean { - let stored: StoredRoomSettings | undefined; - try { - stored = getStoredRoomSettingsRowFromDatabase(database, jid); - } catch { - return true; - } - if (!stored) { - return true; - } - - let plan: LegacyRoomMigrationPlan | undefined; - try { - plan = buildLegacyRoomMigrationPlan(database, jid); - } catch { - return true; - } - if (!plan) { - return false; - } - - if (!storedRoomSettingsMatchesLegacySnapshot(stored, plan.snapshot)) { - return true; - } - - const existingOverrides = getStoredRoomRoleOverrideRows(database, jid); - for (const override of plan.roleOverrides) { - if ( - !roomRoleOverrideMatches(existingOverrides.get(override.role), override) - ) { - return true; - } - } - - return false; -} - export function getStoredRoomSettingsRowFromDatabase( database: Database, chatJid: string, @@ -621,18 +319,9 @@ function collectReservedFolders( `SELECT folder FROM room_settings WHERE folder IS NOT NULL - AND (? IS NULL OR chat_jid != ?) - UNION - SELECT folder - FROM registered_groups - WHERE ? IS NULL OR jid != ?`, + AND (? IS NULL OR chat_jid != ?)`, ) - .all( - exceptChatJid ?? null, - exceptChatJid ?? null, - exceptChatJid ?? null, - exceptChatJid ?? null, - ) as Array<{ + .all(exceptChatJid ?? null, exceptChatJid ?? null) as Array<{ folder: string | null; }>; @@ -698,200 +387,6 @@ export function resolveAssignedRoomFolder( return buildGeneratedRoomFolder(database, chatJid, name); } -function getDesiredRegisteredAgentTypes( - roomMode: RoomMode, - ownerAgentType: AgentType, -): AgentType[] { - const types = new Set([ownerAgentType]); - if (roomMode === 'tribunal') { - types.add(REVIEWER_AGENT_TYPE); - } - return [...types]; -} - -export function materializeRegisteredGroupsForRoom( - database: Database, - chatJid: string, - snapshot: RoomRegistrationSnapshot, - roomMode: RoomMode, - ownerAgentType: AgentType, - ownerAgentConfig?: RegisteredGroup['agentConfig'], - addedAt?: string, -): void { - const now = new Date().toISOString(); - const desiredTypes = getDesiredRegisteredAgentTypes(roomMode, ownerAgentType); - const existingRows = database - .prepare( - `SELECT agent_type, added_at, agent_config - FROM registered_groups - WHERE jid = ?`, - ) - .all(chatJid) as Array<{ - agent_type: string | null; - added_at: string; - agent_config: string | null; - }>; - const existingByType = new Map< - AgentType, - { added_at: string; agent_config: string | null } - >(); - for (const row of existingRows) { - const agentType = normalizeStoredAgentType(row.agent_type); - if (agentType) { - existingByType.set(agentType, row); - } - } - - for (const agentType of desiredTypes) { - const existing = existingByType.get(agentType); - const agentConfig = - agentType === ownerAgentType - ? (ownerAgentConfig ?? - (existing?.agent_config - ? JSON.parse(existing.agent_config) - : undefined)) - : existing?.agent_config - ? JSON.parse(existing.agent_config) - : undefined; - const rowAddedAt = existing?.added_at ?? addedAt ?? now; - - database - .prepare( - `INSERT OR REPLACE INTO registered_groups ( - jid, - name, - folder, - trigger_pattern, - added_at, - agent_config, - requires_trigger, - is_main, - agent_type, - work_dir - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - chatJid, - snapshot.name, - snapshot.folder, - snapshot.triggerPattern, - rowAddedAt, - agentConfig ? JSON.stringify(agentConfig) : null, - snapshot.requiresTrigger ? 1 : 0, - snapshot.isMain ? 1 : 0, - agentType, - snapshot.workDir, - ); - } - - const placeholders = desiredTypes.map(() => '?').join(','); - database - .prepare( - `DELETE FROM registered_groups - WHERE jid = ? - AND agent_type NOT IN (${placeholders})`, - ) - .run(chatJid, ...desiredTypes); -} - -/** - * Legacy snapshot helper for migration/write flows. - * Runtime read paths must not rebuild room metadata from registered_groups. - */ -export function collectRoomRegistrationSnapshot( - database: Database, - jid: string, - existingStored?: Pick< - StoredRoomSettings, - 'modeSource' | 'ownerAgentType' | 'trigger' - >, -): RoomRegistrationSnapshot | undefined { - const rows = database - .prepare( - `SELECT name, folder, trigger_pattern, requires_trigger, is_main, agent_type, work_dir - FROM registered_groups - WHERE jid = ? - ORDER BY agent_type`, - ) - .all(jid) as Array<{ - name: string; - folder: string; - trigger_pattern: string; - requires_trigger: number | null; - is_main: number | null; - agent_type: string | null; - work_dir: string | null; - }>; - - if (rows.length === 0) return undefined; - - const first = rows[0]; - const conflicts = new Set(); - for (const row of rows.slice(1)) { - if (row.name !== first.name) conflicts.add('name'); - if (row.folder !== first.folder) conflicts.add('folder'); - if ((row.requires_trigger ?? 1) !== (first.requires_trigger ?? 1)) { - conflicts.add('requires_trigger'); - } - if ((row.is_main ?? 0) !== (first.is_main ?? 0)) { - conflicts.add('is_main'); - } - if ((row.work_dir ?? null) !== (first.work_dir ?? null)) { - conflicts.add('work_dir'); - } - } - - if (conflicts.size > 0) { - throw new Error( - `Conflicting room-level registered_groups metadata for ${jid}: ${[ - ...conflicts, - ].join(', ')}`, - ); - } - - const agentTypes = collectRegisteredAgentTypes(database, jid); - const inferredOwnerAgentType = - inferOwnerAgentTypeFromRegisteredAgentTypes(agentTypes); - const preferExplicitTrigger = - existingStored?.modeSource === 'explicit' && existingStored.trigger; - const preferExplicitOwner = - existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType; - const preferredOwnerAgentType = preferExplicitOwner - ? existingStored.ownerAgentType - : undefined; - const preferredOwnerRow = preferredOwnerAgentType - ? rows.find( - (row) => - normalizeStoredAgentType(row.agent_type) === preferredOwnerAgentType, - ) - : undefined; - const inferredOwnerRow = - rows.find( - (row) => - normalizeStoredAgentType(row.agent_type) === inferredOwnerAgentType, - ) ?? rows[0]; - const ownerAgentType = preferredOwnerAgentType - ? preferredOwnerRow - ? preferredOwnerAgentType - : preferredOwnerAgentType - : inferredOwnerAgentType; - const ownerRow = preferredOwnerRow ?? inferredOwnerRow; - - return { - name: first.name, - folder: first.folder, - triggerPattern: preferExplicitTrigger - ? existingStored.trigger! - : preferredOwnerRow != null - ? preferredOwnerRow.trigger_pattern - : ownerRow.trigger_pattern, - requiresTrigger: (first.requires_trigger ?? 1) === 1, - isMain: (first.is_main ?? 0) === 1, - ownerAgentType, - workDir: first.work_dir ?? null, - }; -} - export function insertStoredRoomSettings( database: Database, chatJid: string, @@ -1006,31 +501,24 @@ export function syncRoomRoleOverridesForRoom( chatJid: string, roomMode: RoomMode, ownerAgentType: AgentType, - ownerAgentConfig?: RegisteredGroup['agentConfig'], - addedAt?: string, + options: SyncRoomRoleOverridesOptions = {}, ): void { - const now = addedAt ?? new Date().toISOString(); + const now = options.updatedAt ?? new Date().toISOString(); const existingOverrides = getStoredRoomRoleOverrideRows(database, chatJid); const existingOwnerOverride = existingOverrides.get('owner'); - const ownerMetadata = getRegisteredGroupCapabilityMetadata( - database, - chatJid, - ownerAgentType, - ); upsertRoomRoleOverride(database, chatJid, { role: 'owner', agentType: ownerAgentType, agentConfig: - ownerAgentConfig ?? + options.ownerAgentConfig ?? (existingOwnerOverride?.agentType === ownerAgentType ? existingOwnerOverride.agentConfig - : undefined) ?? - ownerMetadata?.agentConfig, + : undefined), createdAt: (existingOwnerOverride?.agentType === ownerAgentType ? existingOwnerOverride.createdAt : undefined) ?? - ownerMetadata?.added_at ?? + options.ownerCreatedAt ?? now, updatedAt: now, }); @@ -1040,24 +528,20 @@ export function syncRoomRoleOverridesForRoom( ownerAgentType === REVIEWER_AGENT_TYPE ? OWNER_AGENT_TYPE : REVIEWER_AGENT_TYPE; - const reviewerMetadata = getRegisteredGroupCapabilityMetadata( - database, - chatJid, - reviewerAgentType, - ); const existingReviewerOverride = existingOverrides.get('reviewer'); upsertRoomRoleOverride(database, chatJid, { role: 'reviewer', agentType: reviewerAgentType, agentConfig: + options.reviewerAgentConfig ?? (existingReviewerOverride?.agentType === reviewerAgentType ? existingReviewerOverride.agentConfig - : undefined) ?? reviewerMetadata?.agentConfig, + : undefined), createdAt: (existingReviewerOverride?.agentType === reviewerAgentType ? existingReviewerOverride.createdAt : undefined) ?? - reviewerMetadata?.added_at ?? + options.reviewerCreatedAt ?? now, updatedAt: now, }); diff --git a/src/db/rooms.ts b/src/db/rooms.ts index 57ca5f0..1f069ec 100644 --- a/src/db/rooms.ts +++ b/src/db/rooms.ts @@ -7,20 +7,13 @@ import { type RoomModeSource, type RoomRegistrationSnapshot, type StoredRoomSettings, - buildLegacyRoomMigrationPlan, buildRegisteredGroupFromStoredSettings, - collectRegisteredAgentTypes, - countPendingLegacyRegisteredGroupRows, - deleteLegacyRegisteredGroupRowsForJid, - getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase, getStoredRoomRowsFromDatabase, getStoredRoomSettingsRowFromDatabase, inferOwnerAgentTypeFromRegisteredAgentTypes, inferRoomModeFromRegisteredAgentTypes, inferStoredRoomCapabilityTypes, insertStoredRoomSettings, - insertStoredRoomSettingsFromMigration, - materializeRegisteredGroupsForRoom, normalizeStoredAgentType, resolveAssignedRoomFolder, resolveStoredRoomCapabilityTypes, @@ -94,13 +87,12 @@ export function getRegisteredGroupFromDatabase( ); } -function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase( +function seedRoomBindingForTestsInDatabase( database: Database, jid: string, group: RegisteredGroup, ): void { const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid); - const existingRoomMode = getStoredRoomModeRowFromDatabase(database, jid); if (!isValidGroupFolder(group.folder)) { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } @@ -108,15 +100,17 @@ function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase( database.transaction(() => { const seededAgentType = group.agentType || 'claude-code'; const agentTypes = new Set( - collectRegisteredAgentTypes(database, jid), + existingStored + ? resolveStoredRoomCapabilityTypes(database, existingStored) + : [], ); agentTypes.add(seededAgentType); const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([ ...agentTypes, ]); const roomMode = - existingRoomMode?.source === 'explicit' - ? existingRoomMode.roomMode + existingStored?.modeSource === 'explicit' + ? existingStored.roomMode : inferredRoomMode; const ownerAgentType = existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType @@ -138,30 +132,28 @@ function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase( if (existingStored) { updateStoredRoomMetadata(database, jid, snapshot); - if (!existingRoomMode || existingRoomMode.source === 'inferred') { + if (existingStored.modeSource === 'inferred') { upsertStoredRoomModeInDatabase(database, jid, roomMode, 'inferred'); } } else { insertStoredRoomSettings(database, jid, roomMode, 'inferred', snapshot); } - materializeRegisteredGroupsForRoom( - database, - jid, - snapshot, - roomMode, - ownerAgentType, - seededAgentType === ownerAgentType ? group.agentConfig : undefined, - group.added_at, - ); - syncRoomRoleOverridesForRoom( - database, - jid, - roomMode, - ownerAgentType, - seededAgentType === ownerAgentType ? group.agentConfig : undefined, - group.added_at, - ); + syncRoomRoleOverridesForRoom(database, jid, roomMode, ownerAgentType, { + ownerAgentConfig: + seededAgentType === ownerAgentType ? group.agentConfig : undefined, + ownerCreatedAt: + seededAgentType === ownerAgentType ? group.added_at : undefined, + reviewerAgentConfig: + roomMode === 'tribunal' && seededAgentType !== ownerAgentType + ? group.agentConfig + : undefined, + reviewerCreatedAt: + roomMode === 'tribunal' && seededAgentType !== ownerAgentType + ? group.added_at + : undefined, + updatedAt: group.added_at, + }); })(); } @@ -170,33 +162,7 @@ export function setRegisteredGroupForTestsInDatabase( jid: string, group: RegisteredGroup, ): void { - writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(database, jid, group); -} - -export function migrateLegacyRoomRegistrationsForTestsInDatabase( - database: Database, -): { - migratedRooms: number; - migratedRoleOverrides: number; -} { - const jids = getPendingLegacyRegisteredGroupJidsFromDatabase(database); - let migratedRooms = 0; - let migratedRoleOverrides = 0; - - database.transaction(() => { - for (const jid of jids) { - const plan = buildLegacyRoomMigrationPlan(database, jid); - if (!plan) continue; - insertStoredRoomSettingsFromMigration(database, plan); - for (const override of plan.roleOverrides) { - upsertRoomRoleOverride(database, jid, override); - migratedRoleOverrides += 1; - } - migratedRooms += 1; - } - })(); - - return { migratedRooms, migratedRoleOverrides }; + seedRoomBindingForTestsInDatabase(database, jid, group); } export function assignRoomInDatabase( @@ -264,15 +230,11 @@ export function assignRoomInDatabase( ); } - syncRoomRoleOverridesForRoom( - database, - chatJid, - roomMode, - ownerAgentType, - input.ownerAgentConfig, - input.addedAt ?? now, - ); - deleteLegacyRegisteredGroupRowsForJid(database, chatJid); + syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, { + ownerAgentConfig: input.ownerAgentConfig, + ownerCreatedAt: input.addedAt ?? now, + updatedAt: input.addedAt ?? now, + }); })(); return getRegisteredGroupFromDatabase(database, chatJid); @@ -283,25 +245,16 @@ export function updateRegisteredGroupNameInDatabase( jid: string, name: string, ): void { - const plan = buildRoomRegistrationPlanForJid(database, jid, { name }); - if (!plan) { + const stored = getStoredRoomSettingsRowFromDatabase(database, jid); + if (!stored) { return; } - database.transaction(() => { - if (plan.hasStoredRoom) { - updateStoredRoomMetadata(database, jid, plan.snapshot); - } else { - insertStoredRoomSettings( - database, - jid, - plan.roomMode, - 'inferred', - plan.snapshot, - ); - } - deleteLegacyRegisteredGroupRowsForJid(database, jid); - })(); + updateStoredRoomMetadata( + database, + jid, + buildRoomRegistrationSnapshotFromStoredRoom(database, stored, { name }), + ); } export function getAllRoomBindingsFromDatabase( @@ -383,33 +336,6 @@ function buildRoomRegistrationSnapshotFromStoredRoom( }; } -function buildRoomRegistrationPlanForJid( - database: Database, - chatJid: string, - overrides?: Partial>, -): - | { - snapshot: RoomRegistrationSnapshot; - roomMode: RoomMode; - hasStoredRoom: boolean; - } - | undefined { - const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid); - if (!stored) { - return undefined; - } - - return { - snapshot: buildRoomRegistrationSnapshotFromStoredRoom( - database, - stored, - overrides, - ), - roomMode: stored.roomMode, - hasStoredRoom: true, - }; -} - function upsertStoredRoomModeInDatabase( database: Database, chatJid: string, @@ -446,7 +372,6 @@ export function setExplicitRoomModeInDatabase( roomMode: RoomMode, ): void { upsertStoredRoomModeInDatabase(database, chatJid, roomMode, 'explicit'); - deleteLegacyRegisteredGroupRowsForJid(database, chatJid); } export function clearExplicitRoomModeInDatabase( @@ -454,14 +379,18 @@ export function clearExplicitRoomModeInDatabase( chatJid: string, ): void { const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid); - const agentTypes = stored - ? inferStoredRoomCapabilityTypes(database, stored) - : collectRegisteredAgentTypes(database, chatJid); + if (!stored) { + return; + } + + const agentTypes = inferStoredRoomCapabilityTypes(database, stored); if (agentTypes.length === 0) { database .prepare('DELETE FROM room_settings WHERE chat_jid = ?') .run(chatJid); - deleteLegacyRegisteredGroupRowsForJid(database, chatJid); + database + .prepare('DELETE FROM room_role_overrides WHERE chat_jid = ?') + .run(chatJid); return; } @@ -471,7 +400,6 @@ export function clearExplicitRoomModeInDatabase( inferRoomModeFromRegisteredAgentTypes(agentTypes), 'inferred', ); - deleteLegacyRegisteredGroupRowsForJid(database, chatJid); } export function getEffectiveRoomModeFromDatabase( @@ -491,9 +419,3 @@ export function getEffectiveRuntimeRoomModeFromDatabase( getStoredRoomSettingsFromDatabase(database, chatJid)?.roomMode ?? 'single' ); } - -export function countPendingLegacyRegisteredGroupRowsInDatabase( - database: Database, -): number { - return countPendingLegacyRegisteredGroupRows(database); -} diff --git a/src/db/schema.ts b/src/db/schema.ts index fbbc203..6034278 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -2937,29 +2937,6 @@ export function applySchemaMigrations( WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`, ); - database.exec(` - UPDATE scheduled_tasks - SET agent_type = COALESCE( - ( - SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END - FROM registered_groups - WHERE jid = scheduled_tasks.chat_jid - AND folder = scheduled_tasks.group_folder - ), - ( - SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END - FROM registered_groups - WHERE jid = scheduled_tasks.chat_jid - ), - ( - SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END - FROM registered_groups - WHERE folder = scheduled_tasks.group_folder - ) - ) - WHERE agent_type IS NULL; - `); - try { database.exec( `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, @@ -2989,114 +2966,6 @@ export function applySchemaMigrations( WHERE status IN ('produced', 'delivery_retry'); `); - const roomBindingsSql = ( - database - .prepare( - `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`, - ) - .get() as { sql?: string } | undefined - )?.sql; - if ( - roomBindingsSql && - !roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)') - ) { - const registeredGroupCols = database - .prepare('PRAGMA table_info(registered_groups)') - .all() as Array<{ name: string }>; - const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main'); - const hasAgentType = registeredGroupCols.some( - (col) => col.name === 'agent_type', - ); - const hasWorkDir = registeredGroupCols.some( - (col) => col.name === 'work_dir', - ); - const hasAgentConfig = registeredGroupCols.some( - (col) => col.name === 'agent_config', - ); - const hasContainerConfig = registeredGroupCols.some( - (col) => col.name === 'container_config', - ); - - database.exec(` - CREATE TABLE registered_groups_new ( - jid TEXT NOT NULL, - name TEXT NOT NULL, - folder TEXT NOT NULL, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - agent_config TEXT, - requires_trigger INTEGER DEFAULT 1, - is_main INTEGER DEFAULT 0, - agent_type TEXT NOT NULL DEFAULT 'claude-code', - work_dir TEXT, - PRIMARY KEY (jid, agent_type), - UNIQUE (folder, agent_type) - ); - `); - - database.exec(` - INSERT INTO registered_groups_new ( - jid, - name, - folder, - trigger_pattern, - added_at, - agent_config, - requires_trigger, - is_main, - agent_type, - work_dir - ) - SELECT - jid, - name, - folder, - trigger_pattern, - added_at, - ${ - hasAgentConfig - ? 'agent_config' - : hasContainerConfig - ? 'container_config' - : 'NULL' - }, - requires_trigger, - ${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"}, - ${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"}, - ${hasWorkDir ? 'work_dir' : 'NULL'} - FROM registered_groups; - `); - - database.exec(` - DROP TABLE registered_groups; - ALTER TABLE registered_groups_new RENAME TO registered_groups; - `); - } else { - database.exec( - `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`, - ); - } - - const registeredGroupCols = database - .prepare('PRAGMA table_info(registered_groups)') - .all() as Array<{ name: string }>; - const hasAgentConfig = registeredGroupCols.some( - (col) => col.name === 'agent_config', - ); - const hasContainerConfig = registeredGroupCols.some( - (col) => col.name === 'container_config', - ); - if (!hasAgentConfig) { - database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`); - } - if (hasContainerConfig) { - database.exec( - `UPDATE registered_groups - SET agent_config = COALESCE(agent_config, container_config) - WHERE container_config IS NOT NULL`, - ); - } - const pairedTasksSqlRow = database .prepare( `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,