1078 lines
59 KiB
TypeScript
1078 lines
59 KiB
TypeScript
// SPEC: PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection; 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";
|
|
|
|
import {
|
|
DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS,
|
|
classifyWorkbenchEmptySessionGcCandidate
|
|
} from "../../../internal/cloud/workbench-empty-session-gc.ts";
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
|
|
interface SessionRecord extends JsonRecord {
|
|
sessionId: string;
|
|
threadId?: string | null;
|
|
status?: string | null;
|
|
lastTraceId?: string | null;
|
|
messages?: JsonRecord[];
|
|
hidden?: boolean;
|
|
}
|
|
|
|
interface ScenarioState {
|
|
scenarioId: string;
|
|
providerProfile: string;
|
|
selectedSessionId: string;
|
|
sessions: SessionRecord[];
|
|
traces: Record<string, JsonRecord>;
|
|
requestLedger: JsonRecord[];
|
|
legacyRequestLedger: JsonRecord[];
|
|
chatRequests: JsonRecord[];
|
|
listOmitSelected: boolean;
|
|
sessionDelayMs: number;
|
|
sessionDetailDelayMs: number;
|
|
chatDelayMs: number;
|
|
terminalScript: boolean;
|
|
terminalFailureScript: boolean;
|
|
staleTraceId: string | null;
|
|
liveBackfillTraceId: string | null;
|
|
liveBackfillReadyAtMs: number | 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: { selectedSessionId: string; providerProfile: string; sessions: SessionRecord[]; traces: Record<string, JsonRecord> } };
|
|
const progressOnlyText = "PROGRESS_ONLY_SHOULD_NOT_RENDER_AS_FINAL";
|
|
const finalOnlyText = "FINAL_ONLY_SHOULD_RENDER";
|
|
|
|
let state = createScenarioState("baseline");
|
|
const sseClients = new Set<ServerResponse>();
|
|
|
|
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;
|
|
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());
|
|
if (path === "/__e2e/reset" && method === "POST") {
|
|
const body = await readJson(request);
|
|
state = createScenarioState(String(body.scenarioId ?? "baseline"));
|
|
return json(response, 200, stateSummary());
|
|
}
|
|
if (path === "/__e2e/scroll-follow/append" && method === "POST") {
|
|
const body = await readJson(request);
|
|
return json(response, 200, appendScrollFollowEvents(positiveInteger(body.count, 1)));
|
|
}
|
|
if (path === "/__e2e/gc-empty-sessions" && method === "POST") {
|
|
const body = await readJson(request);
|
|
return json(response, 200, gcEmptySessions(body));
|
|
}
|
|
|
|
if (path === "/auth/session" || path === "/auth/bootstrap") {
|
|
const payload = authPayload();
|
|
return json(response, payload.authenticated === true ? 200 : 401, payload);
|
|
}
|
|
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
|
|
if (path === "/v1/workbench/events" && method === "GET") return sse(request, response, url);
|
|
if (path === "/v1/workbench/sessions" && method === "GET") {
|
|
await delay(state.sessionDelayMs);
|
|
return json(response, 200, workbenchSessionListPayload(url));
|
|
}
|
|
|
|
const turnMatch = path.match(/^\/v1\/workbench\/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.staleTraceId) 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 traceMatch = path.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/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.staleTraceId) 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 = canonicalSessionId(decodeURIComponent(sessionMessagesMatch[1] ?? ""));
|
|
await delay(sessionDetailDelayMs(sessionId));
|
|
const session = visibleSessionById(sessionId);
|
|
return session ? json(response, 200, workbenchSessionMessagesPayload(session, 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 = canonicalSessionId(decodeURIComponent(workbenchSessionMatch[1] ?? ""));
|
|
await delay(sessionDetailDelayMs(sessionId));
|
|
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 session = visibleSessionById(sessionId, { includeArchived: true });
|
|
return session ? json(response, 200, { ok: true, status: "found", session }) : json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
|
|
}
|
|
|
|
if (path === "/v1/agent/chat" && method === "POST") {
|
|
const body = await readJson(request);
|
|
state.chatRequests.push(redactRequestBody(body));
|
|
await delay(state.chatDelayMs);
|
|
return json(response, 200, acceptChatTurn(body));
|
|
}
|
|
|
|
if (path === "/v1/agent/sessions" && method === "POST") {
|
|
const body = await readJson(request);
|
|
const session = createManualSession(body);
|
|
state.selectedSessionId = session.sessionId;
|
|
state.sessions.unshift(session);
|
|
return json(response, 200, { ok: true, status: "ok", session });
|
|
}
|
|
|
|
const agentSessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
|
|
if (agentSessionMatch && method === "DELETE") {
|
|
const session = sessionById(decodeURIComponent(agentSessionMatch[1] ?? ""));
|
|
if (!session) return json(response, 404, { ok: false, status: 404, error: { code: "session_not_found" } });
|
|
session.status = "archived";
|
|
session.updatedAt = new Date().toISOString();
|
|
return json(response, 200, { ok: true, status: "archived", session, archivedCount: 1 });
|
|
}
|
|
if (agentSessionMatch && method === "GET") return json(response, 200, { session: sessionPayload(decodeURIComponent(agentSessionMatch[1] ?? "")) });
|
|
|
|
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, hwpodSpecsPayload());
|
|
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload());
|
|
if (path === "/v1/web-performance/summary") return json(response, 200, webPerformanceSummaryPayload());
|
|
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 hwpodSpecsPayload(): JsonRecord {
|
|
return {
|
|
ok: true,
|
|
status: "completed",
|
|
count: 1,
|
|
availableCount: 0,
|
|
nodeOpsContractVersion: "hwpod-node-ops-v1",
|
|
specs: [
|
|
{
|
|
name: "d601-f103-v2",
|
|
hwpodId: "d601-f103-v2",
|
|
nodeId: "node-d601-f103-v2",
|
|
workspacePath: "F:\\Work\\D601-HWLAB",
|
|
source: { caseId: "d601-f103-v2-compile" },
|
|
availability: {
|
|
ok: false,
|
|
status: "blocked",
|
|
checkedAt: "2026-06-18T04:30:00.000Z",
|
|
results: [
|
|
{ opId: "op_01_workspace_ls", op: "workspace.ls", ok: false, status: "blocked", blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "no outbound WebSocket hwpod-node is connected" } }
|
|
]
|
|
}
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
function hwpodNodeOpsPayload(): JsonRecord {
|
|
return {
|
|
ok: true,
|
|
status: "ready",
|
|
contractVersion: "hwpod-node-ops-v1",
|
|
route: "/v1/hwpod-node-ops",
|
|
supportedOps: ["node.health", "workspace.ls", "debug.build", "debug.flash"],
|
|
websocket: { connectedCount: 0, pendingCount: 1, nodes: [] },
|
|
events: [
|
|
{ ts: "2026-06-18T04:30:00.000Z", status: "blocked", blocker: { code: "hwpod_node_unavailable", summary: "no outbound WebSocket hwpod-node is connected" } }
|
|
]
|
|
};
|
|
}
|
|
|
|
function webPerformanceSummaryPayload(): JsonRecord {
|
|
const rows = [
|
|
performanceRow({ kind: "workbench-journey", metric: "session-switch-full-load", route: "/workbench/sessions/:sessionId", count: 21, p50: 0.18, p75: 0.31, p95: 1.12, tone: "warn", backend: "agentrun", transport: "sse", targetState: "completed" }),
|
|
performanceRow({ kind: "workbench-event-phase", metric: "trace-event-projected", route: "/workbench", count: 28, p50: 0.08, p75: 0.14, p95: 0.42, tone: "ok", eventType: "tool_result", phase: "render", source: "rum" }),
|
|
performanceRow({ kind: "workbench-backend-event", metric: "backend-event-visible", route: "/v1/workbench/events", count: 15, p50: 0.2, p75: 0.34, p95: 1.38, tone: "warn", eventType: "agentrun.stdout", backend: "agentrun", cache: "miss" }),
|
|
performanceRow({ kind: "web-vital", metric: "LCP", route: "/performance", count: 9, p50: 1.2, p75: 1.8, p95: 2.9, tone: "ok" }),
|
|
performanceRow({ kind: "api", metric: "fetchJson", route: "/v1/web-performance/summary", method: "GET", statusClass: "2xx", outcome: "ok", count: 17, p50: 0.09, p75: 0.15, p95: 0.33, tone: "ok" }),
|
|
performanceRow({ kind: "long-task", metric: "longtask", route: "/workbench", count: 3, p50: 0.08, p75: 0.11, p95: 0.2, tone: "warn", problem: "main-thread busy" })
|
|
];
|
|
return {
|
|
ok: true,
|
|
status: "ready",
|
|
source: "fake-server",
|
|
observedAt: "2026-06-18T04:30:00.000Z",
|
|
namespace: "hwlab-v03",
|
|
gitopsTarget: "D601/v03",
|
|
summary: { status: "ready", sampleCount: 93, sampleSeries: 12, problemCount: 2, lowSampleThreshold: 5, workbenchJourneySeries: 1, workbenchEventPhaseSeries: 1, workbenchBackendEventVisibleSeries: 1 },
|
|
rows,
|
|
problems: rows.filter((row) => row.tone === "warn"),
|
|
webVitals: rows.filter((row) => row.kind === "web-vital"),
|
|
apiRoutes: rows.filter((row) => row.kind === "api"),
|
|
longTasks: rows.filter((row) => row.kind === "long-task"),
|
|
workbenchJourneys: rows.filter((row) => row.kind === "workbench-journey"),
|
|
workbenchEventPhases: rows.filter((row) => row.kind === "workbench-event-phase"),
|
|
workbenchBackendEvents: rows.filter((row) => row.kind === "workbench-backend-event")
|
|
};
|
|
}
|
|
|
|
function performanceRow(row: JsonRecord): JsonRecord {
|
|
return { method: "GET", statusClass: "2xx", outcome: "ok", unit: "seconds", sampleState: "ok", ...row };
|
|
}
|
|
|
|
function createScenarioState(scenarioId: string): ScenarioState {
|
|
const base = structuredClone(capture.scenario);
|
|
const id = scenarioId || "baseline";
|
|
const sessions = base.sessions;
|
|
const traces = base.traces;
|
|
if (id === "cross-project-detail-boundary") sessions.push(hiddenBoundarySession());
|
|
if (id === "legacy-cnv-deeplink-canonical") {
|
|
sessions.unshift(legacyCanonicalSession());
|
|
traces.trc_40badc15523146c9 = legacyCanonicalTrace();
|
|
}
|
|
if (id === "submit-authority-race") {
|
|
sessions.unshift(submitSplitSession());
|
|
traces.trc_submit_split = submitSplitTrace();
|
|
}
|
|
if (id === "session-switch-delayed-detail-frame") {
|
|
markSessionText(sessions, "ses_running", "SESSION_A_ONLY", "SESSION_A_ONLY running source session");
|
|
markSessionText(sessions, "ses_completed", "SESSION_B_ONLY", "SESSION_B_ONLY completed target session");
|
|
}
|
|
if (id === "session-rail-many") {
|
|
for (let index = 0; index < 51; index += 1) sessions.push(manyRailSession(index));
|
|
}
|
|
if (id === "session-switch-detail-404-isolated") {
|
|
sessions.unshift(staleDetailSession());
|
|
sessions.push(emptySession());
|
|
}
|
|
if (id === "terminal-empty-trace") {
|
|
sessions.push(terminalEmptyTraceSession());
|
|
traces.trc_terminal_empty = terminalEmptyTrace();
|
|
}
|
|
if (id === "progress-only-final-response") markRunningProgressOnly(sessions, traces);
|
|
if (id === "terminal-completed-no-final-response") markRunningNoFinalResponse(sessions, traces);
|
|
if (id === "scroll-follow-long-trace") {
|
|
const session = scrollFollowSession();
|
|
sessions.unshift(session);
|
|
traces.trc_scroll_follow = scrollFollowTrace(90);
|
|
}
|
|
if (id === "cross-session-late-events") {
|
|
sessions.unshift(crossSessionLateB(), crossSessionLateA());
|
|
traces.trc_late_A = crossSessionLateTraceA();
|
|
}
|
|
if (id === "deleted-session-deeplink") sessions.unshift(archivedDeletedSession());
|
|
if (id === "session-switch-empty-reload") sessions.push(emptySession());
|
|
if (id === "empty-session-gc") sessions.unshift(staleEmptyTtlSession(), freshEmptyTtlSession(), staleMessagedTtlSession());
|
|
const selectedSessionId = id === "scroll-follow-long-trace"
|
|
? "ses_scroll_follow"
|
|
: id === "deep-link" || id === "stale-nested-trace"
|
|
? "ses_failed"
|
|
: id === "legacy-cnv-deeplink-canonical"
|
|
? "ses_running"
|
|
: id === "session-switch-detail-404-isolated" || id === "completed-replay-detail-404" || id === "deleted-session-deeplink" || id === "terminal-turn-stale-session-active"
|
|
? "ses_completed"
|
|
: id === "terminal-empty-trace"
|
|
? "ses_terminal_empty"
|
|
: id === "progress-only-final-response" || id === "terminal-completed-no-final-response"
|
|
? "ses_running"
|
|
: base.selectedSessionId;
|
|
const staleTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null;
|
|
return {
|
|
scenarioId: id,
|
|
providerProfile: base.providerProfile,
|
|
selectedSessionId,
|
|
sessions,
|
|
traces,
|
|
requestLedger: [],
|
|
legacyRequestLedger: [],
|
|
chatRequests: [],
|
|
listOmitSelected: id === "selected-missing-from-list",
|
|
sessionDelayMs: id === "loading" ? 2_500 : 0,
|
|
sessionDetailDelayMs: id === "legacy-cnv-deeplink-canonical" ? 1_500 : id === "session-switch-delayed-detail-frame" ? 900 : 0,
|
|
chatDelayMs: id === "submit-authority-race" ? 1_000 : 0,
|
|
terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore" || id === "progress-only-final-response" || id === "terminal-completed-no-final-response",
|
|
terminalFailureScript: id === "stale-submit-restore",
|
|
staleTraceId,
|
|
liveBackfillTraceId: null,
|
|
liveBackfillReadyAtMs: null
|
|
};
|
|
}
|
|
|
|
function markSessionText(sessions: SessionRecord[], sessionId: string, userText: string, agentText: string): void {
|
|
const session = sessions.find((item) => item.sessionId === sessionId);
|
|
if (!session) return;
|
|
session.firstUserMessagePreview = userText;
|
|
session.messages = (session.messages ?? []).map((message) => {
|
|
if (message.role === "user") return { ...message, text: userText };
|
|
if (message.role === "agent") return { ...message, text: agentText };
|
|
return message;
|
|
});
|
|
}
|
|
|
|
function markRunningProgressOnly(sessions: SessionRecord[], traces: Record<string, JsonRecord>): void {
|
|
const createdAt = new Date().toISOString();
|
|
const progressEvent = { seq: 1, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "running", replyAuthority: false, final: false, message: progressOnlyText };
|
|
const trace = { traceId: "trc_running", status: "running", sessionId: "ses_running", threadId: "thr_running", turnId: "turn_running", events: [progressEvent], eventCount: 1, fullTraceLoaded: false, hasMore: false };
|
|
traces.trc_running = trace;
|
|
const session = sessions.find((item) => item.sessionId === "ses_running");
|
|
if (!session) return;
|
|
session.status = "running";
|
|
session.lastTraceId = "trc_running";
|
|
session.firstUserMessagePreview = "progress-only running turn";
|
|
session.messages = (session.messages ?? []).map((message) => {
|
|
if (message.role === "user") return { ...message, text: "progress-only running turn" };
|
|
if (message.role !== "agent" || message.traceId !== "trc_running") return message;
|
|
return { ...message, text: "", status: "running", runnerTrace: trace };
|
|
});
|
|
}
|
|
|
|
function markRunningNoFinalResponse(sessions: SessionRecord[], traces: Record<string, JsonRecord>): void {
|
|
const trace = noFinalRunningTrace();
|
|
traces.trc_running = trace;
|
|
const session = sessions.find((item) => item.sessionId === "ses_running");
|
|
if (!session) return;
|
|
session.status = "running";
|
|
session.lastTraceId = "trc_running";
|
|
session.firstUserMessagePreview = "completed turn without final response";
|
|
session.messages = (session.messages ?? []).map((message) => {
|
|
if (message.role === "user") return { ...message, text: "completed turn without final response" };
|
|
if (message.role !== "agent" || message.traceId !== "trc_running") return message;
|
|
return { ...message, text: "", status: "running", runnerTrace: trace };
|
|
});
|
|
}
|
|
|
|
function noFinalRunningTrace(): JsonRecord {
|
|
const createdAt = new Date().toISOString();
|
|
return {
|
|
traceId: "trc_running",
|
|
status: "running",
|
|
sessionId: "ses_running",
|
|
threadId: "thr_running",
|
|
turnId: "turn_running",
|
|
events: [{ seq: 1, createdAt, label: "agentrun:backend:turn/running", type: "backend_status", status: "running" }],
|
|
eventCount: 1,
|
|
fullTraceLoaded: false,
|
|
hasMore: false
|
|
};
|
|
}
|
|
|
|
function completedNoFinalTrace(): JsonRecord {
|
|
const createdAt = new Date().toISOString();
|
|
const events = [
|
|
{ seq: 1, createdAt, label: "agentrun:backend:turn/running", type: "backend_status", status: "running" },
|
|
{ seq: 2, createdAt, label: "agentrun:backend:turn/completed", type: "backend_status", status: "completed", terminal: true }
|
|
];
|
|
return { traceId: "trc_running", status: "completed", sessionId: "ses_running", threadId: "thr_running", turnId: "turn_running", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false };
|
|
}
|
|
|
|
function scrollFollowSession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
const trace = scrollFollowTrace(90);
|
|
const history = Array.from({ length: 18 }, (_, index) => {
|
|
const seq = index + 1;
|
|
return seq % 2 === 0
|
|
? { id: `msg_scroll_history_agent_${seq}`, messageId: `msg_scroll_history_agent_${seq}`, role: "agent", title: "Code Agent", text: `历史回复 ${seq}\n\n用于撑开主工作区滚动高度。`, status: "completed", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: `turn_scroll_history_${seq}` }
|
|
: { id: `msg_scroll_history_user_${seq}`, messageId: `msg_scroll_history_user_${seq}`, role: "user", title: "用户", text: `历史问题 ${seq}:保持主工作区可滚动。`, status: "sent", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: `turn_scroll_history_${seq}` };
|
|
});
|
|
const messages = [
|
|
...history,
|
|
{ id: "msg_scroll_follow_user", messageId: "msg_scroll_follow_user", role: "user", title: "用户", text: "scroll follow long trace probe", status: "sent", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turnId: "turn_scroll_follow" },
|
|
{ id: "msg_scroll_follow_agent", messageId: "msg_scroll_follow_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turnId: "turn_scroll_follow", runnerTrace: trace }
|
|
];
|
|
return {
|
|
sessionId: "ses_scroll_follow",
|
|
threadId: "thr_scroll_follow",
|
|
status: "running",
|
|
lastTraceId: "trc_scroll_follow",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
messageCount: messages.length,
|
|
firstUserMessagePreview: "scroll follow long trace probe",
|
|
messages
|
|
};
|
|
}
|
|
|
|
function scrollFollowTrace(count: number): JsonRecord {
|
|
const events = Array.from({ length: count }, (_, index) => scrollFollowEvent(index + 1));
|
|
return { traceId: "trc_scroll_follow", status: "running", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", turnId: "turn_scroll_follow", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false };
|
|
}
|
|
|
|
function scrollFollowEvent(seq: number): JsonRecord {
|
|
const createdAt = new Date(Date.parse("2026-06-18T18:00:00.000Z") + seq * 1000).toISOString();
|
|
if (seq % 5 === 0) {
|
|
return { seq, sourceSeq: seq, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "running", message: `滚动跟随采样 assistant row ${seq}\n\n- markdown 行 ${seq}\n- append 后不能闪到顶部` };
|
|
}
|
|
return {
|
|
seq,
|
|
sourceSeq: seq,
|
|
createdAt,
|
|
label: "item/commandExecution:running",
|
|
type: "commandExecution",
|
|
status: "running",
|
|
itemId: `scroll-follow-${seq}`,
|
|
command: `printf 'scroll follow row ${seq}\\n'`,
|
|
stdout: `scroll-follow stdout ${seq}\nline ${seq} keeps the row tall enough for scroll sampling\n`
|
|
};
|
|
}
|
|
|
|
function appendScrollFollowEvents(count: number): JsonRecord {
|
|
if (state.scenarioId !== "scroll-follow-long-trace") return { ok: false, status: 409, error: { code: "scenario_mismatch", scenarioId: state.scenarioId } };
|
|
const trace = state.traces.trc_scroll_follow ?? scrollFollowTrace(0);
|
|
const events = Array.isArray(trace.events) ? [...trace.events as JsonRecord[]] : [];
|
|
const appended: JsonRecord[] = [];
|
|
for (let index = 0; index < count; index += 1) {
|
|
const seq = Number(events.at(-1)?.seq ?? events.length) + 1;
|
|
const event = scrollFollowEvent(seq);
|
|
events.push(event);
|
|
appended.push(event);
|
|
}
|
|
const nextTrace = { ...trace, status: "running", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false };
|
|
state.traces.trc_scroll_follow = nextTrace;
|
|
const session = sessionById("ses_scroll_follow");
|
|
if (session) {
|
|
session.status = "running";
|
|
session.updatedAt = new Date().toISOString();
|
|
session.messages = (session.messages ?? []).map((message) => message.role === "agent" && message.traceId === "trc_scroll_follow" ? { ...message, status: "running", runnerTrace: nextTrace } : message);
|
|
}
|
|
for (const event of appended) {
|
|
broadcastSse("workbench.trace.event", { type: "trace.event", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", event, snapshot: nextTrace });
|
|
}
|
|
broadcastSse("workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_scroll_follow", threadId: "thr_scroll_follow", traceId: "trc_scroll_follow", turn: turnPayload("trc_scroll_follow") });
|
|
return { ok: true, status: "appended", appended: appended.length, eventCount: events.length };
|
|
}
|
|
|
|
function createManualSession(body: JsonRecord): SessionRecord {
|
|
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 threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
|
|
return { sessionId, threadId, status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: null, messages: [] };
|
|
}
|
|
|
|
function hiddenBoundarySession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return { sessionId: "ses_cross_project", threadId: "thr_cross_project", hidden: true, status: "completed", lastTraceId: "trc_cross_project", startedAt: now, updatedAt: now, messageCount: 2, firstUserMessagePreview: "边界外 session 不应泄露", messages: [] };
|
|
}
|
|
|
|
function terminalEmptyTraceSession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
sessionId: "ses_terminal_empty",
|
|
threadId: "thr_terminal_empty",
|
|
status: "completed",
|
|
lastTraceId: "trc_terminal_empty",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
messageCount: 2,
|
|
firstUserMessagePreview: "完成态空 Trace 文案验证",
|
|
messages: [
|
|
{ id: "msg_terminal_empty_user", role: "user", title: "用户", text: "完成态空 Trace 文案验证", status: "sent", createdAt: now, 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, 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", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false, finalResponse: { text: "终态空 Trace 已完成。" } };
|
|
}
|
|
|
|
function legacyCanonicalSession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
sessionId: "ses_c5e1330558c94d8e",
|
|
conversationId: "cnv_c5e1330558c94d8e",
|
|
threadId: "thr_c5e1330558c94d8e",
|
|
status: "completed",
|
|
lastTraceId: "trc_40badc15523146c9",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
messageCount: 2,
|
|
firstUserMessagePreview: "legacy cnv deep link canonical smoke",
|
|
messages: [
|
|
{ id: "msg_c5_user", role: "user", title: "用户", text: "legacy cnv deep link canonical smoke", status: "sent", createdAt: now, sessionId: "ses_c5e1330558c94d8e", threadId: "thr_c5e1330558c94d8e", traceId: "trc_40badc15523146c9" },
|
|
{ id: "msg_c5_agent", role: "agent", title: "Code Agent", text: "已完成:新增 Python benchmark 脚本。", status: "completed", createdAt: now, sessionId: "ses_c5e1330558c94d8e", threadId: "thr_c5e1330558c94d8e", traceId: "trc_40badc15523146c9", runnerTrace: legacyCanonicalTrace() }
|
|
]
|
|
};
|
|
}
|
|
|
|
function legacyCanonicalTrace(): JsonRecord {
|
|
return {
|
|
traceId: "trc_40badc15523146c9",
|
|
status: "completed",
|
|
sessionId: "ses_c5e1330558c94d8e",
|
|
threadId: "thr_c5e1330558c94d8e",
|
|
events: [{ seq: 1, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", terminal: true, final: true, message: "已完成:新增 Python benchmark 脚本。" }],
|
|
eventCount: 1,
|
|
fullTraceLoaded: true,
|
|
hasMore: false,
|
|
finalResponse: { text: "已完成:新增 Python benchmark 脚本。", status: "completed" }
|
|
};
|
|
}
|
|
|
|
function manyRailSession(index: number): SessionRecord {
|
|
const id = String(index).padStart(2, "0");
|
|
const now = new Date(Date.now() - (index + 1) * 1000).toISOString();
|
|
return { sessionId: `ses_many_${id}`, threadId: `thr_many_${id}`, status: "completed", lastTraceId: `trc_many_${id}`, startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: `历史 session ${id}`, messages: [] };
|
|
}
|
|
|
|
function staleDetailSession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return { sessionId: "ses_stale_404", threadId: "thr_stale_404", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已失效的列表项", messages: [] };
|
|
}
|
|
|
|
function emptySession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return { sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", messages: [] };
|
|
}
|
|
|
|
function staleEmptyTtlSession(): SessionRecord {
|
|
const at = "2026-06-18T16:00:00.000Z";
|
|
return { sessionId: "ses_empty_ttl", threadId: "thr_empty_ttl", status: "idle", startedAt: at, updatedAt: at, messageCount: 0, firstUserMessagePreview: null, messages: [] };
|
|
}
|
|
|
|
function freshEmptyTtlSession(): SessionRecord {
|
|
const at = "2026-06-18T16:15:30.000Z";
|
|
return { sessionId: "ses_empty_fresh", threadId: "thr_empty_fresh", status: "idle", startedAt: at, updatedAt: at, messageCount: 0, firstUserMessagePreview: null, messages: [] };
|
|
}
|
|
|
|
function staleMessagedTtlSession(): SessionRecord {
|
|
const at = "2026-06-18T15:50:00.000Z";
|
|
return {
|
|
sessionId: "ses_empty_ttl_has_message",
|
|
threadId: "thr_empty_ttl_has_message",
|
|
status: "completed",
|
|
startedAt: at,
|
|
updatedAt: at,
|
|
messageCount: 2,
|
|
firstUserMessagePreview: "超过 TTL 但已有消息,不能被 GC",
|
|
messages: [
|
|
{ id: "msg_empty_ttl_user", role: "user", title: "用户", text: "超过 TTL 但已有消息,不能被 GC", status: "sent", createdAt: at, sessionId: "ses_empty_ttl_has_message", threadId: "thr_empty_ttl_has_message" },
|
|
{ id: "msg_empty_ttl_agent", role: "agent", title: "Code Agent", text: "保留已有消息 session。", status: "completed", createdAt: at, sessionId: "ses_empty_ttl_has_message", threadId: "thr_empty_ttl_has_message" }
|
|
]
|
|
};
|
|
}
|
|
|
|
function archivedDeletedSession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已删除 session", messages: [] };
|
|
}
|
|
|
|
function submitSplitSession(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
sessionId: "ses_submit_split",
|
|
threadId: "thr_submit_split",
|
|
status: "idle",
|
|
lastTraceId: "trc_submit_split",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
messageCount: 2,
|
|
firstUserMessagePreview: "workbench submit race probe redacted: 请只回复 OK。",
|
|
messages: [
|
|
{ id: "msg_submit_split_user", messageId: "msg_submit_split_user", role: "user", title: "用户", text: "", status: "sent", createdAt: now, sessionId: "ses_submit_split", threadId: "thr_submit_split", traceId: "trc_submit_split", turnId: "turn_submit_split" },
|
|
{ id: "msg_submit_split_agent", messageId: "msg_submit_split_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_submit_split", threadId: "thr_submit_split", traceId: "trc_submit_split", turnId: "turn_submit_split", runnerTrace: { traceId: "trc_submit_split", status: "running", sessionId: "ses_submit_split", threadId: "thr_submit_split", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } }
|
|
]
|
|
};
|
|
}
|
|
|
|
function submitSplitTrace(): JsonRecord {
|
|
return { traceId: "trc_submit_split", status: "running", sessionId: "ses_submit_split", threadId: "thr_submit_split", turnId: "turn_submit_split", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false };
|
|
}
|
|
|
|
function crossSessionLateA(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
sessionId: "ses_late_A",
|
|
threadId: "thr_late_A",
|
|
status: "completed",
|
|
lastTraceId: "trc_late_A",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
messageCount: 2,
|
|
firstUserMessagePreview: "写一个python脚本测试性能",
|
|
messages: [
|
|
{ id: "msg_late_A_user", messageId: "msg_late_A_user", role: "user", title: "用户", text: "写一个python脚本测试性能", status: "sent", createdAt: now, sessionId: "ses_late_A", threadId: "thr_late_A", traceId: "trc_late_A", turnId: "turn_late_A" },
|
|
{ id: "msg_late_A_agent", messageId: "msg_late_A_agent", role: "agent", title: "Code Agent", text: "已写好 perf_test.py,脚本可用。", status: "completed", createdAt: now, sessionId: "ses_late_A", threadId: "thr_late_A", traceId: "trc_late_A", turnId: "turn_late_A", runnerTrace: crossSessionLateTraceA() }
|
|
]
|
|
};
|
|
}
|
|
|
|
function crossSessionLateB(): SessionRecord {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
sessionId: "ses_late_B",
|
|
threadId: "thr_late_B",
|
|
status: "active",
|
|
lastTraceId: "trc_late_A",
|
|
startedAt: now,
|
|
updatedAt: now,
|
|
messageCount: 2,
|
|
firstUserMessagePreview: "B session waiting",
|
|
messages: [
|
|
{ id: "msg_late_B_user", messageId: "msg_late_B_user", role: "user", title: "用户", text: "B session waiting", status: "sent", createdAt: now, sessionId: "ses_late_B", threadId: "thr_late_B", traceId: "trc_late_A", turnId: "turn_late_B" },
|
|
{ id: "msg_late_B_agent", messageId: "msg_late_B_agent", role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId: "ses_late_B", threadId: "thr_late_B", traceId: "trc_late_A", turnId: "turn_late_B", runnerTrace: { traceId: "trc_late_A", status: "running", sessionId: "ses_late_B", threadId: "thr_late_B", events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } }
|
|
]
|
|
};
|
|
}
|
|
|
|
function crossSessionLateTraceA(): JsonRecord {
|
|
const now = new Date().toISOString();
|
|
const finalText = "已写好 perf_test.py,脚本可用。";
|
|
const event = { seq: 1, createdAt: now, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", replyAuthority: true, final: true, message: finalText, terminal: true };
|
|
return { traceId: "trc_late_A", status: "completed", sessionId: "ses_late_A", threadId: "thr_late_A", turnId: "turn_late_A", events: [event], eventCount: 1, fullTraceLoaded: true, hasMore: false, finalResponse: { text: finalText, status: "completed" }, assistantText: finalText };
|
|
}
|
|
|
|
function sessionDetailDelayMs(sessionId: string): number {
|
|
if (state.scenarioId === "cross-session-late-events" && sessionId === "ses_late_A") return 900;
|
|
return state.sessionDetailDelayMs;
|
|
}
|
|
|
|
function acceptChatTurn(body: JsonRecord): JsonRecord {
|
|
const sessionId = typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId : state.selectedSessionId;
|
|
const session = sessionById(sessionId) ?? createManualSession({ sessionId });
|
|
if (!state.sessions.includes(session)) state.sessions.unshift(session);
|
|
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
|
|
const traceId = state.scenarioId === "live-rest-backfill-without-terminal-sse"
|
|
? "trc_live_backfill"
|
|
: typeof body.traceId === "string" && body.traceId.trim() ? body.traceId : `trc_${Date.now().toString(16)}`;
|
|
const message = typeof body.message === "string" ? body.message : "";
|
|
const now = new Date().toISOString();
|
|
session.status = "running";
|
|
session.lastTraceId = traceId;
|
|
session.updatedAt = now;
|
|
session.firstUserMessagePreview = session.firstUserMessagePreview ?? message;
|
|
session.messageCount = (session.messages?.length ?? 0) + 2;
|
|
session.messages = [
|
|
...(session.messages ?? []),
|
|
{ id: `msg_${traceId}_user`, role: "user", title: "用户", text: message, status: "sent", createdAt: now, sessionId, threadId, traceId },
|
|
{ id: `msg_${traceId}_agent`, role: "agent", title: "Code Agent", text: "", status: "running", createdAt: now, sessionId, threadId, traceId, runnerTrace: { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false } }
|
|
];
|
|
if (state.scenarioId === "live-rest-backfill-without-terminal-sse") {
|
|
state.liveBackfillTraceId = traceId;
|
|
state.liveBackfillReadyAtMs = Date.now() + 2_000;
|
|
state.traces[traceId] = liveBackfillEarlyTrace(sessionId, threadId, traceId);
|
|
scheduleLiveBackfillProjection(sessionId, threadId, traceId, state.liveBackfillReadyAtMs);
|
|
} else {
|
|
state.traces[traceId] = { traceId, status: "running", sessionId, threadId, events: [], eventCount: 0, fullTraceLoaded: false, hasMore: false };
|
|
}
|
|
return { status: "running", accepted: true, running: true, terminal: false, traceId, sessionId, threadId, turnId: traceId };
|
|
}
|
|
|
|
function liveBackfillEarlyTrace(sessionId: string, threadId: string | null, traceId: string): JsonRecord {
|
|
const createdAt = new Date().toISOString();
|
|
const agentRun = liveBackfillAgentRun("pending", null, 4);
|
|
return {
|
|
traceId,
|
|
status: "running",
|
|
running: true,
|
|
terminal: false,
|
|
sessionId,
|
|
threadId,
|
|
agentRun,
|
|
events: [
|
|
{ seq: 1, sourceSeq: 1, createdAt, label: "agentrun:request:accepted", type: "backend_status", status: "running", payload: { commandId: agentRun.commandId } },
|
|
{ seq: 2, sourceSeq: 2, createdAt, label: "agentrun:run:created", type: "backend_status", status: "running", payload: { runId: agentRun.runId, runStatus: agentRun.runStatus } },
|
|
{ seq: 3, sourceSeq: 3, createdAt, label: "agentrun:command:created", type: "backend_status", status: "running", payload: { commandId: agentRun.commandId, commandState: agentRun.commandState } },
|
|
{ seq: 4, sourceSeq: 4, createdAt, label: "agentrun:runner-job:created", type: "backend_status", status: "running", payload: { commandId: agentRun.commandId, jobName: agentRun.jobName, namespace: agentRun.namespace } }
|
|
],
|
|
eventCount: 4,
|
|
fullTraceLoaded: true,
|
|
hasMore: false,
|
|
traceSummary: { source: "agentrun-events", agentRun, valuesPrinted: false }
|
|
};
|
|
}
|
|
|
|
function liveBackfillCompletedTrace(sessionId: string, threadId: string | null, traceId: string): JsonRecord {
|
|
const createdAt = new Date().toISOString();
|
|
const finalText = "fake AgentRun completed after REST backfill without terminal SSE.";
|
|
const agentRun = liveBackfillAgentRun("completed", "completed", 23);
|
|
const events = [
|
|
...(liveBackfillEarlyTrace(sessionId, threadId, traceId).events as JsonRecord[]),
|
|
{ seq: 5, sourceSeq: 21, createdAt, label: "agentrun:assistant:message", type: "assistant_message", status: "completed", message: finalText, payload: { commandId: agentRun.commandId } },
|
|
{ seq: 6, sourceSeq: 22, createdAt, label: "item/commandExecution:completed", type: "commandExecution", status: "completed", stdout: "OK\n", payload: { commandId: agentRun.commandId, exitCode: 0 } },
|
|
{ seq: 7, sourceSeq: 23, createdAt, label: "agentrun:backend:turn/completed", type: "backend_status", status: "completed", terminal: true, payload: { commandId: agentRun.commandId, phase: "command-terminal", terminalStatus: "completed" } }
|
|
];
|
|
return {
|
|
traceId,
|
|
status: "completed",
|
|
running: false,
|
|
terminal: true,
|
|
sessionId,
|
|
threadId,
|
|
agentRun,
|
|
events,
|
|
eventCount: events.length,
|
|
fullTraceLoaded: true,
|
|
hasMore: false,
|
|
finalResponse: { text: finalText, status: "completed" },
|
|
traceSummary: { source: "agentrun-command-result", terminalStatus: "completed", agentRun, valuesPrinted: false }
|
|
};
|
|
}
|
|
|
|
function liveBackfillAgentRun(commandState: string, terminalStatus: string | null, lastSeq: number): JsonRecord {
|
|
return {
|
|
adapter: "agentrun-v01",
|
|
runId: "run_live_backfill_issue1555",
|
|
commandId: "cmd_live_backfill_issue1555",
|
|
status: commandState,
|
|
runStatus: "claimed",
|
|
commandState,
|
|
terminalStatus,
|
|
runnerStatus: "running",
|
|
namespace: "agentrun-v02",
|
|
jobName: "agentrun-v01-runner-live-backfill-issue1555",
|
|
lastSeq,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
function scheduleLiveBackfillProjection(sessionId: string, threadId: string | null, traceId: string, readyAtMs: number): void {
|
|
const scenarioId = state.scenarioId;
|
|
setTimeout(() => {
|
|
if (state.scenarioId !== scenarioId || state.liveBackfillTraceId !== traceId) return;
|
|
commitLiveBackfillProjection(sessionId, threadId, traceId);
|
|
}, Math.max(0, readyAtMs - Date.now()));
|
|
}
|
|
|
|
function commitLiveBackfillProjection(sessionId: string, threadId: string | null, traceId: string): void {
|
|
const completed = liveBackfillCompletedTrace(sessionId, threadId, traceId);
|
|
const finalText = typeof (completed.finalResponse as JsonRecord | undefined)?.text === "string" ? String((completed.finalResponse as JsonRecord).text) : "";
|
|
state.traces[traceId] = completed;
|
|
const session = sessionById(sessionId);
|
|
if (!session) return;
|
|
session.status = "completed";
|
|
session.updatedAt = new Date().toISOString();
|
|
session.messages = (session.messages ?? []).map((message) => {
|
|
if (message.role !== "agent" || message.traceId !== traceId) return message;
|
|
return { ...message, text: finalText, status: "completed", runnerTrace: completed };
|
|
});
|
|
}
|
|
|
|
function sessionSummary(session: SessionRecord): SessionRecord {
|
|
const { messages: _messages, hidden: _hidden, ...rest } = session;
|
|
const status = typeof rest.status === "string" ? rest.status : "unknown";
|
|
return { ...rest, status, running: isRunningStatus(status), terminal: isTerminalStatus(status) } as SessionRecord;
|
|
}
|
|
|
|
function isRunningStatus(status: string): boolean {
|
|
return ["active", "accepted", "pending", "running"].includes(status);
|
|
}
|
|
|
|
function isTerminalStatus(status: string): boolean {
|
|
return ["blocked", "canceled", "cancelled", "completed", "failed", "timeout"].includes(status);
|
|
}
|
|
|
|
function workbenchSessionListPayload(url: URL): JsonRecord {
|
|
if (url.searchParams.has("projectId") || url.searchParams.has("workspaceId")) return { ok: false, status: 400, error: { code: "workbench_authority_removed" } };
|
|
const includeRouteId = url.searchParams.get("includeSessionId") || "";
|
|
const includeSessionId = includeRouteId ? canonicalSessionId(includeRouteId) : "";
|
|
const limit = boundedPageLimit(url.searchParams.get("limit"));
|
|
const offset = cursorOffset(url.searchParams.get("cursor") || url.searchParams.get("after"));
|
|
const visible = state.sessions.filter((item) => !item.hidden && (item.status !== "archived" || item.sessionId === includeSessionId));
|
|
const listed = state.listOmitSelected && includeSessionId ? visible.filter((item) => item.sessionId !== includeSessionId) : visible;
|
|
const page = listed.slice(offset, offset + limit);
|
|
const included = includeSessionId && !page.some((session) => session.sessionId === includeSessionId) ? listed.find((session) => session.sessionId === includeSessionId) ?? null : null;
|
|
const summaries = [...(included ? [included] : []), ...page].map((session) => sessionSummary(session));
|
|
const hasMore = offset + limit < listed.length;
|
|
return { ok: true, status: "succeeded", contractVersion: "workbench-sessions-v1", sessions: summaries, count: summaries.length, total: listed.length, cursor: offset > 0 ? cursorFromOffset(offset) : null, hasMore, nextCursor: hasMore ? cursorFromOffset(offset + limit) : null };
|
|
}
|
|
|
|
function boundedPageLimit(value: string | null): number {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Math.min(100, Math.max(1, Number.isInteger(parsed) && parsed > 0 ? parsed : 50));
|
|
}
|
|
|
|
function cursorOffset(value: string | null): number {
|
|
const text = typeof value === "string" ? value.trim() : "";
|
|
if (!text) return 0;
|
|
if (text.startsWith("idx:")) return Math.max(0, Number.parseInt(text.slice(4), 10) || 0);
|
|
return Math.max(0, Number.parseInt(text, 10) || 0);
|
|
}
|
|
|
|
function cursorFromOffset(offset: number): string {
|
|
return `idx:${Math.max(0, Math.trunc(offset))}`;
|
|
}
|
|
|
|
function workbenchSessionMessagesPayload(session: SessionRecord, url: URL): JsonRecord {
|
|
const messages = Array.isArray(session.messages) ? session.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 { ok: true, status: "succeeded", sessionId: session.sessionId, messages: page, count: page.length, total: messages.length, hasMore: messages.length > page.length };
|
|
}
|
|
|
|
function sessionById(id: string): SessionRecord | null {
|
|
return state.sessions.find((session) => session.sessionId === id) ?? null;
|
|
}
|
|
|
|
function canonicalSessionId(id: string): string {
|
|
const text = id.trim();
|
|
if (!text.startsWith("cnv_")) return text;
|
|
const conversation = state.sessions.find((session) => session.conversationId === text);
|
|
return conversation?.sessionId ?? `ses_${text.slice("cnv_".length)}`;
|
|
}
|
|
|
|
function visibleSessionById(id: string, options: { includeArchived?: boolean } = {}): SessionRecord | null {
|
|
const session = sessionById(id);
|
|
if (!session || session.hidden) return null;
|
|
if (session.status === "archived" && !options.includeArchived) return null;
|
|
return session;
|
|
}
|
|
|
|
function sessionPayload(sessionId: string): JsonRecord {
|
|
const session = sessionById(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: session?.status ?? "unknown", lastTraceId: session?.lastTraceId ?? null, updatedAt: new Date().toISOString() };
|
|
}
|
|
|
|
function turnPayload(traceId: string): JsonRecord {
|
|
if (state.scenarioId === "live-rest-backfill-without-terminal-sse" && traceId === state.liveBackfillTraceId) {
|
|
const session = state.sessions.find((item) => item.lastTraceId === traceId) ?? state.sessions[0];
|
|
const sessionId = session?.sessionId ?? state.selectedSessionId;
|
|
const threadId = typeof session?.threadId === "string" ? session.threadId : null;
|
|
return state.traces[traceId] ?? liveBackfillEarlyTrace(sessionId, threadId, traceId);
|
|
}
|
|
const trace = state.traces[traceId] ?? { traceId, status: "unknown", events: [] };
|
|
if (state.scenarioId === "terminal-turn-stale-session-active" && traceId === "trc_completed") return { ...trace, traceId, status: "active", running: true, terminal: false, trace: { status: "completed", eventCount: trace.eventCount ?? 0 } };
|
|
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 workbenchTracePayload(traceId: string, url: URL): JsonRecord {
|
|
const payload = tracePayload(traceId, url);
|
|
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, sessionId: payload.sessionId ?? null, threadId: payload.threadId ?? null, 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(request: IncomingMessage, response: ServerResponse, url: URL): void {
|
|
response.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" });
|
|
sseClients.add(response);
|
|
request.on("close", () => sseClients.delete(response));
|
|
writeSse(response, "workbench.connected", { type: "connected", sessionId: url.searchParams.get("sessionId") });
|
|
if (state.scenarioId === "cross-session-late-events") {
|
|
const scenarioId = state.scenarioId;
|
|
setTimeout(() => {
|
|
if (state.scenarioId !== scenarioId) return;
|
|
const trace = crossSessionLateTraceA();
|
|
const event = (trace.events as JsonRecord[])[0];
|
|
writeSse(response, "workbench.trace.event", { type: "trace.event", sessionId: "ses_late_A", threadId: "thr_late_A", traceId: "trc_late_A", event, snapshot: trace });
|
|
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_late_A", threadId: "thr_late_A", traceId: "trc_late_A", turn: trace });
|
|
}, 650);
|
|
return;
|
|
}
|
|
if (!state.terminalScript) return;
|
|
const scenarioId = state.scenarioId;
|
|
setTimeout(() => {
|
|
if (state.scenarioId !== scenarioId) return;
|
|
if (state.scenarioId === "terminal-completed-no-final-response") {
|
|
const trace = completedNoFinalTrace();
|
|
const event = (trace.events as JsonRecord[]).at(-1) ?? { status: "completed", terminal: true };
|
|
writeSse(response, "workbench.trace.event", { type: "trace.event", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", event, snapshot: trace });
|
|
finishRunningNoFinalResponse(trace);
|
|
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", turn: turnPayload("trc_running") });
|
|
return;
|
|
}
|
|
const terminalStatus = state.terminalFailureScript ? "failed" : "completed";
|
|
const terminalText = state.terminalFailureScript ? "恢复后失败:缺少受控依赖。" : state.scenarioId === "progress-only-final-response" ? finalOnlyText : "事件重放后完成。";
|
|
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", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", event, snapshot: { traceId: "trc_running", sessionId: "ses_running", threadId: "thr_running", status: terminalStatus, events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: terminalText, status: terminalStatus } } });
|
|
finishRunningSession(terminalStatus, terminalText);
|
|
writeSse(response, "workbench.turn.snapshot", { type: "turn.snapshot", sessionId: "ses_running", threadId: "thr_running", traceId: "trc_running", turn: turnPayload("trc_running") });
|
|
}, state.scenarioId === "progress-only-final-response" ? 5_000 : 350);
|
|
}
|
|
|
|
function broadcastSse(eventName: string, payload: JsonRecord): void {
|
|
for (const client of Array.from(sseClients)) writeSse(client, eventName, payload);
|
|
}
|
|
|
|
function writeSse(response: ServerResponse, eventName: string, payload: JsonRecord): void {
|
|
response.write(`event: ${eventName}\n`);
|
|
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
}
|
|
|
|
function finishRunningSession(status: "completed" | "failed", text: string): void {
|
|
const session = sessionById("ses_running");
|
|
if (!session) return;
|
|
session.status = status;
|
|
session.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 } };
|
|
session.messages = (session.messages ?? []).map((message) => message.role === "agent" ? { ...message, text, status, runnerTrace: state.traces.trc_running } : message);
|
|
}
|
|
|
|
function finishRunningNoFinalResponse(trace: JsonRecord = completedNoFinalTrace()): void {
|
|
state.traces.trc_running = trace;
|
|
const session = sessionById("ses_running");
|
|
if (!session) return;
|
|
session.status = "completed";
|
|
session.updatedAt = new Date().toISOString();
|
|
session.messages = (session.messages ?? []).map((message) => message.role === "agent" ? { ...message, text: "", status: "completed", runnerTrace: trace } : message);
|
|
}
|
|
|
|
function stateSummary(): JsonRecord {
|
|
return { scenarioId: state.scenarioId, selectedSessionId: state.selectedSessionId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, chatRequests: state.chatRequests, staleTraceId: state.staleTraceId, sessions: state.sessions.map((item) => ({ sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId, hidden: item.hidden === true })) };
|
|
}
|
|
|
|
function gcEmptySessions(body: JsonRecord): JsonRecord {
|
|
const ttlMs = positiveInteger(body.ttlMs, DEFAULT_WORKBENCH_EMPTY_SESSION_TTL_MS);
|
|
const nowMs = Date.parse(typeof body.now === "string" ? body.now : "2026-06-18T16:20:30.000Z");
|
|
const archivedSessionIds: string[] = [];
|
|
const keptByReason: Record<string, number> = {};
|
|
for (const session of state.sessions) {
|
|
if (session.status === "archived") continue;
|
|
const decision = classifyWorkbenchEmptySessionGcCandidate(toBackendSessionRecord(session), { nowMs, ttlMs });
|
|
if (!decision.eligible) {
|
|
keptByReason[decision.reason] = (keptByReason[decision.reason] ?? 0) + 1;
|
|
continue;
|
|
}
|
|
session.status = "archived";
|
|
session.updatedAt = new Date(nowMs).toISOString();
|
|
archivedSessionIds.push(session.sessionId);
|
|
}
|
|
return { ok: true, status: "completed", ttlMs, archived: archivedSessionIds.length, archivedSessionIds, keptByReason, valuesRedacted: true };
|
|
}
|
|
|
|
function toBackendSessionRecord(session: SessionRecord): JsonRecord {
|
|
return {
|
|
id: session.sessionId,
|
|
status: session.status,
|
|
startedAt: session.startedAt,
|
|
updatedAt: session.updatedAt,
|
|
lastTraceId: session.lastTraceId,
|
|
session: {
|
|
createdAt: session.startedAt,
|
|
updatedAt: session.updatedAt,
|
|
messageCount: session.messageCount,
|
|
messages: session.messages ?? [],
|
|
lastTraceId: session.lastTraceId,
|
|
valuesRedacted: true,
|
|
secretMaterialStored: false
|
|
}
|
|
};
|
|
}
|
|
|
|
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 {
|
|
if (/^\/v1\/workbench\/(sessions(?:\/[^/]+(?:\/messages)?)?|events|turns\/[^/]+|traces\/[^/]+\/events)$/u.test(path)) return false;
|
|
if (path === "/v1/agent/chat" || /^\/v1\/agent\/sessions(?:\/[^/]+(?:\/select)?)?$/u.test(path)) return false;
|
|
return path.startsWith("/v1/workbench/") || path.startsWith("/v1/agent/");
|
|
}
|
|
|
|
function authPayload(): JsonRecord {
|
|
const deniedCapabilities = { web: "forbidden", workbench: "forbidden", codeAgent: "forbidden", billing: "forbidden", apiKeys: "forbidden", admin: "forbidden" };
|
|
if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") {
|
|
return { authenticated: false, mode: "server", authMethod: null, identityAuthority: null, sessionKind: null, actor: null, user: null, capabilities: deniedCapabilities, expiresAt: null, valuesRedacted: true, error: { code: "auth_required", message: "Authentication is required", status: 401 } };
|
|
}
|
|
const actor = { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" };
|
|
return { authenticated: true, mode: "server", authMethod: "web-session", identityAuthority: "hwlab-local", sessionKind: "browser", actor, user: actor, capabilities: { web: "available", workbench: "available", codeAgent: "available", billing: "unlinked", apiKeys: "available", admin: "available" }, expiresAt: null, valuesRedacted: true };
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function positiveInteger(value: unknown, fallback: number): number {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|