diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 7bd9092b..b8ea7bb9 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -29,12 +29,18 @@ const requiredFiles = Object.freeze([ "src/utils/scheduler/keyed-singleflight.ts", "src/utils/safe-storage.ts", "src/utils/workbench-health.ts", + "src/utils/workbench-error-runtime.ts", + "src/utils/workbench-realtime-runtime.ts", + "src/utils/workbench-refresh-runtime.ts", + "src/utils/workbench-storage-runtime.ts", + "src/utils/workbench-stream-transport.ts", "src/stores/index.ts", "src/stores/app.ts", "src/stores/auth.ts", "src/stores/workbench.ts", "src/stores/workbench-timeline-model.ts", "src/stores/workbench-session-cache.ts", + "src/composables/useWorkbenchScrollRuntime.ts", "src/composables/useTraceSubscription.ts", "src/composables/useAutoRefresh.ts", "src/composables/useClipboard.ts", @@ -71,6 +77,7 @@ const html = readWeb("index.html"); const pkg = JSON.parse(readWeb("package.json")) as { dependencies?: Record; devDependencies?: Record }; const appSource = readCloudWebAppSource(rootDir); const workbenchStoreSource = readWeb("src/stores/workbench.ts"); +const workbenchRealtimeRuntimeSource = `${readWeb("src/utils/workbench-realtime-runtime.ts")}\n${readWeb("src/utils/workbench-stream-transport.ts")}`; const traceTimelineSource = readWeb("src/components/agent/TraceTimeline.vue"); const messageTraceDebugSource = readWeb("src/components/workbench/MessageTraceDebugPanel.vue"); const conversationPanelSource = readWeb("src/components/workbench/ConversationPanel.vue"); @@ -110,7 +117,8 @@ assertIncludes(appSource, "useTableLoader", "table loader composable must remain assertIncludes(appSource, "useForm", "form composable must remain available for CRUD dialogs"); 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, "connectWorkbenchEvents", "Workbench must use the unified SSE realtime entry"); +assertIncludes(workbenchStoreSource, "createWorkbenchStreamTransportRuntime", "Workbench store must enter SSE through the realtime runtime boundary"); +assertIncludes(workbenchRealtimeRuntimeSource, "connectWorkbenchEvents", "Workbench realtime runtime must own the unified SSE EventSource entry"); assert.doesNotMatch(workbenchStoreSource, /scheduleRealtimeGapHydration|hydrateRealtimeGap/u, "Workbench realtime consumer must not repair projection through REST gap fill"); assert.doesNotMatch(workbenchStoreSource, /subscribeToTrace|TRACE_POLL_INTERVAL_MS/u, "Workbench store must not reintroduce active trace polling"); assertIncludes(workbenchStoreSource, "if (existing) return;", "Session list scheduled refreshes must coalesce instead of resetting timers under SSE error storms"); diff --git a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue index fa5ff760..d2b6ba80 100644 --- a/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue +++ b/web/hwlab-cloud-web/src/components/agent/TraceTimeline.vue @@ -3,8 +3,9 @@ import { computed, ref, watch } from "vue"; import type { RunnerTrace } from "@/types"; import LoadingState from "@/components/common/LoadingState.vue"; import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; -import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll"; +import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime"; import { formatDisplayClock } from "@/config/runtime"; +import { readWorkbenchString, writeWorkbenchString } from "@/utils/workbench-storage-runtime"; import { traceDisplayRows, type TraceEventRow } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts"; const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolean; storageKey?: string; autoExpanded?: boolean | null }>(); @@ -12,7 +13,7 @@ const props = defineProps<{ trace?: RunnerTrace | null; defaultExpanded?: boolea const TRACE_RENDER_ROW_WINDOW = 120; const listRef = ref(null); const expanded = ref(false); -const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useBottomFollowScroll(listRef, { threshold: 24 }); +const { following, keepBottomAfterUpdate, onScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(listRef, { threshold: 24 }); const events = computed(() => Array.isArray(props.trace?.events) ? props.trace.events : []); const eventCount = computed(() => props.trace?.eventCount ?? events.value.length); @@ -74,17 +75,15 @@ function setExpandedProgrammatically(value: boolean): void { function readStoredExpanded(): boolean | null { if (!props.storageKey) return null; - try { - const value = window.localStorage.getItem(props.storageKey); - if (value === "1") return true; - if (value === "0") return false; - } catch { /* ignore */ } + const value = readWorkbenchString(props.storageKey, ""); + if (value === "1") return true; + if (value === "0") return false; return null; } function writeStoredExpanded(value: boolean): void { if (!props.storageKey) return; - try { window.localStorage.setItem(props.storageKey, value ? "1" : "0"); } catch { /* ignore */ } + writeWorkbenchString(props.storageKey, value ? "1" : "0"); } function rowIsTool(row: TraceEventRow): boolean { diff --git a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue index 425221fb..e137f262 100644 --- a/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/ConversationPanel.vue @@ -13,11 +13,12 @@ import MessageActions from "@/components/workbench/MessageActions.vue"; import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import MessageTraceDebugPanel from "@/components/workbench/MessageTraceDebugPanel.vue"; import { workbenchTraceTimelinePolicy } from "@/config/runtime"; -import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll"; +import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime"; 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 { acknowledgeWorkbenchVisibleAfterPaint } from "@/utils/workbench-performance"; import { traceIdentityText } from "./message-rendering"; import { traceLifecycleExpanded } from "./trace-lifecycle"; @@ -25,7 +26,7 @@ import { traceLifecycleExpanded } from "./trace-lifecycle"; const workbench = useWorkbenchStore(); const clipboard = useClipboard(); const panelRef = ref(null); -const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useBottomFollowScroll(panelRef, { threshold: 56 }); +const { following, keepBottomAfterUpdate, onScroll: onPanelScroll, scrollToBottom } = useWorkbenchBottomFollowScroll(panelRef, { threshold: 56 }); const detailMessageId = ref(null); const visibleMessages = computed(() => workbench.sessionDetailLoading ? [] : workbench.activeMessages); const visibleTimelineRows = computed(() => buildWorkbenchTimelineRows(visibleMessages.value)); @@ -163,27 +164,7 @@ function messageErrorDiagnostic(message: ChatMessage): ErrorDiagnostic | null { } function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null { - for (const value of values) { - const record = recordValue(value); - if (!record) continue; - return { - ...record, - contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"), - traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id), - requestId: firstNonEmptyString(record.requestId, record.request_id), - serviceId: firstNonEmptyString(record.serviceId, record.service_id), - route: firstNonEmptyString(record.route), - layer: firstNonEmptyString(record.layer), - category: firstNonEmptyString(record.category), - code: firstValue(record.code), - httpStatus: firstNumber(record.httpStatus, record.http_status), - source: firstNonEmptyString(record.source), - observedAt: firstNonEmptyString(record.observedAt, record.observed_at), - retryable: firstBooleanValue(record.retryable), - valuesPrinted: record.valuesPrinted === true - }; - } - return null; + return normalizeWorkbenchErrorDiagnostic(...values); } function recordValue(value: unknown): Record | null { diff --git a/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue b/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue index 3d71da13..fa268c33 100644 --- a/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue +++ b/web/hwlab-cloud-web/src/components/workbench/MessageTraceDebugPanel.vue @@ -2,7 +2,7 @@ import { computed, ref, watch } from "vue"; import type { ChatMessage, RunnerTrace, TraceEvent } from "@/types"; import { firstNonEmptyString } from "@/utils"; -import { useBottomFollowScroll } from "@/composables/useBottomFollowScroll"; +import { useWorkbenchBottomFollowScroll } from "@/composables/useWorkbenchScrollRuntime"; import { formatDisplayClock } from "@/config/runtime"; import { traceEventBody, traceEventLabel, traceEventMeta } from "@/components/workbench/message-rendering"; import { traceDisplayRows, traceNoiseEventCount } from "../../../../../tools/src/hwlab-cli/trace-renderer.ts"; @@ -11,7 +11,7 @@ const props = defineProps<{ message: ChatMessage }>(); const rawListRef = ref(null); const rawMode = ref(false); -const { following, keepBottomAfterUpdate, onScroll, scrollToBottom, toggleFollowing } = useBottomFollowScroll(rawListRef, { threshold: 24 }); +const { following, keepBottomAfterUpdate, onScroll, scrollToBottom, toggleFollowing } = useWorkbenchBottomFollowScroll(rawListRef, { threshold: 24 }); const trace = computed(() => props.message.runnerTrace ?? null); const events = computed(() => Array.isArray(trace.value?.events) ? trace.value.events : []); diff --git a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue index be598297..5d6e226b 100644 --- a/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue +++ b/web/hwlab-cloud-web/src/components/workbench/SessionRail.vue @@ -8,10 +8,12 @@ import { useClipboard } from "@/composables/useClipboard"; import LoadingState from "@/components/common/LoadingState.vue"; import { formatDisplayDateTime } from "@/config/runtime"; import { workbenchSessionPath } from "@/utils"; +import { readWorkbenchNumber, readWorkbenchString, writeWorkbenchString } from "@/utils/workbench-storage-runtime"; const workbench = useWorkbenchStore(); const { copied, copy } = useClipboard(); const COLLAPSED_STORAGE_KEY = "hwlab.workbench.sessionRailCollapsed.v1"; +const WIDTH_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1"; const width = ref(readWidth()); const collapsed = ref(readCollapsed()); const dragging = ref(false); @@ -58,29 +60,21 @@ function keyResize(event: KeyboardEvent): void { function setWidth(value: number): void { width.value = Math.min(Math.max(Math.round(value), 220), 380); - try { localStorage.setItem("hwlab.workbench.sessionSidebarWidth.v1", String(width.value)); } catch { /* ignore */ } + writeWorkbenchString(WIDTH_STORAGE_KEY, String(width.value)); } function readWidth(): number { - try { - const parsed = Number(localStorage.getItem("hwlab.workbench.sessionSidebarWidth.v1")); - return Number.isFinite(parsed) ? Math.min(Math.max(Math.round(parsed), 220), 380) : 300; - } catch { - return 300; - } + const parsed = readWorkbenchNumber(WIDTH_STORAGE_KEY, 300); + return Number.isFinite(parsed) ? Math.min(Math.max(Math.round(parsed), 220), 380) : 300; } function toggleCollapsed(): void { collapsed.value = !collapsed.value; - try { localStorage.setItem(COLLAPSED_STORAGE_KEY, collapsed.value ? "true" : "false"); } catch { /* ignore */ } + writeWorkbenchString(COLLAPSED_STORAGE_KEY, collapsed.value ? "true" : "false"); } function readCollapsed(): boolean { - try { - return localStorage.getItem(COLLAPSED_STORAGE_KEY) === "true"; - } catch { - return false; - } + return readWorkbenchString(COLLAPSED_STORAGE_KEY, "false") === "true"; } function formatSessionUpdatedTime(value: string | null | undefined): string { diff --git a/web/hwlab-cloud-web/src/composables/useWorkbenchScrollRuntime.ts b/web/hwlab-cloud-web/src/composables/useWorkbenchScrollRuntime.ts new file mode 100644 index 00000000..c467757d --- /dev/null +++ b/web/hwlab-cloud-web/src/composables/useWorkbenchScrollRuntime.ts @@ -0,0 +1,9 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. +// Responsibility: Workbench scroll/follow runtime boundary over the shared bottom-follow primitive. + +import type { Ref } from "vue"; +import { useBottomFollowScroll, type BottomFollowScrollOptions } from "@/composables/useBottomFollowScroll"; + +export function useWorkbenchBottomFollowScroll(containerRef: Ref, options: BottomFollowScrollOptions = {}) { + return useBottomFollowScroll(containerRef, options); +} diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 75141bad..44620803 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -4,7 +4,11 @@ import { computed, nextTick, ref } from "vue"; import { defineStore } from "pinia"; import { api } from "@/api"; -import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events"; +import { createWorkbenchHealthProbeCache } from "@/utils/workbench-health"; +import { normalizeApiErrorRecord as normalizeRuntimeApiErrorRecord, normalizeErrorDiagnostic as normalizeRuntimeErrorDiagnostic } from "@/utils/workbench-error-runtime"; +import { createWorkbenchReadHydrationRuntime, shouldCooldownWorkbenchReadFailure as shouldCooldownWorkbenchReadRuntimeFailure } from "@/utils/workbench-refresh-runtime"; +import { readWorkbenchJson, readWorkbenchNumber, readWorkbenchString, removeWorkbenchStorageKey, writeWorkbenchJson, writeWorkbenchString } from "@/utils/workbench-storage-runtime"; +import { createWorkbenchStreamTransportRuntime, type WorkbenchRealtimeEvent } from "@/utils/workbench-realtime-runtime"; import { mergeRunnerTrace, snapshotToRunnerTrace, type TraceSnapshot } from "@/composables/useTraceSubscription"; 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"; @@ -13,6 +17,7 @@ import { failWorkbenchSessionSwitch, failWorkbenchSubmitJourney, finishWorkbench 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 { createWorkbenchServerState, reduceWorkbenchServerState, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; +import { trimWorkbenchSessionCache } from "./workbench-session-cache"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1_800_000; const DEFAULT_GATEWAY_TIMEOUT_MS = 120_000; @@ -89,18 +94,15 @@ export const useWorkbenchStore = defineStore("workbench", () => { const realtimeSessionRefreshInFlight = new Set(); const realtimeErrorGapFillLastAtByKey = new Map(); const activeTraceRestGapFillTimers = new Map(); - let realtimeStream: WorkbenchEventStream | null = null; - let realtimeKey = ""; + const realtimeTransport = createWorkbenchStreamTransportRuntime(); const realtimeOutboxSeqByKey = new Map(); const sessionListRefreshInFlight = new Map>(); const sessionListRefreshTimers = new Map(); const sessionListLastRefreshAtByKey = new Map(); const workbenchProjectionSignalSourceId = nextProtocolId("wbtab"); let workbenchProjectionSignalChannel: BroadcastChannel | null = null; - let workbenchReadHydrationActive = 0; - const workbenchReadHydrationQueue: Array<() => void> = []; - const workbenchReadCooldownUntilByKey = new Map(); - const workbenchReadLastStartedAtByKey = new Map(); + const workbenchReadHydrationRuntime = createWorkbenchReadHydrationRuntime({ concurrency: WORKBENCH_READ_HYDRATION_CONCURRENCY, failureCooldownMs: WORKBENCH_READ_FAILURE_COOLDOWN_MS }); + const workbenchHealthProbeCache = createWorkbenchHealthProbeCache({ cacheMs: WORKBENCH_READ_FAILURE_COOLDOWN_MS }); const projectedActiveSession = computed(() => selectActiveSession(serverState.value, explicitSessionId.value)); const routeActiveSession = computed(() => routeSelectedSessionRecord(explicitSessionId.value, sessionDetailLoadingId.value, error.value, projectedActiveSession.value)); @@ -186,16 +188,27 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function refreshLive(): Promise { const [healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds] = await Promise.all([ - liveCall("health/live", api.healthLive), - liveCall("health", api.health), - liveCall("REST index", api.restIndex), - liveCall("hwpod specs", api.hwpod.specs), - liveCall("hwpod node ops", api.hwpod.nodeOpsHealth), - liveCall("live builds", api.liveBuilds) + liveHealthCall("health/live", api.healthLive), + liveHealthCall("health", api.health), + liveHealthCall("REST index", api.restIndex), + liveHealthCall("hwpod specs", api.hwpod.specs), + liveHealthCall("hwpod node ops", api.hwpod.nodeOpsHealth), + liveHealthCall("live builds", api.liveBuilds) ]); live.value = { healthLive, health, restIndex, hwpodSpecs, hwpodNodeOps, liveBuilds, loadedAt: new Date().toISOString() }; } + async function liveHealthCall(label: string, call: () => Promise>): Promise> { + const snapshot = await workbenchHealthProbeCache.probe>({ + key: label, + fetcher: () => liveCall(label, call), + classify: (value) => value.ok ? "ok" : "degraded" + }); + if (snapshot.value) return snapshot.value; + const diagnostic: ErrorDiagnostic = { contractVersion: "hwlab-error-diagnostic-v1", code: "workbench_health_probe_unavailable", category: "health", layer: "workbench-health-runtime", source: "workbench-web", retryable: true, valuesPrinted: false }; + return { ok: false, status: 0, data: null, error: `${label}: health probe unavailable`, diagnostic } as ApiResult; + } + async function createSession(): Promise { beginSessionSelection(); loading.value = true; @@ -396,6 +409,8 @@ export const useWorkbenchStore = defineStore("workbench", () => { function rememberSessionList(source: WorkbenchSessionRecord[]): void { reduceServerState({ type: "session.list", sessions: source }); + const trimmed = trimWorkbenchSessionCache(serverState.value, { retainSessionIds: [activeSessionId.value, explicitSessionId.value], maxSessions: currentSessionListLimit() }); + serverState.value = trimmed.state; } function forgetSession(sessionId: string): void { @@ -458,50 +473,11 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function runWorkbenchReadHydration(task: () => Promise, cooldownKey?: string, options: { minIntervalMs?: number; force?: boolean } = {}): Promise { - return new Promise((resolve, reject) => { - const run = () => { - if (cooldownKey) { - const cooldownUntilMs = workbenchReadCooldownUntilByKey.get(cooldownKey) ?? 0; - if (cooldownUntilMs > Date.now()) { - resolve(createWorkbenchReadCooldownResult(cooldownKey, cooldownUntilMs) as T); - const next = workbenchReadHydrationQueue.shift(); - if (next) next(); - return; - } - if (cooldownUntilMs > 0) workbenchReadCooldownUntilByKey.delete(cooldownKey); - const minIntervalMs = Math.max(0, Math.trunc(options.minIntervalMs ?? 0)); - const lastStartedAtMs = workbenchReadLastStartedAtByKey.get(cooldownKey) ?? 0; - if (!options.force && minIntervalMs > 0 && lastStartedAtMs > 0 && Date.now() - lastStartedAtMs < minIntervalMs) { - resolve(createWorkbenchReadThrottledResult(cooldownKey, lastStartedAtMs + minIntervalMs) as T); - const next = workbenchReadHydrationQueue.shift(); - if (next) next(); - return; - } - if (minIntervalMs > 0) workbenchReadLastStartedAtByKey.set(cooldownKey, Date.now()); - } - workbenchReadHydrationActive += 1; - Promise.resolve() - .then(task) - .then( - (value) => { - if (cooldownKey && isApiResultLike(value) && shouldCooldownWorkbenchReadFailure(value)) { - workbenchReadCooldownUntilByKey.set(cooldownKey, Date.now() + WORKBENCH_READ_FAILURE_COOLDOWN_MS); - } - resolve(value); - }, - reject, - ) - .finally(() => { - workbenchReadHydrationActive = Math.max(0, workbenchReadHydrationActive - 1); - const next = workbenchReadHydrationQueue.shift(); - if (next) next(); - }); - }; - if (workbenchReadHydrationActive < WORKBENCH_READ_HYDRATION_CONCURRENCY) { - run(); - return; - } - workbenchReadHydrationQueue.push(run); + return workbenchReadHydrationRuntime.run(task, cooldownKey, { + minIntervalMs: options.minIntervalMs, + force: options.force, + shouldCooldownFailure: (value) => isApiResultLike(value) && shouldCooldownWorkbenchReadFailure(value), + reason: cooldownKey ?? null, }); } @@ -568,13 +544,7 @@ export const useWorkbenchStore = defineStore("workbench", () => { } function shouldCooldownWorkbenchReadFailure(result: ApiResult): boolean { - const diagnostic = normalizeErrorDiagnostic(result.diagnostic, result.apiError?.diagnostic); - const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code); - const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category); - const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source); - if (result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network") return true; - if (result.status === 503) return code === "projection_store_unavailable" || code === "workbench_read_model_store_unavailable"; - return false; + return shouldCooldownWorkbenchReadRuntimeFailure(result); } function fetchWorkbenchTurnStatus(traceId: string, useActivityTimeout = shouldUseActivityTimeoutForTrace(traceId), options: { force?: boolean } = {}): Promise> { @@ -1109,7 +1079,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project function setProviderProfile(value: ProviderProfile): void { providerProfile.value = value; - localStorage.setItem("hwlab.workbench.providerProfile.v1", value); + writeWorkbenchString("hwlab.workbench.providerProfile.v1", value); } async function refreshProviderOptions(): Promise { @@ -1129,7 +1099,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project function clearRecentDrafts(): void { recentDrafts.value = []; - try { localStorage.removeItem(RECENT_DRAFTS_STORAGE_KEY); } catch { /* ignore */ } + removeWorkbenchStorageKey(RECENT_DRAFTS_STORAGE_KEY); } function clearSessionMessages(): void { @@ -1166,12 +1136,8 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project const traceId = realtimeTraceId(); const sessionId = selectedSessionId.value ?? null; const key = workbenchRealtimeScopeKey(sessionId, traceId); - if (key === realtimeKey && realtimeStream) return; - stopRealtime(); - realtimeKey = key; - if (!sessionId && !traceId) return; const afterSeq = realtimeOutboxSeqByKey.get(key) ?? null; - realtimeStream = connectWorkbenchEvents({ + realtimeTransport.restart({ sessionId, traceId, afterSeq, @@ -1241,9 +1207,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project } function stopRealtime(): void { - realtimeStream?.close(); - realtimeStream = null; - realtimeKey = ""; + realtimeTransport.stop(); } function realtimeTraceId(): string | null { @@ -1453,7 +1417,7 @@ function nonBlockingProjection(projection: ProjectionDiagnostic | null): Project if (!id) return; const payload = { type: "session-projection", sourceId: workbenchProjectionSignalSourceId, sessionId: id, traceId: firstNonEmptyString(traceId) ?? null, reason, at: new Date().toISOString(), valuesRedacted: true }; try { workbenchProjectionSignalChannel?.postMessage(payload); } catch { /* ignore */ } - try { window.localStorage.setItem(WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY, JSON.stringify(payload)); } catch { /* ignore */ } + writeWorkbenchString(WORKBENCH_SESSION_PROJECTION_SIGNAL_KEY, JSON.stringify(payload)); } function handleWorkbenchProjectionSignal(value: unknown): void { @@ -2401,51 +2365,11 @@ function normalizeProjectionBlocker(value: unknown, context: { diagnostic?: Erro } function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null { - for (const value of values) { - const record = recordValue(value); - if (!record) continue; - const httpStatus = firstFiniteNumber(record.httpStatus, record.http_status); - return { - ...record, - contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"), - traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id), - requestId: firstNonEmptyString(record.requestId, record.request_id), - serviceId: firstNonEmptyString(record.serviceId, record.service_id), - route: firstNonEmptyString(record.route), - layer: firstNonEmptyString(record.layer), - category: firstNonEmptyString(record.category), - code: firstStringOrNumber(record.code), - httpStatus: httpStatus ?? null, - source: firstNonEmptyString(record.source), - observedAt: firstNonEmptyString(record.observedAt, record.observed_at), - retryable: firstBoolean(record.retryable), - valuesPrinted: record.valuesPrinted === true - }; - } - return null; + return normalizeRuntimeErrorDiagnostic(...values); } function normalizeApiErrorRecord(value: unknown, fallback: string | null = null, diagnostic: ErrorDiagnostic | null = null): ApiError | null { - const record = recordValue(value); - if (!record) return null; - const normalizedDiagnostic = normalizeErrorDiagnostic(record.diagnostic, diagnostic); - const message = firstNonEmptyString(record.message, record.userMessage, record.reason, fallback); - return { - ...record, - message: message ?? fallback ?? "Workbench 请求失败", - userMessage: firstNonEmptyString(record.userMessage, record.message, fallback), - code: firstStringOrNumber(record.code, normalizedDiagnostic?.code), - reason: firstNonEmptyString(record.reason), - retryable: firstBoolean(record.retryable, normalizedDiagnostic?.retryable), - layer: firstNonEmptyString(record.layer, normalizedDiagnostic?.layer), - category: firstNonEmptyString(record.category, normalizedDiagnostic?.category), - route: firstNonEmptyString(record.route, normalizedDiagnostic?.route), - traceId: firstNonEmptyString(record.traceId, normalizedDiagnostic?.traceId), - requestId: firstNonEmptyString(record.requestId, normalizedDiagnostic?.requestId), - source: firstNonEmptyString(record.source, normalizedDiagnostic?.source), - diagnostic: normalizedDiagnostic, - valuesPrinted: record.valuesPrinted === true - } as ApiError; + return normalizeRuntimeApiErrorRecord(value, fallback, diagnostic); } function agentErrorFromApiFailure(result: ApiResult, projection: ProjectionDiagnostic, fallback: string): ChatMessage["error"] { @@ -2666,31 +2590,17 @@ function isTraceActiveStatus(status: unknown): boolean { } function readString(key: string, fallback: string): string { - try { - return localStorage.getItem(key) ?? fallback; - } catch { - return fallback; - } + return readWorkbenchString(key, fallback); } function readRecentDrafts(): DraftEntry[] { - try { - const raw = localStorage.getItem(RECENT_DRAFTS_STORAGE_KEY); - return raw ? normalizeRecentDrafts(JSON.parse(raw)) : []; - } catch { - return []; - } + return normalizeRecentDrafts(readWorkbenchJson(RECENT_DRAFTS_STORAGE_KEY, []).value); } function writeRecentDrafts(drafts: DraftEntry[]): void { - try { localStorage.setItem(RECENT_DRAFTS_STORAGE_KEY, JSON.stringify(drafts)); } catch { /* ignore */ } + writeWorkbenchJson(RECENT_DRAFTS_STORAGE_KEY, drafts); } function readNumber(key: string, fallback: number): number { - try { - const value = Number(localStorage.getItem(key)); - return Number.isFinite(value) && value > 0 ? value : fallback; - } catch { - return fallback; - } + return readWorkbenchNumber(key, fallback); } diff --git a/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts b/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts new file mode 100644 index 00000000..dfb7ef8e --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/workbench-error-runtime.ts @@ -0,0 +1,86 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. +// Responsibility: Workbench typed error and diagnostic normalization. Mechanically migrated from OpenCode error-envelope semantics, then adapted to HWLAB API contracts. + +import type { ApiError, ErrorDiagnostic } from "@/types"; + +export function normalizeErrorDiagnostic(...values: unknown[]): ErrorDiagnostic | null { + for (const value of values) { + const record = recordValue(value); + if (!record) continue; + const httpStatus = firstFiniteNumber(record.httpStatus, record.http_status); + return { + ...record, + contractVersion: firstNonEmptyString(record.contractVersion, record.contract_version, "hwlab-error-diagnostic-v1"), + traceId: firstNonEmptyString(record.traceId, record.trace_id, record.otelTraceId, record.otel_trace_id), + requestId: firstNonEmptyString(record.requestId, record.request_id), + serviceId: firstNonEmptyString(record.serviceId, record.service_id), + route: firstNonEmptyString(record.route), + layer: firstNonEmptyString(record.layer), + category: firstNonEmptyString(record.category), + code: firstStringOrNumber(record.code), + httpStatus: httpStatus ?? null, + source: firstNonEmptyString(record.source), + observedAt: firstNonEmptyString(record.observedAt, record.observed_at), + retryable: firstBoolean(record.retryable), + valuesPrinted: record.valuesPrinted === true + }; + } + return null; +} + +export function normalizeApiErrorRecord(value: unknown, fallback: string | null = null, diagnostic: ErrorDiagnostic | null = null): ApiError | null { + const record = recordValue(value); + if (!record) return null; + const normalizedDiagnostic = normalizeErrorDiagnostic(record.diagnostic, diagnostic); + const message = firstNonEmptyString(record.message, record.userMessage, record.reason, fallback); + return { + ...record, + message: message ?? fallback ?? "Workbench 请求失败", + userMessage: firstNonEmptyString(record.userMessage, record.message, fallback), + code: firstStringOrNumber(record.code, normalizedDiagnostic?.code), + reason: firstNonEmptyString(record.reason), + retryable: firstBoolean(record.retryable, normalizedDiagnostic?.retryable), + layer: firstNonEmptyString(record.layer, normalizedDiagnostic?.layer), + category: firstNonEmptyString(record.category, normalizedDiagnostic?.category), + route: firstNonEmptyString(record.route, normalizedDiagnostic?.route), + traceId: firstNonEmptyString(record.traceId, normalizedDiagnostic?.traceId), + requestId: firstNonEmptyString(record.requestId, normalizedDiagnostic?.requestId), + source: firstNonEmptyString(record.source, normalizedDiagnostic?.source), + diagnostic: normalizedDiagnostic, + valuesPrinted: record.valuesPrinted === true + } as ApiError; +} + +function recordValue(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +function firstNonEmptyString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value !== "string") continue; + const text = value.trim(); + if (text) return text; + } + return null; +} + +function firstStringOrNumber(...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 firstFiniteNumber(...values: unknown[]): number | null { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number)) return Math.trunc(number); + } + return null; +} + +function firstBoolean(...values: unknown[]): boolean | null { + for (const value of values) if (typeof value === "boolean") return value; + return null; +} diff --git a/web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts b/web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts new file mode 100644 index 00000000..43c1f9b3 --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/workbench-realtime-runtime.ts @@ -0,0 +1,4 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. +// Responsibility: Production Workbench realtime runtime entry point for SSE transport, event coalescing, and scoped cursor ownership. + +export { createWorkbenchStreamTransportRuntime, WorkbenchStreamTransportRuntime, type WorkbenchStreamTransportRestartInput, type WorkbenchStreamTransportRestartResult, type WorkbenchRealtimeEvent } from "@/utils/workbench-stream-transport"; diff --git a/web/hwlab-cloud-web/src/utils/workbench-refresh-runtime.ts b/web/hwlab-cloud-web/src/utils/workbench-refresh-runtime.ts new file mode 100644 index 00000000..1bd57708 --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/workbench-refresh-runtime.ts @@ -0,0 +1,154 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. +// Responsibility: Workbench REST refresh queue, keyed single-flight, cooldown, and request-storm suppression. + +import { AsyncQueue } from "@/utils/scheduler/async-queue"; +import { createKeyedSingleflight } from "@/utils/scheduler/keyed-singleflight"; +import { normalizeErrorDiagnostic } from "@/utils/workbench-error-runtime"; + +interface ApiResultLike { + ok: boolean; + status: number; + data?: unknown; + error?: string | null; + apiError?: Record | null; + diagnostic?: Record | null; +} + +export interface WorkbenchReadHydrationRuntimeOptions { + concurrency: number; + failureCooldownMs: number; + now?: () => number; +} + +export interface WorkbenchReadHydrationRunOptions { + minIntervalMs?: number; + force?: boolean; + shouldCooldownFailure?: (value: unknown) => boolean; + reason?: string | null; +} + +type QueuedTask = () => void; + +export class WorkbenchReadHydrationRuntime { + private readonly queue = new AsyncQueue(); + private readonly singleflight = createKeyedSingleflight(); + private readonly cooldownUntilByKey = new Map(); + private readonly lastStartedAtByKey = new Map(); + private readonly now: () => number; + private readonly failureCooldownMs: number; + + constructor(options: WorkbenchReadHydrationRuntimeOptions) { + this.now = options.now ?? Date.now; + this.failureCooldownMs = Math.max(0, Math.trunc(options.failureCooldownMs)); + const concurrency = Math.max(1, Math.trunc(options.concurrency)); + for (let index = 0; index < concurrency; index += 1) void this.workerLoop(); + } + + run(task: () => Promise, cooldownKey?: string, options: WorkbenchReadHydrationRunOptions = {}): Promise { + const key = normalizeOptionalKey(cooldownKey); + const execute = () => this.enqueue(() => this.executeTask(task, key, options)); + if (!key) return execute(); + return this.singleflight.run(key, () => execute(), { reason: options.reason ?? null }) as Promise; + } + + private enqueue(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.queue.push(() => { + void task().then(resolve, reject); + }); + }); + } + + private async executeTask(task: () => Promise, key: string | null, options: WorkbenchReadHydrationRunOptions): Promise { + if (key) { + const now = this.now(); + const cooldownUntilMs = this.cooldownUntilByKey.get(key) ?? 0; + if (cooldownUntilMs > now) return createWorkbenchReadCooldownResult(key, cooldownUntilMs, now) as T; + if (cooldownUntilMs > 0) this.cooldownUntilByKey.delete(key); + const minIntervalMs = Math.max(0, Math.trunc(options.minIntervalMs ?? 0)); + const lastStartedAtMs = this.lastStartedAtByKey.get(key) ?? 0; + if (!options.force && minIntervalMs > 0 && lastStartedAtMs > 0 && now - lastStartedAtMs < minIntervalMs) return createWorkbenchReadThrottledResult(key, lastStartedAtMs + minIntervalMs, now) as T; + if (minIntervalMs > 0) this.lastStartedAtByKey.set(key, now); + } + const value = await task(); + if (key && options.shouldCooldownFailure?.(value)) this.cooldownUntilByKey.set(key, this.now() + this.failureCooldownMs); + return value; + } + + private async workerLoop(): Promise { + for (;;) { + const run = await this.queue.next(); + run(); + } + } +} + +export function createWorkbenchReadHydrationRuntime(options: WorkbenchReadHydrationRuntimeOptions): WorkbenchReadHydrationRuntime { + return new WorkbenchReadHydrationRuntime(options); +} + +export function shouldCooldownWorkbenchReadFailure(value: unknown): boolean { + const result = value as ApiResultLike | null; + if (!result || typeof result !== "object" || typeof result.status !== "number") return false; + const diagnostic = normalizeErrorDiagnostic(result.diagnostic, result.apiError?.diagnostic); + const code = firstStringOrNumber(result.apiError?.code, diagnostic?.code); + const category = firstNonEmptyString(result.apiError?.category, diagnostic?.category); + const source = firstNonEmptyString(result.apiError?.source, diagnostic?.source); + if (result.status === 0 && source === "browser" && code === "browser_network_error" && category === "network") return true; + if (result.status === 503) return code === "projection_store_unavailable" || code === "workbench_read_model_store_unavailable"; + return false; +} + +function createWorkbenchReadCooldownResult(key: string, cooldownUntilMs: number, now: number): ApiResultLike { + const retryAfterMs = Math.max(0, cooldownUntilMs - now); + const message = `Workbench read hydration is cooling down after a transient failure (${key}); retry after ${retryAfterMs}ms.`; + const diagnostic = diagnosticEnvelope("workbench_read_hydration_cooldown", "network", message, key, retryAfterMs); + return { ok: false, status: 503, data: null, error: message, apiError: { ...diagnostic, message, diagnostic }, diagnostic }; +} + +function createWorkbenchReadThrottledResult(key: string, nextAllowedAtMs: number, now: number): ApiResultLike { + const retryAfterMs = Math.max(0, nextAllowedAtMs - now); + const message = `Workbench read hydration is throttled (${key}); retry after ${retryAfterMs}ms.`; + const diagnostic = diagnosticEnvelope("workbench_read_hydration_throttled", "throttle", message, key, retryAfterMs); + return { ok: false, status: 0, data: null, error: message, apiError: { ...diagnostic, message, diagnostic }, diagnostic }; +} + +function diagnosticEnvelope(code: string, category: string, message: string, key: string, retryAfterMs: number): Record { + return { + contractVersion: "hwlab-error-diagnostic-v1", + code, + category, + source: "workbench-web", + layer: "workbench-refresh-runtime", + message, + retryable: true, + recoveryAction: "suppress-duplicate-refresh", + rootCause: code, + scopedKey: key, + retryAfterMs, + valuesPrinted: false, + valuesRedacted: true + }; +} + +function normalizeOptionalKey(value: string | null | undefined): string | null { + const text = String(value ?? "").trim(); + return text || 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 firstStringOrNumber(...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; +} diff --git a/web/hwlab-cloud-web/src/utils/workbench-storage-runtime.ts b/web/hwlab-cloud-web/src/utils/workbench-storage-runtime.ts new file mode 100644 index 00000000..153f9faa --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/workbench-storage-runtime.ts @@ -0,0 +1,40 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. +// Responsibility: Workbench local storage namespace wrapper backed by SafeStorageRuntime. + +import { createSafeStorageRuntime, readJsonStorage, removeStorageKey, writeJsonStorage, type SafeStorageResult, type StorageLike } from "@/utils/safe-storage"; + +const runtime = createSafeStorageRuntime(); + +export function readWorkbenchString(key: string, fallback: string): string { + return workbenchStorage().getItem(key) ?? fallback; +} + +export function writeWorkbenchString(key: string, value: string): void { + workbenchStorage().setItem(key, value); +} + +export function removeWorkbenchStorageKey(key: string): SafeStorageResult { + return removeStorageKey(workbenchStorage(), key); +} + +export function readWorkbenchNumber(key: string, fallback: number): number { + const value = Number(readWorkbenchString(key, "")); + return Number.isFinite(value) && value > 0 ? value : fallback; +} + +export function readWorkbenchJson(key: string, fallback: T): SafeStorageResult { + return readJsonStorage(workbenchStorage(), key, fallback); +} + +export function writeWorkbenchJson(key: string, value: T): SafeStorageResult { + return writeJsonStorage(workbenchStorage(), key, value); +} + +function workbenchStorage(): StorageLike { + return runtime.localStorageDirect(globalLocalStorage(), "workbench"); +} + +function globalLocalStorage(): StorageLike | null { + if (typeof globalThis === "undefined") return null; + return (globalThis as typeof globalThis & { localStorage?: StorageLike }).localStorage ?? null; +} diff --git a/web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts b/web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts new file mode 100644 index 00000000..a43ddd6a --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/workbench-stream-transport.ts @@ -0,0 +1,58 @@ +// SPEC: PJ2026-0106050514 Workbench实时运行面 draft-2026-06-30-p0-1297-spec-first. +// Responsibility: Workbench SSE transport lifecycle and scope-key ownership. + +import { connectWorkbenchEvents, type WorkbenchEventStream, type WorkbenchRealtimeEvent } from "@/api/workbench-events"; +import { workbenchRealtimeScopeKey } from "@/utils/workbench-key"; + +export type { WorkbenchRealtimeEvent }; + +export interface WorkbenchStreamTransportRestartInput { + sessionId?: string | null; + traceId?: string | null; + afterSeq?: number | null; + onOpen?: () => void; + onError?: (event: Event) => void; + onEvent: (event: WorkbenchRealtimeEvent, eventName: string) => void; +} + +export interface WorkbenchStreamTransportRestartResult { + key: string; + changed: boolean; + streamStarted: boolean; +} + +export class WorkbenchStreamTransportRuntime { + private stream: WorkbenchEventStream | null = null; + private key = ""; + + restart(input: WorkbenchStreamTransportRestartInput): WorkbenchStreamTransportRestartResult { + const key = workbenchRealtimeScopeKey(input.sessionId ?? null, input.traceId ?? null); + if (key === this.key && this.stream) return { key, changed: false, streamStarted: true }; + this.stop(); + this.key = key; + if (!input.sessionId && !input.traceId) return { key, changed: true, streamStarted: false }; + this.stream = connectWorkbenchEvents({ + sessionId: input.sessionId ?? null, + traceId: input.traceId ?? null, + afterSeq: input.afterSeq ?? null, + onOpen: input.onOpen, + onError: input.onError, + onEvent: input.onEvent + }); + return { key, changed: true, streamStarted: Boolean(this.stream) }; + } + + stop(): void { + this.stream?.close(); + this.stream = null; + this.key = ""; + } + + currentKey(): string { + return this.key; + } +} + +export function createWorkbenchStreamTransportRuntime(): WorkbenchStreamTransportRuntime { + return new WorkbenchStreamTransportRuntime(); +}