import { mkdir, readFile, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import type { AgentRun, RunEvent, RunRepository, } from "@great-agent/agent-core"; import { appendNdjson } from "../atomic-writes/append-ndjson"; import { writeJsonAtomically } from "../atomic-writes/write-json-atomically"; import type { DataLayout } from "../layout/data-layout"; export class FileRunRepository implements RunRepository { constructor(private readonly layout: DataLayout) {} async create(run: AgentRun): Promise { await this.update(run); } async update(run: AgentRun): Promise { await mkdir(this.runDirectory(run.id), { recursive: true }); await writeJsonAtomically(this.summaryPath(run.id), run); } async getById(id: string): Promise { try { return JSON.parse( await readFile(this.summaryPath(id), "utf8"), ) as AgentRun; } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; throw error; } } async findActive(): Promise { return ( (await this.readAll()).find( (run) => run.status === "running" || run.status === "waiting_user", ) ?? null ); } async appendEvent(event: RunEvent): Promise { await appendNdjson(this.eventsPath(event.runId), event); } async listEvents(runId: string): Promise { try { const text = await readFile(this.eventsPath(runId), "utf8"); return text .trim() .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as RunEvent); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; throw error; } } async hasActiveForConversations( conversationIds: readonly string[], ): Promise { if (conversationIds.length === 0) return false; const targets = new Set(conversationIds); return (await this.readAll()).some( (run) => targets.has(run.conversationId) && (run.status === "running" || run.status === "waiting_user"), ); } async deleteByConversations( conversationIds: readonly string[], ): Promise { if (conversationIds.length === 0) return; const targets = new Set(conversationIds); const runs = await this.readAll(); await Promise.all( runs .filter((run) => targets.has(run.conversationId)) .map((run) => rm(this.runDirectory(run.id), { recursive: true, force: true }), ), ); } private async readAll(): Promise { await mkdir(this.layout.runs, { recursive: true }); const names = await readdir(this.layout.runs, { withFileTypes: true }); const values = await Promise.all( names .filter((entry) => entry.isDirectory()) .map((entry) => this.getById(entry.name)), ); return values.filter((run): run is AgentRun => run !== null); } private runDirectory(id: string) { return join(this.layout.runs, id); } private summaryPath(id: string) { return join(this.runDirectory(id), "run.json"); } private eventsPath(id: string) { return join(this.runDirectory(id), "events.ndjson"); } }