From d6b2b3206700e47d4ede182b66f0b17e82eeac12 Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 17 Jun 2026 19:59:57 +0800 Subject: [PATCH] fix: clear stale workspace trace restore --- internal/cloud/access-control.test.ts | 4 +- internal/cloud/access-control.ts | 15 ++- .../scripts/workbench-e2e-server.ts | 92 +++++++++++++------ .../src/stores/workbench-session.ts | 1 + web/hwlab-cloud-web/src/stores/workbench.ts | 1 + .../specs/stale-workspace-trace.spec.ts | 62 ++++++++++--- 6 files changed, 129 insertions(+), 46 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 7bea5c5a..2a3ffdb8 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -984,7 +984,7 @@ test("workbench workspace read does not repair terminal selected conversation af }, aliceLogin.cookie); assert.equal(update.status, 200); assert.equal(update.body.workspace.activeTraceId, null); - assert.equal(update.body.workspace.workspace.lastTraceId, "trc_issue664_repair_done"); + assert.equal(update.body.workspace.workspace.lastTraceId, undefined); assert.equal(update.body.workspace.workspace.sessionStatus, "idle"); assert.equal(update.body.workspace.selectedConversation.status, "running"); assert.equal(update.body.workspace.selectedConversation.messages.some((message) => message.text === "repair completed"), false); @@ -992,7 +992,7 @@ test("workbench workspace read does not repair terminal selected conversation af const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.status, 200); assert.equal(restored.body.workspace.activeTraceId, null); - assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_repair_done"); + assert.equal(restored.body.workspace.workspace.lastTraceId, undefined); assert.equal(restored.body.workspace.workspace.sessionStatus, "idle"); assert.equal(restored.body.workspace.selectedConversation.status, "running"); assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_repair_done"); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 23140d60..61ff579d 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -2047,7 +2047,8 @@ function defaultWorkspaceId(ownerUserId, projectId) { return `wsp_${sha256(`${ow function currentSessionIdFromAuth(auth) { return textOr(auth?.session?.id, null); } function normalizeWorkspacePatch(body = {}, actor = null) { const workspace = normalizeObject(body.workspace ?? body.workspaceJson ?? body.snapshot ?? body); - return { + const activeTraceCleared = (Object.hasOwn(body, "activeTraceId") && body.activeTraceId === null) || (Object.hasOwn(workspace, "activeTraceId") && workspace.activeTraceId === null); + const normalized = { ...workspace, selectedConversationId: textOr(body.selectedConversationId ?? body.conversationId ?? workspace.selectedConversationId, workspace.selectedConversationId ?? null), selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId ?? workspace.selectedAgentSessionId) || workspace.selectedAgentSessionId, @@ -2058,6 +2059,8 @@ function normalizeWorkspacePatch(body = {}, actor = null) { secretMaterialStored: false, valuesRedacted: true }; + if (activeTraceCleared) normalized.lastTraceId = null; + return normalized; } function normalizeWorkspaceRecord(input = {}, existing = null, now = new Date().toISOString(), { create = false, replaceJson = false } = {}) { const patch = normalizeObject(input.patch); @@ -2114,14 +2117,18 @@ function workspaceJsonForSelectedConversation(workspace, conversation = null) { const snapshot = normalizeObject(workspace?.workspace); const selectedConversationId = textOr(workspace?.selectedConversationId, ""); if (!conversation || textOr(conversation.conversationId, "") !== selectedConversationId) return snapshot; + const activeTraceId = textOr(workspace.activeTraceId, ""); + const conversationStatus = textOr(conversation.status, ""); + const snapshotStatus = textOr(snapshot.sessionStatus, ""); + const sessionStatus = activeTraceId || !snapshotStatus || isActiveConversationStatus(snapshotStatus) ? conversationStatus : snapshotStatus; return { ...snapshot, selectedConversationId, selectedAgentSessionId: textOr(workspace.selectedAgentSessionId ?? conversation.sessionId, ""), - activeTraceId: textOr(workspace.activeTraceId, ""), + activeTraceId, providerProfile: textOr(workspace.providerProfile, snapshot.providerProfile), - sessionStatus: textOr(conversation.status, ""), - lastTraceId: textOr(conversation.lastTraceId, "") + sessionStatus, + lastTraceId: activeTraceId ? textOr(conversation.lastTraceId, "") : "" }; } function redactedWorkspaceJson(value = {}) { diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 3ccc4238..94e85913 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -23,13 +23,16 @@ interface ScenarioState { workspaceId: string; providerProfile: string; selectedConversationId: string; + workspaceJson: JsonRecord; conversations: ConversationRecord[]; traces: Record; selectRequests: JsonRecord[]; + workspacePatchRequests: JsonRecord[]; chatRequests: JsonRecord[]; listOmitSelected: boolean; conversationDelayMs: number; terminalScript: boolean; + terminalFailureScript: boolean; staleNestedTraceId: string | null; } @@ -79,8 +82,8 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) if (path === "/v1/workbench/workspace" && method === "GET") return json(response, 200, { workspace: workspacePayload() }); if (/^\/v1\/workbench\/workspace\/[^/]+$/u.test(path) && method === "PATCH") { const body = await readJson(request); - if (typeof body.providerProfile === "string") state.providerProfile = body.providerProfile; - if (Object.hasOwn(body, "activeTraceId") && body.activeTraceId === null) clearActiveTraceForSelection(); + state.workspacePatchRequests.push(redactRequestBody(body)); + applyWorkspacePatch(body); return json(response, 200, { workspace: workspacePayload() }); } if (/^\/v1\/workbench\/workspace\/[^/]+\/select-conversation$/u.test(path) && method === "POST") { @@ -141,50 +144,83 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) function createScenarioState(scenarioId: string): ScenarioState { const base = structuredClone(capture.scenario); const id = scenarioId || "baseline"; - const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" ? "cnv_failed" : base.selectedConversationId; + const selectedConversationId = id === "deep-link" || id === "stale-nested-trace" ? "cnv_failed" : id === "stale-submit-restore" ? "cnv_running" : base.selectedConversationId; + const staleNestedTraceId = id === "stale-nested-trace" || id === "stale-submit-restore" ? "trc_stale_502" : null; return { scenarioId: id, projectId: base.projectId, workspaceId: base.workspaceId, providerProfile: base.providerProfile, selectedConversationId, + workspaceJson: initialWorkspaceJson(base.projectId, base.providerProfile, selectedConversationId, base.conversations, staleNestedTraceId), conversations: base.conversations, traces: base.traces, selectRequests: [], + workspacePatchRequests: [], chatRequests: [], listOmitSelected: id === "selected-missing-from-list", conversationDelayMs: id === "loading" ? 2_500 : 0, - terminalScript: id === "event-replay" || id === "running-to-terminal", - staleNestedTraceId: id === "stale-nested-trace" ? "trc_stale_502" : null + terminalScript: id === "event-replay" || id === "running-to-terminal" || id === "stale-submit-restore", + terminalFailureScript: id === "stale-submit-restore", + staleNestedTraceId }; } function workspacePayload(): JsonRecord { const selected = conversationById(state.selectedConversationId); - const nestedLastTraceId = state.staleNestedTraceId ?? selected?.lastTraceId ?? null; + 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 + 1, + revision: state.selectRequests.length + state.workspacePatchRequests.length + 1, updatedAt: new Date().toISOString(), selectedConversationId: state.selectedConversationId, selectedAgentSessionId: selected?.sessionId ?? null, - activeTraceId: selected?.status === "running" ? selected.lastTraceId ?? null : null, + activeTraceId: Object.hasOwn(nested, "activeTraceId") ? nested.activeTraceId : selected?.status === "running" ? selected.lastTraceId ?? null : null, providerProfile: state.providerProfile, selectedConversation: selected ?? null, - workspace: { - projectId: state.projectId, - selectedConversationId: state.selectedConversationId, - selectedAgentSessionId: selected?.sessionId ?? null, - threadId: selected?.threadId ?? null, - sessionStatus: selected?.status ?? null, - providerProfile: state.providerProfile, - lastTraceId: nestedLastTraceId, - updatedAt: new Date().toISOString() - } + 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}`; @@ -279,9 +315,11 @@ function sse(response: ServerResponse): void { const scenarioId = state.scenarioId; setTimeout(() => { if (state.scenarioId !== scenarioId) return; - const event = { seq: 3, createdAt: new Date().toISOString(), label: "agentrun:assistant:message", type: "assistant_message", status: "completed", replyAuthority: true, final: true, message: "事件重放后完成。", terminal: true }; - writeSse(response, "workbench.trace.event", { type: "trace.event", traceId: "trc_running", event, snapshot: { traceId: "trc_running", status: "completed", events: [event], eventCount: 3, fullTraceLoaded: true, finalResponse: { text: "事件重放后完成。" } } }); - completeRunningConversation(); + 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); } @@ -292,16 +330,16 @@ function writeSse(response: ServerResponse, eventName: string, payload: JsonReco response.write(`data: ${JSON.stringify(payload)}\n\n`); } -function completeRunningConversation(): void { +function finishRunningConversation(status: "completed" | "failed", text: string): void { const conversation = conversationById("cnv_running"); if (!conversation) return; - conversation.status = "completed"; + conversation.status = status; conversation.updatedAt = new Date().toISOString(); const trace = state.traces.trc_running ?? { traceId: "trc_running", status: "running", events: [] }; - const finalEvent = { seq: 4, createdAt: new Date().toISOString(), label: "agentrun:terminal:completed", status: "completed", terminal: true }; + 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: "completed", events, eventCount: events.length, fullTraceLoaded: true, hasMore: false, assistantText: "事件重放后完成。", finalResponse: { text: "事件重放后完成。" } }; - conversation.messages = (conversation.messages ?? []).map((message) => message.role === "agent" ? { ...message, text: "事件重放后完成。", status: "completed", runnerTrace: state.traces.trc_running } : message); + 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 { @@ -310,7 +348,7 @@ function clearActiveTraceForSelection(): void { } function stateSummary(): JsonRecord { - return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) }; + return { scenarioId: state.scenarioId, selectedConversationId: state.selectedConversationId, selectRequests: state.selectRequests, workspacePatchRequests: state.workspacePatchRequests, chatRequests: state.chatRequests, staleNestedTraceId: state.staleNestedTraceId, workspace: workspacePayload(), conversations: state.conversations.map((item) => ({ conversationId: item.conversationId, sessionId: item.sessionId, threadId: item.threadId, status: item.status, lastTraceId: item.lastTraceId })) }; } function authPayload(): JsonRecord { diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 5866f940..c5f834b7 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -131,6 +131,7 @@ export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | nul workspace: { ...nested, activeTraceId: null, + lastTraceId: null, staleActiveTraceId: traceId, staleActiveTraceReason: reason, sessionStatus: nextSessionStatus, diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 00a26ea0..14fc791d 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -976,6 +976,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const response = await api.workbench.updateWorkspace(current.workspaceId, { projectId: activeProjectId.value, activeTraceId: null, + lastTraceId: null, staleActiveTraceId: traceId, staleActiveTraceReason: reason, sessionStatus: current.workspace?.sessionStatus, diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts index e6674107..b3086ee5 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/stale-workspace-trace.spec.ts @@ -1,18 +1,54 @@ -import { expect, gotoWorkbench, test } from "../fixtures/test"; +import { expect, fakeServerState, gotoWorkbench, test } from "../fixtures/test"; import { selectors, sessionTab } from "../fixtures/selectors"; -test.use({ scenarioId: "stale-nested-trace" }); +test.describe("stale nested workspace trace", () => { + test.use({ scenarioId: "stale-nested-trace" }); -test("session restore ignores stale nested workspace lastTraceId", async ({ page }) => { - const staleTraceRequests: string[] = []; - page.on("request", (request) => { - if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url()); + test("session restore ignores stale nested workspace lastTraceId", async ({ page }) => { + const staleTraceRequests: string[] = []; + page.on("request", (request) => { + if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url()); + }); + + await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench"); + await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖"); + await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible(); + await page.waitForTimeout(500); + expect(staleTraceRequests).toEqual([]); }); - - await gotoWorkbench(page, "/workbench/sessions/cnv_failed?projectId=prj_hwpod_workbench"); - await expect(page.locator(sessionTab("cnv_failed"))).toHaveAttribute("data-active", "true"); - await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("缺少受控依赖"); - await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible(); - await page.waitForTimeout(500); - expect(staleTraceRequests).toEqual([]); }); + +test.describe("stale nested workspace trace after terminal restore", () => { + test.use({ scenarioId: "stale-submit-restore" }); + + test("terminal recovery clears stale nested workspace lastTraceId before persisting", async ({ page }) => { + const staleTraceRequests: string[] = []; + page.on("request", (request) => { + if (/\/v1\/agent\/(?:turns|traces)\/trc_stale_502/u.test(request.url())) staleTraceRequests.push(request.url()); + }); + + await gotoWorkbench(page, "/workbench/sessions/cnv_running?projectId=prj_hwpod_workbench"); + await expect(page.locator(sessionTab("cnv_running"))).toHaveAttribute("data-running", "true"); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("恢复后失败"); + await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible(); + await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "turn"); + + await expect.poll(async () => ((await fakeServerState(page)).workspacePatchRequests as unknown[]).length).toBeGreaterThan(0); + await expect.poll(async () => nestedWorkspaceLastTraceId(await fakeServerState(page))).toBeNull(); + const state = await fakeServerState(page); + const patchRequests = state.workspacePatchRequests as Array<{ workspace?: { lastTraceId?: unknown } }>; + expect(patchRequests.at(-1)?.workspace?.lastTraceId ?? null).toBeNull(); + expect(staleTraceRequests).toEqual([]); + + await page.reload(); + await expect(page.locator(`${selectors.messageCard}[data-role="agent"][data-status="failed"]`)).toContainText("恢复后失败"); + await expect(page.locator(`${selectors.traceTimeline}[data-status="failed"]`)).toBeVisible(); + }); +}); + +function nestedWorkspaceLastTraceId(state: Record): string | null { + const workspace = state.workspace && typeof state.workspace === "object" ? state.workspace as { workspace?: { lastTraceId?: unknown } } : null; + const value = workspace?.workspace?.lastTraceId; + return typeof value === "string" && value ? value : null; +}