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

@@ -0,0 +1,545 @@
import { Database } from 'bun:sqlite';
import { OWNER_AGENT_TYPE } from '../src/config.js';
import type { AgentType, RegisteredGroup } from '../src/types.js';
import {
type LegacyRoomMigrationPlan,
type RoomRegistrationSnapshot,
type RoomRoleOverrideSnapshot,
type StoredRoomSettings,
getStoredRoomSettingsRowFromDatabase,
inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes,
normalizeStoredAgentType,
} from '../src/db/room-registration.js';
interface StoredRoomRoleOverrideRow {
role: 'owner' | 'reviewer' | 'arbiter';
agentType: AgentType;
agentConfig?: RegisteredGroup['agentConfig'];
createdAt: string;
updatedAt: string;
}
function hasLegacyRegisteredGroupsTable(database: Database): boolean {
const row = database
.prepare(
`SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name = 'registered_groups'`,
)
.get() as { 1: number } | undefined;
return Boolean(row);
}
export function normalizeLegacyRegisteredGroupsTable(database: Database): void {
const roomBindingsSql = (
database
.prepare(
`SELECT sql
FROM sqlite_master
WHERE type = 'table'
AND name = 'registered_groups'`,
)
.get() as { sql?: string } | undefined
)?.sql;
if (!roomBindingsSql) {
return;
}
if (!roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)')) {
const legacyCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasIsMain = legacyCols.some((col) => col.name === 'is_main');
const hasAgentType = legacyCols.some((col) => col.name === 'agent_type');
const hasWorkDir = legacyCols.some((col) => col.name === 'work_dir');
const hasAgentConfig = legacyCols.some((col) => col.name === 'agent_config');
const hasContainerConfig = legacyCols.some(
(col) => col.name === 'container_config',
);
database.exec(`
CREATE TABLE registered_groups_new (
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)
);
`);
database.exec(`
INSERT INTO registered_groups_new (
jid,
name,
folder,
trigger_pattern,
added_at,
agent_config,
requires_trigger,
is_main,
agent_type,
work_dir
)
SELECT
jid,
name,
folder,
trigger_pattern,
added_at,
${
hasAgentConfig
? 'agent_config'
: hasContainerConfig
? 'container_config'
: 'NULL'
},
requires_trigger,
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
${hasWorkDir ? 'work_dir' : 'NULL'}
FROM registered_groups;
`);
database.exec(`
DROP TABLE registered_groups;
ALTER TABLE registered_groups_new RENAME TO registered_groups;
`);
}
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasAgentConfig = registeredGroupCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = registeredGroupCols.some(
(col) => col.name === 'container_config',
);
if (!hasAgentConfig) {
database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`);
}
if (hasContainerConfig) {
database.exec(
`UPDATE registered_groups
SET agent_config = COALESCE(agent_config, container_config)
WHERE container_config IS NOT NULL`,
);
}
database.exec(
`UPDATE registered_groups
SET is_main = 1
WHERE folder = 'main'
AND COALESCE(is_main, 0) = 0`,
);
}
function getStoredRoomRoleOverrideRows(
database: Database,
jid: string,
): Map<'owner' | 'reviewer' | 'arbiter', StoredRoomRoleOverrideRow> {
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',
StoredRoomRoleOverrideRow
>();
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 normalizeAgentConfigValue(
agentConfig: RegisteredGroup['agentConfig'] | undefined,
): string {
return JSON.stringify(agentConfig ?? null);
}
function roomRoleOverrideMatches(
actual: StoredRoomRoleOverrideRow | undefined,
expected: RoomRoleOverrideSnapshot,
): boolean {
return (
actual?.agentType === expected.agentType &&
normalizeAgentConfigValue(actual.agentConfig) ===
normalizeAgentConfigValue(expected.agentConfig)
);
}
function storedRoomSettingsMatchesLegacySnapshot(
stored: StoredRoomSettings,
snapshot: RoomRegistrationSnapshot,
): boolean {
return (
stored.name === snapshot.name &&
stored.folder === snapshot.folder &&
(stored.requiresTrigger ?? true) === snapshot.requiresTrigger &&
(stored.isMain ?? false) === snapshot.isMain &&
(stored.workDir ?? null) === (snapshot.workDir ?? null)
);
}
function collectLegacyRegisteredAgentTypes(
database: Database,
jid: string,
): AgentType[] {
if (!hasLegacyRegisteredGroupsTable(database)) {
return [];
}
const rows = database
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
.all(jid) as Array<{ agent_type: string | null }>;
const types = new Set<AgentType>();
for (const row of rows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (agentType) {
types.add(agentType);
}
}
return [...types];
}
function getLegacyGroupCapabilityMetadata(
database: Database,
jid: string,
preferredAgentType?: AgentType,
): Pick<RegisteredGroup, 'added_at' | 'agentConfig'> | undefined {
if (!hasLegacyRegisteredGroupsTable(database)) {
return undefined;
}
const row = (
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;
if (!row) return undefined;
return {
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
};
}
function resolveReviewerAgentTypeFromRegisteredAgentTypes(
agentTypes: readonly AgentType[],
ownerAgentType: AgentType,
): AgentType | undefined {
return agentTypes.find((agentType) => agentType !== ownerAgentType);
}
function collectLegacyRoomRegistrationSnapshot(
database: Database,
jid: string,
existingStored?: Pick<
StoredRoomSettings,
'modeSource' | 'ownerAgentType' | 'trigger'
>,
): RoomRegistrationSnapshot | undefined {
if (!hasLegacyRegisteredGroupsTable(database)) {
return 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 = collectLegacyRegisteredAgentTypes(database, jid);
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
? preferredOwnerRow
? preferredOwnerAgentType
: preferredOwnerAgentType
: inferredOwnerAgentType;
const ownerRow = preferredOwnerRow ?? inferredOwnerRow;
return {
name: first.name,
folder: first.folder,
triggerPattern: preferExplicitTrigger
? existingStored.trigger!
: preferredOwnerRow != null
? preferredOwnerRow.trigger_pattern
: ownerRow.trigger_pattern,
requiresTrigger: (first.requires_trigger ?? 1) === 1,
isMain: (first.is_main ?? 0) === 1,
ownerAgentType,
workDir: first.work_dir ?? null,
};
}
export function buildLegacyRoomMigrationPlan(
database: Database,
jid: string,
): LegacyRoomMigrationPlan | undefined {
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
const snapshot = collectLegacyRoomRegistrationSnapshot(
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 = collectLegacyRegisteredAgentTypes(database, jid);
const roomMode =
existingStored?.roomMode ??
inferRoomModeFromRegisteredAgentTypes(agentTypes);
const createdAt = rows[0]!.added_at;
const updatedAt = rows[rows.length - 1]!.added_at;
const roleOverrides: RoomRoleOverrideSnapshot[] = [];
const ownerMetadata = getLegacyGroupCapabilityMetadata(
database,
jid,
snapshot.ownerAgentType,
);
roleOverrides.push({
role: 'owner',
agentType: snapshot.ownerAgentType,
agentConfig: ownerMetadata?.agentConfig,
createdAt: ownerMetadata?.added_at ?? createdAt,
updatedAt,
});
if (roomMode === 'tribunal') {
const reviewerAgentType = resolveReviewerAgentTypeFromRegisteredAgentTypes(
agentTypes,
snapshot.ownerAgentType,
);
if (!reviewerAgentType) {
throw new Error(
`Missing reviewer agent type for tribunal legacy room ${jid}`,
);
}
const reviewerMetadata = getLegacyGroupCapabilityMetadata(
database,
jid,
reviewerAgentType,
);
roleOverrides.push({
role: 'reviewer',
agentType: reviewerAgentType,
agentConfig: reviewerMetadata?.agentConfig,
createdAt: reviewerMetadata?.added_at ?? createdAt,
updatedAt,
});
}
return {
chatJid: jid,
roomMode,
createdAt,
updatedAt,
snapshot,
roleOverrides,
};
}
function jidRequiresLegacyRoomMigration(
database: Database,
jid: string,
): boolean {
let stored: StoredRoomSettings | undefined;
try {
stored = getStoredRoomSettingsRowFromDatabase(database, jid);
} catch {
return true;
}
if (!stored) {
return true;
}
let plan: LegacyRoomMigrationPlan | undefined;
try {
plan = buildLegacyRoomMigrationPlan(database, jid);
} catch {
return true;
}
if (!plan) {
return false;
}
if (!storedRoomSettingsMatchesLegacySnapshot(stored, plan.snapshot)) {
return true;
}
const existingOverrides = getStoredRoomRoleOverrideRows(database, jid);
for (const override of plan.roleOverrides) {
if (
!roomRoleOverrideMatches(existingOverrides.get(override.role), override)
) {
return true;
}
}
return false;
}
export function getPendingLegacyRegisteredGroupJids(
database: Database,
): string[] {
if (!hasLegacyRegisteredGroupsTable(database)) {
return [];
}
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) => jidRequiresLegacyRoomMigration(database, jid));
}
export function countPendingLegacyRegisteredGroupRows(
database: Database,
): number {
if (!hasLegacyRegisteredGroupsTable(database)) {
return 0;
}
let count = 0;
const countRows = database.prepare(
`SELECT COUNT(*) AS count
FROM registered_groups
WHERE jid = ?`,
);
for (const jid of getPendingLegacyRegisteredGroupJids(database)) {
const row = countRows.get(jid) as { count: number };
count += row.count;
}
return count;
}

View File

@@ -149,6 +149,16 @@ describe('migrate room registrations step', () => {
agent_type: 'claude-code',
},
]);
expect(
migratedDb
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name = 'registered_groups'`,
)
.get(),
).toBeUndefined();
expect(
migratedDb
.prepare(

View File

@@ -9,11 +9,14 @@ import {
openPersistentDatabase,
} from '../src/db/bootstrap.js';
import {
buildLegacyRoomMigrationPlan,
getPendingLegacyRegisteredGroupJids,
insertStoredRoomSettingsFromMigration,
upsertRoomRoleOverride,
} from '../src/db/room-registration.js';
import {
buildLegacyRoomMigrationPlan,
getPendingLegacyRegisteredGroupJids,
normalizeLegacyRegisteredGroupsTable,
} from './legacy-room-registrations.js';
import { emitStatus } from './status.js';
interface MigrationReport {
@@ -42,21 +45,27 @@ function ensureLegacyBackupTable(database: Database): void {
`);
}
function backupPendingLegacyRows(database: Database): number {
ensureLegacyBackupTable(database);
const pendingJids = new Set(getPendingLegacyRegisteredGroupJids(database));
if (pendingJids.size === 0) {
function backupLegacyRows(database: Database): number {
const tableExists = database
.prepare(
`SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name = 'registered_groups'`,
)
.get();
if (!tableExists) {
return 0;
}
const rows = (
database
ensureLegacyBackupTable(database);
const rows = database
.prepare(
`SELECT jid, name, folder, trigger_pattern, added_at, agent_config,
requires_trigger, is_main, agent_type, work_dir
FROM registered_groups`,
)
.all() as Array<Record<string, unknown>>
).filter((row) => pendingJids.has(String(row.jid)));
.all() as Array<Record<string, unknown>>;
const insert = database.prepare(
`INSERT OR REPLACE INTO registered_groups_legacy_backup (
@@ -92,6 +101,10 @@ function backupPendingLegacyRows(database: Database): number {
return rows.length;
}
function dropLegacyRegisteredGroupsTable(database: Database): void {
database.exec(`DROP TABLE IF EXISTS registered_groups`);
}
function migrateLegacyRegisteredGroupsTable(
database: Database,
report: MigrationReport,
@@ -141,8 +154,10 @@ export async function run(_args: string[]): Promise<void> {
};
database.transaction(() => {
report.backedUpLegacyRows = backupPendingLegacyRows(database);
normalizeLegacyRegisteredGroupsTable(database);
report.backedUpLegacyRows = backupLegacyRows(database);
migrateLegacyRegisteredGroupsTable(database, report);
dropLegacyRegisteredGroupsTable(database);
})();
database.close();

View File

@@ -5,7 +5,7 @@ import { Database } from 'bun:sqlite';
import { STORE_DIR } from '../src/config.js';
import { listUnexpectedDataStateFiles } from '../src/data-state-files.js';
import { countPendingLegacyRegisteredGroupRows } from '../src/db/room-registration.js';
import { countPendingLegacyRegisteredGroupRows } from './legacy-room-registrations.js';
export interface AssignedRoomsSummary {
assignedRooms: number;

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,

View File

@@ -142,7 +142,6 @@ import {
getRegisteredAgentTypesForJidFromDatabase,
getRegisteredGroupFromDatabase,
getStoredRoomSettingsFromDatabase,
migrateLegacyRoomRegistrationsForTestsInDatabase,
setExplicitRoomModeInDatabase,
setRegisteredGroupForTestsInDatabase,
setStoredRoomOwnerAgentTypeForTestsInDatabase,
@@ -678,10 +677,7 @@ export function getRegisteredGroup(
return getRegisteredGroupFromDatabase(db, jid, agentType);
}
/**
* @internal Test/migration helper for seeding legacy capability rows.
* Runtime code must use assignRoom() so room_settings remains the explicit SSOT.
*/
/** @internal Test helper for seeding canonical room bindings. */
export function _setRegisteredGroupForTests(
jid: string,
group: RegisteredGroup,
@@ -689,16 +685,6 @@ export function _setRegisteredGroupForTests(
setRegisteredGroupForTestsInDatabase(db, jid, group);
}
export function _migrateLegacyRoomRegistrationsForTests(): {
migratedRooms: number;
migratedRoleOverrides: number;
} {
if (!db) {
throw new Error('Database not initialized');
}
return migrateLegacyRoomRegistrationsForTestsInDatabase(db);
}
export function assignRoom(
chatJid: string,
input: AssignRoomInput,

View File

@@ -97,20 +97,6 @@ export function applyBaseSchema(database: Database): void {
session_id TEXT NOT NULL,
PRIMARY KEY (group_folder, agent_type)
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS paired_projects (
chat_jid TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,

View File

@@ -8,7 +8,6 @@ import {
openPersistentDatabase,
initializeDatabaseSchema,
} from './bootstrap.js';
import { countPendingLegacyRegisteredGroupRows } from './room-registration.js';
import {
clearExpiredPairedTaskExecutionLeasesInDatabase,
clearPairedTaskExecutionLeasesForServiceInDatabase,
@@ -23,14 +22,47 @@ function finalizeDatabaseInitialization(database: Database): void {
clearExpiredPairedTaskExecutionLeasesInDatabase(database);
}
function getUnexpectedLegacyRoomBindingTables(database: Database): string[] {
const tableRows = database
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all() as Array<{ name: string }>;
const matches: string[] = [];
for (const row of tableRows) {
const columns = database
.prepare(`PRAGMA table_info("${row.name.replaceAll('"', '""')}")`)
.all() as Array<{ name: string }>;
const columnNames = new Set(columns.map((column) => column.name));
const looksLikeLegacyRoomBindingTable =
columnNames.has('jid') &&
columnNames.has('name') &&
columnNames.has('folder') &&
columnNames.has('trigger_pattern') &&
columnNames.has('added_at');
const isBackupTable = columnNames.has('backup_at');
if (looksLikeLegacyRoomBindingTable && !isBackupTable) {
matches.push(row.name);
}
}
return matches;
}
function assertNoPendingLegacyRoomMigration(database: Database): void {
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(database);
if (pendingLegacyRows === 0) {
const pendingTables = getUnexpectedLegacyRoomBindingTables(database);
if (pendingTables.length === 0) {
return;
}
throw new Error(
`Legacy room migration required before startup (pending_rows=${pendingLegacyRows})`,
`Legacy room migration required before startup (tables=${pendingTables.join(',')})`,
);
}

View File

@@ -30,19 +30,6 @@ export interface RoomRegistrationSnapshot {
workDir: string | null;
}
export interface RegisteredGroupDatabaseRow {
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
agent_config: string | null;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}
export interface RoomRoleOverrideSnapshot {
role: 'owner' | 'reviewer' | 'arbiter';
agentType: AgentType;
@@ -68,6 +55,14 @@ export interface LegacyRoomMigrationPlan {
roleOverrides: RoomRoleOverrideSnapshot[];
}
export interface SyncRoomRoleOverridesOptions {
ownerAgentConfig?: RegisteredGroup['agentConfig'];
ownerCreatedAt?: string;
reviewerAgentConfig?: RegisteredGroup['agentConfig'];
reviewerCreatedAt?: string;
updatedAt?: string;
}
export function normalizeRoomModeSource(
source: string | null | undefined,
): RoomModeSource | undefined {
@@ -82,46 +77,6 @@ export function normalizeStoredAgentType(
: undefined;
}
/**
* Legacy capability-row helper for migration/write flows.
* Runtime read paths must derive capability types from canonical room tables.
*/
export function collectRegisteredAgentTypes(
database: Database,
jid: string,
): AgentType[] {
const rows = database
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
.all(jid) as Array<{ agent_type: string | null }>;
const types = new Set<AgentType>();
for (const row of rows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (agentType) {
types.add(agentType);
}
}
return [...types];
}
export function collectRegisteredAgentTypesForFolder(
database: Database,
folder: string,
): AgentType[] {
const rows = database
.prepare('SELECT agent_type FROM registered_groups WHERE folder = ?')
.all(folder) as Array<{ agent_type: string | null }>;
const types = new Set<AgentType>();
for (const row of rows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (agentType) {
types.add(agentType);
}
}
return [...types];
}
export function inferRoomModeFromRegisteredAgentTypes(
agentTypes: readonly AgentType[],
): RoomMode {
@@ -138,76 +93,6 @@ export function inferOwnerAgentTypeFromRegisteredAgentTypes(
return 'claude-code';
}
export function parseRegisteredGroupRow(
row: RegisteredGroupDatabaseRow | undefined,
): (RegisteredGroup & { jid: string }) | undefined {
if (!row) return undefined;
if (!isValidGroupFolder(row.folder)) {
logger.warn(
{ jid: row.jid, folder: row.folder },
'Skipping registered group with invalid folder',
);
return undefined;
}
return {
jid: row.jid,
name: row.name,
folder: row.folder,
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
isMain: row.is_main === 1 ? true : undefined,
agentType: normalizeStoredAgentType(row.agent_type),
workDir: row.work_dir || undefined,
};
}
export function getPendingLegacyRegisteredGroupJids(
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) => jidRequiresLegacyRoomMigration(database, jid));
}
export function countPendingLegacyRegisteredGroupRows(
database: Database,
): number {
let count = 0;
const countRows = database.prepare(
`SELECT COUNT(*) AS count
FROM registered_groups
WHERE jid = ?`,
);
for (const jid of getPendingLegacyRegisteredGroupJids(database)) {
const row = countRows.get(jid) as { count: number };
count += row.count;
}
return count;
}
export function deleteLegacyRegisteredGroupRowsForJid(
database: Database,
jid: string,
): void {
database
.prepare(
`DELETE FROM registered_groups
WHERE jid = ?`,
)
.run(jid);
}
function getStoredRoomRoleOverrideRows(
database: Database,
jid: string,
@@ -257,193 +142,6 @@ function getStoredRoomRoleOverrideRows(
return result;
}
function normalizeAgentConfigValue(
agentConfig: RegisteredGroup['agentConfig'] | undefined,
): string {
return JSON.stringify(agentConfig ?? null);
}
function roomRoleOverrideMatches(
actual: StoredRoomRoleOverrideRow | undefined,
expected: RoomRoleOverrideSnapshot,
): boolean {
return (
actual?.agentType === expected.agentType &&
normalizeAgentConfigValue(actual.agentConfig) ===
normalizeAgentConfigValue(expected.agentConfig)
);
}
function storedRoomSettingsMatchesLegacySnapshot(
stored: StoredRoomSettings,
snapshot: RoomRegistrationSnapshot,
): boolean {
return (
stored.name === snapshot.name &&
stored.folder === snapshot.folder &&
(stored.requiresTrigger ?? true) === snapshot.requiresTrigger &&
(stored.isMain ?? false) === snapshot.isMain &&
(stored.workDir ?? null) === (snapshot.workDir ?? null)
);
}
function getRegisteredGroupCapabilityMetadata(
database: Database,
jid: string,
preferredAgentType?: AgentType,
): Pick<RegisteredGroup, 'added_at' | 'agentConfig'> | undefined {
const row = (
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;
if (!row) return undefined;
return {
added_at: row.added_at,
agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined,
};
}
function resolveReviewerAgentTypeFromRegisteredAgentTypes(
agentTypes: readonly AgentType[],
ownerAgentType: AgentType,
): AgentType | undefined {
return agentTypes.find((agentType) => agentType !== ownerAgentType);
}
export function buildLegacyRoomMigrationPlan(
database: Database,
jid: string,
): LegacyRoomMigrationPlan | undefined {
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
const snapshot = collectRoomRegistrationSnapshot(
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 = collectRegisteredAgentTypes(database, jid);
const roomMode =
existingStored?.roomMode ??
inferRoomModeFromRegisteredAgentTypes(agentTypes);
const createdAt = rows[0].added_at;
const updatedAt = rows[rows.length - 1].added_at;
const roleOverrides: RoomRoleOverrideSnapshot[] = [];
const ownerMetadata = getRegisteredGroupCapabilityMetadata(
database,
jid,
snapshot.ownerAgentType,
);
roleOverrides.push({
role: 'owner',
agentType: snapshot.ownerAgentType,
agentConfig: ownerMetadata?.agentConfig,
createdAt: ownerMetadata?.added_at ?? createdAt,
updatedAt,
});
if (roomMode === 'tribunal') {
const reviewerAgentType = resolveReviewerAgentTypeFromRegisteredAgentTypes(
agentTypes,
snapshot.ownerAgentType,
);
if (!reviewerAgentType) {
throw new Error(
`Missing reviewer agent type for tribunal legacy room ${jid}`,
);
}
const reviewerMetadata = getRegisteredGroupCapabilityMetadata(
database,
jid,
reviewerAgentType,
);
roleOverrides.push({
role: 'reviewer',
agentType: reviewerAgentType,
agentConfig: reviewerMetadata?.agentConfig,
createdAt: reviewerMetadata?.added_at ?? createdAt,
updatedAt,
});
}
return {
chatJid: jid,
roomMode,
createdAt,
updatedAt,
snapshot,
roleOverrides,
};
}
function jidRequiresLegacyRoomMigration(
database: Database,
jid: string,
): boolean {
let stored: StoredRoomSettings | undefined;
try {
stored = getStoredRoomSettingsRowFromDatabase(database, jid);
} catch {
return true;
}
if (!stored) {
return true;
}
let plan: LegacyRoomMigrationPlan | undefined;
try {
plan = buildLegacyRoomMigrationPlan(database, jid);
} catch {
return true;
}
if (!plan) {
return false;
}
if (!storedRoomSettingsMatchesLegacySnapshot(stored, plan.snapshot)) {
return true;
}
const existingOverrides = getStoredRoomRoleOverrideRows(database, jid);
for (const override of plan.roleOverrides) {
if (
!roomRoleOverrideMatches(existingOverrides.get(override.role), override)
) {
return true;
}
}
return false;
}
export function getStoredRoomSettingsRowFromDatabase(
database: Database,
chatJid: string,
@@ -621,18 +319,9 @@ function collectReservedFolders(
`SELECT folder
FROM room_settings
WHERE folder IS NOT NULL
AND (? IS NULL OR chat_jid != ?)
UNION
SELECT folder
FROM registered_groups
WHERE ? IS NULL OR jid != ?`,
AND (? IS NULL OR chat_jid != ?)`,
)
.all(
exceptChatJid ?? null,
exceptChatJid ?? null,
exceptChatJid ?? null,
exceptChatJid ?? null,
) as Array<{
.all(exceptChatJid ?? null, exceptChatJid ?? null) as Array<{
folder: string | null;
}>;
@@ -698,200 +387,6 @@ export function resolveAssignedRoomFolder(
return buildGeneratedRoomFolder(database, chatJid, name);
}
function getDesiredRegisteredAgentTypes(
roomMode: RoomMode,
ownerAgentType: AgentType,
): AgentType[] {
const types = new Set<AgentType>([ownerAgentType]);
if (roomMode === 'tribunal') {
types.add(REVIEWER_AGENT_TYPE);
}
return [...types];
}
export function materializeRegisteredGroupsForRoom(
database: Database,
chatJid: string,
snapshot: RoomRegistrationSnapshot,
roomMode: RoomMode,
ownerAgentType: AgentType,
ownerAgentConfig?: RegisteredGroup['agentConfig'],
addedAt?: string,
): void {
const now = new Date().toISOString();
const desiredTypes = getDesiredRegisteredAgentTypes(roomMode, ownerAgentType);
const existingRows = database
.prepare(
`SELECT agent_type, added_at, agent_config
FROM registered_groups
WHERE jid = ?`,
)
.all(chatJid) as Array<{
agent_type: string | null;
added_at: string;
agent_config: string | null;
}>;
const existingByType = new Map<
AgentType,
{ added_at: string; agent_config: string | null }
>();
for (const row of existingRows) {
const agentType = normalizeStoredAgentType(row.agent_type);
if (agentType) {
existingByType.set(agentType, row);
}
}
for (const agentType of desiredTypes) {
const existing = existingByType.get(agentType);
const agentConfig =
agentType === ownerAgentType
? (ownerAgentConfig ??
(existing?.agent_config
? JSON.parse(existing.agent_config)
: undefined))
: existing?.agent_config
? JSON.parse(existing.agent_config)
: undefined;
const rowAddedAt = existing?.added_at ?? addedAt ?? now;
database
.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(
chatJid,
snapshot.name,
snapshot.folder,
snapshot.triggerPattern,
rowAddedAt,
agentConfig ? JSON.stringify(agentConfig) : null,
snapshot.requiresTrigger ? 1 : 0,
snapshot.isMain ? 1 : 0,
agentType,
snapshot.workDir,
);
}
const placeholders = desiredTypes.map(() => '?').join(',');
database
.prepare(
`DELETE FROM registered_groups
WHERE jid = ?
AND agent_type NOT IN (${placeholders})`,
)
.run(chatJid, ...desiredTypes);
}
/**
* Legacy snapshot helper for migration/write flows.
* Runtime read paths must not rebuild room metadata from registered_groups.
*/
export function collectRoomRegistrationSnapshot(
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 = collectRegisteredAgentTypes(database, jid);
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
? preferredOwnerRow
? preferredOwnerAgentType
: preferredOwnerAgentType
: inferredOwnerAgentType;
const ownerRow = preferredOwnerRow ?? inferredOwnerRow;
return {
name: first.name,
folder: first.folder,
triggerPattern: preferExplicitTrigger
? existingStored.trigger!
: preferredOwnerRow != null
? preferredOwnerRow.trigger_pattern
: ownerRow.trigger_pattern,
requiresTrigger: (first.requires_trigger ?? 1) === 1,
isMain: (first.is_main ?? 0) === 1,
ownerAgentType,
workDir: first.work_dir ?? null,
};
}
export function insertStoredRoomSettings(
database: Database,
chatJid: string,
@@ -1006,31 +501,24 @@ export function syncRoomRoleOverridesForRoom(
chatJid: string,
roomMode: RoomMode,
ownerAgentType: AgentType,
ownerAgentConfig?: RegisteredGroup['agentConfig'],
addedAt?: string,
options: SyncRoomRoleOverridesOptions = {},
): void {
const now = addedAt ?? new Date().toISOString();
const now = options.updatedAt ?? new Date().toISOString();
const existingOverrides = getStoredRoomRoleOverrideRows(database, chatJid);
const existingOwnerOverride = existingOverrides.get('owner');
const ownerMetadata = getRegisteredGroupCapabilityMetadata(
database,
chatJid,
ownerAgentType,
);
upsertRoomRoleOverride(database, chatJid, {
role: 'owner',
agentType: ownerAgentType,
agentConfig:
ownerAgentConfig ??
options.ownerAgentConfig ??
(existingOwnerOverride?.agentType === ownerAgentType
? existingOwnerOverride.agentConfig
: undefined) ??
ownerMetadata?.agentConfig,
: undefined),
createdAt:
(existingOwnerOverride?.agentType === ownerAgentType
? existingOwnerOverride.createdAt
: undefined) ??
ownerMetadata?.added_at ??
options.ownerCreatedAt ??
now,
updatedAt: now,
});
@@ -1040,24 +528,20 @@ export function syncRoomRoleOverridesForRoom(
ownerAgentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE
: REVIEWER_AGENT_TYPE;
const reviewerMetadata = getRegisteredGroupCapabilityMetadata(
database,
chatJid,
reviewerAgentType,
);
const existingReviewerOverride = existingOverrides.get('reviewer');
upsertRoomRoleOverride(database, chatJid, {
role: 'reviewer',
agentType: reviewerAgentType,
agentConfig:
options.reviewerAgentConfig ??
(existingReviewerOverride?.agentType === reviewerAgentType
? existingReviewerOverride.agentConfig
: undefined) ?? reviewerMetadata?.agentConfig,
: undefined),
createdAt:
(existingReviewerOverride?.agentType === reviewerAgentType
? existingReviewerOverride.createdAt
: undefined) ??
reviewerMetadata?.added_at ??
options.reviewerCreatedAt ??
now,
updatedAt: now,
});

View File

@@ -7,20 +7,13 @@ import {
type RoomModeSource,
type RoomRegistrationSnapshot,
type StoredRoomSettings,
buildLegacyRoomMigrationPlan,
buildRegisteredGroupFromStoredSettings,
collectRegisteredAgentTypes,
countPendingLegacyRegisteredGroupRows,
deleteLegacyRegisteredGroupRowsForJid,
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
getStoredRoomRowsFromDatabase,
getStoredRoomSettingsRowFromDatabase,
inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes,
inferStoredRoomCapabilityTypes,
insertStoredRoomSettings,
insertStoredRoomSettingsFromMigration,
materializeRegisteredGroupsForRoom,
normalizeStoredAgentType,
resolveAssignedRoomFolder,
resolveStoredRoomCapabilityTypes,
@@ -94,13 +87,12 @@ export function getRegisteredGroupFromDatabase(
);
}
function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(
function seedRoomBindingForTestsInDatabase(
database: Database,
jid: string,
group: RegisteredGroup,
): void {
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
const existingRoomMode = getStoredRoomModeRowFromDatabase(database, jid);
if (!isValidGroupFolder(group.folder)) {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
@@ -108,15 +100,17 @@ function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(
database.transaction(() => {
const seededAgentType = group.agentType || 'claude-code';
const agentTypes = new Set<AgentType>(
collectRegisteredAgentTypes(database, jid),
existingStored
? resolveStoredRoomCapabilityTypes(database, existingStored)
: [],
);
agentTypes.add(seededAgentType);
const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([
...agentTypes,
]);
const roomMode =
existingRoomMode?.source === 'explicit'
? existingRoomMode.roomMode
existingStored?.modeSource === 'explicit'
? existingStored.roomMode
: inferredRoomMode;
const ownerAgentType =
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType
@@ -138,30 +132,28 @@ function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(
if (existingStored) {
updateStoredRoomMetadata(database, jid, snapshot);
if (!existingRoomMode || existingRoomMode.source === 'inferred') {
if (existingStored.modeSource === 'inferred') {
upsertStoredRoomModeInDatabase(database, jid, roomMode, 'inferred');
}
} else {
insertStoredRoomSettings(database, jid, roomMode, 'inferred', snapshot);
}
materializeRegisteredGroupsForRoom(
database,
jid,
snapshot,
roomMode,
ownerAgentType,
syncRoomRoleOverridesForRoom(database, jid, roomMode, ownerAgentType, {
ownerAgentConfig:
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
syncRoomRoleOverridesForRoom(
database,
jid,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at,
);
ownerCreatedAt:
seededAgentType === ownerAgentType ? group.added_at : undefined,
reviewerAgentConfig:
roomMode === 'tribunal' && seededAgentType !== ownerAgentType
? group.agentConfig
: undefined,
reviewerCreatedAt:
roomMode === 'tribunal' && seededAgentType !== ownerAgentType
? group.added_at
: undefined,
updatedAt: group.added_at,
});
})();
}
@@ -170,33 +162,7 @@ export function setRegisteredGroupForTestsInDatabase(
jid: string,
group: RegisteredGroup,
): void {
writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(database, jid, group);
}
export function migrateLegacyRoomRegistrationsForTestsInDatabase(
database: Database,
): {
migratedRooms: number;
migratedRoleOverrides: number;
} {
const jids = getPendingLegacyRegisteredGroupJidsFromDatabase(database);
let migratedRooms = 0;
let migratedRoleOverrides = 0;
database.transaction(() => {
for (const jid of jids) {
const plan = buildLegacyRoomMigrationPlan(database, jid);
if (!plan) continue;
insertStoredRoomSettingsFromMigration(database, plan);
for (const override of plan.roleOverrides) {
upsertRoomRoleOverride(database, jid, override);
migratedRoleOverrides += 1;
}
migratedRooms += 1;
}
})();
return { migratedRooms, migratedRoleOverrides };
seedRoomBindingForTestsInDatabase(database, jid, group);
}
export function assignRoomInDatabase(
@@ -264,15 +230,11 @@ export function assignRoomInDatabase(
);
}
syncRoomRoleOverridesForRoom(
database,
chatJid,
roomMode,
ownerAgentType,
input.ownerAgentConfig,
input.addedAt ?? now,
);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, {
ownerAgentConfig: input.ownerAgentConfig,
ownerCreatedAt: input.addedAt ?? now,
updatedAt: input.addedAt ?? now,
});
})();
return getRegisteredGroupFromDatabase(database, chatJid);
@@ -283,25 +245,16 @@ export function updateRegisteredGroupNameInDatabase(
jid: string,
name: string,
): void {
const plan = buildRoomRegistrationPlanForJid(database, jid, { name });
if (!plan) {
const stored = getStoredRoomSettingsRowFromDatabase(database, jid);
if (!stored) {
return;
}
database.transaction(() => {
if (plan.hasStoredRoom) {
updateStoredRoomMetadata(database, jid, plan.snapshot);
} else {
insertStoredRoomSettings(
updateStoredRoomMetadata(
database,
jid,
plan.roomMode,
'inferred',
plan.snapshot,
buildRoomRegistrationSnapshotFromStoredRoom(database, stored, { name }),
);
}
deleteLegacyRegisteredGroupRowsForJid(database, jid);
})();
}
export function getAllRoomBindingsFromDatabase(
@@ -383,33 +336,6 @@ function buildRoomRegistrationSnapshotFromStoredRoom(
};
}
function buildRoomRegistrationPlanForJid(
database: Database,
chatJid: string,
overrides?: Partial<Pick<RoomRegistrationSnapshot, 'name'>>,
):
| {
snapshot: RoomRegistrationSnapshot;
roomMode: RoomMode;
hasStoredRoom: boolean;
}
| undefined {
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
if (!stored) {
return undefined;
}
return {
snapshot: buildRoomRegistrationSnapshotFromStoredRoom(
database,
stored,
overrides,
),
roomMode: stored.roomMode,
hasStoredRoom: true,
};
}
function upsertStoredRoomModeInDatabase(
database: Database,
chatJid: string,
@@ -446,7 +372,6 @@ export function setExplicitRoomModeInDatabase(
roomMode: RoomMode,
): void {
upsertStoredRoomModeInDatabase(database, chatJid, roomMode, 'explicit');
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
}
export function clearExplicitRoomModeInDatabase(
@@ -454,14 +379,18 @@ export function clearExplicitRoomModeInDatabase(
chatJid: string,
): void {
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
const agentTypes = stored
? inferStoredRoomCapabilityTypes(database, stored)
: collectRegisteredAgentTypes(database, chatJid);
if (!stored) {
return;
}
const agentTypes = inferStoredRoomCapabilityTypes(database, stored);
if (agentTypes.length === 0) {
database
.prepare('DELETE FROM room_settings WHERE chat_jid = ?')
.run(chatJid);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
database
.prepare('DELETE FROM room_role_overrides WHERE chat_jid = ?')
.run(chatJid);
return;
}
@@ -471,7 +400,6 @@ export function clearExplicitRoomModeInDatabase(
inferRoomModeFromRegisteredAgentTypes(agentTypes),
'inferred',
);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
}
export function getEffectiveRoomModeFromDatabase(
@@ -491,9 +419,3 @@ export function getEffectiveRuntimeRoomModeFromDatabase(
getStoredRoomSettingsFromDatabase(database, chatJid)?.roomMode ?? 'single'
);
}
export function countPendingLegacyRegisteredGroupRowsInDatabase(
database: Database,
): number {
return countPendingLegacyRegisteredGroupRows(database);
}

View File

@@ -2937,29 +2937,6 @@ export function applySchemaMigrations(
WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`,
);
database.exec(`
UPDATE scheduled_tasks
SET agent_type = COALESCE(
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE jid = scheduled_tasks.chat_jid
AND folder = scheduled_tasks.group_folder
),
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE jid = scheduled_tasks.chat_jid
),
(
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
FROM registered_groups
WHERE folder = scheduled_tasks.group_folder
)
)
WHERE agent_type IS NULL;
`);
try {
database.exec(
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
@@ -2989,114 +2966,6 @@ export function applySchemaMigrations(
WHERE status IN ('produced', 'delivery_retry');
`);
const roomBindingsSql = (
database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
)
.get() as { sql?: string } | undefined
)?.sql;
if (
roomBindingsSql &&
!roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)')
) {
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
const hasAgentType = registeredGroupCols.some(
(col) => col.name === 'agent_type',
);
const hasWorkDir = registeredGroupCols.some(
(col) => col.name === 'work_dir',
);
const hasAgentConfig = registeredGroupCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = registeredGroupCols.some(
(col) => col.name === 'container_config',
);
database.exec(`
CREATE TABLE registered_groups_new (
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)
);
`);
database.exec(`
INSERT INTO registered_groups_new (
jid,
name,
folder,
trigger_pattern,
added_at,
agent_config,
requires_trigger,
is_main,
agent_type,
work_dir
)
SELECT
jid,
name,
folder,
trigger_pattern,
added_at,
${
hasAgentConfig
? 'agent_config'
: hasContainerConfig
? 'container_config'
: 'NULL'
},
requires_trigger,
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
${hasWorkDir ? 'work_dir' : 'NULL'}
FROM registered_groups;
`);
database.exec(`
DROP TABLE registered_groups;
ALTER TABLE registered_groups_new RENAME TO registered_groups;
`);
} else {
database.exec(
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
);
}
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasAgentConfig = registeredGroupCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = registeredGroupCols.some(
(col) => col.name === 'container_config',
);
if (!hasAgentConfig) {
database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`);
}
if (hasContainerConfig) {
database.exec(
`UPDATE registered_groups
SET agent_config = COALESCE(agent_config, container_config)
WHERE container_config IS NOT NULL`,
);
}
const pairedTasksSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,