diff --git a/.opencode/tools/render_junctions.ts b/.opencode/tools/render_junctions.ts index 86473d7..4b511f6 100644 --- a/.opencode/tools/render_junctions.ts +++ b/.opencode/tools/render_junctions.ts @@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin"; export default tool({ description: - "在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),不要传 /tmp/*.json 之类的临时文件路径,也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若必须自行构造供 render_ref 引用的 JSON,其 data 结构必须为 { node_area_map: Record, area_ids?: string[], area_colors?: Record },其中 node_area_map 的 key 是 junction/node id,value 是 area id。", + "在前端地图上对 junctions 图层应用分区渲染。优先直接传入 render_ref(指向已持久化的渲染结果引用,格式应为 res-...),不要传 /tmp/*.json 之类的临时文件路径,也不要先把 ref 内容完整读出再重组;前端会自行根据 render_ref 拉取完整 payload 并渲染,这样可以避免 LLM 读取大型 node_area_map。若当前只有本地 JSON 文件,请先调用 store_render_ref 把它迁移为受控 render_ref。供 render_ref 引用的 JSON 结构必须为 { node_area_map: Record, area_ids?: string[], area_colors?: Record },其中 node_area_map 的 key 是 junction/node id,value 是 area id。", args: { reason: tool.schema .string() diff --git a/.opencode/tools/store_render_ref.ts b/.opencode/tools/store_render_ref.ts new file mode 100644 index 0000000..f9966cb --- /dev/null +++ b/.opencode/tools/store_render_ref.ts @@ -0,0 +1,36 @@ +import { tool } from "@opencode-ai/plugin"; + +const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; +const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + +export default tool({ + description: + "把本地 JSON 渲染文件迁移成受控的 render_ref。仅适用于需要通过链接引用传递的大型 junction render payload。", + args: { + reason: tool.schema + .string() + .describe("Why this local render payload should be persisted as a render_ref."), + file_path: tool.schema + .string() + .describe("Absolute path to a local JSON file containing the render payload or a wrapper object with data."), + }, + async execute(args, context) { + const response = await fetch(`${internalBaseUrl}/internal/tools/store-render-ref`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-agent-internal-token": internalToken, + }, + body: JSON.stringify({ + sessionId: context.sessionID, + file_path: args.file_path, + }), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(text); + } + return text; + }, +}); diff --git a/src/results/resolver.ts b/src/results/resolver.ts new file mode 100644 index 0000000..68fff02 --- /dev/null +++ b/src/results/resolver.ts @@ -0,0 +1,223 @@ +import { config } from "../config.js"; +import { readJsonFile } from "../utils/fileStore.js"; +import { + type ResultReferenceKind, + type ResultReferenceRecord, + type ResultReferenceSource, + type RetrievalContext, + RESULT_REFERENCE_KIND, + type ResultReferenceStore, +} from "./store.js"; + +type ResolveOptions = { + expectedKind?: ResultReferenceKind; + maxItems?: number; +}; + +type RegisterResultReferenceInput = { + actorKey: string; + clientSessionId: string; + data: unknown; + kind: ResultReferenceKind; + projectId?: string; + projectKey: string; + schemaVersion: number; + sessionId: string; + source: ResultReferenceSource; + traceId: string; +}; + +export type RenderJunctionPayload = { + node_area_map: Record; + area_ids?: string[]; + area_colors?: Record; +}; + +export class ResultReferenceResolver { + constructor(private readonly store: ResultReferenceStore) {} + + async register(input: RegisterResultReferenceInput) { + const normalizedData = normalizeDataForKind( + input.kind, + input.data, + input.schemaVersion, + ); + if (!normalizedData) { + throw new Error(`invalid payload for result ref kind '${input.kind}'`); + } + return this.store.store({ + actorKey: input.actorKey, + clientSessionId: input.clientSessionId, + data: normalizedData, + kind: input.kind, + projectId: input.projectId, + projectKey: input.projectKey, + schemaVersion: input.schemaVersion, + sessionId: input.sessionId, + source: input.source, + traceId: input.traceId, + }); + } + + async registerRenderPayloadFile( + filePath: string, + input: Omit, + ) { + const raw = await readJsonFile(filePath); + if (raw === null) { + throw new Error(`render payload file not found: ${filePath}`); + } + + const payload = extractRenderJunctionPayload(raw); + if (!payload) { + throw new Error("render payload file does not contain a valid junction render payload"); + } + + return this.register({ + ...input, + data: payload, + kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, + schemaVersion: 1, + }); + } + + async getAuthorized(resultRef: string, context: RetrievalContext, options: ResolveOptions = {}) { + const record = await this.getResolvedRecord(resultRef, context, options); + if (!record) { + return null; + } + return { + ok: true, + result_ref: record.resultRef, + result_size_bytes: record.sizeBytes, + stored_at: record.createdAt, + data: projectData(record.data, options.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS), + preview: record.preview, + kind: record.kind, + schema_version: record.schemaVersion, + source: record.source, + }; + } + + async getFullAuthorized( + resultRef: string, + context: RetrievalContext, + options: ResolveOptions = {}, + ) { + const record = await this.getResolvedRecord(resultRef, context, options); + if (!record) { + return null; + } + return { + ok: true, + result_ref: record.resultRef, + result_size_bytes: record.sizeBytes, + stored_at: record.createdAt, + data: record.data, + preview: record.preview, + kind: record.kind, + schema_version: record.schemaVersion, + source: record.source, + }; + } + + private async getResolvedRecord( + resultRef: string, + context: RetrievalContext, + options: ResolveOptions, + ): Promise { + const record = await this.store.getAuthorizedRecord(resultRef, context); + if (!record) { + return null; + } + if (options.expectedKind && record.kind !== options.expectedKind) { + return null; + } + const normalizedData = normalizeDataForKind( + record.kind, + record.data, + record.schemaVersion, + ); + if (!normalizedData) { + return null; + } + return { + ...record, + data: normalizedData, + }; + } +} + +export const extractRenderJunctionPayload = ( + value: unknown, +): RenderJunctionPayload | null => { + const candidate = unwrapReferencePayload(value); + if (!candidate || !isRecord(candidate.node_area_map)) { + return null; + } + + const nodeAreaMap = normalizeStringRecord(candidate.node_area_map); + if (Object.keys(nodeAreaMap).length === 0) { + return null; + } + + const areaIds = Array.isArray(candidate.area_ids) + ? candidate.area_ids.map((entry) => String(entry).trim()).filter(Boolean) + : undefined; + + const areaColors = isRecord(candidate.area_colors) + ? normalizeStringRecord(candidate.area_colors) + : undefined; + + return { + node_area_map: nodeAreaMap, + ...(areaIds && areaIds.length > 0 ? { area_ids: areaIds } : {}), + ...(areaColors && Object.keys(areaColors).length > 0 + ? { area_colors: areaColors } + : {}), + }; +}; + +const normalizeDataForKind = ( + kind: ResultReferenceKind, + data: unknown, + schemaVersion: number, +): unknown | null => { + if (!Number.isInteger(schemaVersion) || schemaVersion < 1) { + return null; + } + if (kind === RESULT_REFERENCE_KIND.renderJunctionsPayload) { + return extractRenderJunctionPayload(data); + } + return data; +}; + +const unwrapReferencePayload = (value: unknown): Record | null => { + if (!isRecord(value)) { + return null; + } + if ("data" in value && value.data !== undefined && value.data !== null) { + return isRecord(value.data) ? value.data : null; + } + return value; +}; + +const normalizeStringRecord = (value: Record) => + Object.fromEntries( + Object.entries(value) + .map(([key, entry]) => [String(key), String(entry ?? "").trim()]) + .filter(([, entry]) => entry.length > 0), + ); + +const projectData = (data: unknown, maxItems: number) => { + if (Array.isArray(data)) { + return data.slice(0, maxItems); + } + if (isRecord(data)) { + return Object.fromEntries(Object.entries(data).slice(0, maxItems)); + } + return data; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/results/store.ts b/src/results/store.ts index eff647e..c0baeed 100644 --- a/src/results/store.ts +++ b/src/results/store.ts @@ -12,19 +12,25 @@ import { removeFileIfExists, } from "../utils/fileStore.js"; -export type ResultReferenceRecord = { - resultRef: string; - actorKey: string; - clientSessionId: string; - createdAt: string; - data: unknown; - preview: ResultPreview; - projectId?: string; - projectKey: string; - sessionId: string; - sizeBytes: number; - traceId: string; -}; +export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{16}$/; + +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; @@ -33,29 +39,51 @@ export type ResultPreview = { 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; - maxItems?: number; 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; @@ -95,55 +123,56 @@ export class ResultReferenceStore { 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 getAuthorized(resultRef: string, context: RetrievalContext) { - const record = await this.readAuthorizedRecord(resultRef, context); - if (!record) { - return null; - } - const data = projectData(record.data, context.maxItems ?? config.RESULT_REF_MAX_RETRIEVAL_ITEMS); - return { - ok: true, - result_ref: record.resultRef, - result_size_bytes: record.sizeBytes, - stored_at: record.createdAt, - data, - preview: record.preview, - }; - } + async getAuthorizedRecord(resultRef: string, context: RetrievalContext) { + if (!RESULT_REF_PATTERN.test(resultRef)) { + return null; + } - async getFullAuthorized(resultRef: string, context: RetrievalContext) { - const record = await this.readAuthorizedRecord(resultRef, context); + const rawRecord = await readJsonFile(this.filePath(resultRef)); + const record = normalizeResultReferenceRecord(rawRecord); if (!record) { return null; } - return { - ok: true, - result_ref: record.resultRef, - result_size_bytes: record.sizeBytes, - stored_at: record.createdAt, - data: record.data, - preview: record.preview, - }; + 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.readAuthorizedRecord(resultRef, context); + 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, }; @@ -152,7 +181,9 @@ export class ResultReferenceStore { async listBySession(sessionId: string) { const files = await listJsonFiles(this.baseDir); const records = await Promise.all( - files.map(async (filePath) => readJsonFile(filePath)), + files.map(async (filePath) => + normalizeResultReferenceRecord(await readJsonFile(filePath)), + ), ); return records .filter((record): record is ResultReferenceRecord => Boolean(record)) @@ -177,28 +208,108 @@ export class ResultReferenceStore { private filePath(resultRef: string) { return join(this.baseDir, `${resultRef}.json`); } - - private async readAuthorizedRecord(resultRef: string, context: RetrievalContext) { - const record = await readJsonFile(this.filePath(resultRef)); - 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; - } } +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 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 => { @@ -219,7 +330,9 @@ const buildPreview = (data: unknown): ResultPreview => { 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]]), + fields + .slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS) + .map((field) => [field, data[field]]), ); return { count: fields.length, @@ -237,15 +350,5 @@ const buildPreview = (data: unknown): ResultPreview => { }; }; -const projectData = (data: unknown, maxItems: number) => { - if (Array.isArray(data)) { - return data.slice(0, maxItems); - } - if (isRecord(data)) { - return Object.fromEntries(Object.entries(data).slice(0, maxItems)); - } - return data; -}; - const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/routes/chat.ts b/src/routes/chat.ts index f4fa0b0..070a55f 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -4,7 +4,8 @@ import { z } from "zod"; import { type LearningOrchestrator } from "../learning/orchestrator.js"; import { logger } from "../logger.js"; import { MemoryStore } from "../memory/store.js"; -import { type ResultReferenceStore } from "../results/store.js"; +import { type ResultReferenceResolver } from "../results/resolver.js"; +import { RESULT_REFERENCE_KIND } from "../results/store.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type ChatSessionBridge } from "../chat/sessionBridge.js"; import { toActorKey } from "../utils/fileStore.js"; @@ -40,7 +41,7 @@ export const buildChatRouter = ( runtime: OpencodeRuntimeAdapter, memoryStore: MemoryStore, learningOrchestrator: LearningOrchestrator, - resultReferenceStore: ResultReferenceStore, + resultReferenceResolver: ResultReferenceResolver, ) => { const chatRouter = Router(); @@ -67,11 +68,17 @@ export const buildChatRouter = ( return; } - const result = await resultReferenceStore.getFullAuthorized(renderRef, { - actorKey: toActorKey(userId), - clientSessionId, - projectId, - }); + const result = await resultReferenceResolver.getFullAuthorized( + renderRef, + { + actorKey: toActorKey(userId), + clientSessionId, + projectId, + }, + { + expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload, + }, + ); if (!result) { res.status(404).json({ message: "render_ref not found" }); diff --git a/src/server.ts b/src/server.ts index 7ee9166..f7fc967 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import { config } from "./config.js"; import { logger } from "./logger.js"; import { LearningOrchestrator } from "./learning/orchestrator.js"; import { MemoryStore } from "./memory/store.js"; +import { ResultReferenceResolver } from "./results/resolver.js"; import { ResultReferenceStore } from "./results/store.js"; import { buildChatRouter } from "./routes/chat.js"; import { opencodeRuntime } from "./runtime/opencode.js"; @@ -27,10 +28,11 @@ const learningOrchestrator = new LearningOrchestrator( sessionHistoryStore, ); const resultReferenceStore = new ResultReferenceStore(); +const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const dynamicHttpExecutor = new DynamicHttpExecutor(resultReferenceStore); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); -// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref)。 +// 这个 token 只用于仍需服务端上下文的工具桥(dynamic_http_call / fetch_result_ref / store_render_ref)。 process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; app.use(cors()); @@ -121,12 +123,17 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => { return; } - const result = await resultReferenceStore.getAuthorized(resultRef, { - actorKey: context.actorKey, - maxItems: - typeof req.body?.max_items === "number" ? req.body.max_items : undefined, - projectId: context.projectId, - }); + const result = await resultReferenceResolver.getAuthorized( + resultRef, + { + actorKey: context.actorKey, + projectId: context.projectId, + }, + { + maxItems: + typeof req.body?.max_items === "number" ? req.body.max_items : undefined, + }, + ); if (!result) { res.status(404).json({ message: "result_ref not found" }); @@ -136,6 +143,55 @@ app.post("/internal/tools/fetch-result-ref", async (req, res) => { res.json(result); }); +app.post("/internal/tools/store-render-ref", async (req, res) => { + if (req.header("x-agent-internal-token") !== internalToken) { + res.status(403).json({ message: "forbidden" }); + return; + } + + const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : ""; + const filePath = typeof req.body?.file_path === "string" ? req.body.file_path.trim() : ""; + const context = sessionBridge.getSessionContext(sessionId); + if (!context) { + res.status(404).json({ + message: "session context not found", + detail: sessionId, + }); + return; + } + if (!filePath) { + res.status(400).json({ message: "file_path is required" }); + return; + } + + try { + const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, { + actorKey: context.actorKey, + clientSessionId: context.clientSessionId, + projectId: context.projectId, + projectKey: context.projectKey, + sessionId, + source: "migration", + traceId: context.traceId, + }); + res.json({ + ok: true, + render_ref: record.resultRef, + stored_at: record.createdAt, + preview: record.preview, + kind: record.kind, + schema_version: record.schemaVersion, + source: record.source, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + res.status(400).json({ + message: "store render ref failed", + detail, + }); + } +}); + app.post("/internal/tools/session-search", async (req, res) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" }); @@ -177,7 +233,7 @@ app.use( opencodeRuntime, memoryStore, learningOrchestrator, - resultReferenceStore, + resultReferenceResolver, ), ); diff --git a/src/tools/dynamicHttpExecutor.ts b/src/tools/dynamicHttpExecutor.ts index a6e1c79..d002395 100644 --- a/src/tools/dynamicHttpExecutor.ts +++ b/src/tools/dynamicHttpExecutor.ts @@ -1,5 +1,6 @@ import { config } from "../config.js"; import { logger } from "../logger.js"; +import { RESULT_REFERENCE_KIND, RESULT_REFERENCE_SOURCE } from "../results/store.js"; import { ResultReferenceStore } from "../results/store.js"; export type DynamicHttpInput = { @@ -146,9 +147,12 @@ const normalizeSuccessResult = async ( actorKey: context.actorKey, clientSessionId: context.clientSessionId, data, + kind: RESULT_REFERENCE_KIND.dynamicHttpResult, projectId: context.projectId, projectKey: context.projectKey, + schemaVersion: 1, sessionId: context.sessionId, + source: RESULT_REFERENCE_SOURCE.dynamicHttp, traceId: context.traceId, }); diff --git a/tests/results/store.test.ts b/tests/results/store.test.ts new file mode 100644 index 0000000..30049f4 --- /dev/null +++ b/tests/results/store.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { ResultReferenceResolver } from "../../src/results/resolver.js"; +import { + RESULT_REFERENCE_KIND, + RESULT_REFERENCE_SOURCE, + ResultReferenceStore, +} from "../../src/results/store.js"; + +describe("ResultReferenceResolver", () => { + let tempDir: string; + let store: ResultReferenceStore; + let resolver: ResultReferenceResolver; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-")); + store = new ResultReferenceStore(tempDir, 60_000); + resolver = new ResultReferenceResolver(store); + await store.initialize(); + }); + + afterEach(async () => { + await rm(tempDir, { force: true, recursive: true }); + }); + + it("stores metadata for new referenced results and resolves them", async () => { + const record = await resolver.register({ + actorKey: "actor-1", + clientSessionId: "client-1", + data: [{ id: "J1" }, { id: "J2" }], + kind: RESULT_REFERENCE_KIND.dynamicHttpResult, + projectId: "project-1", + projectKey: "project-key-1", + schemaVersion: 1, + sessionId: "session-1", + source: RESULT_REFERENCE_SOURCE.dynamicHttp, + traceId: "trace-1", + }); + + expect(record.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult); + expect(record.schemaVersion).toBe(1); + expect(record.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp); + + const result = await resolver.getAuthorized( + record.resultRef, + { + actorKey: "actor-1", + projectId: "project-1", + }, + { + maxItems: 1, + }, + ); + + expect(result).not.toBeNull(); + expect(result?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult); + expect(result?.schema_version).toBe(1); + expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.dynamicHttp); + expect(result?.data).toEqual([{ id: "J1" }]); + }); + + it("keeps legacy result refs readable while defaulting metadata", async () => { + const legacyRef = "res-aaaaaaaaaaaaaaaa"; + await writeFile( + join(tempDir, `${legacyRef}.json`), + JSON.stringify( + { + resultRef: legacyRef, + actorKey: "actor-legacy", + clientSessionId: "client-legacy", + createdAt: "2026-05-21T00:00:00.000Z", + data: { nodes: ["J1"] }, + preview: { + count: 1, + fields: ["nodes"], + sample: { nodes: ["J1"] }, + summary: "object<1 fields>", + }, + projectId: "project-legacy", + projectKey: "project-key-legacy", + sessionId: "session-legacy", + sizeBytes: 16, + traceId: "trace-legacy", + }, + null, + 2, + ), + "utf8", + ); + + const record = await store.getAuthorizedRecord(legacyRef, { + actorKey: "actor-legacy", + projectId: "project-legacy", + }); + + expect(record).not.toBeNull(); + expect(record?.kind).toBe(RESULT_REFERENCE_KIND.dynamicHttpResult); + expect(record?.schemaVersion).toBe(1); + expect(record?.source).toBe(RESULT_REFERENCE_SOURCE.legacy); + }); + + it("rejects malformed refs, mismatched kinds, and auth mismatches", async () => { + const malformedRef = "res-bbbbbbbbbbbbbbbb"; + await writeFile( + join(tempDir, `${malformedRef}.json`), + JSON.stringify( + { + resultRef: malformedRef, + createdAt: "2026-05-21T00:00:00.000Z", + data: { value: 1 }, + preview: { + count: 1, + fields: ["value"], + sample: { value: 1 }, + summary: "object<1 fields>", + }, + projectId: "project-1", + projectKey: "project-key-1", + sessionId: "session-1", + sizeBytes: 10, + traceId: "trace-1", + }, + null, + 2, + ), + "utf8", + ); + + const malformed = await store.getAuthorizedRecord(malformedRef, { + actorKey: "actor-1", + projectId: "project-1", + }); + expect(malformed).toBeNull(); + + const renderRecord = await resolver.register({ + actorKey: "actor-2", + clientSessionId: "client-2", + data: { + node_area_map: { + J1: "DMA-1", + }, + }, + kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, + projectId: "project-2", + projectKey: "project-key-2", + schemaVersion: 1, + sessionId: "session-2", + source: RESULT_REFERENCE_SOURCE.agentGenerated, + traceId: "trace-2", + }); + + const wrongKind = await resolver.getFullAuthorized( + renderRecord.resultRef, + { + actorKey: "actor-2", + projectId: "project-2", + }, + { + expectedKind: RESULT_REFERENCE_KIND.dynamicHttpResult, + }, + ); + expect(wrongKind).toBeNull(); + + const wrongActor = await resolver.getFullAuthorized(renderRecord.resultRef, { + actorKey: "actor-other", + projectId: "project-2", + }); + expect(wrongActor).toBeNull(); + }); + + it("registers render refs from local wrapper files and normalizes payloads", async () => { + const filePath = join(tempDir, "render-wrapper.json"); + await writeFile( + filePath, + JSON.stringify( + { + data: { + node_area_map: { + J1: "DMA-1", + J2: 2, + }, + area_ids: ["DMA-1", " DMA-2 "], + area_colors: { + "DMA-1": "#ff0000", + "DMA-2": "#00ff00", + }, + }, + createdAt: "2026-05-21T00:00:00.000Z", + }, + null, + 2, + ), + "utf8", + ); + + const record = await resolver.registerRenderPayloadFile(filePath, { + actorKey: "actor-3", + clientSessionId: "client-3", + projectId: "project-3", + projectKey: "project-key-3", + sessionId: "session-3", + source: RESULT_REFERENCE_SOURCE.migration, + traceId: "trace-3", + }); + + expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); + expect(record.source).toBe(RESULT_REFERENCE_SOURCE.migration); + + const result = await resolver.getFullAuthorized( + record.resultRef, + { + actorKey: "actor-3", + projectId: "project-3", + }, + { + expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload, + }, + ); + + expect(result?.data).toEqual({ + node_area_map: { + J1: "DMA-1", + J2: "2", + }, + area_ids: ["DMA-1", "DMA-2"], + area_colors: { + "DMA-1": "#ff0000", + "DMA-2": "#00ff00", + }, + }); + }); +});