Merge pull request #1420 from pikasTech/codex/1413-stale-restore-fake-server

fix: 清理 Workbench stale workspace trace 恢复
This commit is contained in:
Lyon
2026-06-17 20:01:21 +08:00
committed by GitHub
6 changed files with 129 additions and 46 deletions
+2 -2
View File
@@ -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");
+11 -4
View File
@@ -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 = {}) {
@@ -23,13 +23,16 @@ interface ScenarioState {
workspaceId: string;
providerProfile: string;
selectedConversationId: string;
workspaceJson: JsonRecord;
conversations: ConversationRecord[];
traces: Record<string, JsonRecord>;
selectRequests: JsonRecord[];
workspacePatchRequests: JsonRecord[];
chatRequests: JsonRecord[];
listOmitSelected: boolean;
conversationDelayMs: number;
terminalScript: boolean;
terminalFailureScript: boolean;
staleNestedTraceId: string | null;
}
@@ -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 {
@@ -131,6 +131,7 @@ export function workspaceWithClearedActiveTrace(workspace: WorkspaceRecord | nul
workspace: {
...nested,
activeTraceId: null,
lastTraceId: null,
staleActiveTraceId: traceId,
staleActiveTraceReason: reason,
sessionStatus: nextSessionStatus,
@@ -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,
@@ -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, unknown>): 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;
}