@sema-agent/core
Version:
Stateless, task-oriented AI agent core
118 lines (117 loc) • 4.48 kB
JavaScript
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)),
};
}