import { randomBytes } from "node:crypto"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { SessionSnapshot, SessionTask, SessionTaskPlacement, SessionTaskStatus } from "./types.ts"; import { READABLE_SESSION_SNAPSHOT_VERSIONS, SESSION_SNAPSHOT_VERSION } from "./types.ts"; export const SESSION_SNAPSHOT_TYPE = "worklist-session-snapshot"; const SESSION_TASK_STATUSES: readonly SessionTaskStatus[] = ["todo", "doing", "done"]; export class SessionTaskAnchorNotFoundError extends Error { readonly anchorId: string; constructor(anchorId: string) { super(`Session task anchor ${anchorId} not found`); this.name = "SessionTaskAnchorNotFoundError"; this.anchorId = anchorId; } } export interface SessionMutationOptions { expectedRevision?: string; } export interface SessionMutationOutcome { result: T; changed: boolean; revision: string; } export class SessionRevisionConflictError extends Error { readonly expectedRevision: string; readonly actualRevision: string; constructor(expectedRevision: string, actualRevision: string) { super(`Session task revision changed from ${expectedRevision} to ${actualRevision}.`); this.name = "SessionRevisionConflictError"; this.expectedRevision = expectedRevision; this.actualRevision = actualRevision; } } function isValidSessionTask(value: unknown): value is SessionTask { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const task = value as Record; if (typeof task.id !== "string") return false; if (typeof task.title !== "string") return false; if (!SESSION_TASK_STATUSES.includes(task.status as SessionTaskStatus)) return false; if (task.goalId !== undefined && typeof task.goalId !== "string") return false; return true; } function toPublicSessionTask(task: SessionTask): SessionTask { const { id, title, status, goalId } = task; return { id, title, status, ...(goalId !== undefined ? { goalId } : {}) }; } /** * Field-picking also sheds unknown snapshot metadata, such as the orchestrator * projections that 0.5.x-0.8.x snapshots may carry, so legacy entries stay readable. */ function normalizeSessionTask(value: unknown): SessionTask | undefined { return isValidSessionTask(value) ? toPublicSessionTask(value) : undefined; } function createSessionTaskId(): string { return `st-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; } export class SessionStore { private readonly pi: ExtensionAPI; private tasks: SessionTask[] = []; private revision = "0"; private mutationQueue: Promise = Promise.resolve(); // A plain assignment keeps this module loadable under Node's strip-only TypeScript mode. constructor(pi: ExtensionAPI) { this.pi = pi; } getTasks(): SessionTask[] { return this.tasks.map(toPublicSessionTask); } getRevision(): string { return this.revision; } setTasks(tasks: SessionTask[]): void { this.tasks = tasks.map(toPublicSessionTask); } reconstruct(ctx: ExtensionContext): void { this.tasks = []; this.revision = "0"; const branch = ctx.sessionManager.getBranch(); for (const entry of branch) { if (entry.type !== "custom") continue; if (entry.customType !== SESSION_SNAPSHOT_TYPE) continue; const data = entry.data as SessionSnapshot | undefined; if (data && READABLE_SESSION_SNAPSHOT_VERSIONS.includes(data.version) && Array.isArray(data.tasks)) { this.tasks = data.tasks.flatMap((task) => { const normalized = normalizeSessionTask(task); return normalized === undefined ? [] : [normalized]; }); this.revision = "0"; if (typeof data.revision === "string" && data.revision.length > 0) { this.revision = data.revision; } else if (typeof entry.id === "string" && entry.id.length > 0) { this.revision = entry.id; } } } } private serialized(fn: () => T | Promise): Promise { const next = this.mutationQueue.then(fn); this.mutationQueue = next.catch(() => undefined); return next; } private assertExpectedRevision(options: SessionMutationOptions): void { if (options.expectedRevision !== undefined && options.expectedRevision !== this.revision) { throw new SessionRevisionConflictError(options.expectedRevision, this.revision); } } addTask( title: string, goalId?: string, placement?: SessionTaskPlacement, options: SessionMutationOptions = {}, ): Promise> { return this.serialized(() => { this.assertExpectedRevision(options); let insertionIndex = this.tasks.length; if (placement) { const anchorId = placement.beforeId ?? placement.afterId; const anchorIndex = this.tasks.findIndex((task) => task.id === anchorId); if (anchorIndex === -1) throw new SessionTaskAnchorNotFoundError(anchorId); insertionIndex = placement.beforeId !== undefined ? anchorIndex : anchorIndex + 1; } const id = createSessionTaskId(); const task: SessionTask = { id, title, status: "todo", ...(goalId !== undefined ? { goalId } : {}), }; const revision = this.persist([ ...this.tasks.slice(0, insertionIndex), task, ...this.tasks.slice(insertionIndex), ]); return { result: task, changed: true, revision }; }); } moveTask( id: string, placement: SessionTaskPlacement, options: SessionMutationOptions = {}, ): Promise> { return this.serialized(() => { this.assertExpectedRevision(options); const sourceIndex = this.tasks.findIndex((task) => task.id === id); if (sourceIndex === -1) return { result: null, changed: false, revision: this.revision }; const task = this.tasks[sourceIndex]; const anchorId = placement.beforeId ?? placement.afterId; if (anchorId === id) { return { result: toPublicSessionTask(task), changed: false, revision: this.revision }; } const remaining = [...this.tasks.slice(0, sourceIndex), ...this.tasks.slice(sourceIndex + 1)]; const anchorIndex = remaining.findIndex((candidate) => candidate.id === anchorId); if (anchorIndex === -1) throw new SessionTaskAnchorNotFoundError(anchorId); const insertionIndex = placement.beforeId !== undefined ? anchorIndex : anchorIndex + 1; const next = [...remaining.slice(0, insertionIndex), task, ...remaining.slice(insertionIndex)]; if (next.every((candidate, index) => candidate.id === this.tasks[index]?.id)) { return { result: toPublicSessionTask(task), changed: false, revision: this.revision }; } const revision = this.persist(next); return { result: toPublicSessionTask(task), changed: true, revision }; }); } updateTask( id: string, updates: Partial>, options: SessionMutationOptions = {}, ): Promise> { return this.serialized(() => { this.assertExpectedRevision(options); const index = this.tasks.findIndex((task) => task.id === id); if (index === -1) return { result: null, changed: false, revision: this.revision }; const current = this.tasks[index]; const updated = { ...current, ...updates }; if (updated.title === current.title && updated.goalId === current.goalId) { return { result: toPublicSessionTask(current), changed: false, revision: this.revision }; } const revision = this.persist([...this.tasks.slice(0, index), updated, ...this.tasks.slice(index + 1)]); return { result: toPublicSessionTask(updated), changed: true, revision }; }); } setTaskStatus( id: string, status: SessionTaskStatus, options: SessionMutationOptions = {}, ): Promise> { return this.serialized(() => { this.assertExpectedRevision(options); const index = this.tasks.findIndex((task) => task.id === id); if (index === -1) return { result: null, changed: false, revision: this.revision }; const current = this.tasks[index]; if (current.status === status) { return { result: toPublicSessionTask(current), changed: false, revision: this.revision }; } const next = { ...current, status }; const revision = this.persist([...this.tasks.slice(0, index), next, ...this.tasks.slice(index + 1)]); return { result: toPublicSessionTask(next), changed: true, revision }; }); } deleteTask(id: string, options: SessionMutationOptions = {}): Promise> { return this.serialized(() => { this.assertExpectedRevision(options); const tasks = this.tasks.filter((task) => task.id !== id); if (tasks.length === this.tasks.length) { return { result: false, changed: false, revision: this.revision }; } const revision = this.persist(tasks); return { result: true, changed: true, revision }; }); } private persist(tasks: SessionTask[]): string { const revision = randomBytes(16).toString("hex"); this.pi.appendEntry(SESSION_SNAPSHOT_TYPE, { version: SESSION_SNAPSHOT_VERSION, revision, tasks: [...tasks], }); this.tasks = tasks; this.revision = revision; return revision; } }