fix: converge workbench session authority
This commit is contained in:
@@ -8,34 +8,29 @@ import { extname, join, resolve, sep } from "node:path";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface ConversationRecord extends JsonRecord {
|
||||
conversationId: string;
|
||||
sessionId?: string | null;
|
||||
interface SessionRecord extends JsonRecord {
|
||||
sessionId: string;
|
||||
threadId?: string | null;
|
||||
status?: string | null;
|
||||
lastTraceId?: string | null;
|
||||
messages?: JsonRecord[];
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
interface ScenarioState {
|
||||
scenarioId: string;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
providerProfile: string;
|
||||
selectedConversationId: string;
|
||||
workspaceJson: JsonRecord;
|
||||
conversations: ConversationRecord[];
|
||||
selectedSessionId: string;
|
||||
sessions: SessionRecord[];
|
||||
traces: Record<string, JsonRecord>;
|
||||
requestLedger: JsonRecord[];
|
||||
legacyRequestLedger: JsonRecord[];
|
||||
selectRequests: JsonRecord[];
|
||||
workspacePatchRequests: JsonRecord[];
|
||||
chatRequests: JsonRecord[];
|
||||
listOmitSelected: boolean;
|
||||
conversationDelayMs: number;
|
||||
sessionDelayMs: number;
|
||||
terminalScript: boolean;
|
||||
terminalFailureScript: boolean;
|
||||
staleNestedTraceId: string | null;
|
||||
staleTraceId: string | null;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
@@ -52,7 +47,7 @@ for (let index = 2; index < process.argv.length; 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> } };
|
||||
const capture = JSON.parse(await readFile(capturePath, "utf8")) as { scenario: { selectedSessionId: string; providerProfile: string; sessions: SessionRecord[]; traces: Record<string, JsonRecord> } };
|
||||
|
||||
let state = createScenarioState("baseline");
|
||||
|
||||
@@ -84,43 +79,40 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
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);
|
||||
await delay(state.sessionDelayMs);
|
||||
return json(response, 200, workbenchSessionListPayload(url));
|
||||
}
|
||||
const workbenchTurnMatch = path.match(/^\/v1\/workbench\/turns\/([^/]+)$/u);
|
||||
if (workbenchTurnMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(workbenchTurnMatch[1] ?? "");
|
||||
|
||||
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.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is 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 workbenchTraceMatch = path.match(/^\/v1\/workbench\/traces\/([^/]+)\/events$/u);
|
||||
if (workbenchTraceMatch && method === "GET") {
|
||||
const traceId = decodeURIComponent(workbenchTraceMatch[1] ?? "");
|
||||
|
||||
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.staleNestedTraceId) return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "stale trace is 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 = 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 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 = 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") return legacyApiResponse(response, method, path);
|
||||
if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") {
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") {
|
||||
return legacyApiResponse(response, method, path);
|
||||
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") {
|
||||
@@ -131,36 +123,14 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
|
||||
if (path === "/v1/agent/sessions" && method === "POST") {
|
||||
const body = await readJson(request);
|
||||
const conversation = createManualSessionConversation(body);
|
||||
state.selectedConversationId = conversation.conversationId;
|
||||
state.conversations.unshift(conversation);
|
||||
return json(response, 200, { ok: true, status: "ok", session: conversation });
|
||||
const session = createManualSession(body);
|
||||
state.selectedSessionId = session.sessionId;
|
||||
state.sessions.unshift(session);
|
||||
return json(response, 200, { ok: true, status: "ok", session });
|
||||
}
|
||||
|
||||
if (path === "/v1/agent/conversations" && method === "GET") {
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
const conversationMatch = path.match(/^\/v1\/agent\/conversations\/([^/]+)$/u);
|
||||
if (conversationMatch && method === "GET") {
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
if (conversationMatch && method === "PUT") return legacyApiResponse(response, method, path);
|
||||
if (conversationMatch && method === "DELETE") {
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
|
||||
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") {
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
|
||||
const traceMatch = path.match(/^\/v1\/agent\/traces\/([^/]+)$/u);
|
||||
if (traceMatch && method === "GET") {
|
||||
return legacyApiResponse(response, method, path);
|
||||
}
|
||||
const agentSessionMatch = path.match(/^\/v1\/agent\/sessions\/([^/]+)$/u);
|
||||
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" } });
|
||||
@@ -175,184 +145,60 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
||||
function createScenarioState(scenarioId: string): ScenarioState {
|
||||
const base = structuredClone(capture.scenario);
|
||||
const id = scenarioId || "baseline";
|
||||
const conversations = base.conversations;
|
||||
const sessions = base.sessions;
|
||||
const traces = base.traces;
|
||||
if (id === "cross-project-detail-boundary") {
|
||||
conversations.push(crossProjectConversation());
|
||||
traces.trc_cross_project = crossProjectTrace();
|
||||
}
|
||||
if (id === "cross-project-detail-boundary") sessions.push(hiddenBoundarySession());
|
||||
if (id === "session-switch-detail-404-isolated") {
|
||||
conversations.unshift(staleDetailConversation());
|
||||
conversations.push(emptyConversation());
|
||||
sessions.unshift(staleDetailSession());
|
||||
sessions.push(emptySession());
|
||||
}
|
||||
if (id === "terminal-empty-trace") {
|
||||
conversations.push(terminalEmptyTraceConversation());
|
||||
sessions.push(terminalEmptyTraceSession());
|
||||
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"
|
||||
if (id === "deleted-session-deeplink") sessions.unshift(archivedDeletedSession());
|
||||
if (id === "session-switch-empty-reload") sessions.push(emptySession());
|
||||
const selectedSessionId = id === "deep-link" || id === "stale-nested-trace"
|
||||
? "ses_failed"
|
||||
: 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"
|
||||
? "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;
|
||||
? "ses_terminal_empty"
|
||||
: base.selectedSessionId;
|
||||
const staleTraceId = 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,
|
||||
selectedSessionId,
|
||||
sessions,
|
||||
traces,
|
||||
requestLedger: [],
|
||||
legacyRequestLedger: [],
|
||||
selectRequests: [],
|
||||
workspacePatchRequests: [],
|
||||
chatRequests: [],
|
||||
listOmitSelected: id === "selected-missing-from-list",
|
||||
conversationDelayMs: id === "loading" ? 2_500 : 0,
|
||||
sessionDelayMs: id === "loading" ? 2_500 : 0,
|
||||
terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore",
|
||||
terminalFailureScript: id === "stale-submit-restore",
|
||||
staleNestedTraceId
|
||||
staleTraceId
|
||||
};
|
||||
}
|
||||
|
||||
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 createManualSessionConversation(body: JsonRecord): ConversationRecord {
|
||||
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 conversationId = typeof body.conversationId === "string" && body.conversationId.trim() ? body.conversationId : `cnv_${token}`;
|
||||
const threadId = typeof body.threadId === "string" && body.threadId.trim() ? body.threadId : null;
|
||||
return {
|
||||
conversationId,
|
||||
projectId: state.projectId,
|
||||
sessionId,
|
||||
threadId,
|
||||
status: "active",
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
messageCount: 0,
|
||||
firstUserMessagePreview: null,
|
||||
session: { sessionId, threadId, status: "active" },
|
||||
messages: []
|
||||
};
|
||||
return { sessionId, threadId, status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: null, messages: [] };
|
||||
}
|
||||
|
||||
function crossProjectConversation(): ConversationRecord {
|
||||
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 {
|
||||
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",
|
||||
@@ -361,164 +207,97 @@ function terminalEmptyTraceConversation(): ConversationRecord {
|
||||
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() }
|
||||
{ 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", conversationId: "cnv_terminal_empty", sessionId: "ses_terminal_empty", threadId: "thr_terminal_empty", events: [], eventCount: 0, fullTraceLoaded: true, hasMore: false, finalResponse: { text: "终态空 Trace 已完成。" } };
|
||||
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 staleDetailConversation(): ConversationRecord {
|
||||
function staleDetailSession(): SessionRecord {
|
||||
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: []
|
||||
};
|
||||
return { sessionId: "ses_stale_404", threadId: "thr_stale_404", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已失效的列表项", messages: [] };
|
||||
}
|
||||
|
||||
function emptyConversation(): ConversationRecord {
|
||||
function emptySession(): SessionRecord {
|
||||
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: [] };
|
||||
return { sessionId: "ses_empty", threadId: "thr_empty", status: "active", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "空白会话", messages: [] };
|
||||
}
|
||||
|
||||
function archivedDeletedConversation(): ConversationRecord {
|
||||
function archivedDeletedSession(): SessionRecord {
|
||||
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: []
|
||||
};
|
||||
return { sessionId: "ses_deleted", threadId: "thr_deleted", status: "archived", startedAt: now, updatedAt: now, messageCount: 0, firstUserMessagePreview: "已删除 session", messages: [] };
|
||||
}
|
||||
|
||||
function acceptChatTurn(body: JsonRecord): JsonRecord {
|
||||
const requestedSession = typeof body.sessionId === "string" ? conversationBySessionId(body.sessionId) : null;
|
||||
const conversationId = typeof body.conversationId === "string" ? body.conversationId : requestedSession?.conversationId ?? state.selectedConversationId;
|
||||
const sessionId = typeof body.sessionId === "string" ? body.sessionId : conversationById(conversationId)?.sessionId ?? `ses_${conversationId.slice(4)}`;
|
||||
const 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 = 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 };
|
||||
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 } }
|
||||
];
|
||||
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 conversationSummary(conversation: ConversationRecord): ConversationRecord {
|
||||
const { messages: _messages, ...rest } = conversation;
|
||||
return rest as ConversationRecord;
|
||||
function sessionSummary(session: SessionRecord): SessionRecord {
|
||||
const { messages: _messages, hidden: _hidden, ...rest } = session;
|
||||
return rest as SessionRecord;
|
||||
}
|
||||
|
||||
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 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 };
|
||||
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 summaries = listed.map((session) => sessionSummary(session));
|
||||
return { ok: true, status: "succeeded", sessions: summaries, count: summaries.length };
|
||||
}
|
||||
|
||||
function workbenchSessionMessagesPayload(conversation: ConversationRecord, url: URL): JsonRecord {
|
||||
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
|
||||
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 { sessionId: conversation.sessionId ?? null, conversationId: conversation.conversationId, messages: page, count: page.length, total: messages.length, hasMore: messages.length > page.length };
|
||||
return { ok: true, status: "succeeded", sessionId: session.sessionId, 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 sessionById(id: string): SessionRecord | null {
|
||||
return state.sessions.find((session) => session.sessionId === 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 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 conversation = state.conversations.find((item) => item.sessionId === sessionId);
|
||||
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: conversation?.status ?? "unknown", lastTraceId: conversation?.lastTraceId ?? null, updatedAt: new Date().toISOString() };
|
||||
return { sessionId, status: session?.status ?? "unknown", lastTraceId: session?.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)
|
||||
};
|
||||
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 {
|
||||
@@ -536,44 +315,23 @@ function tracePayload(traceId: string, url: URL): JsonRecord {
|
||||
|
||||
function workbenchTracePayload(traceId: string, url: URL): JsonRecord {
|
||||
const payload = tracePayload(traceId, url);
|
||||
return {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
contractVersion: "workbench-read-model-v1",
|
||||
traceId,
|
||||
traceStatus: payload.status,
|
||||
events: payload.events,
|
||||
eventCount: payload.eventCount,
|
||||
hasMore: payload.hasMore,
|
||||
nextSeq: payload.nextSinceSeq,
|
||||
range: payload.range,
|
||||
fullTraceLoaded: payload.fullTraceLoaded,
|
||||
terminalEvidence: payload.terminalEvidence,
|
||||
finalResponse: payload.finalResponse,
|
||||
traceSummary: payload.traceSummary,
|
||||
retention: payload.retention
|
||||
};
|
||||
return { ok: true, status: "ok", contractVersion: "workbench-read-model-v1", traceId, traceStatus: payload.status, events: payload.events, eventCount: payload.eventCount, hasMore: payload.hasMore, nextSeq: payload.nextSinceSeq, range: payload.range, fullTraceLoaded: payload.fullTraceLoaded, terminalEvidence: payload.terminalEvidence, finalResponse: payload.finalResponse, traceSummary: payload.traceSummary, retention: payload.retention };
|
||||
}
|
||||
|
||||
function sse(response: ServerResponse, url: URL): void {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"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);
|
||||
}
|
||||
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") });
|
||||
if (!state.terminalScript) return;
|
||||
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 } } });
|
||||
finishRunningSession(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 {
|
||||
@@ -581,25 +339,20 @@ function writeSse(response: ServerResponse, eventName: string, payload: JsonReco
|
||||
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();
|
||||
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 } };
|
||||
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";
|
||||
session.messages = (session.messages ?? []).map((message) => message.role === "agent" ? { ...message, text, status, runnerTrace: state.traces.trc_running } : message);
|
||||
}
|
||||
|
||||
function stateSummary(): JsonRecord {
|
||||
return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, requestLedger: state.requestLedger, legacyRequestLedger: state.legacyRequestLedger, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) };
|
||||
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 recordLedger(method: string, url: URL): void {
|
||||
@@ -610,16 +363,9 @@ function recordLedger(method: string, url: URL): void {
|
||||
}
|
||||
|
||||
function isLegacyWorkbenchPath(path: string): boolean {
|
||||
return path === "/v1/workbench/workspace"
|
||||
|| /^\/v1\/workbench\/workspace\/[^/]+(?:\/select-conversation)?$/u.test(path)
|
||||
|| path === "/v1/agent/conversations"
|
||||
|| /^\/v1\/agent\/conversations\/[^/]+$/u.test(path)
|
||||
|| /^\/v1\/agent\/turns\/[^/]+$/u.test(path)
|
||||
|| /^\/v1\/agent\/traces\/[^/]+$/u.test(path);
|
||||
}
|
||||
|
||||
function legacyApiResponse(response: ServerResponse, method: string, path: string): void {
|
||||
return json(response, 410, { ok: false, status: 410, error: { code: "legacy_workbench_endpoint_forbidden", message: `${method} ${path} is not part of the sessionId Workbench contract.` } });
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user