/** * Goal state, entity-oriented from the outset (D31). * * A goal is not one run: it is many entities advancing independently, most of them * blocked on someone else at any moment. Every consequential action carries an * idempotency key so resuming tomorrow cannot repeat a contact or a submission, and * all state lives on disk so a fresh process can continue with no session context. */ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { writeJsonAtomic } from "./atomic.ts"; import { shortId } from "./ids.ts"; import { ensureGoalDirs, goalPaths, type GoalPaths } from "./paths.ts"; import { redactDeep } from "./redact.ts"; import { CoreError, type ParkedOutcome, type TaskOutcome } from "./types.ts"; export interface ActionJournal { actionId: string; idempotencyKey: string; kind: string; intent?: string; status: "prepared" | "fired" | "verified" | "abandoned"; preparedAt: string; firedAt?: string; verifiedAt?: string; } export interface EntityRecord { entityId: string; goalId: string; label?: string; stage: string; /** Distilled semantic facts, never DOM history. */ facts: Record; outcome?: TaskOutcome; parked?: ParkedOutcome; wakeAt?: string; cooldownUntil?: string; journal?: ActionJournal; lastHandoff?: string; /** Idempotency keys already consumed by this entity. */ consumed: string[]; createdAt: string; updatedAt: string; } export interface GoalRecord { goalId: string; goal: string; createdAt: string; updatedAt: string; facts: Record; } function nowIso(): string { return new Date().toISOString(); } function entityFile(paths: GoalPaths, entityId: string): string { return path.join(paths.entitiesDir, `${entityId}.json`); } async function writeJson(file: string, value: unknown): Promise { await writeJsonAtomic(file, redactDeep(value)); } async function readJson(file: string): Promise { const raw = await readFile(file, "utf8").catch(() => ""); if (!raw.trim()) return undefined; return JSON.parse(raw) as T; } export class GoalStore { private constructor( readonly goalId: string, private readonly paths: GoalPaths, ) {} /** Open or create a goal. Safe to call in a fresh process. */ static async open(root: string, goalId: string, goal?: string): Promise { const paths = goalPaths(root, goalId); await ensureGoalDirs(paths); const store = new GoalStore(goalId, paths); const existing = await readJson(paths.goalFile); if (!existing) { await writeJson(paths.goalFile, { goalId, goal: goal ?? "", createdAt: nowIso(), updatedAt: nowIso(), facts: {}, } satisfies GoalRecord); } return store; } static newGoalId(): string { return shortId("goal"); } async goal(): Promise { const record = await readJson(this.paths.goalFile); if (!record) throw new CoreError("missing_goal", `No goal record for ${this.goalId}`); return record; } async mergeGoalFacts(facts: Record): Promise { const record = await this.goal(); const next: GoalRecord = { ...record, facts: { ...record.facts, ...facts }, updatedAt: nowIso(), }; await writeJson(this.paths.goalFile, next); return next; } async addEntity(input: { entityId?: string; label?: string; stage?: string; facts?: Record }): Promise { const entityId = input.entityId ?? shortId("ent"); const record: EntityRecord = { entityId, goalId: this.goalId, label: input.label, stage: input.stage ?? "discovered", facts: input.facts ?? {}, consumed: [], createdAt: nowIso(), updatedAt: nowIso(), }; await writeJson(entityFile(this.paths, entityId), record); return record; } async getEntity(entityId: string): Promise { return readJson(entityFile(this.paths, entityId)); } async requireEntity(entityId: string): Promise { const record = await this.getEntity(entityId); if (!record) throw new CoreError("missing_entity", `No entity ${entityId}`); return record; } async listEntities(): Promise { const names = await readdir(this.paths.entitiesDir).catch(() => []); const records: EntityRecord[] = []; for (const name of names) { if (!name.endsWith(".json")) continue; const record = await readJson(path.join(this.paths.entitiesDir, name)); if (record) records.push(record); } return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } private async update( entityId: string, change: (record: EntityRecord) => EntityRecord, ): Promise { const record = await this.requireEntity(entityId); const next = { ...change(record), updatedAt: nowIso() }; await writeJson(entityFile(this.paths, entityId), next); return next; } async setStage(entityId: string, stage: string): Promise { return this.update(entityId, (record) => ({ ...record, stage })); } async mergeFacts(entityId: string, facts: Record): Promise { return this.update(entityId, (record) => ({ ...record, facts: { ...record.facts, ...facts } })); } /** * Claim an idempotency key before a consequential action. * Returns false when the key was already consumed, which is how a resumed goal * avoids contacting or submitting twice. */ async claim(entityId: string, idempotencyKey: string): Promise { const record = await this.requireEntity(entityId); if (record.consumed.includes(idempotencyKey)) return false; await this.update(entityId, (current) => ({ ...current, consumed: [...current.consumed, idempotencyKey], })); return true; } async isConsumed(entityId: string, idempotencyKey: string): Promise { const record = await this.requireEntity(entityId); return record.consumed.includes(idempotencyKey); } /** Parking is per entity: unrelated entities keep working (D32). */ async park( entityId: string, parked: Omit & { wakeAt?: string; cooldownUntil?: string }, ): Promise { return this.update(entityId, (record) => ({ ...record, parked: { status: "parked", ...parked }, outcome: { status: "parked", ...parked }, wakeAt: parked.wakeAt, cooldownUntil: parked.cooldownUntil ?? record.cooldownUntil, lastHandoff: parked.handoff ?? record.lastHandoff, })); } async unpark(entityId: string): Promise { return this.update(entityId, (record) => ({ ...record, parked: undefined, outcome: undefined, })); } async setJournal(entityId: string, journal: ActionJournal | undefined): Promise { return this.update(entityId, (record) => ({ ...record, journal })); } async finish(entityId: string, outcome: TaskOutcome): Promise { return this.update(entityId, (record) => ({ ...record, outcome, parked: undefined })); } async parkedEntities(): Promise { return (await this.listEntities()).filter((record) => record.parked); } async activeEntities(): Promise { return (await this.listEntities()).filter((record) => !record.parked && !record.outcome); } }