File size: 7,500 Bytes
30cc31a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | // 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<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;
}
// ---- 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<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`);
}
// ---- projects ----
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;
}
// ---- tasks ----
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;
}
// ---- 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<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;
}
}
|