refactor: finish legacy room cleanup and explicit migration boundary
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(',')})`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
164
src/db/rooms.ts
164
src/db/rooms.ts
@@ -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,
|
||||
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
|
||||
group.added_at,
|
||||
);
|
||||
syncRoomRoleOverridesForRoom(
|
||||
database,
|
||||
jid,
|
||||
roomMode,
|
||||
ownerAgentType,
|
||||
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
|
||||
group.added_at,
|
||||
);
|
||||
syncRoomRoleOverridesForRoom(database, jid, roomMode, ownerAgentType, {
|
||||
ownerAgentConfig:
|
||||
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
|
||||
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(
|
||||
database,
|
||||
jid,
|
||||
plan.roomMode,
|
||||
'inferred',
|
||||
plan.snapshot,
|
||||
);
|
||||
}
|
||||
deleteLegacyRegisteredGroupRowsForJid(database, jid);
|
||||
})();
|
||||
updateStoredRoomMetadata(
|
||||
database,
|
||||
jid,
|
||||
buildRoomRegistrationSnapshotFromStoredRoom(database, stored, { name }),
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
131
src/db/schema.ts
131
src/db/schema.ts
@@ -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'`,
|
||||
|
||||
Reference in New Issue
Block a user