| |
| 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"); |
|
|
| |
| const locks = new Map<string, Promise<unknown>>(); |
| function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> { |
| 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; |
| } |
|
|
| |
| 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)}`; } |
|
|
| |
| async function ensureDirs(): Promise<void> { |
| await fs.mkdir(TASKS_DIR, { recursive: true }); |
| await fs.mkdir(ARTIFACTS_DIR, { recursive: true }); |
| } |
|
|
| async function readJson<T>(file: string): Promise<T | null> { |
| 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<void> { |
| 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`); |
| } |
|
|
| |
| async function readProjects(): Promise<ProjectRecord[]> { |
| await ensureDirs(); |
| const existing = await readJson<ProjectRecord[]>(PROJECTS_FILE); |
| return existing ?? []; |
| } |
|
|
| async function writeProjects(projects: ProjectRecord[]): Promise<void> { |
| await writeJsonAtomic(PROJECTS_FILE, projects); |
| } |
|
|
| export async function ensureDefaultProject(): Promise<ProjectRecord> { |
| 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<ProjectRecord[]> { |
| const projects = await readProjects(); |
| if (projects.length === 0) { |
| const def = await ensureDefaultProject(); |
| return [def]; |
| } |
| return projects; |
| } |
|
|
| async function getProject(id: string): Promise<ProjectRecord | null> { |
| const projects = await readProjects(); |
| return projects.find((p) => p.id === id) ?? null; |
| } |
|
|
| |
| export async function createTask( |
| projectIdArg: string, |
| firstMessage: string, |
| ): Promise<TaskRecord> { |
| 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<TaskRecord | null> { |
| return readJson<TaskRecord>(taskFile(id)); |
| } |
|
|
| export async function appendMessage( |
| id: string, |
| message: ChatMessage, |
| ): Promise<TaskRecord> { |
| return withLock(`task:${id}`, async () => { |
| const task = await readJson<TaskRecord>(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<TaskRecord>, |
| ): Promise<TaskRecord> { |
| return withLock(`task:${id}`, async () => { |
| const task = await readJson<TaskRecord>(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<TaskRecord[]> { |
| 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<TaskRecord>(path.join(TASKS_DIR, entry)); |
| if (task && task.projectId === projectIdArg) tasks.push(task); |
| } |
| tasks.sort((a, b) => b.updatedAt - a.updatedAt); |
| return tasks; |
| } |
|
|
| |
| 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<void> { |
| 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<TaskRecord>(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<ArtifactMeta>(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; |
| } |
| } |
|
|