refactor: streamline query requests and session storage
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user