// File-based persistence adapter for Tasks, Projects, and Artifacts (JSON on disk, per-task locks). import { promises as fs } from "fs"; import path from "path"; import crypto from "crypto"; import type { TaskRecord, ProjectRecord, Artifact, ChatMessage } from "@/lib/types"; import { inferTaskTitle } from "@/lib/task-title"; const DATA_DIR = path.join(process.cwd(), ".data"); const TASKS_DIR = path.join(DATA_DIR, "tasks"); const ARTIFACTS_DIR = path.join(DATA_DIR, "artifacts"); const PROJECTS_FILE = path.join(DATA_DIR, "projects.json"); // ---- tiny per-key sequential lock ---- const locks = new Map>(); function withLock(key: string, fn: () => Promise): Promise { const prev = locks.get(key) ?? Promise.resolve(); const next = prev.then(fn, fn); locks.set( key, next.catch(() => undefined).finally(() => { if (locks.get(key) === next) locks.delete(key); }), ); return next; } // ---- id helpers ---- function randomHex(len: number): string { return crypto.randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len); } function projectId(): string { return `proj_${randomHex(10)}`; } function taskId(): string { return `sess_${randomHex(12)}`; } export function artifactId(): string { return `art_${randomHex(12)}`; } // ---- fs helpers ---- async function ensureDirs(): Promise { await fs.mkdir(TASKS_DIR, { recursive: true }); await fs.mkdir(ARTIFACTS_DIR, { recursive: true }); } async function readJson(file: string): Promise { try { const buf = await fs.readFile(file, "utf8"); return JSON.parse(buf) as T; } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } } async function writeJsonAtomic(file: string, value: unknown): Promise { await fs.mkdir(path.dirname(file), { recursive: true }); const tmp = `${file}.${process.pid}.${randomHex(6)}.tmp`; await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8"); await fs.rename(tmp, file); } function taskFile(id: string): string { return path.join(TASKS_DIR, `${id}.json`); } // ---- projects ---- async function readProjects(): Promise { await ensureDirs(); const existing = await readJson(PROJECTS_FILE); return existing ?? []; } async function writeProjects(projects: ProjectRecord[]): Promise { await writeJsonAtomic(PROJECTS_FILE, projects); } export async function ensureDefaultProject(): Promise { return withLock("projects", async () => { const projects = await readProjects(); if (projects.length > 0) return projects[0]; const proj: ProjectRecord = { id: projectId(), name: "Default", createdAt: Date.now(), }; await writeProjects([proj]); return proj; }); } export async function listProjects(): Promise { const projects = await readProjects(); if (projects.length === 0) { const def = await ensureDefaultProject(); return [def]; } return projects; } async function getProject(id: string): Promise { const projects = await readProjects(); return projects.find((p) => p.id === id) ?? null; } // ---- tasks ---- export async function createTask( projectIdArg: string, firstMessage: string, ): Promise { const project = await getProject(projectIdArg); if (!project) throw new Error(`Project not found: ${projectIdArg}`); const id = taskId(); const now = Date.now(); const title = inferTaskTitle(firstMessage); const firstMsg: ChatMessage = { id: `msg_${randomHex(10)}`, role: "user", content: firstMessage, }; const task: TaskRecord = { id, projectId: projectIdArg, title, createdAt: now, updatedAt: now, messages: [firstMsg], artifacts: [], }; return withLock(`task:${id}`, async () => { await writeJsonAtomic(taskFile(id), task); return task; }); } export async function getTask(id: string): Promise { return readJson(taskFile(id)); } export async function appendMessage( id: string, message: ChatMessage, ): Promise { return withLock(`task:${id}`, async () => { const task = await readJson(taskFile(id)); if (!task) throw new Error(`Task not found: ${id}`); task.messages.push(message); task.updatedAt = Date.now(); await writeJsonAtomic(taskFile(id), task); return task; }); } export async function updateTask( id: string, patch: Partial, ): Promise { return withLock(`task:${id}`, async () => { const task = await readJson(taskFile(id)); if (!task) throw new Error(`Task not found: ${id}`); const next: TaskRecord = { ...task, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.messages !== undefined ? { messages: patch.messages } : {}), ...(patch.artifacts !== undefined ? { artifacts: patch.artifacts } : {}), id: task.id, projectId: task.projectId, createdAt: task.createdAt, updatedAt: Date.now(), }; await writeJsonAtomic(taskFile(id), next); return next; }); } export async function listTasks(projectIdArg: string): Promise { await ensureDirs(); const entries = await fs.readdir(TASKS_DIR).catch(() => [] as string[]); const tasks: TaskRecord[] = []; for (const entry of entries) { if (!entry.endsWith(".json")) continue; const task = await readJson(path.join(TASKS_DIR, entry)); if (task && task.projectId === projectIdArg) tasks.push(task); } tasks.sort((a, b) => b.updatedAt - a.updatedAt); return tasks; } // ---- artifacts ---- interface ArtifactMeta { artifact: Artifact; taskId: string; filename: string; } function artifactDir(id: string): string { return path.join(ARTIFACTS_DIR, id); } export async function saveArtifact( taskIdArg: string, artifact: Artifact, body: Buffer | string, ): Promise { const task = await getTask(taskIdArg); if (!task) throw new Error(`Task not found: ${taskIdArg}`); const dir = artifactDir(artifact.id); await fs.mkdir(dir, { recursive: true }); const filename = artifact.name || "artifact.bin"; const safeName = path.basename(filename); const blobPath = path.join(dir, safeName); const data = typeof body === "string" ? Buffer.from(body, "utf8") : body; await fs.writeFile(blobPath, data); const meta: ArtifactMeta = { artifact, taskId: taskIdArg, filename: safeName }; await writeJsonAtomic(path.join(dir, "meta.json"), meta); await withLock(`task:${taskIdArg}`, async () => { const t = await readJson(taskFile(taskIdArg)); if (!t) return; const idx = t.artifacts.findIndex((a) => a.id === artifact.id); if (idx >= 0) t.artifacts[idx] = artifact; else t.artifacts.push(artifact); t.updatedAt = Date.now(); await writeJsonAtomic(taskFile(taskIdArg), t); }); } export async function readArtifact( id: string, ): Promise<{ artifact: Artifact; body: Buffer } | null> { const dir = artifactDir(id); const meta = await readJson(path.join(dir, "meta.json")); if (!meta) return null; const blobPath = path.join(dir, meta.filename); try { const body = await fs.readFile(blobPath); return { artifact: meta.artifact, body }; } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; throw err; } }