merge: 收敛 HWPOD native L1

This commit is contained in:
pikastech
2026-07-21 09:15:00 +02:00
6 changed files with 139 additions and 59 deletions
+20 -8
View File
@@ -197,7 +197,7 @@ lanes:
bindHost: 0.0.0.0
probeHost: 127.0.0.1
port: 5174
runtimeConfig:
runtimeConfig: &hwlabV03NativeCloudWebRuntimeConfig
displayTime:
timeZone: Asia/Shanghai
locale: zh-CN
@@ -259,6 +259,12 @@ lanes:
bindHost: 0.0.0.0
probeHost: 127.0.0.1
port: 5175
runtimeConfig: *hwlabV03NativeCloudWebRuntimeConfig
accessProfile:
id: hwpod-native-l1
allowedNavIds:
- admin.hwpodGroups
startPath: /hwpods/devices
caserun:
publicExposure:
publicBaseUrl: https://lab-dev-caserun.hwpod.com
@@ -980,26 +986,32 @@ templates:
hwpod:
application: hwpod
authentication: none
path: /
path: /hwpods/devices
navigationTimeoutMs: 30000
settleMs: 700
commandTimeoutSeconds: 120
viewport:
width: 1440
height: 900
width: 1920
height: 1080
readySelectors:
- "[data-testid='hwpod-console']"
- "[data-resources] .resource"
- .platform-sidebar
minBodyTextLength: 40
interaction:
selector: "[data-resources] .resource:first-child"
observedSelector: "[data-resources] .resource:first-child"
observedAttribute: aria-current
selector: .desktop-sidebar-toggle
observedSelector: .platform-sidebar
observedAttribute: data-collapsed
failedResponseStatus: 400
criticalPathPrefixes:
- /v1/hwpod
- /health/ready
screenshotName: native-hwpod-ready.png
layout:
requireNoDocumentOverflow: true
requireNoMainContentOverflow: false
mainContentSelector: .platform-content
internalScrollSelectors:
- .platform-content
outputLimits:
errors: 20
failedResponses: 20
+15 -1
View File
@@ -101,20 +101,34 @@ HWLAB v0.2/v0.3 仓库内 `docs/reference/spec-*`,以及已收编的 `cloud-wo
- 当前 lane 入口:
- development `NC01/v03` 前端/API 入口为 `https://lab-dev.hwpod.com`
- production release lane `NC01/production` 的正式入口为 `https://lab.hwpod.com`
- HWPOD L1 Native 是独立于 Cloud Web 的运行面:
- HWPOD L1 Native 是进程独立、界面复用完整 Cloud Web Shell 的运行面:
- API、Temporal worker、Web 和固定端口必须从 owning YAML 读取:
- `config/hwlab-node-lanes.yaml#lanes.<lane>.targets.<node>.nativeDevelopment.hwpod`
- 启停和状态统一使用 `hwlab nodes native-development hwpod api|worker|web`
- 聚合状态统一使用
`bun scripts/cli.ts hwlab nodes native-development hwpod status --node <node> --lane <lane>`
- L1 API、worker 和 Web 是 NC01 host 上的 native 进程:
- 任何情况下都不依赖 CI/CD、GitOps、Argo、Kubernetes 或集群 rollout
- 正常启动、首次拉起、配置变更、合并后复测和故障处理均适用;
- L1 Web 必须复用完整 Cloud Web 的 `AppShell`、Router、总根导航和
`/hwpods/devices` 页面,禁止另建 standalone SPA 或复制一套 HWPOD 页面;
- 可见根导航和首屏路径由
`nativeDevelopment.hwpod.web.accessProfile` 声明,runtime config 从同一 owning YAML
渲染并注入,禁止前端或启动脚本硬编码默认值;
- topology 首屏由 native HWPOD API 从 YAML-first/PostgreSQL spec registry 构建,
禁止代理 deployment Cloud API 作为 L1 首屏数据源;
- CLI `--over-api` 直接调用 owning YAML 固定端口上的 native API
- 同 host 回归使用 YAML probe host
- 禁止改走 Cloud API 或 Kubernetes Service
- L1 只验收本机 API、worker、Web 小回环:
- 公网入口与 public-edge 是独立可选检查;
- 不得成为 L1 启动、执行或完成条件;
- 浏览器验收统一使用
`bun scripts/cli.ts web-probe native-readiness --node <node> --lane <lane> --profile hwpod`
- HWPOD Web 无业务登录页时,由 owning YAML profile 声明 `authentication: none`,不得改用临时 Playwright 脚本或弱化浏览器错误断言。
- native API 已接受操作且 Temporal workflow 已创建时,后续公共
`/v1/hwpod-node-ops` 失败必须分类为外部 Cloud API 或 node 运行面故障,
不得误判为 L1 API、worker 或 Temporal 未启动。
- HWPOD L0 与 L1 的 host 边界:
- L0 `--local` 直接执行当前 host 的 function 和文件系统,不提供跨 host 路由;
- 跨 host 的 Windows spec 在 API host 做 L0 时,只比较 compiler/plan 合同;
+6 -1
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import { hwlabNativeDevelopmentEnvironment, hwlabNativeDevelopmentHelp } from "./hwlab-native-development";
import { hwlabHwpodNativeDevelopmentEnvironment, hwlabNativeDevelopmentEnvironment, hwlabNativeDevelopmentHelp } from "./hwlab-native-development";
import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
import { resolveConfigRef } from "./ops/config-refs";
@@ -67,6 +67,11 @@ test("L1 HWPOD spec registry resolves PostgreSQL authority and frozen built-ins
connectionTimeoutMs: 5000,
});
expect(native!.healthTimeoutMs).toBe(6000);
expect(native!.web.accessProfile).toEqual({ id: "hwpod-native-l1", allowedNavIds: ["admin.hwpodGroups"], startPath: "/hwpods/devices" });
const env = hwlabHwpodNativeDevelopmentEnvironment(native!);
expect(JSON.parse(env.HWPOD_WEB_RUNTIME_CONFIG!)).toEqual(native!.web.runtimeConfig);
expect(native!.web.runtimeConfig).toEqual(hwlabRuntimeLaneSpecForNode("v03", "NC01").nativeDevelopment!.workbench.web.runtimeConfig);
expect(JSON.parse(env.HWPOD_WEB_ACCESS_PROFILE_JSON!)).toEqual(native!.web.accessProfile);
expect(native!.specRegistry.builtInConfigRefs).toHaveLength(1);
const resolved = resolveConfigRef(native!.specRegistry.builtInConfigRefs[0], "HWPOD built-in spec");
expect(resolved.value).toMatchObject({ kind: "Hwpod", metadata: { name: "constart-71freq-c" } });
+23 -2
View File
@@ -17,6 +17,7 @@ export function hwlabNativeDevelopmentHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes native-development workbench status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes native-development workbench api|worker|web start|stop|restart|status|logs --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes native-development workbench cli <health|session|turn|events ...> --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes native-development hwpod status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes native-development hwpod api|worker|web start|stop|restart|status|logs --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes native-development caserun api|web start|stop|restart|status|logs --node <node> --lane <lane>",
],
@@ -96,6 +97,24 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record<string,
...(result.exitCode === 0 ? {} : { error: { code: "hwlab_native_cli_failed", exitCode: result.exitCode, stderr: result.stderr.trim().slice(-2000) } }),
};
}
if (application === "hwpod" && serviceRaw === "status") {
const remaining = withoutOptions(args.slice(2), ["--node", "--lane"]);
if (remaining.length > 0) throw new Error(`native-development hwpod status does not accept positional arguments: ${remaining.join(" ")}`);
const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", "hwpod", "service", "status"];
const result = runCommand(command, spec.workspace, { timeoutMs: 30_000, env: { ...process.env, ...env } });
const payload = parsePayload(result.stdout);
return {
ok: result.exitCode === 0 && payload.ok !== false,
command: "hwlab nodes native-development hwpod status",
node,
lane,
action: "status",
configSource: `${hwlabRuntimeLaneConfigPath()}#lanes.${lane}.targets.${node}.nativeDevelopment.hwpod`,
userEndpoint: { url: `${native.publicExposure.publicBaseUrl}${(native as NonNullable<HwlabRuntimeNativeDevelopmentSpec["hwpod"]>).web.accessProfile.startPath}` },
serviceResult: payload,
...(result.exitCode === 0 ? {} : { error: { code: "hwlab_native_cli_failed", exitCode: result.exitCode, stderr: result.stderr.trim().slice(-2000) } }),
};
}
const service = parseService(serviceRaw, application);
const action = parseAction(actionRaw);
const command = ["bun", "tools/hwlab-cli/bin/hwlab-cli.ts", application, "service", service, action];
@@ -104,7 +123,7 @@ export function runHwlabNativeDevelopmentCommand(args: string[]): Record<string,
const publicEndpoint = service === "api"
? { url: native.publicExposure.publicBaseUrl }
: service === "web"
? { url: application === "hwpod" ? native.publicExposure.publicBaseUrl : `${native.publicExposure.publicBaseUrl}/${application === "workbench" ? "workbench" : "caserun"}` }
? { url: application === "hwpod" ? `${native.publicExposure.publicBaseUrl}${(native as NonNullable<HwlabRuntimeNativeDevelopmentSpec["hwpod"]>).web.accessProfile.startPath}` : `${native.publicExposure.publicBaseUrl}/${application === "workbench" ? "workbench" : "caserun"}` }
: null;
return {
ok: result.exitCode === 0 && payload.ok !== false,
@@ -281,7 +300,7 @@ function hwlabCaseRunNativeDevelopmentEnvironment(native: NonNullable<HwlabRunti
};
}
function hwlabHwpodNativeDevelopmentEnvironment(native: NonNullable<HwlabRuntimeNativeDevelopmentSpec["hwpod"]>): NodeJS.ProcessEnv {
export function hwlabHwpodNativeDevelopmentEnvironment(native: NonNullable<HwlabRuntimeNativeDevelopmentSpec["hwpod"]>): NodeJS.ProcessEnv {
const authorizationSource = readEnvSourceFile({
root: "/root/.unidesk/.state/secrets",
sourceRef: native.runtimeApiAuth.sourceRef,
@@ -323,6 +342,8 @@ function hwlabHwpodNativeDevelopmentEnvironment(native: NonNullable<HwlabRuntime
HWPOD_WEB_BIND_HOST: native.web.bindHost,
HWPOD_WEB_PROBE_HOST: native.web.probeHost,
HWPOD_WEB_PORT: String(native.web.port),
HWPOD_WEB_RUNTIME_CONFIG: JSON.stringify(native.web.runtimeConfig),
HWPOD_WEB_ACCESS_PROFILE_JSON: JSON.stringify(native.web.accessProfile),
HWPOD_NATIVE_API_URL: `http://${native.api.probeHost}:${native.api.port}`,
};
}
+73 -46
View File
@@ -687,6 +687,18 @@ export interface HwlabRuntimeSourceWorkspaceHostDependenciesSpec {
};
}
export interface HwlabRuntimeNativeCloudWebRuntimeConfigSpec {
readonly displayTime: { readonly timeZone: string; readonly locale: string; readonly label: string };
readonly workbench: {
readonly realtimeFeatures: { readonly liveKafkaSse: boolean; readonly kafkaRefreshReplay: boolean; readonly projectionRealtime: boolean };
readonly debugCapabilities: {
readonly isolatedKafka: boolean;
readonly rawHwlabEventWindow: { readonly enabled: boolean; readonly maxEntries: number; readonly maxRetainedBytes: number };
};
readonly traceTimeline: { readonly autoExpandRunning: boolean; readonly autoCollapseTerminal: boolean };
};
}
export interface HwlabRuntimeNativeDevelopmentSpec {
readonly tasktree?: {
readonly publicExposure: HwlabRuntimeNativePublicExposureSpec;
@@ -745,17 +757,7 @@ export interface HwlabRuntimeNativeDevelopmentSpec {
readonly bindHost: string;
readonly probeHost: string;
readonly port: number;
readonly runtimeConfig: {
readonly displayTime: { readonly timeZone: string; readonly locale: string; readonly label: string };
readonly workbench: {
readonly realtimeFeatures: { readonly liveKafkaSse: boolean; readonly kafkaRefreshReplay: boolean; readonly projectionRealtime: boolean };
readonly debugCapabilities: {
readonly isolatedKafka: boolean;
readonly rawHwlabEventWindow: { readonly enabled: boolean; readonly maxEntries: number; readonly maxRetainedBytes: number };
};
readonly traceTimeline: { readonly autoExpandRunning: boolean; readonly autoCollapseTerminal: boolean };
};
};
readonly runtimeConfig: HwlabRuntimeNativeCloudWebRuntimeConfigSpec;
};
};
readonly hwpod?: {
@@ -787,7 +789,13 @@ export interface HwlabRuntimeNativeDevelopmentSpec {
};
readonly api: { readonly bindHost: string; readonly probeHost: string; readonly port: number };
readonly worker: { readonly bindHost: string; readonly probeHost: string; readonly healthPort: number };
readonly web: { readonly bindHost: string; readonly probeHost: string; readonly port: number };
readonly web: {
readonly bindHost: string;
readonly probeHost: string;
readonly port: number;
readonly runtimeConfig: HwlabRuntimeNativeCloudWebRuntimeConfigSpec;
readonly accessProfile: { readonly id: string; readonly allowedNavIds: readonly string[]; readonly startPath: string };
};
};
readonly caserun?: {
readonly publicExposure: HwlabRuntimeNativePublicExposureSpec;
@@ -1626,17 +1634,10 @@ function nativeDevelopmentConfig(value: unknown, path: string): HwlabRuntimeNati
const api = asRecord(workbench.api, `${path}.workbench.api`);
const worker = asRecord(workbench.worker, `${path}.workbench.worker`);
const web = asRecord(workbench.web, `${path}.workbench.web`);
const runtimeConfig = asRecord(web.runtimeConfig, `${path}.workbench.web.runtimeConfig`);
const displayTime = asRecord(runtimeConfig.displayTime, `${path}.workbench.web.runtimeConfig.displayTime`);
const workbenchRuntime = asRecord(runtimeConfig.workbench, `${path}.workbench.web.runtimeConfig.workbench`);
const realtimeFeatures = asRecord(workbenchRuntime.realtimeFeatures, `${path}.workbench.web.runtimeConfig.workbench.realtimeFeatures`);
const refreshReplay = asRecord(kafka.refreshReplay, `${path}.workbench.kafka.refreshReplay`);
const sessionIndex = refreshReplay.sessionIndex === undefined
? undefined
: asRecord(refreshReplay.sessionIndex, `${path}.workbench.kafka.refreshReplay.sessionIndex`);
const debugCapabilities = asRecord(workbenchRuntime.debugCapabilities, `${path}.workbench.web.runtimeConfig.workbench.debugCapabilities`);
const rawHwlabEventWindow = asRecord(debugCapabilities.rawHwlabEventWindow, `${path}.workbench.web.runtimeConfig.workbench.debugCapabilities.rawHwlabEventWindow`);
const traceTimeline = asRecord(workbenchRuntime.traceTimeline, `${path}.workbench.web.runtimeConfig.workbench.traceTimeline`);
return {
...(raw.tasktree === undefined ? {} : { tasktree: nativeTaskTreeConfig(raw.tasktree, `${path}.tasktree`) }),
workbench: {
@@ -1689,32 +1690,7 @@ function nativeDevelopmentConfig(value: unknown, path: string): HwlabRuntimeNati
bindHost: stringField(web, "bindHost", `${path}.workbench.web`),
probeHost: stringField(web, "probeHost", `${path}.workbench.web`),
port: numberField(web, "port", `${path}.workbench.web`),
runtimeConfig: {
displayTime: {
timeZone: stringField(displayTime, "timeZone", `${path}.workbench.web.runtimeConfig.displayTime`),
locale: stringField(displayTime, "locale", `${path}.workbench.web.runtimeConfig.displayTime`),
label: stringField(displayTime, "label", `${path}.workbench.web.runtimeConfig.displayTime`),
},
workbench: {
realtimeFeatures: {
liveKafkaSse: booleanField(realtimeFeatures, "liveKafkaSse", `${path}.workbench.web.runtimeConfig.workbench.realtimeFeatures`),
kafkaRefreshReplay: booleanField(realtimeFeatures, "kafkaRefreshReplay", `${path}.workbench.web.runtimeConfig.workbench.realtimeFeatures`),
projectionRealtime: booleanField(realtimeFeatures, "projectionRealtime", `${path}.workbench.web.runtimeConfig.workbench.realtimeFeatures`),
},
debugCapabilities: {
isolatedKafka: booleanField(debugCapabilities, "isolatedKafka", `${path}.workbench.web.runtimeConfig.workbench.debugCapabilities`),
rawHwlabEventWindow: {
enabled: booleanField(rawHwlabEventWindow, "enabled", `${path}.workbench.web.runtimeConfig.workbench.debugCapabilities.rawHwlabEventWindow`),
maxEntries: numberField(rawHwlabEventWindow, "maxEntries", `${path}.workbench.web.runtimeConfig.workbench.debugCapabilities.rawHwlabEventWindow`),
maxRetainedBytes: numberField(rawHwlabEventWindow, "maxRetainedBytes", `${path}.workbench.web.runtimeConfig.workbench.debugCapabilities.rawHwlabEventWindow`),
},
},
traceTimeline: {
autoExpandRunning: booleanField(traceTimeline, "autoExpandRunning", `${path}.workbench.web.runtimeConfig.workbench.traceTimeline`),
autoCollapseTerminal: booleanField(traceTimeline, "autoCollapseTerminal", `${path}.workbench.web.runtimeConfig.workbench.traceTimeline`),
},
},
},
runtimeConfig: nativeCloudWebRuntimeConfig(web.runtimeConfig, `${path}.workbench.web.runtimeConfig`),
},
},
...(raw.hwpod === undefined ? {} : { hwpod: nativeHwpodConfig(raw.hwpod, `${path}.hwpod`) }),
@@ -1732,6 +1708,11 @@ function nativeHwpodConfig(value: unknown, path: string): NonNullable<HwlabRunti
const api = asRecord(raw.api, `${path}.api`);
const worker = asRecord(raw.worker, `${path}.worker`);
const web = asRecord(raw.web, `${path}.web`);
const accessProfile = asRecord(web.accessProfile, `${path}.web.accessProfile`);
const startPath = stringField(accessProfile, "startPath", `${path}.web.accessProfile`);
if (!startPath.startsWith("/")) throw new Error(`${path}.web.accessProfile.startPath must be an absolute application path`);
const allowedNavIds = stringArrayField(accessProfile, "allowedNavIds", `${path}.web.accessProfile`);
if (allowedNavIds.length === 0) throw new Error(`${path}.web.accessProfile.allowedNavIds must not be empty`);
return {
publicExposure: nativePublicExposureConfig(raw.publicExposure, `${path}.publicExposure`),
stateDir: relativeWorkspacePathField(stringField(raw, "stateDir", path), `${path}.stateDir`),
@@ -1764,7 +1745,53 @@ function nativeHwpodConfig(value: unknown, path: string): NonNullable<HwlabRunti
},
api: { bindHost: stringField(api, "bindHost", `${path}.api`), probeHost: stringField(api, "probeHost", `${path}.api`), port: numberField(api, "port", `${path}.api`) },
worker: { bindHost: stringField(worker, "bindHost", `${path}.worker`), probeHost: stringField(worker, "probeHost", `${path}.worker`), healthPort: numberField(worker, "healthPort", `${path}.worker`) },
web: { bindHost: stringField(web, "bindHost", `${path}.web`), probeHost: stringField(web, "probeHost", `${path}.web`), port: numberField(web, "port", `${path}.web`) },
web: {
bindHost: stringField(web, "bindHost", `${path}.web`),
probeHost: stringField(web, "probeHost", `${path}.web`),
port: numberField(web, "port", `${path}.web`),
runtimeConfig: nativeCloudWebRuntimeConfig(web.runtimeConfig, `${path}.web.runtimeConfig`),
accessProfile: {
id: stringField(accessProfile, "id", `${path}.web.accessProfile`),
allowedNavIds,
startPath,
},
},
};
}
function nativeCloudWebRuntimeConfig(value: unknown, path: string): HwlabRuntimeNativeCloudWebRuntimeConfigSpec {
const runtimeConfig = asRecord(value, path);
const displayTime = asRecord(runtimeConfig.displayTime, `${path}.displayTime`);
const workbench = asRecord(runtimeConfig.workbench, `${path}.workbench`);
const realtimeFeatures = asRecord(workbench.realtimeFeatures, `${path}.workbench.realtimeFeatures`);
const debugCapabilities = asRecord(workbench.debugCapabilities, `${path}.workbench.debugCapabilities`);
const rawHwlabEventWindow = asRecord(debugCapabilities.rawHwlabEventWindow, `${path}.workbench.debugCapabilities.rawHwlabEventWindow`);
const traceTimeline = asRecord(workbench.traceTimeline, `${path}.workbench.traceTimeline`);
return {
displayTime: {
timeZone: stringField(displayTime, "timeZone", `${path}.displayTime`),
locale: stringField(displayTime, "locale", `${path}.displayTime`),
label: stringField(displayTime, "label", `${path}.displayTime`),
},
workbench: {
realtimeFeatures: {
liveKafkaSse: booleanField(realtimeFeatures, "liveKafkaSse", `${path}.workbench.realtimeFeatures`),
kafkaRefreshReplay: booleanField(realtimeFeatures, "kafkaRefreshReplay", `${path}.workbench.realtimeFeatures`),
projectionRealtime: booleanField(realtimeFeatures, "projectionRealtime", `${path}.workbench.realtimeFeatures`),
},
debugCapabilities: {
isolatedKafka: booleanField(debugCapabilities, "isolatedKafka", `${path}.workbench.debugCapabilities`),
rawHwlabEventWindow: {
enabled: booleanField(rawHwlabEventWindow, "enabled", `${path}.workbench.debugCapabilities.rawHwlabEventWindow`),
maxEntries: numberField(rawHwlabEventWindow, "maxEntries", `${path}.workbench.debugCapabilities.rawHwlabEventWindow`),
maxRetainedBytes: numberField(rawHwlabEventWindow, "maxRetainedBytes", `${path}.workbench.debugCapabilities.rawHwlabEventWindow`),
},
},
traceTimeline: {
autoExpandRunning: booleanField(traceTimeline, "autoExpandRunning", `${path}.workbench.traceTimeline`),
autoCollapseTerminal: booleanField(traceTimeline, "autoCollapseTerminal", `${path}.workbench.traceTimeline`),
},
},
};
}
@@ -103,8 +103,9 @@ test("HWPOD native-readiness resolves the independent L1 Web origin", () => {
assert.equal(options.action, "script");
if (options.action !== "script") return;
assert.equal(options.url, "https://lab-dev-hwpod.hwpod.com");
assert.equal(options.viewport, "1440x900");
assert.equal(options.viewport, "1920x1080");
assert.equal(options.authentication, "none");
assert.equal(options.scriptSource.path, "builtin:native-readiness/hwpod");
});
test("CaseRun native readiness resolves the CaseRun YAML HTTPS origin", () => {