refactor: complete config unification and split db helpers

This commit is contained in:
ejclaw
2026-04-08 19:05:19 +09:00
parent d996d5ff10
commit 8b9bebd29e
27 changed files with 4752 additions and 3652 deletions

542
src/db/schema.ts Normal file
View File

@@ -0,0 +1,542 @@
import { Database } from 'bun:sqlite';
import { inferAgentTypeFromServiceShadow } from '../role-service-shadow.js';
import {
backfillLegacyServiceSessions,
dropLegacyServiceSessionsTable,
migrateSessionsTableToCompositePk,
} from './sessions.js';
export interface SchemaMigrationHooks {
backfillMessageSeq(database: Database): void;
backfillStoredRoomSettings(database: Database): void;
backfillChannelOwnerRoleMetadata(database: Database): void;
backfillWorkItemServiceShadows(database: Database): void;
backfillServiceHandoffServiceShadows(database: Database): void;
backfillPairedTaskRoleMetadata(database: Database): void;
rebuildWorkItemsCanonicalSchema(database: Database): void;
rebuildChannelOwnerCanonicalSchema(database: Database): void;
rebuildPairedTasksCanonicalSchema(database: Database): void;
rebuildServiceHandoffsCanonicalSchema(database: Database): void;
}
function getTableColumns(database: Database, tableName: string): string[] {
return (
database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
name: string;
}>
).map((column) => column.name);
}
export function tableHasColumn(
database: Database,
tableName: string,
columnName: string,
): boolean {
return getTableColumns(database, tableName).includes(columnName);
}
function tryExecMigration(database: Database, sql: string): void {
try {
database.exec(sql);
} catch {
/* column already exists */
}
}
export function applySchemaMigrations(
database: Database,
args: {
assistantName: string;
hooks: SchemaMigrationHooks;
},
): void {
const { assistantName, hooks } = args;
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`,
);
tryExecMigration(database, `ALTER TABLE room_settings ADD COLUMN name TEXT`);
tryExecMigration(database, `ALTER TABLE room_settings ADD COLUMN folder TEXT`);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN source_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN target_role TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE service_handoffs ADD COLUMN source_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN owner_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN reviewer_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN arbiter_agent_type TEXT`,
);
tryExecMigration(
database,
`ALTER TABLE work_items ADD COLUMN delivery_role TEXT`,
);
database.exec(
`UPDATE service_handoffs
SET target_role = COALESCE(
target_role,
intended_role,
CASE
WHEN reason LIKE 'reviewer-%' THEN 'reviewer'
WHEN reason LIKE 'arbiter-%' THEN 'arbiter'
WHEN reason IS NOT NULL THEN 'owner'
ELSE NULL
END
)
WHERE target_role IS NULL`,
);
database.exec(
`UPDATE service_handoffs
SET source_role = COALESCE(source_role, target_role, intended_role)
WHERE source_role IS NULL`,
);
database.exec(
`UPDATE room_settings
SET mode_source = 'explicit'
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`,
);
database
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
.run(`${assistantName}:%`);
} catch {
/* column already exists */
}
tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`);
hooks.backfillMessageSeq(database);
database.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq);
`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`);
database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent
ON work_items(chat_jid, agent_type, delivery_role, status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
ON work_items(chat_jid, agent_type, IFNULL(delivery_role, ''))
WHERE status IN ('produced', 'delivery_retry');
`);
const registeredGroupsSql = (
database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
)
.get() as { sql?: string } | undefined
)?.sql;
if (
registeredGroupsSql &&
!registeredGroupsSql.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'`,
)
.get() as { sql?: string } | undefined;
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
const pairedTasksNeedsRebuild =
pairedTasksSql &&
(pairedTasksSql.includes('task_policy') ||
!pairedTasksSql.includes('round_trip_count'));
if (pairedTasksNeedsRebuild) {
database.exec(`DROP TABLE IF EXISTS paired_tasks`);
database.exec(`
CREATE TABLE IF NOT EXISTS paired_tasks (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_agent_type TEXT,
reviewer_agent_type TEXT,
arbiter_agent_type TEXT,
title TEXT,
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
completion_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
);
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
`);
}
{
const ptSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
)
.get() as { sql?: string } | undefined;
const ptSql = ptSqlRow?.sql || '';
if (ptSql && !ptSql.includes('arbiter_requested')) {
database.exec(`
CREATE TABLE paired_tasks_new (
id TEXT PRIMARY KEY,
chat_jid TEXT NOT NULL,
group_folder TEXT NOT NULL,
owner_agent_type TEXT,
reviewer_agent_type TEXT,
arbiter_agent_type TEXT,
title TEXT,
source_ref TEXT,
plan_notes TEXT,
review_requested_at TEXT,
round_trip_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
arbiter_verdict TEXT,
arbiter_requested_at TEXT,
completion_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
);
INSERT INTO paired_tasks_new (
id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type,
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
round_trip_count, status, created_at, updated_at
)
SELECT
id, chat_jid, group_folder, owner_agent_type, reviewer_agent_type,
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
round_trip_count, status, created_at, updated_at
FROM paired_tasks;
DROP TABLE paired_tasks;
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
ON paired_tasks(chat_jid, status, updated_at);
`);
}
}
for (const column of [
'owner_agent_type',
'reviewer_agent_type',
'arbiter_agent_type',
]) {
tryExecMigration(
database,
`ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`,
);
}
tryExecMigration(
database,
`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`,
);
for (const table of [
'paired_executions',
'paired_approvals',
'paired_artifacts',
'paired_events',
]) {
database.exec(`DROP TABLE IF EXISTS ${table}`);
}
const pairedWsSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`,
)
.get() as { sql?: string } | undefined;
const pairedWsSql = pairedWsSqlRow?.sql || '';
if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) {
database.exec(`DROP TABLE IF EXISTS paired_workspaces`);
database.exec(`
CREATE TABLE IF NOT EXISTS paired_workspaces (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
role TEXT NOT NULL,
workspace_dir TEXT NOT NULL,
snapshot_source_dir TEXT,
snapshot_ref TEXT,
status TEXT NOT NULL DEFAULT 'ready',
snapshot_refreshed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role IN ('owner', 'reviewer')),
CHECK (status IN ('ready', 'stale'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
ON paired_workspaces(task_id, role);
`);
}
const pairedProjSqlRow = database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`,
)
.get() as { sql?: string } | undefined;
const pairedProjSql = pairedProjSqlRow?.sql || '';
if (pairedProjSql && pairedProjSql.includes('workspace_topology')) {
database.exec(`DROP TABLE IF EXISTS paired_projects`);
database.exec(`
CREATE TABLE IF NOT EXISTS paired_projects (
chat_jid TEXT PRIMARY KEY,
group_folder TEXT NOT NULL,
canonical_work_dir TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
}
migrateSessionsTableToCompositePk(database, 'claude-code');
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`,
);
database.exec(
`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`,
);
database.exec(
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
);
database.exec(
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
);
} catch {
/* columns already exist */
}
hooks.backfillStoredRoomSettings(database);
if (tableHasColumn(database, 'channel_owner', 'owner_service_id')) {
hooks.backfillChannelOwnerRoleMetadata(database);
}
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
if (tableHasColumn(database, 'work_items', 'service_id')) {
hooks.backfillWorkItemServiceShadows(database);
}
if (tableHasColumn(database, 'service_handoffs', 'source_service_id')) {
hooks.backfillServiceHandoffServiceShadows(database);
}
if (tableHasColumn(database, 'paired_tasks', 'owner_service_id')) {
hooks.backfillPairedTaskRoleMetadata(database);
}
hooks.rebuildWorkItemsCanonicalSchema(database);
dropLegacyServiceSessionsTable(database);
hooks.rebuildChannelOwnerCanonicalSchema(database);
hooks.rebuildPairedTasksCanonicalSchema(database);
hooks.rebuildServiceHandoffsCanonicalSchema(database);
}