fix(web): 收敛 Workbench sessionId 权威路径
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user