import { mkdir, readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { writeUtf8FileAtomic } from "./file-persistence.js"; import { deepCloneJson, isRecord, type JsonObject, type JsonValue } from "./types.js"; export type SessionHistoryEntry = { nodeId: string; event: string; timestamp: string; data?: JsonObject; }; export type StoredSessionState = { flags: Record; metadata: JsonObject; history: SessionHistoryEntry[]; }; export type NodeHandoff = { nodeId: string; fromNodeId?: string; payload: JsonObject; createdAt: string; }; export type NodeExecutionContext = { sessionId: string; nodeId: string; handoff: NodeHandoff; state: StoredSessionState; }; const DEFAULT_STATE: StoredSessionState = { flags: {}, metadata: {}, history: [], }; function toSessionDirectory(rootDirectory: string, sessionId: string): string { return resolve(rootDirectory, sessionId); } function toStatePath(rootDirectory: string, sessionId: string): string { return resolve(toSessionDirectory(rootDirectory, sessionId), "state.json"); } function toHandoffPath(rootDirectory: string, sessionId: string, nodeId: string): string { return resolve(toSessionDirectory(rootDirectory, sessionId), "handoffs", `${nodeId}.json`); } function toJsonObject(value: unknown, errorMessage: string): JsonObject { if (!isRecord(value)) { throw new Error(errorMessage); } return value as JsonObject; } function toStoredSessionState(value: unknown): StoredSessionState { if (!isRecord(value)) { throw new Error("Stored state file is malformed."); } const flagsValue = value.flags; if (!isRecord(flagsValue)) { throw new Error("Stored state.flags is malformed."); } const flags: Record = {}; for (const [key, flagValue] of Object.entries(flagsValue)) { if (typeof flagValue !== "boolean") { throw new Error(`Stored state flag \"${key}\" must be a boolean.`); } flags[key] = flagValue; } const metadata = toJsonObject(value.metadata, "Stored state.metadata is malformed."); const historyValue = value.history; if (!Array.isArray(historyValue)) { throw new Error("Stored state.history is malformed."); } const history: SessionHistoryEntry[] = []; for (const entry of historyValue) { if (!isRecord(entry)) { throw new Error("Stored state.history entry is malformed."); } const nodeId = entry.nodeId; const event = entry.event; const timestamp = entry.timestamp; if (typeof nodeId !== "string" || nodeId.trim().length === 0) { throw new Error("Stored state.history entry nodeId is malformed."); } if (typeof event !== "string" || event.trim().length === 0) { throw new Error("Stored state.history entry event is malformed."); } if (typeof timestamp !== "string" || timestamp.trim().length === 0) { throw new Error("Stored state.history entry timestamp is malformed."); } const data = entry.data === undefined ? undefined : toJsonObject(entry.data, "Stored state.history entry data is malformed."); history.push({ nodeId, event, timestamp, ...(data ? { data } : {}), }); } return { flags, metadata, history, }; } function toNodeHandoff(value: unknown): NodeHandoff { if (!isRecord(value)) { throw new Error("Stored handoff file is malformed."); } const nodeId = value.nodeId; const createdAt = value.createdAt; const payload = value.payload; if (typeof nodeId !== "string" || nodeId.trim().length === 0) { throw new Error("Stored handoff nodeId is malformed."); } if (typeof createdAt !== "string" || createdAt.trim().length === 0) { throw new Error("Stored handoff createdAt is malformed."); } if (!isRecord(payload)) { throw new Error("Stored handoff payload is malformed."); } const fromNodeId = value.fromNodeId; if (fromNodeId !== undefined && (typeof fromNodeId !== "string" || fromNodeId.trim().length === 0)) { throw new Error("Stored handoff fromNodeId is malformed."); } return { nodeId, ...(typeof fromNodeId === "string" ? { fromNodeId } : {}), payload: payload as JsonObject, createdAt, }; } export class FileSystemStateContextManager { private readonly rootDirectory: string; constructor(input: { rootDirectory: string; }) { this.rootDirectory = resolve(input.rootDirectory); } getRootDirectory(): string { return this.rootDirectory; } getSessionStatePath(sessionId: string): string { return toStatePath(this.rootDirectory, sessionId); } async initializeSession( sessionId: string, initialState: Partial = {}, ): Promise { const state: StoredSessionState = { flags: { ...(initialState.flags ?? {}) }, metadata: deepCloneJson((initialState.metadata ?? {}) as JsonValue) as JsonObject, history: [...(initialState.history ?? [])], }; await mkdir(toSessionDirectory(this.rootDirectory, sessionId), { recursive: true }); await this.writeState(sessionId, state); return state; } async readState(sessionId: string): Promise { const path = toStatePath(this.rootDirectory, sessionId); try { const content = await readFile(path, "utf8"); const parsed = JSON.parse(content) as unknown; return toStoredSessionState(parsed); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { return deepCloneJson(DEFAULT_STATE as JsonValue) as StoredSessionState; } throw error; } } async writeState(sessionId: string, state: StoredSessionState): Promise { const path = toStatePath(this.rootDirectory, sessionId); await mkdir(dirname(path), { recursive: true }); await writeUtf8FileAtomic(path, `${JSON.stringify(state, null, 2)}\n`); } async patchState( sessionId: string, patch: { flags?: Record; metadata?: JsonObject; historyEvents?: SessionHistoryEntry[]; }, ): Promise { const current = await this.readState(sessionId); if (patch.flags) { Object.assign(current.flags, patch.flags); } if (patch.metadata) { Object.assign(current.metadata, patch.metadata); } if (patch.historyEvents && patch.historyEvents.length > 0) { current.history.push(...patch.historyEvents); } await this.writeState(sessionId, current); return current; } async writeHandoff( sessionId: string, handoff: { nodeId: string; fromNodeId?: string; payload: JsonObject; }, ): Promise { const nodeHandoff: NodeHandoff = { nodeId: handoff.nodeId, ...(handoff.fromNodeId ? { fromNodeId: handoff.fromNodeId } : {}), payload: deepCloneJson(handoff.payload), createdAt: new Date().toISOString(), }; const path = toHandoffPath(this.rootDirectory, sessionId, handoff.nodeId); await mkdir(dirname(path), { recursive: true }); await writeUtf8FileAtomic(path, `${JSON.stringify(nodeHandoff, null, 2)}\n`); return nodeHandoff; } async readHandoff(sessionId: string, nodeId: string): Promise { const path = toHandoffPath(this.rootDirectory, sessionId, nodeId); try { const content = await readFile(path, "utf8"); const parsed = JSON.parse(content) as unknown; return toNodeHandoff(parsed); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return undefined; } throw error; } } async buildFreshNodeContext(sessionId: string, nodeId: string): Promise { const handoff = await this.readHandoff(sessionId, nodeId); if (!handoff) { throw new Error(`No handoff exists for node \"${nodeId}\" in session \"${sessionId}\".`); } const state = await this.readState(sessionId); return { sessionId, nodeId, handoff: { nodeId: handoff.nodeId, ...(handoff.fromNodeId ? { fromNodeId: handoff.fromNodeId } : {}), payload: deepCloneJson(handoff.payload), createdAt: handoff.createdAt, }, state: { flags: { ...state.flags }, metadata: deepCloneJson(state.metadata), history: state.history.map((entry) => ({ nodeId: entry.nodeId, event: entry.event, timestamp: entry.timestamp, ...(entry.data ? { data: deepCloneJson(entry.data) } : {}), })), }, }; } }