From 1ed7e56f3544127275b4cb19df648b318efc88e1 Mon Sep 17 00:00:00 2001 From: Huarch Date: Sun, 7 Jun 2026 16:56:23 +0800 Subject: [PATCH] refactor: remove legacy data compatibility --- src/results/resolver.ts | 99 +++----------- src/results/store.ts | 67 +-------- src/server.ts | 9 +- src/sessions/transcriptStore.ts | 44 ++---- tests/results/store.test.ts | 180 +------------------------ tests/sessions/transcriptStore.test.ts | 59 +------- 6 files changed, 46 insertions(+), 412 deletions(-) diff --git a/src/results/resolver.ts b/src/results/resolver.ts index 3c5730b..7b6995a 100644 --- a/src/results/resolver.ts +++ b/src/results/resolver.ts @@ -1,10 +1,11 @@ -import { atomicWriteJson, readJsonFile } from "../utils/fileStore.js"; +import { readJsonFile } from "../utils/fileStore.js"; import { type ResultReferenceKind, type ResultReferenceRecord, type ResultReferenceSource, type RetrievalContext, RESULT_REFERENCE_KIND, + RESULT_REFERENCE_SOURCE, type ResultReferenceStore, } from "./store.js"; @@ -34,6 +35,7 @@ export type RenderJunctionPayload = { export class ResultReferenceResolver { constructor(private readonly store: ResultReferenceStore) {} + // Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。 async register(input: RegisterResultReferenceInput) { const normalizedData = normalizeDataForKind( input.kind, @@ -66,15 +68,12 @@ export class ResultReferenceResolver { throw new Error(`render payload file not found: ${filePath}`); } - const payloadCandidate = normalizeRenderPayloadFile(raw, { - filePath, - projectId: input.projectId, - }); - if (payloadCandidate.repaired) { - await atomicWriteJson(filePath, payloadCandidate.file); + const wrapper = normalizeRenderPayloadFile(raw, filePath); + if (!wrapper) { + throw new Error("render payload file must use the wrapped { metadata, location, data } format"); } - const payload = extractRenderJunctionPayload(payloadCandidate.data); + const payload = extractRenderJunctionPayload(wrapper.data); if (!payload) { throw new Error("render payload file does not contain a valid junction render payload"); } @@ -84,6 +83,7 @@ export class ResultReferenceResolver { data: payload, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, schemaVersion: 1, + source: RESULT_REFERENCE_SOURCE.agentGenerated, }); } @@ -144,6 +144,7 @@ export const extractRenderJunctionPayload = ( return null; } + // 节点渲染结果只保留前端真正需要的映射字段,剔除空值并统一转为字符串。 const nodeAreaMap = normalizeStringRecord(candidate.node_area_map); if (Object.keys(nodeAreaMap).length === 0) { return null; @@ -182,35 +183,18 @@ const normalizeDataForKind = ( const normalizeRenderPayloadFile = ( value: unknown, - context: { filePath: string; projectId?: string }, -): { data: unknown; file: Record; repaired: boolean } => { + filePath: string, +): { data: unknown } | null => { if (!isRecord(value) || !("data" in value)) { - return { - data: value, - file: { - metadata: buildWrapperMetadata({}, value, context.projectId), - location: buildWrapperLocation(undefined, context.filePath), - data: value, - }, - repaired: false, - }; + return null; } - - 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), - }; + if (!isRecord(value.metadata) || !isRecord(value.location)) { + return null; + } + if (value.location.file_path !== filePath) { + return null; + } + return { data: value.data }; }; const unwrapReferencePayload = (value: unknown): Record | null => { @@ -232,48 +216,3 @@ const normalizeStringRecord = (value: Record) => 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 a033254..8566eea 100644 --- a/src/results/store.ts +++ b/src/results/store.ts @@ -10,7 +10,6 @@ import { listJsonFiles, readJsonFile, removeFileIfExists, - toProjectKey, } from "../utils/fileStore.js"; export const RESULT_REF_PATTERN = /^res-[a-f0-9-]{8,64}$/; @@ -22,8 +21,6 @@ export const RESULT_REFERENCE_KIND = { export const RESULT_REFERENCE_SOURCE = { agentGenerated: "agent_generated", - legacy: "legacy", - migration: "migration", } as const; export type ResultReferenceKind = @@ -133,6 +130,7 @@ export class ResultReferenceStore { source: input.source, traceId: input.traceId, }; + // result_ref 对外暴露短引用,完整数据落盘;这样可以避免大结果直接塞进模型上下文。 await atomicWriteJson(this.filePath(resultRef), record); return record; } @@ -143,13 +141,13 @@ export class ResultReferenceStore { return null; } - const rawRecord = await readJsonFile(this.filePath(normalizedResultRef)); - const record = - normalizeResultReferenceRecord(rawRecord) ?? - normalizeLegacyRenderReferenceRecord(rawRecord, normalizedResultRef, context); + const record = normalizeResultReferenceRecord( + await readJsonFile(this.filePath(normalizedResultRef)), + ); if (!record) { return null; } + // 读取 result_ref 时按用户、项目和可选会话三层校验,防止跨项目/跨用户取数。 if (record.actorKey !== context.actorKey) { return null; } @@ -202,6 +200,7 @@ export class ResultReferenceStore { if (!stats) { continue; } + // TTL 以文件修改时间为准,清理长期无人访问的 result_ref 文件。 if (now - stats.mtimeMs > this.ttlMs) { await removeFileIfExists(filePath); } @@ -303,60 +302,6 @@ const normalizeResultRef = (value: string) => { 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/src/server.ts b/src/server.ts index 07b21fe..b89dcf2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,12 +12,17 @@ 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 { + RESULT_REFERENCE_SOURCE, + ResultReferenceStore, +} from "./results/store.js"; import { buildChatRouter } from "./routes/chat.js"; import { opencodeRuntime } from "./runtime/opencode.js"; import { SessionRuntimeContextStore } from "./sessions/runtimeContextStore.js"; const app = express(); + +// 这里集中组装 Agent 服务的运行期依赖,路由层只通过接口调用,便于测试时替换实现。 const sessionBridge = new ChatSessionBridge(opencodeRuntime); const sessionMetadataStore = new SessionMetadataStore(); const sessionUiStateStore = new SessionUiStateStore(); @@ -190,7 +195,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => { projectId: context.projectId, projectKey: context.projectKey, sessionId: context.clientSessionId, - source: "migration", + source: RESULT_REFERENCE_SOURCE.agentGenerated, traceId: context.traceId, }); res.json({ diff --git a/src/sessions/transcriptStore.ts b/src/sessions/transcriptStore.ts index 44449c6..bbe3f14 100644 --- a/src/sessions/transcriptStore.ts +++ b/src/sessions/transcriptStore.ts @@ -43,6 +43,9 @@ type SessionTranscriptContext = { sessionId: string; }; +const DEFAULT_SEARCH_MAX_RESULTS = 8; +const DEFAULT_SEARCH_MAX_QUERY_CHARS = 240; + export class SessionTranscriptStore { private readonly writeQueues = new Map>(); @@ -62,6 +65,7 @@ export class SessionTranscriptStore { ) { const key = this.filePath(context); return this.serializeWrite(key, async () => { + // 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。 const transcript = (await this.readTranscript(context)) ?? { actorKey: context.actorKey, clientSessionId: context.clientSessionId, @@ -82,6 +86,7 @@ export class SessionTranscriptStore { lastTurn?.userMessage === userMessage && lastTurn.assistantMessage === assistantMessage ) { + // 相同问答重复写入时只更新工具调用数量,避免刷新/重试造成 transcript 重复膨胀。 lastTurn.toolCallCount = Math.max(lastTurn.toolCallCount, turn.toolCallCount); transcript.clientSessionId = context.clientSessionId ?? transcript.clientSessionId; transcript.sessionId = context.sessionId; @@ -129,6 +134,7 @@ export class SessionTranscriptStore { ) { const sourceTranscript = await this.readTranscript(sourceContext); const timestamp = new Date().toISOString(); + // 分叉会话只复制用户选择保留的上下文,后续新分支拥有独立 transcript 文件。 const nextTranscript: SessionTranscriptRecord = { actorKey: targetContext.actorKey, clientSessionId: targetContext.clientSessionId, @@ -144,9 +150,10 @@ export class SessionTranscriptStore { async search( context: Pick, query: string, - maxResults = config.SESSION_SEARCH_MAX_RESULTS, + maxResults = DEFAULT_SEARCH_MAX_RESULTS, ): Promise { - const normalizedQuery = query.trim().toLowerCase().slice(0, config.SESSION_SEARCH_MAX_QUERY_CHARS); + // 当前搜索是轻量本地文本匹配,按 actor/project 过滤后再计算简单相关性分数。 + const normalizedQuery = query.trim().toLowerCase().slice(0, DEFAULT_SEARCH_MAX_QUERY_CHARS); if (!normalizedQuery) { return []; } @@ -189,38 +196,7 @@ export class SessionTranscriptStore { } private async readTranscript(context: SessionTranscriptContext) { - const direct = await readJsonFile(this.filePath(context)); - if (direct) { - return direct; - } - - const clientSessionId = context.clientSessionId?.trim(); - if (!clientSessionId) { - return null; - } - - const files = await listJsonFiles(this.baseDir); - const matches: SessionTranscriptRecord[] = []; - for (const file of files) { - const transcript = await readJsonFile(file); - if (!transcript) { - continue; - } - if ( - transcript.actorKey !== context.actorKey || - transcript.projectKey !== context.projectKey || - transcript.clientSessionId !== clientSessionId - ) { - continue; - } - matches.push(transcript); - } - - if (matches.length === 0) { - return null; - } - - return matches.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null; + return await readJsonFile(this.filePath(context)); } private filePath(context: SessionTranscriptContext) { diff --git a/tests/results/store.test.ts b/tests/results/store.test.ts index 2afb1fb..4c5f0ab 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, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -164,12 +164,12 @@ describe("ResultReferenceResolver", () => { projectId: "project-3", projectKey: "project-key-3", sessionId: "session-3", - source: RESULT_REFERENCE_SOURCE.migration, + source: RESULT_REFERENCE_SOURCE.agentGenerated, traceId: "trace-3", }); expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload); - expect(record.source).toBe(RESULT_REFERENCE_SOURCE.migration); + expect(record.source).toBe(RESULT_REFERENCE_SOURCE.agentGenerated); const result = await resolver.getFullAuthorized( record.resultRef, @@ -195,178 +195,4 @@ 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(); - }); }); diff --git a/tests/sessions/transcriptStore.test.ts b/tests/sessions/transcriptStore.test.ts index 3e06a83..a03aff5 100644 --- a/tests/sessions/transcriptStore.test.ts +++ b/tests/sessions/transcriptStore.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, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,63 +19,6 @@ describe("SessionTranscriptStore", () => { await rm(tempDir, { force: true, recursive: true }); }); - it("falls back to legacy runtime-session transcripts by client session id and migrates on append", async () => { - await writeFile( - join(tempDir, "actor-1__project-1__runtime-session-1.json"), - JSON.stringify( - { - actorKey: "actor-1", - clientSessionId: "thread-1", - projectKey: "project-1", - sessionId: "runtime-session-1", - turns: [ - { - id: "turn-1", - assistantMessage: "先检查泵站流量。", - timestamp: "2026-05-21T00:00:00.000Z", - toolCallCount: 1, - userMessage: "帮我看一下当前异常。", - }, - ], - updatedAt: "2026-05-21T00:00:00.000Z", - }, - null, - 2, - ), - "utf8", - ); - - const recentTurns = await store.getRecentTurns( - { - actorKey: "actor-1", - clientSessionId: "thread-1", - projectKey: "project-1", - sessionId: "thread-1", - }, - 5, - ); - - expect(recentTurns).toHaveLength(1); - expect(recentTurns[0]?.userMessage).toBe("帮我看一下当前异常。"); - - const transcript = await store.appendTurn( - { - actorKey: "actor-1", - clientSessionId: "thread-1", - projectKey: "project-1", - sessionId: "thread-1", - }, - { - assistantMessage: "已经定位到 3 条疑似异常支路。", - toolCallCount: 2, - userMessage: "继续分析这些支路。", - }, - ); - - expect(transcript.sessionId).toBe("thread-1"); - expect(transcript.turns).toHaveLength(2); - }); - it("clones only the kept prefix when forking a thread", async () => { await store.appendTurn( {