refactor: finish legacy room cleanup and explicit migration boundary
This commit is contained in:
385
src/db.test.ts
385
src/db.test.ts
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user