311 lines
8.7 KiB
TypeScript
311 lines
8.7 KiB
TypeScript
// SQLite database schema and queries
|
|
|
|
import { Database } from "bun:sqlite";
|
|
import type { Device, OutputLog, PromptData, Session } from "./types";
|
|
|
|
// Extend Prompt interface to include prompt_json field
|
|
export interface Prompt {
|
|
id: number;
|
|
session_id: number;
|
|
created_at: number;
|
|
prompt_text: string;
|
|
response: string | null;
|
|
responded_at: number | null;
|
|
prompt_json: PromptData | null;
|
|
}
|
|
|
|
let db: Database;
|
|
|
|
// Prepared statements - initialized once in initDb()
|
|
let createDeviceStmt: ReturnType<Database["prepare"]>;
|
|
let getDeviceBySecretStmt: ReturnType<Database["prepare"]>;
|
|
let updateLastSeenStmt: ReturnType<Database["prepare"]>;
|
|
let createSessionStmt: ReturnType<Database["prepare"]>;
|
|
let getSessionStmt: ReturnType<Database["prepare"]>;
|
|
let endSessionStmt: ReturnType<Database["prepare"]>;
|
|
let getActiveSessionsStmt: ReturnType<Database["prepare"]>;
|
|
let createPromptStmt: ReturnType<Database["prepare"]>;
|
|
let getPromptStmt: ReturnType<Database["prepare"]>;
|
|
let respondToPromptStmt: ReturnType<Database["prepare"]>;
|
|
let getPendingPromptsStmt: ReturnType<Database["prepare"]>;
|
|
let appendOutputStmt: ReturnType<Database["prepare"]>;
|
|
let getSessionOutputStmt: ReturnType<Database["prepare"]>;
|
|
let updateSessionStatsStmt: ReturnType<Database["prepare"]>;
|
|
|
|
// Migration function to add new columns to existing tables
|
|
function runMigrations(): void {
|
|
// Add Phase 2.3 session state and stats columns
|
|
const migrations = [
|
|
// Phase 2.1: Rich prompt support
|
|
"ALTER TABLE prompts ADD COLUMN prompt_json TEXT",
|
|
// Stats columns
|
|
"ALTER TABLE sessions ADD COLUMN state TEXT DEFAULT 'ready'",
|
|
"ALTER TABLE sessions ADD COLUMN prompts INTEGER DEFAULT 0",
|
|
"ALTER TABLE sessions ADD COLUMN completions INTEGER DEFAULT 0",
|
|
"ALTER TABLE sessions ADD COLUMN tools INTEGER DEFAULT 0",
|
|
"ALTER TABLE sessions ADD COLUMN compressions INTEGER DEFAULT 0",
|
|
"ALTER TABLE sessions ADD COLUMN thinking_seconds INTEGER DEFAULT 0",
|
|
"ALTER TABLE sessions ADD COLUMN work_seconds INTEGER DEFAULT 0",
|
|
"ALTER TABLE sessions ADD COLUMN mode TEXT DEFAULT 'normal'",
|
|
"ALTER TABLE sessions ADD COLUMN model TEXT",
|
|
"ALTER TABLE sessions ADD COLUMN idle_since INTEGER",
|
|
// Git columns (for later phase)
|
|
"ALTER TABLE sessions ADD COLUMN git_branch TEXT",
|
|
"ALTER TABLE sessions ADD COLUMN git_files_json TEXT",
|
|
];
|
|
|
|
for (const migration of migrations) {
|
|
try {
|
|
db.exec(migration);
|
|
} catch (error: any) {
|
|
// Ignore "duplicate column" errors - column already exists
|
|
if (!error.message?.includes("duplicate column")) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function initDb(
|
|
path = process.env.DB_PATH || "data/dev/clarc.db",
|
|
): Database {
|
|
db = new Database(path);
|
|
|
|
// Enable WAL mode for better concurrency
|
|
db.exec("PRAGMA journal_mode = WAL");
|
|
|
|
// Create tables if they don't exist
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS devices (
|
|
id INTEGER PRIMARY KEY,
|
|
secret TEXT NOT NULL UNIQUE,
|
|
name TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
last_seen INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id INTEGER PRIMARY KEY,
|
|
device_id INTEGER NOT NULL,
|
|
started_at INTEGER NOT NULL,
|
|
ended_at INTEGER,
|
|
cwd TEXT,
|
|
command TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS prompts (
|
|
id INTEGER PRIMARY KEY,
|
|
session_id INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
prompt_text TEXT NOT NULL,
|
|
prompt_json TEXT,
|
|
response TEXT,
|
|
responded_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS output_log (
|
|
id INTEGER PRIMARY KEY,
|
|
session_id INTEGER NOT NULL,
|
|
timestamp INTEGER NOT NULL,
|
|
line TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
// Run migrations to add new columns
|
|
runMigrations();
|
|
|
|
// Prepare all statements once
|
|
createDeviceStmt = db.prepare(
|
|
"INSERT INTO devices (secret, name, created_at, last_seen) VALUES (?, ?, ?, ?) RETURNING *",
|
|
);
|
|
getDeviceBySecretStmt = db.prepare("SELECT * FROM devices WHERE secret = ?");
|
|
updateLastSeenStmt = db.prepare(
|
|
"UPDATE devices SET last_seen = ? WHERE id = ?",
|
|
);
|
|
createSessionStmt = db.prepare(
|
|
"INSERT INTO sessions (device_id, started_at, cwd, command) VALUES (?, ?, ?, ?) RETURNING *",
|
|
);
|
|
getSessionStmt = db.prepare("SELECT * FROM sessions WHERE id = ?");
|
|
endSessionStmt = db.prepare("UPDATE sessions SET ended_at = ? WHERE id = ?");
|
|
getActiveSessionsStmt = db.prepare(
|
|
"SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC",
|
|
);
|
|
createPromptStmt = db.prepare(
|
|
"INSERT INTO prompts (session_id, created_at, prompt_text, prompt_json) VALUES (?, ?, ?, ?) RETURNING *",
|
|
);
|
|
getPromptStmt = db.prepare("SELECT * FROM prompts WHERE id = ?");
|
|
respondToPromptStmt = db.prepare(
|
|
"UPDATE prompts SET response = ?, responded_at = ? WHERE id = ?",
|
|
);
|
|
getPendingPromptsStmt = db.prepare(
|
|
"SELECT * FROM prompts WHERE response IS NULL ORDER BY created_at ASC",
|
|
);
|
|
appendOutputStmt = db.prepare(
|
|
"INSERT INTO output_log (session_id, timestamp, line) VALUES (?, ?, ?)",
|
|
);
|
|
getSessionOutputStmt = db.prepare(
|
|
"SELECT * FROM output_log WHERE session_id = ? ORDER BY timestamp ASC",
|
|
);
|
|
updateSessionStatsStmt = db.prepare(`
|
|
UPDATE sessions
|
|
SET state = ?, prompts = ?, completions = ?, tools = ?, compressions = ?,
|
|
thinking_seconds = ?, work_seconds = ?, mode = ?, model = ?, idle_since = ?,
|
|
git_branch = ?, git_files_json = ?
|
|
WHERE id = ?
|
|
`);
|
|
|
|
return db;
|
|
}
|
|
|
|
// Device functions
|
|
|
|
export function createDevice(
|
|
secret: string,
|
|
name: string | null = null,
|
|
): Device {
|
|
const now = Date.now();
|
|
return createDeviceStmt.get(secret, name, now, now) as Device;
|
|
}
|
|
|
|
export function getDeviceBySecret(secret: string): Device | null {
|
|
return (getDeviceBySecretStmt.get(secret) as Device) ?? null;
|
|
}
|
|
|
|
export function updateLastSeen(deviceId: number): void {
|
|
updateLastSeenStmt.run(Date.now(), deviceId);
|
|
}
|
|
|
|
// Session functions
|
|
|
|
export function createSession(
|
|
deviceId: number,
|
|
cwd: string | null = null,
|
|
command: string | null = null,
|
|
): Session {
|
|
const now = Date.now();
|
|
return createSessionStmt.get(deviceId, now, cwd, command) as Session;
|
|
}
|
|
|
|
export function getSession(sessionId: number): Session | null {
|
|
return (getSessionStmt.get(sessionId) as Session) ?? null;
|
|
}
|
|
|
|
export function endSession(sessionId: number): void {
|
|
endSessionStmt.run(Date.now(), sessionId);
|
|
}
|
|
|
|
export function getActiveSessions(): Session[] {
|
|
return getActiveSessionsStmt.all() as Session[];
|
|
}
|
|
|
|
// Prompt functions
|
|
|
|
export function createPrompt(
|
|
sessionId: number,
|
|
promptText: string,
|
|
promptJson?: string,
|
|
): Prompt {
|
|
const now = Date.now();
|
|
const row = createPromptStmt.get(
|
|
sessionId,
|
|
now,
|
|
promptText,
|
|
promptJson ?? null,
|
|
);
|
|
const prompt = row as any;
|
|
|
|
// Parse prompt_json if present
|
|
if (prompt.prompt_json) {
|
|
try {
|
|
prompt.prompt_json = JSON.parse(prompt.prompt_json);
|
|
} catch {
|
|
prompt.prompt_json = null;
|
|
}
|
|
}
|
|
|
|
return prompt as Prompt;
|
|
}
|
|
|
|
export function getPrompt(promptId: number): Prompt | null {
|
|
const row = getPromptStmt.get(promptId);
|
|
if (!row) return null;
|
|
|
|
const prompt = row as any;
|
|
|
|
// Parse prompt_json if present
|
|
if (prompt.prompt_json) {
|
|
try {
|
|
prompt.prompt_json = JSON.parse(prompt.prompt_json);
|
|
} catch {
|
|
prompt.prompt_json = null;
|
|
}
|
|
}
|
|
|
|
return prompt as Prompt;
|
|
}
|
|
|
|
export function respondToPrompt(promptId: number, response: string): void {
|
|
respondToPromptStmt.run(response, Date.now(), promptId);
|
|
}
|
|
|
|
export function getPendingPrompts(): Prompt[] {
|
|
const rows = getPendingPromptsStmt.all();
|
|
|
|
return rows.map((row: any) => {
|
|
// Parse prompt_json if present
|
|
if (row.prompt_json) {
|
|
try {
|
|
row.prompt_json = JSON.parse(row.prompt_json);
|
|
} catch {
|
|
row.prompt_json = null;
|
|
}
|
|
}
|
|
return row as Prompt;
|
|
});
|
|
}
|
|
|
|
// OutputLog functions
|
|
|
|
export function appendOutput(sessionId: number, line: string): void {
|
|
appendOutputStmt.run(sessionId, Date.now(), line);
|
|
}
|
|
|
|
export function getSessionOutput(sessionId: number): OutputLog[] {
|
|
return getSessionOutputStmt.all(sessionId) as OutputLog[];
|
|
}
|
|
|
|
// Session state functions
|
|
|
|
export function updateSessionStats(
|
|
sessionId: number,
|
|
state: {
|
|
state: string;
|
|
prompts: number;
|
|
completions: number;
|
|
tools: number;
|
|
compressions: number;
|
|
thinking_seconds: number;
|
|
work_seconds: number;
|
|
mode: string;
|
|
model: string | null;
|
|
idle_since: number | null;
|
|
git_branch: string | null;
|
|
git_files_json: string | null;
|
|
},
|
|
): void {
|
|
updateSessionStatsStmt.run(
|
|
state.state,
|
|
state.prompts,
|
|
state.completions,
|
|
state.tools,
|
|
state.compressions,
|
|
state.thinking_seconds,
|
|
state.work_seconds,
|
|
state.mode,
|
|
state.model,
|
|
state.idle_since,
|
|
state.git_branch,
|
|
state.git_files_json,
|
|
sessionId,
|
|
);
|
|
}
|