diff --git a/src/db.test.ts b/src/db.test.ts index ccc42b9..bf2de8e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -36,6 +36,7 @@ import { getPairedWorkspace, getRouterStateForService, getSession, + getStoredRoomSettings, getTaskById, listPairedWorkspacesForTask, markWorkItemDelivered, @@ -46,6 +47,7 @@ import { setExplicitRoomMode, storeChatMetadata, storeMessage, + updateRegisteredGroupName, updatePairedTask, upsertPairedProject, upsertPairedWorkspace, @@ -748,16 +750,16 @@ describe('registered group isMain', () => { it('filters duplicate jid registrations by agent type', () => { setRegisteredGroup('dc:shared', { - name: 'Shared Room Claude', + name: 'Shared Room', folder: 'shared-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); setRegisteredGroup('dc:shared', { - name: 'Shared Room Codex', + name: 'Shared Room', folder: 'shared-room', - trigger: '@Andy', + trigger: '@Codex', added_at: '2024-01-01T00:00:00.000Z', agentType: 'codex', }); @@ -766,24 +768,24 @@ describe('registered group isMain', () => { const codexGroups = getAllRegisteredGroups('codex'); expect(claudeGroups['dc:shared']?.agentType).toBe('claude-code'); - expect(claudeGroups['dc:shared']?.name).toBe('Shared Room Claude'); + expect(claudeGroups['dc:shared']?.name).toBe('Shared Room'); expect(codexGroups['dc:shared']?.agentType).toBe('codex'); - expect(codexGroups['dc:shared']?.name).toBe('Shared Room Codex'); + expect(codexGroups['dc:shared']?.name).toBe('Shared Room'); }); }); describe('paired room registration', () => { it('detects when both Claude and Codex are registered on the same jid', () => { setRegisteredGroup('dc:123', { - name: 'Paired Room Claude', - folder: 'paired-claude', + name: 'Paired Room', + folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); setRegisteredGroup('dc:123', { - name: 'Paired Room Codex', - folder: 'paired-codex', + name: 'Paired Room', + folder: 'paired-room', trigger: '@Codex', added_at: '2024-01-01T00:00:00.000Z', agentType: 'codex', @@ -813,15 +815,15 @@ describe('paired room registration', () => { it('keeps inferred room mode available when no explicit override exists', () => { setRegisteredGroup('dc:legacy-paired', { - name: 'Legacy Paired Claude', - folder: 'legacy-paired-claude', + name: 'Legacy Paired', + folder: 'legacy-paired', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); setRegisteredGroup('dc:legacy-paired', { - name: 'Legacy Paired Codex', - folder: 'legacy-paired-codex', + name: 'Legacy Paired', + folder: 'legacy-paired', trigger: '@Codex', added_at: '2024-01-01T00:00:00.000Z', agentType: 'codex', @@ -876,16 +878,16 @@ describe('paired room registration', () => { ); insertGroup.run( 'dc:legacy-sql', - 'Legacy SQL Claude', - 'legacy-sql-claude', - '@Andy', + 'Legacy SQL Room', + 'legacy-sql-room', + '@Claude', '2024-01-01T00:00:00.000Z', 'claude-code', ); insertGroup.run( 'dc:legacy-sql', - 'Legacy SQL Codex', - 'legacy-sql-codex', + 'Legacy SQL Room', + 'legacy-sql-room', '@Codex', '2024-01-01T00:00:00.000Z', 'codex', @@ -897,19 +899,147 @@ describe('paired room registration', () => { expect(getExplicitRoomMode('dc:legacy-sql')).toBeUndefined(); expect(getEffectiveRoomMode('dc:legacy-sql')).toBe('tribunal'); expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal'); + + expect(getStoredRoomSettings('dc:legacy-sql')).toMatchObject({ + chatJid: 'dc:legacy-sql', + roomMode: 'tribunal', + modeSource: 'inferred', + name: 'Legacy SQL Room', + folder: 'legacy-sql-room', + trigger: '@Codex', + requiresTrigger: true, + isMain: false, + ownerAgentType: 'codex', + }); + }); + + it('keeps room-level metadata synced on legacy register_group writes', () => { + setRegisteredGroup('dc:room-settings', { + name: 'Room Settings Test', + folder: 'room-settings-test', + trigger: '@Claude', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + setRegisteredGroup('dc:room-settings', { + name: 'Room Settings Test', + folder: 'room-settings-test', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + expect(getStoredRoomSettings('dc:room-settings')).toMatchObject({ + chatJid: 'dc:room-settings', + roomMode: 'tribunal', + modeSource: 'inferred', + name: 'Room Settings Test', + folder: 'room-settings-test', + trigger: '@Codex', + ownerAgentType: 'codex', + }); + + setExplicitRoomMode('dc:room-settings', 'single'); + + expect(getStoredRoomSettings('dc:room-settings')).toMatchObject({ + chatJid: 'dc:room-settings', + roomMode: 'single', + modeSource: 'explicit', + name: 'Room Settings Test', + folder: 'room-settings-test', + trigger: '@Codex', + ownerAgentType: 'codex', + }); + + updateRegisteredGroupName('dc:room-settings', 'Room Settings Renamed'); + + expect(getStoredRoomSettings('dc:room-settings')).toMatchObject({ + chatJid: 'dc:room-settings', + roomMode: 'single', + modeSource: 'explicit', + name: 'Room Settings Renamed', + folder: 'room-settings-test', + trigger: '@Codex', + ownerAgentType: 'codex', + }); + }); + + it('fails room_settings backfill when room-level metadata conflicts across agent rows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-settings-conflict-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + 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 room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK (room_mode IN ('single', 'tribunal')) + ); + `); + + const insertGroup = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, NULL, 1, 0, ?, NULL)`, + ); + insertGroup.run( + 'dc:conflict', + 'Conflict Room', + 'conflict-folder-1', + '@Claude', + '2024-01-01T00:00:00.000Z', + 'claude-code', + ); + insertGroup.run( + 'dc:conflict', + 'Conflict Room', + 'conflict-folder-2', + '@Codex', + '2024-01-01T00:00:00.000Z', + 'codex', + ); + legacyDb.close(); + + expect(() => _initTestDatabaseFromFile(dbPath)).toThrow( + /Conflicting room-level registered_groups metadata/, + ); }); it('lets explicit single override dual registration for paired-room checks', () => { setRegisteredGroup('dc:explicit-single', { - name: 'Explicit Single Claude', - folder: 'explicit-single-claude', + name: 'Explicit Single', + folder: 'explicit-single', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); setRegisteredGroup('dc:explicit-single', { - name: 'Explicit Single Codex', - folder: 'explicit-single-codex', + name: 'Explicit Single', + folder: 'explicit-single', trigger: '@Codex', added_at: '2024-01-01T00:00:00.000Z', agentType: 'codex', diff --git a/src/db.ts b/src/db.ts index d389576..706d50e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -9,6 +9,7 @@ import { CODEX_REVIEW_SERVICE_ID, DATA_DIR, normalizeServiceId, + OWNER_AGENT_TYPE, REVIEWER_AGENT_TYPE, SERVICE_ID, SERVICE_SESSION_SCOPE, @@ -45,6 +46,29 @@ interface StoredRoomModeRow { source: RoomModeSource; } +export interface StoredRoomSettings { + chatJid: string; + roomMode: RoomMode; + modeSource: RoomModeSource; + name?: string; + folder?: string; + trigger?: string; + requiresTrigger?: boolean; + isMain?: boolean; + ownerAgentType?: AgentType; + workDir?: string; +} + +interface RoomRegistrationSnapshot { + name: string; + folder: string; + triggerPattern: string; + requiresTrigger: boolean; + isMain: boolean; + ownerAgentType: AgentType; + workDir: string | null; +} + export interface WorkItem { id: number; group_folder: string; @@ -310,8 +334,16 @@ function createSchema(database: Database): void { chat_jid TEXT PRIMARY KEY, room_mode TEXT NOT NULL, mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER DEFAULT 1, + is_main INTEGER DEFAULT 0, + owner_agent_type TEXT, + work_dir TEXT, updated_at TEXT NOT NULL, - CHECK (room_mode IN ('single', 'tribunal')) + CHECK (room_mode IN ('single', 'tribunal')), + CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL) ); CREATE TABLE IF NOT EXISTS service_handoffs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -402,6 +434,52 @@ function createSchema(database: Database): void { /* column already exists */ } + try { + database.exec(`ALTER TABLE room_settings ADD COLUMN name TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE room_settings ADD COLUMN folder TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec( + `ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`, + ); + } catch { + /* column already exists */ + } + + try { + database.exec( + `ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`, + ); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`); + } catch { + /* column already exists */ + } + + try { + database.exec(`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`); + } catch { + /* column already exists */ + } + database.exec( `UPDATE room_settings SET mode_source = 'explicit' @@ -807,7 +885,7 @@ function createSchema(database: Database): void { /* columns already exist */ } - backfillStoredRoomModes(database); + backfillStoredRoomSettings(database); } export function initDatabase(): void { @@ -835,6 +913,22 @@ export function _initTestDatabaseFromFile(dbPath: string): void { createSchema(db); } +/** @internal - for tests only. */ +export function _setStoredRoomOwnerAgentTypeForTests( + chatJid: string, + ownerAgentType: AgentType | null, +): void { + if (!db) { + throw new Error('Database not initialized'); + } + db.prepare( + `UPDATE room_settings + SET owner_agent_type = ?, + updated_at = ? + WHERE chat_jid = ?`, + ).run(ownerAgentType, new Date().toISOString(), chatJid); +} + /** * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. @@ -1859,25 +1953,29 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { if (!isValidGroupFolder(group.folder)) { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } - db.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( - jid, - group.name, - group.folder, - group.trigger, - group.added_at, - group.agentConfig ? JSON.stringify(group.agentConfig) : null, - group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, - group.isMain ? 1 : 0, - group.agentType || 'claude-code', - group.workDir || null, - ); + const tx = db.transaction(() => { + db.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( + jid, + group.name, + group.folder, + group.trigger, + group.added_at, + group.agentConfig ? JSON.stringify(group.agentConfig) : null, + group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, + group.isMain ? 1 : 0, + group.agentType || 'claude-code', + group.workDir || null, + ); - if (!existingRoomMode || existingRoomMode.source === 'inferred') { - syncInferredRoomModeForJid(jid); - } + syncStoredRoomRegistrationSnapshotForJid(jid); + if (!existingRoomMode || existingRoomMode.source === 'inferred') { + syncInferredRoomModeForJid(jid); + } + }); + tx(); } export function updateRegisteredGroupName(jid: string, name: string): void { @@ -1885,6 +1983,7 @@ export function updateRegisteredGroupName(jid: string, name: string): void { name, jid, ); + syncStoredRoomRegistrationSnapshotForJid(jid); } export function getAllRegisteredGroups( @@ -1974,6 +2073,15 @@ export function inferRoomModeFromRegisteredAgentTypes( return types.has('claude-code') && types.has('codex') ? 'tribunal' : 'single'; } +function inferOwnerAgentTypeFromRegisteredAgentTypes( + agentTypes: readonly AgentType[], +): AgentType { + const types = new Set(agentTypes); + if (types.has(OWNER_AGENT_TYPE)) return OWNER_AGENT_TYPE; + if (types.has('codex')) return 'codex'; + return 'claude-code'; +} + /** * Internal registration/backfill helper. * This infers the stored room mode from current registrations and must not be @@ -1991,24 +2099,228 @@ function normalizeRoomModeSource( return source === 'explicit' || source === 'inferred' ? source : undefined; } -function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined { - if (!db) return undefined; +function normalizeStoredAgentType( + agentType: string | null | undefined, +): AgentType | undefined { + return agentType === 'claude-code' || agentType === 'codex' + ? agentType + : undefined; +} - const row = db +function getStoredRoomSettingsRowFromDatabase( + database: Database, + chatJid: string, +): StoredRoomSettings | undefined { + const row = database .prepare( - `SELECT room_mode, mode_source + `SELECT room_mode, mode_source, name, folder, trigger_pattern, + requires_trigger, is_main, owner_agent_type, work_dir FROM room_settings WHERE chat_jid = ?`, ) .get(chatJid) as - | { room_mode: string | null; mode_source: string | null } + | { + room_mode: string | null; + mode_source: string | null; + name: string | null; + folder: string | null; + trigger_pattern: string | null; + requires_trigger: number | null; + is_main: number | null; + owner_agent_type: string | null; + work_dir: string | null; + } | undefined; const roomMode = row?.room_mode === 'single' || row?.room_mode === 'tribunal' ? row.room_mode : undefined; const source = normalizeRoomModeSource(row?.mode_source); - return roomMode && source ? { roomMode, source } : undefined; + if (!row || !roomMode || !source) return undefined; + + return { + chatJid, + roomMode, + modeSource: source, + name: row.name ?? undefined, + folder: row.folder ?? undefined, + trigger: row.trigger_pattern ?? undefined, + requiresTrigger: + row.requires_trigger === null ? undefined : row.requires_trigger === 1, + isMain: row.is_main === null ? undefined : row.is_main === 1, + ownerAgentType: normalizeStoredAgentType(row.owner_agent_type), + workDir: row.work_dir ?? undefined, + }; +} + +export function getStoredRoomSettings( + chatJid: string, +): StoredRoomSettings | undefined { + if (!db) return undefined; + return getStoredRoomSettingsRowFromDatabase(db, chatJid); +} + +function getStoredRoomModeRow(chatJid: string): StoredRoomModeRow | undefined { + const row = getStoredRoomSettings(chatJid); + return row + ? { + roomMode: row.roomMode, + source: row.modeSource, + } + : undefined; +} + +function collectRoomRegistrationSnapshot( + database: Database, + jid: string, +): 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 ownerAgentType = inferOwnerAgentTypeFromRegisteredAgentTypes(agentTypes); + const ownerRow = + rows.find( + (row) => normalizeStoredAgentType(row.agent_type) === ownerAgentType, + ) ?? rows[0]; + + return { + name: first.name, + folder: first.folder, + triggerPattern: ownerRow.trigger_pattern, + requiresTrigger: (first.requires_trigger ?? 1) === 1, + isMain: (first.is_main ?? 0) === 1, + ownerAgentType, + workDir: first.work_dir ?? null, + }; +} + +function insertStoredRoomSettings( + database: Database, + chatJid: string, + roomMode: RoomMode, + source: RoomModeSource, + snapshot: RoomRegistrationSnapshot, +): void { + database + .prepare( + `INSERT INTO room_settings ( + chat_jid, + room_mode, + mode_source, + name, + folder, + trigger_pattern, + requires_trigger, + is_main, + owner_agent_type, + work_dir, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + chatJid, + roomMode, + source, + snapshot.name, + snapshot.folder, + snapshot.triggerPattern, + snapshot.requiresTrigger ? 1 : 0, + snapshot.isMain ? 1 : 0, + snapshot.ownerAgentType, + snapshot.workDir, + new Date().toISOString(), + ); +} + +function updateStoredRoomMetadata( + database: Database, + chatJid: string, + snapshot: RoomRegistrationSnapshot, +): void { + database + .prepare( + `UPDATE room_settings + SET name = ?, + folder = ?, + trigger_pattern = ?, + requires_trigger = ?, + is_main = ?, + owner_agent_type = ?, + work_dir = ?, + updated_at = ? + WHERE chat_jid = ?`, + ) + .run( + snapshot.name, + snapshot.folder, + snapshot.triggerPattern, + snapshot.requiresTrigger ? 1 : 0, + snapshot.isMain ? 1 : 0, + snapshot.ownerAgentType, + snapshot.workDir, + new Date().toISOString(), + chatJid, + ); +} + +function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void { + const snapshot = collectRoomRegistrationSnapshot(db, chatJid); + if (!snapshot) return; + + const existing = getStoredRoomModeRow(chatJid); + if (!existing) { + insertStoredRoomSettings( + db, + chatJid, + inferRoomModeFromRegisteredAgentTypes(getRegisteredAgentTypesForJid(chatJid)), + 'inferred', + snapshot, + ); + return; + } + + updateStoredRoomMetadata(db, chatJid, snapshot); } function upsertStoredRoomMode( @@ -2017,12 +2329,16 @@ function upsertStoredRoomMode( source: RoomModeSource, ): void { db.prepare( - `INSERT OR REPLACE INTO room_settings ( + `INSERT INTO room_settings ( chat_jid, room_mode, mode_source, updated_at - ) VALUES (?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?) + ON CONFLICT(chat_jid) DO UPDATE SET + room_mode = excluded.room_mode, + mode_source = excluded.mode_source, + updated_at = excluded.updated_at`, ).run(chatJid, roomMode, source, new Date().toISOString()); } @@ -2075,36 +2391,34 @@ export function getEffectiveRuntimeRoomMode(chatJid: string): RoomMode { : 'single'; } -function backfillStoredRoomModes(database: Database): void { +function backfillStoredRoomSettings(database: Database): void { const rows = database - .prepare( - `SELECT DISTINCT rg.jid - FROM registered_groups rg - LEFT JOIN room_settings rs ON rs.chat_jid = rg.jid - WHERE rs.chat_jid IS NULL`, - ) + .prepare(`SELECT DISTINCT jid FROM registered_groups`) .all() as Array<{ jid: string }>; if (rows.length === 0) return; - const insertRoomMode = database.prepare( - `INSERT OR REPLACE INTO room_settings ( - chat_jid, - room_mode, - mode_source, - updated_at - ) VALUES (?, ?, 'inferred', ?)`, - ); + const tx = database.transaction((registeredRows: Array<{ jid: string }>) => { + for (const row of registeredRows) { + const snapshot = collectRoomRegistrationSnapshot(database, row.jid); + if (!snapshot) continue; - const tx = database.transaction((missingRows: Array<{ jid: string }>) => { - const updatedAt = new Date().toISOString(); - for (const row of missingRows) { - const roomMode = inferRoomModeFromRegisteredAgentTypes( - collectRegisteredAgentTypes(database, row.jid), + const existing = getStoredRoomSettingsRowFromDatabase(database, row.jid); + if (existing) { + updateStoredRoomMetadata(database, row.jid, snapshot); + continue; + } + + insertStoredRoomSettings( + database, + row.jid, + inferRoomModeFromRegisteredAgentTypes( + collectRegisteredAgentTypes(database, row.jid), + ), + 'inferred', + snapshot, ); - insertRoomMode.run(row.jid, roomMode, updatedAt); } }); - tx(rows); } diff --git a/src/service-routing.test.ts b/src/service-routing.test.ts index cd41ca8..0e6e766 100644 --- a/src/service-routing.test.ts +++ b/src/service-routing.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { + _setStoredRoomOwnerAgentTypeForTests, _initTestDatabase, setExplicitRoomMode, setRegisteredGroup, @@ -22,15 +23,15 @@ beforeEach(() => { describe('service-routing global failover', () => { it('uses codex-review as owner during global failover', () => { setRegisteredGroup('dc:paired', { - name: 'Paired Room Claude', - folder: 'paired-claude', + name: 'Paired Room', + folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); setRegisteredGroup('dc:paired', { - name: 'Paired Room Codex', - folder: 'paired-codex', + name: 'Paired Room', + folder: 'paired-room', trigger: '@Codex', added_at: '2024-01-01T00:00:00.000Z', agentType: 'codex', @@ -57,15 +58,15 @@ describe('service-routing global failover', () => { it('restores default lease after global failover is cleared', () => { setRegisteredGroup('dc:paired', { - name: 'Paired Room Claude', - folder: 'paired-claude', + name: 'Paired Room', + folder: 'paired-room', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); setRegisteredGroup('dc:paired', { - name: 'Paired Room Codex', - folder: 'paired-codex', + name: 'Paired Room', + folder: 'paired-room', trigger: '@Codex', added_at: '2024-01-01T00:00:00.000Z', agentType: 'codex', @@ -85,15 +86,15 @@ describe('service-routing global failover', () => { it('uses explicit single room mode to suppress reviewer lease on dual registration', () => { setRegisteredGroup('dc:explicit-single', { - name: 'Explicit Single Claude', - folder: 'explicit-single-claude', + name: 'Explicit Single', + folder: 'explicit-single', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', agentType: 'claude-code', }); setRegisteredGroup('dc:explicit-single', { - name: 'Explicit Single Codex', - folder: 'explicit-single-codex', + name: 'Explicit Single', + folder: 'explicit-single', trigger: '@Codex', added_at: '2024-01-01T00:00:00.000Z', agentType: 'codex', @@ -108,6 +109,53 @@ describe('service-routing global failover', () => { }); }); + it('uses stored owner agent type when room_settings selects a different owner', () => { + setRegisteredGroup('dc:stored-owner-claude', { + name: 'Stored Owner Claude', + folder: 'stored-owner-claude', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + setRegisteredGroup('dc:stored-owner-claude', { + name: 'Stored Owner Claude', + folder: 'stored-owner-claude', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + setExplicitRoomMode('dc:stored-owner-claude', 'single'); + _setStoredRoomOwnerAgentTypeForTests( + 'dc:stored-owner-claude', + 'claude-code', + ); + + expect(getEffectiveChannelLease('dc:stored-owner-claude')).toMatchObject({ + chat_jid: 'dc:stored-owner-claude', + owner_service_id: 'claude', + reviewer_service_id: null, + explicit: false, + }); + }); + + it('falls back to the available service when stored owner agent type is unavailable', () => { + setRegisteredGroup('dc:stored-owner-fallback', { + name: 'Stored Owner Fallback', + folder: 'stored-owner-fallback', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + _setStoredRoomOwnerAgentTypeForTests('dc:stored-owner-fallback', 'codex'); + + expect(getEffectiveChannelLease('dc:stored-owner-fallback')).toMatchObject({ + chat_jid: 'dc:stored-owner-fallback', + owner_service_id: 'claude', + reviewer_service_id: null, + explicit: false, + }); + }); + it('creates a same-service reviewer lease when explicit tribunal is runnable on a solo registration', () => { setRegisteredGroup('dc:explicit-tribunal', { name: 'Explicit Tribunal Claude', @@ -145,4 +193,13 @@ describe('service-routing global failover', () => { explicit: false, }); }); + + it('keeps the legacy claude fallback for chats without room settings', () => { + expect(getEffectiveChannelLease('dc:unregistered')).toMatchObject({ + chat_jid: 'dc:unregistered', + owner_service_id: 'claude', + reviewer_service_id: null, + explicit: false, + }); + }); }); diff --git a/src/service-routing.ts b/src/service-routing.ts index d1b8790..9194228 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -14,10 +14,12 @@ import { getAllChannelOwnerLeases, getEffectiveRuntimeRoomMode, getRegisteredAgentTypesForJid, + getStoredRoomSettings, setChannelOwnerLease, type ChannelOwnerLeaseRow, } from './db.js'; import { logger } from './logger.js'; +import type { AgentType } from './types.js'; export interface EffectiveChannelLease { chat_jid: string; @@ -55,21 +57,44 @@ function normalizeLeaseRow( }; } -function getDefaultLease(chatJid: string): EffectiveChannelLease { +function getServiceIdForAgentType(agentType: AgentType): string { + return agentType === 'codex' ? CODEX_MAIN_SERVICE_ID : CLAUDE_SERVICE_ID; +} + +function inferFallbackOwnerAgentType( + hasClaude: boolean, + hasCodex: boolean, +): AgentType | undefined { + if (hasClaude && hasCodex) return OWNER_AGENT_TYPE; + if (hasCodex) return 'codex'; + if (hasClaude) return 'claude-code'; + return undefined; +} + +function resolveDefaultOwnerAgentType(chatJid: string): AgentType | undefined { const types = getRegisteredAgentTypesForJid(chatJid); const hasClaude = types.includes('claude-code'); const hasCodex = types.includes('codex'); + const storedOwnerAgentType = getStoredRoomSettings(chatJid)?.ownerAgentType; + + if (storedOwnerAgentType === 'claude-code' && hasClaude) { + return 'claude-code'; + } + if (storedOwnerAgentType === 'codex' && hasCodex) { + return 'codex'; + } + + return inferFallbackOwnerAgentType(hasClaude, hasCodex); +} + +function getDefaultLease(chatJid: string): EffectiveChannelLease { const roomMode = getEffectiveRuntimeRoomMode(chatJid); + const ownerAgentType = resolveDefaultOwnerAgentType(chatJid); + const ownerServiceId = ownerAgentType + ? getServiceIdForAgentType(ownerAgentType) + : CLAUDE_SERVICE_ID; if (roomMode === 'tribunal') { - const ownerServiceId = - hasClaude && hasCodex - ? OWNER_AGENT_TYPE === 'codex' - ? CODEX_MAIN_SERVICE_ID - : CLAUDE_SERVICE_ID - : hasCodex - ? CODEX_MAIN_SERVICE_ID - : CLAUDE_SERVICE_ID; return { chat_jid: chatJid, owner_service_id: ownerServiceId, @@ -81,33 +106,9 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease { }; } - if (hasCodex) { - return { - chat_jid: chatJid, - owner_service_id: CODEX_MAIN_SERVICE_ID, - reviewer_service_id: null, - arbiter_service_id: null, - activated_at: null, - reason: null, - explicit: false, - }; - } - - if (hasClaude) { - return { - chat_jid: chatJid, - owner_service_id: CLAUDE_SERVICE_ID, - reviewer_service_id: null, - arbiter_service_id: null, - activated_at: null, - reason: null, - explicit: false, - }; - } - return { chat_jid: chatJid, - owner_service_id: CLAUDE_SERVICE_ID, + owner_service_id: ownerServiceId, reviewer_service_id: null, arbiter_service_id: null, activated_at: null,