// SQLite-backed persistence for projects, conversations, messages, and the // per-project set of open file tabs. The on-disk project folder under // .od/projects// is still the single owner of the user's actual files // (HTML artifacts, sketches, uploads); this database tracks the metadata // that used to live in localStorage. import Database from 'better-sqlite3'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; import { migrateCritique } from './critique/persistence.js'; import { migrateMediaTasks } from './media-tasks.js'; import { migratePlugins } from './plugins/persistence.js'; type SqliteDb = Database.Database; type DbRow = Record; type JsonObject = Record; let dbInstance: SqliteDb | null = null; let dbFile: string | null = null; function row(value: unknown): DbRow | null { return value && typeof value === 'object' ? value as DbRow : null; } function rows(value: unknown[]): DbRow[] { return value.map((item) => row(item) ?? {}); } export function openDatabase(projectRoot: string, { dataDir }: { dataDir?: string } = {}): SqliteDb { const dir = dataDir ? path.resolve(dataDir) : path.join(projectRoot, '.od'); const file = path.join(dir, 'app.sqlite'); if (dbInstance && dbFile === file) return dbInstance; if (dbInstance) closeDatabase(); fs.mkdirSync(dir, { recursive: true }); const db = new Database(file); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); migrate(db); dbInstance = db; dbFile = file; return db; } export function closeDatabase() { if (!dbInstance) return; dbInstance.close(); dbInstance = null; dbFile = null; } function migrate(db: SqliteDb): void { db.exec(` CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, skill_id TEXT, design_system_id TEXT, pending_prompt TEXT, metadata_json TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS templates ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, source_project_id TEXT, files_json TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_conv_project ON conversations(project_id, updated_at DESC); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, agent_id TEXT, agent_name TEXT, events_json TEXT, attachments_json TEXT, produced_files_json TEXT, feedback_json TEXT, pre_turn_file_names_json TEXT, started_at INTEGER, ended_at INTEGER, position INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, position); CREATE TABLE IF NOT EXISTS preview_comments ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, conversation_id TEXT NOT NULL, file_path TEXT NOT NULL, element_id TEXT NOT NULL, selector TEXT NOT NULL, label TEXT NOT NULL, text TEXT NOT NULL, position_json TEXT NOT NULL, html_hint TEXT NOT NULL, style_json TEXT, note TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(project_id, conversation_id, file_path, element_id), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_preview_comments_conversation ON preview_comments(project_id, conversation_id, updated_at DESC); CREATE TABLE IF NOT EXISTS tabs ( project_id TEXT NOT NULL, name TEXT NOT NULL, position INTEGER NOT NULL, is_active INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(project_id, name), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS tabs_state ( project_id TEXT PRIMARY KEY, updated_at INTEGER NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_tabs_project ON tabs(project_id, position); CREATE TABLE IF NOT EXISTS deployments ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, file_name TEXT NOT NULL, provider_id TEXT NOT NULL, url TEXT NOT NULL, deployment_id TEXT, deployment_count INTEGER NOT NULL DEFAULT 1, target TEXT NOT NULL DEFAULT 'preview', status TEXT NOT NULL DEFAULT 'ready', status_message TEXT, reachable_at INTEGER, provider_metadata_json TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(project_id, file_name, provider_id), FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_deployments_project ON deployments(project_id, updated_at DESC); CREATE TABLE IF NOT EXISTS routines ( id TEXT PRIMARY KEY, name TEXT NOT NULL, prompt TEXT NOT NULL, schedule_kind TEXT NOT NULL, schedule_value TEXT NOT NULL, schedule_json TEXT, project_mode TEXT NOT NULL, project_id TEXT, skill_id TEXT, agent_id TEXT, context_json TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS routine_runs ( id TEXT PRIMARY KEY, routine_id TEXT NOT NULL, trigger TEXT NOT NULL, status TEXT NOT NULL, project_id TEXT NOT NULL, conversation_id TEXT NOT NULL, agent_run_id TEXT NOT NULL, started_at INTEGER NOT NULL, completed_at INTEGER, summary TEXT, error TEXT, error_code TEXT, FOREIGN KEY(routine_id) REFERENCES routines(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS routine_schedule_claims ( routine_id TEXT NOT NULL, slot_at INTEGER NOT NULL, claimed_at INTEGER NOT NULL, PRIMARY KEY(routine_id, slot_at), FOREIGN KEY(routine_id) REFERENCES routines(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_routine_runs_routine ON routine_runs(routine_id, started_at DESC); `); // Forward-compatible column add for databases created before metadata_json. // SQLite has no IF NOT EXISTS for ALTER, so we check pragma_table_info. const cols = db.prepare(`PRAGMA table_info(projects)`).all() as DbRow[]; if (!cols.some((c: DbRow) => c.name === 'metadata_json')) { db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`); } if (!cols.some((c: DbRow) => c.name === 'custom_instructions')) { db.exec(`ALTER TABLE projects ADD COLUMN custom_instructions TEXT`); } const messageCols = db.prepare(`PRAGMA table_info(messages)`).all() as DbRow[]; if (!messageCols.some((c: DbRow) => c.name === 'agent_id')) { db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`); } if (!messageCols.some((c: DbRow) => c.name === 'agent_name')) { db.exec(`ALTER TABLE messages ADD COLUMN agent_name TEXT`); } if (!messageCols.some((c: DbRow) => c.name === 'run_id')) { db.exec(`ALTER TABLE messages ADD COLUMN run_id TEXT`); } if (!messageCols.some((c: DbRow) => c.name === 'run_status')) { db.exec(`ALTER TABLE messages ADD COLUMN run_status TEXT`); } if (!messageCols.some((c: DbRow) => c.name === 'last_run_event_id')) { db.exec(`ALTER TABLE messages ADD COLUMN last_run_event_id TEXT`); } if (!messageCols.some((c: DbRow) => c.name === 'comment_attachments_json')) { db.exec(`ALTER TABLE messages ADD COLUMN comment_attachments_json TEXT`); } if (!messageCols.some((c: DbRow) => c.name === 'feedback_json')) { db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`); } if (!messageCols.some((c: DbRow) => c.name === 'pre_turn_file_names_json')) { db.exec(`ALTER TABLE messages ADD COLUMN pre_turn_file_names_json TEXT`); } const routineRunCols = db.prepare(`PRAGMA table_info(routine_runs)`).all() as DbRow[]; if (!routineRunCols.some((c: DbRow) => c.name === 'error_code')) { db.exec(`ALTER TABLE routine_runs ADD COLUMN error_code TEXT`); } const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all() as DbRow[]; if (!previewCommentCols.some((c: DbRow) => c.name === 'selection_kind')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN selection_kind TEXT`); } if (!previewCommentCols.some((c: DbRow) => c.name === 'member_count')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN member_count INTEGER`); } if (!previewCommentCols.some((c: DbRow) => c.name === 'pod_members_json')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`); } if (!previewCommentCols.some((c: DbRow) => c.name === 'style_json')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN style_json TEXT`); } const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all() as DbRow[]; if (!deploymentCols.some((c: DbRow) => c.name === 'status')) { db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`); } if (!deploymentCols.some((c: DbRow) => c.name === 'status_message')) { db.exec(`ALTER TABLE deployments ADD COLUMN status_message TEXT`); } if (!deploymentCols.some((c: DbRow) => c.name === 'reachable_at')) { db.exec(`ALTER TABLE deployments ADD COLUMN reachable_at INTEGER`); } if (!deploymentCols.some((c: DbRow) => c.name === 'provider_metadata_json')) { db.exec(`ALTER TABLE deployments ADD COLUMN provider_metadata_json TEXT`); } // schedule_json holds the full RoutineSchedule object (kind discriminator // plus kind-specific fields like time/timezone/weekday). The legacy // schedule_kind/schedule_value columns are kept populated for query // convenience and as a fallback when reading rows written before this // column existed. const routineCols = db.prepare(`PRAGMA table_info(routines)`).all() as DbRow[]; if (routineCols.length > 0 && !routineCols.some((c: DbRow) => c.name === 'schedule_json')) { db.exec(`ALTER TABLE routines ADD COLUMN schedule_json TEXT`); } if (routineCols.length > 0 && !routineCols.some((c: DbRow) => c.name === 'context_json')) { db.exec(`ALTER TABLE routines ADD COLUMN context_json TEXT`); } migrateCritique(db); migrateMediaTasks(db); migratePlugins(db); } // ---------- deployments ---------- const DEPLOYMENT_COLS = `id, project_id AS projectId, file_name AS fileName, provider_id AS providerId, url, deployment_id AS deploymentId, deployment_count AS deploymentCount, target, status, status_message AS statusMessage, reachable_at AS reachableAt, provider_metadata_json AS providerMetadataJson, created_at AS createdAt, updated_at AS updatedAt`; export function listDeployments(db: SqliteDb, projectId: string) { return (db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? ORDER BY updated_at DESC`, ) .all(projectId) as DbRow[]) .map(normalizeDeployment); } export function getDeployment(db: SqliteDb, projectId: string, fileName: string, providerId: string) { const row = db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? AND file_name = ? AND provider_id = ?`, ) .get(projectId, fileName, providerId) as DbRow | undefined; return row ? normalizeDeployment(row) : null; } export function getDeploymentById(db: SqliteDb, projectId: string, id: string) { const row = db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? AND id = ?`, ) .get(projectId, id) as DbRow | undefined; return row ? normalizeDeployment(row) : null; } export function upsertDeployment(db: SqliteDb, deployment: DbRow) { const existing = getDeployment( db, deployment.projectId, deployment.fileName, deployment.providerId, ); const now = Date.now(); const inputProviderMetadata = deployment.providerMetadata === undefined ? existing?.providerMetadata : deployment.providerMetadata; const providerMetadata = deployment.cloudflarePages && typeof deployment.cloudflarePages === 'object' ? { ...(inputProviderMetadata && typeof inputProviderMetadata === 'object' && !Array.isArray(inputProviderMetadata) ? inputProviderMetadata : {}), cloudflarePages: deployment.cloudflarePages, } : inputProviderMetadata; const next = { id: existing?.id ?? deployment.id, projectId: deployment.projectId, fileName: deployment.fileName, providerId: deployment.providerId, url: deployment.url, deploymentId: deployment.deploymentId ?? null, deploymentCount: typeof deployment.deploymentCount === 'number' ? deployment.deploymentCount : (existing?.deploymentCount ?? 0) + 1, target: deployment.target ?? 'preview', status: deployment.status ?? existing?.status ?? 'ready', statusMessage: deployment.statusMessage ?? null, reachableAt: deployment.reachableAt ?? null, providerMetadata, createdAt: existing?.createdAt ?? deployment.createdAt ?? now, updatedAt: deployment.updatedAt ?? now, }; const providerMetadataJson = stringifyJsonObjectOrNull(next.providerMetadata); db.prepare( `INSERT INTO deployments (id, project_id, file_name, provider_id, url, deployment_id, deployment_count, target, status, status_message, reachable_at, provider_metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, file_name, provider_id) DO UPDATE SET url = excluded.url, deployment_id = excluded.deployment_id, deployment_count = excluded.deployment_count, target = excluded.target, status = excluded.status, status_message = excluded.status_message, reachable_at = excluded.reachable_at, provider_metadata_json = excluded.provider_metadata_json, updated_at = excluded.updated_at`, ).run( next.id, next.projectId, next.fileName, next.providerId, next.url, next.deploymentId, next.deploymentCount, next.target, next.status, next.statusMessage, next.reachableAt, providerMetadataJson, next.createdAt, next.updatedAt, ); return getDeployment(db, next.projectId, next.fileName, next.providerId); } function normalizeDeployment(row: DbRow) { const providerMetadata = parseJsonOrUndef(row.providerMetadataJson); const normalizedProviderMetadata = providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata) ? providerMetadata : undefined; return { id: row.id, projectId: row.projectId, fileName: row.fileName, providerId: row.providerId, url: row.url, deploymentId: row.deploymentId ?? undefined, deploymentCount: Number(row.deploymentCount ?? 1), target: 'preview', status: row.status || 'ready', statusMessage: row.statusMessage ?? undefined, reachableAt: row.reachableAt == null ? undefined : Number(row.reachableAt), cloudflarePages: normalizedProviderMetadata?.cloudflarePages && typeof normalizedProviderMetadata.cloudflarePages === 'object' && !Array.isArray(normalizedProviderMetadata.cloudflarePages) ? normalizedProviderMetadata.cloudflarePages : undefined, providerMetadata: normalizedProviderMetadata, createdAt: Number(row.createdAt), updatedAt: Number(row.updatedAt), }; } function stringifyJsonObjectOrNull(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return Object.keys(value).length > 0 ? JSON.stringify(value) : null; } // ---------- projects ---------- const PROJECT_COLS = `id, name, skill_id AS skillId, design_system_id AS designSystemId, pending_prompt AS pendingPrompt, metadata_json AS metadataJson, applied_plugin_snapshot_id AS appliedPluginSnapshotId, custom_instructions AS customInstructions, created_at AS createdAt, updated_at AS updatedAt`; export function listProjects(db: SqliteDb) { const rows = db .prepare( `SELECT ${PROJECT_COLS} FROM projects ORDER BY updated_at DESC`, ) .all() as DbRow[]; return rows.map(normalizeProject); } export function listLatestProjectRunStatuses(db: SqliteDb) { const rows = db .prepare( `SELECT c.project_id AS projectId, m.run_id AS runId, m.run_status AS status, COALESCE(m.ended_at, m.started_at, m.created_at) AS updatedAt FROM messages m JOIN conversations c ON c.id = m.conversation_id WHERE m.run_status IS NOT NULL ORDER BY updatedAt DESC`, ) .all() as DbRow[]; const latestByProject = new Map(); for (const row of rows) { if (!latestByProject.has(row.projectId)) { latestByProject.set(row.projectId, { value: normalizeProjectRunStatus(row.status), updatedAt: Number(row.updatedAt), runId: row.runId ?? undefined, }); } } return latestByProject; } export function listProjectsAwaitingInput(db: SqliteDb) { const rows = db .prepare( `SELECT latest.projectId FROM ( SELECT c.project_id AS projectId, m.conversation_id AS conversationId, m.created_at AS createdAt, m.position AS position, ROW_NUMBER() OVER ( PARTITION BY c.project_id ORDER BY m.created_at DESC, m.position DESC ) AS rowNum FROM messages m JOIN conversations c ON c.id = m.conversation_id WHERE m.role = 'assistant' AND LOWER(m.content) LIKE '% latest.createdAt OR (reply.created_at = latest.createdAt AND reply.position > latest.position) ) )`, ) .all() as DbRow[]; return new Set((rows as DbRow[]).map((row: DbRow) => row.projectId)); } export function getProject(db: SqliteDb, id: string) { const row = db .prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`) .get(id) as DbRow | undefined; return row ? normalizeProject(row) : null; } export function insertProject(db: SqliteDb, p: DbRow) { db.prepare( `INSERT INTO projects (id, name, skill_id, design_system_id, pending_prompt, metadata_json, custom_instructions, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( p.id, p.name, p.skillId ?? null, p.designSystemId ?? null, p.pendingPrompt ?? null, p.metadata ? JSON.stringify(p.metadata) : null, p.customInstructions ?? null, p.createdAt, p.updatedAt, ); return getProject(db, p.id); } export function updateProject(db: SqliteDb, id: string, patch: DbRow) { const existing = getProject(db, id); if (!existing) return null; const merged = { ...existing, ...patch, updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(), }; db.prepare( `UPDATE projects SET name = ?, skill_id = ?, design_system_id = ?, pending_prompt = ?, metadata_json = ?, custom_instructions = ?, updated_at = ? WHERE id = ?`, ).run( merged.name, merged.skillId ?? null, merged.designSystemId ?? null, merged.pendingPrompt ?? null, merged.metadata ? JSON.stringify(merged.metadata) : null, merged.customInstructions ?? null, merged.updatedAt, id, ); return getProject(db, id); } export function deleteProject(db: SqliteDb, id: string) { db.prepare(`DELETE FROM projects WHERE id = ?`).run(id); } function normalizeProject(row: DbRow) { let metadata; if (row.metadataJson) { try { metadata = JSON.parse(row.metadataJson); } catch { metadata = undefined; } } return { id: row.id, name: row.name, skillId: row.skillId, designSystemId: row.designSystemId, pendingPrompt: row.pendingPrompt ?? undefined, metadata, appliedPluginSnapshotId: row.appliedPluginSnapshotId ?? undefined, customInstructions: row.customInstructions ?? undefined, createdAt: Number(row.createdAt), updatedAt: Number(row.updatedAt), }; } function normalizeProjectRunStatus(status: unknown) { if (status === 'starting') return 'running'; if (status === 'cancelled') return 'canceled'; if ( status === 'queued' || status === 'running' || status === 'succeeded' || status === 'failed' || status === 'canceled' ) { return status; } return 'not_started'; } // ---------- templates ---------- export function listTemplates(db: SqliteDb) { return (db .prepare( `SELECT id, name, description, source_project_id AS sourceProjectId, files_json AS filesJson, created_at AS createdAt FROM templates ORDER BY created_at DESC`, ) .all() as DbRow[]) .map(normalizeTemplate); } export function getTemplate(db: SqliteDb, id: string) { const row = db .prepare( `SELECT id, name, description, source_project_id AS sourceProjectId, files_json AS filesJson, created_at AS createdAt FROM templates WHERE id = ?`, ) .get(id) as DbRow | undefined; return row ? normalizeTemplate(row) : null; } export function findTemplateByNameAndProject( db: SqliteDb, name: string, sourceProjectId: string, ) { const row = db .prepare( `SELECT id, name, description, source_project_id AS sourceProjectId, files_json AS filesJson, created_at AS createdAt FROM templates WHERE name = ? AND source_project_id = ?`, ) .get(name, sourceProjectId) as DbRow | undefined; return row ? normalizeTemplate(row) : null; } export function insertTemplate(db: SqliteDb, t: DbRow) { db.prepare( `INSERT INTO templates (id, name, description, source_project_id, files_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`, ).run( t.id, t.name, t.description ?? null, t.sourceProjectId ?? null, JSON.stringify(t.files ?? []), t.createdAt, ); return getTemplate(db, t.id); } export function updateTemplate( db: SqliteDb, id: string, t: { description: string | null; files: unknown[] }, ) { db.prepare( `UPDATE templates SET description = ?, files_json = ? WHERE id = ?`, ).run(t.description, JSON.stringify(t.files), id); return getTemplate(db, id); } export function deleteTemplate(db: SqliteDb, id: string) { db.prepare(`DELETE FROM templates WHERE id = ?`).run(id); } function normalizeTemplate(row: DbRow) { let files = []; try { files = JSON.parse(row.filesJson || '[]'); } catch { files = []; } return { id: row.id, name: row.name, description: row.description ?? undefined, sourceProjectId: row.sourceProjectId ?? undefined, files, createdAt: Number(row.createdAt), }; } // ---------- conversations ---------- export function listConversations(db: SqliteDb, projectId: string) { return rows(db .prepare( `WITH project_conversations AS ( SELECT id, project_id AS projectId, title, created_at AS createdAt, updated_at AS updatedAt FROM conversations WHERE project_id = ? ), latest_runs AS ( SELECT conversation_id AS conversationId, run_status AS latestRunStatus, started_at AS latestRunStartedAt, ended_at AS latestRunEndedAt, events_json AS latestRunEventsJson FROM ( SELECT m.conversation_id, m.run_status, m.started_at, m.ended_at, m.events_json, ROW_NUMBER() OVER ( PARTITION BY m.conversation_id ORDER BY m.position DESC ) AS rn FROM messages m JOIN project_conversations c ON c.id = m.conversation_id WHERE m.role = 'assistant' AND m.run_status IS NOT NULL ) WHERE rn = 1 ), total_run_durations AS ( SELECT m.conversation_id AS conversationId, SUM(${terminalRunDurationSql('m')}) AS totalDurationMs FROM messages m JOIN project_conversations c ON c.id = m.conversation_id WHERE m.role = 'assistant' AND m.run_status IN ('succeeded', 'failed', 'canceled') GROUP BY m.conversation_id ) SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt, lr.latestRunStatus, lr.latestRunStartedAt, lr.latestRunEndedAt, lr.latestRunEventsJson, trd.totalDurationMs FROM project_conversations c LEFT JOIN latest_runs lr ON lr.conversationId = c.id LEFT JOIN total_run_durations trd ON trd.conversationId = c.id ORDER BY c.updatedAt DESC`, ) .all(projectId)).map(normalizeConversation); } export function getConversation(db: SqliteDb, id: string) { const r = db .prepare( `SELECT id, project_id AS projectId, title, created_at AS createdAt, updated_at AS updatedAt FROM conversations WHERE id = ?`, ) .get(id) as DbRow | undefined; if (!r) return null; return { ...normalizeConversation(r), latestRun: latestConversationRunSummary(db, r.id) ?? undefined, ...numberProperty('totalDurationMs', totalConversationRunDurationMs(db, r.id)), }; } function normalizeConversation(r: DbRow) { const latestRun = conversationRunSummaryFromRow({ runStatus: r.latestRunStatus, startedAt: r.latestRunStartedAt, endedAt: r.latestRunEndedAt, eventsJson: r.latestRunEventsJson, }); return { id: r.id, projectId: r.projectId, title: r.title ?? null, createdAt: Number(r.createdAt), updatedAt: Number(r.updatedAt), ...numberProperty('totalDurationMs', r.totalDurationMs), latestRun: latestRun ?? undefined, }; } function numberProperty(key: string, value: unknown) { const n = value == null ? undefined : Number(value); return typeof n === 'number' && Number.isFinite(n) ? { [key]: n } : {}; } function latestConversationRunSummary(db: SqliteDb, conversationId: string) { const row = db .prepare( `SELECT run_status AS runStatus, started_at AS startedAt, ended_at AS endedAt, events_json AS eventsJson FROM messages WHERE conversation_id = ? AND role = 'assistant' AND run_status IS NOT NULL ORDER BY position DESC LIMIT 1`, ) .get(conversationId) as DbRow | undefined; return conversationRunSummaryFromRow(row); } function totalConversationRunDurationMs(db: SqliteDb, conversationId: string): number | undefined { const row = db .prepare( `SELECT SUM(${terminalRunDurationSql()}) AS totalDurationMs FROM messages WHERE conversation_id = ? AND role = 'assistant' AND run_status IN ('succeeded', 'failed', 'canceled')`, ) .get(conversationId) as DbRow | undefined; return row?.totalDurationMs == null ? undefined : Number(row.totalDurationMs); } function terminalRunDurationSql(alias?: string) { const p = alias ? `${alias}.` : ''; return `CASE WHEN ${p}started_at IS NOT NULL AND ${p}ended_at IS NOT NULL THEN CASE WHEN CAST(${p}ended_at AS INTEGER) >= CAST(${p}started_at AS INTEGER) THEN CAST(${p}ended_at AS INTEGER) - CAST(${p}started_at AS INTEGER) ELSE 0 END ELSE ( SELECT CASE WHEN json_extract(usage_event.value, '$.durationMs') >= 0 THEN json_extract(usage_event.value, '$.durationMs') ELSE 0 END FROM json_each( CASE WHEN json_valid(${p}events_json) AND json_type(${p}events_json) = 'array' THEN ${p}events_json ELSE '[]' END ) AS usage_event WHERE usage_event.type = 'object' AND json_extract(usage_event.value, '$.kind') = 'usage' AND json_type(usage_event.value, '$.durationMs') IN ('integer', 'real') ORDER BY CAST(usage_event.key AS INTEGER) DESC LIMIT 1 ) END`; } function conversationRunSummaryFromRow(row: DbRow | undefined) { if (!row || typeof row.runStatus !== 'string') return null; const startedAt = row.startedAt == null ? undefined : Number(row.startedAt); const endedAt = row.endedAt == null ? undefined : Number(row.endedAt); const usageDurationMs = latestUsageDurationMs(row.eventsJson); const durationMs = Number.isFinite(startedAt) && Number.isFinite(endedAt) ? Math.max(0, (endedAt as number) - (startedAt as number)) : usageDurationMs; return { status: row.runStatus, ...(Number.isFinite(startedAt) ? { startedAt } : {}), ...(Number.isFinite(endedAt) ? { endedAt } : {}), ...(typeof durationMs === 'number' && Number.isFinite(durationMs) ? { durationMs } : {}), }; } function latestUsageDurationMs(eventsJson: unknown): number | undefined { if (typeof eventsJson !== 'string' || eventsJson.length === 0) return undefined; try { const events = JSON.parse(eventsJson); if (!Array.isArray(events)) return undefined; for (let i = events.length - 1; i >= 0; i -= 1) { const event = events[i]; if ( event && typeof event === 'object' && event.kind === 'usage' && typeof event.durationMs === 'number' && Number.isFinite(event.durationMs) ) { return Math.max(0, event.durationMs); } } } catch { return undefined; } return undefined; } export function insertConversation(db: SqliteDb, c: DbRow) { db.prepare( `INSERT INTO conversations (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, ).run(c.id, c.projectId, c.title ?? null, c.createdAt, c.updatedAt); return getConversation(db, c.id); } export function updateConversation(db: SqliteDb, id: string, patch: DbRow) { const existing = getConversation(db, id); if (!existing) return null; const merged = { ...existing, ...patch, updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(), }; db.prepare( `UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?`, ).run(merged.title ?? null, merged.updatedAt, id); return getConversation(db, id); } export function deleteConversation(db: SqliteDb, id: string) { db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id); } // ---------- messages ---------- export function listMessages(db: SqliteDb, conversationId: string) { return (db .prepare( `SELECT id, role, content, agent_id AS agentId, agent_name AS agentName, run_id AS runId, run_status AS runStatus, last_run_event_id AS lastRunEventId, events_json AS eventsJson, attachments_json AS attachmentsJson, comment_attachments_json AS commentAttachmentsJson, produced_files_json AS producedFilesJson, feedback_json AS feedbackJson, pre_turn_file_names_json AS preTurnFileNamesJson, created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt, position FROM messages WHERE conversation_id = ? ORDER BY position ASC`, ) .all(conversationId) as DbRow[]) .map(normalizeMessage); } export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) { const existing = db .prepare(`SELECT position FROM messages WHERE id = ?`) .get(m.id) as DbRow | undefined; const now = Date.now(); if (existing) { db.prepare( `UPDATE messages SET role = ?, content = ?, agent_id = ?, agent_name = ?, run_id = ?, run_status = ?, last_run_event_id = ?, events_json = ?, attachments_json = ?, comment_attachments_json = ?, produced_files_json = ?, feedback_json = ?, pre_turn_file_names_json = ?, started_at = ?, ended_at = ? WHERE id = ?`, ).run( m.role, m.content, m.agentId ?? null, m.agentName ?? null, m.runId ?? null, m.runStatus ?? null, m.lastRunEventId ?? null, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.commentAttachments ? JSON.stringify(m.commentAttachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.feedback ? JSON.stringify(m.feedback) : null, m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null, m.startedAt ?? null, m.endedAt ?? null, m.id, ); } else { const max = db .prepare( `SELECT COALESCE(MAX(position), -1) AS m FROM messages WHERE conversation_id = ?`, ) .get(conversationId) as DbRow | undefined; const position = (max?.m ?? -1) + 1; // 19 values: id, conversation_id, role, content, agent_id, agent_name, // run_id, run_status, last_run_event_id, events_json, attachments_json, // comment_attachments_json, produced_files_json, feedback_json, // pre_turn_file_names_json, started_at, ended_at, position, created_at. db.prepare( `INSERT INTO messages (id, conversation_id, role, content, agent_id, agent_name, run_id, run_status, last_run_event_id, events_json, attachments_json, comment_attachments_json, produced_files_json, feedback_json, pre_turn_file_names_json, started_at, ended_at, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( m.id, conversationId, m.role, m.content, m.agentId ?? null, m.agentName ?? null, m.runId ?? null, m.runStatus ?? null, m.lastRunEventId ?? null, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.commentAttachments ? JSON.stringify(m.commentAttachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.feedback ? JSON.stringify(m.feedback) : null, m.preTurnFileNames ? JSON.stringify(m.preTurnFileNames) : null, m.startedAt ?? null, m.endedAt ?? null, position, now, ); } // Bump conversation activity so the sidebar's recency sort works. db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`).run( now, conversationId, ); const row = db .prepare( `SELECT id, role, content, agent_id AS agentId, agent_name AS agentName, run_id AS runId, run_status AS runStatus, last_run_event_id AS lastRunEventId, events_json AS eventsJson, attachments_json AS attachmentsJson, comment_attachments_json AS commentAttachmentsJson, produced_files_json AS producedFilesJson, feedback_json AS feedbackJson, pre_turn_file_names_json AS preTurnFileNamesJson, created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt, position FROM messages WHERE id = ?`, ) .get(m.id) as DbRow | undefined; return row ? normalizeMessage(row) : null; } export function appendMessageStatusEvent(db: SqliteDb, messageId: string, event: DbRow) { const label = typeof event?.label === 'string' ? event.label.trim() : ''; const detail = typeof event?.detail === 'string' ? event.detail.trim() : ''; if (!label) return null; const row = db .prepare(`SELECT events_json AS eventsJson FROM messages WHERE id = ?`) .get(messageId) as DbRow | undefined; if (!row) return null; const parsed = parseJsonOrUndef(row.eventsJson); const events = Array.isArray(parsed) ? parsed : []; const last = events[events.length - 1]; if (last?.kind === 'status' && last.label === label && (last.detail ?? '') === detail) { return events; } const nextEvent = detail ? { kind: 'status', label, detail } : { kind: 'status', label }; const next = [...events, nextEvent]; db.prepare(`UPDATE messages SET events_json = ? WHERE id = ?`) .run(JSON.stringify(next), messageId); return next; } export function appendMessageAgentEvent(db: SqliteDb, messageId: string, event: DbRow) { if (!event || typeof event !== 'object') return null; const kind = typeof event.kind === 'string' ? event.kind : ''; if (!kind) return null; const row = db .prepare(`SELECT content, events_json AS eventsJson FROM messages WHERE id = ?`) .get(messageId) as DbRow | undefined; if (!row) return null; const parsed = parseJsonOrUndef(row.eventsJson); const events = Array.isArray(parsed) ? parsed : []; const last = events[events.length - 1]; if (last && JSON.stringify(last) === JSON.stringify(event)) { return events; } const next = [...events, event]; const textDelta = kind === 'text' && typeof event.text === 'string' ? event.text : ''; db.prepare(`UPDATE messages SET content = COALESCE(content, '') || ?, events_json = ? WHERE id = ?`) .run(textDelta, JSON.stringify(next), messageId); return next; } export function deleteMessage(db: SqliteDb, id: string) { db.prepare(`DELETE FROM messages WHERE id = ?`).run(id); } // ---------- preview comments ---------- const PREVIEW_COMMENT_STATUSES = new Set([ 'open', 'attached', 'applying', 'needs_review', 'resolved', 'failed', ]); export function listPreviewComments(db: SqliteDb, projectId: string, conversationId: string) { return (db .prepare( `SELECT id, project_id AS projectId, conversation_id AS conversationId, file_path AS filePath, element_id AS elementId, selector, label, text, position_json AS positionJson, html_hint AS htmlHint, selection_kind AS selectionKind, member_count AS memberCount, pod_members_json AS podMembersJson, style_json AS styleJson, note, status, created_at AS createdAt, updated_at AS updatedAt FROM preview_comments WHERE project_id = ? AND conversation_id = ? ORDER BY updated_at DESC`, ) .all(projectId, conversationId) as DbRow[]) .map(normalizePreviewComment); } export function upsertPreviewComment(db: SqliteDb, projectId: string, conversationId: string, input: DbRow) { const target = input?.target ?? {}; const note = typeof input?.note === 'string' ? input.note.trim() : ''; if (!note) throw new Error('comment note required'); const filePath = cleanRequiredString(target.filePath, 'filePath'); const elementId = cleanRequiredString(target.elementId, 'elementId'); const selector = cleanRequiredString(target.selector, 'selector'); const label = cleanRequiredString(target.label, 'label'); const text = typeof target.text === 'string' ? compactWhitespace(target.text).slice(0, 160) : ''; const htmlHint = typeof target.htmlHint === 'string' ? compactWhitespace(target.htmlHint).slice(0, 180) : ''; const position = normalizePosition(target.position); const selectionKind = target.selectionKind === 'pod' ? 'pod' : 'element'; const podMembers = selectionKind === 'pod' ? normalizePodMembers(target.podMembers) : []; const style = normalizeAnnotationStyle(target.style); const memberCount = selectionKind === 'pod' ? (podMembers.length > 0 ? podMembers.length : Number.isFinite(target.memberCount) ? Math.max(0, Math.round(target.memberCount)) : 0) : 0; const now = Date.now(); const existing = db .prepare( `SELECT id, created_at AS createdAt FROM preview_comments WHERE project_id = ? AND conversation_id = ? AND file_path = ? AND element_id = ?`, ) .get(projectId, conversationId, filePath, elementId) as DbRow | undefined; const id = existing?.id ?? randomCommentId(); const createdAt = existing?.createdAt ?? now; db.prepare( `INSERT INTO preview_comments (id, project_id, conversation_id, file_path, element_id, selector, label, text, position_json, html_hint, selection_kind, member_count, pod_members_json, style_json, note, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET selector = excluded.selector, label = excluded.label, text = excluded.text, position_json = excluded.position_json, html_hint = excluded.html_hint, selection_kind = excluded.selection_kind, member_count = excluded.member_count, pod_members_json = excluded.pod_members_json, style_json = excluded.style_json, note = excluded.note, status = 'open', updated_at = excluded.updated_at`, ).run( id, projectId, conversationId, filePath, elementId, selector, label, text, JSON.stringify(position), htmlHint, selectionKind, selectionKind === 'pod' ? memberCount : null, selectionKind === 'pod' ? JSON.stringify(podMembers) : null, style ? JSON.stringify(style) : null, note, 'open', createdAt, now, ); return getPreviewComment(db, projectId, conversationId, id); } export function updatePreviewCommentStatus(db: SqliteDb, projectId: string, conversationId: string, id: string, status: string) { if (!PREVIEW_COMMENT_STATUSES.has(status)) throw new Error('invalid comment status'); const now = Date.now(); db.prepare( `UPDATE preview_comments SET status = ?, updated_at = ? WHERE id = ? AND project_id = ? AND conversation_id = ?`, ).run(status, now, id, projectId, conversationId); return getPreviewComment(db, projectId, conversationId, id); } export function deletePreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) { const result = db .prepare( `DELETE FROM preview_comments WHERE id = ? AND project_id = ? AND conversation_id = ?`, ) .run(id, projectId, conversationId); return result.changes > 0; } function getPreviewComment(db: SqliteDb, projectId: string, conversationId: string, id: string) { const row = db .prepare( `SELECT id, project_id AS projectId, conversation_id AS conversationId, file_path AS filePath, element_id AS elementId, selector, label, text, position_json AS positionJson, html_hint AS htmlHint, selection_kind AS selectionKind, member_count AS memberCount, pod_members_json AS podMembersJson, style_json AS styleJson, note, status, created_at AS createdAt, updated_at AS updatedAt FROM preview_comments WHERE id = ? AND project_id = ? AND conversation_id = ?`, ) .get(id, projectId, conversationId) as DbRow | undefined; return row ? normalizePreviewComment(row) : null; } function normalizePreviewComment(row: DbRow) { const podMembers = parseJsonOrUndef(row.podMembersJson); const normalizedPodMembers = Array.isArray(podMembers) ? podMembers : undefined; return { id: row.id, projectId: row.projectId, conversationId: row.conversationId, filePath: row.filePath, elementId: row.elementId, selector: row.selector, label: row.label, text: row.text, position: parseJsonOrUndef(row.positionJson) ?? { x: 0, y: 0, width: 0, height: 0 }, htmlHint: row.htmlHint, style: normalizeAnnotationStyle(parseJsonOrUndef(row.styleJson)), selectionKind: row.selectionKind === 'pod' ? 'pod' : 'element', memberCount: normalizedPodMembers && normalizedPodMembers.length > 0 ? normalizedPodMembers.length : Number.isFinite(row.memberCount) ? row.memberCount : undefined, podMembers: normalizedPodMembers, note: row.note, status: row.status, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } function cleanRequiredString(value: unknown, name: string): string { if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} required`); return value.trim(); } function normalizePodMembers(input: unknown) { if (!Array.isArray(input)) return []; return input .map((member) => { if (!member || typeof member !== 'object') return null; const elementId = cleanRequiredString(member.elementId, 'podMember.elementId'); const selector = cleanRequiredString(member.selector, 'podMember.selector'); const label = cleanRequiredString(member.label, 'podMember.label'); return { elementId, selector, label, text: typeof member.text === 'string' ? compactWhitespace(member.text).slice(0, 160) : '', position: normalizePosition(member.position), htmlHint: typeof member.htmlHint === 'string' ? compactWhitespace(member.htmlHint).slice(0, 180) : '', style: normalizeAnnotationStyle(member.style), }; }) .filter(Boolean); } function normalizeAnnotationStyle(input: unknown) { if (!input || typeof input !== 'object') return undefined; const raw = input as DbRow; const style: DbRow = {}; for (const key of ANNOTATION_STYLE_KEYS) { const value = raw[key]; if (typeof value !== 'string') continue; const trimmed = compactWhitespace(value); if (trimmed) style[key] = trimmed.slice(0, 120); } return Object.keys(style).length > 0 ? style : undefined; } const ANNOTATION_STYLE_KEYS = [ 'color', 'backgroundColor', 'fontSize', 'fontWeight', 'lineHeight', 'textAlign', 'fontFamily', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderRadius', ] as const; function compactWhitespace(value: string): string { return value.replace(/\s+/g, ' ').trim(); } function normalizePosition(input: unknown) { const value: DbRow = input && typeof input === 'object' ? input as DbRow : {}; return { x: finiteNumber(value.x), y: finiteNumber(value.y), width: finiteNumber(value.width), height: finiteNumber(value.height), }; } function finiteNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0; } function randomCommentId(): string { return `cmt_${randomUUID().slice(0, 8)}`; } function normalizeMessage(row: DbRow) { return { id: row.id, role: row.role, content: row.content, agentId: row.agentId ?? undefined, agentName: row.agentName ?? undefined, runId: row.runId ?? undefined, runStatus: row.runStatus ?? undefined, lastRunEventId: row.lastRunEventId ?? undefined, events: parseJsonOrUndef(row.eventsJson), attachments: parseJsonOrUndef(row.attachmentsJson), commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson), producedFiles: parseJsonOrUndef(row.producedFilesJson), feedback: parseJsonOrUndef(row.feedbackJson), preTurnFileNames: parseJsonOrUndef(row.preTurnFileNamesJson), createdAt: row.createdAt ?? undefined, startedAt: row.startedAt ?? undefined, endedAt: row.endedAt ?? undefined, }; } function parseJsonOrUndef(s: unknown): any { if (typeof s !== 'string' || !s) return undefined; try { return JSON.parse(s); } catch { return undefined; } } // ---------- routines ---------- const ROUTINE_COLS = `id, name, prompt, schedule_kind AS scheduleKind, schedule_value AS scheduleValue, schedule_json AS scheduleJson, project_mode AS projectMode, project_id AS projectId, skill_id AS skillId, agent_id AS agentId, context_json AS contextJson, enabled, created_at AS createdAt, updated_at AS updatedAt`; const ROUTINE_RUN_COLS = `id, routine_id AS routineId, trigger, status, project_id AS projectId, conversation_id AS conversationId, agent_run_id AS agentRunId, started_at AS startedAt, completed_at AS completedAt, summary, error, error_code AS errorCode`; export function listRoutines(db: SqliteDb) { return (db .prepare(`SELECT ${ROUTINE_COLS} FROM routines ORDER BY created_at ASC`) .all() as DbRow[]) .map(normalizeRoutine); } export function getRoutine(db: SqliteDb, id: string) { const r = db .prepare(`SELECT ${ROUTINE_COLS} FROM routines WHERE id = ?`) .get(id) as DbRow | undefined; return r ? normalizeRoutine(r) : null; } export function insertRoutine(db: SqliteDb, r: DbRow) { db.prepare( `INSERT INTO routines (id, name, prompt, schedule_kind, schedule_value, schedule_json, project_mode, project_id, skill_id, agent_id, context_json, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( r.id, r.name, r.prompt, r.scheduleKind, r.scheduleValue, r.scheduleJson ?? null, r.projectMode, r.projectId ?? null, r.skillId ?? null, r.agentId ?? null, r.contextJson ?? null, r.enabled ? 1 : 0, r.createdAt, r.updatedAt, ); return getRoutine(db, r.id); } export function updateRoutine(db: SqliteDb, id: string, patch: DbRow) { const existing = getRoutine(db, id); if (!existing) return null; const merged = { ...existing, ...patch, updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(), }; db.prepare( `UPDATE routines SET name = ?, prompt = ?, schedule_kind = ?, schedule_value = ?, schedule_json = ?, project_mode = ?, project_id = ?, skill_id = ?, agent_id = ?, context_json = ?, enabled = ?, updated_at = ? WHERE id = ?`, ).run( merged.name, merged.prompt, merged.scheduleKind, merged.scheduleValue, merged.scheduleJson ?? null, merged.projectMode, merged.projectId ?? null, merged.skillId ?? null, merged.agentId ?? null, merged.contextJson ?? null, merged.enabled ? 1 : 0, merged.updatedAt, id, ); return getRoutine(db, id); } export function deleteRoutine(db: SqliteDb, id: string): boolean { const result = db.prepare(`DELETE FROM routines WHERE id = ?`).run(id); return result.changes > 0; } function normalizeRoutine(row: DbRow) { return { id: row.id, name: row.name, prompt: row.prompt, scheduleKind: row.scheduleKind, scheduleValue: row.scheduleValue, scheduleJson: row.scheduleJson ?? null, projectMode: row.projectMode, projectId: row.projectId ?? null, skillId: row.skillId ?? null, agentId: row.agentId ?? null, contextJson: row.contextJson ?? null, enabled: Number(row.enabled) === 1, createdAt: Number(row.createdAt), updatedAt: Number(row.updatedAt), }; } export function listRoutineRuns(db: SqliteDb, routineId: string, limit = 20) { return (db .prepare( `SELECT ${ROUTINE_RUN_COLS} FROM routine_runs WHERE routine_id = ? ORDER BY started_at DESC LIMIT ?`, ) .all(routineId, limit) as DbRow[]) .map(normalizeRoutineRun); } export function getLatestRoutineRun(db: SqliteDb, routineId: string) { const r = db .prepare( `SELECT ${ROUTINE_RUN_COLS} FROM routine_runs WHERE routine_id = ? ORDER BY started_at DESC LIMIT 1`, ) .get(routineId) as DbRow | undefined; return r ? normalizeRoutineRun(r) : null; } export function getRoutineRun(db: SqliteDb, id: string) { const r = db .prepare(`SELECT ${ROUTINE_RUN_COLS} FROM routine_runs WHERE id = ?`) .get(id) as DbRow | undefined; return r ? normalizeRoutineRun(r) : null; } export function insertRoutineRun(db: SqliteDb, r: DbRow) { db.prepare( `INSERT INTO routine_runs (id, routine_id, trigger, status, project_id, conversation_id, agent_run_id, started_at, completed_at, summary, error, error_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run( r.id, r.routineId, r.trigger, r.status, r.projectId, r.conversationId, r.agentRunId, r.startedAt, r.completedAt ?? null, r.summary ?? null, r.error ?? null, r.errorCode ?? null, ); return getRoutineRun(db, r.id); } export function insertScheduledRoutineRun(db: SqliteDb, r: DbRow, slotAt: number) { const insertClaim = db.prepare( `INSERT OR IGNORE INTO routine_schedule_claims (routine_id, slot_at, claimed_at) VALUES (?, ?, ?)`, ); const insertRun = db.prepare( `INSERT INTO routine_runs (id, routine_id, trigger, status, project_id, conversation_id, agent_run_id, started_at, completed_at, summary, error, error_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); const tx = db.transaction(() => { const claim = insertClaim.run(r.routineId, slotAt, Date.now()); if (claim.changes === 0) return false; insertRun.run( r.id, r.routineId, r.trigger, r.status, r.projectId, r.conversationId, r.agentRunId, r.startedAt, r.completedAt ?? null, r.summary ?? null, r.error ?? null, r.errorCode ?? null, ); return true; }); if (!tx()) return null; return getRoutineRun(db, r.id); } export function updateRoutineRun(db: SqliteDb, id: string, patch: DbRow) { const existing = getRoutineRun(db, id); if (!existing) return null; const merged = { ...existing, ...patch, }; db.prepare( `UPDATE routine_runs SET status = ?, project_id = ?, conversation_id = ?, agent_run_id = ?, completed_at = ?, summary = ?, error = ?, error_code = ? WHERE id = ?`, ).run( merged.status, merged.projectId, merged.conversationId, merged.agentRunId, merged.completedAt ?? null, merged.summary ?? null, merged.error ?? null, merged.errorCode ?? null, id, ); return getRoutineRun(db, id); } function normalizeRoutineRun(row: DbRow) { return { id: row.id, routineId: row.routineId, trigger: row.trigger, status: row.status, projectId: row.projectId, conversationId: row.conversationId, agentRunId: row.agentRunId, startedAt: Number(row.startedAt), completedAt: row.completedAt == null ? null : Number(row.completedAt), summary: row.summary ?? null, error: row.error ?? null, errorCode: row.errorCode ?? null, }; } // ---------- tabs ---------- export function listTabs(db: SqliteDb, projectId: string) { const rows = db .prepare( `SELECT name, position, is_active AS isActive FROM tabs WHERE project_id = ? ORDER BY position ASC`, ) .all(projectId) as DbRow[]; const state = db .prepare(`SELECT project_id FROM tabs_state WHERE project_id = ? LIMIT 1`) .get(projectId) as DbRow | undefined; const active = (rows as DbRow[]).find((r: DbRow) => r.isActive) ?? null; return { tabs: (rows as DbRow[]).map((r: DbRow) => r.name), active: active ? active.name : null, hasSavedState: rows.length > 0 || Boolean(state), }; } export function setTabs(db: SqliteDb, projectId: string, names: string[], activeName: string | null) { const tx = db.transaction(() => { db.prepare( `INSERT INTO tabs_state (project_id, updated_at) VALUES (?, ?) ON CONFLICT(project_id) DO UPDATE SET updated_at = excluded.updated_at`, ).run(projectId, Date.now()); db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId); const ins = db.prepare( `INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, ?, ?)`, ); names.forEach((name: string, i: number) => { ins.run(projectId, name, i, name === activeName ? 1 : 0); }); }); tx(); return listTabs(db, projectId); }