import * as path from "node:path" import { randomUUID } from "node:crypto" import { Clock, Context, Effect, Layer, Result } from "effect" import { apneaRoot, artifactsDir, statePath, tasksDir, } from "../domain/paths.ts" import { ConfigError, NoRunState, StateCorrupt } from "../errors.ts" import type { RunState } from "../domain/types.ts" import { decodeRunState } from "../schema/state.ts" import { FileSystem, type FileSystemService } from "./file-system.ts" export interface RunStoreService { readonly load: ( root: string, ) => Effect.Effect readonly save: ( state: RunState, root: string, ) => Effect.Effect readonly require: ( root: string, ) => Effect.Effect readonly abandon: ( root: string, audit?: unknown, ) => Effect.Effect } export class RunStore extends Context.Service()( "apnea/RunStore", ) {} function ensureApneaDirs( fs: FileSystemService, root: string, runId?: string, ): Effect.Effect { const dirs = [ apneaRoot(root), artifactsDir(root, runId), tasksDir(root, runId), path.join(artifactsDir(root, runId), "plan-review"), ] return Effect.forEach(dirs, (d) => fs.mkdirProject(root, d), { discard: true, }) } export const RunStoreLive = Layer.effect( RunStore, Effect.gen(function* () { const fs = yield* FileSystem const load = ( root: string, ): Effect.Effect => Effect.gen(function* () { const p = statePath(root) const present = yield* fs.projectPathExists(root, p) if (!present) return null const text = yield* fs.readProjectFile(root, p).pipe( Effect.mapError( (error) => new StateCorrupt({ path: p, message: error.message, }), ), ) let json: unknown try { json = JSON.parse(text) } catch (e) { return yield* new StateCorrupt({ path: p, message: e instanceof Error ? e.message : String(e), }) } const decoded = decodeRunState(json, p) if (Result.isFailure(decoded)) { return yield* decoded.failure } return decoded.success }) const save = ( state: RunState, root: string, ): Effect.Effect => Effect.gen(function* () { yield* ensureApneaDirs(fs, root, state.run_id) const p = statePath(root) const { run_id, acquired_panes, ...fields } = state const body = `${JSON.stringify({ ...fields, run_id, acquired_panes }, null, 2)}\n` yield* fs.writeProjectFile(root, p, body) }) const require = ( root: string, ): Effect.Effect => Effect.gen(function* () { const s = yield* load(root) if (!s) return yield* new NoRunState({}) return s }) const abandon = ( root: string, audit?: unknown, ): Effect.Effect => Effect.gen(function* () { const p = statePath(root) const present = yield* fs.projectPathExists(root, p) if (!present) return yield* new NoRunState({}) const millis = yield* Clock.currentTimeMillis const bak = `${p}.abandoned.${millis}.${randomUUID()}` if (yield* fs.projectPathExists(root, bak)) return yield* new ConfigError({ message: "archive collision; active state retained", path: bak, }) if (audit !== undefined) yield* fs.writeProjectFile( root, `${bak}.audit.json`, `${JSON.stringify(audit, null, 2)}\n`, ) yield* fs.archiveProjectFile(root, p, bak) return bak }) return RunStore.of({ load, save, require, abandon }) }), )