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', agent_type: 'claude-code',
}, },
]); ]);
expect(
migratedDb
.prepare(
`SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name = 'registered_groups'`,
)
.get(),
).toBeUndefined();
expect( expect(
migratedDb migratedDb
.prepare( .prepare(

View File

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

View File

@@ -5,7 +5,7 @@ import { Database } from 'bun:sqlite';
import { STORE_DIR } from '../src/config.js'; import { STORE_DIR } from '../src/config.js';
import { listUnexpectedDataStateFiles } from '../src/data-state-files.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 { export interface AssignedRoomsSummary {
assignedRooms: number; assignedRooms: number;

View File

@@ -81,9 +81,14 @@ import {
buildPairedTurnAttemptParentId, buildPairedTurnAttemptParentId,
} from './db/paired-turn-attempts.js'; } from './db/paired-turn-attempts.js';
import { import {
buildLegacyRoomMigrationPlan, type RoomRegistrationSnapshot,
getPendingLegacyRegisteredGroupJids, type RoomRoleOverrideSnapshot,
type StoredRoomSettings,
getStoredRoomSettingsRowFromDatabase,
inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes,
insertStoredRoomSettingsFromMigration, insertStoredRoomSettingsFromMigration,
normalizeStoredAgentType,
upsertRoomRoleOverride, upsertRoomRoleOverride,
} from './db/room-registration.js'; } from './db/room-registration.js';
import { buildPairedTurnIdentity } from './paired-turn-identity.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): { function migrateLegacyRoomRegistrationsInFile(dbPath: string): {
migratedRooms: number; migratedRooms: number;
migratedRoleOverrides: number; migratedRoleOverrides: number;
@@ -182,12 +502,12 @@ function migrateLegacyRoomRegistrationsInFile(dbPath: string): {
try { try {
initializeDatabaseSchema(migrationDb); initializeDatabaseSchema(migrationDb);
migrationDb.transaction(() => { migrationDb.transaction(() => {
const rows = getPendingLegacyRegisteredGroupJids(migrationDb).map( const rows = getPendingLegacyRegisteredGroupJidsForTests(migrationDb).map(
(jid) => ({ jid }), (jid) => ({ jid }),
); );
for (const row of rows) { for (const row of rows) {
const plan = buildLegacyRoomMigrationPlan(migrationDb, row.jid); const plan = buildLegacyRoomMigrationPlanForTests(migrationDb, row.jid);
if (!plan) continue; if (!plan) continue;
const existing = migrationDb const existing = migrationDb
.prepare('SELECT 1 FROM room_settings WHERE chat_jid = ?') .prepare('SELECT 1 FROM room_settings WHERE chat_jid = ?')
@@ -201,6 +521,7 @@ function migrateLegacyRoomRegistrationsInFile(dbPath: string): {
migratedRoleOverrides += 1; migratedRoleOverrides += 1;
} }
} }
migrationDb.exec(`DROP TABLE IF EXISTS registered_groups`);
})(); })();
} finally { } finally {
migrationDb.close(); migrationDb.close();
@@ -2118,7 +2439,7 @@ describe('room assignment writes', () => {
).toBeUndefined(); ).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 tempDir = fs.mkdtempSync('/tmp/ejclaw-room-assign-canonical-');
const dbPath = path.join(tempDir, 'messages.db'); const dbPath = path.join(tempDir, 'messages.db');
@@ -2131,16 +2452,17 @@ describe('room assignment writes', () => {
}); });
const rawDb = new Database(dbPath, { readonly: true }); const rawDb = new Database(dbPath, { readonly: true });
const projectionRows = rawDb const legacyTable = rawDb
.prepare( .prepare(
`SELECT agent_type `SELECT name
FROM registered_groups FROM sqlite_master
WHERE jid = ?`, WHERE type = 'table'
AND name = 'registered_groups'`,
) )
.all('dc:assign-no-projection'); .get();
rawDb.close(); rawDb.close();
expect(projectionRows).toEqual([]); expect(legacyTable).toBeUndefined();
expect( expect(
getAllRoomBindings('claude-code')['dc:assign-no-projection'], getAllRoomBindings('claude-code')['dc:assign-no-projection'],
).toMatchObject({ ).toMatchObject({
@@ -2473,7 +2795,7 @@ describe('room assignment writes', () => {
legacyDb.close(); legacyDb.close();
const pendingDb = new Database(dbPath, { readonly: true }); const pendingDb = new Database(dbPath, { readonly: true });
expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([ expect(getPendingLegacyRegisteredGroupJidsForTests(pendingDb)).toEqual([
'dc:mixed-conflict', 'dc:mixed-conflict',
]); ]);
pendingDb.close(); pendingDb.close();
@@ -2654,7 +2976,7 @@ describe('room assignment writes', () => {
expect(getRegisteredAgentTypesForJid('dc:ssot-room')).toEqual(['codex']); 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 tempDir = fs.mkdtempSync('/tmp/ejclaw-room-writeback-');
const dbPath = path.join(tempDir, 'messages.db'); const dbPath = path.join(tempDir, 'messages.db');
const legacyDb = new Database(dbPath); const legacyDb = new Database(dbPath);
@@ -2777,21 +3099,18 @@ describe('room assignment writes', () => {
ownerAgentType: 'codex', ownerAgentType: 'codex',
}); });
const rawDb = new Database(dbPath); const rawDb = new Database(dbPath, { readonly: true });
let projectionRows = rawDb const legacyTable = rawDb
.prepare( .prepare(
`SELECT agent_type, name `SELECT name
FROM registered_groups FROM sqlite_master
WHERE jid = ? WHERE type = 'table'
ORDER BY agent_type`, AND name = 'registered_groups'`,
) )
.all('dc:ssot-writeback') as Array<{ .get();
agent_type: string | null;
name: string;
}>;
rawDb.close(); rawDb.close();
expect(projectionRows).toEqual([]); expect(legacyTable).toBeUndefined();
_initTestDatabaseFromFile(dbPath); _initTestDatabaseFromFile(dbPath);
@@ -2802,10 +3121,6 @@ describe('room assignment writes', () => {
name: 'SSOT Writeback Renamed', name: 'SSOT Writeback Renamed',
ownerAgentType: 'codex', ownerAgentType: 'codex',
}); });
const pendingDb = new Database(dbPath);
expect(getPendingLegacyRegisteredGroupJids(pendingDb)).toEqual([]);
pendingDb.close();
clearExplicitRoomMode('dc:ssot-writeback'); clearExplicitRoomMode('dc:ssot-writeback');
expect(getExplicitRoomMode('dc:ssot-writeback')).toBeUndefined(); expect(getExplicitRoomMode('dc:ssot-writeback')).toBeUndefined();
@@ -3226,20 +3541,6 @@ describe('service handoff completion', () => {
work_dir TEXT, work_dir TEXT,
updated_at TEXT NOT NULL 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 ( CREATE TABLE service_handoffs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_jid TEXT NOT NULL, chat_jid TEXT NOT NULL,

View File

@@ -142,7 +142,6 @@ import {
getRegisteredAgentTypesForJidFromDatabase, getRegisteredAgentTypesForJidFromDatabase,
getRegisteredGroupFromDatabase, getRegisteredGroupFromDatabase,
getStoredRoomSettingsFromDatabase, getStoredRoomSettingsFromDatabase,
migrateLegacyRoomRegistrationsForTestsInDatabase,
setExplicitRoomModeInDatabase, setExplicitRoomModeInDatabase,
setRegisteredGroupForTestsInDatabase, setRegisteredGroupForTestsInDatabase,
setStoredRoomOwnerAgentTypeForTestsInDatabase, setStoredRoomOwnerAgentTypeForTestsInDatabase,
@@ -678,10 +677,7 @@ export function getRegisteredGroup(
return getRegisteredGroupFromDatabase(db, jid, agentType); return getRegisteredGroupFromDatabase(db, jid, agentType);
} }
/** /** @internal Test helper for seeding canonical room bindings. */
* @internal Test/migration helper for seeding legacy capability rows.
* Runtime code must use assignRoom() so room_settings remains the explicit SSOT.
*/
export function _setRegisteredGroupForTests( export function _setRegisteredGroupForTests(
jid: string, jid: string,
group: RegisteredGroup, group: RegisteredGroup,
@@ -689,16 +685,6 @@ export function _setRegisteredGroupForTests(
setRegisteredGroupForTestsInDatabase(db, jid, group); 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( export function assignRoom(
chatJid: string, chatJid: string,
input: AssignRoomInput, input: AssignRoomInput,

View File

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

View File

@@ -8,7 +8,6 @@ import {
openPersistentDatabase, openPersistentDatabase,
initializeDatabaseSchema, initializeDatabaseSchema,
} from './bootstrap.js'; } from './bootstrap.js';
import { countPendingLegacyRegisteredGroupRows } from './room-registration.js';
import { import {
clearExpiredPairedTaskExecutionLeasesInDatabase, clearExpiredPairedTaskExecutionLeasesInDatabase,
clearPairedTaskExecutionLeasesForServiceInDatabase, clearPairedTaskExecutionLeasesForServiceInDatabase,
@@ -23,14 +22,47 @@ function finalizeDatabaseInitialization(database: Database): void {
clearExpiredPairedTaskExecutionLeasesInDatabase(database); 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 { function assertNoPendingLegacyRoomMigration(database: Database): void {
const pendingLegacyRows = countPendingLegacyRegisteredGroupRows(database); const pendingTables = getUnexpectedLegacyRoomBindingTables(database);
if (pendingLegacyRows === 0) { if (pendingTables.length === 0) {
return; return;
} }
throw new Error( 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; 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 { export interface RoomRoleOverrideSnapshot {
role: 'owner' | 'reviewer' | 'arbiter'; role: 'owner' | 'reviewer' | 'arbiter';
agentType: AgentType; agentType: AgentType;
@@ -68,6 +55,14 @@ export interface LegacyRoomMigrationPlan {
roleOverrides: RoomRoleOverrideSnapshot[]; roleOverrides: RoomRoleOverrideSnapshot[];
} }
export interface SyncRoomRoleOverridesOptions {
ownerAgentConfig?: RegisteredGroup['agentConfig'];
ownerCreatedAt?: string;
reviewerAgentConfig?: RegisteredGroup['agentConfig'];
reviewerCreatedAt?: string;
updatedAt?: string;
}
export function normalizeRoomModeSource( export function normalizeRoomModeSource(
source: string | null | undefined, source: string | null | undefined,
): RoomModeSource | undefined { ): RoomModeSource | undefined {
@@ -82,46 +77,6 @@ export function normalizeStoredAgentType(
: undefined; : 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( export function inferRoomModeFromRegisteredAgentTypes(
agentTypes: readonly AgentType[], agentTypes: readonly AgentType[],
): RoomMode { ): RoomMode {
@@ -138,76 +93,6 @@ export function inferOwnerAgentTypeFromRegisteredAgentTypes(
return 'claude-code'; 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( function getStoredRoomRoleOverrideRows(
database: Database, database: Database,
jid: string, jid: string,
@@ -257,193 +142,6 @@ function getStoredRoomRoleOverrideRows(
return result; 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( export function getStoredRoomSettingsRowFromDatabase(
database: Database, database: Database,
chatJid: string, chatJid: string,
@@ -621,18 +319,9 @@ function collectReservedFolders(
`SELECT folder `SELECT folder
FROM room_settings FROM room_settings
WHERE folder IS NOT NULL WHERE folder IS NOT NULL
AND (? IS NULL OR chat_jid != ?) AND (? IS NULL OR chat_jid != ?)`,
UNION
SELECT folder
FROM registered_groups
WHERE ? IS NULL OR jid != ?`,
) )
.all( .all(exceptChatJid ?? null, exceptChatJid ?? null) as Array<{
exceptChatJid ?? null,
exceptChatJid ?? null,
exceptChatJid ?? null,
exceptChatJid ?? null,
) as Array<{
folder: string | null; folder: string | null;
}>; }>;
@@ -698,200 +387,6 @@ export function resolveAssignedRoomFolder(
return buildGeneratedRoomFolder(database, chatJid, name); 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( export function insertStoredRoomSettings(
database: Database, database: Database,
chatJid: string, chatJid: string,
@@ -1006,31 +501,24 @@ export function syncRoomRoleOverridesForRoom(
chatJid: string, chatJid: string,
roomMode: RoomMode, roomMode: RoomMode,
ownerAgentType: AgentType, ownerAgentType: AgentType,
ownerAgentConfig?: RegisteredGroup['agentConfig'], options: SyncRoomRoleOverridesOptions = {},
addedAt?: string,
): void { ): void {
const now = addedAt ?? new Date().toISOString(); const now = options.updatedAt ?? new Date().toISOString();
const existingOverrides = getStoredRoomRoleOverrideRows(database, chatJid); const existingOverrides = getStoredRoomRoleOverrideRows(database, chatJid);
const existingOwnerOverride = existingOverrides.get('owner'); const existingOwnerOverride = existingOverrides.get('owner');
const ownerMetadata = getRegisteredGroupCapabilityMetadata(
database,
chatJid,
ownerAgentType,
);
upsertRoomRoleOverride(database, chatJid, { upsertRoomRoleOverride(database, chatJid, {
role: 'owner', role: 'owner',
agentType: ownerAgentType, agentType: ownerAgentType,
agentConfig: agentConfig:
ownerAgentConfig ?? options.ownerAgentConfig ??
(existingOwnerOverride?.agentType === ownerAgentType (existingOwnerOverride?.agentType === ownerAgentType
? existingOwnerOverride.agentConfig ? existingOwnerOverride.agentConfig
: undefined) ?? : undefined),
ownerMetadata?.agentConfig,
createdAt: createdAt:
(existingOwnerOverride?.agentType === ownerAgentType (existingOwnerOverride?.agentType === ownerAgentType
? existingOwnerOverride.createdAt ? existingOwnerOverride.createdAt
: undefined) ?? : undefined) ??
ownerMetadata?.added_at ?? options.ownerCreatedAt ??
now, now,
updatedAt: now, updatedAt: now,
}); });
@@ -1040,24 +528,20 @@ export function syncRoomRoleOverridesForRoom(
ownerAgentType === REVIEWER_AGENT_TYPE ownerAgentType === REVIEWER_AGENT_TYPE
? OWNER_AGENT_TYPE ? OWNER_AGENT_TYPE
: REVIEWER_AGENT_TYPE; : REVIEWER_AGENT_TYPE;
const reviewerMetadata = getRegisteredGroupCapabilityMetadata(
database,
chatJid,
reviewerAgentType,
);
const existingReviewerOverride = existingOverrides.get('reviewer'); const existingReviewerOverride = existingOverrides.get('reviewer');
upsertRoomRoleOverride(database, chatJid, { upsertRoomRoleOverride(database, chatJid, {
role: 'reviewer', role: 'reviewer',
agentType: reviewerAgentType, agentType: reviewerAgentType,
agentConfig: agentConfig:
options.reviewerAgentConfig ??
(existingReviewerOverride?.agentType === reviewerAgentType (existingReviewerOverride?.agentType === reviewerAgentType
? existingReviewerOverride.agentConfig ? existingReviewerOverride.agentConfig
: undefined) ?? reviewerMetadata?.agentConfig, : undefined),
createdAt: createdAt:
(existingReviewerOverride?.agentType === reviewerAgentType (existingReviewerOverride?.agentType === reviewerAgentType
? existingReviewerOverride.createdAt ? existingReviewerOverride.createdAt
: undefined) ?? : undefined) ??
reviewerMetadata?.added_at ?? options.reviewerCreatedAt ??
now, now,
updatedAt: now, updatedAt: now,
}); });

View File

@@ -7,20 +7,13 @@ import {
type RoomModeSource, type RoomModeSource,
type RoomRegistrationSnapshot, type RoomRegistrationSnapshot,
type StoredRoomSettings, type StoredRoomSettings,
buildLegacyRoomMigrationPlan,
buildRegisteredGroupFromStoredSettings, buildRegisteredGroupFromStoredSettings,
collectRegisteredAgentTypes,
countPendingLegacyRegisteredGroupRows,
deleteLegacyRegisteredGroupRowsForJid,
getPendingLegacyRegisteredGroupJids as getPendingLegacyRegisteredGroupJidsFromDatabase,
getStoredRoomRowsFromDatabase, getStoredRoomRowsFromDatabase,
getStoredRoomSettingsRowFromDatabase, getStoredRoomSettingsRowFromDatabase,
inferOwnerAgentTypeFromRegisteredAgentTypes, inferOwnerAgentTypeFromRegisteredAgentTypes,
inferRoomModeFromRegisteredAgentTypes, inferRoomModeFromRegisteredAgentTypes,
inferStoredRoomCapabilityTypes, inferStoredRoomCapabilityTypes,
insertStoredRoomSettings, insertStoredRoomSettings,
insertStoredRoomSettingsFromMigration,
materializeRegisteredGroupsForRoom,
normalizeStoredAgentType, normalizeStoredAgentType,
resolveAssignedRoomFolder, resolveAssignedRoomFolder,
resolveStoredRoomCapabilityTypes, resolveStoredRoomCapabilityTypes,
@@ -94,13 +87,12 @@ export function getRegisteredGroupFromDatabase(
); );
} }
function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase( function seedRoomBindingForTestsInDatabase(
database: Database, database: Database,
jid: string, jid: string,
group: RegisteredGroup, group: RegisteredGroup,
): void { ): void {
const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid); const existingStored = getStoredRoomSettingsRowFromDatabase(database, jid);
const existingRoomMode = getStoredRoomModeRowFromDatabase(database, 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}`);
} }
@@ -108,15 +100,17 @@ function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(
database.transaction(() => { database.transaction(() => {
const seededAgentType = group.agentType || 'claude-code'; const seededAgentType = group.agentType || 'claude-code';
const agentTypes = new Set<AgentType>( const agentTypes = new Set<AgentType>(
collectRegisteredAgentTypes(database, jid), existingStored
? resolveStoredRoomCapabilityTypes(database, existingStored)
: [],
); );
agentTypes.add(seededAgentType); agentTypes.add(seededAgentType);
const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([ const inferredRoomMode = inferRoomModeFromRegisteredAgentTypes([
...agentTypes, ...agentTypes,
]); ]);
const roomMode = const roomMode =
existingRoomMode?.source === 'explicit' existingStored?.modeSource === 'explicit'
? existingRoomMode.roomMode ? existingStored.roomMode
: inferredRoomMode; : inferredRoomMode;
const ownerAgentType = const ownerAgentType =
existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType existingStored?.modeSource === 'explicit' && existingStored.ownerAgentType
@@ -138,30 +132,28 @@ function writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(
if (existingStored) { if (existingStored) {
updateStoredRoomMetadata(database, jid, snapshot); updateStoredRoomMetadata(database, jid, snapshot);
if (!existingRoomMode || existingRoomMode.source === 'inferred') { if (existingStored.modeSource === 'inferred') {
upsertStoredRoomModeInDatabase(database, jid, roomMode, 'inferred'); upsertStoredRoomModeInDatabase(database, jid, roomMode, 'inferred');
} }
} else { } else {
insertStoredRoomSettings(database, jid, roomMode, 'inferred', snapshot); insertStoredRoomSettings(database, jid, roomMode, 'inferred', snapshot);
} }
materializeRegisteredGroupsForRoom( syncRoomRoleOverridesForRoom(database, jid, roomMode, ownerAgentType, {
database, ownerAgentConfig:
jid,
snapshot,
roomMode,
ownerAgentType,
seededAgentType === ownerAgentType ? group.agentConfig : undefined, seededAgentType === ownerAgentType ? group.agentConfig : undefined,
group.added_at, ownerCreatedAt:
); seededAgentType === ownerAgentType ? group.added_at : undefined,
syncRoomRoleOverridesForRoom( reviewerAgentConfig:
database, roomMode === 'tribunal' && seededAgentType !== ownerAgentType
jid, ? group.agentConfig
roomMode, : undefined,
ownerAgentType, reviewerCreatedAt:
seededAgentType === ownerAgentType ? group.agentConfig : undefined, roomMode === 'tribunal' && seededAgentType !== ownerAgentType
group.added_at, ? group.added_at
); : undefined,
updatedAt: group.added_at,
});
})(); })();
} }
@@ -170,33 +162,7 @@ export function setRegisteredGroupForTestsInDatabase(
jid: string, jid: string,
group: RegisteredGroup, group: RegisteredGroup,
): void { ): void {
writeLegacyRegisteredGroupAndSyncRoomSettingsInDatabase(database, jid, group); seedRoomBindingForTestsInDatabase(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 };
} }
export function assignRoomInDatabase( export function assignRoomInDatabase(
@@ -264,15 +230,11 @@ export function assignRoomInDatabase(
); );
} }
syncRoomRoleOverridesForRoom( syncRoomRoleOverridesForRoom(database, chatJid, roomMode, ownerAgentType, {
database, ownerAgentConfig: input.ownerAgentConfig,
chatJid, ownerCreatedAt: input.addedAt ?? now,
roomMode, updatedAt: input.addedAt ?? now,
ownerAgentType, });
input.ownerAgentConfig,
input.addedAt ?? now,
);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
})(); })();
return getRegisteredGroupFromDatabase(database, chatJid); return getRegisteredGroupFromDatabase(database, chatJid);
@@ -283,26 +245,17 @@ export function updateRegisteredGroupNameInDatabase(
jid: string, jid: string,
name: string, name: string,
): void { ): void {
const plan = buildRoomRegistrationPlanForJid(database, jid, { name }); const stored = getStoredRoomSettingsRowFromDatabase(database, jid);
if (!plan) { if (!stored) {
return; return;
} }
database.transaction(() => { updateStoredRoomMetadata(
if (plan.hasStoredRoom) {
updateStoredRoomMetadata(database, jid, plan.snapshot);
} else {
insertStoredRoomSettings(
database, database,
jid, jid,
plan.roomMode, buildRoomRegistrationSnapshotFromStoredRoom(database, stored, { name }),
'inferred',
plan.snapshot,
); );
} }
deleteLegacyRegisteredGroupRowsForJid(database, jid);
})();
}
export function getAllRoomBindingsFromDatabase( export function getAllRoomBindingsFromDatabase(
database: Database, database: Database,
@@ -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( function upsertStoredRoomModeInDatabase(
database: Database, database: Database,
chatJid: string, chatJid: string,
@@ -446,7 +372,6 @@ export function setExplicitRoomModeInDatabase(
roomMode: RoomMode, roomMode: RoomMode,
): void { ): void {
upsertStoredRoomModeInDatabase(database, chatJid, roomMode, 'explicit'); upsertStoredRoomModeInDatabase(database, chatJid, roomMode, 'explicit');
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
} }
export function clearExplicitRoomModeInDatabase( export function clearExplicitRoomModeInDatabase(
@@ -454,14 +379,18 @@ export function clearExplicitRoomModeInDatabase(
chatJid: string, chatJid: string,
): void { ): void {
const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid); const stored = getStoredRoomSettingsRowFromDatabase(database, chatJid);
const agentTypes = stored if (!stored) {
? inferStoredRoomCapabilityTypes(database, stored) return;
: collectRegisteredAgentTypes(database, chatJid); }
const agentTypes = inferStoredRoomCapabilityTypes(database, stored);
if (agentTypes.length === 0) { if (agentTypes.length === 0) {
database database
.prepare('DELETE FROM room_settings WHERE chat_jid = ?') .prepare('DELETE FROM room_settings WHERE chat_jid = ?')
.run(chatJid); .run(chatJid);
deleteLegacyRegisteredGroupRowsForJid(database, chatJid); database
.prepare('DELETE FROM room_role_overrides WHERE chat_jid = ?')
.run(chatJid);
return; return;
} }
@@ -471,7 +400,6 @@ export function clearExplicitRoomModeInDatabase(
inferRoomModeFromRegisteredAgentTypes(agentTypes), inferRoomModeFromRegisteredAgentTypes(agentTypes),
'inferred', 'inferred',
); );
deleteLegacyRegisteredGroupRowsForJid(database, chatJid);
} }
export function getEffectiveRoomModeFromDatabase( export function getEffectiveRoomModeFromDatabase(
@@ -491,9 +419,3 @@ export function getEffectiveRuntimeRoomModeFromDatabase(
getStoredRoomSettingsFromDatabase(database, chatJid)?.roomMode ?? 'single' 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')`, 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 { try {
database.exec( database.exec(
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
@@ -2989,114 +2966,6 @@ export function applySchemaMigrations(
WHERE status IN ('produced', 'delivery_retry'); 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 const pairedTasksSqlRow = database
.prepare( .prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`, `SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,