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
+223
View File
@@ -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<string, string>;
area_ids?: string[];
area_colors?: Record<string, string>;
};
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<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
) {
const raw = await readJsonFile<unknown>(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<ResultReferenceRecord | null> {
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<string, unknown> | 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<string, unknown>) =>
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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
+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);
+14 -7
View File
@@ -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" });
+64 -8
View File
@@ -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,
),
);
+4
View File
@@ -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,
});