From 6e82e191e245dc680c3117a952b09320155cef38 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 14:29:20 +0200 Subject: [PATCH 1/4] fix: proxy Workbench commands through Cloud Web --- internal/dev-entrypoint/cloud-web-routes.mjs | 1 + .../dev-entrypoint/cloud-web-routes.test.mjs | 9 +++++ .../dev-entrypoint/cloud-web-runtime.test.mjs | 36 ++++++++++--------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 1fbb4eb5..95378f99 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -8,6 +8,7 @@ const POST_PROXY_ROUTES = new Set([ "/v1/agent/chat/cancel", "/v1/agent/chat/steer", "/v1/agent/sessions", + "/v1/workbench/commands", "/v1/workbench/launches", "/v1/hwpod-node-ops", "/v1/web-performance", diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index f88d0257..2d5b8067 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -236,3 +236,12 @@ test("cloud web proxies authenticated Workbench launch writes to cloud-api", () routeKey: "POST /v1/workbench/launches" }); }); + +test("cloud web proxies authenticated Workbench commands to cloud-api", () => { + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/workbench/commands"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "POST /v1/workbench/commands" + }); +}); diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index f2924882..ba027e7b 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -105,7 +105,7 @@ test("cloud web auth requests are proxied once", async () => { } }); -test("cloud web proxies Workbench launches with trace context", async () => { +test("cloud web proxies Workbench writes with trace context", async () => { const upstreamRequests = []; const upstream = createServer(async (request, response) => { let body = ""; @@ -145,27 +145,29 @@ test("cloud web proxies Workbench launches with trace context", async () => { try { const body = JSON.stringify({ projectId: "project_d601", taskRef: "task_2136" }); - const response = await fetch(`${serverUrl(cloudWeb)}/v1/workbench/launches`, { + for (const route of ["/v1/workbench/launches", "/v1/workbench/commands"]) { + const response = await fetch(`${serverUrl(cloudWeb)}${route}`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "hwlab_session=session-launch", + traceparent: "00-11111111111111111111111111111111-2222222222222222-01", + "x-hwlab-otel-trace-id": "11111111111111111111111111111111" + }, + body + }); + assert.equal(response.status, 404); + assert.equal(response.headers.get("x-hwlab-otel-trace-id"), "11111111111111111111111111111111"); + assert.deepEqual(await response.json(), { error: "not_found" }); + } + assert.deepEqual(upstreamRequests, ["/v1/workbench/launches", "/v1/workbench/commands"].map((url) => ({ method: "POST", - headers: { - "content-type": "application/json", - cookie: "hwlab_session=session-launch", - traceparent: "00-11111111111111111111111111111111-2222222222222222-01", - "x-hwlab-otel-trace-id": "11111111111111111111111111111111" - }, - body - }); - assert.equal(response.status, 404); - assert.equal(response.headers.get("x-hwlab-otel-trace-id"), "11111111111111111111111111111111"); - assert.deepEqual(await response.json(), { error: "not_found" }); - assert.deepEqual(upstreamRequests, [{ - method: "POST", - url: "/v1/workbench/launches", + url, cookie: "hwlab_session=session-launch", traceparent: "00-11111111111111111111111111111111-2222222222222222-01", otelTraceId: "11111111111111111111111111111111", body - }]); + }))); } finally { await close(cloudWeb); await close(upstream); From 3657661c6306d1ca7dceb5c3ee75630e3cd7b007 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 14:51:31 +0200 Subject: [PATCH 2/4] fix: normalize workbench cloud authorization --- internal/workbench/cloud-application.ts | 6 ++++-- internal/workbench/workbench.test.ts | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/workbench/cloud-application.ts b/internal/workbench/cloud-application.ts index fb725e64..61f9e142 100644 --- a/internal/workbench/cloud-application.ts +++ b/internal/workbench/cloud-application.ts @@ -6,13 +6,15 @@ export function createCloudWorkbenchApplication(options: { fetchImpl?: typeof fetch; }): WorkbenchApplication { if (!options.baseUrl) throw codedError("workbench_cloud_api_url_required", "WORKBENCH_CLOUD_API_URL is required"); - if (!options.authorization) throw codedError("workbench_cloud_api_auth_required", "WORKBENCH_CLOUD_API_AUTHORIZATION is required"); + const authorization = options.authorization.trim(); + if (!authorization) throw codedError("workbench_cloud_api_auth_required", "WORKBENCH_CLOUD_API_AUTHORIZATION is required"); + const authorizationHeader = /^Bearer\s+/iu.test(authorization) ? authorization : `Bearer ${authorization}`; const fetchImpl = options.fetchImpl ?? fetch; const request = async (method: string, route: string, body: Record | undefined, actor: { id: string; role?: string | null }) => { const response = await fetchImpl(`${options.baseUrl.replace(/\/$/u, "")}${route}`, { method, headers: { - authorization: options.authorization, + authorization: authorizationHeader, "content-type": "application/json", "x-workbench-activity-dispatch": "1", "x-hwlab-actor-id": actor.id, diff --git a/internal/workbench/workbench.test.ts b/internal/workbench/workbench.test.ts index 6dec557e..7d800c98 100644 --- a/internal/workbench/workbench.test.ts +++ b/internal/workbench/workbench.test.ts @@ -54,10 +54,24 @@ describe("Workbench Cloud application adapter", () => { await application.admitTurn(input); await application.dispatchTurn(input); expect(calls).toEqual([ - { path: "/v1/internal/workbench/admit", authorization: "unified-internal-key" }, - { path: "/v1/agent/chat", authorization: "unified-internal-key" } + { path: "/v1/internal/workbench/admit", authorization: "Bearer unified-internal-key" }, + { path: "/v1/agent/chat", authorization: "Bearer unified-internal-key" } ]); }); + + test("preserves an existing Bearer authorization scheme", async () => { + let authorization: string | null = null; + const application = createCloudWorkbenchApplication({ + baseUrl: "http://cloud.internal", + authorization: "Bearer unified-internal-key", + fetchImpl: (async (_url: string | URL | Request, init?: RequestInit) => { + authorization = new Headers(init?.headers).get("authorization"); + return Response.json({ ok: true, session: { sessionId: "ses_test" } }); + }) as typeof fetch + }); + await application.createSession({ actor: { id: "usr_test" }, params: {} }); + expect(authorization).toBe("Bearer unified-internal-key"); + }); }); describe("Workbench native HTTP adapter", () => { From f6357811b03d279b15f6b698f287a15c83b85cfd Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 15:01:18 +0200 Subject: [PATCH 3/4] fix: align workbench cloud auth validation --- internal/cloud/server.ts | 3 ++- internal/cloud/workbench-command-proxy.test.ts | 7 +++++++ internal/workbench/cloud-application.ts | 6 +++--- internal/workbench/cloud-authorization.ts | 5 +++++ 4 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 internal/workbench/cloud-authorization.ts diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index c1acc0ef..1a2080cb 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -101,6 +101,7 @@ import { handleTaskTreeProxyHttp } from "./tasktree-proxy.ts"; import { handleDashboardSummaryHttp } from "./server-dashboard-http.ts"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; import { proxyWorkbenchCommandHttp, workbenchActivityDispatchRequested, workbenchTemporalProxyEnabled } from "./workbench-command-proxy.ts"; +import { normalizeWorkbenchCloudAuthorization } from "../workbench/cloud-authorization.ts"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; const HWLAB_WEB_SESSION_COOKIE = "hwlab_session"; @@ -1078,7 +1079,7 @@ function navIdForRestPath(pathname, method = "GET") { async function codeAgentOptions(request, response, options, authOptions = {}) { if (workbenchActivityDispatchRequested(request)) { - const expected = String(options.env?.WORKBENCH_CLOUD_API_AUTHORIZATION ?? "").trim(); + const expected = normalizeWorkbenchCloudAuthorization(String(options.env?.WORKBENCH_CLOUD_API_AUTHORIZATION ?? "")); const actual = String(request.headers?.authorization ?? "").trim(); const actorId = String(request.headers?.["x-hwlab-actor-id"] ?? "").trim(); if (!expected || actual !== expected || !actorId) { diff --git a/internal/cloud/workbench-command-proxy.test.ts b/internal/cloud/workbench-command-proxy.test.ts index 298c66e3..5f09d649 100644 --- a/internal/cloud/workbench-command-proxy.test.ts +++ b/internal/cloud/workbench-command-proxy.test.ts @@ -1,9 +1,16 @@ import { expect, test } from "bun:test"; import { workbenchActivityDispatchRequested } from "./workbench-command-proxy.ts"; +import { normalizeWorkbenchCloudAuthorization } from "../workbench/cloud-authorization.ts"; test("accepts activity dispatch header from Node and Fetch request shapes", () => { expect(workbenchActivityDispatchRequested({ headers: { "x-workbench-activity-dispatch": "1" } })).toBe(true); expect(workbenchActivityDispatchRequested({ headers: new Headers({ "x-workbench-activity-dispatch": "1" }) })).toBe(true); expect(workbenchActivityDispatchRequested({ headers: new Headers({ "x-workbench-activity-dispatch": "0" }) })).toBe(false); }); + +test("normalizes the unified Workbench Cloud authorization on both sides", () => { + expect(normalizeWorkbenchCloudAuthorization(" unified-internal-key ")).toBe("Bearer unified-internal-key"); + expect(normalizeWorkbenchCloudAuthorization("Bearer unified-internal-key")).toBe("Bearer unified-internal-key"); + expect(normalizeWorkbenchCloudAuthorization(" ")).toBe(""); +}); diff --git a/internal/workbench/cloud-application.ts b/internal/workbench/cloud-application.ts index 61f9e142..d3005130 100644 --- a/internal/workbench/cloud-application.ts +++ b/internal/workbench/cloud-application.ts @@ -1,4 +1,5 @@ import type { WorkbenchApplication } from "./contracts.ts"; +import { normalizeWorkbenchCloudAuthorization } from "./cloud-authorization.ts"; export function createCloudWorkbenchApplication(options: { baseUrl: string; @@ -6,15 +7,14 @@ export function createCloudWorkbenchApplication(options: { fetchImpl?: typeof fetch; }): WorkbenchApplication { if (!options.baseUrl) throw codedError("workbench_cloud_api_url_required", "WORKBENCH_CLOUD_API_URL is required"); - const authorization = options.authorization.trim(); + const authorization = normalizeWorkbenchCloudAuthorization(options.authorization); if (!authorization) throw codedError("workbench_cloud_api_auth_required", "WORKBENCH_CLOUD_API_AUTHORIZATION is required"); - const authorizationHeader = /^Bearer\s+/iu.test(authorization) ? authorization : `Bearer ${authorization}`; const fetchImpl = options.fetchImpl ?? fetch; const request = async (method: string, route: string, body: Record | undefined, actor: { id: string; role?: string | null }) => { const response = await fetchImpl(`${options.baseUrl.replace(/\/$/u, "")}${route}`, { method, headers: { - authorization: authorizationHeader, + authorization, "content-type": "application/json", "x-workbench-activity-dispatch": "1", "x-hwlab-actor-id": actor.id, diff --git a/internal/workbench/cloud-authorization.ts b/internal/workbench/cloud-authorization.ts new file mode 100644 index 00000000..1b8221bf --- /dev/null +++ b/internal/workbench/cloud-authorization.ts @@ -0,0 +1,5 @@ +export function normalizeWorkbenchCloudAuthorization(value: string) { + const authorization = value.trim(); + if (!authorization) return ""; + return /^Bearer\s+/iu.test(authorization) ? authorization : `Bearer ${authorization}`; +} From 759092ec5074f71b268b652711e51f1178fa5307 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 17:34:29 +0200 Subject: [PATCH 4/4] 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),