UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

118 lines (117 loc) • 4.48 kB
import { join, resolve } from "node:path"; import { readFileSync } from "node:fs"; import { assertJsonMetadata, normalizeTaskShape } from "../../tools/task-list.js"; import { canonicalStoreKey, atomicWriteFile, ensureDir } from "./fs-atomic.js"; import { assertAdoptionBootGate } from "./adoption/marker.js"; import { guardCorruptSink } from "./corrupt-read-sink.js"; const dirLocks = new Map(); export function createFileTaskListStore(root, opts) { const dir = resolve(root); assertAdoptionBootGate(opts?.dataRoot !== undefined ? resolve(opts.dataRoot) : dir, "createFileTaskListStore"); const tmpDir = join(dir, "tmp"); const path = join(dir, "task-list.json"); ensureDir(dir); const lockKey = canonicalStoreKey(dir); ensureDir(tmpDir); const disclose = guardCorruptSink(opts?.onCorruptRead); const refuseDamaged = (fault) => { disclose?.({ kind: "file", path, reason: fault }); throw new Error(`file task-list store: the task list document ${fault} — refusing rather than start an empty list, which would hand out again the task ids it reserved. Repair it: fix the file, or delete it (deleting it = an empty task list whose ids restart at 1). The file's location is reported to the store's corrupt-read disclosure (onCorruptRead), not in this message.`); }; const load = () => { let raw; try { raw = readFileSync(path, "utf8"); } catch (err) { if (err.code === "ENOENT") return { version: 1, nextId: 1, tasks: {} }; return refuseDamaged(`could not be read (${err.code ?? err.name})`); } if (raw === "") return { version: 1, nextId: 1, tasks: {} }; let parsed; try { parsed = JSON.parse(raw); } catch { return refuseDamaged("is not valid JSON (torn write?)"); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return refuseDamaged("is not a JSON object"); const doc = parsed; if (typeof doc.nextId !== "number" || typeof doc.tasks !== "object" || doc.tasks === null) return refuseDamaged("carries no numeric nextId or no tasks map"); return doc; }; const save = (doc) => { atomicWriteFile(tmpDir, path, JSON.stringify(doc)); }; const lockedOn = (fn) => { const prev = dirLocks.get(lockKey) ?? Promise.resolve(); const run = prev.then(fn, fn); dirLocks.set(lockKey, run.then(() => { }, () => { })); return run; }; const locked = lockedOn; const raw = { get: (id) => { const t = load().tasks[id]; return t ? snap(t) : undefined; }, set: (id, item) => { if (item.metadata) assertJsonMetadata(item.metadata); const doc = load(); doc.tasks[id] = snap(normalizeTaskShape(item)); save(doc); }, delete: (id) => { const doc = load(); if (!(id in doc.tasks)) return false; delete doc.tasks[id]; save(doc); return true; }, list: () => Object.values(load().tasks).map(snap), allocateId: () => { const doc = load(); const id = String(doc.nextId); doc.nextId += 1; save(doc); return id; }, }; const snap = (t) => structuredClone(t); return { get: (id) => locked(() => { const t = load().tasks[id]; return t ? snap(t) : undefined; }), set: (id, item) => locked(() => { if (item.metadata) assertJsonMetadata(item.metadata); const doc = load(); doc.tasks[id] = snap(normalizeTaskShape(item)); save(doc); }), delete: (id) => locked(() => { const doc = load(); if (!(id in doc.tasks)) return false; delete doc.tasks[id]; save(doc); return true; }), list: () => locked(() => Object.values(load().tasks).map(snap)), allocateId: () => locked(() => { const doc = load(); const id = String(doc.nextId); doc.nextId += 1; save(doc); return id; }), mutate: (fn) => lockedOn(() => fn(raw)), }; }