Merge pull request #1420 from pikasTech/codex/1413-stale-restore-fake-server
fix: 清理 Workbench stale workspace trace 恢复
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user