import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; import { atomicWriteJson, ensureDirectory, getFileStat, listJsonFiles, readJsonFile, removeFileIfExists, toProjectKey, } from "../utils/fileStore.js"; export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/; const RESULT_REF_FILE_PATTERN = /^(res-[a-f0-9-]{8,64})(?:\.json)?$/; export const RESULT_REFERENCE_KIND = { dynamicHttpResult: "dynamic-http-result", renderJunctionsPayload: "render-junctions-payload", } as const; export const RESULT_REFERENCE_SOURCE = { dynamicHttp: "dynamic_http", agentGenerated: "agent_generated", legacy: "legacy", migration: "migration", } as const; export type ResultReferenceKind = (typeof RESULT_REFERENCE_KIND)[keyof typeof RESULT_REFERENCE_KIND]; export type ResultReferenceSource = (typeof RESULT_REFERENCE_SOURCE)[keyof typeof RESULT_REFERENCE_SOURCE]; export type ResultPreview = { count: number; fields: string[]; sample: unknown; summary: string; }; export type ResultReferenceRecord = { resultRef: string; actorKey: string; clientSessionId: string; createdAt: string; data: unknown; kind: ResultReferenceKind; preview: ResultPreview; projectId?: string; projectKey: string; schemaVersion: number; sessionId: string; sizeBytes: number; source: ResultReferenceSource; traceId: string; }; export type StoreResultInput = { actorKey: string; clientSessionId: string; data: unknown; kind: ResultReferenceKind; projectId?: string; projectKey: string; schemaVersion: number; sessionId: string; source: ResultReferenceSource; traceId: string; }; export type RetrievalContext = { actorKey: string; clientSessionId?: string; projectId?: string; }; export type ResultReferencePeek = { resultRef: string; kind: ResultReferenceKind; preview: ResultPreview; storedAt: string; }; type PartialRecord = Partial & { data?: unknown }; export class ResultReferenceStore { private cleanupTimer: NodeJS.Timeout | null = null; constructor( private readonly baseDir = config.RESULT_REF_STORAGE_DIR, private readonly ttlMs = config.RESULT_REF_TTL_HOURS * 60 * 60 * 1000, ) {} async initialize() { await ensureDirectory(this.baseDir); } startCleanupLoop() { if (this.cleanupTimer) { return; } this.cleanupTimer = setInterval(() => { void this.cleanupExpired().catch((error) => { logger.warn({ err: error }, "result ref cleanup failed"); }); }, config.RESULT_REF_CLEANUP_INTERVAL_MS); this.cleanupTimer.unref?.(); } stopCleanupLoop() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = null; } } async store(input: StoreResultInput) { const resultRef = `res-${randomUUID().slice(0, 16)}`; const record: ResultReferenceRecord = { resultRef, actorKey: input.actorKey, clientSessionId: input.clientSessionId, createdAt: new Date().toISOString(), data: input.data, kind: input.kind, preview: buildPreview(input.data), projectId: input.projectId, projectKey: input.projectKey, schemaVersion: input.schemaVersion, sessionId: input.sessionId, sizeBytes: estimateBytes(input.data), source: input.source, traceId: input.traceId, }; await atomicWriteJson(this.filePath(resultRef), record); return record; } async getAuthorizedRecord(resultRef: string, context: RetrievalContext) { const normalizedResultRef = normalizeResultRef(resultRef); if (!normalizedResultRef) { return null; } const rawRecord = await readJsonFile(this.filePath(normalizedResultRef)); const record = normalizeResultReferenceRecord(rawRecord) ?? normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context); if (!record) { return null; } if (record.actorKey !== context.actorKey) { return null; } if ((record.projectId ?? "") !== (context.projectId ?? "")) { return null; } if ( context.clientSessionId && record.clientSessionId !== context.clientSessionId ) { return null; } return record; } async peekAuthorized( resultRef: string, context: RetrievalContext, ): Promise { const record = await this.getAuthorizedRecord(resultRef, context); if (!record) { return null; } return { resultRef: record.resultRef, kind: record.kind, preview: record.preview, storedAt: record.createdAt, }; } async listBySession(sessionId: string) { const files = await listJsonFiles(this.baseDir); const records = await Promise.all( files.map(async (filePath) => normalizeResultReferenceRecord(await readJsonFile(filePath)), ), ); return records .filter((record): record is ResultReferenceRecord => Boolean(record)) .filter((record) => record.sessionId === sessionId) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)); } async cleanupExpired() { const files = await listJsonFiles(this.baseDir); const now = Date.now(); for (const filePath of files) { const stats = await getFileStat(filePath); if (!stats) { continue; } if (now - stats.mtimeMs > this.ttlMs) { await removeFileIfExists(filePath); } } } private filePath(resultRef: string) { return join(this.baseDir, `${resultRef}.json`); } } export const normalizeResultReferenceRecord = ( value: unknown, ): ResultReferenceRecord | null => { if (!isRecord(value)) { return null; } const partial = value as PartialRecord; if ( !isValidResultRef(partial.resultRef) || typeof partial.actorKey !== "string" || typeof partial.clientSessionId !== "string" || typeof partial.createdAt !== "string" || !("data" in partial) || !isResultPreview(partial.preview) || typeof partial.projectKey !== "string" || typeof partial.sessionId !== "string" || typeof partial.sizeBytes !== "number" || !Number.isFinite(partial.sizeBytes) || typeof partial.traceId !== "string" ) { return null; } const kind = normalizeResultReferenceKind(partial.kind); const source = normalizeResultReferenceSource(partial.source); const schemaVersion = typeof partial.schemaVersion === "number" && Number.isInteger(partial.schemaVersion) && partial.schemaVersion > 0 ? partial.schemaVersion : 1; if (!kind || !source) { return null; } if ( partial.projectId !== undefined && typeof partial.projectId !== "string" ) { return null; } return { resultRef: partial.resultRef, actorKey: partial.actorKey, clientSessionId: partial.clientSessionId, createdAt: partial.createdAt, data: partial.data, kind, preview: partial.preview, projectId: partial.projectId, projectKey: partial.projectKey, schemaVersion, sessionId: partial.sessionId, sizeBytes: partial.sizeBytes, source, traceId: partial.traceId, }; }; const normalizeResultReferenceKind = ( value: unknown, ): ResultReferenceKind | null => { if (value === undefined) { return RESULT_REFERENCE_KIND.dynamicHttpResult; } return Object.values(RESULT_REFERENCE_KIND).includes( value as ResultReferenceKind, ) ? (value as ResultReferenceKind) : null; }; const normalizeResultReferenceSource = ( value: unknown, ): ResultReferenceSource | null => { if (value === undefined) { return RESULT_REFERENCE_SOURCE.legacy; } return Object.values(RESULT_REFERENCE_SOURCE).includes( value as ResultReferenceSource, ) ? (value as ResultReferenceSource) : null; }; const isValidResultRef = (value: unknown): value is string => typeof value === "string" && RESULT_REF_PATTERN.test(value); const normalizeResultRef = (value: string) => { const match = value.trim().match(RESULT_REF_FILE_PATTERN); return match?.[1] ?? null; }; const normalizeLegacyRenderReferenceRecord = ( value: unknown, resultRef: string, context: RetrievalContext, ): ResultReferenceRecord | null => { const data = extractLegacyRenderPayload(value); if (!data) { return null; } const root = isRecord(value) ? value : {}; const metadata = isRecord(root.metadata) ? root.metadata : {}; const projectId = firstNonEmptyString(root.projectId, metadata.projectId); const createdAt = firstNonEmptyString(root.createdAt, metadata.createdAt) ?? new Date().toISOString(); return { resultRef, actorKey: context.actorKey, clientSessionId: context.clientSessionId ?? "", createdAt, data, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, preview: buildPreview(data), projectId, projectKey: toProjectKey(projectId), schemaVersion: 1, sessionId: context.clientSessionId ?? resultRef, sizeBytes: estimateBytes(data), source: RESULT_REFERENCE_SOURCE.legacy, traceId: "legacy-render-ref", }; }; const extractLegacyRenderPayload = (value: unknown) => { if (!isRecord(value)) { return null; } const candidate = isRecord(value.data) ? value.data : value; if (!isRecord(candidate.node_area_map)) { return null; } return candidate; }; const firstNonEmptyString = (...values: unknown[]) => { for (const value of values) { if (typeof value === "string" && value.trim().length > 0) { return value.trim(); } } return undefined; }; const isResultPreview = (value: unknown): value is ResultPreview => isRecord(value) && typeof value.count === "number" && Number.isFinite(value.count) && Array.isArray(value.fields) && value.fields.every((field) => typeof field === "string") && typeof value.summary === "string" && "sample" in value; const estimateBytes = (data: unknown) => Buffer.byteLength(JSON.stringify(data)); const buildPreview = (data: unknown): ResultPreview => { if (Array.isArray(data)) { const sample = data.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS); const fields = sample.length > 0 && isRecord(sample[0]) ? Object.keys(sample[0]).slice(0, 30) : []; return { count: data.length, fields, sample, summary: `list[${data.length}]`, }; } if (isRecord(data)) { const fields = Object.keys(data).slice(0, 30); const sample = Object.fromEntries( fields .slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS) .map((field) => [field, data[field]]), ); return { count: fields.length, fields, sample, summary: `object<${fields.length} fields>`, }; } return { count: 1, fields: [], sample: String(data).slice(0, 300), summary: `scalar<${typeof data}>`, }; }; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value);