From ab12d79d91216196c93cf414a64e932c03a806bd Mon Sep 17 00:00:00 2001 From: Huarch Date: Thu, 21 May 2026 18:18:16 +0800 Subject: [PATCH] fix(results): support legacy render refs --- .opencode/tools/store_render_ref.ts | 4 +- src/results/resolver.ts | 90 +++++++++++++- src/results/store.ts | 72 ++++++++++- tests/results/store.test.ts | 184 +++++++++++++++++++++++++++- 4 files changed, 342 insertions(+), 8 deletions(-) diff --git a/.opencode/tools/store_render_ref.ts b/.opencode/tools/store_render_ref.ts index 4b5496e..20725f1 100644 --- a/.opencode/tools/store_render_ref.ts +++ b/.opencode/tools/store_render_ref.ts @@ -15,7 +15,9 @@ export default tool({ .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."), + .describe( + "Absolute path to a local JSON file containing the raw render payload, or a wrapper object with data, metadata, and location. If wrapper metadata/location is missing or stale, the resolver will normalize and write it back before storing the render_ref.", + ), }, async execute(args, context) { await initializePromise; diff --git a/src/results/resolver.ts b/src/results/resolver.ts index 68fff02..95986a2 100644 --- a/src/results/resolver.ts +++ b/src/results/resolver.ts @@ -1,5 +1,5 @@ import { config } from "../config.js"; -import { readJsonFile } from "../utils/fileStore.js"; +import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js"; import { type ResultReferenceKind, type ResultReferenceRecord, @@ -68,7 +68,15 @@ export class ResultReferenceResolver { throw new Error(`render payload file not found: ${filePath}`); } - const payload = extractRenderJunctionPayload(raw); + const payloadCandidate = normalizeRenderPayloadFile(raw, { + filePath, + projectId: input.projectId, + }); + if (payloadCandidate.repaired) { + await atomicWriteJson(filePath, payloadCandidate.file); + } + + const payload = extractRenderJunctionPayload(payloadCandidate.data); if (!payload) { throw new Error("render payload file does not contain a valid junction render payload"); } @@ -192,6 +200,39 @@ const normalizeDataForKind = ( return data; }; +const normalizeRenderPayloadFile = ( + value: unknown, + context: { filePath: string; projectId?: string }, +): { data: unknown; file: Record; repaired: boolean } => { + if (!isRecord(value) || !("data" in value)) { + return { + data: value, + file: { + metadata: buildWrapperMetadata({}, value, context.projectId), + location: buildWrapperLocation(undefined, context.filePath), + data: value, + }, + repaired: false, + }; + } + + const metadata = buildWrapperMetadata(value.metadata, value, context.projectId); + const location = buildWrapperLocation(value.location, context.filePath); + const next: Record = { + ...value, + metadata, + location, + }; + + return { + data: next.data, + file: next, + repaired: + JSON.stringify(metadata) !== JSON.stringify(value.metadata ?? null) || + JSON.stringify(location) !== JSON.stringify(value.location ?? null), + }; +}; + const unwrapReferencePayload = (value: unknown): Record | null => { if (!isRecord(value)) { return null; @@ -221,3 +262,48 @@ const projectData = (data: unknown, maxItems: number) => { const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); + +const buildWrapperMetadata = ( + value: unknown, + root: unknown, + fallbackProjectId?: string, +) => { + const metadata = isRecord(value) ? { ...value } : {}; + const source = isRecord(root) ? root : {}; + + if (typeof metadata.createdAt !== "string" || metadata.createdAt.trim().length === 0) { + const createdAt = + typeof source.createdAt === "string" && source.createdAt.trim().length > 0 + ? source.createdAt.trim() + : new Date().toISOString(); + metadata.createdAt = createdAt; + } + + if ( + typeof metadata.projectId !== "string" || + metadata.projectId.trim().length === 0 + ) { + const projectId = + typeof source.projectId === "string" && source.projectId.trim().length > 0 + ? source.projectId.trim() + : fallbackProjectId; + if (projectId) { + metadata.projectId = projectId; + } + } + + return metadata; +}; + +const buildWrapperLocation = (value: unknown, filePath: string) => { + if (isRecord(value)) { + return { + ...value, + file_path: filePath, + }; + } + + return { + file_path: filePath, + }; +}; diff --git a/src/results/store.ts b/src/results/store.ts index c0baeed..81530f5 100644 --- a/src/results/store.ts +++ b/src/results/store.ts @@ -10,9 +10,11 @@ import { listJsonFiles, readJsonFile, removeFileIfExists, + toProjectKey, } from "../utils/fileStore.js"; -export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{16}$/; +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", @@ -138,12 +140,15 @@ export class ResultReferenceStore { } async getAuthorizedRecord(resultRef: string, context: RetrievalContext) { - if (!RESULT_REF_PATTERN.test(resultRef)) { + const normalizedResultRef = normalizeResultRef(resultRef); + if (!normalizedResultRef) { return null; } - const rawRecord = await readJsonFile(this.filePath(resultRef)); - const record = normalizeResultReferenceRecord(rawRecord); + const rawRecord = await readJsonFile(this.filePath(normalizedResultRef)); + const record = + normalizeResultReferenceRecord(rawRecord) ?? + normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context); if (!record) { return null; } @@ -301,6 +306,65 @@ const normalizeResultReferenceSource = ( 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" && diff --git a/tests/results/store.test.ts b/tests/results/store.test.ts index 30049f4..a86424d 100644 --- a/tests/results/store.test.ts +++ b/tests/results/store.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -177,6 +177,13 @@ describe("ResultReferenceResolver", () => { filePath, JSON.stringify( { + metadata: { + createdAt: "2026-05-21T00:00:00.000Z", + projectId: "project-3", + }, + location: { + file_path: filePath, + }, data: { node_area_map: { J1: "DMA-1", @@ -232,4 +239,179 @@ describe("ResultReferenceResolver", () => { }, }); }); + + it("repairs wrapper files that omit metadata and location", async () => { + const filePath = join(tempDir, "render-wrapper-missing-fields.json"); + await writeFile( + filePath, + JSON.stringify( + { + data: { + node_area_map: { + J1: "DMA-1", + }, + }, + createdAt: "2026-05-21T00:00:00.000Z", + }, + null, + 2, + ), + "utf8", + ); + + const record = await resolver.registerRenderPayloadFile(filePath, { + actorKey: "actor-4", + clientSessionId: "client-4", + projectId: "project-4", + projectKey: "project-key-4", + sessionId: "session-4", + source: RESULT_REFERENCE_SOURCE.migration, + traceId: "trace-4", + }); + + expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); + + const repaired = JSON.parse(await readFile(filePath, "utf8")) as { + metadata?: Record; + location?: Record; + }; + + expect(repaired.metadata).toEqual({ + createdAt: "2026-05-21T00:00:00.000Z", + projectId: "project-4", + }); + expect(repaired.location).toEqual({ + file_path: filePath, + }); + }); + + it("repairs wrapper files whose location points elsewhere", async () => { + const filePath = join(tempDir, "render-wrapper-wrong-location.json"); + await writeFile( + filePath, + JSON.stringify( + { + metadata: { + createdAt: "2026-05-21T00:00:00.000Z", + }, + location: { + file_path: "/tmp/elsewhere.json", + source: "legacy", + }, + data: { + node_area_map: { + J1: "DMA-1", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await resolver.registerRenderPayloadFile(filePath, { + actorKey: "actor-4", + clientSessionId: "client-4", + projectId: "project-4", + projectKey: "project-key-4", + sessionId: "session-4", + source: RESULT_REFERENCE_SOURCE.migration, + traceId: "trace-4", + }); + + const repaired = JSON.parse(await readFile(filePath, "utf8")) as { + metadata?: Record; + location?: Record; + }; + + expect(repaired.metadata).toEqual({ + createdAt: "2026-05-21T00:00:00.000Z", + projectId: "project-4", + }); + expect(repaired.location).toEqual({ + file_path: filePath, + source: "legacy", + }); + }); + + it("resolves legacy render payload files when callers include the json suffix", async () => { + const legacyRef = "res-c2fcee33-577e"; + await writeFile( + join(tempDir, `${legacyRef}.json`), + JSON.stringify( + { + data: { + node_area_map: { + J1: "DMA-1", + J2: 2, + }, + area_ids: ["DMA-1"], + }, + createdAt: "2026-05-21T00:00:00.000Z", + projectId: "project-legacy-render", + }, + null, + 2, + ), + "utf8", + ); + + const result = await resolver.getFullAuthorized( + `${legacyRef}.json`, + { + actorKey: "actor-legacy-render", + clientSessionId: "chat-legacy-render", + projectId: "project-legacy-render", + }, + { + expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload, + }, + ); + + expect(result?.result_ref).toBe(legacyRef); + expect(result?.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); + expect(result?.source).toBe(RESULT_REFERENCE_SOURCE.legacy); + expect(result?.data).toEqual({ + node_area_map: { + J1: "DMA-1", + J2: "2", + }, + area_ids: ["DMA-1"], + }); + }); + + it("keeps legacy render payload files scoped to their project", async () => { + const legacyRef = "res-dddddddddddddddd"; + await writeFile( + join(tempDir, `${legacyRef}.json`), + JSON.stringify( + { + data: { + node_area_map: { + J1: "DMA-1", + }, + }, + projectId: "project-allowed", + }, + null, + 2, + ), + "utf8", + ); + + const result = await resolver.getFullAuthorized( + legacyRef, + { + actorKey: "actor-legacy-render", + clientSessionId: "chat-legacy-render", + projectId: "project-denied", + }, + { + expectedKind: RESULT_REFERENCE_KIND.renderJunctionsPayload, + }, + ); + + expect(result).toBeNull(); + }); });