From 759092ec5074f71b268b652711e51f1178fa5307 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 17:34:29 +0200 Subject: [PATCH] fix: keep Workbench metadata drift non-blocking --- .../workbench-provider-profile.test.ts | 9 +++- .../workbench-realtime-runtime.test.ts | 36 ++++++++++++- .../src/stores/workbench-realtime-plan.ts | 51 +++++++++++++++++++ .../src/stores/workbench-session.ts | 6 +++ web/hwlab-cloud-web/src/stores/workbench.ts | 48 +++++++++++------ .../src/utils/workbench-performance.ts | 9 ++-- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/web/hwlab-cloud-web/scripts/workbench-provider-profile.test.ts b/web/hwlab-cloud-web/scripts/workbench-provider-profile.test.ts index d261011c..6254f919 100644 --- a/web/hwlab-cloud-web/scripts/workbench-provider-profile.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-provider-profile.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { providerProfileOptionsFromPayload } from "../src/stores/workbench-session.ts"; +import { preferredProviderProfile, providerProfileOptionsFromPayload } from "../src/stores/workbench-session.ts"; test("Workbench provider catalog marks missing selected profile as unavailable", () => { const options = providerProfileOptionsFromPayload({ profiles: [{ profile: "dsflash-go", label: "DeepSeek V4 Flash", configured: true }] }, "codex"); @@ -11,4 +11,11 @@ test("Workbench provider catalog marks missing selected profile as unavailable", assert.equal(available?.configured, true); assert.equal(missingSelected?.configured, false); + assert.equal(preferredProviderProfile(options, "codex"), "dsflash-go"); +}); + +test("Workbench provider selection stays non-blocking without a configured catalog option", () => { + const options = providerProfileOptionsFromPayload({ profiles: [{ profile: "codex", configured: false }] }, "codex"); + assert.equal(preferredProviderProfile(options, "codex"), "codex"); + assert.equal(preferredProviderProfile(providerProfileOptionsFromPayload({}, "codex"), "codex"), "codex"); }); diff --git a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts index c8ac44ca..3590caec 100644 --- a/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts +++ b/web/hwlab-cloud-web/scripts/workbench-realtime-runtime.test.ts @@ -21,7 +21,7 @@ import { messageDiagnosticView } from "../src/utils/workbench-error-runtime.ts"; import { projectRejectedWorkbenchAdmission } from "../src/stores/workbench-admission-failure.ts"; import { WORKBENCH_TIMELINE_OPENCODE_PARITY, buildWorkbenchTimelineRows, normalizeWorkbenchTimelineMessages, workbenchTimelineSignature } from "../src/stores/workbench-timeline-model.ts"; import { reduceWorkbenchRealtimeEvent } from "../src/stores/workbench-event-reducer.ts"; -import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery } from "../src/stores/workbench-realtime-plan.ts"; +import { assessWorkbenchRealtimeConnected, planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery } from "../src/stores/workbench-realtime-plan.ts"; import { WORKBENCH_REALTIME_AUTHORITY_VERSION, workbenchRealtimePrimaryAuthorityDecision } from "../src/stores/workbench-realtime-authority.ts"; import { cleanupWorkbenchServerStateDroppedSessions, cleanupWorkbenchServerStateSessions, createWorkbenchServerState, reduceWorkbenchServerState } from "../src/stores/workbench-server-state.ts"; import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "../src/stores/workbench-session-cache.ts"; @@ -93,6 +93,40 @@ test("the active projection trace scope follows the current request", () => { assert.equal(workbenchRealtimeTraceIdForCapabilities({ liveKafkaSse: true, kafkaRefreshReplay: true, projectionRealtime: false }, "trc_current_request"), null); }); +test("connected realtime metadata drift warns without blocking the active session", () => { + const assessment = assessWorkbenchRealtimeConnected({ + deliverySemantics: "live-only", + liveOnly: true, + replay: false, + filters: { sessionId: "ses_active" } + }, "ses_active", false); + + assert.equal(assessment.ready, true); + assert.equal(assessment.connectedSessionId, "ses_active"); + assert.equal(assessment.blocker, null); + assert.deepEqual(assessment.warning?.fields, [ + "capabilities.liveKafkaSse", + "capabilities.kafkaRefreshReplay" + ]); + assert.equal(assessment.warning?.severity, "warning"); +}); + +test("connected realtime session scope mismatch remains blocking", () => { + const assessment = assessWorkbenchRealtimeConnected({ + deliverySemantics: "live-only", + liveOnly: true, + replay: false, + capabilities: { liveKafkaSse: true, kafkaRefreshReplay: false, projectionRealtime: false }, + filters: { sessionId: "ses_other" } + }, "ses_active", false); + + assert.equal(assessment.ready, false); + assert.equal(assessment.warning, null); + assert.equal(assessment.blocker?.code, "workbench_realtime_session_scope_mismatch"); + assert.equal(assessment.blocker?.expectedSessionId, "ses_active"); + assert.equal(assessment.blocker?.connectedSessionId, "ses_other"); +}); + test("Error runtime owns Workbench message diagnostic view model", () => { const degraded = messageDiagnosticView(agentMessage({ status: "running", diff --git a/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts b/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts index 8c70fb56..b0ae2163 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-realtime-plan.ts @@ -51,6 +51,23 @@ export interface WorkbenchRealtimeRecoveryPlan { }; } +export interface WorkbenchRealtimeConnectedAssessment { + connectedSessionId: string | null; + ready: boolean; + warning: { + code: "workbench_realtime_metadata_drift_warning"; + fields: string[]; + severity: "warning"; + valuesRedacted: true; + } | null; + blocker: { + code: "workbench_realtime_session_scope_mismatch"; + expectedSessionId: string | null; + connectedSessionId: string | null; + valuesRedacted: true; + } | null; +} + export function planWorkbenchRealtimeApply(action: WorkbenchRealtimeAction): WorkbenchRealtimeApplyPlan { const steps = applySteps(action); return { @@ -65,6 +82,40 @@ export function planWorkbenchRealtimeApply(action: WorkbenchRealtimeAction): Wor }; } +export function assessWorkbenchRealtimeConnected( + event: WorkbenchRealtimeEvent, + expectedSessionId: string | null | undefined, + kafkaRefreshReplay: boolean +): WorkbenchRealtimeConnectedAssessment { + const filters = event.filters && typeof event.filters === "object" ? event.filters as Record : null; + const connectedSessionId = normalizeWorkbenchSessionId(filters?.sessionId); + const normalizedExpectedSessionId = normalizeWorkbenchSessionId(expectedSessionId); + const scopeMatches = Boolean(connectedSessionId && normalizedExpectedSessionId && connectedSessionId === normalizedExpectedSessionId); + const fields: string[] = []; + const expectedDelivery = kafkaRefreshReplay ? "kafka-retention-then-live" : "live-only"; + if (event.deliverySemantics !== expectedDelivery) fields.push("deliverySemantics"); + if (event.liveOnly !== !kafkaRefreshReplay) fields.push("liveOnly"); + if (event.replay !== kafkaRefreshReplay) fields.push("replay"); + if (kafkaRefreshReplay && event.lossPossible !== false) fields.push("lossPossible"); + if (event.capabilities?.liveKafkaSse !== true) fields.push("capabilities.liveKafkaSse"); + if (event.capabilities?.kafkaRefreshReplay !== kafkaRefreshReplay) fields.push("capabilities.kafkaRefreshReplay"); + return { + connectedSessionId, + ready: scopeMatches, + warning: fields.length > 0 + ? { code: "workbench_realtime_metadata_drift_warning", fields, severity: "warning", valuesRedacted: true } + : null, + blocker: scopeMatches + ? null + : { + code: "workbench_realtime_session_scope_mismatch", + expectedSessionId: normalizedExpectedSessionId, + connectedSessionId, + valuesRedacted: true + } + }; +} + export function planWorkbenchRealtimeRecovery(recovery: WorkbenchStreamTransportRecovery, context: WorkbenchRealtimeRecoveryContext): WorkbenchRealtimeRecoveryPlan { const sessionId = normalizeWorkbenchSessionId(recovery.sessionId ?? context.selectedSessionId); const traceId = firstNonEmptyString(recovery.traceId, context.fallbackTraceId); diff --git a/web/hwlab-cloud-web/src/stores/workbench-session.ts b/web/hwlab-cloud-web/src/stores/workbench-session.ts index 56a89311..48b31726 100644 --- a/web/hwlab-cloud-web/src/stores/workbench-session.ts +++ b/web/hwlab-cloud-web/src/stores/workbench-session.ts @@ -259,6 +259,12 @@ export function defaultProviderProfileOptions(selectedProfile: ProviderProfile): return ensureSelectedProviderProfile(BUILTIN_PROVIDER_PROFILE_ORDER.map((profile) => ({ value: profile, label: providerProfileLabel(profile) })), selectedProfile); } +export function preferredProviderProfile(options: ProviderProfileOption[], selectedProfile: ProviderProfile): ProviderProfile { + const selected = options.find((option) => option.value === selectedProfile); + if (selected?.configured !== false) return selectedProfile; + return options.find((option) => option.configured === true)?.value ?? selectedProfile; +} + function activeSession(sessions: WorkbenchSessionRecord[], sessionId: string | null): WorkbenchSessionRecord | null { return sessionId ? sessions.find((item) => item.sessionId === sessionId) ?? null : null; } diff --git a/web/hwlab-cloud-web/src/stores/workbench.ts b/web/hwlab-cloud-web/src/stores/workbench.ts index 9f31b912..f1c2df6e 100644 --- a/web/hwlab-cloud-web/src/stores/workbench.ts +++ b/web/hwlab-cloud-web/src/stores/workbench.ts @@ -17,7 +17,7 @@ import type { AgentChatResponse, AgentChatResultResponse, AgentRunProvenance, Ap import { firstNonEmptyString, nextProtocolId, normalizeWorkbenchSessionId, normalizeWorkbenchSessionRouteId } from "@/utils"; import { composeWorkbenchScopedKey, workbenchRealtimeScopeKey } from "@/utils/workbench-key"; 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, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; +import { RECENT_DRAFTS_STORAGE_KEY, appendSessionPage, defaultProviderProfileOptions, isArchivedSession, mergeSessionIntoList, normalizeChatMessageStatus, normalizeRecentDrafts, normalizeWorkbenchMessageTitle, preferredProviderProfile, providerProfileOptionsFromPayload, recordRecentDraft, resolveCancelableAgentMessage, resolveComposerState, shouldReadTerminalTraceDetail, shouldShowSessionListLoading, sortSessionTabs, stableSessionList, type DraftEntry, type ProviderProfileOption, type TurnStatusAuthority } from "./workbench-session"; import { initialWorkbenchSessionIdFromLocation } from "./workbench-projection"; import { cleanupWorkbenchServerStateSessions, selectActiveMessages, selectActiveSession, selectSessionList, selectSessionStatusAuthority, selectTraceAuthorityById, selectTurnStatusAuthority, type WorkbenchServerAction } from "./workbench-server-state"; import { cleanupDroppedWorkbenchSessionCaches, trimWorkbenchSessionCache } from "./workbench-session-cache"; @@ -65,7 +65,7 @@ import { traceHasEvents, traceResultHasTerminalEvidence, } from "./workbench-message-projection-runtime"; -import { planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type WorkbenchRealtimeApplyStep, type WorkbenchRealtimeRecoveryStep } from "./workbench-realtime-plan"; +import { assessWorkbenchRealtimeConnected, planWorkbenchRealtimeApply, planWorkbenchRealtimeRecovery, type WorkbenchRealtimeApplyStep, type WorkbenchRealtimeRecoveryStep } from "./workbench-realtime-plan"; import { useWorkbenchColadaMutations } from "./workbench-colada-mutations"; import { useWorkbenchColadaQueries } from "./workbench-colada-queries"; import { useWorkbenchColadaReducer } from "./workbench-colada-reducer"; @@ -921,7 +921,13 @@ export const useWorkbenchStore = defineStore("workbench", () => { async function refreshProviderOptions(): Promise { const response = await workbenchColadaQueries.fetchProviderProfiles({ minIntervalMs: runtimePolicy.workbenchReadFailureCooldownMs }); - providerOptions.value = response.ok ? providerProfileOptionsFromPayload(response.data, providerProfile.value) : defaultProviderProfileOptions(providerProfile.value); + if (response.ok) { + providerOptions.value = providerProfileOptionsFromPayload(response.data, providerProfile.value); + const preferred = preferredProviderProfile(providerOptions.value, providerProfile.value); + if (preferred !== providerProfile.value) setProviderProfile(preferred); + } else { + providerOptions.value = defaultProviderProfileOptions(providerProfile.value); + } return response.ok === true; } @@ -983,18 +989,30 @@ export const useWorkbenchStore = defineStore("workbench", () => { onRecovery: (recovery) => handleRealtimeRecovery(recovery), onEvent: (event, eventName) => { if (realtimeCapabilities.liveKafkaSse && eventName === "workbench.connected") { - const filters = recordValue(event.filters); - const connectedSessionId = normalizeWorkbenchSessionId(filters?.sessionId); - const refreshReplay = realtimeCapabilities.kafkaRefreshReplay; - const deliveryValid = refreshReplay - ? event.deliverySemantics === "kafka-retention-then-live" && event.liveOnly === false && event.replay === true && event.lossPossible === false - : event.deliverySemantics === "live-only" && event.liveOnly === true && event.replay === false; - const valid = deliveryValid - && event.capabilities?.liveKafkaSse === true - && event.capabilities?.kafkaRefreshReplay === refreshReplay - && connectedSessionId === sessionId; - liveRealtimeReadySessionId.value = valid ? connectedSessionId : null; - if (!valid) error.value = "workbench_live_realtime_contract_invalid"; + const assessment = assessWorkbenchRealtimeConnected(event, sessionId, realtimeCapabilities.kafkaRefreshReplay); + liveRealtimeReadySessionId.value = assessment.ready ? assessment.connectedSessionId : null; + if (assessment.warning) { + recordWorkbenchRuntimeDiagnostic({ + module: "workbench-stream-contract", + sessionId, + traceId, + outcome: "partial", + level: "warning", + diagnostic: assessment.warning + }); + } + if (assessment.blocker) { + error.value = assessment.blocker.code; + recordWorkbenchRuntimeDiagnostic({ + module: "workbench-stream-contract", + sessionId, + traceId, + outcome: "error", + diagnostic: assessment.blocker + }); + } else if (error.value === "workbench_live_realtime_contract_invalid" || error.value === "workbench_realtime_session_scope_mismatch") { + error.value = null; + } } applyRealtimeEvent(event, eventName); } diff --git a/web/hwlab-cloud-web/src/utils/workbench-performance.ts b/web/hwlab-cloud-web/src/utils/workbench-performance.ts index 12b451dd..8eef5e6d 100644 --- a/web/hwlab-cloud-web/src/utils/workbench-performance.ts +++ b/web/hwlab-cloud-web/src/utils/workbench-performance.ts @@ -365,25 +365,26 @@ export function recordWorkbenchSseLifecycle(input: { state: "connect" | "open" | }); } -export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null; diagnostic?: Record | null; sessionId?: string | null; traceId?: string | null; outcome?: WorkbenchOutcome | null }): void { +export function recordWorkbenchRuntimeDiagnostic(input: { module?: string | null; diagnostic?: Record | null; sessionId?: string | null; traceId?: string | null; outcome?: WorkbenchOutcome | null; level?: "error" | "warning" | "sample" }): void { ensureInstalled(); const diagnostic = recordValue(input.diagnostic); const endedAt = wallNow(); const code = diagnosticText(diagnostic, "code") ?? "runtime_diagnostic"; const flushDurationMs = diagnosticNumber(diagnostic, "flushDurationMs"); const isSseFlush = code === "workbench_sse_flush"; + const warning = input.level === "warning" || diagnosticText(diagnostic, "severity", "level") === "warning"; enqueueWorkbenchUiEvent({ eventType: "runtime_diagnostic", loadingScope: "sse", - state: isSseFlush ? "sample" : "error", - reason: isSseFlush ? "sse_flush" : "sse_error", + state: isSseFlush || warning ? "sample" : "error", + reason: isSseFlush ? "sse_flush" : warning ? "unknown" : "sse_error", route: pageRoute(), valueMs: flushDurationMs ?? 0, startedAtEpochMs: Math.max(0, endedAt - (flushDurationMs ?? 0)), 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")), + outcome: input.outcome ?? (warning ? "partial" : normalizeUiOutcome(diagnosticText(diagnostic, "outcome"))), errorName: code.slice(0, 80), module: diagnosticLabel(input.module ?? diagnosticText(diagnostic, "module") ?? diagnosticText(diagnostic, "layer")), diagnosticCode: diagnosticLabel(code),