refactor: complete room registration ssot cleanup

This commit is contained in:
ejclaw
2026-04-09 09:41:19 +09:00
parent dae0bcafc9
commit b0f51b0366
3 changed files with 533 additions and 39 deletions

View File

@@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { import {
_initTestDatabase, _initTestDatabase,
_initTestDatabaseFromFile, _initTestDatabaseFromFile,
_deleteStoredRoomSettingsForTests,
_setMemoryTimestampsForTests, _setMemoryTimestampsForTests,
_setRegisteredGroupForTests, _setRegisteredGroupForTests,
assignRoom, assignRoom,
@@ -30,6 +31,7 @@ import {
getLatestPairedTaskForChat, getLatestPairedTaskForChat,
getLatestTurnNumber, getLatestTurnNumber,
getLastRespondingAgentType, getLastRespondingAgentType,
getRegisteredGroup,
getMessagesSinceSeq, getMessagesSinceSeq,
getNewMessagesBySeq, getNewMessagesBySeq,
getOpenWorkItem, 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', () => { describe('paired room registration', () => {
@@ -1938,6 +2260,36 @@ describe('paired room registration', () => {
expect(getEffectiveRuntimeRoomMode('dc:explicit-single')).toBe('single'); 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', () => { it('lets explicit tribunal become runnable when the configured reviewer can run on the solo registration', () => {
_setRegisteredGroupForTests('dc:explicit-tribunal', { _setRegisteredGroupForTests('dc:explicit-tribunal', {
name: 'Explicit Tribunal Claude', name: 'Explicit Tribunal Claude',

170
src/db.ts
View File

@@ -39,6 +39,7 @@ import {
normalizeRoomModeSource, normalizeRoomModeSource,
normalizeStoredAgentType, normalizeStoredAgentType,
parseRegisteredGroupRow, parseRegisteredGroupRow,
resolveStoredRoomCapabilityTypes,
resolveAssignedRoomFolder, resolveAssignedRoomFolder,
updateStoredRoomMetadata, updateStoredRoomMetadata,
} from './db/room-registration.js'; } from './db/room-registration.js';
@@ -313,6 +314,14 @@ export function _setStoredRoomOwnerAgentTypeForTests(
).run(ownerAgentType, new Date().toISOString(), chatJid); ).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. */ /** @internal - for tests only. */
export function _setMemoryTimestampsForTests( export function _setMemoryTimestampsForTests(
id: number, id: number,
@@ -749,12 +758,7 @@ export function getRegisteredGroup(
const requestedAgentType = normalizeStoredAgentType(agentType); const requestedAgentType = normalizeStoredAgentType(agentType);
const stored = getStoredRoomSettingsRowFromDatabase(db, jid); const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
if (stored) { if (stored) {
const group = buildRegisteredGroupFromStoredSettings( return buildRegisteredGroupFromStoredSettings(db, stored, requestedAgentType);
db,
stored,
requestedAgentType,
);
if (group) return group;
} }
return getLegacyRegisteredGroup(db, jid, agentType); return getLegacyRegisteredGroup(db, jid, agentType);
} }
@@ -763,31 +767,58 @@ function writeLegacyRegisteredGroupAndSyncRoomSettings(
jid: string, jid: string,
group: RegisteredGroup, group: RegisteredGroup,
): void { ): void {
const existingStored = getStoredRoomSettingsRowFromDatabase(db, jid);
const existingRoomMode = getStoredRoomModeRow(jid); const existingRoomMode = getStoredRoomModeRow(jid);
if (!isValidGroupFolder(group.folder)) { if (!isValidGroupFolder(group.folder)) {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
} }
const tx = db.transaction(() => { const tx = db.transaction(() => {
db.prepare( const seededAgentType = group.agentType || 'claude-code';
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir) const agentTypes = new Set<AgentType>(collectRegisteredAgentTypes(db, jid));
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, agentTypes.add(seededAgentType);
).run( const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([
jid, ...agentTypes,
group.name, ]);
group.folder, const roomMode =
group.trigger, existingRoomMode?.source === 'explicit'
group.added_at, ? existingRoomMode.roomMode
group.agentConfig ? JSON.stringify(group.agentConfig) : null, : inferredRoomMode;
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, const ownerAgentType =
group.isMain ? 1 : 0, existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType
group.agentType || 'claude-code', ? existingStored.ownerAgentType
group.workDir || null, : 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 (existingStored) {
if (!existingRoomMode || existingRoomMode.source === 'inferred') { updateStoredRoomMetadata(db, jid, snapshot);
syncInferredRoomModeForJid(jid); 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(); tx();
} }
@@ -874,18 +905,24 @@ export function assignRoom(
} }
export function updateRegisteredGroupName(jid: string, name: string): void { export function updateRegisteredGroupName(jid: string, name: string): void {
db.prepare( const plan = buildRoomRegistrationPlanForJid(jid, { name });
`UPDATE room_settings if (!plan) {
SET name = ?, updated_at = ? return;
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);
} }
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( export function getAllRegisteredGroups(
@@ -921,6 +958,10 @@ export function getAllRegisteredGroups(
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] { export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
if (!db) return []; if (!db) return [];
const stored = getStoredRoomSettingsRowFromDatabase(db, jid);
if (stored) {
return resolveStoredRoomCapabilityTypes(db, stored);
}
return collectRegisteredAgentTypes(db, jid); return collectRegisteredAgentTypes(db, jid);
} }
@@ -977,6 +1018,63 @@ function syncStoredRoomRegistrationSnapshotForJid(chatJid: string): void {
updateStoredRoomMetadata(db, chatJid, snapshot); updateStoredRoomMetadata(db, chatJid, snapshot);
} }
function buildRoomRegistrationSnapshotFromStoredRoom(
stored: StoredRoomSettings,
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
): 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<Pick<RoomRegistrationSnapshot, 'name'>>,
):
| {
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( function upsertStoredRoomMode(
chatJid: string, chatJid: string,
roomMode: RoomMode, roomMode: RoomMode,
@@ -1010,7 +1108,7 @@ export function setExplicitRoomMode(chatJid: string, roomMode: RoomMode): void {
} }
export function clearExplicitRoomMode(chatJid: string): void { export function clearExplicitRoomMode(chatJid: string): void {
const agentTypes = getRegisteredAgentTypesForJid(chatJid); const agentTypes = collectRegisteredAgentTypes(db, chatJid);
if (agentTypes.length === 0) { if (agentTypes.length === 0) {
db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid); db.prepare('DELETE FROM room_settings WHERE chat_jid = ?').run(chatJid);
return; return;

View File

@@ -147,9 +147,28 @@ export function getLegacyRegisteredGroupRows(
return ( return (
agentTypeFilter agentTypeFilter
? database ? 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) .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[]; ) as RegisteredGroupDatabaseRow[];
} }
@@ -158,6 +177,10 @@ export function getLegacyRegisteredGroup(
jid: string, jid: string,
agentType?: string, agentType?: string,
): (RegisteredGroup & { jid: string }) | undefined { ): (RegisteredGroup & { jid: string }) | undefined {
if (getStoredRoomSettingsRowFromDatabase(database, jid)) {
return undefined;
}
const row = ( const row = (
agentType agentType
? database ? database
@@ -276,7 +299,7 @@ export function buildRegisteredGroupFromStoredSettings(
stored: StoredRoomSettings, stored: StoredRoomSettings,
requestedAgentType?: AgentType, requestedAgentType?: AgentType,
): (RegisteredGroup & { jid: string }) | undefined { ): (RegisteredGroup & { jid: string }) | undefined {
const capabilityTypes = collectRegisteredAgentTypes(database, stored.chatJid); const capabilityTypes = resolveStoredRoomCapabilityTypes(database, stored);
const resolvedAgentType = requestedAgentType const resolvedAgentType = requestedAgentType
? capabilityTypes.includes(requestedAgentType) ? capabilityTypes.includes(requestedAgentType)
? 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<AgentType>([ownerAgentType]);
if (stored.roomMode === 'tribunal') {
types.add(REVIEWER_AGENT_TYPE);
}
return [...types];
}
function detectChannelPrefixForFolder(chatJid: string): string { function detectChannelPrefixForFolder(chatJid: string): string {
if (chatJid.startsWith('dc:')) return 'discord'; if (chatJid.startsWith('dc:')) return 'discord';
if (chatJid.startsWith('tg:')) return 'telegram'; if (chatJid.startsWith('tg:')) return 'telegram';