import { isAbsolute } from "node:path"; import { decodeTaskAliases, normalizeTaskSubject, type TaskAliases } from "./orchestrator-alias.ts"; import { decodeWork, type PublishedWork } from "./orchestrator-work.ts"; export const ORCHESTRATOR_STATE_ENTRY = "gentle-agents.published-state"; const fields = ["objective", "progress", "decisions", "blockers"] as const; export type CuratedState = Partial> & { work?: PublishedWork }; export interface PublishedState { schema: 1 | 2; sessionId: string; recordedAt: number; cwd: string | null; source: "owner-curated"; ownerReply: false; authority: "none"; state: CuratedState | null; aliases?: TaskAliases; } const object = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); const safeText = (v: unknown): v is string => typeof v === "string" && !/[\p{Cc}\p{Cf}\p{Cs}]/u.test(v); const safeCwd = (v: unknown): v is string => safeText(v) && isAbsolute(v) && Buffer.byteLength(v) <= 1024 && !/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/u.test(v); /** Field whitelist, not secret redaction. Reject rather than truncate meaning. */ export function decodeCuratedState(value: unknown): CuratedState | null { if (value === null) return null; if (!object(value) || Object.keys(value).some(k => k !== "work" && !fields.includes(k as typeof fields[number]))) throw new Error("invalid-published-state"); const state: CuratedState = {}; let bytes = 0; for (const key of fields) { if (!Object.hasOwn(value, key)) continue; if (!safeText(value[key])) throw new Error("invalid-published-state"); state[key] = value[key]; bytes += Buffer.byteLength(value[key]); } if (Object.hasOwn(value, "work")) { state.work = decodeWork(value.work); bytes += Buffer.byteLength(JSON.stringify(state.work)); } if (bytes > 2048) throw new Error("invalid-published-state"); return state; } export function decodePublishedState(value: unknown): PublishedState | undefined { try { const keys = ["schema", "sessionId", "recordedAt", "cwd", "source", "ownerReply", "authority", "state", ...(object(value) && Object.hasOwn(value, "aliases") ? ["aliases"] : [])]; if (!object(value) || Object.keys(value).length !== keys.length || !keys.every(k => Object.hasOwn(value, k)) || (value.schema !== 1 && value.schema !== 2) || !safeText(value.sessionId) || !value.sessionId || Buffer.byteLength(value.sessionId) > 256 || !Number.isSafeInteger(value.recordedAt) || (value.recordedAt as number) < 0 || !(value.cwd === null || safeCwd(value.cwd)) || value.source !== "owner-curated" || value.ownerReply !== false || value.authority !== "none" || Buffer.byteLength(JSON.stringify(value)) > 4096) return undefined; const state = decodeCuratedState(value.state); const aliases = decodeTaskAliases(value.aliases); if ((value.schema === 2) !== !!state?.work || (Object.hasOwn(value, "aliases") && !aliases)) return undefined; return { schema: value.schema, sessionId: value.sessionId, recordedAt: value.recordedAt as number, cwd: safeText(value.cwd) ? value.cwd : null, source: "owner-curated", ownerReply: false, authority: "none", state, ...(aliases ? { aliases } : {}) }; } catch { return undefined; } } interface StateManager { getSessionId(): string; getCwd(): string; getBranch(): readonly { type: string; customType?: string; data?: unknown }[]; } /** One branch-local cache. Never accesses message bodies, summaries, or results. */ export class OrchestratorStateCache { private manager?: StateManager; private sessionId?: string; private value?: PublishedState; private epoch = 0; clear() { this.epoch++; this.manager = undefined; this.sessionId = undefined; this.value = undefined; } load(manager: StateManager) { this.clear(); this.manager = manager; this.sessionId = manager.getSessionId(); const branch = manager.getBranch(); for (let i = branch.length - 1; i >= 0; i--) { const entry = branch[i]; if (entry.type !== "custom" || entry.customType !== ORCHESTRATOR_STATE_ENTRY) continue; const decoded = decodePublishedState(entry.data); // A malformed/foreign latest record suppresses older knowledge. this.value = decoded?.sessionId === this.sessionId ? decoded : undefined; break; } } get(manager: StateManager): PublishedState | undefined { return this.manager === manager && this.sessionId === manager.getSessionId() && this.value ? structuredClone(this.value) : undefined; } publish(manager: StateManager, input: unknown, append: (type: string, data: PublishedState) => void, now = Date.now(), subject?: unknown) { const state = input === undefined ? this.value?.state ?? null : decodeCuratedState(input); const declared = normalizeTaskSubject(subject); if (this.manager !== manager || this.sessionId !== manager.getSessionId()) throw new Error("stale-published-state"); const epoch = this.epoch; const previous = this.value?.aliases; // Null/null marks a new publication with no declaration yet; legacy absence // cannot establish historical first-task knowledge. // An explicitly published work topic is owner-curated public task // classification, so it refreshes the current alias when the caller omits // the subject (#2553). State-only refreshes and automatic republications of // unchanged state must not re-derive it, and an explicit subject in the // same publication always wins. const topic = input !== undefined && !declared && state?.work?.topic ? normalizeTaskSubject(state.work.topic) : undefined; let aliases = previous; if (declared || topic) { const current = declared ?? topic; let initialAlias = previous?.initialAlias ?? null; if (!this.value || previous?.currentAlias === null) initialAlias = current; aliases = { initialAlias, currentAlias: current }; } else if (!this.value) aliases = { initialAlias: null, currentAlias: null }; const cwd = input === undefined && this.value ? this.value.cwd : manager.getCwd(); const record = decodePublishedState({ schema: state?.work ? 2 : 1, sessionId: manager.getSessionId(), recordedAt: input === undefined && this.value ? this.value.recordedAt : now, cwd: safeCwd(cwd) ? cwd : null, source: "owner-curated", ownerReply: false, authority: "none", state, ...(aliases ? { aliases } : {}) }); if (!record) throw new Error("invalid-published-state"); append(ORCHESTRATOR_STATE_ENTRY, structuredClone(record)); if (this.epoch !== epoch || this.manager !== manager || this.sessionId !== record.sessionId || manager.getSessionId() !== record.sessionId) throw new Error("stale-published-state"); this.value = record; } }