From 3930c216254eb7af0c737e5df243a991e97a1326 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:06:46 +0900 Subject: [PATCH] fix(rooms): keep rooms routable when mode_source is corrupt A room_settings row with a valid room_mode but an unrecognized mode_source (e.g. the stray 'room' value that disabled the cgv-macro channel) was silently dropped by getStoredRoomSettingsRowFromDatabase. That removed the room from every binding lookup, so the router ignored the channel, it vanished from the status list, and re-registration wedged on the UNIQUE(chat_jid) constraint because assignRoom's "existing" probe uses the same loader. Coerce an invalid mode_source to 'explicit' (preserving the stored room_mode) and log a warning so the corruption stays visible and self-heals on the next assignRoom, instead of taking the channel silently offline. room_mode is already protected by a column CHECK; mode_source was not. Co-Authored-By: Claude Opus 4.7 --- src/db/room-registration.test.ts | 79 ++++++++++++++++++++++++++++++++ src/db/room-registration.ts | 19 +++++++- 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/db/room-registration.test.ts diff --git a/src/db/room-registration.test.ts b/src/db/room-registration.test.ts new file mode 100644 index 0000000..ae97bb8 --- /dev/null +++ b/src/db/room-registration.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { openInitializedInMemoryDatabase } from './database-lifecycle.js'; +import { + getStoredRoomRowsFromDatabase, + getStoredRoomSettingsRowFromDatabase, + insertStoredRoomSettings, +} from './room-registration.js'; + +// Regression guard for the cgv-macro incident: a room_settings row that carried +// an invalid mode_source ('room') was silently dropped from every binding +// lookup, so the router ignored the channel, it vanished from the status list, +// and re-registration wedged on the UNIQUE(chat_jid) constraint. The loader must +// keep such a room routable by coercing the bad source to a valid one. +describe('getStoredRoomSettingsRowFromDatabase mode_source resilience', () => { + const jid = 'dc:invalid-source-room'; + const snapshot = { + name: 'cgv-macro', + folder: 'cgv-macro', + triggerPattern: '', + requiresTrigger: false, + isMain: false, + ownerAgentType: 'claude-code' as const, + workDir: null, + }; + + function seedWithSource(source: string) { + const db = openInitializedInMemoryDatabase(); + insertStoredRoomSettings(db, jid, 'single', 'explicit', snapshot); + db.prepare('UPDATE room_settings SET mode_source = ? WHERE chat_jid = ?').run( + source, + jid, + ); + return db; + } + + it('coerces an unrecognized mode_source to explicit instead of dropping the room', () => { + const db = seedWithSource('room'); + + const stored = getStoredRoomSettingsRowFromDatabase(db, jid); + expect(stored).toBeDefined(); + expect(stored?.roomMode).toBe('single'); + expect(stored?.modeSource).toBe('explicit'); + expect(stored?.folder).toBe('cgv-macro'); + + // And it stays visible in the full-room listing that feeds getAllRoomBindings. + const listed = getStoredRoomRowsFromDatabase(db).map((r) => r.chatJid); + expect(listed).toContain(jid); + }); + + it('room_mode is guarded by a schema CHECK, but mode_source is not — hence the loader coercion', () => { + const db = openInitializedInMemoryDatabase(); + insertStoredRoomSettings(db, jid, 'single', 'explicit', snapshot); + + // room_mode cannot be corrupted at all: the column CHECK rejects the write. + expect(() => + db + .prepare('UPDATE room_settings SET room_mode = ? WHERE chat_jid = ?') + .run('bogus', jid), + ).toThrow(/CHECK constraint/); + + // mode_source has no such CHECK, so a bad value persists — which is exactly + // the corruption the loader now tolerates instead of dropping the room. + db.prepare('UPDATE room_settings SET mode_source = ? WHERE chat_jid = ?').run( + 'room', + jid, + ); + expect(getStoredRoomSettingsRowFromDatabase(db, jid)?.roomMode).toBe('single'); + }); + + it('preserves a valid mode_source unchanged', () => { + const db = openInitializedInMemoryDatabase(); + insertStoredRoomSettings(db, jid, 'tribunal', 'inferred', snapshot); + + expect(getStoredRoomSettingsRowFromDatabase(db, jid)?.modeSource).toBe( + 'inferred', + ); + }); +}); diff --git a/src/db/room-registration.ts b/src/db/room-registration.ts index b1ede31..7d8e076 100644 --- a/src/db/room-registration.ts +++ b/src/db/room-registration.ts @@ -182,8 +182,23 @@ export function getStoredRoomSettingsRowFromDatabase( row?.room_mode === 'single' || row?.room_mode === 'tribunal' ? row.room_mode : undefined; - const source = normalizeRoomModeSource(row?.mode_source); - if (!row || !roomMode || !source) return undefined; + if (!row || !roomMode) return undefined; + + // A row with a valid room_mode but an unrecognized mode_source must NOT be + // silently dropped. Dropping it removes the room from every binding lookup, + // so the router ignores the channel, it disappears from the status list, and + // re-registration wedges on the UNIQUE(chat_jid) constraint (assignRoom's + // "existing" probe also relies on this function). Coerce the source to + // 'explicit' — which preserves the stored room_mode as-is — and warn so the + // underlying corruption stays visible and self-heals on the next assignRoom. + let source = normalizeRoomModeSource(row.mode_source); + if (!source) { + logger.warn( + { jid: chatJid, modeSource: row.mode_source ?? null }, + 'room_settings row has invalid mode_source; coercing to explicit to keep room routable', + ); + source = 'explicit'; + } return { chatJid,