Unify referenced result validation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-21 12:58:16 +08:00
parent 4870e8a577
commit cb298f2099
8 changed files with 753 additions and 89 deletions
+176 -73
View File
@@ -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<ResultReferenceRecord> & { 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<unknown>(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<ResultReferencePeek | null> {
const record = await this.readAuthorizedRecord(resultRef, context);
async peekAuthorized(
resultRef: string,
context: RetrievalContext,
): Promise<ResultReferencePeek | null> {
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<ResultReferenceRecord>(filePath)),
files.map(async (filePath) =>
normalizeResultReferenceRecord(await readJsonFile<unknown>(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<ResultReferenceRecord>(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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);