refactor: finish legacy room cleanup and explicit migration boundary
This commit is contained in:
545
setup/legacy-room-registrations.ts
Normal file
545
setup/legacy-room-registrations.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
.prepare(
|
const rows = database
|
||||||
`SELECT jid, name, folder, trigger_pattern, added_at, agent_config,
|
.prepare(
|
||||||
requires_trigger, is_main, agent_type, work_dir
|
`SELECT jid, name, folder, trigger_pattern, added_at, agent_config,
|
||||||
FROM registered_groups`,
|
requires_trigger, is_main, agent_type, work_dir
|
||||||
)
|
FROM registered_groups`,
|
||||||
.all() as Array<Record<string, unknown>>
|
)
|
||||||
).filter((row) => pendingJids.has(String(row.jid)));
|
.all() as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
const insert = database.prepare(
|
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
385
src/db.test.ts
385
src/db.test.ts
@@ -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,
|
||||||
|
|||||||
16
src/db.ts
16
src/db.ts
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(',')})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
164
src/db/rooms.ts
164
src/db/rooms.ts
@@ -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,
|
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
|
||||||
snapshot,
|
ownerCreatedAt:
|
||||||
roomMode,
|
seededAgentType === ownerAgentType ? group.added_at : undefined,
|
||||||
ownerAgentType,
|
reviewerAgentConfig:
|
||||||
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
|
roomMode === 'tribunal' && seededAgentType !== ownerAgentType
|
||||||
group.added_at,
|
? group.agentConfig
|
||||||
);
|
: undefined,
|
||||||
syncRoomRoleOverridesForRoom(
|
reviewerCreatedAt:
|
||||||
database,
|
roomMode === 'tribunal' && seededAgentType !== ownerAgentType
|
||||||
jid,
|
? group.added_at
|
||||||
roomMode,
|
: undefined,
|
||||||
ownerAgentType,
|
updatedAt: group.added_at,
|
||||||
seededAgentType === ownerAgentType ? group.agentConfig : undefined,
|
});
|
||||||
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,25 +245,16 @@ 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) {
|
database,
|
||||||
updateStoredRoomMetadata(database, jid, plan.snapshot);
|
jid,
|
||||||
} else {
|
buildRoomRegistrationSnapshotFromStoredRoom(database, stored, { name }),
|
||||||
insertStoredRoomSettings(
|
);
|
||||||
database,
|
|
||||||
jid,
|
|
||||||
plan.roomMode,
|
|
||||||
'inferred',
|
|
||||||
plan.snapshot,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
deleteLegacyRegisteredGroupRowsForJid(database, jid);
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllRoomBindingsFromDatabase(
|
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(
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
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')`,
|
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'`,
|
||||||
|
|||||||
Reference in New Issue
Block a user