diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 692676f5..ccce0288 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -46,6 +46,7 @@ export function isCloudApiProxyRoute(method, pathname) { } if (normalizedMethod === "POST") { return POST_PROXY_ROUTES.has(normalizedPath) || + normalizedPath.startsWith("/v1/rpc/") || normalizedPath.startsWith("/v1/agent/sessions/") || isDevicePodPostProxyRoute(normalizedPath) || isWorkbenchWorkspaceWriteProxyRoute(normalizedMethod, normalizedPath); diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index d61b4864..452171f4 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -24,6 +24,21 @@ test("cloud web proxies skills routes through authenticated cloud-api", () => { }); }); +test("cloud web proxies authenticated REST RPC bridge routes", () => { + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/rpc/system.health"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "POST /v1/rpc/system.health" + }); + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/rpc/system.health"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "GET /v1/rpc/system.health" + }); +}); + test("cloud web proxies only supported authenticated device-pod write routes", () => { assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/device-pods/D601-F103-V2/jobs"), { proxy: true, diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 8f8e0895..9f62ddf2 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -57,6 +57,10 @@ assertIncludes(appSource, "SkillsView", "skills component must exist"); assertIncludes(appSource, "SettingsView", "settings component must exist"); assertIncludes(appTsx, "useWorkbenchStore(auth.authState === \"authenticated\")", "Workbench data requests must wait until auth is authenticated"); assertIncludes(apiClientSource, "adapter: (): Promise> => fetchJson(\"/v1/rpc/system.health\", { method: \"POST\"", "adapter health must use POST /v1/rpc/system.health"); +assertIncludes(appSource, "selectVisibleDevicePodId", "Device Pod detail probes must be selected from the authenticated visible list"); +assertIncludes(appSource, "api.devicePods()", "Device Pod list must be fetched before detail probes"); +assert.doesNotMatch(appSource, /api\.devicePods\(\),\s*api\.devicePodStatus/u, "Device Pod status must not be requested in the initial parallel live probe"); +assertIncludes(readRepo("internal/dev-entrypoint/cloud-web-routes.mjs"), "normalizedPath.startsWith(\"/v1/rpc/\")", "Cloud Web must proxy REST RPC bridge routes to cloud-api"); assertIncludes(appSource, "id=\"command-input\"", "React runtime must render command input selector"); assertIncludes(appSource, "id=\"conversation-list\"", "React runtime must render conversation list selector"); assertIncludes(appSource, "id=\"device-pod-sidebar\"", "React runtime must render Device Pod sidebar selector"); diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts index c1aaaf69..c5fe8e9e 100644 --- a/web/hwlab-cloud-web/src/state/workbench.ts +++ b/web/hwlab-cloud-web/src/state/workbench.ts @@ -3,10 +3,14 @@ import { useCallback, useEffect, useMemo, useReducer } from "react"; import { api } from "../services/api/client"; import type { AgentChatResponse, + ApiResult, ChatMessage, CodeAgentAvailability, + DevicePodEventsResponse, ConversationRecord, DevicePodItem, + DevicePodListResponse, + DevicePodStatusResponse, LiveSurface, ProviderProfile, SessionTab, @@ -129,16 +133,19 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { }, []); const refreshLive = useCallback(async (devicePodId?: string) => { - const selected = devicePodId ?? state.selectedDevicePodId; - const [healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents] = await Promise.all([ + const requestedDevicePodId = devicePodId ?? state.selectedDevicePodId; + const [healthLive, health, restIndex, adapter, devicePods] = await Promise.all([ api.healthLive(), api.health(), api.restIndex(), api.adapter(), - api.devicePods(), - api.devicePodStatus(selected), - api.devicePodEvents(selected) + api.devicePods() ]); + const visibleDevicePods = devicePodsFromResult(devicePods); + const selected = selectVisibleDevicePodId(devicePods.data, visibleDevicePods, requestedDevicePodId); + const [devicePodStatus, devicePodEvents] = selected + ? await Promise.all([api.devicePodStatus(selected), api.devicePodEvents(selected)]) + : [null, null] satisfies [ApiResult | null, ApiResult | null]; const live: LiveSurface = { healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents, loadedAt: new Date().toISOString() }; dispatch({ type: "live:set", live, availability: availabilityFromLive(live), selectedDevicePodId: selected }); }, [state.selectedDevicePodId]); @@ -320,3 +327,15 @@ export function devicePodsFromLive(live: LiveSurface | null): DevicePodItem[] { const payload = live?.devicePods.data; return payload?.devicePods ?? payload?.items ?? []; } + +function devicePodsFromResult(result: ApiResult): DevicePodItem[] { + return result.data?.devicePods ?? result.data?.items ?? []; +} + +function selectVisibleDevicePodId(payload: DevicePodListResponse | null, pods: DevicePodItem[], requestedDevicePodId: string): string { + const payloadSelected = nonEmptyString(payload?.selectedDevicePodId); + const requested = nonEmptyString(requestedDevicePodId); + const requestedVisible = requested ? pods.some((pod) => pod.devicePodId === requested) : false; + if (requested && requestedVisible) return requested; + return payloadSelected ?? pods[0]?.devicePodId ?? ""; +}