Merge pull request #2313 from pikasTech/issue-1326-opencode-100-followup

refactor: complete Workbench runtime module integration
This commit is contained in:
Lyon
2026-06-30 23:52:07 +08:00
committed by GitHub
9 changed files with 474 additions and 182 deletions
+14 -1
View File
@@ -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<string, string>; devDependencies?: Record<string, string> };
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");
@@ -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);
}
@@ -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>): 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;
}
@@ -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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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 {
<TraceTimeline v-if="row.message.role === 'agent' && row.message.runnerTrace" :trace="traceForDisplay(row.message)" :auto-expanded="traceAutoExpanded(row.message)" :storage-key="traceStorageKey(row.message)" />
<LoadingState v-if="isAwaitingAgentBody(row.message)" class="message-loading" label="思考中..." compact />
<MessageMarkdown v-if="showMessageText(row.message)" class="message-text" :source="visibleMessageText(row.message)" />
<ApiErrorDiagnostic v-if="messageHasDiagnostic(row.message)" class="message-diagnostic projection-diagnostic" :error="messageDiagnosticText(row.message)" :api-error="messageApiError(row.message)" :diagnostic="messageErrorDiagnostic(row.message)" compact />
<ApiErrorDiagnostic v-if="messageHasDiagnostic(row.message)" class="message-diagnostic projection-diagnostic" :error="diagnosticFor(row.message).text" :api-error="diagnosticFor(row.message).apiError" :diagnostic="diagnosticFor(row.message).diagnostic" compact />
</article>
</template>
<div v-if="!workbench.sessionDetailLoading && workbench.activeMessages.length === 0 && workbench.error" class="conversation-empty-hint conversation-error-hint">加载失败{{ workbench.error }}</div>
@@ -330,7 +220,7 @@ function formatDuration(ms: number): string {
<button type="button" class="dialog-close" aria-label="关闭" @click="detailMessageId = null">x</button>
</header>
<CodeAgentStatusSummary :message="detailMessage" />
<ApiErrorDiagnostic v-if="messageHasDiagnostic(detailMessage)" class="message-detail-diagnostic" title="诊断" :error="messageDiagnosticText(detailMessage)" :api-error="messageApiError(detailMessage)" :diagnostic="messageErrorDiagnostic(detailMessage)" compact />
<ApiErrorDiagnostic v-if="messageHasDiagnostic(detailMessage)" class="message-detail-diagnostic" title="诊断" :error="diagnosticFor(detailMessage).text" :api-error="diagnosticFor(detailMessage).apiError" :diagnostic="diagnosticFor(detailMessage).diagnostic" compact />
<MessageTraceDebugPanel :message="detailMessage" />
<MessageActions
:message="detailMessage"
@@ -0,0 +1,107 @@
// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first.
// Responsibility: Workbench runtime tunables consumed by store/runtime modules from the injected YAML-backed web config.
export interface WorkbenchRuntimePolicy {
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;
}
const DEFAULT_WORKBENCH_RUNTIME_POLICY: WorkbenchRuntimePolicy = Object.freeze({
defaultCodeAgentTimeoutMs: 1_800_000,
defaultGatewayTimeoutMs: 120_000,
traceHydrationPageLimit: 50,
traceHydrationMaxPages: 4,
traceHydrationMaxAttempts: 3,
traceHydrationRetryDelayMs: 700,
traceHydrationAutoQueueLimit: 4,
traceHydrationBackgroundConcurrency: 1,
traceHydrationBackgroundDelayMs: 400,
sessionListPageLimit: 20,
workbenchReadHydrationConcurrency: 3,
workbenchReadFailureCooldownMs: 5_000,
workbenchTurnStatusMinRefreshMs: 2_000,
workbenchTraceEventsMinRefreshMs: 4_000,
workbenchSessionMessagesMinRefreshMs: 5_000,
workbenchRealtimeSessionMessagesMinRefreshMs: 1_000,
workbenchTraceEventsTimeoutMs: 5_000,
sessionListRealtimeRefreshDelayMs: 5_000,
sessionListTerminalRefreshDelayMs: 1_500,
sessionListMinRefreshIntervalMs: 15_000,
workbenchRealtimeErrorGapFillMinMs: 2_000,
workbenchActiveTraceRestGapFillInitialMs: 2_500,
workbenchActiveTraceRestGapFillRepeatMs: 5_000
});
export function workbenchRuntimePolicy(input: unknown = runtimePolicyConfig()): WorkbenchRuntimePolicy {
const source = recordValue(input) ?? {};
return {
defaultCodeAgentTimeoutMs: positiveNumber(source.defaultCodeAgentTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.defaultCodeAgentTimeoutMs),
defaultGatewayTimeoutMs: positiveNumber(source.defaultGatewayTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.defaultGatewayTimeoutMs),
traceHydrationPageLimit: positiveInteger(source.traceHydrationPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationPageLimit),
traceHydrationMaxPages: positiveInteger(source.traceHydrationMaxPages, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationMaxPages),
traceHydrationMaxAttempts: positiveInteger(source.traceHydrationMaxAttempts, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationMaxAttempts),
traceHydrationRetryDelayMs: positiveNumber(source.traceHydrationRetryDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationRetryDelayMs),
traceHydrationAutoQueueLimit: positiveInteger(source.traceHydrationAutoQueueLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationAutoQueueLimit),
traceHydrationBackgroundConcurrency: positiveInteger(source.traceHydrationBackgroundConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationBackgroundConcurrency),
traceHydrationBackgroundDelayMs: nonNegativeNumber(source.traceHydrationBackgroundDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.traceHydrationBackgroundDelayMs),
sessionListPageLimit: positiveInteger(source.sessionListPageLimit, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListPageLimit),
workbenchReadHydrationConcurrency: positiveInteger(source.workbenchReadHydrationConcurrency, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchReadHydrationConcurrency),
workbenchReadFailureCooldownMs: positiveNumber(source.workbenchReadFailureCooldownMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchReadFailureCooldownMs),
workbenchTurnStatusMinRefreshMs: nonNegativeNumber(source.workbenchTurnStatusMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTurnStatusMinRefreshMs),
workbenchTraceEventsMinRefreshMs: nonNegativeNumber(source.workbenchTraceEventsMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTraceEventsMinRefreshMs),
workbenchSessionMessagesMinRefreshMs: nonNegativeNumber(source.workbenchSessionMessagesMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchSessionMessagesMinRefreshMs),
workbenchRealtimeSessionMessagesMinRefreshMs: nonNegativeNumber(source.workbenchRealtimeSessionMessagesMinRefreshMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchRealtimeSessionMessagesMinRefreshMs),
workbenchTraceEventsTimeoutMs: positiveNumber(source.workbenchTraceEventsTimeoutMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchTraceEventsTimeoutMs),
sessionListRealtimeRefreshDelayMs: nonNegativeNumber(source.sessionListRealtimeRefreshDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListRealtimeRefreshDelayMs),
sessionListTerminalRefreshDelayMs: nonNegativeNumber(source.sessionListTerminalRefreshDelayMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListTerminalRefreshDelayMs),
sessionListMinRefreshIntervalMs: nonNegativeNumber(source.sessionListMinRefreshIntervalMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.sessionListMinRefreshIntervalMs),
workbenchRealtimeErrorGapFillMinMs: nonNegativeNumber(source.workbenchRealtimeErrorGapFillMinMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchRealtimeErrorGapFillMinMs),
workbenchActiveTraceRestGapFillInitialMs: nonNegativeNumber(source.workbenchActiveTraceRestGapFillInitialMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchActiveTraceRestGapFillInitialMs),
workbenchActiveTraceRestGapFillRepeatMs: nonNegativeNumber(source.workbenchActiveTraceRestGapFillRepeatMs, DEFAULT_WORKBENCH_RUNTIME_POLICY.workbenchActiveTraceRestGapFillRepeatMs)
};
}
function runtimePolicyConfig(): unknown {
if (typeof window === "undefined") return null;
return window.HWLAB_CLOUD_WEB_CONFIG?.workbench?.runtimePolicy ?? null;
}
function recordValue(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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;
}
+45 -64
View File
@@ -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<ProviderProfile>(readString("hwlab.workbench.providerProfile.v1", "codex"));
const providerOptions = ref<ProviderProfileOption[]>(defaultProviderProfileOptions(providerProfile.value));
const recentDrafts = ref<DraftEntry[]>(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<LiveSurface | null>(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<ChatMessage>({ concurrency: TRACE_HYDRATION_BACKGROUND_CONCURRENCY, delayMs: TRACE_HYDRATION_BACKGROUND_DELAY_MS });
const traceHydrationRuntime = createWorkbenchTraceHydrationQueueRuntime<ChatMessage>({ 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<void> {
@@ -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<ApiResult<AgentChatResultResponse>> {
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<void> {
@@ -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<void> {
@@ -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<ApiResult<AgentChatResultResponse>> {
let lastResult: ApiResult<AgentChatResultResponse> | 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<void> {
@@ -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<void> {
@@ -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<void> {
@@ -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 {
+25
View File
@@ -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;
@@ -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, string | number>): 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;
@@ -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<string, unknown> | 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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function diagnosticText(record: Record<string, unknown> | 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");
}