fix: 修复 Workbench L1 页面启动卡死

This commit is contained in:
root
2026-07-17 18:40:25 +02:00
parent 79b1db8ca3
commit 5f472df95b
6 changed files with 121 additions and 6 deletions
+16 -2
View File
@@ -8,12 +8,15 @@ export function createWorkbenchHttpApp(options: { dispatch: (command: WorkbenchC
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-workbench-api", status: "live" });
if (url.pathname === "/health/ready") return resultResponse(await options.dispatch({ operation: "health" }));
if (options.snapshot) {
if (url.pathname === "/auth/session" || url.pathname === "/auth/bootstrap") return json(200, { ok: true, authenticated: true, actor: { id: "usr_native", role: "user" }, mode: "native-test" });
if (url.pathname === "/auth/login" && request.method === "POST") return Response.json({ ok: true, authenticated: true, actor: { id: "usr_native", role: "user" }, mode: "native-test" }, { status: 200, headers: { "set-cookie": "hwlab_session=native-test; Path=/; HttpOnly; SameSite=Lax", "cache-control": "no-store" } });
if (url.pathname === "/auth/session" || url.pathname === "/auth/bootstrap") return json(200, nativeAuthSession());
if (url.pathname === "/auth/login" && request.method === "POST") return Response.json(nativeAuthSession(), { status: 200, headers: { "set-cookie": "hwlab_session=native-test; Path=/; HttpOnly; SameSite=Lax", "cache-control": "no-store" } });
if (url.pathname === "/v1/users/me") return json(200, { ok: true, actor: { id: "usr_native", role: "user" }, authMethod: "native-test" });
if (url.pathname === "/v1/access/status") return json(200, { ok: true, status: "active", actor: { id: "usr_native", role: "user" }, nav: ["workbench.code"] });
if (url.pathname === "/v1/provider-profiles") return json(200, { ok: true, items: [{ profile: "native-test", backendProfile: "native-test", configured: true }], count: 1, mode: "native-test" });
if (url.pathname === "/v1/dashboard/summary") return json(200, { ok: true, status: "ready", mode: "native-test" });
if (url.pathname === "/v1/caserun/cases") return json(200, { ok: true, status: "unavailable", cases: [], count: 0, mode: "native-test" });
if (url.pathname === "/v1/hwpod/specs") return json(200, { ok: true, status: "unavailable", specs: [], mode: "native-test" });
if (url.pathname === "/v1/hwpod-node-ops") return json(200, { ok: true, status: "unavailable", events: [], groups: [], mode: "native-test" });
if (url.pathname === "/v1/workbench/events" && request.method === "GET") return nativeEventStream();
if (url.pathname === "/v1/workbench/sessions" && request.method === "GET") return nativeSessionList(options, url);
const sessionMatch = /^\/v1\/workbench\/sessions\/([^/]+)(?:\/(messages))?$/u.exec(url.pathname);
@@ -47,6 +50,17 @@ export function createWorkbenchHttpApp(options: { dispatch: (command: WorkbenchC
};
}
function nativeAuthSession() {
return {
ok: true,
authenticated: true,
actor: { id: "usr_native", role: "user" },
access: { nav: { profileId: "workbench-native", allowedIds: ["workbench.code"], valuesRedacted: true } },
mode: "native-test",
valuesRedacted: true
};
}
function requireAuthorization(request: Request, options: { snapshot?: unknown; authorization?: string }) {
if (options.snapshot) return;
const expected = text(options.authorization);
+32
View File
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
import type { WorkbenchApplication, WorkbenchTemporalGateway } from "./contracts.ts";
import { createWorkbenchDispatcher } from "./dispatcher.ts";
import { createCloudWorkbenchApplication } from "./cloud-application.ts";
import { createWorkbenchHttpApp } from "./http.ts";
describe("Workbench dispatcher", () => {
test("submits through Temporal without synthesizing terminal state", async () => {
@@ -58,6 +59,37 @@ describe("Workbench Cloud application adapter", () => {
});
});
describe("Workbench native HTTP adapter", () => {
test("authorizes only the Workbench navigation entry", async () => {
const app = createWorkbenchHttpApp({
async dispatch() { return { ok: true }; },
async snapshot() { return { sessions: {}, turns: {} }; }
});
for (const path of ["/auth/session", "/auth/bootstrap"]) {
const response = await app.fetch(new Request(`http://native.test${path}`));
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
authenticated: true,
access: { nav: { profileId: "workbench-native", allowedIds: ["workbench.code"] } }
});
}
});
test("serves read-only empty states for Workbench auxiliary panels", async () => {
const app = createWorkbenchHttpApp({
async dispatch() { return { ok: true }; },
async snapshot() { return { sessions: {}, turns: {} }; }
});
for (const path of ["/v1/caserun/cases", "/v1/hwpod/specs?probe=1", "/v1/hwpod-node-ops"]) {
const response = await app.fetch(new Request(`http://native.test${path}`));
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ ok: true, status: "unavailable", mode: "native-test" });
}
});
});
function stubApplication(): WorkbenchApplication {
return {
async health() { return {}; },
+1 -1
View File
@@ -31,7 +31,7 @@ function help() {
],
transportContract: "--over-api only changes transport; --overapi is unsupported",
localConfig: ["WORKBENCH_MODE", "WORKBENCH_NATIVE_STATE_FILE"],
nativeServiceConfig: ["WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PUBLIC_HOST", "WORKBENCH_API_PORT", "WORKBENCH_WORKER_BIND_HOST", "WORKBENCH_WORKER_PROBE_HOST", "WORKBENCH_WORKER_HEALTH_PORT", "WORKBENCH_WEB_BIND_HOST", "WORKBENCH_WEB_PROBE_HOST", "WORKBENCH_WEB_PUBLIC_HOST", "WORKBENCH_WEB_PORT", "WORKBENCH_NATIVE_API_URL"],
nativeServiceConfig: ["WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PUBLIC_HOST", "WORKBENCH_API_PORT", "WORKBENCH_WORKER_BIND_HOST", "WORKBENCH_WORKER_PROBE_HOST", "WORKBENCH_WORKER_HEALTH_PORT", "WORKBENCH_WEB_BIND_HOST", "WORKBENCH_WEB_PROBE_HOST", "WORKBENCH_WEB_PUBLIC_HOST", "WORKBENCH_WEB_PORT", "WORKBENCH_NATIVE_API_URL", "WORKBENCH_WEB_RUNTIME_CONFIG"],
apiConfig: ["WORKBENCH_API_URL", "HWLAB_API_KEY"]
};
}
+1 -1
View File
@@ -71,7 +71,7 @@ function serviceCommand(service: ServiceName, env: Record<string, string | undef
}
function serviceEnv(service: ServiceName, env: Record<string, string | undefined>) {
if (service === "api") return { ...process.env, ...env, WORKBENCH_MODE: env.WORKBENCH_MODE ?? "native-test", WORKBENCH_API_HOST: requiredEnv(env, "WORKBENCH_API_BIND_HOST"), WORKBENCH_API_PORT: requiredEnv(env, "WORKBENCH_API_PORT") };
if (service === "web") return { ...process.env, ...env, WORKBENCH_WEB_HOST: requiredEnv(env, "WORKBENCH_WEB_BIND_HOST"), WORKBENCH_WEB_PORT: requiredEnv(env, "WORKBENCH_WEB_PORT"), WORKBENCH_NATIVE_API_URL: requiredEnv(env, "WORKBENCH_NATIVE_API_URL") };
if (service === "web") return { ...process.env, ...env, WORKBENCH_WEB_HOST: requiredEnv(env, "WORKBENCH_WEB_BIND_HOST"), WORKBENCH_WEB_PORT: requiredEnv(env, "WORKBENCH_WEB_PORT"), WORKBENCH_NATIVE_API_URL: requiredEnv(env, "WORKBENCH_NATIVE_API_URL"), WORKBENCH_WEB_RUNTIME_CONFIG: requiredEnv(env, "WORKBENCH_WEB_RUNTIME_CONFIG") };
return { ...process.env, ...env, WORKBENCH_MODE: "temporal", WORKBENCH_WORKER_HEALTH_HOST: requiredEnv(env, "WORKBENCH_WORKER_BIND_HOST"), WORKBENCH_WORKER_HEALTH_PORT: requiredEnv(env, "WORKBENCH_WORKER_HEALTH_PORT") };
}
function validService(value: string): ServiceName { if (value === "api" || value === "worker" || value === "web") return value; throw codedError("invalid_service", "service must be api, worker, or web"); }
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { parseWorkbenchNativeRuntimeConfig } from "../vite.config";
const validConfig = {
displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" },
workbench: {
realtimeFeatures: { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true },
debugCapabilities: { isolatedKafka: false, rawHwlabEventWindow: { enabled: false, maxEntries: 1, maxRetainedBytes: 1 } },
traceTimeline: { autoExpandRunning: false, autoCollapseTerminal: false }
}
};
describe("Workbench native Vite runtime config", () => {
test("accepts the YAML-rendered runtime contract", () => {
expect(parseWorkbenchNativeRuntimeConfig(JSON.stringify(validConfig))).toEqual(validConfig);
});
test("fails closed when required runtime capabilities are missing", () => {
expect(() => parseWorkbenchNativeRuntimeConfig(JSON.stringify({ displayTime: validConfig.displayTime, workbench: {} }))).toThrow("workbench.realtimeFeatures");
});
});
+49 -2
View File
@@ -2,11 +2,12 @@ import { Agent } from "node:http";
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig, loadEnv } from "vite";
import { defineConfig, loadEnv, type Plugin } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const nativeApiUrl = String(process.env.WORKBENCH_NATIVE_API_URL ?? env.WORKBENCH_NATIVE_API_URL ?? "").trim();
const nativeRuntimeConfig = String(process.env.WORKBENCH_WEB_RUNTIME_CONFIG ?? env.WORKBENCH_WEB_RUNTIME_CONFIG ?? "").trim();
const nativeCaseRunEnabled = (process.env.HWLAB_CASERUN_NATIVE_TEST ?? env.HWLAB_CASERUN_NATIVE_TEST) === "1";
const nativeCaseRunTarget = process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "http://127.0.0.1:4316";
const nativeCaseRunAgent = new Agent({ keepAlive: true });
@@ -14,9 +15,10 @@ export default defineConfig(({ mode }) => {
if (nativeApiUrl && nativeCaseRunEnabled) {
throw new Error("Workbench and CaseRun native modes cannot share one Vite server");
}
if (nativeApiUrl && !nativeRuntimeConfig) throw new Error("WORKBENCH_WEB_RUNTIME_CONFIG is required in Workbench native mode");
return {
plugins: [vue()],
plugins: [...(nativeApiUrl ? [nativeRuntimeConfigPlugin(nativeRuntimeConfig)] : []), vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
@@ -64,3 +66,48 @@ export default defineConfig(({ mode }) => {
}
};
});
function nativeRuntimeConfigPlugin(serialized: string): Plugin {
const config = parseWorkbenchNativeRuntimeConfig(serialized);
const inlineJson = JSON.stringify(config).replace(/</gu, "\\u003c");
return {
name: "hwlab-workbench-native-runtime-config",
transformIndexHtml() {
return [{ tag: "script", injectTo: "head-prepend", children: `window.HWLAB_CLOUD_WEB_CONFIG=${inlineJson};` }];
}
};
}
export function parseWorkbenchNativeRuntimeConfig(serialized: string): Record<string, unknown> {
let value: unknown;
try { value = JSON.parse(serialized); }
catch { throw new Error("WORKBENCH_WEB_RUNTIME_CONFIG must be valid JSON"); }
const config = record(value, "WORKBENCH_WEB_RUNTIME_CONFIG");
const displayTime = record(config.displayTime, "WORKBENCH_WEB_RUNTIME_CONFIG.displayTime");
requiredString(displayTime.timeZone, "displayTime.timeZone");
requiredString(displayTime.locale, "displayTime.locale");
requiredString(displayTime.label, "displayTime.label");
const workbench = record(config.workbench, "WORKBENCH_WEB_RUNTIME_CONFIG.workbench");
const realtime = record(workbench.realtimeFeatures, "workbench.realtimeFeatures");
requiredBoolean(realtime.liveKafkaSse, "workbench.realtimeFeatures.liveKafkaSse");
requiredBoolean(realtime.kafkaRefreshReplay, "workbench.realtimeFeatures.kafkaRefreshReplay");
requiredBoolean(realtime.projectionRealtime, "workbench.realtimeFeatures.projectionRealtime");
const debug = record(workbench.debugCapabilities, "workbench.debugCapabilities");
requiredBoolean(debug.isolatedKafka, "workbench.debugCapabilities.isolatedKafka");
const rawWindow = record(debug.rawHwlabEventWindow, "workbench.debugCapabilities.rawHwlabEventWindow");
requiredBoolean(rawWindow.enabled, "workbench.debugCapabilities.rawHwlabEventWindow.enabled");
requiredPositiveInteger(rawWindow.maxEntries, "workbench.debugCapabilities.rawHwlabEventWindow.maxEntries");
requiredPositiveInteger(rawWindow.maxRetainedBytes, "workbench.debugCapabilities.rawHwlabEventWindow.maxRetainedBytes");
const traceTimeline = record(workbench.traceTimeline, "workbench.traceTimeline");
requiredBoolean(traceTimeline.autoExpandRunning, "workbench.traceTimeline.autoExpandRunning");
requiredBoolean(traceTimeline.autoCollapseTerminal, "workbench.traceTimeline.autoCollapseTerminal");
return config;
}
function record(value: unknown, path: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object`);
return value as Record<string, unknown>;
}
function requiredString(value: unknown, path: string): void { if (typeof value !== "string" || !value.trim()) throw new Error(`${path} must be a non-empty string`); }
function requiredBoolean(value: unknown, path: string): void { if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`); }
function requiredPositiveInteger(value: unknown, path: string): void { if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${path} must be a positive integer`); }