fix(web): 收敛 Workbench sessionId 权威路径

This commit is contained in:
lyon
2026-06-18 13:34:34 +08:00
parent a8aec11c13
commit 3724e82a63
26 changed files with 368 additions and 613 deletions
@@ -26,6 +26,8 @@ interface ScenarioState {
workspaceJson: JsonRecord;
conversations: ConversationRecord[];
traces: Record<string, JsonRecord>;
requestLedger: JsonRecord[];
legacyRequestLedger: JsonRecord[];
selectRequests: JsonRecord[];
workspacePatchRequests: JsonRecord[];
chatRequests: JsonRecord[];
@@ -68,6 +70,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
const method = request.method ?? "GET";
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `127.0.0.1:${port}`}`);
const path = url.pathname;
recordLedger(method, url);
if (path === "/__e2e/health") return json(response, 200, { ok: true, scenarioId: state.scenarioId });
if (path === "/__e2e/state") return json(response, 200, stateSummary());
@@ -84,6 +87,20 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
await delay(state.conversationDelayMs);
return json(response, 200, workbenchSessionListPayload(url));
}
const workbenchTurnMatch = path.match(/^\/v1\/workbench\/turns\/([^/]+)$/u);
if (workbenchTurnMatch && method === "GET") {
const traceId = decodeURIComponent(workbenchTurnMatch[1] ?? "");
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } });
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
return json(response, 200, { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", turn: turnPayload(traceId) });
}
const workbenchTraceMatch = path.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u);
if (workbenchTraceMatch && method === "GET") {
const traceId = decodeURIComponent(workbenchTraceMatch[1] ?? "");
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } });
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
return json(response, 200, workbenchTracePayload(traceId, url));
}
const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u);
if (sessionMessagesMatch && method === "GET") {
const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? "");
@@ -98,31 +115,12 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
const conversation = workbenchConversationBySessionId(sessionId);
return conversation ? json(response, 200, { session: conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
}
if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() });
if (path === "/v1/workbench/workspace") return legacyApiResponse(response, method, path);
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
const body = await readJson(request);
state.workspacePatchRequests.push(redactRequestBody(body));
applyWorkspacePatch(body);
return json(response, 200, { workspace: workspacePayload() });
return legacyApiResponse(response, method, path);
}
if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") {
const body = await readJson(request);
state.selectRequests.push(redactRequestBody(body));
const requestedProjectId = typeof body.projectId === "string" && body.projectId.trim() ? body.projectId.trim() : state.projectId;
if (requestedProjectId !== state.projectId) return json(response, 409, { ok: false, status: 409, error: { code: "workspace_project_mismatch" } });
if (state.scenarioId === "server-authoritative-create" && body.create === true) {
const conversationId = "cnv_server_created";
if (!conversationById(conversationId)) state.conversations.unshift(createConversationFromSelect({ ...body, sessionId: "ses_server_created", threadId: null }, conversationId));
state.selectedConversationId = conversationId;
return json(response, 200, { workspace: workspacePayload() });
}
const conversationId = String(body.conversationId ?? "");
const conversation = conversationId ? conversationById(conversationId) : null;
if (conversation && conversationProjectId(conversation) !== requestedProjectId) return json(response, 404, { ok: false, status: 404, error: { code: "agent_conversation_not_found" } });
if (conversation?.status === "archived") return json(response, 404, { ok: false, status: 404, error: { code: "agent_conversation_not_found" } });
if (conversationId && !conversationById(conversationId) && body.create === true) state.conversations.unshift(createConversationFromSelect(body, conversationId));
if (conversationId && conversationById(conversationId)) state.selectedConversationId = conversationId;
return json(response, 200, { workspace: workspacePayload() });
return legacyApiResponse(response, method, path);
}
if (path === "/v1/agent/chat" && method === "POST") {
@@ -131,27 +129,24 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
return json(response, 200, acceptChatTurn(body));
}
if (path === "/v1/agent/sessions" && method === "POST") {
const body = await readJson(request);
const conversation = createManualSessionConversation(body);
state.selectedConversationId = conversation.conversationId;
state.conversations.unshift(conversation);
return json(response, 200, { ok: true, status: "ok", session: conversation });
}
if (path === "/v1/agent/conversations" && method === "GET") {
await delay(state.conversationDelayMs);
const include = url.searchParams.get("includeConversationId");
const projectId = requestedProjectId(url);
const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === projectId);
const conversations = state.listOmitSelected && include ? visibleConversations.filter((item) => item.conversationId !== include) : visibleConversations;
return json(response, 200, { conversations: conversations.map((conversation) => conversationSummary(conversation)) });
return legacyApiResponse(response, method, path);
}
const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
if (conversationMatch && method === "GET") {
const conversationId = decodeURIComponent(conversationMatch[1] ?? "");
if (state.scenarioId === "session-switch-detail-404-isolated" && conversationId === "cnv_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } });
if (state.scenarioId === "completed-replay-detail-404" && conversationId === "cnv_completed") return json(response, 404, { ok: false, status: 404, error: { code: "conversation_replay_unavailable" } });
const conversation = conversationById(conversationId);
if (conversation && conversationProjectMismatch(conversation, url)) return json(response, 404, { ok: false, status: 404, error: { code: "conversation_project_mismatch" } });
return conversation ? json(response, 200, { conversation }) : json(response, 404, { ok: false, status: 404, error: { code: "conversation_not_found" } });
return legacyApiResponse(response, method, path);
}
if (conversationMatch && method === "PUT") return json(response, 200, { ok: true, workspace: workspacePayload() });
if (conversationMatch && method === "PUT") return legacyApiResponse(response, method, path);
if (conversationMatch && method === "DELETE") {
deleteConversationById(decodeURIComponent(conversationMatch[1] ?? ""));
return json(response, 200, { ok: true, status: "deleted", workspace: workspacePayload() });
return legacyApiResponse(response, method, path);
}
const sessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
@@ -159,20 +154,12 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
const turnMatch = path.match(/^\/v1\/agent\/turns\/([^/]+)$/u);
if (turnMatch && method === "GET") {
const traceId = decodeURIComponent(turnMatch[1] ?? "");
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "turn_replay_unavailable" } });
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } });
return json(response, 200, turnPayload(traceId));
return legacyApiResponse(response, method, path);
}
const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u);
if (traceMatch && method === "GET") {
const traceId = decodeURIComponent(traceMatch[1] ?? "");
if (state.scenarioId === "completed-replay-detail-404" && traceId === "trc_completed") return json(response, 404, { ok: false, status: 404, error: { code: "trace_replay_unavailable" } });
if (traceId === state.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is unavailable" } });
if (traceProjectMismatch(traceId, url)) return json(response, 404, { ok: false, status: 404, error: { code: "trace_project_mismatch" } });
return json(response, 200, tracePayload(traceId, url));
return legacyApiResponse(response, method, path);
}
if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] });
@@ -227,6 +214,8 @@ function createScenarioState(scenarioId: string): ScenarioState {
workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, conversations, staleNestedTraceId),
conversations,
traces,
requestLedger: [],
legacyRequestLedger: [],
selectRequests: [],
workspacePatchRequests: [],
chatRequests: [],
@@ -313,6 +302,27 @@ function createConversationFromSelect(body: JsonRecord, conversationId: string):
};
}
function createManualSessionConversation(body: JsonRecord): ConversationRecord {
const now = new Date().toISOString();
const token = state.scenarioId === "server-authoritative-create" ? "server_created" : Date.now().toString(36);
const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`;
const conversationId = typeof body.conversationId === "string" && body.conversationId.trim() ? body.conversationId : `cnv_${token}`;
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
return {
conversationId,
projectId: state.projectId,
sessionId,
threadId,
status: "active",
startedAt: now,
updatedAt: now,
messageCount: 0,
firstUserMessagePreview: null,
session: { sessionId, threadId, status: "active" },
messages: []
};
}
function crossProjectConversation(): ConversationRecord {
const now = new Date().toISOString();
return {
@@ -403,7 +413,8 @@ function archivedDeletedConversation(): ConversationRecord {
}
function acceptChatTurn(body: JsonRecord): JsonRecord {
const conversationId = typeof body.conversationId === "string" ? body.conversationId : state.selectedConversationId;
const requestedSession = typeof body.sessionId === "string" ? conversationBySessionId(body.sessionId) : null;
const conversationId = typeof body.conversationId === "string" ? body.conversationId : requestedSession?.conversationId ?? state.selectedConversationId;
const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`;
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
const traceId = typeof body.traceId === "string" && body.traceId.trim() ? body.traceId : `trc_${Date.now().toString(16)}`;
@@ -523,6 +534,27 @@ function tracePayload(traceId: string, url: URL): JsonRecord {
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: hasMore ? lastSeq : null, range: { sinceSeq, returned: page.length, total: events.length } };
}
function workbenchTracePayload(traceId: string, url: URL): JsonRecord {
const payload = tracePayload(traceId, url);
return {
ok: true,
status: "ok",
contractVersion: "workbench-read-model-v1",
traceId,
traceStatus: payload.status,
events: payload.events,
eventCount: payload.eventCount,
hasMore: payload.hasMore,
nextSeq: payload.nextSinceSeq,
range: payload.range,
fullTraceLoaded: payload.fullTraceLoaded,
terminalEvidence: payload.terminalEvidence,
finalResponse: payload.finalResponse,
traceSummary: payload.traceSummary,
retention: payload.retention
};
}
function sse(response: ServerResponse, url: URL): void {
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
@@ -567,7 +599,27 @@ function clearActiveTraceForSelection(): void {
}
function stateSummary(): JsonRecord {
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) };
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) };
}
function recordLedger(method: string, url: URL): void {
if (!url.pathname.startsWith("/v1/")) return;
const entry = { method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()) };
state.requestLedger.push(entry);
if (isLegacyWorkbenchPath(url.pathname)) state.legacyRequestLedger.push(entry);
}
function isLegacyWorkbenchPath(path: string): boolean {
return path === "/v1/workbench/workspace"
|| /^\/v1\/workbench\/workspace\/[^/]+(?:\/select-conversation)?$/u.test(path)
|| path === "/v1/agent/conversations"
|| /^\/v1\/agent\/conversations\/[^/]+$/u.test(path)
|| /^\/v1\/agent\/turns\/[^/]+$/u.test(path)
|| /^\/v1\/agent\/traces\/[^/]+$/u.test(path);
}
function legacyApiResponse(response: ServerResponse, method: string, path: string): void {
return json(response, 410, { ok: false, status: 410, error: { code: "legacy_workbench_endpoint_forbidden", message: `${method} ${path} is not part of the sessionId Workbench contract.` } });
}
function authPayload(): JsonRecord {