Path: blob/main/extensions/copilot/src/platform/chronicle/node/sessionStore.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { mkdirSync } from 'fs';6import { DatabaseSync } from 'node:sqlite';7import { dirname } from 'path';8import type { CheckpointRow, FileRow, ISessionStore, RefRow, SearchResult, SessionRow, TurnRow } from '../common/sessionStore';910/**11* SQLite authorizer action codes that are safe for read-only access.12* Values from https://www.sqlite.org/c3ref/c_alter_table.html13* (node:sqlite constants do not expose authorizer codes)14*/15const SQLITE_OK = 0;16const SQLITE_DENY = 1;17const SQLITE_READ = 20;18const SQLITE_SELECT = 21;19const SQLITE_FUNCTION = 31;20const SQLITE_PRAGMA = 19;21const SQLITE_RECURSIVE = 33;2223const READ_ONLY_ACTION_CODES = new Set([24SQLITE_READ, // read a column25SQLITE_SELECT, // top-level SELECT26SQLITE_FUNCTION, // call a function (needed for bm25(), etc.)27SQLITE_RECURSIVE, // recursive CTE28]);2930/** Schema version — bump when altering tables so existing DBs get migrated. */31const SCHEMA_VERSION = 3;3233/**34* Session store backed by SQLite + FTS5.35*36* Schema is identical to the copilot-agent-runtime SessionStore so that37* tooling and queries are portable across CLI and VS Code surfaces.38* The store is populated incrementally by live hooks — no background indexing.39*/40export class SessionStore implements ISessionStore {41declare readonly _serviceBrand: undefined;42private db: DatabaseSync | null = null;43private readonly dbPath: string;4445constructor(dbPath: string) {46this.dbPath = dbPath;47}4849/**50* Get the path to the database file.51*/52getPath(): string {53return this.dbPath;54}5556/**57* Lazily open (or create) the database and ensure the schema exists.58*/59private ensureDb(): DatabaseSync {60if (this.db) {61return this.db;62}6364if (this.dbPath !== ':memory:') {65mkdirSync(dirname(this.dbPath), { recursive: true });66}6768const db = new DatabaseSync(this.dbPath);69try {70db.exec('PRAGMA journal_mode = WAL');71db.exec('PRAGMA busy_timeout = 3000');72db.exec('PRAGMA foreign_keys = ON');73this.db = db;74this.ensureSchema();75} catch (err) {76db.close();77this.db = null;78throw err;79}80return this.db;81}8283/**84* Create or migrate tables to the current schema version.85*86* IMPORTANT: When bumping SCHEMA_VERSION, add explicit migration logic87* for each version step (e.g., v1→v2). CREATE TABLE IF NOT EXISTS does88* NOT alter existing tables — use ALTER TABLE for schema changes.89*/90private ensureSchema(): void {91const db = this.db!;9293const versionRow = (() => {94try {95const stmt = db.prepare('SELECT version FROM schema_version LIMIT 1');96return stmt.get() as unknown as { version: number } | undefined;97} catch {98return undefined;99}100})();101102const currentVersion = versionRow?.version ?? 0;103104if (currentVersion >= SCHEMA_VERSION) {105return;106}107108db.exec(`109CREATE TABLE IF NOT EXISTS schema_version (110version INTEGER NOT NULL111);112113CREATE TABLE IF NOT EXISTS sessions (114id TEXT PRIMARY KEY,115cwd TEXT,116repository TEXT,117host_type TEXT,118branch TEXT,119summary TEXT,120agent_name TEXT,121agent_description TEXT,122created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),123updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))124);125126CREATE TABLE IF NOT EXISTS turns (127id INTEGER PRIMARY KEY AUTOINCREMENT,128session_id TEXT NOT NULL REFERENCES sessions(id),129turn_index INTEGER NOT NULL,130user_message TEXT,131assistant_response TEXT,132timestamp TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),133UNIQUE(session_id, turn_index)134);135136CREATE TABLE IF NOT EXISTS checkpoints (137id INTEGER PRIMARY KEY AUTOINCREMENT,138session_id TEXT NOT NULL REFERENCES sessions(id),139checkpoint_number INTEGER NOT NULL,140title TEXT,141overview TEXT,142history TEXT,143work_done TEXT,144technical_details TEXT,145important_files TEXT,146next_steps TEXT,147created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),148UNIQUE(session_id, checkpoint_number)149);150151CREATE TABLE IF NOT EXISTS session_files (152id INTEGER PRIMARY KEY AUTOINCREMENT,153session_id TEXT NOT NULL REFERENCES sessions(id),154file_path TEXT NOT NULL,155tool_name TEXT,156turn_index INTEGER,157first_seen_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),158UNIQUE(session_id, file_path)159);160161CREATE TABLE IF NOT EXISTS session_refs (162id INTEGER PRIMARY KEY AUTOINCREMENT,163session_id TEXT NOT NULL REFERENCES sessions(id),164ref_type TEXT NOT NULL,165ref_value TEXT NOT NULL,166turn_index INTEGER,167created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),168UNIQUE(session_id, ref_type, ref_value)169);170171CREATE INDEX IF NOT EXISTS idx_sessions_repo ON sessions(repository);172CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);173CREATE INDEX IF NOT EXISTS idx_session_files_path ON session_files(file_path);174CREATE INDEX IF NOT EXISTS idx_session_refs_type_value ON session_refs(ref_type, ref_value);175CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);176CREATE INDEX IF NOT EXISTS idx_checkpoints_session ON checkpoints(session_id);177`);178179// FTS5 virtual table — CREATE VIRTUAL TABLE doesn't support IF NOT EXISTS180// in all SQLite builds, so we guard with a check.181const ftsExists = db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=\'search_index\'').get();182if (!ftsExists) {183db.exec(`184CREATE VIRTUAL TABLE search_index USING fts5(185content,186session_id UNINDEXED,187source_type UNINDEXED,188source_id UNINDEXED189);190`);191}192193// ── Schema migrations ────────────────────────────────────────────194if (currentVersion >= 1 && currentVersion < 2) {195db.exec('ALTER TABLE sessions ADD COLUMN host_type TEXT');196}197if (currentVersion >= 1 && currentVersion < 3) {198db.exec('ALTER TABLE sessions ADD COLUMN agent_name TEXT');199db.exec('ALTER TABLE sessions ADD COLUMN agent_description TEXT');200}201202// Update or insert schema version203if (currentVersion === 0) {204db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);205} else {206db.prepare('UPDATE schema_version SET version = ?').run(SCHEMA_VERSION);207}208}209210// ── CRUD Methods ────────────────────────────────────────────────────211212/**213* Insert or update a session's metadata.214*/215upsertSession(session: SessionRow): void {216const db = this.ensureDb();217db.prepare(218`INSERT INTO sessions (id, cwd, repository, host_type, branch, summary, agent_name, agent_description, created_at, updated_at)219VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)220ON CONFLICT(id) DO UPDATE SET221cwd = COALESCE(excluded.cwd, cwd),222repository = COALESCE(excluded.repository, repository),223host_type = COALESCE(excluded.host_type, host_type),224branch = COALESCE(excluded.branch, branch),225summary = COALESCE(excluded.summary, summary),226agent_name = COALESCE(excluded.agent_name, agent_name),227agent_description = COALESCE(excluded.agent_description, agent_description),228created_at = MIN(created_at, excluded.created_at),229updated_at = MAX(updated_at, excluded.updated_at)`,230).run(231session.id,232session.cwd ?? null,233session.repository ?? null,234session.host_type ?? null,235session.branch ?? null,236session.summary ?? null,237session.agent_name ?? null,238session.agent_description ?? null,239session.created_at ?? new Date().toISOString(),240session.updated_at ?? new Date().toISOString(),241);242}243244/**245* Insert a conversation turn and index it for full-text search.246*/247insertTurn(turn: TurnRow): void {248const db = this.ensureDb();249250// Ensure session exists (lightweight upsert)251db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(turn.session_id);252253db.prepare(254`INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp)255VALUES (?, ?, ?, ?, ?)256ON CONFLICT(session_id, turn_index) DO UPDATE SET257user_message = COALESCE(excluded.user_message, user_message),258assistant_response = COALESCE(excluded.assistant_response, assistant_response)`,259).run(260turn.session_id,261turn.turn_index,262turn.user_message ?? null,263turn.assistant_response ?? null,264turn.timestamp ?? new Date().toISOString(),265);266267// Index searchable content268const content = [turn.user_message, turn.assistant_response].filter(Boolean).join('\n');269if (content) {270const turnId = `${turn.session_id}:turn:${turn.turn_index}`;271// Remove old FTS entry if exists, then insert new one272db.prepare('DELETE FROM search_index WHERE source_id = ?').run(turnId);273db.prepare(274'INSERT INTO search_index (content, session_id, source_type, source_id) VALUES (?, ?, ?, ?)',275).run(content, turn.session_id, 'turn', turnId);276}277}278279/**280* Insert a compaction checkpoint and index its sections for full-text search.281*/282insertCheckpoint(checkpoint: CheckpointRow): void {283const db = this.ensureDb();284285// Ensure session exists286db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(checkpoint.session_id);287288db.prepare(289`INSERT INTO checkpoints (session_id, checkpoint_number, title, overview, history, work_done, technical_details, important_files, next_steps, created_at)290VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)291ON CONFLICT(session_id, checkpoint_number) DO UPDATE SET292title = COALESCE(excluded.title, title),293overview = COALESCE(excluded.overview, overview),294history = COALESCE(excluded.history, history),295work_done = COALESCE(excluded.work_done, work_done),296technical_details = COALESCE(excluded.technical_details, technical_details),297important_files = COALESCE(excluded.important_files, important_files),298next_steps = COALESCE(excluded.next_steps, next_steps)`,299).run(300checkpoint.session_id,301checkpoint.checkpoint_number,302checkpoint.title ?? null,303checkpoint.overview ?? null,304checkpoint.history ?? null,305checkpoint.work_done ?? null,306checkpoint.technical_details ?? null,307checkpoint.important_files ?? null,308checkpoint.next_steps ?? null,309checkpoint.created_at ?? new Date().toISOString(),310);311312// Index each non-empty section313const sections: [string, string | undefined][] = [314['checkpoint_overview', checkpoint.overview],315['checkpoint_history', checkpoint.history],316['checkpoint_work_done', checkpoint.work_done],317['checkpoint_technical', checkpoint.technical_details],318['checkpoint_files', checkpoint.important_files],319['checkpoint_next_steps', checkpoint.next_steps],320];321322for (const [sourceType, content] of sections) {323if (!content) { continue; }324const sourceId = `${checkpoint.session_id}:ckpt:${checkpoint.checkpoint_number}:${sourceType}`;325db.prepare('DELETE FROM search_index WHERE source_id = ?').run(sourceId);326db.prepare(327'INSERT INTO search_index (content, session_id, source_type, source_id) VALUES (?, ?, ?, ?)',328).run(content, checkpoint.session_id, sourceType, sourceId);329}330}331332/**333* Record a file touched during a session.334* Uses INSERT OR IGNORE so the first occurrence wins.335*/336insertFile(file: FileRow): void {337const db = this.ensureDb();338db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(file.session_id);339db.prepare(340`INSERT OR IGNORE INTO session_files (session_id, file_path, tool_name, turn_index, first_seen_at)341VALUES (?, ?, ?, ?, ?)`,342).run(343file.session_id,344file.file_path,345file.tool_name ?? null,346file.turn_index ?? null,347file.first_seen_at ?? new Date().toISOString(),348);349}350351/**352* Record a reference (commit, PR, issue) for a session.353*/354insertRef(ref: RefRow): void {355const db = this.ensureDb();356db.prepare('INSERT OR IGNORE INTO sessions (id) VALUES (?)').run(ref.session_id);357db.prepare(358`INSERT OR IGNORE INTO session_refs (session_id, ref_type, ref_value, turn_index, created_at)359VALUES (?, ?, ?, ?, ?)`,360).run(361ref.session_id,362ref.ref_type,363ref.ref_value,364ref.turn_index ?? null,365ref.created_at ?? new Date().toISOString(),366);367}368369// ── Query Methods ───────────────────────────────────────────────────370371/**372* Index a workspace artifact (e.g. plan.md, context files) for full-text search.373* Content is upserted: subsequent writes to the same file replace the previous index entry.374*/375indexWorkspaceArtifact(sessionId: string, filePath: string, content: string): void {376if (!content.trim()) { return; }377const db = this.ensureDb();378const sourceId = `${sessionId}:workspace:${filePath}`;379db.prepare('DELETE FROM search_index WHERE source_id = ?').run(sourceId);380db.prepare('INSERT INTO search_index (content, session_id, source_type, source_id) VALUES (?, ?, ?, ?)').run(381content,382sessionId,383'workspace_artifact',384sourceId,385);386}387388/**389* Full-text search across all indexed content (turns, checkpoint sections, and workspace artifacts).390* Uses FTS5 MATCH with BM25 ranking.391*392* @param query FTS5 query string (supports AND, OR, NOT, phrase "..." etc.)393* @param limit Maximum results to return (default 20)394*/395search(query: string, limit: number = 20): SearchResult[] {396const db = this.ensureDb();397const stmt = db.prepare(`398SELECT content, session_id, source_type, bm25(search_index) AS rank399FROM search_index400WHERE search_index MATCH ?401ORDER BY rank402LIMIT ?403`);404return stmt.all(query, limit) as unknown as SearchResult[];405}406407/**408* Get a session by ID.409*/410getSession(sessionId: string): SessionRow | undefined {411const db = this.ensureDb();412return db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as unknown as SessionRow | undefined;413}414415/**416* Get all turns for a session, ordered by turn index.417*/418getTurns(sessionId: string): TurnRow[] {419const db = this.ensureDb();420return db421.prepare('SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index')422.all(sessionId) as unknown as TurnRow[];423}424425/**426* Get all files touched in a session.427*/428getFiles(sessionId: string): FileRow[] {429const db = this.ensureDb();430return db.prepare('SELECT * FROM session_files WHERE session_id = ?').all(sessionId) as unknown as FileRow[];431}432433/**434* Get all refs for a session.435*/436getRefs(sessionId: string): RefRow[] {437const db = this.ensureDb();438return db.prepare('SELECT * FROM session_refs WHERE session_id = ?').all(sessionId) as unknown as RefRow[];439}440441/**442* Execute a raw read-only SQL query against the store.443* Uses SQLite's authorizer API to enforce read-only access at the engine level,444* blocking INSERT, UPDATE, DELETE, DROP, CREATE, ATTACH, PRAGMA, etc.445*/446executeReadOnly(sql: string): Record<string, unknown>[] {447const db = this.ensureDb();448449// Use setAuthorizer to enforce read-only when available (Node.js 24.2+)450const hasAuthorizer = typeof (db as DatabaseSync & { setAuthorizer?: unknown }).setAuthorizer === 'function';451452if (!hasAuthorizer) {453// Fail closed: refuse to execute arbitrary SQL without engine-level enforcement454throw new Error('executeReadOnly requires SQLite authorizer support (Node.js 24.2+)');455}456457(db as DatabaseSync & { setAuthorizer: (cb: ((actionCode: number, p1: string | null) => number) | null) => void }).setAuthorizer((actionCode: number, p1: string | null) => {458if (READ_ONLY_ACTION_CODES.has(actionCode)) {459return SQLITE_OK;460}461// FTS5 internally uses PRAGMA data_version to detect DB changes462if (actionCode === SQLITE_PRAGMA && p1 === 'data_version') {463return SQLITE_OK;464}465return SQLITE_DENY;466});467468try {469return db.prepare(sql).all() as Record<string, unknown>[];470} finally {471(db as DatabaseSync & { setAuthorizer: (cb: null) => void }).setAuthorizer(null);472}473}474475/**476* Execute a read-only SQL query without authorizer enforcement.477* Used as a fallback when the authorizer API is unavailable (Node.js < 24.2).478* Callers MUST validate SQL safety before calling this method.479*/480executeReadOnlyFallback(sql: string): Record<string, unknown>[] {481const db = this.ensureDb();482return db.prepare(sql).all() as Record<string, unknown>[];483}484485/**486* Get the highest turn_index for a session, or -1 if no turns exist.487*/488getMaxTurnIndex(sessionId: string): number {489const db = this.ensureDb();490const row = db491.prepare('SELECT MAX(turn_index) as max_idx FROM turns WHERE session_id = ?')492.get(sessionId) as unknown as { max_idx: number | null } | undefined;493return row?.max_idx ?? -1;494}495496/**497* Get basic stats about the store.498*/499getStats(): { sessions: number; turns: number; checkpoints: number; files: number; refs: number } {500const db = this.ensureDb();501const count = (table: string) =>502(db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as unknown as { c: number }).c;503return {504sessions: count('sessions'),505turns: count('turns'),506checkpoints: count('checkpoints'),507files: count('session_files'),508refs: count('session_refs'),509};510}511512/**513* Run a function inside a SQLite transaction (BEGIN/COMMIT/ROLLBACK).514* All writes are batched into a single atomic commit, which is significantly515* faster than auto-committing each individual INSERT.516*/517runInTransaction(fn: () => void): void {518const db = this.ensureDb();519db.exec('BEGIN');520try {521fn();522db.exec('COMMIT');523} catch (err) {524db.exec('ROLLBACK');525throw err;526}527}528529/**530* Close the database connection.531*/532close(): void {533if (this.db) {534this.db.close();535this.db = null;536}537}538}539540541