fix(results): support legacy render refs
This commit is contained in:
@@ -15,7 +15,9 @@ export default tool({
|
|||||||
.describe("Why this local render payload should be persisted as a render_ref."),
|
.describe("Why this local render payload should be persisted as a render_ref."),
|
||||||
file_path: tool.schema
|
file_path: tool.schema
|
||||||
.string()
|
.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) {
|
async execute(args, context) {
|
||||||
await initializePromise;
|
await initializePromise;
|
||||||
|
|||||||
+88
-2
@@ -1,5 +1,5 @@
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { readJsonFile } from "../utils/fileStore.js";
|
import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js";
|
||||||
import {
|
import {
|
||||||
type ResultReferenceKind,
|
type ResultReferenceKind,
|
||||||
type ResultReferenceRecord,
|
type ResultReferenceRecord,
|
||||||
@@ -68,7 +68,15 @@ export class ResultReferenceResolver {
|
|||||||
throw new Error(`render payload file not found: ${filePath}`);
|
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) {
|
if (!payload) {
|
||||||
throw new Error("render payload file does not contain a valid junction render payload");
|
throw new Error("render payload file does not contain a valid junction render payload");
|
||||||
}
|
}
|
||||||
@@ -192,6 +200,39 @@ const normalizeDataForKind = (
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeRenderPayloadFile = (
|
||||||
|
value: unknown,
|
||||||
|
context: { filePath: string; projectId?: string },
|
||||||
|
): { data: unknown; file: Record<string, unknown>; 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<string, unknown> = {
|
||||||
|
...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<string, unknown> | null => {
|
const unwrapReferencePayload = (value: unknown): Record<string, unknown> | null => {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -221,3 +262,48 @@ const projectData = (data: unknown, maxItems: number) => {
|
|||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+68
-4
@@ -10,9 +10,11 @@ import {
|
|||||||
listJsonFiles,
|
listJsonFiles,
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
removeFileIfExists,
|
removeFileIfExists,
|
||||||
|
toProjectKey,
|
||||||
} from "../utils/fileStore.js";
|
} 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 = {
|
export const RESULT_REFERENCE_KIND = {
|
||||||
dynamicHttpResult: "dynamic-http-result",
|
dynamicHttpResult: "dynamic-http-result",
|
||||||
@@ -138,12 +140,15 @@ export class ResultReferenceStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
async getAuthorizedRecord(resultRef: string, context: RetrievalContext) {
|
||||||
if (!RESULT_REF_PATTERN.test(resultRef)) {
|
const normalizedResultRef = normalizeResultRef(resultRef);
|
||||||
|
if (!normalizedResultRef) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawRecord = await readJsonFile<unknown>(this.filePath(resultRef));
|
const rawRecord = await readJsonFile<unknown>(this.filePath(normalizedResultRef));
|
||||||
const record = normalizeResultReferenceRecord(rawRecord);
|
const record =
|
||||||
|
normalizeResultReferenceRecord(rawRecord) ??
|
||||||
|
normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -301,6 +306,65 @@ const normalizeResultReferenceSource = (
|
|||||||
const isValidResultRef = (value: unknown): value is string =>
|
const isValidResultRef = (value: unknown): value is string =>
|
||||||
typeof value === "string" && RESULT_REF_PATTERN.test(value);
|
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 =>
|
const isResultPreview = (value: unknown): value is ResultPreview =>
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
typeof value.count === "number" &&
|
typeof value.count === "number" &&
|
||||||
|
|||||||
+183
-1
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -177,6 +177,13 @@ describe("ResultReferenceResolver", () => {
|
|||||||
filePath,
|
filePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
|
metadata: {
|
||||||
|
createdAt: "2026-05-21T00:00:00.000Z",
|
||||||
|
projectId: "project-3",
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
file_path: filePath,
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
node_area_map: {
|
node_area_map: {
|
||||||
J1: "DMA-1",
|
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<string, unknown>;
|
||||||
|
location?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
location?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user