import { appendFile, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { TaskState } from '../task/task-state.js'; export class TaskFileWriter { private readonly root: string; private readonly ensuredDirs = new Set(); constructor(root: string) { this.root = root; } private taskDir(taskId: string): string { return join(this.root, taskId); } private async ensureDir(taskId: string): Promise { const dir = this.taskDir(taskId); if (!this.ensuredDirs.has(taskId)) { await mkdir(dir, { recursive: true }); this.ensuredDirs.add(taskId); } return dir; } async writeMeta(task: TaskState): Promise { const dir = await this.ensureDir(task.id); const target = join(dir, 'meta.json'); const tmp = join(dir, `meta.json.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`); await writeFile(tmp, JSON.stringify(task, null, 2), 'utf8'); await rename(tmp, target); } async appendSummary(taskId: string, line: string): Promise { const dir = await this.ensureDir(taskId); await appendFile(join(dir, 'summary.log'), line + '\n', 'utf8'); } async appendVerbose(taskId: string, line: string): Promise { const dir = await this.ensureDir(taskId); await appendFile(join(dir, 'verbose.log'), line + '\n', 'utf8'); } async appendTimeline(taskId: string, line: string): Promise { const dir = await this.ensureDir(taskId); await appendFile(join(dir, 'timeline.log'), line + '\n', 'utf8'); } /** Append a raw JSON event line to events.jsonl for full debug tracing. */ async appendEvent(taskId: string, event: unknown): Promise { const dir = await this.ensureDir(taskId); const entry = JSON.stringify({ t: new Date().toISOString(), ...event as Record }); await appendFile(join(dir, 'events.jsonl'), entry + '\n', 'utf8'); } /** Read a persisted task's events.jsonl. Returns null if missing. */ async readEventsLog(taskId: string): Promise { try { return await readFile(join(this.taskDir(taskId), 'events.jsonl'), 'utf8'); } catch { return null; } } /** Read a persisted task's meta.json. Returns null if missing or corrupt. */ async readMeta(taskId: string): Promise { try { const raw = await readFile(join(this.taskDir(taskId), 'meta.json'), 'utf8'); return JSON.parse(raw) as TaskState; } catch { return null; } } /** Read a persisted task's verbose.log. Returns null if missing. */ async readVerboseLog(taskId: string): Promise { try { return await readFile(join(this.taskDir(taskId), 'verbose.log'), 'utf8'); } catch { return null; } } /** Read a persisted task's summary.log. Returns null if missing. */ async readSummaryLog(taskId: string): Promise { try { return await readFile(join(this.taskDir(taskId), 'summary.log'), 'utf8'); } catch { return null; } } /** Read a persisted task's timeline.log. Returns null if missing. */ async readTimeline(taskId: string): Promise { try { return await readFile(join(this.taskDir(taskId), 'timeline.log'), 'utf8'); } catch { return null; } } /** List all task IDs that have a persisted directory with meta.json. */ async listPersistedTaskIds(): Promise { try { const entries = await readdir(this.root, { withFileTypes: true }); const ids: string[] = []; for (const entry of entries) { if (entry.isDirectory()) { try { await readFile(join(this.root, entry.name, 'meta.json'), 'utf8'); ids.push(entry.name); } catch { // No meta.json — skip } } } return ids; } catch { return []; } } }