Merge pull request #2313 from pikasTech/issue-1326-opencode-100-followup
refactor: complete Workbench runtime module integration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user