refactor: streamline query requests and session storage
Generic Container CI/CD / test-build-publish (push) Successful in 2m4s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 2m4s

This commit is contained in:
2026-11-09 18:49:32 +08:00
parent 6279a4b555
commit 5ee93329a4
25 changed files with 111 additions and 100 deletions
+3 -3
View File
@@ -203,7 +203,8 @@ export class McpQueryClient {
if (!owner.context.projectId || owner.context.projectKey.startsWith("personal-") || !owner.context.purpose || !["ordinary_chat", "task_execution", "task_collaboration"].includes(owner.context.purpose)) throw error(403, "Project query context required");
// No model-supplied identity, scope, bearer or Server URL can pass this boundary.
const schema = this.catalog().find((tool) => tool.name === name)!.inputSchema;
const budget = queryTimeouts(this.tools.find((tool) => tool.name === name)!, args);
const definition = this.tools.find((tool) => tool.name === name)!;
const budget = queryTimeouts(definition, args);
const reserved = new Set(["context_handle", "authorization", "headers", "project_id", "projectId", "network", "url", "server_url", "user_id", "subject", "client_id", "purpose", "native_session_id", "parent_session_id", "task_id", "scope", "scopes"]);
if (Object.keys(args).some((key) => reserved.has(key) || !Object.hasOwn(schema.properties ?? {}, key))) throw error(400, "Unknown query parameter");
const token = await this.userToken(owner.runtimeSessionId, owner.context.projectId, signal);
@@ -223,8 +224,7 @@ export class McpQueryClient {
const handle = body.context_handle;
try {
return await this.withClient(token.token, project, signal, async (client) => {
await client.listTools(undefined, { signal, timeout: 5000 });
return client.callTool({ name, arguments: { ...args, ...(this.tools.find((tool) => tool.name === name)!.inputSchema.properties?.timeout_seconds ? { timeout_seconds: budget.serverMs / 1000 } : {}), context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs });
return client.callTool({ name, arguments: { ...args, ...(definition.inputSchema.properties?.timeout_seconds ? { timeout_seconds: budget.serverMs / 1000 } : {}), context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs, toolDefinition: definition });
}, budget.callMs);
} finally {
// Expiry remains authoritative if transport cancellation prevents revocation.
+3 -2
View File
@@ -196,7 +196,8 @@ export const registerChatInteractionRoutes = (
}
}
// A host-confirmed reply remains authoritative after native forms are pruned.
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId });
const form = confirmedStatus ? undefined : await runtime.getQuestion({ sessionId: session.sessionId, requestId: canonicalId });
const nativeStatus = form?.state.status ?? "missing";
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
const persist = async (nextStatus: string, answers?: string[][]) => {
const response = {
@@ -215,7 +216,7 @@ export const registerChatInteractionRoutes = (
return;
}
const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined;
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] });
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [], form: form! });
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
await persist(action === "reply" ? "answered" : "rejected", answers);
if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId);
+6 -7
View File
@@ -2,7 +2,7 @@ import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS,
import { assertWebSearchReady } from "./web-search-check.js";
import { allowedQueryTools } from "./query-mcp-policy.js";
import { setTimeout as delayAbortable } from "node:timers/promises";
import { isMessageNotFoundError, isSessionNotFoundError, type OpenCodeClient as OpencodeClient } from "@opencode/client";
import { isMessageNotFoundError, isSessionNotFoundError, type FormDetail, type OpenCodeClient as OpencodeClient } from "@opencode/client";
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
import { existsSync, readFileSync } from "node:fs";
@@ -726,19 +726,18 @@ export class OpencodeRuntimeAdapter {
return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message });
}
async replyQuestion(options: { requestId: string; sessionId?: string; answers: QuestionAnswers }) {
async replyQuestion(options: { requestId: string; sessionId: string; answers: QuestionAnswers; form: FormDetail }) {
if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId");
const client = await this.ensureClient();
const form = await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId });
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) });
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(options.form, options.answers) });
}
async getQuestionStatus(options: { requestId: string; sessionId: string }): Promise<"pending" | "answered" | "cancelled" | "missing"> {
async getQuestion(options: { requestId: string; sessionId: string }): Promise<FormDetail | null> {
const client = await this.ensureClient();
try { return (await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId })).state.status; }
try { return await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId }); }
catch (error) {
const tag = error && typeof error === "object" && "_tag" in error ? error._tag : undefined;
if (tag === "FormNotFoundError") return "missing";
if (tag === "FormNotFoundError") return null;
throw error;
}
}
+2 -4
View File
@@ -48,9 +48,9 @@ const sessionWorkspaces = new SessionWorkspaceStore(config.CONVERSATION_WORKSPAC
const sessionMetadataStore = new SessionMetadataStore(undefined, sessionWorkspaces);
const answerViewStateStore = new AnswerViewStateStore(sessionWorkspaces);
const interactionRequestStore = new InteractionRequestStore(sessionWorkspaces);
const sessionUiStateStore = new SessionUiStateStore(undefined, sessionWorkspaces);
const sessionUiStateStore = new SessionUiStateStore(sessionWorkspaces);
const memoryStore = new MemoryStore();
const sessionTranscriptStore = new SessionTranscriptStore(undefined, sessionWorkspaces);
const sessionTranscriptStore = new SessionTranscriptStore(sessionWorkspaces);
const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime,
memoryStore,
@@ -169,10 +169,8 @@ const bootstrap = async () => {
logger.info(migrated, "session records migrated to owned workspaces; legacy sources archived");
}
await Promise.all([
sessionUiStateStore.initialize(),
learningOrchestrator.initialize(),
memoryStore.initialize(),
sessionTranscriptStore.initialize(),
]);
};
+8 -24
View File
@@ -1,10 +1,5 @@
import { join } from "node:path";
import { config } from "../config.js";
import {
atomicWriteJson,
ensureDirectory,
listJsonFiles,
readJsonFile,
toStableId,
} from "../utils/fileStore.js";
@@ -50,11 +45,7 @@ const DEFAULT_SEARCH_MAX_QUERY_CHARS = 240;
export class SessionTranscriptStore {
private readonly writeQueues = new Map<string, Promise<void>>();
constructor(private readonly baseDir = config.SESSION_TRANSCRIPT_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
async initialize() {
if (!this.workspaces) await ensureDirectory(this.baseDir);
}
constructor(private readonly workspaces: SessionWorkspaceStore) {}
async appendTurn(
context: SessionTranscriptContext,
@@ -66,7 +57,7 @@ export class SessionTranscriptStore {
) {
const key = this.filePath(context);
return this.serializeWrite(key, async () => {
if (this.workspaces) await this.workspaces.ensure(this.workspaceContext(context));
await this.workspaces.ensure(this.workspaceContext(context));
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
const transcript = (await this.readTranscript(context)) ?? {
actorKey: context.actorKey,
@@ -148,10 +139,8 @@ export class SessionTranscriptStore {
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
updatedAt: timestamp,
};
if (this.workspaces) {
await this.workspaces.ensure(this.workspaceContext(targetContext));
await this.workspaces.validate(this.workspaceContext(targetContext), "transcript.json");
}
await this.workspaces.ensure(this.workspaceContext(targetContext));
await this.workspaces.validate(this.workspaceContext(targetContext), "transcript.json");
await this.persist(targetContext, nextTranscript);
return nextTranscript;
}
@@ -168,7 +157,7 @@ export class SessionTranscriptStore {
}
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
const hits: SessionSearchHit[] = [];
const files = this.workspaces ? await this.workspaces.list(context, "transcript.json") : await listJsonFiles(this.baseDir);
const files = await this.workspaces.list(context, "transcript.json");
for (const file of files) {
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
if (!transcript) {
@@ -205,7 +194,7 @@ export class SessionTranscriptStore {
}
private async readTranscript(context: SessionTranscriptContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "transcript.json")) return null;
if (!await this.workspaces.validate(this.workspaceContext(context), "transcript.json")) return null;
const record = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
if (record && (record.actorKey !== context.actorKey || record.projectKey !== context.projectKey ||
(record.clientSessionId || record.sessionId) !== (context.clientSessionId || context.sessionId))) throw new Error("chat transcript ownership mismatch");
@@ -213,16 +202,11 @@ export class SessionTranscriptStore {
}
private filePath(context: SessionTranscriptContext) {
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "transcript.json");
return join(
this.baseDir,
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
);
return this.workspaces.path(this.workspaceContext(context), "transcript.json");
}
private async persist(context: SessionTranscriptContext, transcript: SessionTranscriptRecord) {
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "transcript.json", transcript);
else await atomicWriteJson(this.filePath(context), transcript);
await this.workspaces.writeRecord(this.workspaceContext(context), "transcript.json", transcript);
}
private workspaceContext(context: SessionTranscriptContext) {
+6 -24
View File
@@ -1,14 +1,9 @@
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
import { join } from "node:path";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js";
import {
atomicWriteJson,
ensureDirectory,
readJsonFile,
removeFileIfExists,
slugify,
} from "../utils/fileStore.js";
export type SessionUiStateRecord = {
@@ -21,49 +16,36 @@ export type SessionUiStateRecord = {
type SessionUiStateContext = SessionWorkspaceContext;
export class SessionUiStateStore {
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
async initialize() {
if (!this.workspaces) await ensureDirectory(this.baseDir);
}
constructor(private readonly workspaces: SessionWorkspaceStore) {}
async read(context: SessionUiStateContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return null;
if (!await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return null;
return await readJsonFile<SessionUiStateRecord>(this.filePath(context));
}
async write(context: SessionUiStateContext, state: SessionUiStateRecord) {
if (state.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
if (this.workspaces) {
await this.workspaces.ensure(this.workspaceContext(context));
await this.workspaces.validate(this.workspaceContext(context), "messages.json");
}
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "messages.json", state);
else await atomicWriteJson(this.filePath(context), state);
await this.workspaces.writeRecord(this.workspaceContext(context), "messages.json", state);
return state;
}
async update(context: SessionUiStateContext, change: (state: SessionUiStateRecord) => SessionUiStateRecord) {
if (this.workspaces) return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => {
return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => {
const state = current as SessionUiStateRecord | null;
if (!state || state.sessionId !== context.sessionId || !Array.isArray(state.messages)) throw new Error("chat state unavailable");
const next = change(state);
if (next.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
return { result: next, value: next };
});
const state = await this.read(context);
if (!state) throw new Error("chat state unavailable");
return this.write(context, change(state));
}
async remove(context: SessionUiStateContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
if (!await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
await removeFileIfExists(this.filePath(context));
}
private filePath(context: SessionUiStateContext) {
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "messages.json");
return join(this.baseDir, `${slugify(context.sessionId)}.json`);
return this.workspaces.path(this.workspaceContext(context), "messages.json");
}
private workspaceContext(context: SessionUiStateContext) {