From d947b9e4e8e6a2609b896ebd063ef7ddb3b4c983 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 16:03:05 +0800 Subject: [PATCH] feat(web): surface Workbench diagnostics --- .../scripts/workbench-e2e-server.ts | 37 +++- .../workbench/ConversationPanel.vue | 111 +++++++++++- web/hwlab-cloud-web/src/stores/workbench.ts | 169 ++++++++++++++++-- web/hwlab-cloud-web/src/types/index.ts | 10 +- .../specs/projection-diagnostics.spec.ts | 14 ++ 5 files changed, 315 insertions(+), 26 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 824a3806..309e1c78 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -158,8 +158,8 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) 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 (state.scenarioId === "trace-hydration-timeout-diagnostic" && traceId === "trc_trace_hydration_timeout") return json(response, 504, { ok: false, status: 504, error: { code: "trace_hydration_timeout", message: "Trace 更新超时,运行记录暂不可见。" } }); - if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return json(response, 504, { ok: false, status: 504, error: { code: "trace_hydration_timeout", message: "Trace 更新超时,运行记录暂不可见。" } }); + if (state.scenarioId === "trace-hydration-timeout-diagnostic" && traceId === "trc_trace_hydration_timeout") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "33333333333333333333333333333333", requestId: "req_e2e_trace_hydration_timeout", layer: "workbench-read-model", category: "trace-hydration" }); + if (state.scenarioId === "sealed-final-response-diagnostics" && traceId === "trc_sealed_final_diag") return errorDiagnosticResponse(response, 504, `/v1/workbench/traces/${traceId}/events`, "trace_hydration_timeout", "Trace 更新超时,运行记录暂不可见。", { traceId: "55555555555555555555555555555555", requestId: "req_e2e_sealed_final_trace_timeout", layer: "workbench-read-model", category: "trace-hydration" }); 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)); } @@ -921,6 +921,20 @@ function toolCompletedProjectionRunningTrace(): JsonRecord { } function projectionDiagnostic(input: { code: string; message: string; health?: string; category?: string; layer?: string; traceId: string; runId?: string; commandId?: string; staleMs?: number }): JsonRecord { + const diagnostic = { + contractVersion: "hwlab-error-diagnostic-v1", + traceId: input.traceId, + requestId: `req_e2e_${input.code}`, + route: "/v1/workbench/events", + layer: input.layer ?? "agentrun", + category: input.category ?? "upstream-timeout", + code: input.code, + httpStatus: 0, + source: "server", + observedAt: new Date().toISOString(), + retryable: true, + valuesPrinted: false + }; return { projectionStatus: "projecting", projectionHealth: input.health ?? "degraded", @@ -937,8 +951,15 @@ function projectionDiagnostic(input: { code: string; message: string; health?: s timeoutMs: 2500, runId: input.runId ?? "run_projection_diag", commandId: input.commandId ?? "cmd_projection_diag", + traceId: diagnostic.traceId, + requestId: diagnostic.requestId, + route: diagnostic.route, + source: diagnostic.source, + httpStatus: diagnostic.httpStatus, + diagnostic, valuesPrinted: false }, + diagnostic, valuesRedacted: true }; } @@ -1884,17 +1905,17 @@ async function readJson(request: IncomingMessage): Promise { } } -function errorDiagnosticResponse(response: ServerResponse, status: number, route: string, code: string, message: string): void { +function errorDiagnosticResponse(response: ServerResponse, status: number, route: string, code: string, message: string, overrides: Partial = {}): void { const diagnostic = { contractVersion: "hwlab-error-diagnostic-v1", - traceId: "11111111111111111111111111111111", - requestId: "req_e2e_api_error_diagnostic", + traceId: String(overrides.traceId ?? "11111111111111111111111111111111"), + requestId: String(overrides.requestId ?? "req_e2e_api_error_diagnostic"), route, - layer: "api", - category: "server", + layer: String(overrides.layer ?? "api"), + category: String(overrides.category ?? "server"), code, httpStatus: status, - source: "server", + source: String(overrides.source ?? "server"), observedAt: new Date().toISOString(), valuesPrinted: false }; diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 0068efa5..62f29a71 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -3,7 +3,8 @@ // Confirms Workbench message visibility after Vue render and keeps sealed final responses separate from diagnostics. import { computed, nextTick, onMounted, ref, watch } from "vue"; -import type { ChatMessage } from "@/types"; +import type { ApiError, ChatMessage, ErrorDiagnostic } from "@/types"; +import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue"; import LoadingState from "@/components/common/LoadingState.vue"; import StatusBadge from "@/components/common/StatusBadge.vue"; import TraceTimeline from "@/components/agent/TraceTimeline.vue"; @@ -89,13 +90,95 @@ function messageDiagnosticText(message: ChatMessage): string | null { return "消息内容缺失:Workbench read model 未返回 user text。"; } +function messageHasDiagnostic(message: ChatMessage): boolean { + return Boolean(messageDiagnosticText(message) || messageApiError(message) || messageErrorDiagnostic(message)); +} + function projectionDiagnosticText(message: ChatMessage): string | null { - const projection = message.projection ?? message.runnerTrace?.projection ?? null; + const projection = messageProjection(message); const health = projection?.projectionHealth ?? message.projectionHealth ?? message.runnerTrace?.projectionHealth; if (!projection || health === "caught-up") return null; return firstNonEmptyString(projection.blocker?.userMessage, projection.blocker?.message, projection.blocker?.summary, projection.blocker?.code ? `状态更新异常:${projection.blocker.code}` : null); } +function messageProjection(message: ChatMessage): ChatMessage["projection"] { + return message.projection ?? message.runnerTrace?.projection ?? null; +} + +function messageApiError(message: ChatMessage): ApiError | null { + const projection = messageProjection(message); + const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker); + const error = recordValue(message.error); + const apiError = recordValue(projection?.apiError ?? error?.apiError); + const diagnostic = messageErrorDiagnostic(message); + if (!projectionDiagnosticText(message) && !blocker && !error && !apiError && !diagnostic) return null; + const source = apiError ?? error ?? blocker ?? {}; + const messageText = firstNonEmptyString(source.userMessage, source.message, blocker?.userMessage, blocker?.message, blocker?.summary, diagnostic?.code ? String(diagnostic.code) : null, messageDiagnosticText(message)); + return { + ...source, + message: messageText ?? "Workbench 诊断", + userMessage: firstNonEmptyString(source.userMessage, blocker?.userMessage, messageText), + code: firstValue(source.code, blocker?.code, diagnostic?.code), + retryable: firstBooleanValue(source.retryable, blocker?.retryable, diagnostic?.retryable), + layer: firstNonEmptyString(source.layer, blocker?.layer, diagnostic?.layer), + category: firstNonEmptyString(source.category, blocker?.category, diagnostic?.category), + route: firstNonEmptyString(source.route, blocker?.route, diagnostic?.route), + traceId: firstNonEmptyString(source.traceId, blocker?.traceId, diagnostic?.traceId, message.traceId, message.runnerTrace?.traceId), + requestId: firstNonEmptyString(source.requestId, blocker?.requestId, diagnostic?.requestId), + source: firstNonEmptyString(source.source, blocker?.source, diagnostic?.source), + diagnostic, + valuesPrinted: source.valuesPrinted === true || blocker?.valuesPrinted === true + } as ApiError; +} + +function messageErrorDiagnostic(message: ChatMessage): ErrorDiagnostic | null { + const projection = messageProjection(message); + const blocker = recordValue(projection?.blocker ?? message.blocker ?? message.runnerTrace?.blocker); + const error = recordValue(message.error); + const apiError = recordValue(projection?.apiError ?? error?.apiError); + if (!projectionDiagnosticText(message) && !blocker && !error && !apiError && !projection?.diagnostic) return null; + const nested = normalizeErrorDiagnostic(projection?.diagnostic, blocker?.diagnostic, error?.diagnostic, apiError?.diagnostic); + if (nested) return nested; + const code = firstValue(error?.code, apiError?.code, blocker?.code); + const traceId = firstNonEmptyString(error?.traceId, apiError?.traceId, blocker?.traceId, message.traceId, message.runnerTrace?.traceId); + const requestId = firstNonEmptyString(error?.requestId, apiError?.requestId, blocker?.requestId); + const route = firstNonEmptyString(error?.route, apiError?.route, blocker?.route); + const layer = firstNonEmptyString(error?.layer, apiError?.layer, blocker?.layer); + const category = firstNonEmptyString(error?.category, apiError?.category, blocker?.category); + const source = firstNonEmptyString(error?.source, apiError?.source, blocker?.source); + const httpStatus = firstNumber(error?.httpStatus, apiError?.httpStatus, blocker?.httpStatus, error?.providerStatus); + if (!code && !traceId && !requestId && !route && !layer && !category && !source && httpStatus === null) return null; + return { contractVersion: "hwlab-error-diagnostic-v1", traceId, requestId, route, layer, category, code, httpStatus, source, retryable: firstBooleanValue(error?.retryable, apiError?.retryable, blocker?.retryable), valuesPrinted: false }; +} + +function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null { + for (const value of values) { + const record = recordValue(value); + if (!record) continue; + return { + ...record, + contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"), + traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id), + requestId: firstNonEmptyString(record.requestId, record.request_id), + serviceId: firstNonEmptyString(record.serviceId, record.service_id), + route: firstNonEmptyString(record.route), + layer: firstNonEmptyString(record.layer), + category: firstNonEmptyString(record.category), + code: firstValue(record.code), + httpStatus: firstNumber(record.httpStatus, record.http_status), + source: firstNonEmptyString(record.source), + observedAt: firstNonEmptyString(record.observedAt, record.observed_at), + retryable: firstBooleanValue(record.retryable), + valuesPrinted: record.valuesPrinted === true + }; + } + return null; +} + +function recordValue(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + function firstNonEmptyString(...values: unknown[]): string | null { for (const value of values) { if (typeof value !== "string") continue; @@ -105,6 +188,27 @@ function firstNonEmptyString(...values: unknown[]): string | null { return null; } +function firstValue(...values: unknown[]): string | number | null { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +function firstNumber(...values: unknown[]): number | null { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); + } + return null; +} + +function firstBooleanValue(...values: unknown[]): boolean | null { + for (const value of values) if (typeof value === "boolean") return value; + return null; +} + function acknowledgeVisibleMessages(): void { acknowledgeWorkbenchVisibleAfterPaint({ messages: workbench.activeMessages, activeSessionId: workbench.activeSessionId, detailLoading: workbench.sessionDetailLoading }); } @@ -176,7 +280,7 @@ function formatDuration(ms: number): string { -

{{ messageDiagnosticText(message) }}

+
加载失败:{{ workbench.error }}
发起对话,或从左侧选择 session。
@@ -190,6 +294,7 @@ function formatDuration(ms: number): string { + { if (!id) return; const response = await api.workbench.sessionMessages(id, { limit: 100 }); if (!response.ok || !response.data) { - applyProjectionDiagnostic(traceId, projectionDiagnosticFromFailure({ code: "message_projection_refresh_failed", message: response.error ?? "消息投影刷新失败,主消息正文保持上一份 canonical projection。", health: response.status === 0 ? "unavailable" : "degraded" })); + applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(response, { code: "message_projection_refresh_failed", message: response.error ?? "消息投影刷新失败,主消息正文保持上一份 canonical projection。", health: response.status === 0 ? "unavailable" : "degraded" })); return; } const pageMessages = Array.isArray(response.data.messages) ? response.data.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : []; @@ -466,7 +466,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (response.data.terminal === true || isTerminalMessageStatus(response.data.status)) completeTrace(id, response.data); return; } - applyProjectionDiagnostic(id, projectionDiagnosticFromFailure({ code: "turn_status_poll_failed", message: response.error ?? "状态更新失败,当前 turn 状态暂不可见。", health: response.status === 0 ? "unavailable" : "degraded" })); + applyProjectionDiagnostic(id, projectionDiagnosticFromApiFailure(response, { code: "turn_status_poll_failed", message: response.error ?? "状态更新失败,当前 turn 状态暂不可见。", health: response.status === 0 ? "unavailable" : "degraded" })); } function applyTurnStatusSnapshot(traceId: string, result: AgentChatResultResponse | TraceSnapshot): void { @@ -538,7 +538,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { const route = steerMode ? api.agent.steerAgentMessage : api.agent.sendAgentMessage; const response = await route(payload, codeAgentTimeoutMs.value, () => activityRef.value); if (!response.ok || !response.data) { - markMessage(traceId, { status: "failed", text: response.error ?? "Code Agent 请求失败" }); + const projection = projectionDiagnosticFromApiFailure(response, { code: "code_agent_admission_failed", message: response.error ?? "Code Agent 请求失败", health: response.status === 0 ? "unavailable" : "degraded" }); + const failureError = agentErrorFromApiFailure(response, projection, "Code Agent 请求失败"); + const failureMessage = agentErrorDisplayText(failureError) ?? "Code Agent 请求失败"; + markMessage(traceId, { status: "failed", text: failureMessage, error: failureError, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null }); + applyProjectionDiagnostic(traceId, projection); reduceServerState({ type: "turn.forget", traceId }); failWorkbenchSubmitJourney(traceId, response.status === 0 ? "network" : "error"); await clearActiveTrace(traceId, steerMode && response.status === 404 ? "steer-trace-not-found" : "submit-not-admitted"); @@ -612,7 +616,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { for (let page = 0; page < TRACE_HYDRATION_MAX_PAGES; page += 1) { const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq); if (!result.ok || !result.data) { - applyProjectionDiagnostic(traceId, projectionDiagnosticFromFailure({ code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" })); + applyProjectionDiagnostic(traceId, projectionDiagnosticFromApiFailure(result, { code: "trace_hydration_failed", message: result.error ?? "Trace 更新超时,运行记录暂不可见。", health: result.status === 0 ? "unavailable" : "degraded" })); return; } applyTraceHydrationResult(traceId, result.data); @@ -1081,7 +1085,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { const traceId = firstNonEmptyString(event.traceId, realtimeTraceId()); if (!traceId) return; const error = recordValue(event.error) ?? event; - applyProjectionDiagnostic(traceId, projectionDiagnosticFromFailure({ code: firstNonEmptyString(error.code, "workbench_realtime_error") ?? "workbench_realtime_error", message: firstNonEmptyString(error.message, error.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。", health: "degraded" })); + const projection = normalizeProjectionDiagnostic(event) ?? projectionDiagnosticFromFailure({ code: firstNonEmptyString(error.code, "workbench_realtime_error") ?? "workbench_realtime_error", message: firstNonEmptyString(error.message, error.summary, "Workbench 实时状态更新异常,最新运行记录暂不可见。") ?? "Workbench 实时状态更新异常,最新运行记录暂不可见。", health: "degraded", diagnostic: normalizeErrorDiagnostic(error.diagnostic, recordValue(event.projection)?.diagnostic, recordValue(event)?.diagnostic), apiError: normalizeApiErrorRecord(error, "Workbench 实时状态更新异常,最新运行记录暂不可见。") }); + applyProjectionDiagnostic(traceId, projection.projectionStatus ? projection : { ...projection, projectionStatus: "blocked", projectionHealth: projection.projectionHealth ?? "degraded" }); } function applyProjectionDiagnostic(traceId: string, projection: ProjectionDiagnostic): void { @@ -1093,11 +1098,12 @@ export const useWorkbenchStore = defineStore("workbench", () => { const now = new Date().toISOString(); reduceServerState({ type: "turn.status", turn: { traceId, status, running, terminal: existing?.terminal === true, sessionId, threadId: firstNonEmptyString(message?.threadId, message?.runnerTrace?.threadId) ?? existing?.threadId ?? null, updatedAt: now, projection, loadedAt: now } }); if (sessionId) reduceServerState({ type: "session.status", session: { sessionId, status: activeSession.value?.status ?? status, updatedAt: now, lastTraceId: traceId, projection, loadedAt: now } }); + const projectionError = agentErrorFromProjection(projection); updateActiveMessages((source) => source.map((item) => { if (!shouldApplyTraceToMessage(item, traceId, sessionId)) return item; const runnerTrace = mergeRunnerTrace(item.runnerTrace, { ...(item.runnerTrace ?? {}), traceId, sessionId: sessionId ?? item.runnerTrace?.sessionId, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, staleMs: projection.staleMs ?? null, blocker: projection.blocker ?? null, events: item.runnerTrace?.events ?? [], eventCount: item.runnerTrace?.eventCount ?? item.runnerTrace?.events?.length ?? 0, eventSource: item.runnerTrace?.eventSource ?? "projection-diagnostic", updatedAt: now }); rememberTraceAuthority(runnerTrace); - return { ...item, runnerTrace, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null, updatedAt: now }; + return { ...item, runnerTrace, error: projectionError ? { ...(item.error ?? {}), ...projectionError } : item.error ?? null, projection, projectionStatus: projection.projectionStatus ?? null, projectionHealth: projection.projectionHealth ?? null, blocker: projection.blocker ?? null, updatedAt: now }; })); } @@ -1461,7 +1467,10 @@ function normalizeProjectionDiagnostic(value: unknown): ProjectionDiagnostic | n if (!record) return null; const nested = recordValue(record.projection); const source = nested ?? record; - const blocker = recordValue(source.blocker ?? record.blocker); + const rawBlocker = recordValue(source.blocker ?? record.blocker); + const apiError = normalizeApiErrorRecord(source.apiError ?? record.apiError, null); + const diagnostic = normalizeErrorDiagnostic(source.diagnostic, record.diagnostic, rawBlocker?.diagnostic, apiError?.diagnostic); + const blocker = normalizeProjectionBlocker(rawBlocker, { diagnostic, apiError }); const projectionStatus = firstNonEmptyString(source.projectionStatus, record.projectionStatus); const projectionHealth = firstNonEmptyString(source.projectionHealth, record.projectionHealth); const lastProjectedSeq = firstFiniteNumber(source.lastProjectedSeq, record.lastProjectedSeq); @@ -1469,11 +1478,132 @@ function normalizeProjectionDiagnostic(value: unknown): ProjectionDiagnostic | n const sourceRunId = firstNonEmptyString(source.sourceRunId, record.sourceRunId); const sourceCommandId = firstNonEmptyString(source.sourceCommandId, record.sourceCommandId); if (!projectionStatus && !projectionHealth && !blocker && lastProjectedSeq == null && staleMs == null && !sourceRunId && !sourceCommandId) return null; - return { ...source, projectionStatus, projectionHealth, lastProjectedSeq: lastProjectedSeq ?? null, staleMs: staleMs ?? null, sourceRunId: sourceRunId ?? null, sourceCommandId: sourceCommandId ?? null, blocker: blocker as ProjectionDiagnostic["blocker"] ?? null, valuesRedacted: source.valuesRedacted !== false } as ProjectionDiagnostic; + return { ...source, projectionStatus, projectionHealth, lastProjectedSeq: lastProjectedSeq ?? null, staleMs: staleMs ?? null, sourceRunId: sourceRunId ?? null, sourceCommandId: sourceCommandId ?? null, blocker, diagnostic, apiError, valuesRedacted: source.valuesRedacted !== false } as ProjectionDiagnostic; } -function projectionDiagnosticFromFailure(input: { code: string; message: string; health: string }): ProjectionDiagnostic { - return { projectionStatus: "blocked", projectionHealth: input.health, blocker: { code: input.code, message: input.message, category: "workbench-projection", retryable: true }, valuesRedacted: true }; +function projectionDiagnosticFromApiFailure(result: ApiResult, input: { code: string; message: string; health: string }): ProjectionDiagnostic { + return projectionDiagnosticFromFailure({ ...input, status: result.status, apiError: result.apiError, diagnostic: result.diagnostic }); +} + +function projectionDiagnosticFromFailure(input: { code: string; message: string; health: string; status?: number | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; route?: string | null; source?: string | null }): ProjectionDiagnostic { + const apiError = input.apiError ?? null; + const diagnostic = normalizeErrorDiagnostic(input.diagnostic, apiError?.diagnostic, { + contractVersion: "hwlab-error-diagnostic-v1", + traceId: apiError?.traceId ?? null, + requestId: apiError?.requestId ?? null, + route: firstNonEmptyString(apiError?.route, input.route), + layer: apiError?.layer ?? "workbench", + category: apiError?.category ?? "workbench-projection", + code: firstStringOrNumber(apiError?.code, input.code), + httpStatus: input.status ?? null, + source: firstNonEmptyString(apiError?.source, input.source, "browser"), + retryable: apiError?.retryable ?? true, + valuesPrinted: false + }); + const message = firstNonEmptyString(apiError?.userMessage, apiError?.message, input.message) ?? input.message; + const blocker = normalizeProjectionBlocker({ + code: firstStringOrNumber(apiError?.code, diagnostic?.code, input.code), + message, + userMessage: firstNonEmptyString(apiError?.userMessage, message), + layer: firstNonEmptyString(apiError?.layer, diagnostic?.layer, "workbench"), + category: firstNonEmptyString(apiError?.category, diagnostic?.category, "workbench-projection"), + retryable: firstBoolean(apiError?.retryable, diagnostic?.retryable, true), + route: firstNonEmptyString(apiError?.route, diagnostic?.route, input.route), + traceId: firstNonEmptyString(apiError?.traceId, diagnostic?.traceId), + requestId: firstNonEmptyString(apiError?.requestId, diagnostic?.requestId), + source: firstNonEmptyString(apiError?.source, diagnostic?.source, input.source), + httpStatus: firstFiniteNumber(diagnostic?.httpStatus, input.status), + diagnostic, + valuesPrinted: false + }, { diagnostic, apiError, fallbackCode: input.code, fallbackMessage: message }); + return { projectionStatus: "blocked", projectionHealth: input.health, blocker, diagnostic, apiError, valuesRedacted: true }; +} + +function normalizeProjectionBlocker(value: unknown, context: { diagnostic?: ErrorDiagnostic | null; apiError?: ApiError | null; fallbackCode?: string | null; fallbackMessage?: string | null } = {}): ProjectionBlocker | null { + const record = recordValue(value); + if (!record && !context.diagnostic && !context.apiError && !context.fallbackMessage && !context.fallbackCode) return null; + const source = record ?? {}; + const diagnostic = context.diagnostic ?? normalizeErrorDiagnostic(source.diagnostic, context.apiError?.diagnostic); + const code = firstStringOrNumber(source.code, context.apiError?.code, diagnostic?.code, context.fallbackCode); + const message = firstNonEmptyString(source.message, source.userMessage, source.summary, context.apiError?.userMessage, context.apiError?.message, context.fallbackMessage); + const retryable = firstBoolean(source.retryable, context.apiError?.retryable, diagnostic?.retryable); + return { + ...source, + code: code === null ? undefined : String(code), + message: message ?? null, + userMessage: firstNonEmptyString(source.userMessage, context.apiError?.userMessage, message), + summary: firstNonEmptyString(source.summary), + layer: firstNonEmptyString(source.layer, context.apiError?.layer, diagnostic?.layer), + category: firstNonEmptyString(source.category, context.apiError?.category, diagnostic?.category, "workbench-projection"), + retryable: retryable ?? true, + route: firstNonEmptyString(source.route, context.apiError?.route, diagnostic?.route), + traceId: firstNonEmptyString(source.traceId, context.apiError?.traceId, diagnostic?.traceId), + requestId: firstNonEmptyString(source.requestId, context.apiError?.requestId, diagnostic?.requestId), + source: firstNonEmptyString(source.source, context.apiError?.source, diagnostic?.source), + httpStatus: firstFiniteNumber(source.httpStatus, source.http_status, diagnostic?.httpStatus) ?? null, + diagnostic, + valuesPrinted: source.valuesPrinted === true + } as ProjectionBlocker; +} + +function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null { + for (const value of values) { + const record = recordValue(value); + if (!record) continue; + const httpStatus = firstFiniteNumber(record.httpStatus, record.http_status); + return { + ...record, + contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"), + traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id), + requestId: firstNonEmptyString(record.requestId, record.request_id), + serviceId: firstNonEmptyString(record.serviceId, record.service_id), + route: firstNonEmptyString(record.route), + layer: firstNonEmptyString(record.layer), + category: firstNonEmptyString(record.category), + code: firstStringOrNumber(record.code), + httpStatus: httpStatus ?? null, + source: firstNonEmptyString(record.source), + observedAt: firstNonEmptyString(record.observedAt, record.observed_at), + retryable: firstBoolean(record.retryable), + valuesPrinted: record.valuesPrinted === true + }; + } + return null; +} + +function normalizeApiErrorRecord(value: unknown, fallback: string | null = null, diagnostic: ErrorDiagnostic | null = null): ApiError | null { + const record = recordValue(value); + if (!record) return null; + const normalizedDiagnostic = normalizeErrorDiagnostic(record.diagnostic, diagnostic); + const message = firstNonEmptyString(record.message, record.userMessage, record.reason, fallback); + return { + ...record, + message: message ?? fallback ?? "Workbench 请求失败", + userMessage: firstNonEmptyString(record.userMessage, record.message, fallback), + code: firstStringOrNumber(record.code, normalizedDiagnostic?.code), + reason: firstNonEmptyString(record.reason), + retryable: firstBoolean(record.retryable, normalizedDiagnostic?.retryable), + layer: firstNonEmptyString(record.layer, normalizedDiagnostic?.layer), + category: firstNonEmptyString(record.category, normalizedDiagnostic?.category), + route: firstNonEmptyString(record.route, normalizedDiagnostic?.route), + traceId: firstNonEmptyString(record.traceId, normalizedDiagnostic?.traceId), + requestId: firstNonEmptyString(record.requestId, normalizedDiagnostic?.requestId), + source: firstNonEmptyString(record.source, normalizedDiagnostic?.source), + diagnostic: normalizedDiagnostic, + valuesPrinted: record.valuesPrinted === true + } as ApiError; +} + +function agentErrorFromApiFailure(result: ApiResult, projection: ProjectionDiagnostic, fallback: string): ChatMessage["error"] { + return normalizeAgentError(result.apiError ?? projection.apiError ?? projection.blocker ?? { message: result.error ?? fallback, diagnostic: result.diagnostic ?? projection.diagnostic }) ?? { message: result.error ?? fallback, diagnostic: result.diagnostic ?? projection.diagnostic }; +} + +function agentErrorFromProjection(projection: ProjectionDiagnostic): ChatMessage["error"] | null { + const blocker = projection.blocker ?? null; + const diagnostic = projection.diagnostic ?? blocker?.diagnostic ?? projection.apiError?.diagnostic ?? null; + const message = firstNonEmptyString(blocker?.userMessage, blocker?.message, blocker?.summary, projection.apiError?.userMessage, projection.apiError?.message, diagnostic?.code ? String(diagnostic.code) : null); + if (!blocker && !projection.apiError && !diagnostic && !message) return null; + return normalizeAgentError({ ...(projection.apiError ?? {}), ...(blocker ?? {}), message, code: firstStringOrNumber(blocker?.code, projection.apiError?.code, diagnostic?.code), diagnostic, traceId: firstNonEmptyString(blocker?.traceId, projection.apiError?.traceId, diagnostic?.traceId), requestId: firstNonEmptyString(blocker?.requestId, projection.apiError?.requestId, diagnostic?.requestId) }); } function messageNeedsTerminalDiagnostics(message: ChatMessage): boolean { @@ -1518,11 +1648,12 @@ function normalizeAgentError(value: unknown): ChatMessage["error"] | null { if (typeof value === "string") return value.trim() ? { message: value.trim() } : null; if (value && typeof value === "object") { const record = value as Record; + const diagnostic = normalizeErrorDiagnostic(record.diagnostic, recordValue(record.apiError)?.diagnostic, recordValue(record.error)?.diagnostic); const message = firstNonEmptyString(record.message, record.userMessage, record.reason, record.detail, record.error); - const code = firstNonEmptyString(record.code, record.failureKind, record.name); - const category = firstNonEmptyString(record.category, record.layer, record.type); + const code = firstStringOrNumber(record.code, record.failureKind, record.name, diagnostic?.code); + const category = firstNonEmptyString(record.category, diagnostic?.category, record.layer, record.type); const providerStatus = typeof record.providerStatus === "number" ? record.providerStatus : Number.isFinite(Number(record.providerStatus)) ? Number(record.providerStatus) : undefined; - return { ...record, code: code ?? undefined, message: message ?? undefined, category: category ?? undefined, providerStatus }; + return { ...record, code: code ?? undefined, message: message ?? undefined, userMessage: firstNonEmptyString(record.userMessage, message), category: category ?? undefined, layer: firstNonEmptyString(record.layer, diagnostic?.layer), route: firstNonEmptyString(record.route, diagnostic?.route), traceId: firstNonEmptyString(record.traceId, diagnostic?.traceId), requestId: firstNonEmptyString(record.requestId, diagnostic?.requestId), source: firstNonEmptyString(record.source, diagnostic?.source), retryable: firstBoolean(record.retryable, diagnostic?.retryable), diagnostic, providerStatus }; } return { message: String(value) }; } @@ -1569,6 +1700,16 @@ function firstFiniteNumber(...values: unknown[]): number | undefined { return undefined; } +function firstStringOrNumber(...values: unknown[]): string | number | null { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") continue; + const text = value.trim(); + if (text) return text; + } + return null; +} + function firstBoolean(...values: unknown[]): boolean | undefined { for (const value of values) { if (typeof value === "boolean") return value; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index f9b66598..279b44d3 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -135,6 +135,12 @@ export interface ProjectionBlocker { layer?: string | null; category?: string | null; retryable?: boolean | null; + route?: string | null; + traceId?: string | null; + requestId?: string | null; + source?: "server" | "browser" | string | null; + httpStatus?: number | null; + diagnostic?: ErrorDiagnostic | null; runId?: string | null; commandId?: string | null; timeoutMs?: number | null; @@ -149,6 +155,8 @@ export interface ProjectionDiagnostic { sourceCommandId?: string | null; staleMs?: number | null; blocker?: ProjectionBlocker | null; + diagnostic?: ErrorDiagnostic | null; + apiError?: ApiError | null; updatedAt?: string | null; valuesRedacted?: boolean; [key: string]: unknown; @@ -265,7 +273,7 @@ export interface ChatMessage { lastEventAt?: string | null; finishedAt?: string | null; durationMs?: number | null; - error?: { code?: string; message?: string; category?: string; providerStatus?: number; [key: string]: unknown } | null; + error?: { code?: string | number | null; message?: string; userMessage?: string | null; category?: string | null; layer?: string | null; providerStatus?: number; retryable?: boolean | null; route?: string | null; traceId?: string | null; requestId?: string | null; source?: "server" | "browser" | string | null; diagnostic?: ErrorDiagnostic | null; [key: string]: unknown } | null; traceAutoLifecycle?: "running" | "terminal" | null; projection?: ProjectionDiagnostic | null; projectionStatus?: string | null; diff --git a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts index 01ed204d..c19503ed 100644 --- a/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts +++ b/web/hwlab-cloud-web/tests/workbench-e2e/specs/projection-diagnostics.spec.ts @@ -13,6 +13,11 @@ test.describe("projection diagnostics visibility", () => { await expect(page.locator(sessionTab("ses_projection_degraded"))).toHaveAttribute("data-projection-health", "degraded"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last(); await expect(card).toContainText("状态更新超时"); + await expect(card.locator(".projection-diagnostic")).toContainText("agentrun_result_timeout"); + await expect(card.locator(".projection-diagnostic")).toContainText("trace_id"); + await expect(card.locator(".projection-diagnostic")).toContainText("trc_projection_degraded"); + await expect(card.locator(".projection-diagnostic")).toContainText("valuesPrinted"); + await expect(card.locator(".projection-diagnostic")).toContainText("false"); await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded"); await expect(card.locator(".trace-empty")).toContainText("状态更新超时"); await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel"); @@ -27,6 +32,9 @@ test.describe("SSE projection errors", () => { await gotoWorkbench(page, "/workbench/sessions/ses_projection_sse_error"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last(); await expect(card).toContainText("SSE 投影事件异常"); + await expect(card.locator(".projection-diagnostic")).toContainText("workbench_sse_projection_error"); + await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_workbench_sse_projection_error"); + await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/events"); await expect(page.locator(sessionTab("ses_projection_sse_error"))).toHaveAttribute("data-projection-health", "degraded"); await saveScreenshot(page, testInfo, "projection-sse-error-visible"); }); @@ -39,6 +47,10 @@ test.describe("REST gap hydration errors", () => { await gotoWorkbench(page, "/workbench/sessions/ses_trace_hydration_timeout"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="running"]`).last(); await expect(card).toContainText("Trace 更新超时"); + await expect(card.locator(".projection-diagnostic")).toContainText("trace_hydration_timeout"); + await expect(card.locator(".projection-diagnostic")).toContainText("33333333333333333333333333333333"); + await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_trace_hydration_timeout"); + await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_trace_hydration_timeout/events"); await expect(card).toHaveAttribute("data-status", "running"); await expect(card.locator(selectors.traceTimeline)).toHaveAttribute("data-projection-health", "degraded"); await expect(page.locator(selectors.commandSend)).toHaveAttribute("data-action", "cancel"); @@ -53,6 +65,8 @@ test.describe("sealed final response diagnostics separation", () => { await gotoWorkbench(page, "/workbench/sessions/ses_sealed_final_diag"); const card = page.locator(`${selectors.messageCard}[data-role="agent"][data-status="completed"]`).last(); await expect(card.locator(".projection-diagnostic")).toContainText("Trace 更新超时"); + await expect(card.locator(".projection-diagnostic")).toContainText("req_e2e_sealed_final_trace_timeout"); + await expect(card.locator(".projection-diagnostic")).toContainText("/v1/workbench/traces/trc_sealed_final_diag/events"); const finalResponse = card.locator(".message-text").filter({ hasText: "已完成的 sealed final response 不应被诊断覆盖。" }); await expect(finalResponse).toBeVisible(); await expect(finalResponse).not.toContainText("Trace 更新超时");