// @ts-nocheck // 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'; let dbInstance = null; let dbFile = null; export function openDatabase(projectRoot, { dataDir } = {}) { 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) { 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, 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, 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 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, 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); `); // 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(); if (!cols.some((c) => c.name === 'metadata_json')) { db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`); } const messageCols = db.prepare(`PRAGMA table_info(messages)`).all(); if (!messageCols.some((c) => c.name === 'agent_id')) { db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`); } if (!messageCols.some((c) => c.name === 'agent_name')) { db.exec(`ALTER TABLE messages ADD COLUMN agent_name TEXT`); } if (!messageCols.some((c) => c.name === 'run_id')) { db.exec(`ALTER TABLE messages ADD COLUMN run_id TEXT`); } if (!messageCols.some((c) => c.name === 'run_status')) { db.exec(`ALTER TABLE messages ADD COLUMN run_status TEXT`); } if (!messageCols.some((c) => c.name === 'last_run_event_id')) { db.exec(`ALTER TABLE messages ADD COLUMN last_run_event_id TEXT`); } if (!messageCols.some((c) => c.name === 'comment_attachments_json')) { db.exec(`ALTER TABLE messages ADD COLUMN comment_attachments_json TEXT`); } const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all(); if (!previewCommentCols.some((c) => c.name === 'selection_kind')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN selection_kind TEXT`); } if (!previewCommentCols.some((c) => c.name === 'member_count')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN member_count INTEGER`); } if (!previewCommentCols.some((c) => c.name === 'pod_members_json')) { db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`); } const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all(); if (!deploymentCols.some((c) => c.name === 'status')) { db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`); } if (!deploymentCols.some((c) => c.name === 'status_message')) { db.exec(`ALTER TABLE deployments ADD COLUMN status_message TEXT`); } if (!deploymentCols.some((c) => c.name === 'reachable_at')) { db.exec(`ALTER TABLE deployments ADD COLUMN reachable_at INTEGER`); } migrateCritique(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, created_at AS createdAt, updated_at AS updatedAt`; export function listDeployments(db, projectId) { return db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? ORDER BY updated_at DESC`, ) .all(projectId) .map(normalizeDeployment); } export function getDeployment(db, projectId, fileName, providerId) { const row = db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? AND file_name = ? AND provider_id = ?`, ) .get(projectId, fileName, providerId); return row ? normalizeDeployment(row) : null; } export function getDeploymentById(db, projectId, id) { const row = db .prepare( `SELECT ${DEPLOYMENT_COLS} FROM deployments WHERE project_id = ? AND id = ?`, ) .get(projectId, id); return row ? normalizeDeployment(row) : null; } export function upsertDeployment(db, deployment) { const existing = getDeployment( db, deployment.projectId, deployment.fileName, deployment.providerId, ); const now = Date.now(); 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, createdAt: existing?.createdAt ?? deployment.createdAt ?? now, updatedAt: deployment.updatedAt ?? now, }; db.prepare( `INSERT INTO deployments (id, project_id, file_name, provider_id, url, deployment_id, deployment_count, target, status, status_message, reachable_at, 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, 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, next.createdAt, next.updatedAt, ); return getDeployment(db, next.projectId, next.fileName, next.providerId); } function normalizeDeployment(row) { 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), createdAt: Number(row.createdAt), updatedAt: Number(row.updatedAt), }; } // ---------- projects ---------- const PROJECT_COLS = `id, name, skill_id AS skillId, design_system_id AS designSystemId, pending_prompt AS pendingPrompt, metadata_json AS metadataJson, created_at AS createdAt, updated_at AS updatedAt`; export function listProjects(db) { const rows = db .prepare( `SELECT ${PROJECT_COLS} FROM projects ORDER BY updated_at DESC`, ) .all(); return rows.map(normalizeProject); } export function listLatestProjectRunStatuses(db) { 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(); 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) { 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(); return new Set(rows.map((row) => row.projectId)); } export function getProject(db, id) { const row = db .prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`) .get(id); return row ? normalizeProject(row) : null; } export function insertProject(db, p) { db.prepare( `INSERT INTO projects (id, name, skill_id, design_system_id, pending_prompt, metadata_json, 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.createdAt, p.updatedAt, ); return getProject(db, p.id); } export function updateProject(db, id, patch) { 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 = ?, updated_at = ? WHERE id = ?`, ).run( merged.name, merged.skillId ?? null, merged.designSystemId ?? null, merged.pendingPrompt ?? null, merged.metadata ? JSON.stringify(merged.metadata) : null, merged.updatedAt, id, ); return getProject(db, id); } export function deleteProject(db, id) { db.prepare(`DELETE FROM projects WHERE id = ?`).run(id); } function normalizeProject(row) { 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, createdAt: Number(row.createdAt), updatedAt: Number(row.updatedAt), }; } function normalizeProjectRunStatus(status) { 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) { 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() .map(normalizeTemplate); } export function getTemplate(db, id) { 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); return row ? normalizeTemplate(row) : null; } export function insertTemplate(db, t) { 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 deleteTemplate(db, id) { db.prepare(`DELETE FROM templates WHERE id = ?`).run(id); } function normalizeTemplate(row) { 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, projectId) { return db .prepare( `SELECT id, project_id AS projectId, title, created_at AS createdAt, updated_at AS updatedAt FROM conversations WHERE project_id = ? ORDER BY updated_at DESC`, ) .all(projectId) .map((r) => ({ id: r.id, projectId: r.projectId, title: r.title ?? null, createdAt: Number(r.createdAt), updatedAt: Number(r.updatedAt), })); } export function getConversation(db, id) { 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); if (!r) return null; return { id: r.id, projectId: r.projectId, title: r.title ?? null, createdAt: Number(r.createdAt), updatedAt: Number(r.updatedAt), }; } export function insertConversation(db, c) { 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, id, patch) { 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, id) { db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id); } // ---------- messages ---------- export function listMessages(db, conversationId) { 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, created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt, position FROM messages WHERE conversation_id = ? ORDER BY position ASC`, ) .all(conversationId) .map(normalizeMessage); } export function upsertMessage(db, conversationId, m) { const existing = db .prepare(`SELECT position FROM messages WHERE id = ?`) .get(m.id); 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 = ?, 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.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); const position = (max?.m ?? -1) + 1; // 17 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, 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, 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.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, created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt, position FROM messages WHERE id = ?`, ) .get(m.id); return row ? normalizeMessage(row) : null; } export function deleteMessage(db, id) { 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, projectId, conversationId) { 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, 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) .map(normalizePreviewComment); } export function upsertPreviewComment(db, projectId, conversationId, input) { 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 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); 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, 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, 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, note, 'open', createdAt, now, ); return getPreviewComment(db, projectId, conversationId, id); } export function updatePreviewCommentStatus(db, projectId, conversationId, id, status) { 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, projectId, conversationId, id) { 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, projectId, conversationId, id) { 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, 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); return row ? normalizePreviewComment(row) : null; } function normalizePreviewComment(row) { 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, 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, name) { if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} required`); return value.trim(); } function normalizePodMembers(input) { 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) : '', }; }) .filter(Boolean); } function compactWhitespace(value) { return value.replace(/\s+/g, ' ').trim(); } function normalizePosition(input) { const value = input && typeof input === 'object' ? input : {}; return { x: finiteNumber(value.x), y: finiteNumber(value.y), width: finiteNumber(value.width), height: finiteNumber(value.height), }; } function finiteNumber(value) { return Number.isFinite(value) ? Math.round(value) : 0; } function randomCommentId() { return `cmt_${randomUUID().slice(0, 8)}`; } function normalizeMessage(row) { 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), createdAt: row.createdAt ?? undefined, startedAt: row.startedAt ?? undefined, endedAt: row.endedAt ?? undefined, }; } function parseJsonOrUndef(s) { if (!s) return undefined; try { return JSON.parse(s); } catch { return undefined; } } // ---------- tabs ---------- export function listTabs(db, projectId) { const rows = db .prepare( `SELECT name, position, is_active AS isActive FROM tabs WHERE project_id = ? ORDER BY position ASC`, ) .all(projectId); const active = rows.find((r) => r.isActive) ?? null; return { tabs: rows.map((r) => r.name), active: active ? active.name : null, }; } export function setTabs(db, projectId, names, activeName) { const tx = db.transaction(() => { 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, i) => { ins.run(projectId, name, i, name === activeName ? 1 : 0); }); }); tx(); return listTabs(db, projectId); }