refactor: finish legacy room cleanup and explicit migration boundary

This commit is contained in:
ejclaw
2026-04-10 23:12:54 +09:00
parent 497e6dec74
commit 921c241873
11 changed files with 1028 additions and 878 deletions

View File

@@ -81,9 +81,14 @@ import {
buildPairedTurnAttemptParentId,
} from './db/paired-turn-attempts.js';
import {
buildLegacyRoomMigrationPlan,
getPendingLegacyRegisteredGroupJids,
type RoomRegistrationSnapshot,
type RoomRoleOverrideSnapshot,
type StoredRoomSettings,
getStoredRoomSettingsRowFromDatabase,
inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes,
insertStoredRoomSettingsFromMigration,
normalizeStoredAgentType,
upsertRoomRoleOverride,
} from './db/room-registration.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.js';
@@ -171,6 +176,321 @@ function insertPairedTurnIdentityRow(
);
}
type LegacyRoomMigrationPlanForTests = Parameters<
typeof insertStoredRoomSettingsFromMigration
>[1];
function getStoredRoleOverridesForLegacyMigration(
database: Database,
jid: string,
): Map<
'owner' | 'reviewer' | 'arbiter',
{
role: 'owner' | 'reviewer' | 'arbiter';
agentType: 'claude-code' | 'codex';
agentConfig?: unknown;
createdAt: string;
updatedAt: string;
}
> {
let rows: Array<{
role: 'owner' | 'reviewer' | 'arbiter';
agent_type: string | null;
agent_config_json: string | null;
created_at: string;
updated_at: string;
}> = [];
try {
rows = database
.prepare(
`SELECT role, agent_type, agent_config_json, created_at, updated_at
FROM room_role_overrides
WHERE chat_jid = ?`,
)
.all(jid) as Array<{
role: 'owner' | 'reviewer' | 'arbiter';
agent_type: string | null;
agent_config_json: string | null;
created_at: string;
updated_at: string;
}>;
} catch {
return new Map();
}
const result = new Map<
'owner' | 'reviewer' | 'arbiter',
{
role: 'owner' | 'reviewer' | 'arbiter';
agentType: 'claude-code' | 'codex';
agentConfig?: unknown;
createdAt: string;
updatedAt: string;
}
>();
for (const row of rows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (!agentType) continue;
result.set(row.role, {
role: row.role,
agentType,
agentConfig: row.agent_config_json
? JSON.parse(row.agent_config_json)
: undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
}
return result;
}
function collectLegacyRoomRegistrationSnapshotForTests(
database: Database,
jid: string,
existingStored?: Pick<
StoredRoomSettings,
'modeSource' | 'ownerAgentType' | 'trigger'
>,
): 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<string>();
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 = new Set<'claude-code' | 'codex'>();
for (const row of rows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (agentType) {
agentTypes.add(agentType);
}
}
const inferredOwnerAgentType = inferOwnerAgentTypeFromRegisteredAgentTypes([
...agentTypes,
]);
const preferExplicitTrigger =
existingStored?.modeSource === 'explicit' && existingStored.trigger;
const preferExplicitOwner =
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType;
const preferredOwnerAgentType = preferExplicitOwner
? existingStored.ownerAgentType
: undefined;
const preferredOwnerRow = preferredOwnerAgentType
? rows.find(
(row) =>
normalizeStoredAgentType(row.agent_type) === preferredOwnerAgentType,
)
: undefined;
const inferredOwnerRow =
rows.find(
(row) =>
normalizeStoredAgentType(row.agent_type) === inferredOwnerAgentType,
) ?? rows[0]!;
const ownerAgentType = preferredOwnerAgentType ?? inferredOwnerAgentType;
const ownerRow = preferredOwnerRow ?? inferredOwnerRow;
return {
name: first.name,
folder: first.folder,
triggerPattern: preferExplicitTrigger
? existingStored.trigger!
: (preferredOwnerRow?.trigger_pattern ?? ownerRow.trigger_pattern),
requiresTrigger: (first.requires_trigger ?? 1) === 1,
isMain: (first.is_main ?? 0) === 1,
ownerAgentType,
workDir: first.work_dir ?? null,
};
}
function buildLegacyRoomMigrationPlanForTests(
database: Database,
jid: string,
): LegacyRoomMigrationPlanForTests | undefined {
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
const snapshot = collectLegacyRoomRegistrationSnapshotForTests(
database,
jid,
existingStored,
);
if (!snapshot) return undefined;
const rows = database
.prepare(
`SELECT added_at
FROM registered_groups
WHERE jid = ?
ORDER BY added_at, agent_type`,
)
.all(jid) as Array<{ added_at: string }>;
if (rows.length === 0) return undefined;
const agentTypes = (
database
.prepare(
`SELECT agent_type
FROM registered_groups
WHERE jid = ?`,
)
.all(jid) as Array<{ agent_type: string | null }>
)
.map((row) => normalizeStoredAgentType(row.agent_type))
.filter((value): value is 'claude-code' | 'codex' => Boolean(value));
const roomMode =
existingStored?.roomMode ??
inferRoomModeFromRegisteredAgentTypes(agentTypes);
const createdAt = rows[0]!.added_at;
const updatedAt = rows[rows.length - 1]!.added_at;
const roleOverrides: RoomRoleOverrideSnapshot[] = [];
const getCapabilityMetadata = (
preferredAgentType?: 'claude-code' | 'codex',
) =>
(preferredAgentType
? database
.prepare(
`SELECT added_at, agent_config
FROM registered_groups
WHERE jid = ? AND agent_type = ?
LIMIT 1`,
)
.get(jid, preferredAgentType)
: database
.prepare(
`SELECT added_at, agent_config
FROM registered_groups
WHERE jid = ?
ORDER BY CASE WHEN agent_type = ? THEN 0 ELSE 1 END, added_at
LIMIT 1`,
)
.get(jid, OWNER_AGENT_TYPE)) as
| { added_at: string; agent_config: string | null }
| undefined;
const ownerMetadata = getCapabilityMetadata(snapshot.ownerAgentType);
roleOverrides.push({
role: 'owner',
agentType: snapshot.ownerAgentType,
agentConfig: ownerMetadata?.agent_config
? JSON.parse(ownerMetadata.agent_config)
: undefined,
createdAt: ownerMetadata?.added_at ?? createdAt,
updatedAt,
});
if (roomMode === 'tribunal') {
const reviewerAgentType = agentTypes.find(
(agentType) => agentType !== snapshot.ownerAgentType,
);
if (!reviewerAgentType) {
throw new Error(
`Missing reviewer agent type for tribunal legacy room ${jid}`,
);
}
const reviewerMetadata = getCapabilityMetadata(reviewerAgentType);
roleOverrides.push({
role: 'reviewer',
agentType: reviewerAgentType,
agentConfig: reviewerMetadata?.agent_config
? JSON.parse(reviewerMetadata.agent_config)
: undefined,
createdAt: reviewerMetadata?.added_at ?? createdAt,
updatedAt,
});
}
return {
chatJid: jid,
roomMode,
createdAt,
updatedAt,
snapshot,
roleOverrides,
};
}
function getPendingLegacyRegisteredGroupJidsForTests(
database: Database,
): string[] {
const rows = database
.prepare(
`SELECT DISTINCT jid
FROM registered_groups
ORDER BY jid`,
)
.all() as Array<{ jid: string }>;
return rows
.map((row) => row.jid)
.filter((jid) => {
const stored = getStoredRoomSettingsRowFromDatabase(database, jid);
if (!stored) {
return true;
}
const plan = buildLegacyRoomMigrationPlanForTests(database, jid);
if (!plan) {
return false;
}
if (
stored.name !== plan.snapshot.name ||
stored.folder !== plan.snapshot.folder ||
(stored.requiresTrigger ?? true) !== plan.snapshot.requiresTrigger ||
(stored.isMain ?? false) !== plan.snapshot.isMain ||
(stored.workDir ?? null) !== (plan.snapshot.workDir ?? null)
) {
return true;
}
const existingOverrides = getStoredRoleOverridesForLegacyMigration(
database,
jid,
);
return plan.roleOverrides.some((override) => {
const actual = existingOverrides.get(override.role);
return (
actual?.agentType !== override.agentType ||
JSON.stringify(actual?.agentConfig ?? null) !==
JSON.stringify(override.agentConfig ?? null)
);
});
});
}
function migrateLegacyRoomRegistrationsInFile(dbPath: string): {
migratedRooms: number;
migratedRoleOverrides: number;
@@ -182,12 +502,12 @@ function migrateLegacyRoomRegistrationsInFile(dbPath: string): {
try {
initializeDatabaseSchema(migrationDb);
migrationDb.transaction(() => {
const rows = getPendingLegacyRegisteredGroupJids(migrationDb).map(
const rows = getPendingLegacyRegisteredGroupJidsForTests(migrationDb).map(
(jid) => ({ jid }),
);
for (const row of rows) {
const plan = buildLegacyRoomMigrationPlan(migrationDb, row.jid);
const plan = buildLegacyRoomMigrationPlanForTests(migrationDb, row.jid);
if (!plan) continue;
const existing = migrationDb
.prepare('SELECT 1 FROM room_settings WHERE chat_jid = ?')
@@ -201,6 +521,7 @@ function migrateLegacyRoomRegistrationsInFile(dbPath: string): {
migratedRoleOverrides += 1;
}
}
migrationDb.exec(`DROP TABLE IF EXISTS registered_groups`);
})();
} finally {
migrationDb.close();
@@ -2118,7 +2439,7 @@ describe('room assignment writes', () => {
).toBeUndefined();
});
it('does not write legacy registered_groups rows when assigning a canonical room', () => {
it('does not create a legacy room projection table when assigning a canonical room', () => {
const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-assign-canonical-');
const dbPath = path.join(tempDir, 'messages.db');
@@ -2131,16 +2452,17 @@ describe('room assignment writes', () => {
});
const rawDb = new Database(dbPath, { readonly: true });
const projectionRows = rawDb
const legacyTable = rawDb
.prepare(
`SELECT agent_type
FROM registered_groups
WHERE jid = ?`,
`SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name = 'registered_groups'`,
)
.all('dc:assign-no-projection');
.get();
rawDb.close();
expect(projectionRows).toEqual([]);
expect(legacyTable).toBeUndefined();
expect(
getAllRoomBindings('claude-code')['dc:assign-no-projection'],
).toMatchObject({
@@ -2473,7 +2795,7 @@ describe('room assignment writes', () => {
legacyDb.close();
const pendingDb = new Database(dbPath, { readonly: true });
expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([
expect(getPendingLegacyRegisteredGroupJidsForTests(pendingDb)).toEqual([
'dc:mixed-conflict',
]);
pendingDb.close();
@@ -2654,7 +2976,7 @@ describe('room assignment writes', () => {
expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']);
});
it('clears stale legacy registered_groups rows after canonical room metadata changes', () => {
it('keeps canonical room metadata writable after explicit migration drops the legacy table', () => {
const tempDir = fs.mkdtempSync('/tmp/ejclaw-room-writeback-');
const dbPath = path.join(tempDir, 'messages.db');
const legacyDb = new Database(dbPath);
@@ -2777,21 +3099,18 @@ describe('room assignment writes', () => {
ownerAgentType: 'codex',
});
const rawDb = new Database(dbPath);
let projectionRows = rawDb
const rawDb = new Database(dbPath, { readonly: true });
const legacyTable = rawDb
.prepare(
`SELECT agent_type, name
FROM registered_groups
WHERE jid = ?
ORDER BY agent_type`,
`SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name = 'registered_groups'`,
)
.all('dc:ssot-writeback') as Array<{
agent_type: string | null;
name: string;
}>;
.get();
rawDb.close();
expect(projectionRows).toEqual([]);
expect(legacyTable).toBeUndefined();
_initTestDatabaseFromFile(dbPath);
@@ -2802,10 +3121,6 @@ describe('room assignment writes', () => {
name: 'SSOT Writeback Renamed',
ownerAgentType: 'codex',
});
const pendingDb = new Database(dbPath);
expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([]);
pendingDb.close();
clearExplicitRoomMode('dc:ssot-writeback');
expect(getExplicitRoomMode('dc:ssot-writeback')).toBeUndefined();
@@ -3226,20 +3541,6 @@ describe('service handoff completion', () => {
work_dir TEXT,
updated_at TEXT NOT NULL
);
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 service_handoffs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_jid TEXT NOT NULL,