fix: 修复 Workbench L1 页面启动卡死
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {}; },
|
||||
|
||||
@@ -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"]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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`); }
|
||||
|
||||
Reference in New Issue
Block a user