Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts
T

632 lines
35 KiB
TypeScript

// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-010403 API契约 draft-2026-06-18-r1.
// Responsibility: Same-origin fake Workbench API and static server for Playwright browser regression tests.
import { createReadStream, existsSync, statSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { extname, join, resolve, sep } from "node:path";
type JsonRecord = Record<string, unknown>;
interface ConversationRecord extends JsonRecord {
conversationId: string;
sessionId?: string | null;
threadId?: string | null;
status?: string | null;
lastTraceId?: string | null;
messages?: JsonRecord[];
}
interface ScenarioState {
scenarioId: string;
projectId: string;
workspaceId: string;
providerProfile: string;
selectedConversationId: string;
workspaceJson: JsonRecord;
conversations: ConversationRecord[];
traces: Record<string, JsonRecord>;
selectRequests: JsonRecord[];
workspacePatchRequests: JsonRecord[];
chatRequests: JsonRecord[];
listOmitSelected: boolean;
conversationDelayMs: number;
terminalScript: boolean;
terminalFailureScript: boolean;
staleNestedTraceId: string | null;
}
const cwd = process.cwd();
const args = new Map<string, string>();
for (let index = 2; index < process.argv.length; index += 1) {
const key = process.argv[index];
const value = process.argv[index + 1];
if (key?.startsWith("--")) {
args.set(key.slice(2), value && !value.startsWith("--") ? value : "true");
if (value && !value.startsWith("--")) index += 1;
}
}
const port = Number(args.get("port") ?? process.env.HWLAB_WORKBENCH_E2E_PORT ?? 4173);
const distDir = resolve(cwd, args.get("dist") ?? "dist");
const capturePath = resolve(cwd, "tests/workbench-e2e/fixtures/real-captures/d601-v03-redacted.json");
const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { projectId: string; workspaceId: string; selectedConversationId: string; providerProfile: string; conversations: ConversationRecord[]; traces: Record<string, JsonRecord> } };
let state = createScenarioState("baseline");
const server = createServer((request, response) => {
void handleRequest(request, response).catch((error) => {
json(response, 500, { ok: false, status: 500, error: { code: "e2e_server_error", message: error instanceof Error ? error.message : String(error) } });
});
});
server.listen(port, "127.0.0.1", () => {
console.log(`workbench-e2e-server listening on http://127.0.0.1:${port}`);
});
async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
const method = request.method ?? "GET";
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `127.0.0.1:${port}`}`);
const path = url.pathname;
if (path === "/__e2e/health") return json(response, 200, { ok: true, scenarioId: state.scenarioId });
if (path === "/__e2e/state") return json(response, 200, stateSummary());
if (path === "/__e2e/reset" && method === "POST") {
const body = await readJson(request);
state = createScenarioState(String(body.scenarioId ?? "baseline"));
return json(response, 200, stateSummary());
}
if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload());
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
if (path === "/v1/workbench/events" && method === "GET") return sse(response, url);
if (path === "/v1/workbench/sessions" && method === "GET") {
await delay(state.conversationDelayMs);
return json(response, 200, workbenchSessionListPayload(url));
}
const sessionMessagesMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)\/messages$/u);
if (sessionMessagesMatch && method === "GET") {
const sessionId = decodeURIComponent(sessionMessagesMatch[1] ?? "");
const conversation = workbenchConversationBySessionId(sessionId);
return conversation ? json(response, 200, workbenchSessionMessagesPayload(conversation, url)) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
}
const workbenchSessionMatch = path.match(/^\/v1\/workbench\/sessions\/([^/]+)$/u);
if (workbenchSessionMatch && method === "GET") {
const sessionId = decodeURIComponent(workbenchSessionMatch[1] ?? "");
if (state.scenarioId === "session-switch-detail-404-isolated" && sessionId === "ses_stale_404") return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
if (state.scenarioId === "completed-replay-detail-404" && sessionId === "ses_completed") return json(response, 404, { ok: false, status: 404, error: { code: "session_replay_unavailable" } });
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 (/^\/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() });
}
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() });
}
if (path === "/v1/agent/chat" && method === "POST") {
const body = await readJson(request);
state.chatRequests.push(redactRequestBody(body));
return json(response, 200, acceptChatTurn(body));
}
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)) });
}
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" } });
}
if (conversationMatch && method === "PUT") return json(response, 200, { ok: true, workspace: workspacePayload() });
if (conversationMatch && method === "DELETE") {
deleteConversationById(decodeURIComponent(conversationMatch[1] ?? ""));
return json(response, 200, { ok: true, status: "deleted", workspace: workspacePayload() });
}
const sessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
if (sessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(sessionMatch[1] ?? "")) });
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));
}
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));
}
if (path === "/v1/provider-profiles") return json(response, 200, { profiles: [{ profile: "codex-api", name: "Codex API", configured: true }, { profile: "deepseek", name: "DeepSeek", configured: true }] });
if (path === "/health/live" || path === "/health" || path === "/v1") return json(response, 200, { status: "ok", serviceId: "workbench-e2e", codeAgent: { ready: true, status: "ready" } });
if (path === "/v1/live-builds") return json(response, 200, { status: "ok", builds: [] });
if (path === "/v1/hwpod/specs") return json(response, 200, { specs: [] });
if (path === "/v1/hwpod-node-ops") return json(response, 200, { ok: true, status: "ready", events: [], groups: [] });
if (path.startsWith("/v1/") || path.startsWith("/auth/") || path.startsWith("/health")) return json(response, 500, { ok: false, status: 500, error: { code: "unmocked_request", path } });
return staticFile(response, path);
}
function createScenarioState(scenarioId: string): ScenarioState {
const base = structuredClone(capture.scenario);
const id = scenarioId || "baseline";
const conversations = base.conversations;
const traces = base.traces;
if (id === "cross-project-detail-boundary") {
conversations.push(crossProjectConversation());
traces.trc_cross_project = crossProjectTrace();
}
if (id === "session-switch-detail-404-isolated") {
conversations.unshift(staleDetailConversation());
conversations.push(emptyConversation());
}
if (id === "terminal-empty-trace") {
conversations.push(terminalEmptyTraceConversation());
traces.trc_terminal_empty = terminalEmptyTrace();
}
if (id === "deleted-session-deeplink") conversations.unshift(archivedDeletedConversation());
if (id === "session-switch-empty-reload") conversations.push(emptyConversation());
const selectedConversationId = id === "deep-link" || id === "stale-nested-trace"
? "cnv_failed"
: id === "session-switch-detail-404-isolated" || id === "completed-replay-detail-404"
? "cnv_completed"
: id === "deleted-session-deeplink"
? "cnv_completed"
: id === "terminal-turn-stale-session-active"
? "cnv_completed"
: id === "terminal-empty-trace"
? "cnv_terminal_empty"
: id === "deep-link-stale-workspace-authority" || id === "stale-submit-restore"
? "cnv_running"
: base.selectedConversationId;
const staleNestedTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null;
return {
scenarioId: id,
projectId: base.projectId,
workspaceId: base.workspaceId,
providerProfile: base.providerProfile,
selectedConversationId,
workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, conversations, staleNestedTraceId),
conversations,
traces,
selectRequests: [],
workspacePatchRequests: [],
chatRequests: [],
listOmitSelected: id === "selected-missing-from-list",
conversationDelayMs: id === "loading" ? 2_500 : 0,
terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore",
terminalFailureScript: id === "stale-submit-restore",
staleNestedTraceId
};
}
function workspacePayload(): JsonRecord {
const selected = conversationById(state.selectedConversationId);
const nested = {
projectId: state.projectId,
...state.workspaceJson,
selectedConversationId: state.selectedConversationId,
selectedAgentSessionId: selected?.sessionId ?? null,
threadId: selected?.threadId ?? null,
providerProfile: state.providerProfile,
updatedAt: new Date().toISOString()
} as JsonRecord;
if (!Object.hasOwn(state.workspaceJson, "activeTraceId")) nested.activeTraceId = selected?.status === "running" ? selected.lastTraceId ?? null : null;
if (!Object.hasOwn(state.workspaceJson, "sessionStatus")) nested.sessionStatus = selected?.status ?? null;
if (!Object.hasOwn(state.workspaceJson, "lastTraceId")) nested.lastTraceId = selected?.lastTraceId ?? null;
return {
workspaceId: state.workspaceId,
projectId: state.projectId,
revision: state.selectRequests.length + state.workspacePatchRequests.length + 1,
updatedAt: new Date().toISOString(),
selectedConversationId: state.selectedConversationId,
selectedAgentSessionId: selected?.sessionId ?? null,
activeTraceId: Object.hasOwn(nested, "activeTraceId") ? nested.activeTraceId : selected?.status === "running" ? selected.lastTraceId ?? null : null,
providerProfile: state.providerProfile,
selectedConversation: selected ?? null,
workspace: nested
};
}
function initialWorkspaceJson(projectId: string, providerProfile: string, selectedConversationId: string, conversations: ConversationRecord[], staleLastTraceId: string | null): JsonRecord {
const selected = conversations.find((conversation) => conversation.conversationId === selectedConversationId) ?? null;
return {
projectId,
selectedConversationId,
selectedAgentSessionId: selected?.sessionId ?? null,
threadId: selected?.threadId ?? null,
activeTraceId: selected?.status === "running" ? selected.lastTraceId ?? null : null,
sessionStatus: selected?.status ?? null,
providerProfile,
lastTraceId: staleLastTraceId ?? selected?.lastTraceId ?? null,
updatedAt: new Date().toISOString()
};
}
function applyWorkspacePatch(body: JsonRecord): void {
const bodyWorkspace = body.workspace && typeof body.workspace === "object" ? body.workspace as JsonRecord : {};
const next = { ...state.workspaceJson, ...bodyWorkspace } as JsonRecord;
for (const key of ["selectedConversationId", "selectedAgentSessionId", "activeTraceId", "providerProfile", "sessionStatus", "lastTraceId", "staleActiveTraceId", "staleActiveTraceReason", "updatedByClient"]) {
if (Object.hasOwn(body, key)) next[key] = body[key];
}
state.workspaceJson = next;
if (typeof body.providerProfile === "string") state.providerProfile = body.providerProfile;
if (typeof next.selectedConversationId === "string" && conversationById(next.selectedConversationId)) state.selectedConversationId = next.selectedConversationId;
if ((Object.hasOwn(body, "activeTraceId") && body.activeTraceId === null) || (Object.hasOwn(bodyWorkspace, "activeTraceId") && bodyWorkspace.activeTraceId === null)) clearActiveTraceForSelection();
}
function createConversationFromSelect(body: JsonRecord, conversationId: string): ConversationRecord {
const token = conversationId.slice(4);
const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : `ses_${token}`;
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
const now = new Date().toISOString();
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 {
conversationId: "cnv_cross_project",
projectId: "prj_v02_code_agent",
sessionId: "ses_cross_project",
threadId: "thr_cross_project",
status: "completed",
lastTraceId: "trc_cross_project",
startedAt: now,
updatedAt: now,
messageCount: 2,
firstUserMessagePreview: "跨项目会话不应泄露",
session: { sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed" },
messages: [
{ id: "msg_cross_project_user", role: "user", title: "用户", text: "读取另一个项目", status: "sent", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project" },
{ id: "msg_cross_project_agent", role: "agent", title: "Code Agent", text: "另一个项目的结果", status: "completed", createdAt: now, conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", traceId: "trc_cross_project", runnerTrace: crossProjectTrace() }
]
};
}
function crossProjectTrace(): JsonRecord {
return { traceId: "trc_cross_project", projectId: "prj_v02_code_agent", conversationId: "cnv_cross_project", sessionId: "ses_cross_project", threadId: "thr_cross_project", status: "completed", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false };
}
function terminalEmptyTraceConversation(): ConversationRecord {
const now = new Date().toISOString();
return {
conversationId: "cnv_terminal_empty",
projectId: capture.scenario.projectId,
sessionId: "ses_terminal_empty",
threadId: "thr_terminal_empty",
status: "completed",
lastTraceId: "trc_terminal_empty",
startedAt: now,
updatedAt: now,
messageCount: 2,
firstUserMessagePreview: "完成态空 Trace 文案验证",
session: { sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", status: "completed" },
messages: [
{ id: "msg_terminal_empty_user", role: "user", title: "用户", text: "完成态空 Trace 文案验证", status: "sent", createdAt: now, conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", turnId: "turn_terminal_empty" },
{ id: "msg_terminal_empty_agent", role: "agent", title: "Code Agent", text: "终态空 Trace 已完成。", status: "completed", createdAt: now, conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", traceId: "trc_terminal_empty", turnId: "turn_terminal_empty", runnerTrace: terminalEmptyTrace() }
]
};
}
function terminalEmptyTrace(): JsonRecord {
return { traceId: "trc_terminal_empty", status: "completed", conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false, finalResponse: { text: "终态空 Trace 已完成。" } };
}
function staleDetailConversation(): ConversationRecord {
const now = new Date().toISOString();
return {
conversationId: "cnv_stale_404",
projectId: state?.projectId ?? capture.scenario.projectId,
sessionId: "ses_stale_404",
threadId: "thr_stale_404",
status: "active",
startedAt: now,
updatedAt: now,
messageCount: 0,
firstUserMessagePreview: "已失效的列表项",
session: { sessionId: "ses_stale_404", threadId: "thr_stale_404", status: "active" },
messages: []
};
}
function emptyConversation(): ConversationRecord {
const now = new Date().toISOString();
return { conversationId: "cnv_empty", projectId: state?.projectId ?? capture.scenario.projectId, sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", session: { sessionId: "ses_empty", threadId: "thr_empty", status: "active" }, messages: [] };
}
function archivedDeletedConversation(): ConversationRecord {
const now = new Date().toISOString();
return {
conversationId: "cnv_deleted",
projectId: capture.scenario.projectId,
sessionId: "ses_deleted",
threadId: "thr_deleted",
status: "archived",
startedAt: now,
updatedAt: now,
messageCount: 0,
firstUserMessagePreview: "已删除 session",
session: { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived" },
messages: []
};
}
function acceptChatTurn(body: JsonRecord): JsonRecord {
const conversationId = typeof body.conversationId === "string" ? body.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)}`;
const message = typeof body.message === "string" ? body.message : "";
const conversation = conversationById(conversationId);
const now = new Date().toISOString();
if (conversation) {
conversation.status = "running";
conversation.lastTraceId = traceId;
conversation.updatedAt = now;
conversation.firstUserMessagePreview = conversation.firstUserMessagePreview ?? message;
conversation.messageCount = (conversation.messages?.length ?? 0) + 2;
conversation.messages = [
...(conversation.messages ?? []),
{ id: `msg_${traceId}_user`, role: "user", title: "用户", text: message, status: "sent", createdAt: now, conversationId, sessionId, threadId, traceId },
{ id: `msg_${traceId}_agent`, role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, conversationId, sessionId, threadId, traceId, runnerTrace: { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } }
];
}
state.traces[traceId] = { traceId, status: "running", conversationId, sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false };
return { status: "running", accepted: true, running: true, terminal: false, traceId, conversationId, sessionId, threadId, turnId: traceId };
}
function conversationSummary(conversation: ConversationRecord): ConversationRecord {
const { messages: _messages, ...rest } = conversation;
return rest as ConversationRecord;
}
function workbenchSessionListPayload(url: URL): JsonRecord {
const includeSessionId = url.searchParams.get("includeSessionId") || "";
const visibleConversations = state.conversations.filter((item) => conversationProjectId(item) === state.projectId);
const conversations = state.listOmitSelected && includeSessionId ? visibleConversations.filter((item) => item.sessionId !== includeSessionId) : visibleConversations;
const summaries = conversations.map((conversation) => conversationSummary(conversation));
return { projectId: null, sessions: summaries, conversations: summaries, count: summaries.length };
}
function workbenchSessionMessagesPayload(conversation: ConversationRecord, url: URL): JsonRecord {
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const requestedLimit = Number(url.searchParams.get("limit") ?? messages.length);
const limit = Math.max(1, Number.isFinite(requestedLimit) ? Math.trunc(requestedLimit) : messages.length || 100);
const page = messages.slice(0, limit);
return { sessionId: conversation.sessionId ?? null, conversationId: conversation.conversationId, messages: page, count: page.length, total: messages.length, hasMore: messages.length > page.length };
}
function conversationById(id: string): ConversationRecord | null {
return state.conversations.find((conversation) => conversation.conversationId === id) ?? null;
}
function conversationBySessionId(id: string): ConversationRecord | null {
return state.conversations.find((conversation) => conversation.sessionId === id) ?? null;
}
function workbenchConversationBySessionId(id: string): ConversationRecord | null {
const conversation = conversationBySessionId(id);
return conversation && conversationProjectId(conversation) === state.projectId ? conversation : null;
}
function deleteConversationById(id: string): void {
const conversation = conversationById(id);
if (!conversation) return;
conversation.status = "archived";
conversation.session = { ...(conversation.session ?? {}), status: "archived" };
conversation.updatedAt = new Date().toISOString();
if (state.selectedConversationId === id) {
state.selectedConversationId = state.conversations.find((item) => item.conversationId !== id && item.status !== "archived" && conversationProjectId(item) === state.projectId)?.conversationId ?? "";
}
}
function requestedProjectId(url: URL): string {
return url.searchParams.get("projectId") || state.projectId;
}
function conversationProjectId(conversation: ConversationRecord): string {
return typeof conversation.projectId === "string" && conversation.projectId.trim() ? conversation.projectId : state.projectId;
}
function conversationProjectMismatch(conversation: ConversationRecord, url: URL): boolean {
const projectId = url.searchParams.get("projectId");
return Boolean(projectId && conversationProjectId(conversation) !== projectId);
}
function traceProjectMismatch(traceId: string, url: URL): boolean {
const projectId = url.searchParams.get("projectId");
if (!projectId) return false;
const trace = state.traces[traceId];
const traceProjectId = typeof trace?.projectId === "string" && trace.projectId.trim() ? trace.projectId : state.conversations.find((conversation) => conversation.lastTraceId === traceId)?.projectId;
return Boolean(traceProjectId && traceProjectId !== projectId);
}
function sessionPayload(sessionId: string): JsonRecord {
const conversation = state.conversations.find((item) => item.sessionId === sessionId);
if (state.scenarioId === "terminal-turn-stale-session-active" && sessionId === "ses_completed") return { sessionId, status: "active", lastTraceId: "trc_completed", updatedAt: new Date().toISOString() };
return { sessionId, status: conversation?.status ?? "unknown", lastTraceId: conversation?.lastTraceId ?? null, updatedAt: new Date().toISOString() };
}
function turnPayload(traceId: string): JsonRecord {
const trace = state.traces[traceId] ?? { traceId, status: "unknown", events: [] };
const status = String(trace.status ?? "unknown");
return {
...trace,
traceId,
status,
running: ["running", "pending", "accepted"].includes(status),
terminal: ["completed", "failed", "blocked", "timeout", "canceled"].includes(status)
};
}
function tracePayload(traceId: string, url: URL): JsonRecord {
const turn = turnPayload(traceId);
const events = Array.isArray(turn.events) ? turn.events as JsonRecord[] : [];
const sinceSeq = Number(url.searchParams.get("sinceSeq") ?? 0);
const requestedLimit = Number(url.searchParams.get("limit") ?? events.length);
const limit = requestedLimit || events.length || 100;
const filtered = Number.isFinite(sinceSeq) && sinceSeq > 0 ? events.filter((event) => Number(event.seq ?? 0) > sinceSeq) : events;
const page = filtered.slice(0, Math.max(1, Number.isFinite(limit) ? limit : 100));
const lastSeq = Number(page.at(-1)?.seq ?? 0);
const hasMore = filtered.length > page.length;
return { ...turn, events: page, eventCount: events.length, hasMore, fullTraceLoaded: !hasMore, nextSinceSeq: hasMore ? lastSeq : null, range: { sinceSeq, returned: page.length, total: events.length } };
}
function sse(response: ServerResponse, url: URL): void {
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache",
connection: "keep-alive"
});
writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId"), workspaceId: state.workspaceId });
if (state.terminalScript) {
const scenarioId = state.scenarioId;
setTimeout(() => {
if (state.scenarioId !== scenarioId) return;
const terminalStatus = state.terminalFailureScript ? "failed" : "completed";
const terminalText = state.terminalFailureScript ? "恢复后失败:缺少受控依赖。" : "事件重放后完成。";
const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: terminalStatus, replyAuthority: true, final: true, message: terminalText, terminal: true };
writeSse(response, "workbench.trace.event", { type: "trace.event", traceId: "trc_running", event, snapshot: { traceId: "trc_running", status: terminalStatus, events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: terminalText, status: terminalStatus } } });
finishRunningConversation(terminalStatus, terminalText);
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", traceId: "trc_running", turn: turnPayload("trc_running") });
}, 350);
}
}
function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void {
response.write(`event: ${eventName}\n`);
response.write(`data: ${JSON.stringify(payload)}\n\n`);
}
function finishRunningConversation(status: "completed" | "failed", text: string): void {
const conversation = conversationById("cnv_running");
if (!conversation) return;
conversation.status = status;
conversation.updatedAt = new Date().toISOString();
const trace = state.traces.trc_running ?? { traceId: "trc_running", status: "running", events: [] };
const finalEvent = { seq: 4, createdAt: new Date().toISOString(), label: `agentrun:terminal:${status}`, status, terminal: true };
const events = [...(Array.isArray(trace.events) ? trace.events as JsonRecord[] : []), finalEvent];
state.traces.trc_running = { ...trace, status, events, eventCount: events.length, fullTraceLoaded: true, hasMore: false, assistantText: text, finalResponse: { text, status } };
conversation.messages = (conversation.messages ?? []).map((message) => message.role === "agent" ? { ...message, text, status, runnerTrace: state.traces.trc_running } : message);
}
function clearActiveTraceForSelection(): void {
const conversation = conversationById(state.selectedConversationId);
if (conversation?.status === "running") conversation.status = "failed";
}
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 })) };
}
function authPayload(): JsonRecord {
if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") return { authenticated: false, mode: "server", actor: null, user: null, expiresAt: null };
return { authenticated: true, mode: "server", actor: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, user: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, expiresAt: null };
}
function authLoginResponse(response: ServerResponse): void {
if (state.scenarioId === "auth-upstream-unavailable") return json(response, 502, { ok: false, status: "failed", error: { code: "upstream_unavailable", message: "auth upstream unavailable", retryable: true, layer: "proxy" } });
return json(response, 401, { ok: false, status: "failed", error: { code: "invalid_credentials", message: "invalid credentials" } });
}
function redactRequestBody(value: JsonRecord): JsonRecord {
const output: JsonRecord = {};
for (const [key, item] of Object.entries(value)) output[key] = /secret|token|key|password|authorization/iu.test(key) ? "<redacted>" : item;
return output;
}
async function readJson(request: IncomingMessage): Promise<JsonRecord> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
if (chunks.length === 0) return {};
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
return parsed && typeof parsed === "object" ? parsed as JsonRecord : {};
} catch {
return {};
}
}
function json(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(body));
}
function staticFile(response: ServerResponse, path: string): void {
const filePath = safeStaticPath(path);
const target = filePath && existsSync(filePath) && statSync(filePath).isFile() ? filePath : join(distDir, "index.html");
response.writeHead(200, { "content-type": contentType(target) });
createReadStream(target).pipe(response);
}
function safeStaticPath(path: string): string | null {
const normalized = resolve(distDir, `.${decodeURIComponent(path === "/" ? "/index.html" : path)}`);
return normalized === distDir || normalized.startsWith(`${distDir}${sep}`) ? normalized : null;
}
function contentType(filePath: string): string {
switch (extname(filePath)) {
case ".html": return "text/html; charset=utf-8";
case ".js": return "text/javascript; charset=utf-8";
case ".css": return "text/css; charset=utf-8";
case ".svg": return "image/svg+xml";
case ".ico": return "image/x-icon";
default: return "application/octet-stream";
}
}
function delay(ms: number): Promise<void> {
return ms > 0 ? new Promise((resolveDelay) => setTimeout(resolveDelay, ms)) : Promise.resolve();
}