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 <noreply@anthropic.com>
This commit is contained in:
79
src/db/room-registration.test.ts
Normal file
79
src/db/room-registration.test.ts
Normal file
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user