diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 53d5363a..157916f1 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -22,6 +22,7 @@ const requiredFiles = Object.freeze([ "src/api/workbench.ts", "src/api/workbench-events.ts", "src/api/agent.ts", + "src/config/workbench-runtime-policy.ts", "src/utils/workbench-key.ts", "src/utils/scoped-cache.ts", "src/utils/scheduler/async-queue.ts", @@ -78,8 +79,10 @@ const html = readWeb("index.html"); const pkg = JSON.parse(readWeb("package.json")) as { dependencies?: Record; devDependencies?: Record }; const appSource = readCloudWebAppSource(rootDir); const workbenchStoreSource = readWeb("src/stores/workbench.ts"); +const workbenchRuntimePolicySource = readWeb("src/config/workbench-runtime-policy.ts"); const workbenchRealtimeRuntimeSource = `${readWeb("src/utils/workbench-realtime-runtime.ts")}\n${readWeb("src/utils/workbench-stream-transport.ts")}`; const workbenchRefreshRuntimeSource = readWeb("src/utils/workbench-refresh-runtime.ts"); +const workbenchPerformanceSource = readWeb("src/utils/workbench-performance.ts"); const workbenchEventReducerSource = readWeb("src/stores/workbench-event-reducer.ts"); const workbenchTimelineRuntimeSource = readWeb("src/stores/workbench-timeline-model.ts"); const workbenchScrollRuntimeSource = readWeb("src/composables/useWorkbenchScrollRuntime.ts"); @@ -125,6 +128,9 @@ assertIncludes(appSource, "useForm", "form composable must remain available for assertIncludes(appSource, "hwlab_session", "auth comments/code must preserve Web session cookie boundary"); assertIncludes(appSource, "activityRef", "Code Agent inactivity-timeout activityRef must be preserved"); assertIncludes(workbenchStoreSource, "createWorkbenchStreamTransportRuntime", "Workbench store must enter SSE through the realtime runtime boundary"); +assertIncludes(workbenchStoreSource, "workbenchRuntimePolicy", "Workbench store must consume runtime tunables through workbenchRuntimePolicy"); +assertIncludes(workbenchRuntimePolicySource, "runtimePolicyConfig", "Workbench runtime policy must read the injected YAML-backed web config"); +assert.doesNotMatch(workbenchStoreSource, /const\s+(?:DEFAULT_CODE_AGENT_TIMEOUT_MS|TRACE_HYDRATION_PAGE_LIMIT|SESSION_LIST_PAGE_LIMIT|WORKBENCH_[A-Z0-9_]+)\s*=\s*[0-9_]+/u, "Workbench store must not own hardcoded runtime policy numbers"); assertIncludes(workbenchRealtimeRuntimeSource, "connectWorkbenchEvents", "Workbench realtime runtime must own the unified SSE EventSource entry"); assertIncludes(workbenchRealtimeRuntimeSource, "WorkbenchStreamTransportRecovery", "SSE transport must own recovery actions, not just wrap EventSource"); assertIncludes(workbenchRealtimeRuntimeSource, "cursorByKey", "SSE transport runtime must own cursor replay state"); @@ -142,6 +148,9 @@ assertIncludes(workbenchScrollRuntimeSource, "createWorkbenchScrollPersistenceRu assertIncludes(conversationPanelSource, "sessionKey: scrollSessionKey", "ConversationPanel must use session-scoped scroll persistence in production"); assertIncludes(workbenchErrorRuntimeSource, "formatServerError", "Error runtime must preserve OpenCode readable server error formatting"); assertIncludes(workbenchErrorRuntimeSource, "projectionDiagnosticFromFailure", "Error runtime must own projection diagnostic envelopes"); +assertIncludes(workbenchErrorRuntimeSource, "messageDiagnosticView", "Error runtime must own Workbench message diagnostic view models"); +assertIncludes(conversationPanelSource, "messageDiagnosticView", "ConversationPanel must consume message diagnostics from ErrorRuntime"); +assert.doesNotMatch(conversationPanelSource, /function\s+(?:messageApiError|messageErrorDiagnostic|projectionDiagnosticText|normalizeErrorDiagnostic)\b/u, "ConversationPanel must not rebuild Workbench diagnostics locally"); assertIncludes(workbenchHealthRuntimeSource, "createScopedCache", "ScopedCache must be used by a production Workbench runtime, not only by tests/checks"); assert.doesNotMatch(workbenchStoreSource, /function (?:normalizeProjectionDiagnostic|projectionDiagnosticFromApiFailure|projectionDiagnosticFromFailure|normalizeProjectionBlocker|normalizeErrorDiagnostic|normalizeApiErrorRecord|agentErrorFromProjection)\b/u, "Workbench store must call ErrorRuntime directly instead of reintroducing diagnostic wrapper ownership"); assertIncludes(workbenchStoreSource, "cleanupDroppedWorkbenchSessionCaches", "Workbench store must run OpenCode-style dropped session cache cleanup after trim"); @@ -152,7 +161,11 @@ assert.doesNotMatch(workbenchStoreSource, /scheduleRealtimeGapHydration|hydrateR assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling"); assertIncludes(workbenchRefreshRuntimeSource, "if (existing)", "Scheduled refresh runtime must coalesce instead of resetting timers under SSE error storms"); assertIncludes(workbenchRefreshRuntimeSource, "replaceTimer", "Scheduled refresh runtime must make replacement explicit instead of ad hoc timer resets"); -assertIncludes(workbenchStoreSource, "if (activeId) scheduleSessionListRefresh(activeId, SESSION_LIST_REALTIME_REFRESH_DELAY_MS);", "Realtime stream errors must schedule bounded session list refreshes"); +assertIncludes(workbenchPerformanceSource, "recordWorkbenchRuntimeDiagnostic", "Workbench performance probe must record runtime diagnostics for monitor root cause visibility"); +assertIncludes(workbenchStoreSource, "recordWorkbenchRuntimeDiagnostic", "Workbench store must surface SSE recovery diagnostics to the performance probe"); +assertIncludes(workbenchStoreSource, "new Set(recovery.actions)", "Realtime recovery must consume transport-owned actions explicitly"); +assertIncludes(workbenchStoreSource, "actions.has(\"schedule-session-list\")", "Realtime stream errors must schedule bounded session list refreshes only when transport requests that action"); +assertIncludes(workbenchStoreSource, "runtimePolicy.sessionListRealtimeRefreshDelayMs", "Realtime recovery delay must come from runtime policy instead of store constants"); assert.doesNotMatch(workbenchStoreSource, /handleRealtimeStreamError[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Realtime stream errors must not force-refresh the full session list"); assert.doesNotMatch(workbenchStoreSource, /refreshActiveTraceFromRest[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Active trace REST gap-fill must not force-refresh the full session list"); assert.doesNotMatch(workbenchStoreSource, /refreshTerminalTraceFromRest[\s\S]{0,1200}refreshSessions\([^;]+force:\s*true/u, "Terminal trace REST refresh must not force-refresh the full session list"); diff --git a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts index 61156b77..275c4d63 100644 --- a/web/hwlab-cloud-web/scripts/workbench-performance.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-performance.test.ts @@ -14,6 +14,7 @@ import { markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, + recordWorkbenchRuntimeDiagnostic, resetWorkbenchPerformanceForTest, startWorkbenchOpenJourney, startWorkbenchSessionSwitch, @@ -64,6 +65,39 @@ test("Workbench submit failures do not pollute first visible output SLI", () => assert.ok(events.some((event) => event.kind === "workbench_journey" && event.journey === "submit_to_failure" && event.outcome === "network")); }); +test("Workbench runtime diagnostics expose root cause labels without raw identifiers", () => { + resetWorkbenchPerformanceForTest(); + const restoreBrowserRuntime = installBrowserProbeRuntime(); + try { + recordWorkbenchRuntimeDiagnostic({ + module: "workbench-stream-transport", + sessionId: "ses_secret", + traceId: "trc_secret", + outcome: "network", + diagnostic: { + code: "workbench_sse_recovery", + rootCause: "eventsource-error", + recoveryAction: "refresh-session-messages,schedule-session-list", + transportState: "degraded", + scopedKey: "workbench.realtime|ses_secret|trc_secret" + } + }); + + const events = drainWorkbenchPerformanceEventsForTest(); + const event = events.find((item) => item.kind === "workbench_ui_event" && item.eventType === "runtime_diagnostic"); + assert.equal(event?.module, "workbench-stream-transport"); + assert.equal(event?.diagnosticCode, "workbench_sse_recovery"); + assert.equal(event?.rootCause, "eventsource-error"); + assert.equal(event?.recoveryAction, "refresh-session-messages,schedule-session-list"); + assert.equal(event?.transportState, "degraded"); + assert.match(event?.scopedKey ?? "", /^scope_[0-9a-f]{8}$/u); + assert.doesNotMatch(JSON.stringify(events), /ses_secret|trc_secret|workbench\.realtime\|/u); + } finally { + resetWorkbenchPerformanceForTest(); + restoreBrowserRuntime(); + } +}); + test("Workbench session switch treats visible empty sessions as successful first paint", () => { resetWorkbenchPerformanceForTest(); startWorkbenchSessionSwitch({ sessionId: "ses_empty_visible", source: "rail", targetState: "empty", cache: "cold" }); @@ -133,3 +167,35 @@ function traceOnlyAgentMessage(sessionId: string, traceId: string, events: Trace runnerTrace: { traceId, sessionId, eventCount: events.length, events } }; } + +function installBrowserProbeRuntime(): () => void { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + location: { pathname: "/workbench", hash: "" }, + crypto: globalThis.crypto, + addEventListener: () => undefined + } + }); + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + visibilityState: "visible", + addEventListener: () => undefined + } + }); + return () => { + restoreGlobalProperty("window", previousWindow); + restoreGlobalProperty("document", previousDocument); + }; +} + +function restoreGlobalProperty(key: "window" | "document", descriptor: PropertyDescriptor | undefined): void { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + return; + } + Reflect.deleteProperty(globalThis, key); +} diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index 591a66c2..ae501925 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -4,6 +4,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import type { ChatMessage } from "../src/types/index.ts"; +import { workbenchRuntimePolicy } from "../src/config/workbench-runtime-policy.ts"; import { AsyncQueue, work } from "../src/utils/scheduler/async-queue.ts"; import { createCoalescedEventQueue } from "../src/utils/scheduler/coalesced-event-queue.ts"; import { createKeyedSingleflight } from "../src/utils/scheduler/keyed-singleflight.ts"; @@ -11,6 +13,7 @@ import { createScopedCache } from "../src/utils/scoped-cache.ts"; import { composeWorkbenchScopedKey, splitWorkbenchScopedKey, workbenchPathKey, workbenchRealtimeScopeKey } from "../src/utils/workbench-key.ts"; import { createSafeStorageRuntime, isStorageQuotaError, migrateLegacyStorage, normalizePersistedValue, readJsonStorage, removePersistedTarget, removeStorageKey, writeJsonStorage, type StorageLike } from "../src/utils/safe-storage.ts"; import { checkWorkbenchHealth, createWorkbenchHealthProbeCache } from "../src/utils/workbench-health.ts"; +import { messageDiagnosticView } from "../src/utils/workbench-error-runtime.ts"; import { buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts"; import { reduceWorkbenchRealtimeEvent } from "../src/stores/workbench-event-reducer.ts"; import { cleanupWorkbenchServerStateDroppedSessions, cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState } from "../src/stores/workbench-server-state.ts"; @@ -23,6 +26,42 @@ test("Workbench scoped keys encode delimiter characters", () => { assert.equal(workbenchPathKey("C:\\Users\\foo\\"), "C:/Users/foo"); }); +test("Workbench runtime policy reads injected config while preserving defaults", () => { + const policy = workbenchRuntimePolicy({ + sessionListPageLimit: 7, + traceHydrationMaxPages: 2, + workbenchRealtimeErrorGapFillMinMs: 0, + defaultGatewayTimeoutMs: "bad" + }); + + assert.equal(policy.sessionListPageLimit, 7); + assert.equal(policy.traceHydrationMaxPages, 2); + assert.equal(policy.workbenchRealtimeErrorGapFillMinMs, 0); + assert.equal(policy.defaultGatewayTimeoutMs, 120_000); +}); + +test("Error runtime owns Workbench message diagnostic view model", () => { + const degraded = messageDiagnosticView(agentMessage({ + status: "running", + text: "", + projection: { + projectionHealth: "degraded", + blocker: { code: "workbench_facts_missing", userMessage: "read model unavailable", traceId: "trc_1" } + } + })); + assert.equal(degraded.visible, true); + assert.equal(degraded.text, "read model unavailable"); + assert.equal(degraded.apiError?.code, "workbench_facts_missing"); + assert.equal(degraded.diagnostic?.traceId, "trc_1"); + + const sealed = messageDiagnosticView(agentMessage({ + status: "completed", + text: "done", + projection: { projectionHealth: "degraded", blocker: { code: "stale" } } + })); + assert.equal(sealed.visible, false); +}); + test("scoped cache keeps OpenCode LRU and TTL semantics", () => { const disposed: string[] = []; let clock = 0; @@ -302,3 +341,16 @@ class MemoryStorage implements StorageLike { } } +function agentMessage(overrides: Partial): ChatMessage { + return { + id: "msg_agent", + role: "agent", + title: "Code Agent", + text: "", + status: "running", + createdAt: "2026-06-30T00:00:00.000Z", + traceId: "trc_1", + ...overrides + } as ChatMessage; +} + diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 6bd14225..8baf94b7 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -3,7 +3,7 @@ // 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 { ApiError, ChatMessage, ErrorDiagnostic } from "@/types"; +import type { ChatMessage } from "@/types"; import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue"; import LoadingState from "@/components/common/LoadingState.vue"; import StatusBadge from "@/components/common/StatusBadge.vue"; @@ -18,7 +18,7 @@ import { useClipboard } from "@/composables/useClipboard"; import { useWorkbenchNowTicker } from "@/composables/useWorkbenchNowTicker"; import { useWorkbenchStore } from "@/stores/workbench"; import { buildWorkbenchTimelineRows, workbenchTimelineSignature } from "@/stores/workbench-timeline-model"; -import { normalizeErrorDiagnostic as normalizeWorkbenchErrorDiagnostic } from "@/utils/workbench-error-runtime"; +import { messageDiagnosticView } from "@/utils/workbench-error-runtime"; import { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance"; import { traceIdentityText } from "./message-rendering"; import { traceLifecycleExpanded } from "./trace-lifecycle"; @@ -66,15 +66,6 @@ function isTerminalAgentMessage(message: ChatMessage): boolean { return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-")); } -function isCompletedAgentMessage(message: ChatMessage): boolean { - if (message.role !== "agent") return false; - return String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-") === "completed"; -} - -function hasSealedCompletedText(message: ChatMessage): boolean { - return isCompletedAgentMessage(message) && Boolean(visibleMessageText(message)); -} - function visibleMessageText(message: ChatMessage): string { const text = String(message.text ?? "").trim(); if (message.role === "agent" && !isTerminalAgentMessage(message)) return ""; @@ -93,113 +84,12 @@ function showMessageText(message: ChatMessage): boolean { return Boolean(visibleMessageText(message)); } -function messageDiagnosticText(message: ChatMessage): string | null { - if (message.role === "agent") return projectionDiagnosticText(message); - if (message.role !== "user" || String(message.text ?? "").trim()) return null; - return "消息内容缺失:Workbench read model 未返回 user text。"; -} - function messageHasDiagnostic(message: ChatMessage): boolean { - if (hasSealedCompletedText(message)) return false; - return Boolean(messageDiagnosticText(message) || messageApiError(message) || messageErrorDiagnostic(message)); + return diagnosticFor(message).visible; } -function projectionDiagnosticText(message: ChatMessage): string | null { - if (hasSealedCompletedText(message)) return 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 { - if (hasSealedCompletedText(message)) return 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 { - if (hasSealedCompletedText(message)) return 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 { - return normalizeWorkbenchErrorDiagnostic(...values); -} - -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; - const text = value.trim(); - if (text) return text; - } - 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 diagnosticFor(message: ChatMessage) { + return messageDiagnosticView(message); } function acknowledgeVisibleMessages(): void { @@ -315,7 +205,7 @@ function formatDuration(ms: number): string { - +
加载失败:{{ workbench.error }}
@@ -330,7 +220,7 @@ function formatDuration(ms: number): string { - + | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function positiveInteger(value: unknown, fallback: number): number { + const number = positiveNumber(value, fallback); + return Math.max(1, Math.trunc(number)); +} + +function positiveNumber(value: unknown, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} + +function nonNegativeNumber(value: unknown, fallback: number): number { + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : fallback; +} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 93403bbe..358f7d4c 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,6 +4,7 @@ import { computed, nextTick, ref } from "vue"; import { defineStore } from "pinia"; import { api } from "@/api"; +import { workbenchRuntimePolicy } from "@/config/workbench-runtime-policy"; import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health"; import { agentErrorFromProjection, normalizeApiErrorRecord, normalizeErrorDiagnostic, normalizeProjectionBlocker, normalizeProjectionDiagnostic, projectionDiagnosticFromApiFailure, projectionDiagnosticFromFailure } from "@/utils/workbench-error-runtime"; import { createWorkbenchReadHydrationRuntime, createWorkbenchScheduledTaskRuntime, createWorkbenchTraceHydrationQueueRuntime, shouldCooldownWorkbenchReadFailure as shouldCooldownWorkbenchReadRuntimeFailure } from "@/utils/workbench-refresh-runtime"; @@ -13,38 +14,15 @@ import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/c import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, ApiError, ApiResult, ChatMessage, ErrorDiagnostic, LiveSurface, ProjectionBlocker, ProjectionDiagnostic, ProviderProfile, TraceEvent, WorkbenchSessionRecord, WorkbenchTurnTimingProjection } from "@/types"; import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils"; import { composeWorkbenchScopedKey } from "@/utils/workbench-key"; -import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance"; +import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbenchSessionSwitchFullLoad, markWorkbenchSubmitApiAccepted, markWorkbenchTraceEventsReceived, markWorkbenchTraceProjected, recordWorkbenchLoadingState, recordWorkbenchRuntimeDiagnostic, startWorkbenchSessionSwitch, startWorkbenchSubmitJourney } from "@/utils/workbench-performance"; import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection"; import { cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache"; import { reduceWorkbenchRealtimeEvent, type WorkbenchRealtimeAction } from "./workbench-event-reducer"; -const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; -const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; -const TRACE_HYDRATION_PAGE_LIMIT = 50; -const TRACE_HYDRATION_MAX_PAGES = 4; -const TRACE_HYDRATION_MAX_ATTEMPTS = 3; -const TRACE_HYDRATION_RETRY_DELAY_MS = 700; -const TRACE_HYDRATION_AUTO_QUEUE_LIMIT = 4; -const TRACE_HYDRATION_BACKGROUND_CONCURRENCY = 1; -const TRACE_HYDRATION_BACKGROUND_DELAY_MS = 400; -const SESSION_LIST_PAGE_LIMIT = 20; -const WORKBENCH_READ_HYDRATION_CONCURRENCY = 3; -const WORKBENCH_READ_FAILURE_COOLDOWN_MS = 5_000; -const WORKBENCH_TURN_STATUS_MIN_REFRESH_MS = 2_000; -const WORKBENCH_TRACE_EVENTS_MIN_REFRESH_MS = 4_000; -const WORKBENCH_SESSION_MESSAGES_MIN_REFRESH_MS = 5_000; -const WORKBENCH_REALTIME_SESSION_MESSAGES_MIN_REFRESH_MS = 1_000; const WORKBENCH_SESSION_PROJECTION_SIGNAL_CHANNEL = "hwlab.workbench.sessionProjection.v1"; const WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY = "hwlab.workbench.sessionProjectionSignal.v1"; -const WORKBENCH_TRACE_EVENTS_TIMEOUT_MS = 5_000; -const SESSION_LIST_REALTIME_REFRESH_DELAY_MS = 5_000; -const SESSION_LIST_TERMINAL_REFRESH_DELAY_MS = 1_500; -const SESSION_LIST_MIN_REFRESH_INTERVAL_MS = 15_000; -const WORKBENCH_REALTIME_ERROR_GAP_FILL_MIN_MS = 2_000; -const WORKBENCH_ACTIVE_TRACE_REST_GAP_FILL_INITIAL_MS = 2_500; -const WORKBENCH_ACTIVE_TRACE_REST_GAP_FILL_REPEAT_MS = 5_000; interface HydrateOptions { sessionId?: string | null; @@ -58,11 +36,12 @@ interface SelectSessionOptions { } export const useWorkbenchStore = defineStore("workbench", () => { + const runtimePolicy = workbenchRuntimePolicy(); const providerProfile = ref(readString("hwlab.workbench.providerProfile.v1", "codex")); const providerOptions = ref(defaultProviderProfileOptions(providerProfile.value)); const recentDrafts = ref(readRecentDrafts()); - const codeAgentTimeoutMs = ref(readNumber("hwlab.workbench.codeAgentTimeoutMs.v1", DEFAULT_CODE_AGENT_TIMEOUT_MS)); - const gatewayShellTimeoutMs = ref(readNumber("hwlab.workbench.gatewayShellTimeoutMs.v1", DEFAULT_GATEWAY_TIMEOUT_MS)); + const codeAgentTimeoutMs = ref(readNumber("hwlab.workbench.codeAgentTimeoutMs.v1", runtimePolicy.defaultCodeAgentTimeoutMs)); + const gatewayShellTimeoutMs = ref(readNumber("hwlab.workbench.gatewayShellTimeoutMs.v1", runtimePolicy.defaultGatewayTimeoutMs)); const live = ref(null); const loading = ref(false); const sessionsReady = ref(false); @@ -85,7 +64,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const turnStatusAuthority = computed(() => selectTurnStatusAuthority(serverState.value)); const traceAuthorityById = computed(() => selectTraceAuthorityById(serverState.value)); const realtimeTransport = createWorkbenchStreamTransportRuntime(); - const traceHydrationRuntime = createWorkbenchTraceHydrationQueueRuntime({ concurrency: TRACE_HYDRATION_BACKGROUND_CONCURRENCY, delayMs: TRACE_HYDRATION_BACKGROUND_DELAY_MS }); + const traceHydrationRuntime = createWorkbenchTraceHydrationQueueRuntime({ concurrency: runtimePolicy.traceHydrationBackgroundConcurrency, delayMs: runtimePolicy.traceHydrationBackgroundDelayMs }); const forcedTraceHydrationRetryRuntime = createWorkbenchScheduledTaskRuntime(); const terminalRealtimeRefreshRuntime = createWorkbenchScheduledTaskRuntime(); const realtimeSessionMessagesRuntime = createWorkbenchScheduledTaskRuntime(); @@ -94,8 +73,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { const sessionListRefreshRuntime = createWorkbenchScheduledTaskRuntime(); const workbenchProjectionSignalSourceId = nextProtocolId("wbtab"); let workbenchProjectionSignalChannel: BroadcastChannel | null = null; - const workbenchReadHydrationRuntime = createWorkbenchReadHydrationRuntime({ concurrency: WORKBENCH_READ_HYDRATION_CONCURRENCY, failureCooldownMs: WORKBENCH_READ_FAILURE_COOLDOWN_MS }); - const workbenchHealthProbeCache = createWorkbenchHealthProbeCache({ cacheMs: WORKBENCH_READ_FAILURE_COOLDOWN_MS }); + const workbenchReadHydrationRuntime = createWorkbenchReadHydrationRuntime({ concurrency: runtimePolicy.workbenchReadHydrationConcurrency, failureCooldownMs: runtimePolicy.workbenchReadFailureCooldownMs }); + const workbenchHealthProbeCache = createWorkbenchHealthProbeCache({ cacheMs: runtimePolicy.workbenchReadFailureCooldownMs }); const projectedActiveSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value)); const routeActiveSession = computed(() => routeSelectedSessionRecord(explicitSessionId.value, sessionDetailLoadingId.value, error.value, projectedActiveSession.value)); @@ -133,7 +112,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { recordWorkbenchLoadingState({ scope: "session_detail", active: true, reason: "hydrate", sessionId: routeSessionId }); setActiveSessionSelection(routeSessionId, "route"); } - const sessionsResult = await api.workbench.sessions({ includeSessionId, limit: SESSION_LIST_PAGE_LIMIT }); + const sessionsResult = await api.workbench.sessions({ includeSessionId, limit: runtimePolicy.sessionListPageLimit }); const listedSessions = sessionsResult.ok ? workbenchSessionsFromPayload(sessionsResult.data) : []; if (sessionsResult.ok) { applySessionPagination(sessionsResult.data); @@ -305,7 +284,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const requestIncludeSessionId = firstNonEmptyString(includeSessionId); const requestLimit = currentSessionListLimit(); const requestKey = sessionListRefreshKey(requestIncludeSessionId, requestLimit); - await sessionListRefreshRuntime.run(requestKey, () => refreshSessionsNow(requestIncludeSessionId, requestLimit), { force: options.force, minIntervalMs: SESSION_LIST_MIN_REFRESH_INTERVAL_MS, reason: "session-list" }); + await sessionListRefreshRuntime.run(requestKey, () => refreshSessionsNow(requestIncludeSessionId, requestLimit), { force: options.force, minIntervalMs: runtimePolicy.sessionListMinRefreshIntervalMs, reason: "session-list" }); } async function refreshSessionsNow(includeSessionId: string | null, limit: number): Promise { @@ -325,7 +304,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { if (sessions.value.length === 0) error.value = response.error ?? "session list unavailable"; } - function scheduleSessionListRefresh(includeSessionId: string | null | undefined = activeSessionId.value, delayMs = SESSION_LIST_REALTIME_REFRESH_DELAY_MS): void { + function scheduleSessionListRefresh(includeSessionId: string | null | undefined = activeSessionId.value, delayMs = runtimePolicy.sessionListRealtimeRefreshDelayMs): void { const requestIncludeSessionId = firstNonEmptyString(includeSessionId); const requestLimit = currentSessionListLimit(); const requestKey = sessionListRefreshKey(requestIncludeSessionId, requestLimit); @@ -333,7 +312,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { void refreshSessions(requestIncludeSessionId); return; } - sessionListRefreshRuntime.schedule(requestKey, () => refreshSessions(requestIncludeSessionId), { delayMs, minIntervalMs: SESSION_LIST_MIN_REFRESH_INTERVAL_MS, reason: "session-list" }); + sessionListRefreshRuntime.schedule(requestKey, () => refreshSessions(requestIncludeSessionId), { delayMs, minIntervalMs: runtimePolicy.sessionListMinRefreshIntervalMs, reason: "session-list" }); } function sessionListRefreshKey(includeSessionId: string | null | undefined, limit: number): string { @@ -349,7 +328,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } sessionListLoadingMore.value = true; sessionListLoadMoreError.value = null; - const response = await api.workbench.sessions({ includeSessionId: activeSessionId.value, limit: SESSION_LIST_PAGE_LIMIT, cursor }); + const response = await api.workbench.sessions({ includeSessionId: activeSessionId.value, limit: runtimePolicy.sessionListPageLimit, cursor }); sessionListLoadingMore.value = false; if (!response.ok) { sessionListLoadMoreError.value = response.error ?? "session list load more failed"; @@ -363,7 +342,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function currentSessionListLimit(): number { - return Math.max(SESSION_LIST_PAGE_LIMIT, sessionListLoadedCount.value || 0); + return Math.max(runtimePolicy.sessionListPageLimit, sessionListLoadedCount.value || 0); } function applySessionPagination(payload: unknown): void { @@ -480,16 +459,16 @@ export const useWorkbenchStore = defineStore("workbench", () => { return runWorkbenchReadHydration( () => api.workbench.turn(traceId, 8000, activitySource), workbenchReadCooldownKey("turn", traceId), - { minIntervalMs: WORKBENCH_TURN_STATUS_MIN_REFRESH_MS, force: options.force }, + { minIntervalMs: runtimePolicy.workbenchTurnStatusMinRefreshMs, force: options.force }, ); } function fetchWorkbenchTraceEvents(traceId: string, afterProjectedSeq: number, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise> { const activitySource = useActivityTimeout ? () => activityRef.value : null; return runWorkbenchReadHydration( - () => api.workbench.traceEvents(traceId, WORKBENCH_TRACE_EVENTS_TIMEOUT_MS, activitySource, { afterProjectedSeq, limit: TRACE_HYDRATION_PAGE_LIMIT }), + () => api.workbench.traceEvents(traceId, runtimePolicy.workbenchTraceEventsTimeoutMs, activitySource, { afterProjectedSeq, limit: runtimePolicy.traceHydrationPageLimit }), workbenchReadCooldownKey("trace-events", traceId), - { minIntervalMs: WORKBENCH_TRACE_EVENTS_MIN_REFRESH_MS, force: options.force }, + { minIntervalMs: runtimePolicy.workbenchTraceEventsMinRefreshMs, force: options.force }, ); } @@ -510,7 +489,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const response = await runWorkbenchReadHydration( () => api.workbench.sessionMessages(id, { limit: 100 }), workbenchReadCooldownKey("session-messages", id), - { minIntervalMs: WORKBENCH_SESSION_MESSAGES_MIN_REFRESH_MS, force: options.force }, + { minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs, force: options.force }, ); if (!response.ok || !response.data) return; const pageMessages = Array.isArray(response.data.messages) ? response.data.messages.map((message) => normalizeChatMessage(message as ChatMessage)) : []; @@ -525,7 +504,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { await realtimeSessionMessagesRuntime.run(composeWorkbenchScopedKey("workbench.realtime.session-messages", id), async () => { recordActivity(reason); await refreshSessionMessageProjectionPage(id, { force: true }); - }, { force: options.force, minIntervalMs: WORKBENCH_REALTIME_SESSION_MESSAGES_MIN_REFRESH_MS, reason }); + }, { force: options.force, minIntervalMs: runtimePolicy.workbenchRealtimeSessionMessagesMinRefreshMs, reason }); } async function refreshMessageProjectionForTrace(sessionId: string | null | undefined, traceId: string, options: { force?: boolean } = {}): Promise { @@ -534,7 +513,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { const response = await runWorkbenchReadHydration( () => api.workbench.sessionMessages(id, { limit: 100 }), workbenchReadCooldownKey("session-messages", id), - { minIntervalMs: WORKBENCH_SESSION_MESSAGES_MIN_REFRESH_MS, force: options.force }, + { minIntervalMs: runtimePolicy.workbenchSessionMessagesMinRefreshMs, force: options.force }, ); if (!response.ok || !response.data) { if (shouldSuppressTransientWorkbenchReadFailure(response)) return; @@ -748,7 +727,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project status: "running" }; void hydrateTraceEvents(messages.value); - scheduleSessionListRefresh(sessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); restartRealtime("steer"); return true; } @@ -764,7 +743,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project }; applyTurnStatusSnapshot(canonicalTraceId, response.data); publishWorkbenchProjectionSignal(sessionId, canonicalTraceId, "submit-admitted"); - scheduleSessionListRefresh(sessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); if ((response.data as AgentChatResultResponse).terminal === true || isTerminalMessageStatus(response.data.status)) { completeTrace(canonicalTraceId, response.data as AgentChatResultResponse); return true; @@ -788,7 +767,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project currentRequest.value = null; clearActiveTraceRestGapFill(traceId); void clearActiveTrace(traceId, "cancel-agent-message"); - scheduleSessionListRefresh(sessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); } async function cancelRunningTrace(): Promise { @@ -821,7 +800,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const traceId = message.traceId ?? message.runnerTrace?.traceId; if (!traceId) return; let afterProjectedSeq = traceHydrationProjectedSeq(message.runnerTrace); - for (let page = 0; page < TRACE_HYDRATION_MAX_PAGES; page += 1) { + for (let page = 0; page < runtimePolicy.traceHydrationMaxPages; page += 1) { const result = await fetchTraceHydrationPage(traceId, afterProjectedSeq, { force: options.force }); if (!result.ok || !result.data) { if (shouldSuppressTransientWorkbenchReadFailure(result)) return; @@ -838,11 +817,11 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project async function fetchTraceHydrationPage(traceId: string, afterProjectedSeq: number, options: { force?: boolean } = {}): Promise> { let lastResult: ApiResult | null = null; - for (let attempt = 0; attempt < TRACE_HYDRATION_MAX_ATTEMPTS; attempt += 1) { + for (let attempt = 0; attempt < runtimePolicy.traceHydrationMaxAttempts; attempt += 1) { const result = await fetchWorkbenchTraceEvents(traceId, afterProjectedSeq, shouldUseActivityTimeoutForTrace(traceId), { force: options.force }); if (result.ok && result.data) return result; lastResult = result; - if (attempt < TRACE_HYDRATION_MAX_ATTEMPTS - 1) await delayTraceHydrationRetry(TRACE_HYDRATION_RETRY_DELAY_MS * (attempt + 1)); + if (attempt < runtimePolicy.traceHydrationMaxAttempts - 1) await delayTraceHydrationRetry(runtimePolicy.traceHydrationRetryDelayMs * (attempt + 1)); } return lastResult ?? { ok: false, status: 0, data: null, error: "trace_hydration_failed" }; } @@ -866,7 +845,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const ownerMessages = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value; const latest = latestMessageForTrace(traceId, ownerMessages) ?? message; await hydrateTraceEventsForMessage(latest, { force: true }); - }, { delayMs: TRACE_HYDRATION_RETRY_DELAY_MS, reason: "trace-hydration-retry" }); + }, { delayMs: runtimePolicy.traceHydrationRetryDelayMs, reason: "trace-hydration-retry" }); } async function hydrateTraceEvents(source: ChatMessage[] = messages.value): Promise { @@ -874,7 +853,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project } function traceHydrationCandidates(source: ChatMessage[]): ChatMessage[] { - return source.filter(messageNeedsTraceHydration).slice(-TRACE_HYDRATION_AUTO_QUEUE_LIMIT).reverse(); + return source.filter(messageNeedsTraceHydration).slice(-runtimePolicy.traceHydrationAutoQueueLimit).reverse(); } function queueTraceHydration(message: ChatMessage): void { @@ -1034,7 +1013,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project realtimeTransport.restart({ sessionId, traceId, - errorRecoveryMinMs: WORKBENCH_REALTIME_ERROR_GAP_FILL_MIN_MS, + errorRecoveryMinMs: runtimePolicy.workbenchRealtimeErrorGapFillMinMs, onOpen: () => undefined, onRecovery: (recovery) => handleRealtimeRecovery(recovery), onEvent: (event, eventName) => applyRealtimeEvent(event, eventName) @@ -1044,16 +1023,18 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project function handleRealtimeRecovery(recovery: WorkbenchStreamTransportRecovery): void { const activeId = normalizeWorkbenchSessionId(recovery.sessionId ?? selectedSessionId.value); const activeTraceId = firstNonEmptyString(recovery.traceId, realtimeTraceId()); + const actions = new Set(recovery.actions); + recordWorkbenchRuntimeDiagnostic({ module: "workbench-stream-transport", diagnostic: recovery.diagnostic, sessionId: activeId, traceId: activeTraceId, outcome: "network" }); if (!activeId && !activeTraceId) return; - if (activeId === activeSessionId.value) void refreshRealtimeSessionMessages(activeId, "realtime-error:messages", { force: true }); - if (activeId) scheduleSessionListRefresh(activeId, SESSION_LIST_REALTIME_REFRESH_DELAY_MS); + if (actions.has("refresh-session-messages") && activeId === activeSessionId.value) void refreshRealtimeSessionMessages(activeId, "realtime-error:messages", { force: true }); + if (actions.has("schedule-session-list") && activeId) scheduleSessionListRefresh(activeId, runtimePolicy.sessionListRealtimeRefreshDelayMs); if (!activeTraceId || !shouldApplyActiveTraceAuthority(activeTraceId, activeId)) return; - void refreshTurnStatusByTraceId(activeTraceId, { force: true }); + if (actions.has("refresh-turn-status")) void refreshTurnStatusByTraceId(activeTraceId, { force: true }); const message = latestMessageForTrace(activeTraceId); - if (message) void hydrateTraceEventsForMessage(message, { force: true }); + if (actions.has("hydrate-trace-events") && message) void hydrateTraceEventsForMessage(message, { force: true }); } - function scheduleActiveTraceRestGapFill(traceId: string | null | undefined, reason: string, delayMs = WORKBENCH_ACTIVE_TRACE_REST_GAP_FILL_INITIAL_MS): void { + function scheduleActiveTraceRestGapFill(traceId: string | null | undefined, reason: string, delayMs = runtimePolicy.workbenchActiveTraceRestGapFillInitialMs): void { const id = firstNonEmptyString(traceId); if (!id || typeof window === "undefined") return; activeTraceRestGapFillRuntime.schedule(composeWorkbenchScopedKey("workbench.active-trace-gap", id), () => refreshActiveTraceFromRest(id, reason), { delayMs, replaceTimer: true, reason }); @@ -1073,7 +1054,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project await refreshTurnStatusByTraceId(id, { force: true }); const ownerSessionId = traceOwnerSessionId(id, turnStatusAuthority.value[id]?.sessionId ?? null) ?? ownerBefore; if (ownerSessionId) { - scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_REALTIME_REFRESH_DELAY_MS); + scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListRealtimeRefreshDelayMs); await refreshMessageProjectionForTrace(ownerSessionId, id, { force: true }); } const ownerMessages = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value; @@ -1086,7 +1067,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project } if (message) await hydrateTraceEventsForMessage(message, { force: true }); const stillActive = currentRequest.value?.traceId === id || isTraceActiveStatus(turn?.status) || isTraceActiveStatus(message?.status); - if (stillActive) scheduleActiveTraceRestGapFill(id, "active-rest-gap:repeat", WORKBENCH_ACTIVE_TRACE_REST_GAP_FILL_REPEAT_MS); + if (stillActive) scheduleActiveTraceRestGapFill(id, "active-rest-gap:repeat", runtimePolicy.workbenchActiveTraceRestGapFillRepeatMs); } function stopRealtime(): void { @@ -1242,7 +1223,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project await refreshTurnStatusByTraceId(id, { force: true }); const ownerSessionId = traceOwnerSessionId(id, turnStatusAuthority.value[id]?.sessionId ?? null) ?? ownerBefore; if (ownerSessionId) { - scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); await refreshMessageProjectionForTrace(ownerSessionId, id, { force: true }); } const ownerMessages = ownerSessionId ? serverState.value.messagesBySessionId[ownerSessionId] ?? [] : messages.value; @@ -1324,7 +1305,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project void refreshRealtimeSessionFromRest(ownerSessionId, `realtime-trace-snapshot:${traceId}`); } markWorkbenchTraceProjected(traceId); - scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_REALTIME_REFRESH_DELAY_MS); + scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListRealtimeRefreshDelayMs); } async function refreshRealtimeSessionFromRest(sessionId: string, reason: string): Promise { @@ -1371,7 +1352,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project void clearActiveTrace(traceId, "trace-terminal"); restartRealtime("trace-terminal"); } - scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); } async function hydrateTerminalMessageDiagnostics(): Promise { @@ -1400,7 +1381,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project return { ...message, ...messageTimingPatchForMerge(message, result), ...messageStatusPatchForTerminalMerge(message, resultStatus, terminal), title: normalizeWorkbenchMessageTitle(message.role, message.title), runnerTrace, error, projection, projectionStatus: projection?.projectionStatus ?? null, projectionHealth: projection?.projectionHealth ?? null, blocker: projection?.blocker ?? null, agentRun: agentRun ?? undefined, updatedAt: new Date().toISOString() }; })); void hydrateTraceEvents(serverState.value.messagesBySessionId[ownerSessionId] ?? []); - scheduleSessionListRefresh(ownerSessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(ownerSessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); } function applyRealtimeProjectionError(event: WorkbenchRealtimeEvent): void { @@ -1442,7 +1423,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project chatPending.value = false; currentRequest.value = null; void clearActiveTrace(traceId, "trace-infrastructure-error"); - scheduleSessionListRefresh(selectedSessionId.value, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(selectedSessionId.value, runtimePolicy.sessionListTerminalRefreshDelayMs); } function projectLocalAdmissionFailure(input: { traceId: string; sessionId: string; threadId: string | null; message: string; error: ChatMessage["error"]; projection: ProjectionDiagnostic; submittedAt: string }): void { @@ -1483,7 +1464,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project hasMore: false, truncated: false, nextProjectedSeq: 1, - range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: TRACE_HYDRATION_PAGE_LIMIT, returned: 1, total: 1 }, + range: { afterProjectedSeq: 0, fromProjectedSeq: 1, toProjectedSeq: 1, limit: runtimePolicy.traceHydrationPageLimit, returned: 1, total: 1 }, lastEventLabel: "workbench:admission:failed", error: input.error ?? undefined, projection: input.projection, @@ -1533,7 +1514,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project messages: projectedMessages } as WorkbenchSessionRecord)); publishWorkbenchProjectionSignal(input.sessionId, input.traceId, "submit-admission-failed"); - scheduleSessionListRefresh(input.sessionId, SESSION_LIST_TERMINAL_REFRESH_DELAY_MS); + scheduleSessionListRefresh(input.sessionId, runtimePolicy.sessionListTerminalRefreshDelayMs); } function projectOptimisticRunningTurn(input: { sessionId: string; threadId: string | null; traceId: string; userText: string }): void { diff --git a/web/hwlab-cloud-web/src/types/global.d.ts b/web/hwlab-cloud-web/src/types/global.d.ts index 28441ba5..809c9c53 100644 --- a/web/hwlab-cloud-web/src/types/global.d.ts +++ b/web/hwlab-cloud-web/src/types/global.d.ts @@ -13,6 +13,31 @@ declare global { autoCollapseTerminal?: boolean; }; traceExplorerUrlTemplate?: string; + runtimePolicy?: { + defaultCodeAgentTimeoutMs?: number; + defaultGatewayTimeoutMs?: number; + traceHydrationPageLimit?: number; + traceHydrationMaxPages?: number; + traceHydrationMaxAttempts?: number; + traceHydrationRetryDelayMs?: number; + traceHydrationAutoQueueLimit?: number; + traceHydrationBackgroundConcurrency?: number; + traceHydrationBackgroundDelayMs?: number; + sessionListPageLimit?: number; + workbenchReadHydrationConcurrency?: number; + workbenchReadFailureCooldownMs?: number; + workbenchTurnStatusMinRefreshMs?: number; + workbenchTraceEventsMinRefreshMs?: number; + workbenchSessionMessagesMinRefreshMs?: number; + workbenchRealtimeSessionMessagesMinRefreshMs?: number; + workbenchTraceEventsTimeoutMs?: number; + sessionListRealtimeRefreshDelayMs?: number; + sessionListTerminalRefreshDelayMs?: number; + sessionListMinRefreshIntervalMs?: number; + workbenchRealtimeErrorGapFillMinMs?: number; + workbenchActiveTraceRestGapFillInitialMs?: number; + workbenchActiveTraceRestGapFillRepeatMs?: number; + }; }; opencode?: { url?: string; diff --git a/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts b/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts index 8fee50a5..0afae5cd 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts @@ -172,6 +172,104 @@ export function agentErrorFromProjection(projection: ProjectionDiagnostic): Chat return normalizeApiErrorRecord({ ...(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) }, message) as ChatMessage["error"]; } +export interface WorkbenchMessageDiagnosticView { + visible: boolean; + text: string | null; + apiError: ApiError | null; + diagnostic: ErrorDiagnostic | null; +} + +export function messageDiagnosticView(message: ChatMessage): WorkbenchMessageDiagnosticView { + if (hasSealedCompletedText(message)) return { visible: false, text: null, apiError: null, diagnostic: null }; + const text = messageDiagnosticText(message); + const diagnostic = messageErrorDiagnostic(message, text); + const apiError = messageApiError(message, text, diagnostic); + return { visible: Boolean(text || apiError || diagnostic), text, apiError, diagnostic }; +} + +function messageDiagnosticText(message: ChatMessage): string | null { + if (message.role === "agent") return projectionDiagnosticText(message); + if (message.role !== "user" || String(message.text ?? "").trim()) return null; + return "消息内容缺失:Workbench read model 未返回 user text。"; +} + +function projectionDiagnosticText(message: ChatMessage): string | null { + if (hasSealedCompletedText(message)) return 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, diagnosticText: string | null, diagnostic: ErrorDiagnostic | null): 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); + if (!diagnosticText && !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, diagnosticText); + return { + ...source, + message: messageText ?? "Workbench 诊断", + userMessage: firstNonEmptyString(source.userMessage, blocker?.userMessage, messageText), + code: firstStringOrNumber(source.code, blocker?.code, diagnostic?.code), + retryable: firstBoolean(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, diagnosticText: string | null): 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 (!diagnosticText && !blocker && !error && !apiError && !projection?.diagnostic) return null; + const nested = normalizeErrorDiagnostic(projection?.diagnostic, blocker?.diagnostic, error?.diagnostic, apiError?.diagnostic); + if (nested) return nested; + const code = firstStringOrNumber(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: firstBoolean(error?.retryable, apiError?.retryable, blocker?.retryable), valuesPrinted: false }; +} + +function hasSealedCompletedText(message: ChatMessage): boolean { + return isCompletedAgentMessage(message) && Boolean(visibleMessageText(message)); +} + +function isCompletedAgentMessage(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + return String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-") === "completed"; +} + +function visibleMessageText(message: ChatMessage): string { + const text = String(message.text ?? "").trim(); + if (message.role === "agent" && !isTerminalAgentMessage(message)) return ""; + return text; +} + +function isTerminalAgentMessage(message: ChatMessage): boolean { + if (message.role !== "agent") return false; + return ["completed", "failed", "blocked", "timeout", "canceled", "cancelled", "stale", "thread-resume-failed"].includes(String(message.status ?? "").trim().toLowerCase().replace(/_/gu, "-")); +} + function tr(translator: Translator | undefined, key: string, text: string, vars?: Record): string { if (!translator) return text; const out = translator(key, vars); @@ -249,6 +347,14 @@ function firstFiniteNumber(...values: unknown[]): number | null { 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 firstBoolean(...values: unknown[]): boolean | null { for (const value of values) if (typeof value === "boolean") return value; return null; diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index 2e3ae1ff..5eae9212 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -54,6 +54,12 @@ interface WorkbenchPerformanceEvent { resourceDecodedBodySize?: number; resourceNextHopProtocol?: string; resourceServerTiming?: string; + module?: string; + diagnosticCode?: string; + rootCause?: string; + recoveryAction?: string; + transportState?: string; + scopedKey?: string; } interface TraceEventTimingInput { @@ -320,6 +326,33 @@ export function recordWorkbenchSseLifecycle(input: { state: "connect" | "open" | }); } +export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null; diagnostic?: Record | null; sessionId?: string | null; traceId?: string | null; outcome?: WorkbenchOutcome | null }): void { + ensureInstalled(); + const diagnostic = recordValue(input.diagnostic); + const endedAt = wallNow(); + const code = diagnosticText(diagnostic, "code") ?? "runtime_diagnostic"; + enqueueWorkbenchUiEvent({ + eventType: "runtime_diagnostic", + loadingScope: "sse", + state: "error", + reason: "sse_error", + route: pageRoute(), + valueMs: 0, + startedAtEpochMs: endedAt, + endedAtEpochMs: endedAt, + sessionHash: hashIdentifier(input.sessionId ?? diagnosticText(diagnostic, "sessionId"), "ses"), + traceHash: hashIdentifier(input.traceId ?? diagnosticText(diagnostic, "traceId"), "trc"), + outcome: input.outcome ?? normalizeUiOutcome(diagnosticText(diagnostic, "outcome")), + errorName: code.slice(0, 80), + module: diagnosticLabel(input.module ?? diagnosticText(diagnostic, "module") ?? diagnosticText(diagnostic, "layer")), + diagnosticCode: diagnosticLabel(code), + rootCause: diagnosticText(diagnostic, "rootCause", "reason")?.slice(0, 160), + recoveryAction: diagnosticText(diagnostic, "recoveryAction")?.slice(0, 160), + transportState: diagnosticLabel(diagnosticText(diagnostic, "transportState")), + scopedKey: hashIdentifier(diagnosticText(diagnostic, "scopedKey"), "scope") + }); +} + export function startWorkbenchOpenJourney(input: { route?: string; cache?: "warm" | "cold" | "unknown"; authState?: "warm" | "login_redirect" | "unknown" } = {}): void { ensureInstalled(); openJourney = { @@ -747,6 +780,25 @@ function hashIdentifier(value: unknown, prefix: string): string | undefined { return `${prefix}_${hash.toString(16).padStart(8, "0")}`; } +function recordValue(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function diagnosticText(record: Record | null, ...keys: string[]): string | undefined { + if (!record) return undefined; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + } + return undefined; +} + +function diagnosticLabel(value: unknown): string | undefined { + const text = safeText(value).replace(/[^A-Za-z0-9_.:-]/gu, "_").slice(0, 80); + return text || undefined; +} + function isWorkbenchPage(): boolean { return pageRoute().startsWith("/workbench"); }