From 20ced5245bace56c89bfcafaac5f92c40d22ecdf Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 5 Jun 2026 16:23:20 +0800 Subject: [PATCH] fix: collapse v02 workspace first screen bootstrap --- internal/dev-entrypoint/cloud-web-routes.mjs | 3 +- .../dev-entrypoint/cloud-web-routes.test.mjs | 6 ++ internal/dev-entrypoint/cloud-web-runtime.mjs | 96 +++++++++++++++++-- .../dev-entrypoint/cloud-web-runtime.test.mjs | 82 +++++++++++++++- .../conversation/ConversationPanel.tsx | 5 +- web/hwlab-cloud-web/src/hooks/useAuth.ts | 15 ++- .../src/services/api/client.ts | 1 + web/hwlab-cloud-web/src/state/workbench.ts | 23 ++++- web/hwlab-cloud-web/src/types/config.ts | 9 ++ 9 files changed, 225 insertions(+), 15 deletions(-) diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index d6fed256..8d84455e 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -1,5 +1,5 @@ const GET_PROXY_PREFIXES = Object.freeze(["/v1/"]); -const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap"]); +const GET_PROXY_ROUTES = new Set(["/v1", "/auth/session", "/auth/bootstrap", "/auth/workspace-bootstrap"]); const POST_PROXY_ROUTES = new Set([ "/auth/login", "/auth/logout", @@ -16,6 +16,7 @@ const POST_PROXY_ROUTES = new Set([ const PUBLIC_PROXY_ROUTES = new Set([ "GET /auth/session", "GET /auth/bootstrap", + "GET /auth/workspace-bootstrap", "POST /auth/login", "POST /auth/logout", "POST /v1/agent/chat", diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index b93f16ab..b4a666a0 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -61,6 +61,12 @@ test("cloud web exposes auth bootstrap as a public auth route", () => { publicRoute: true, routeKey: "GET /auth/bootstrap" }); + assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/auth/workspace-bootstrap"), { + proxy: true, + authRequired: false, + publicRoute: true, + routeKey: "GET /auth/workspace-bootstrap" + }); }); test("cloud web proxies authenticated API key management write routes", () => { diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 71b14a85..fe266d01 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -17,6 +17,7 @@ const gzipAsync = promisify(gzip); const GZIP_MIN_BYTES = 1024; const DEFAULT_AUTH_USERNAME = "admin"; const DEFAULT_AUTH_PASSWORD = "hwlab2026"; +const DEFAULT_WORKBENCH_PROJECT_ID = "prj_device_pod_workbench"; export async function serveCloudWeb(options) { const server = createCloudWebServer(options); @@ -88,6 +89,10 @@ export function createCloudWebServer({ export async function handleCloudWebAuth(options) { const { request, response, url } = options; + if (url.pathname === "/auth/workspace-bootstrap" && request.method === "GET") { + await handleWorkspaceBootstrap(options); + return true; + } if (url.pathname === "/auth/bootstrap" && request.method === "GET") { await handleAuthBootstrap(options); return true; @@ -116,18 +121,50 @@ async function handleAuthBootstrap(options) { return; } - const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs }); - if (session.ok && session.body?.authenticated === true) { - sendJsonWithForwardedCookies(response, session.statusCode, session.body, session.headers); + const auth = await resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }); + sendJsonWithForwardedCookies(response, auth.statusCode || 502, auth.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, auth.headers); +} + +async function handleWorkspaceBootstrap(options) { + const { request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson } = options; + if (!cloudApiBaseUrl) { + sendJson(response, 503, { error: "cloud_api_base_url_missing", serviceId, path: "/auth/workspace-bootstrap" }); return; } - const config = webAuthConfig(); - if (config.mode !== "auto") { - sendJsonWithForwardedCookies(response, session.statusCode || 200, session.body ?? { authenticated: false }, session.headers); + const auth = await resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }); + if (auth.body?.authenticated !== true) { + sendJsonWithForwardedCookies(response, auth.statusCode || 200, auth.body ?? { authenticated: false }, auth.headers); return; } + const projectId = authConfigValueFromSearch(url.searchParams.get("projectId"), DEFAULT_WORKBENCH_PROJECT_ID); + const workspace = await requestCloudApiJson({ + request, + method: "GET", + pathname: `/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, + headers: auth.cookieHeader ? { cookie: auth.cookieHeader } : {}, + cloudApiBaseUrl, + cloudApiProxyTimeoutMs + }); + const body = { + ...(auth.body ?? { authenticated: true }), + projectId, + workspace: workspace.ok ? workspace.body?.workspace ?? null : null, + workspaceStatus: workspace.statusCode || null, + workspaceError: workspace.ok ? null : workspace.body?.error ?? workspace.body?.message ?? null + }; + sendJsonWithForwardedCookies(response, auth.statusCode || 200, body, auth.headers); +} + +async function resolveAuthBootstrap({ request, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) { + const config = webAuthConfig(); + if (config.mode !== "auto" || hasCookieHeader(request.headers.cookie)) { + const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs }); + if (session.ok && session.body?.authenticated === true) return withCookieHeader(request, session); + if (config.mode !== "auto") return withCookieHeader(request, session); + } + const login = await requestCloudApiJson({ request, method: "POST", @@ -136,7 +173,7 @@ async function handleAuthBootstrap(options) { cloudApiBaseUrl, cloudApiProxyTimeoutMs }); - sendJsonWithForwardedCookies(response, login.statusCode || 502, login.body ?? { authenticated: false, error: "auth_bootstrap_failed" }, login.headers); + return withCookieHeader(request, login); } function webAuthConfig() { @@ -152,11 +189,15 @@ function authConfigValue(name, fallback) { return typeof value === "string" && value.trim() ? value.trim() : fallback; } -function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBaseUrl, cloudApiProxyTimeoutMs }) { +function authConfigValueFromSearch(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +function requestCloudApiJson({ request, method, pathname, body = "", headers = {}, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) { return new Promise((resolve) => { let settled = false; const target = new URL(pathname, cloudApiBaseUrl); - const proxyRequest = { method, headers: upstreamRequestHeaders({ headers: request.headers }, body) }; + const proxyRequest = { method, headers: { ...upstreamRequestHeaders({ headers: request.headers }, body), ...headers } }; const upstream = httpRequest(target, { method, headers: proxyRequest.headers }, (upstreamResponse) => { const chunks = []; upstreamResponse.on("data", (chunk) => chunks.push(chunk)); @@ -192,6 +233,43 @@ function requestCloudApiJson({ request, method, pathname, body = "", cloudApiBas }); } +function withCookieHeader(request, result) { + return { + ...result, + cookieHeader: cookieHeaderForUpstream(request.headers.cookie, result.headers?.["set-cookie"]) + }; +} + +function cookieHeaderForUpstream(existingCookie, setCookie) { + const pairs = new Map(); + for (const pair of cookiePairsFromHeader(existingCookie)) pairs.set(pair.name, pair.value); + for (const pair of cookiePairsFromSetCookie(setCookie)) pairs.set(pair.name, pair.value); + return Array.from(pairs.entries()).map(([name, value]) => `${name}=${value}`).join("; "); +} + +function cookiePairsFromHeader(header) { + if (!header) return []; + return String(header).split(";").map((part) => cookiePair(part)).filter(Boolean); +} + +function hasCookieHeader(header) { + return cookiePairsFromHeader(header).length > 0; +} + +function cookiePairsFromSetCookie(setCookie) { + const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []; + return values.map((value) => cookiePair(String(value).split(";")[0])).filter(Boolean); +} + +function cookiePair(value) { + const index = String(value).indexOf("="); + if (index <= 0) return null; + const name = String(value).slice(0, index).trim(); + const cookieValue = String(value).slice(index + 1).trim(); + if (!name || !cookieValue) return null; + return { name, value: cookieValue }; +} + function sendJsonWithForwardedCookies(response, statusCode, body, headers = {}) { const payload = JSON.stringify(body); const setCookie = headers["set-cookie"]; diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 419ef9c2..d1cb04ad 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -114,7 +114,6 @@ test("cloud web auth bootstrap auto-logins only after an unauthenticated session assert.equal(response.headers.get("set-cookie"), "hwlab_session=session-auto; Path=/; HttpOnly"); assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "admin" } }); assert.deepEqual(upstreamRequests, [ - { method: "GET", url: "/auth/session", body: "" }, { method: "POST", url: "/auth/login", body: JSON.stringify({ username: "admin", password: "hwlab2026" }) } ]); } finally { @@ -123,6 +122,87 @@ test("cloud web auth bootstrap auto-logins only after an unauthenticated session } }); +test("cloud web workspace bootstrap keeps an existing session and includes workspace", async () => { + const upstreamRequests = []; + const workspacePayload = { workspace: { workspaceId: "ws_existing", selectedConversationId: "cnv_1", selectedConversation: { conversationId: "cnv_1", messages: [] } } }; + const upstream = createServer((request, response) => { + upstreamRequests.push({ method: request.method, url: request.url, cookie: request.headers.cookie }); + request.resume(); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(request.url?.startsWith("/v1/workbench/workspace") ? workspacePayload : { authenticated: true, actor: { username: "alice" } })); + }); + await listen(upstream); + + const cloudWeb = createCloudWebServer({ + serviceId: "hwlab-cloud-web", + cloudApiBaseUrl: serverUrl(upstream), + cloudApiProxyTimeoutMs: 1000, + healthPayload: () => ({ status: "ok" }), + sendJson(response, statusCode, body) { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }); + response.end(payload); + } + }); + await listen(cloudWeb); + + try { + const response = await fetch(`${serverUrl(cloudWeb)}/auth/workspace-bootstrap?projectId=prj_device_pod_workbench`, { headers: { cookie: "hwlab_session=alice" } }); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "alice" }, projectId: "prj_device_pod_workbench", workspace: workspacePayload.workspace, workspaceStatus: 200, workspaceError: null }); + assert.deepEqual(upstreamRequests, [ + { method: "GET", url: "/auth/session", cookie: "hwlab_session=alice" }, + { method: "GET", url: "/v1/workbench/workspace?projectId=prj_device_pod_workbench", cookie: "hwlab_session=alice" } + ]); + } finally { + await close(cloudWeb); + await close(upstream); + } +}); + +test("cloud web workspace bootstrap reads workspace with auto-login cookie", async () => { + const upstreamRequests = []; + const workspacePayload = { workspace: { workspaceId: "ws_auto", selectedConversationId: "cnv_auto", selectedConversation: { conversationId: "cnv_auto", messages: [{ id: "msg_1", role: "agent", title: "Code Agent", text: "ready", status: "completed", createdAt: "2026-06-05T00:00:00.000Z" }] } } }; + const upstream = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + upstreamRequests.push({ method: request.method, url: request.url, cookie: request.headers.cookie, body }); + response.writeHead(200, { + "content-type": "application/json", + ...(request.url === "/auth/login" ? { "set-cookie": "hwlab_session=session-auto; Path=/; HttpOnly" } : {}) + }); + response.end(JSON.stringify(request.url === "/auth/login" ? { authenticated: true, actor: { username: "admin" } } : request.url?.startsWith("/v1/workbench/workspace") ? workspacePayload : { authenticated: false })); + }); + await listen(upstream); + + const cloudWeb = createCloudWebServer({ + serviceId: "hwlab-cloud-web", + cloudApiBaseUrl: serverUrl(upstream), + cloudApiProxyTimeoutMs: 1000, + healthPayload: () => ({ status: "ok" }), + sendJson(response, statusCode, body) { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }); + response.end(payload); + } + }); + await listen(cloudWeb); + + try { + const response = await fetch(`${serverUrl(cloudWeb)}/auth/workspace-bootstrap?projectId=prj_device_pod_workbench`); + assert.equal(response.status, 200); + assert.equal(response.headers.get("set-cookie"), "hwlab_session=session-auto; Path=/; HttpOnly"); + assert.deepEqual(await response.json(), { authenticated: true, actor: { username: "admin" }, projectId: "prj_device_pod_workbench", workspace: workspacePayload.workspace, workspaceStatus: 200, workspaceError: null }); + assert.deepEqual(upstreamRequests, [ + { method: "POST", url: "/auth/login", cookie: undefined, body: JSON.stringify({ username: "admin", password: "hwlab2026" }) }, + { method: "GET", url: "/v1/workbench/workspace?projectId=prj_device_pod_workbench", cookie: "hwlab_session=session-auto", body: "" } + ]); + } finally { + await close(cloudWeb); + await close(upstream); + } +}); + test("cloud web proxies Admin Access write routes", async () => { const upstreamRequests = []; const upstream = createServer(async (request, response) => { diff --git a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx index 76b6dc6b..e09c7164 100644 --- a/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx +++ b/web/hwlab-cloud-web/src/components/conversation/ConversationPanel.tsx @@ -113,6 +113,8 @@ export function shouldAutoReplayTrace(message: ChatMessage): boolean { return events.length === 0 && declaredCount > 0; } +const HISTORICAL_TRACE_REPLAY_DELAY_MS = 4500; + function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayTrace }: { message: ChatMessage; codeAgentTimeoutMs: number; onCancel(): void; onRetry(): void; onReplayTrace(): void }): ReactElement { const traceKey = `hwlab.workbench.trace-open.${message.traceId ?? message.id}`; const replayRequestedRef = useRef(false); @@ -120,7 +122,8 @@ function MessageCard({ message, codeAgentTimeoutMs, onCancel, onRetry, onReplayT if (replayRequestedRef.current) return; if (!shouldAutoReplayTrace(message)) return; replayRequestedRef.current = true; - onReplayTrace(); + const timer = window.setTimeout(() => onReplayTrace(), HISTORICAL_TRACE_REPLAY_DELAY_MS); + return () => window.clearTimeout(timer); }, [message, onReplayTrace]); if (message.role === "agent") { const hasFinalResponse = message.status !== "running" && message.text.trim().length > 0; diff --git a/web/hwlab-cloud-web/src/hooks/useAuth.ts b/web/hwlab-cloud-web/src/hooks/useAuth.ts index 610d831a..149b9002 100644 --- a/web/hwlab-cloud-web/src/hooks/useAuth.ts +++ b/web/hwlab-cloud-web/src/hooks/useAuth.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { api } from "../services/api/client"; -import type { AuthSession, AuthState, AuthUser } from "../types/domain"; +import { WORKBENCH_PROJECT_ID } from "../state/constants"; +import type { AuthSession, AuthState, AuthUser, WorkspaceRecord } from "../types/domain"; import { asRecord, nonEmptyString } from "../utils"; const DEFAULT_USERNAME = "admin"; @@ -25,9 +26,13 @@ export function useAuth(): UseAuthResult { async function run(): Promise { const config = window.HWLAB_CLOUD_WEB_CONFIG?.auth; const mode = config?.mode ?? "auto"; - const current = mode === "auto" ? await api.authBootstrap() : await api.session(); + const current = mode === "auto" ? await api.workspaceBootstrap(WORKBENCH_PROJECT_ID) : await api.session(); if (canceled) return; if (current.ok && current.data?.authenticated === true) { + const workspace = workspaceFromPayload(current.data); + if (workspace) { + window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = { projectId: WORKBENCH_PROJECT_ID, workspace }; + } const next = sessionFromPayload(current.data); setSession(next); setAuthState("authenticated"); @@ -77,6 +82,12 @@ export function useAuth(): UseAuthResult { return useMemo(() => ({ authState, session, error, login, logout }), [authState, session, error, login, logout]); } +function workspaceFromPayload(payload: unknown): WorkspaceRecord | null { + const record = asRecord(payload); + const workspace = asRecord(record?.workspace); + return typeof workspace?.workspaceId === "string" && workspace.workspaceId.trim() ? workspace as unknown as WorkspaceRecord : null; +} + function sessionFromPayload(payload: { authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }): AuthSession { return { authenticated: payload.authenticated === true, diff --git a/web/hwlab-cloud-web/src/services/api/client.ts b/web/hwlab-cloud-web/src/services/api/client.ts index 2b21122a..1e4c18ec 100644 --- a/web/hwlab-cloud-web/src/services/api/client.ts +++ b/web/hwlab-cloud-web/src/services/api/client.ts @@ -206,6 +206,7 @@ function errorMessage(payload: unknown, fallback: string): string { export const api = { session: (): Promise> => fetchJson("/auth/session", { timeoutName: "auth session" }), authBootstrap: (): Promise> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }), + workspaceBootstrap: (projectId: string): Promise> => fetchJson(`/auth/workspace-bootstrap?projectId=${encodeURIComponent(projectId)}`, { timeoutName: "workspace bootstrap", timeoutMs: 8000 }), login: (username: string, password: string): Promise> => fetchJson("/auth/login", { method: "POST", body: JSON.stringify({ username, password }), diff --git a/web/hwlab-cloud-web/src/state/workbench.ts b/web/hwlab-cloud-web/src/state/workbench.ts index 409ae23d..bef32015 100644 --- a/web/hwlab-cloud-web/src/state/workbench.ts +++ b/web/hwlab-cloud-web/src/state/workbench.ts @@ -179,8 +179,19 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { } hydrateInFlightRef.current = true; dispatch({ type: "hydrate:start" }); - const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID); try { + const bootstrapWorkspace = consumeWorkspaceBootstrap(); + if (bootstrapWorkspace) { + workspaceLoadedRef.current = true; + dispatch({ + type: "hydrate:done", + workspace: bootstrapWorkspace, + messages: messagesFromWorkspace(bootstrapWorkspace) + }); + if (enabledRef.current) scheduleConversationList(); + return; + } + const workspaceResult = await api.workspace(WORKBENCH_PROJECT_ID); if (!workspaceResult.ok) { workspaceLoadedRef.current = false; dispatch({ type: "hydrate:error", error: workspaceResult.error ?? "workspace unavailable" }); @@ -475,6 +486,16 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore { } +function consumeWorkspaceBootstrap(): WorkspaceRecord | null { + if (typeof window === "undefined") return null; + const bootstrap = window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP; + if (!bootstrap || bootstrap.consumed === true || bootstrap.projectId !== WORKBENCH_PROJECT_ID) return null; + bootstrap.consumed = true; + window.HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP = undefined; + return bootstrap.workspace?.workspaceId ? bootstrap.workspace : null; +} + + function selectVisibleDevicePodId(payload: DevicePodListResponse | null, pods: DevicePodItem[], requestedDevicePodId: string): string { const payloadSelected = nonEmptyString(payload?.selectedDevicePodId); const requested = nonEmptyString(requestedDevicePodId); diff --git a/web/hwlab-cloud-web/src/types/config.ts b/web/hwlab-cloud-web/src/types/config.ts index b664279e..df29a7a8 100644 --- a/web/hwlab-cloud-web/src/types/config.ts +++ b/web/hwlab-cloud-web/src/types/config.ts @@ -1,3 +1,5 @@ +import type { WorkspaceRecord } from "./domain"; + export interface WorkbenchAuthConfig { mode?: "server" | "auto"; username?: string; @@ -22,9 +24,16 @@ export interface WorkbenchConfig { timeouts?: WorkbenchTimeoutConfig; } +export interface WorkbenchWorkspaceBootstrap { + projectId?: string; + workspace?: WorkspaceRecord | null; + consumed?: boolean; +} + declare global { interface Window { HWLAB_CLOUD_WEB_CONFIG?: WorkbenchConfig; HWLAB_CLOUD_WEB_TIMEOUTS?: WorkbenchTimeoutConfig; + HWLAB_CLOUD_WEB_WORKSPACE_BOOTSTRAP?: WorkbenchWorkspaceBootstrap; } }