import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts"; import { SessionError, toError } from "../types.ts"; import { getFileSystemResultOrThrow } from "./repo-utils.ts"; import { uuidv7 } from "./uuid.ts"; type JsonlSessionStorageFileSystem = Pick; interface SessionHeader { type: "session"; version: 3; id: string; timestamp: string; cwd: string; parentSession?: string; } function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { if (entry.type !== "label") return; const label = entry.label?.trim(); if (label) { labelsById.set(entry.targetId, label); } else { labelsById.delete(entry.targetId); } } function buildLabelsById(entries: SessionTreeEntry[]): Map { const labelsById = new Map(); for (const entry of entries) { updateLabelCache(labelsById, entry); } return labelsById; } function generateEntryId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { const id = uuidv7().slice(0, 8); if (!byId.has(id)) return id; } return uuidv7(); } function invalidSession(filePath: string, message: string, cause?: Error): SessionError { return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause); } function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError { return new SessionError( "invalid_entry", `Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`, cause, ); } function parseHeaderLine(line: string, filePath: string): SessionHeader { let parsed: unknown; try { parsed = JSON.parse(line); } catch (error) { throw invalidSession(filePath, "first line is not a valid session header", toError(error)); } if (typeof parsed !== "object" || parsed === null) { throw invalidSession(filePath, "first line is not a valid session header"); } const header = parsed as Partial; if (header.type !== "session") throw invalidSession(filePath, "first line is not a valid session header"); if (header.version !== 3) throw invalidSession(filePath, "unsupported session version"); if (typeof header.id !== "string" || !header.id) throw invalidSession(filePath, "session header is missing id"); if (typeof header.timestamp !== "string" || !header.timestamp) { throw invalidSession(filePath, "session header is missing timestamp"); } if (typeof header.cwd !== "string" || !header.cwd) throw invalidSession(filePath, "session header is missing cwd"); if (header.parentSession !== undefined && typeof header.parentSession !== "string") { throw invalidSession(filePath, "session header parentSession must be a string"); } return { type: "session", version: 3, id: header.id, timestamp: header.timestamp, cwd: header.cwd, parentSession: header.parentSession, }; } function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry { let parsed: unknown; try { parsed = JSON.parse(line); } catch (error) { throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error)); } if (typeof parsed !== "object" || parsed === null) { throw invalidEntry(filePath, lineNumber, "is not a valid session entry"); } const entry = parsed as { type?: unknown; id?: unknown; parentId?: unknown; timestamp?: unknown; targetId?: unknown; }; if (typeof entry.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type"); if (typeof entry.id !== "string" || !entry.id) throw invalidEntry(filePath, lineNumber, "is missing entry id"); if (entry.parentId !== null && typeof entry.parentId !== "string") { throw invalidEntry(filePath, lineNumber, "has invalid parentId"); } if (typeof entry.timestamp !== "string" || !entry.timestamp) { throw invalidEntry(filePath, lineNumber, "is missing timestamp"); } if (entry.type === "leaf" && entry.targetId !== null && typeof entry.targetId !== "string") { throw invalidEntry(filePath, lineNumber, "has invalid targetId"); } return entry as SessionTreeEntry; } function leafIdAfterEntry(entry: SessionTreeEntry): string | null { return entry.type === "leaf" ? entry.targetId : entry.id; } function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { return { id: header.id, createdAt: header.timestamp, cwd: header.cwd, path, parentSessionPath: header.parentSession, }; } export async function loadJsonlSessionMetadata( fs: JsonlSessionStorageFileSystem, filePath: string, ): Promise { const lines = getFileSystemResultOrThrow( await fs.readTextLines(filePath, { maxLines: 1 }), `Failed to read session header ${filePath}`, ); const line = lines[0]; if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath); throw invalidSession(filePath, "missing session header"); } async function loadJsonlStorage( fs: JsonlSessionStorageFileSystem, filePath: string, ): Promise<{ header: SessionHeader; entries: SessionTreeEntry[]; leafId: string | null; }> { const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`); const lines = content.split("\n").filter((line) => line.trim()); if (lines.length === 0) { throw invalidSession(filePath, "missing session header"); } const header = parseHeaderLine(lines[0]!, filePath); const entries: SessionTreeEntry[] = []; let leafId: string | null = null; for (let i = 1; i < lines.length; i++) { const entry = parseEntryLine(lines[i]!, filePath, i + 1); entries.push(entry); leafId = leafIdAfterEntry(entry); } return { header, entries, leafId }; } export class JsonlSessionStorage implements SessionStorage { private readonly fs: JsonlSessionStorageFileSystem; private readonly filePath: string; private readonly metadata: JsonlSessionMetadata; private entries: SessionTreeEntry[]; private byId: Map; private labelsById: Map; private currentLeafId: string | null; private constructor( fs: JsonlSessionStorageFileSystem, filePath: string, header: SessionHeader, entries: SessionTreeEntry[], leafId: string | null, ) { this.fs = fs; this.filePath = filePath; this.metadata = headerToSessionMetadata(header, this.filePath); this.entries = entries; this.byId = new Map(entries.map((entry) => [entry.id, entry])); this.labelsById = buildLabelsById(entries); this.currentLeafId = leafId; } static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise { const loaded = await loadJsonlStorage(fs, filePath); return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId); } static async create( fs: JsonlSessionStorageFileSystem, filePath: string, options: { cwd: string; sessionId: string; parentSessionPath?: string; }, ): Promise { const header: SessionHeader = { type: "session", version: 3, id: options.sessionId, timestamp: new Date().toISOString(), cwd: options.cwd, parentSession: options.parentSessionPath, }; getFileSystemResultOrThrow( await fs.writeFile(filePath, `${JSON.stringify(header)}\n`), `Failed to create session ${filePath}`, ); return new JsonlSessionStorage(fs, filePath, header, [], null); } async getMetadata(): Promise { return this.metadata; } async getLeafId(): Promise { if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) { throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`); } return this.currentLeafId; } async setLeafId(leafId: string | null): Promise { if (leafId !== null && !this.byId.has(leafId)) { throw new SessionError("not_found", `Entry ${leafId} not found`); } const entry: LeafEntry = { type: "leaf", id: generateEntryId(this.byId), parentId: this.currentLeafId, timestamp: new Date().toISOString(), targetId: leafId, }; getFileSystemResultOrThrow( await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), `Failed to append session leaf ${entry.id}`, ); this.entries.push(entry); this.byId.set(entry.id, entry); this.currentLeafId = leafId; } async createEntryId(): Promise { return generateEntryId(this.byId); } async appendEntry(entry: SessionTreeEntry): Promise { getFileSystemResultOrThrow( await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), `Failed to append session entry ${entry.id}`, ); this.entries.push(entry); this.byId.set(entry.id, entry); updateLabelCache(this.labelsById, entry); this.currentLeafId = leafIdAfterEntry(entry); } async getEntry(id: string): Promise { return this.byId.get(id); } async findEntries( type: TType, ): Promise>> { return this.entries.filter((entry): entry is Extract => entry.type === type); } async getLabel(id: string): Promise { return this.labelsById.get(id); } async getPathToRoot(leafId: string | null): Promise { if (leafId === null) return []; const path: SessionTreeEntry[] = []; let current = this.byId.get(leafId); if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); while (current) { path.unshift(current); if (!current.parentId) break; const parent = this.byId.get(current.parentId); if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); current = parent; } return path; } async getEntries(): Promise { return [...this.entries]; } }