diff --git a/src/db.test.ts b/src/db.test.ts index fdceaf6..2e4b54e 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + _deleteStoredRoomSettingsForTests, _setMemoryTimestampsForTests, _setRegisteredGroupForTests, assignRoom, @@ -30,6 +31,7 @@ import { getLatestPairedTaskForChat, getLatestTurnNumber, getLastRespondingAgentType, + getRegisteredGroup, getMessagesSinceSeq, getNewMessagesBySeq, getOpenWorkItem, @@ -1658,6 +1660,326 @@ describe('room assignment writes', () => { }, ); }); + + it('recreates inferred room_settings when renaming a legacy projection-only room', () => { + _setRegisteredGroupForTests('dc:legacy-rename', { + name: 'Legacy Rename', + folder: 'legacy-rename', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + _setRegisteredGroupForTests('dc:legacy-rename', { + name: 'Legacy Rename', + folder: 'legacy-rename', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + _deleteStoredRoomSettingsForTests('dc:legacy-rename'); + expect(getStoredRoomSettings('dc:legacy-rename')).toBeUndefined(); + + updateRegisteredGroupName('dc:legacy-rename', 'Legacy Rename Updated'); + + expect(getStoredRoomSettings('dc:legacy-rename')).toMatchObject({ + chatJid: 'dc:legacy-rename', + roomMode: 'tribunal', + modeSource: 'inferred', + name: 'Legacy Rename Updated', + folder: 'legacy-rename', + }); + expect( + getAllRegisteredGroups('claude-code')['dc:legacy-rename'], + ).toMatchObject({ + name: 'Legacy Rename Updated', + agentType: 'claude-code', + }); + expect(getAllRegisteredGroups('codex')['dc:legacy-rename']).toMatchObject({ + name: 'Legacy Rename Updated', + agentType: 'codex', + }); + }); + + it('ignores stale registered_groups capability rows once room_settings exists', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-ssot-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL DEFAULT 'single', + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER, + is_main INTEGER, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT + ); + + 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, + is_main INTEGER, + agent_type TEXT, + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + legacyDb + .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( + 'dc:ssot-room', + 'single', + 'explicit', + 'SSOT Room', + 'ssot-room', + '@Andy', + 1, + 0, + 'codex', + null, + '2026-04-08T00:00:00.000Z', + ); + + legacyDb + .prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'dc:ssot-room', + 'Stale Projection', + 'ssot-room', + '@Codex', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'codex', + null, + ); + + legacyDb + .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( + 'dc:ssot-room', + 'Stale Projection', + 'ssot-room', + '@Claude', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'claude-code', + null, + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + expect(getRegisteredGroup('dc:ssot-room')).toMatchObject({ + folder: 'ssot-room', + agentType: 'codex', + }); + expect(getRegisteredGroup('dc:ssot-room', 'claude-code')).toBeUndefined(); + expect(getAllRegisteredGroups('claude-code')['dc:ssot-room']).toBeUndefined(); + expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']); + }); + + it('re-materializes explicit room_settings writes back into the projection rows', () => { + const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-writeback-'); + const dbPath = path.join(tempDir, 'messages.db'); + const legacyDb = new Database(dbPath); + + legacyDb.exec(` + CREATE TABLE room_settings ( + chat_jid TEXT PRIMARY KEY, + room_mode TEXT NOT NULL DEFAULT 'single', + mode_source TEXT NOT NULL DEFAULT 'explicit', + name TEXT, + folder TEXT, + trigger_pattern TEXT, + requires_trigger INTEGER, + is_main INTEGER, + owner_agent_type TEXT, + work_dir TEXT, + updated_at TEXT + ); + + 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, + is_main INTEGER, + agent_type TEXT, + work_dir TEXT, + PRIMARY KEY (jid, agent_type), + UNIQUE (folder, agent_type) + ); + `); + + legacyDb + .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( + 'dc:ssot-writeback', + 'single', + 'explicit', + 'Explicit Writeback', + 'ssot-writeback', + '@Codex', + 1, + 0, + 'codex', + null, + '2026-04-08T00:00:00.000Z', + ); + + const insertProjection = legacyDb.prepare( + `INSERT INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + + insertProjection.run( + 'dc:ssot-writeback', + 'Stale Projection', + 'ssot-writeback', + '@Codex', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'codex', + null, + ); + insertProjection.run( + 'dc:ssot-writeback', + 'Stale Projection', + 'ssot-writeback', + '@Claude', + '2026-04-08T00:00:00.000Z', + null, + 1, + 0, + 'claude-code', + null, + ); + legacyDb.close(); + + _initTestDatabaseFromFile(dbPath); + + updateRegisteredGroupName('dc:ssot-writeback', 'SSOT Writeback Renamed'); + + expect(getStoredRoomSettings('dc:ssot-writeback')).toMatchObject({ + chatJid: 'dc:ssot-writeback', + roomMode: 'single', + modeSource: 'explicit', + name: 'SSOT Writeback Renamed', + ownerAgentType: 'codex', + }); + + const rawDb = new Database(dbPath); + const projectionRows = rawDb + .prepare( + `SELECT agent_type, name + FROM registered_groups + WHERE jid = ? + ORDER BY agent_type`, + ) + .all('dc:ssot-writeback') as Array<{ + agent_type: string | null; + name: string; + }>; + rawDb.close(); + + expect(projectionRows).toEqual([ + { + agent_type: 'codex', + name: 'SSOT Writeback Renamed', + }, + ]); + + clearExplicitRoomMode('dc:ssot-writeback'); + + expect(getExplicitRoomMode('dc:ssot-writeback')).toBeUndefined(); + expect(getEffectiveRoomMode('dc:ssot-writeback')).toBe('single'); + }); }); describe('paired room registration', () => { @@ -1938,6 +2260,36 @@ describe('paired room registration', () => { expect(getEffectiveRuntimeRoomMode('dc:explicit-single')).toBe('single'); }); + it('restores inferred paired mode when clearing an explicit single override', () => { + _setRegisteredGroupForTests('dc:explicit-single-clear', { + name: 'Explicit Single Clear', + folder: 'explicit-single-clear', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'claude-code', + }); + _setRegisteredGroupForTests('dc:explicit-single-clear', { + name: 'Explicit Single Clear', + folder: 'explicit-single-clear', + trigger: '@Codex', + added_at: '2024-01-01T00:00:00.000Z', + agentType: 'codex', + }); + + setExplicitRoomMode('dc:explicit-single-clear', 'single'); + + expect(getExplicitRoomMode('dc:explicit-single-clear')).toBe('single'); + expect(getEffectiveRoomMode('dc:explicit-single-clear')).toBe('single'); + + clearExplicitRoomMode('dc:explicit-single-clear'); + + expect(getExplicitRoomMode('dc:explicit-single-clear')).toBeUndefined(); + expect(getEffectiveRoomMode('dc:explicit-single-clear')).toBe('tribunal'); + expect(getEffectiveRuntimeRoomMode('dc:explicit-single-clear')).toBe( + 'tribunal', + ); + }); + it('lets explicit tribunal become runnable when the configured reviewer can run on the solo registration', () => { _setRegisteredGroupForTests('dc:explicit-tribunal', { name: 'Explicit Tribunal Claude', diff --git a/src/db.ts b/src/db.ts index 646c0a7..8114fd6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -39,6 +39,7 @@ import { normalizeRoomModeSource, normalizeStoredAgentType, parseRegisteredGroupRow, + resolveStoredRoomCapabilityTypes, resolveAssignedRoomFolder, updateStoredRoomMetadata, } from './db/room-registration.js'; @@ -313,6 +314,14 @@ export function _setStoredRoomOwnerAgentTypeForTests( ).run(ownerAgentType, new Date().toISOString(), chatJid); } +/** @internal - for tests only. */ +export function _deleteStoredRoomSettingsForTests(chatJid: string): void { + if (!db) { + throw new Error('Database not initialized'); + } + db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); +} + /** @internal - for tests only. */ export function _setMemoryTimestampsForTests( id: number, @@ -749,12 +758,7 @@ export function getRegisteredGroup( const requestedAgentType = normalizeStoredAgentType(agentType); const stored = getStoredRoomSettingsRowFromDatabase(db, jid); if (stored) { - const group = buildRegisteredGroupFromStoredSettings( - db, - stored, - requestedAgentType, - ); - if (group) return group; + return buildRegisteredGroupFromStoredSettings(db, stored, requestedAgentType); } return getLegacyRegisteredGroup(db, jid, agentType); } @@ -763,31 +767,58 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings( jid: string, group: RegisteredGroup, ): void { + const existingStored = getStoredRoomSettingsRowFromDatabase(db, jid); const existingRoomMode = getStoredRoomModeRow(jid); if (!isValidGroupFolder(group.folder)) { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } 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, - ); + const seededAgentType = group.agentType || 'claude-code'; + const agentTypes = new Set(collectRegisteredAgentTypes(db, jid)); + agentTypes.add(seededAgentType); + const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([ + ...agentTypes, + ]); + const roomMode = + existingRoomMode?.source === 'explicit' + ? existingRoomMode.roomMode + : inferredRoomMode; + const ownerAgentType = + existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType + ? existingStored.ownerAgentType + : inferOwnerAgentTypeFromRegisteredAgentTypes([...agentTypes]); + const snapshot: RoomRegistrationSnapshot = { + name: group.name, + folder: group.folder, + triggerPattern: + existingStored?.modeSource === 'explicit' && existingStored.trigger + ? existingStored.trigger + : group.trigger, + requiresTrigger: + group.requiresTrigger ?? existingStored?.requiresTrigger ?? true, + isMain: group.isMain ?? existingStored?.isMain ?? false, + ownerAgentType, + workDir: group.workDir ?? existingStored?.workDir ?? null, + }; - syncStoredRoomRegistrationSnapshotForJid(jid); - if (!existingRoomMode || existingRoomMode.source === 'inferred') { - syncInferredRoomModeForJid(jid); + if (existingStored) { + updateStoredRoomMetadata(db, jid, snapshot); + if (!existingRoomMode || existingRoomMode.source === 'inferred') { + upsertStoredRoomMode(jid, roomMode, 'inferred'); + } + } else { + insertStoredRoomSettings(db, jid, roomMode, 'inferred', snapshot); } + + materializeRegisteredGroupsForRoom( + db, + jid, + snapshot, + roomMode, + ownerAgentType, + seededAgentType === ownerAgentType ? group.agentConfig : undefined, + group.added_at, + ); }); tx(); } @@ -874,18 +905,24 @@ export function assignRoom( } export function updateRegisteredGroupName(jid: string, name: string): void { - db.prepare( - `UPDATE room_settings - SET name = ?, updated_at = ? - WHERE chat_jid = ?`, - ).run(name, new Date().toISOString(), jid); - db.prepare('UPDATE registered_groups SET name = ? WHERE jid = ?').run( - name, - jid, - ); - if (!getStoredRoomSettingsRowFromDatabase(db, jid)) { - syncStoredRoomRegistrationSnapshotForJid(jid); + const plan = buildRoomRegistrationPlanForJid(jid, { name }); + if (!plan) { + return; } + db.transaction(() => { + if (plan.hasStoredRoom) { + updateStoredRoomMetadata(db, jid, plan.snapshot); + } else { + insertStoredRoomSettings(db, jid, plan.roomMode, 'inferred', plan.snapshot); + } + materializeRegisteredGroupsForRoom( + db, + jid, + plan.snapshot, + plan.roomMode, + plan.snapshot.ownerAgentType, + ); + })(); } export function getAllRegisteredGroups( @@ -921,6 +958,10 @@ export function getAllRegisteredGroups( export function getRegisteredAgentTypesForJid(jid: string): AgentType[] { if (!db) return []; + const stored = getStoredRoomSettingsRowFromDatabase(db, jid); + if (stored) { + return resolveStoredRoomCapabilityTypes(db, stored); + } return collectRegisteredAgentTypes(db, jid); } @@ -977,6 +1018,63 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void { updateStoredRoomMetadata(db, chatJid, snapshot); } +function buildRoomRegistrationSnapshotFromStoredRoom( + stored: StoredRoomSettings, + overrides?: Partial>, +): RoomRegistrationSnapshot { + const capabilityTypes = resolveStoredRoomCapabilityTypes(db, stored); + const ownerAgentType = + stored.ownerAgentType || + (capabilityTypes.length > 0 + ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) + : OWNER_AGENT_TYPE); + const name = overrides?.name ?? stored.name ?? stored.chatJid; + + return { + name, + folder: resolveAssignedRoomFolder(db, stored.chatJid, name, stored.folder), + triggerPattern: stored.trigger || `@${ASSISTANT_NAME}`, + requiresTrigger: stored.requiresTrigger ?? true, + isMain: stored.isMain ?? false, + ownerAgentType, + workDir: stored.workDir ?? null, + }; +} + +function buildRoomRegistrationPlanForJid( + chatJid: string, + overrides?: Partial>, +): + | { + snapshot: RoomRegistrationSnapshot; + roomMode: RoomMode; + hasStoredRoom: boolean; + } + | undefined { + const stored = getStoredRoomSettingsRowFromDatabase(db, chatJid); + if (stored) { + return { + snapshot: buildRoomRegistrationSnapshotFromStoredRoom(stored, overrides), + roomMode: stored.roomMode, + hasStoredRoom: true, + }; + } + + const snapshot = collectRoomRegistrationSnapshot(db, chatJid); + if (!snapshot) return undefined; + + return { + snapshot: { + ...snapshot, + name: overrides?.name ?? snapshot.name, + }, + roomMode: inferRoomModeFromRegisteredAgentTypes( + collectRegisteredAgentTypes(db, chatJid), + ), + hasStoredRoom: false, + }; +} + function upsertStoredRoomMode( chatJid: string, roomMode: RoomMode, @@ -1010,7 +1108,7 @@ export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void { } export function clearExplicitRoomMode(chatJid: string): void { - const agentTypes = getRegisteredAgentTypesForJid(chatJid); + const agentTypes = collectRegisteredAgentTypes(db, chatJid); if (agentTypes.length === 0) { db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); return; diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index d762600..342ac08 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -147,9 +147,28 @@ export function getLegacyRegisteredGroupRows( return ( agentTypeFilter ? database - .prepare('SELECT * FROM registered_groups WHERE agent_type = ?') + .prepare( + `SELECT * + FROM registered_groups + WHERE agent_type = ? + AND NOT EXISTS ( + SELECT 1 + FROM room_settings + WHERE chat_jid = registered_groups.jid + )`, + ) .all(agentTypeFilter) - : database.prepare('SELECT * FROM registered_groups').all() + : database + .prepare( + `SELECT * + FROM registered_groups + WHERE NOT EXISTS ( + SELECT 1 + FROM room_settings + WHERE chat_jid = registered_groups.jid + )`, + ) + .all() ) as RegisteredGroupDatabaseRow[]; } @@ -158,6 +177,10 @@ export function getLegacyRegisteredGroup( jid: string, agentType?: string, ): (RegisteredGroup & { jid: string }) | undefined { + if (getStoredRoomSettingsRowFromDatabase(database, jid)) { + return undefined; + } + const row = ( agentType ? database @@ -276,7 +299,7 @@ export function buildRegisteredGroupFromStoredSettings( stored: StoredRoomSettings, requestedAgentType?: AgentType, ): (RegisteredGroup & { jid: string }) | undefined { - const capabilityTypes = collectRegisteredAgentTypes(database, stored.chatJid); + const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored); const resolvedAgentType = requestedAgentType ? capabilityTypes.includes(requestedAgentType) ? requestedAgentType @@ -315,6 +338,27 @@ export function buildRegisteredGroupFromStoredSettings( }; } +export function resolveStoredRoomCapabilityTypes( + database: Database, + stored: StoredRoomSettings, +): AgentType[] { + const projectedTypes = collectRegisteredAgentTypes(database, stored.chatJid); + if (stored.modeSource !== 'explicit') { + return projectedTypes; + } + + const ownerAgentType = + stored.ownerAgentType || + (projectedTypes.length > 0 + ? inferOwnerAgentTypeFromRegisteredAgentTypes(projectedTypes) + : OWNER_AGENT_TYPE); + const types = new Set([ownerAgentType]); + if (stored.roomMode === 'tribunal') { + types.add(REVIEWER_AGENT_TYPE); + } + return [...types]; +} + function detectChannelPrefixForFolder(chatJid: string): string { if (chatJid.startsWith('dc:')) return 'discord'; if (chatJid.startsWith('tg:')) return 'telegram';