first commit
This commit is contained in:
291
src/agents/state-context.ts
Normal file
291
src/agents/state-context.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
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<string, boolean>;
|
||||
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<string, boolean> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
async initializeSession(
|
||||
sessionId: string,
|
||||
initialState: Partial<StoredSessionState> = {},
|
||||
): Promise<StoredSessionState> {
|
||||
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<StoredSessionState> {
|
||||
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<void> {
|
||||
const path = toStatePath(this.rootDirectory, sessionId);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async patchState(
|
||||
sessionId: string,
|
||||
patch: {
|
||||
flags?: Record<string, boolean>;
|
||||
metadata?: JsonObject;
|
||||
historyEvent?: SessionHistoryEntry;
|
||||
},
|
||||
): Promise<StoredSessionState> {
|
||||
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.historyEvent) {
|
||||
current.history.push(patch.historyEvent);
|
||||
}
|
||||
|
||||
await this.writeState(sessionId, current);
|
||||
return current;
|
||||
}
|
||||
|
||||
async writeHandoff(
|
||||
sessionId: string,
|
||||
handoff: {
|
||||
nodeId: string;
|
||||
fromNodeId?: string;
|
||||
payload: JsonObject;
|
||||
},
|
||||
): Promise<NodeHandoff> {
|
||||
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 writeFile(path, `${JSON.stringify(nodeHandoff, null, 2)}\n`, "utf8");
|
||||
return nodeHandoff;
|
||||
}
|
||||
|
||||
async readHandoff(sessionId: string, nodeId: string): Promise<NodeHandoff | undefined> {
|
||||
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<NodeExecutionContext> {
|
||||
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) } : {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user