open-design/apps/daemon/src/db.ts
marco 5dd70b5016
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m3s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 11s
Initial import: open-design source for helix-mind.ai distribution
This repository contains the open-design daemon CLI source code, built
and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use
by the HelixMind /design slash command.

Licenses: Apache-2.0 (root) + MIT (skills/*)
2026-05-06 20:50:24 +02:00

1011 lines
32 KiB
TypeScript

// @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/<id>/ 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 '%<question-form%'
) latest
WHERE latest.rowNum = 1
AND NOT EXISTS (
SELECT 1
FROM messages reply
WHERE reply.conversation_id = latest.conversationId
AND reply.role = 'user'
AND (
reply.created_at > 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);
}