feat: manage D601 hwpod gateway from yaml (#995)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -26,11 +26,15 @@ gateway:
|
||||
managedRun:
|
||||
mode: windows-scheduled-task
|
||||
runtimeRoot: "C:\\Users\\liang\\hwpod-node-runtime"
|
||||
bunPath: "C:\\Users\\liang\\.bun\\bin\\bun.exe"
|
||||
taskName: HWLAB-HWPOD-Node-WS
|
||||
periodicTaskName: hwpod-node-ws-periodic
|
||||
runKeyName: hwpod-node-ws
|
||||
startCommand: "C:\\Users\\liang\\hwpod-node-runtime\\start-node-ws.cmd"
|
||||
statusCommand: "C:\\Users\\liang\\hwpod-node-runtime\\status-node-ws.cmd"
|
||||
processPattern: "tools\\hwpod-node.ts connect"
|
||||
reconnectBaseMs: 1000
|
||||
reconnectMaxMs: 15000
|
||||
secretRefs:
|
||||
- purpose: gatewayAuthToken
|
||||
sourceRef: hwlab/d601-v03-gateway.env
|
||||
|
||||
@@ -35,13 +35,13 @@
|
||||
|
||||
71FREQ预装负责把 D601 v0.3 上已存在的 71-FREQ 项目资源声明为 YAML-first HWPOD 预装资源,使 HWLAB Cloud API、HWPOD 工具、CaseRun、MDTODO 项目管理页面和 web-probe 都通过同一套受控配置发现 `constart-71freq-c`,而不是依赖旧 v0.2 direct-url、D601 Windows 本地文件、一次性脚本或代码硬编码。
|
||||
|
||||
本规格的目标状态是:UniDesk YAML 声明 D601/v03 node/lane、preinstalled HWPOD spec、project-management MDTODO source、public/API origin 和必要 Secret sourceRef;HWLAB runtime 只读取挂载到运行面的 spec registry 与 project-management source config;Windows gateway 只按声明的 outbound profile 主动轮询云端命令;CaseRun、MDTODO 和 web-probe 验收只访问 public API 或同源 Web/API,不直连本地文件或私有调试通道。
|
||||
本规格的目标状态是:UniDesk YAML 声明 D601/v03 node/lane、preinstalled HWPOD spec、project-management MDTODO source、public/API origin 和必要 Secret sourceRef;HWLAB runtime 只读取挂载到运行面的 spec registry 与 project-management source config;D601 Windows 运行面只按声明的 v0.3+ outbound HWPOD node/gateway profile 主动连接云端执行面;CaseRun、MDTODO 和 web-probe 验收只访问 public API 或同源 Web/API,不直连本地文件或私有调试通道。
|
||||
|
||||
### 2.2 范围内
|
||||
|
||||
- D601 v0.3 node/lane、workspace、namespace、public URL 和 target 解析与 preinstall configRef 关系。
|
||||
- `constart-71freq-c` 的 preinstalled HWPOD spec、metadata sidecar、ConfigMap/volume/env 挂载和 Cloud API discovery。
|
||||
- 71-FREQ Windows gateway outbound profile、session/resource/capability 绑定和 poll/result 状态可见性。
|
||||
- 71-FREQ Windows v0.3+ outbound HWPOD node/gateway profile、session/resource/capability 绑定和 dispatch/result 状态可见性。
|
||||
- Project-management MDTODO source 通过 HWPOD workspace ops 读取 `F:\Work\ConStart\docs\MDTODO`,不新增第二套文件读取器。
|
||||
- 顶层 `web-probe` CLI 对 HWPOD/MDTODO/Web 原入口的 observe、command、collect 和 analyze 验收。
|
||||
- CaseRun build、download、UART/ioProbe smoke 的最小闭环和 41 相关未决项的非阻塞边界。
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
### 2.3 范围外
|
||||
|
||||
- 旧 v0.2 `hwpod-node` direct-url 运行面、旧 `.device-pod` profile 和历史 `gws_DESKTOP` 会话不得成为 v0.3 runtime truth;它们只可作为迁移输入或对照证据。
|
||||
- v0.2 `hwpod-node` direct-url 运行面、旧 `.device-pod` profile 和历史 `gws_DESKTOP` 会话不保留支持或兼容;它们只能作为 legacy-retire 迁移输入和 drift 诊断证据。v0.3+ 受控 apply 必须停止或覆盖旧 direct-url runner,不能保留并行 fallback、alias、compat task 或旧 cloud URL 运行面。
|
||||
- HWPOD spec 字段通用标准仍归 [PJ2026-010101 HWPOD标准](PJ2026-010101-hwpod-standard.md)。
|
||||
- HWPOD CLI 输出格式、交互体验和 validate/inspect 命令通用语义归 [PJ2026-010102 HWPOD工具](PJ2026-010102-hwpod-tools.md)。
|
||||
- 真实 adapter 执行、Keil、debug probe、UART、board-comm、ioProbe 和安全恢复归 [PJ2026-010104 AI网关](PJ2026-010104-ai-gateway.md)。
|
||||
@@ -63,7 +63,7 @@
|
||||
| preinstalled HWPOD spec | 由 UniDesk YAML 声明并以 ConfigMap/volume 形式挂载到 HWLAB runtime 的 HWPOD spec,不随 cloud-api 镜像硬编码。 |
|
||||
| preinstall configRef | node/lane YAML 指向 HWPOD preinstall owning YAML 的引用,负责把目标、spec、metadata、mount、env 和 rollout 关联起来。 |
|
||||
| `constart-71freq-c` | D601 v0.3 71-FREQ 预装 HWPOD 的稳定资源 id;该 id 是 YAML 声明事实,不是代码默认值。 |
|
||||
| outbound gateway profile | Windows gateway 主动连接云端领取命令的运行 profile,包含 gatewayId、sessionId、resourceId、capabilityId、cloudUrl 和本地执行能力绑定。 |
|
||||
| outbound HWPOD node/gateway profile | Windows 运行面主动连接云端领取命令的 v0.3+ profile,包含 gatewayId/sessionId、resourceId、capabilityId、cloudUrl/nodeOps WebSocket URL 和本地执行能力绑定。 |
|
||||
| MDTODO source | project-management 服务中的项目管理 source,使用 `sourceKind=hwpod-workspace` 经 HWPOD workspace ops 读取工作区内 Markdown todo 文件。 |
|
||||
| `readMdtodo` command | web-probe observe command 类型,用 public Web/API 或同源项目管理 API 触发 MDTODO source/files/tasks 读取和摘要采样。 |
|
||||
| configRef graph | 由 YAML path、object path、presence、fingerprint、target 和缺失字段组成的配置引用图,默认输出不得展开 Secret 或完整 Markdown。 |
|
||||
@@ -94,7 +94,7 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| PJ2026-0101030501 | 配置真相 | 本规格 6.1 | D601/v03 target、preinstall、MDTODO source、gateway profile 的 YAML owning/ref 图 | YAML运维、HWPOD标准 | HWPOD discovery、PM source、gateway |
|
||||
| PJ2026-0101030502 | 运行发现 | 本规格 6.2 | ConfigMap/volume/env 挂载、cloud-api discovery、spec validate/inspect | 配置真相、HWPOD服务 | HWPOD工具、客户端、CaseRun |
|
||||
| PJ2026-0101030503 | 网关出站 | 本规格 6.3 | D601 Windows gateway outbound poll、resource/capability 和 result 状态 | AI网关、运行发现 | HWPOD路由、CaseRun |
|
||||
| PJ2026-0101030503 | 网关出站 | 本规格 6.3 | D601 Windows v0.3+ outbound HWPOD node/gateway、resource/capability 和 result 状态 | AI网关、运行发现 | HWPOD路由、CaseRun |
|
||||
| PJ2026-0101030504 | MDTODO源 | 本规格 6.4 | `hwpod-workspace` source、workspace ops、source/files/tasks 投影 | 运行发现、客户端 | 项目管理页面、web-probe |
|
||||
| PJ2026-0101030505 | web-probe验收 | 本规格 6.5 | 顶层 `web-probe` observe/command/collect/analyze、`readMdtodo` 和单实现迁移 | Web哨兵、公开入口 | P4 原入口验收 |
|
||||
| PJ2026-0101030506 | CaseRun smoke | 本规格 6.6 | build/download/UART/ioProbe smoke 与 41 相关未决边界 | HarnessRL、AI网关 | 硬件闭环验收 |
|
||||
@@ -155,28 +155,27 @@ flowchart TD
|
||||
|
||||
运行发现要求 `constart-71freq-c` 的 runtime authority 来自挂载目录和 discovery 结果。cloud-api 代码不得把 71-FREQ 路径、COM 口、probe UID、board-comm IP、HWPOD id 或旧 node id 写成隐藏默认值。
|
||||
|
||||
### 5.4 Windows gateway outbound poll 时序图
|
||||
### 5.4 Windows outbound HWPOD node/gateway 时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Svc as HWLAB Cloud HWPOD service
|
||||
participant GW as D601 Windows gateway
|
||||
participant GW as D601 Windows HWPOD node/gateway
|
||||
participant Keil as Keil/debug probe/UART
|
||||
participant Board as 71-FREQ board and probes
|
||||
|
||||
GW->>Svc: register gatewayId/sessionId/resourceId/capabilityId
|
||||
Svc-->>GW: poll lease / command / heartbeat interval
|
||||
GW->>Svc: poll next command
|
||||
Svc-->>GW: command request with requestId and adapter args
|
||||
GW->>Svc: connect/register via v0.3 cloud URL and nodeOps WebSocket
|
||||
Svc-->>GW: accepted nodeId/resource/capability and heartbeat policy
|
||||
Svc-->>GW: dispatch command request with requestId and adapter args
|
||||
GW->>Keil: execute build/download/UART/diagnostic
|
||||
Keil->>Board: flash/reset/read/probe
|
||||
Board-->>Keil: raw hardware facts
|
||||
Keil-->>GW: adapter result
|
||||
GW-->>Svc: result requestId/status/facts/log refs
|
||||
Svc-->>GW: ack and next poll policy
|
||||
Svc-->>GW: ack and next dispatch policy
|
||||
```
|
||||
|
||||
gateway 必须是可长期运行的 detached/service/scheduled-task 或等价托管进程。一次性 `cmd start`、交互式 PowerShell 或旧 v0.2 node 进程不能作为完成状态。
|
||||
D601 Windows 运行面必须是可长期运行的 detached/service/scheduled-task 或等价托管进程,且启动命令必须由 v0.3+ YAML profile 渲染。一次性 `cmd start`、交互式 PowerShell、旧 v0.2 direct-url node 进程、并行 compat task 或旧 cloud URL 进程不能作为完成状态。
|
||||
|
||||
### 5.5 CaseRun build/download/ioProbe 验收时序图
|
||||
|
||||
@@ -212,7 +211,7 @@ CaseRun smoke 只要求证明 71-FREQ 最小硬件链路能通过 HWPOD 服务 r
|
||||
|
||||
71-FREQ 预装配置必须由 UniDesk YAML 承载,并通过 configRef 串联 node/lane、HWPOD spec、metadata、project-management source、gateway profile、public/API origin 和 Secret sourceRef。代码只负责解析、校验和渲染,不得内置 `D601`、`v03`、`constart-71freq-c`、`F:\Work\ConStart`、Keil 路径、COM 口、probe UID、board-comm IP、ioProbe id、旧 cloud URL 或 gateway/session id 作为 fallback。
|
||||
|
||||
HWPOD preinstall owning YAML 至少应声明 hwpodId、sourceRef、metadataRef、targetDevice、nodeBinding、workspaceRootRef、projectRoot、toolchain、debugProbe、uart、boardComm endpoints、ioProbe、ConfigMap 名称、mount path、environment key 和 rollout target。71-FREQ 的 boardComm endpoints 必须显式包含 `freq71` 和 `main41`,ioProbe 必须显式包含 `main41.ai0.current`,这些字段必须进入最终挂载给 cloud-api 的 `specDocument`,不能只停留在运维 YAML 的旁路摘要字段。MDTODO source owning YAML 至少应声明 sourceId、sourceKind、projectId、hwpodId、nodeId、workspaceRootRef、mdtodoRootRef、focus files 和 hwpod node ops URL configRef。Gateway profile owning YAML 至少应声明 cloudUrl、gatewayId、sessionId、resourceId、capabilityId、secretRefs 和托管运行方式。
|
||||
HWPOD preinstall owning YAML 至少应声明 hwpodId、sourceRef、metadataRef、targetDevice、nodeBinding、workspaceRootRef、projectRoot、toolchain、debugProbe、uart、boardComm endpoints、ioProbe、ConfigMap 名称、mount path、environment key 和 rollout target。71-FREQ 的 boardComm endpoints 必须显式包含 `freq71` 和 `main41`,ioProbe 必须显式包含 `main41.ai0.current`,这些字段必须进入最终挂载给 cloud-api 的 `specDocument`,不能只停留在运维 YAML 的旁路摘要字段。MDTODO source owning YAML 至少应声明 sourceId、sourceKind、projectId、hwpodId、nodeId、workspaceRootRef、mdtodoRootRef、focus files 和 hwpod node ops URL configRef。Gateway profile owning YAML 至少应声明 cloudUrl、nodeOps websocketUrl、gatewayId、sessionId、resourceId、capabilityId、secretRefs、runtimeRoot、bunPath、taskName、periodicTaskName、runKeyName、start/status command、processPattern 和重连参数;代码不得为这些运行面事实提供隐藏默认。
|
||||
|
||||
### 6.2 HWPOD-PRE-REQ-002 Runtime discovery 与 HWPOD工具验证
|
||||
|
||||
@@ -224,15 +223,17 @@ HWLAB v0.3 cloud-api 必须从 runtime 挂载的 preinstalled HWPOD spec registr
|
||||
|
||||
cloud-api Pod manifest 必须包含只读 mount `/etc/hwlab/hwpod-specs` 或 YAML 声明的等价 mount,并设置 `HWLAB_HWPOD_SPEC_REGISTRY_DIRS` 指向该目录。spec discovery 可以继续支持 workspace-local `.hwlab/hwpod-spec.yaml` 和 registry dirs,但 D601/v03 71-FREQ 的 completion authority 必须是运行面挂载的 YAML preinstall,不是镜像内文件、旧 `.device-pod` profile 或旧 direct cloud URL。
|
||||
|
||||
### 6.3 HWPOD-PRE-REQ-003 D601 Windows gateway 出站托管
|
||||
### 6.3 HWPOD-PRE-REQ-003 D601 Windows v0.3+ 出站托管
|
||||
|
||||
| 编号 | 短名 | 主责模块 | 关联模块 |
|
||||
| --- | --- | --- | --- |
|
||||
| HWPOD-PRE-REQ-003 | 网关出站 | PJ2026-0101030503 网关出站 | [AI网关](PJ2026-010104-ai-gateway.md)、[HWPOD服务](PJ2026-010103-hwpod-service.md) |
|
||||
|
||||
D601 Windows gateway 必须以 v0.3 outbound profile 长期运行,主动连接 YAML 声明的 HWLAB Cloud URL,注册 gateway/session/resource/capability,并持续 poll 命令、回传 result、暴露最近 poll/result 摘要和托管状态。
|
||||
D601 Windows HWPOD node/gateway 必须以 v0.3+ outbound profile 长期运行,主动连接 YAML 声明的 HWLAB Cloud URL 或 nodeOps WebSocket URL,注册 nodeId/gateway/session/resource/capability,并持续接收命令、回传 result、暴露最近 dispatch/result 摘要和托管状态。
|
||||
|
||||
gateway 配置和状态输出应能证明:它绑定 `constart-71freq-c` 对应 resource、能力覆盖 workspace/Keil/debug/UART/diagnostic/ioProbe 所需动作、服务托管方式不是交互式临时命令、最近 poll 正常或失败原因可见。Secret 只通过 sourceRef 注入;日志、status 和 issue evidence 不得打印完整 token、API key 或 DSN。
|
||||
gateway 配置和状态输出应能证明:它绑定 `constart-71freq-c` 对应 resource、能力覆盖 workspace/Keil/debug/UART/diagnostic/ioProbe 所需动作、服务托管方式不是交互式临时命令、最近 dispatch 正常或失败原因可见。Secret 只通过 sourceRef 注入;日志、status 和 issue evidence 不得打印完整 token、API key 或 DSN。
|
||||
|
||||
v0.2 不再是兼容目标。受控 `apply` 必须从 YAML 生成 v0.3+ runner/env/status/install 文件并覆盖同名 legacy runner,停止仍连接旧 `http://74.48.78.17:19667` 或其他非 YAML cloud URL 的 `hwpod-node.ts connect` 进程,重建 scheduled task/HKCU Run 指向 v0.3+ runner。状态命令必须把旧 v0.2 process、旧 direct cloud URL、旧 `.device-pod` profile 和 compat task 判定为 legacy drift,而不是 fallback 或 degraded-but-compatible。
|
||||
|
||||
### 6.4 HWPOD-PRE-REQ-004 MDTODO source 经 HWPOD workspace ops 解耦读取
|
||||
|
||||
@@ -278,4 +279,4 @@ P4 原入口验收必须覆盖 HWPOD discovery、gateway echo 或等价 command
|
||||
|
||||
本规格不承载执行证据、运行日志、trace、CaseRun registry 或长 Markdown 样本;这些证据保留在对应 GitHub 执行 issue、PR closeout、运行面 artifact 和受控 CLI 输出中。
|
||||
|
||||
执行阶段固定为 P0 SPEC-first、P1 YAML-first preinstall config/render plan、P2 runtime discovery 与 MDTODO source、P3 D601 Windows gateway 托管、P4 HWPOD/MDTODO/web-probe/CaseRun 原入口验收。P0 未完成前,不得推进代码实现、部署、CI/CD、测试补充或运行面 closeout。
|
||||
执行阶段固定为 P0 SPEC-first、P1 YAML-first preinstall config/render plan、P2 runtime discovery 与 MDTODO source、P3 D601 Windows v0.3+ HWPOD node/gateway 托管、P4 HWPOD/MDTODO/web-probe/CaseRun 原入口验收。v0.2 支持和兼容路径不进入本规格完成态;发现旧 v0.2 runner 只作为 legacy drift 处理。P0 未完成前,不得推进代码实现、部署、CI/CD、测试补充或运行面 closeout。
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
// Responsibility: Redacted YAML configRef graph and plan/status rendering for D601 71-FREQ HWPOD preinstall.
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { rootPath } from "./config";
|
||||
import { repoRoot, rootPath } from "./config";
|
||||
import { runCommand, type CommandResult } from "./command";
|
||||
import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
||||
import { assertLane, assertNodeId, requiredOption } from "./hwlab-node/utils";
|
||||
import { assertKnownOptions } from "./hwlab-node/web-probe-observe";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
|
||||
type HwpodPreinstallAction = "plan" | "status";
|
||||
type HwpodPreinstallAction = "plan" | "status" | "gateway-plan" | "gateway-status" | "gateway-apply";
|
||||
type HwpodPreinstallConfigRefKey = "preinstall" | "projectManagementSource" | "gatewayProfile";
|
||||
|
||||
interface HwpodPreinstallOptions {
|
||||
@@ -16,6 +17,7 @@ interface HwpodPreinstallOptions {
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly dryRun: boolean;
|
||||
readonly confirm: boolean;
|
||||
}
|
||||
|
||||
interface HwpodPreinstallRoot {
|
||||
@@ -41,6 +43,76 @@ interface HwpodPreinstallConfigPlan {
|
||||
readonly valuesRedacted: true;
|
||||
}
|
||||
|
||||
interface HwpodGatewayPlan {
|
||||
readonly ok: boolean;
|
||||
readonly command: string;
|
||||
readonly status: "ready" | "blocked" | "applied";
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly runtimeRoot: string;
|
||||
readonly profile: HwpodGatewayProfileSummary | null;
|
||||
readonly files: readonly HwpodGatewayFilePlan[];
|
||||
readonly legacyRetire: readonly string[];
|
||||
readonly blockers: readonly string[];
|
||||
readonly remoteStatus: HwpodGatewayRemoteStatus | null;
|
||||
readonly remoteApply: HwpodGatewayApplyResult | null;
|
||||
readonly next: Record<string, string>;
|
||||
readonly valuesRedacted: true;
|
||||
}
|
||||
|
||||
interface HwpodGatewayProfileSummary {
|
||||
readonly hwlabNode: string;
|
||||
readonly lane: string;
|
||||
readonly cloudUrl: string;
|
||||
readonly websocketUrl: string;
|
||||
readonly hwpodId: string;
|
||||
readonly nodeId: string;
|
||||
readonly gatewayId: string;
|
||||
readonly sessionId: string;
|
||||
readonly resourceId: string;
|
||||
readonly capabilityId: string;
|
||||
readonly taskName: string;
|
||||
readonly periodicTaskName: string;
|
||||
readonly runtimeRoot: string;
|
||||
readonly bunPath: string;
|
||||
readonly runKeyName: string;
|
||||
readonly startCommand: string;
|
||||
readonly statusCommand: string;
|
||||
readonly processPattern: string;
|
||||
readonly reconnectBaseMs: number;
|
||||
readonly reconnectMaxMs: number;
|
||||
}
|
||||
|
||||
interface HwpodGatewayFilePlan {
|
||||
readonly path: string;
|
||||
readonly kind: "source" | "runner";
|
||||
readonly bytes: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
interface HwpodGatewayRemoteStatus {
|
||||
readonly ok: boolean;
|
||||
readonly status: string;
|
||||
readonly rootExists: boolean;
|
||||
readonly expectedProcessCount: number;
|
||||
readonly legacyProcessCount: number;
|
||||
readonly taskStates: Record<string, string>;
|
||||
readonly fileStates: Record<string, string>;
|
||||
readonly legacyProcessCommands: readonly string[];
|
||||
readonly valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
interface HwpodGatewayApplyResult {
|
||||
readonly ok: boolean;
|
||||
readonly status: string;
|
||||
readonly writtenFiles: readonly string[];
|
||||
readonly stoppedLegacyPids: readonly number[];
|
||||
readonly taskName: string;
|
||||
readonly periodicTaskName: string;
|
||||
readonly runTaskExitCode: number | null;
|
||||
readonly valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
interface HwpodPreinstallConfigRefStatus {
|
||||
readonly key: HwpodPreinstallConfigRefKey;
|
||||
readonly ref: string;
|
||||
@@ -130,6 +202,8 @@ const REQUIRED_TARGET_SHAPES: Record<HwpodPreinstallConfigRefKey, RequiredTarget
|
||||
kind: "object",
|
||||
requiredPaths: [
|
||||
"cloudUrl",
|
||||
"node",
|
||||
"lane",
|
||||
"gatewayId",
|
||||
"sessionId",
|
||||
"resourceId",
|
||||
@@ -138,8 +212,18 @@ const REQUIRED_TARGET_SHAPES: Record<HwpodPreinstallConfigRefKey, RequiredTarget
|
||||
"nodeId",
|
||||
"nodeOps.serviceUrl",
|
||||
"nodeOps.publicUrl",
|
||||
"nodeOps.websocketUrl",
|
||||
"managedRun.mode",
|
||||
"managedRun.runtimeRoot",
|
||||
"managedRun.bunPath",
|
||||
"managedRun.taskName",
|
||||
"managedRun.periodicTaskName",
|
||||
"managedRun.runKeyName",
|
||||
"managedRun.startCommand",
|
||||
"managedRun.statusCommand",
|
||||
"managedRun.processPattern",
|
||||
"managedRun.reconnectBaseMs",
|
||||
"managedRun.reconnectMaxMs",
|
||||
"secretRefs[0].sourceRef",
|
||||
],
|
||||
},
|
||||
@@ -154,14 +238,21 @@ export function hwlabNodeHwpodPreinstallHelp(): Record<string, unknown> {
|
||||
examples: [
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node D601 --lane v03 --dry-run",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-preinstall status --node D601 --lane v03",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-plan --node D601 --lane v03 --dry-run",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-status --node D601 --lane v03",
|
||||
"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-apply --node D601 --lane v03 --dry-run",
|
||||
],
|
||||
actions: {
|
||||
plan: "Validate D601/v03 hwpodPreinstall configRefs, cross-check ids, and render runtime artifact targets.",
|
||||
status: "Render the same redacted graph for the selected target as a read-only status view.",
|
||||
"gateway-plan": "Render the v0.3+ Windows HWPOD node/gateway runner files and legacy-retire policy without touching runtime.",
|
||||
"gateway-status": "Read D601 Windows task/process/file state and classify old v0.2 direct-url runners as legacy drift.",
|
||||
"gateway-apply": "Sync v0.3 source tools, overwrite the Windows runner from YAML, stop legacy v0.2 runners, and recreate managed tasks.",
|
||||
},
|
||||
notes: [
|
||||
"Root hwpodPreinstall must be declared under the selected lane target; this command does not use global defaults.",
|
||||
"Secret values, full YAML objects, host file content, and raw Markdown are not printed.",
|
||||
"v0.2 is not a compatibility target for this feature; gateway commands only support v0.3+ selected lanes.",
|
||||
"Runtime apply/rollout is a later P3 step; plan/status are P2 visibility and friction-removal commands.",
|
||||
],
|
||||
};
|
||||
@@ -171,21 +262,35 @@ export async function runHwlabNodeHwpodPreinstallCommand(args: string[]): Promis
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return hwlabNodeHwpodPreinstallHelp();
|
||||
const options = parseHwpodPreinstallOptions(args);
|
||||
const spec = hwlabRuntimeLaneSpecForNode(options.lane, options.node);
|
||||
return withHwpodPreinstallRendered(hwpodPreinstallConfigPlan(spec, options.action, options.dryRun));
|
||||
if (options.action === "plan" || options.action === "status") {
|
||||
return withHwpodPreinstallRendered(hwpodPreinstallConfigPlan(spec, options.action, options.dryRun));
|
||||
}
|
||||
return withHwpodGatewayRendered(await hwpodGatewayPlan(spec, options));
|
||||
}
|
||||
|
||||
function parseHwpodPreinstallOptions(args: string[]): HwpodPreinstallOptions {
|
||||
const [actionRaw] = args;
|
||||
if (actionRaw !== "plan" && actionRaw !== "status") {
|
||||
throw new Error("hwpod-preinstall usage: hwpod-preinstall plan|status --node NODE --lane vNN [--dry-run]");
|
||||
if (
|
||||
actionRaw !== "plan"
|
||||
&& actionRaw !== "status"
|
||||
&& actionRaw !== "gateway-plan"
|
||||
&& actionRaw !== "gateway-status"
|
||||
&& actionRaw !== "gateway-apply"
|
||||
) {
|
||||
throw new Error("hwpod-preinstall usage: hwpod-preinstall plan|status|gateway-plan|gateway-status|gateway-apply --node NODE --lane vNN [--dry-run|--confirm]");
|
||||
}
|
||||
assertKnownOptions(args, new Set(["--node", "--lane"]), new Set(["--dry-run", "--full"]));
|
||||
assertKnownOptions(args, new Set(["--node", "--lane"]), new Set(["--dry-run", "--confirm", "--full"]));
|
||||
const node = requiredOption(args, "--node");
|
||||
assertNodeId(node);
|
||||
const lane = requiredOption(args, "--lane");
|
||||
assertLane(lane);
|
||||
if (!isHwlabRuntimeLane(lane)) throw new Error(`hwpod-preinstall only supports HWLAB runtime lanes, got ${lane}`);
|
||||
return { action: actionRaw, node, lane, dryRun: args.includes("--dry-run") };
|
||||
const confirm = args.includes("--confirm");
|
||||
const dryRun = args.includes("--dry-run");
|
||||
if (confirm && dryRun) throw new Error("hwpod-preinstall accepts only one of --confirm or --dry-run");
|
||||
if (actionRaw === "gateway-apply" && !confirm && !dryRun) throw new Error("gateway-apply requires --dry-run or --confirm");
|
||||
if (actionRaw !== "gateway-apply" && confirm) throw new Error(`${actionRaw} is read-only and does not accept --confirm`);
|
||||
return { action: actionRaw, node, lane, dryRun, confirm };
|
||||
}
|
||||
|
||||
function hwpodPreinstallConfigPlan(spec: HwlabRuntimeLaneSpec, action: HwpodPreinstallAction, dryRun: boolean): HwpodPreinstallConfigPlan {
|
||||
@@ -221,6 +326,72 @@ function withHwpodPreinstallRendered(value: HwpodPreinstallConfigPlan): Rendered
|
||||
};
|
||||
}
|
||||
|
||||
function withHwpodGatewayRendered(value: HwpodGatewayPlan): RenderedCliResult {
|
||||
return {
|
||||
ok: value.ok,
|
||||
command: value.command,
|
||||
contentType: "text/plain",
|
||||
renderedText: renderHwpodGatewayPlan(value),
|
||||
};
|
||||
}
|
||||
|
||||
async function hwpodGatewayPlan(spec: HwlabRuntimeLaneSpec, options: HwpodPreinstallOptions): Promise<HwpodGatewayPlan> {
|
||||
const root = readHwpodPreinstallRoot(spec);
|
||||
const refs = root.present && root.enabled ? CONFIG_REF_KEYS.map((key) => readConfigRef(key, root.configRefs[key] ?? "")) : [];
|
||||
const graph = hwpodPreinstallConfigPlan(spec, "plan", true);
|
||||
const gateway = recordTarget(new Map(refs.map((ref) => [ref.key, ref])).get("gatewayProfile"));
|
||||
const profile = gatewayProfileSummary(gateway);
|
||||
const unsupportedLegacyLane = spec.lane === "v02" || spec.version === "v0.2";
|
||||
const blockers = [
|
||||
...(graph.ok ? [] : ["preinstall configRef graph is not ready; run hwpod-preinstall plan first"]),
|
||||
...(profile === null ? ["gatewayProfile configRef target is missing or invalid"] : []),
|
||||
...(unsupportedLegacyLane ? ["v0.2 is legacy-retire for 71-FREQ HWPOD gateway; no compatibility or fallback is retained"] : []),
|
||||
];
|
||||
const files = profile === null ? [] : gatewayRunnerFiles(profile).map((file) => ({
|
||||
path: file.path,
|
||||
kind: "runner" as const,
|
||||
bytes: Buffer.byteLength(file.content, "utf8"),
|
||||
sha256: sha256Fingerprint(file.content),
|
||||
}));
|
||||
let remoteStatus: HwpodGatewayRemoteStatus | null = null;
|
||||
let remoteApply: HwpodGatewayApplyResult | null = null;
|
||||
if (profile !== null && !unsupportedLegacyLane && (options.action === "gateway-status" || options.action === "gateway-apply")) {
|
||||
remoteStatus = readGatewayRemoteStatus(spec, profile);
|
||||
}
|
||||
if (profile !== null && !unsupportedLegacyLane && options.action === "gateway-apply" && options.confirm && blockers.length === 0) {
|
||||
const sourceFiles = readGatewaySourceFiles(spec);
|
||||
remoteApply = applyGatewayRuntime(spec, profile, sourceFiles, gatewayRunnerFiles(profile));
|
||||
remoteStatus = readGatewayRemoteStatus(spec, profile);
|
||||
}
|
||||
const remoteOk = remoteStatus === null || remoteStatus.ok;
|
||||
const applyOk = remoteApply === null || remoteApply.ok;
|
||||
const ok = blockers.length === 0 && remoteOk && applyOk;
|
||||
return {
|
||||
ok,
|
||||
command: `hwlab nodes hwpod-preinstall ${options.action} --node ${spec.nodeId} --lane ${spec.lane}${options.dryRun ? " --dry-run" : ""}${options.confirm ? " --confirm" : ""}`,
|
||||
status: remoteApply?.ok ? "applied" : ok ? "ready" : "blocked",
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
runtimeRoot: profile?.runtimeRoot ?? "-",
|
||||
profile,
|
||||
files,
|
||||
legacyRetire: [
|
||||
"v0.2 direct-url hwpod-node is not a compatibility target",
|
||||
"apply stops hwpod-node.ts connect processes for the selected node before starting the YAML-rendered v0.3 runner",
|
||||
"old .device-pod profiles and old cloud URLs are drift evidence only",
|
||||
],
|
||||
blockers,
|
||||
remoteStatus,
|
||||
remoteApply,
|
||||
next: {
|
||||
plan: `bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-plan --node ${spec.nodeId} --lane ${spec.lane} --dry-run`,
|
||||
status: `bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-status --node ${spec.nodeId} --lane ${spec.lane}`,
|
||||
apply: `bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-apply --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readHwpodPreinstallRoot(spec: HwlabRuntimeLaneSpec): HwpodPreinstallRoot {
|
||||
const rootFragment = `lanes.${spec.lane}.targets.${spec.nodeId}.hwpodPreinstall`;
|
||||
const root = `config/hwlab-node-lanes.yaml#${rootFragment}`;
|
||||
@@ -464,6 +635,269 @@ function summarizeTarget(key: HwpodPreinstallConfigRefKey, target: unknown): str
|
||||
return `keys=${Object.keys(target).length}`;
|
||||
}
|
||||
|
||||
function gatewayProfileSummary(gateway: Record<string, unknown> | null): HwpodGatewayProfileSummary | null {
|
||||
if (gateway === null) return null;
|
||||
const hwlabNode = stringAt(gateway, "node");
|
||||
const lane = stringAt(gateway, "lane");
|
||||
const cloudUrl = stringAt(gateway, "cloudUrl");
|
||||
const websocketUrl = stringAt(gateway, "nodeOps.websocketUrl");
|
||||
const hwpodId = stringAt(gateway, "hwpodId");
|
||||
const nodeId = stringAt(gateway, "nodeId");
|
||||
const gatewayId = stringAt(gateway, "gatewayId");
|
||||
const sessionId = stringAt(gateway, "sessionId");
|
||||
const resourceId = stringAt(gateway, "resourceId");
|
||||
const capabilityId = stringAt(gateway, "capabilityId");
|
||||
const runtimeRoot = stringAt(gateway, "managedRun.runtimeRoot");
|
||||
const bunPath = stringAt(gateway, "managedRun.bunPath");
|
||||
const taskName = stringAt(gateway, "managedRun.taskName");
|
||||
const periodicTaskName = stringAt(gateway, "managedRun.periodicTaskName");
|
||||
const runKeyName = stringAt(gateway, "managedRun.runKeyName");
|
||||
const startCommand = stringAt(gateway, "managedRun.startCommand");
|
||||
const statusCommand = stringAt(gateway, "managedRun.statusCommand");
|
||||
const processPattern = stringAt(gateway, "managedRun.processPattern");
|
||||
const reconnectBaseMs = numberAt(gateway, "managedRun.reconnectBaseMs");
|
||||
const reconnectMaxMs = numberAt(gateway, "managedRun.reconnectMaxMs");
|
||||
if (
|
||||
hwlabNode === null
|
||||
|| lane === null
|
||||
|| cloudUrl === null
|
||||
|| websocketUrl === null
|
||||
|| hwpodId === null
|
||||
|| nodeId === null
|
||||
|| gatewayId === null
|
||||
|| sessionId === null
|
||||
|| resourceId === null
|
||||
|| capabilityId === null
|
||||
|| runtimeRoot === null
|
||||
|| bunPath === null
|
||||
|| taskName === null
|
||||
|| periodicTaskName === null
|
||||
|| runKeyName === null
|
||||
|| startCommand === null
|
||||
|| statusCommand === null
|
||||
|| processPattern === null
|
||||
|| reconnectBaseMs === null
|
||||
|| reconnectMaxMs === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
hwlabNode,
|
||||
lane,
|
||||
cloudUrl,
|
||||
websocketUrl,
|
||||
hwpodId,
|
||||
nodeId,
|
||||
gatewayId,
|
||||
sessionId,
|
||||
resourceId,
|
||||
capabilityId,
|
||||
runtimeRoot,
|
||||
bunPath,
|
||||
taskName,
|
||||
periodicTaskName,
|
||||
runKeyName,
|
||||
startCommand,
|
||||
statusCommand,
|
||||
processPattern,
|
||||
reconnectBaseMs,
|
||||
reconnectMaxMs,
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayRunnerFiles(profile: HwpodGatewayProfileSummary): { readonly path: string; readonly content: string }[] {
|
||||
return [
|
||||
{ path: "run-node-ws-detached.cmd", content: gatewayRunDetachedCmd(profile) },
|
||||
{ path: "start-node-ws.cmd", content: gatewayStartCmd(profile) },
|
||||
{ path: "status-node-ws.cmd", content: gatewayStatusCmd(profile) },
|
||||
{ path: "install-node-ws.cmd", content: gatewayInstallCmd(profile) },
|
||||
{ path: "run-node-ws.vbs", content: gatewayRunVbs(profile) },
|
||||
{ path: "ensure-node-ws.vbs", content: gatewayEnsureVbs(profile) },
|
||||
];
|
||||
}
|
||||
|
||||
function readGatewayRemoteStatus(spec: HwlabRuntimeLaneSpec, profile: HwpodGatewayProfileSummary): HwpodGatewayRemoteStatus {
|
||||
const result = runTrans(spec, "win", ["ps"], gatewayRemoteStatusScript(profile), 30_000);
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "capture-failed",
|
||||
rootExists: false,
|
||||
expectedProcessCount: 0,
|
||||
legacyProcessCount: 0,
|
||||
taskStates: {},
|
||||
fileStates: {},
|
||||
legacyProcessCommands: [compactResultTail(result)],
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
const parsed = parseJsonFromStdout(result.stdout);
|
||||
if (parsed === null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "invalid-status-json",
|
||||
rootExists: false,
|
||||
expectedProcessCount: 0,
|
||||
legacyProcessCount: 0,
|
||||
taskStates: {},
|
||||
fileStates: {},
|
||||
legacyProcessCommands: [short(result.stdout, 500)],
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: parsed.ok === true,
|
||||
status: typeof parsed.status === "string" ? parsed.status : "unknown",
|
||||
rootExists: parsed.rootExists === true,
|
||||
expectedProcessCount: numberFromUnknown(parsed.expectedProcessCount),
|
||||
legacyProcessCount: numberFromUnknown(parsed.legacyProcessCount),
|
||||
taskStates: stringRecord(parsed.taskStates),
|
||||
fileStates: stringRecord(parsed.fileStates),
|
||||
legacyProcessCommands: stringArray(parsed.legacyProcessCommands).map((item) => short(item, 180)),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readGatewaySourceFiles(spec: HwlabRuntimeLaneSpec): { readonly path: string; readonly base64: string; readonly sha256: string; readonly bytes: number }[] {
|
||||
const result = runTrans(spec, spec.workspace, ["sh", "--", gatewaySourceBundleScript()], "", 30_000);
|
||||
if (result.exitCode !== 0) throw new Error(`failed to read HWLAB v0.3 hwpod-node source files from ${spec.nodeId}:${spec.workspace}: ${compactResultTail(result)}`);
|
||||
const parsed = parseJsonFromStdout(result.stdout);
|
||||
if (parsed === null || parsed.ok !== true || !Array.isArray(parsed.files)) throw new Error(`invalid hwpod-node source bundle output: ${short(result.stdout, 500)}`);
|
||||
return parsed.files.map((item) => {
|
||||
if (!isRecord(item)) throw new Error("invalid source file record");
|
||||
const path = stringAt(item, "path");
|
||||
const base64 = stringAt(item, "base64");
|
||||
const sha256 = stringAt(item, "sha256");
|
||||
const bytes = numberFromUnknown(item.bytes);
|
||||
if (path === null || base64 === null || sha256 === null || bytes <= 0) throw new Error("invalid source file fields");
|
||||
return { path, base64, sha256, bytes };
|
||||
});
|
||||
}
|
||||
|
||||
function applyGatewayRuntime(
|
||||
spec: HwlabRuntimeLaneSpec,
|
||||
profile: HwpodGatewayProfileSummary,
|
||||
sourceFiles: readonly { readonly path: string; readonly base64: string; readonly sha256: string; readonly bytes: number }[],
|
||||
runnerFiles: readonly { readonly path: string; readonly content: string }[],
|
||||
): HwpodGatewayApplyResult {
|
||||
const payload = {
|
||||
root: profile.runtimeRoot,
|
||||
profile,
|
||||
sourceFiles,
|
||||
runnerFiles: runnerFiles.map((file) => ({
|
||||
path: file.path,
|
||||
base64: Buffer.from(file.content, "utf8").toString("base64"),
|
||||
sha256: sha256Fingerprint(file.content),
|
||||
bytes: Buffer.byteLength(file.content, "utf8"),
|
||||
})),
|
||||
};
|
||||
const result = runTrans(spec, "win", ["ps"], gatewayApplyScript(payload), 60_000);
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "apply-failed",
|
||||
writtenFiles: [],
|
||||
stoppedLegacyPids: [],
|
||||
taskName: profile.taskName,
|
||||
periodicTaskName: profile.periodicTaskName,
|
||||
runTaskExitCode: null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
const parsed = parseJsonFromStdout(result.stdout);
|
||||
if (parsed === null) {
|
||||
return {
|
||||
ok: false,
|
||||
status: "invalid-apply-json",
|
||||
writtenFiles: [],
|
||||
stoppedLegacyPids: [],
|
||||
taskName: profile.taskName,
|
||||
periodicTaskName: profile.periodicTaskName,
|
||||
runTaskExitCode: null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: parsed.ok === true,
|
||||
status: typeof parsed.status === "string" ? parsed.status : "unknown",
|
||||
writtenFiles: stringArray(parsed.writtenFiles),
|
||||
stoppedLegacyPids: numberArray(parsed.stoppedLegacyPids),
|
||||
taskName: profile.taskName,
|
||||
periodicTaskName: profile.periodicTaskName,
|
||||
runTaskExitCode: typeof parsed.runTaskExitCode === "number" ? parsed.runTaskExitCode : null,
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHwpodGatewayPlan(value: HwpodGatewayPlan): string {
|
||||
const profileRows = value.profile === null
|
||||
? [["profile", "missing"]]
|
||||
: [
|
||||
["cloudUrl", value.profile.cloudUrl],
|
||||
["websocketUrl", value.profile.websocketUrl],
|
||||
["hwpodId", value.profile.hwpodId],
|
||||
["nodeId", value.profile.nodeId],
|
||||
["resourceId", value.profile.resourceId],
|
||||
["capabilityId", value.profile.capabilityId],
|
||||
["taskName", value.profile.taskName],
|
||||
["periodicTaskName", value.profile.periodicTaskName],
|
||||
["runKeyName", value.profile.runKeyName],
|
||||
["bunPath", value.profile.bunPath],
|
||||
["reconnectBaseMs", value.profile.reconnectBaseMs],
|
||||
["reconnectMaxMs", value.profile.reconnectMaxMs],
|
||||
["runtimeRoot", value.runtimeRoot],
|
||||
];
|
||||
const remoteRows = value.remoteStatus === null
|
||||
? [["remote", "not-read"]]
|
||||
: [
|
||||
["status", value.remoteStatus.status],
|
||||
["rootExists", value.remoteStatus.rootExists],
|
||||
["expectedProcessCount", value.remoteStatus.expectedProcessCount],
|
||||
["legacyProcessCount", value.remoteStatus.legacyProcessCount],
|
||||
["taskStates", Object.entries(value.remoteStatus.taskStates).map(([key, state]) => `${key}=${state}`).join(",") || "-"],
|
||||
["fileStates", Object.entries(value.remoteStatus.fileStates).map(([key, state]) => `${key}=${state}`).join(",") || "-"],
|
||||
];
|
||||
const applyRows = value.remoteApply === null
|
||||
? [["apply", "not-run"]]
|
||||
: [
|
||||
["status", value.remoteApply.status],
|
||||
["writtenFiles", value.remoteApply.writtenFiles.length],
|
||||
["stoppedLegacyPids", value.remoteApply.stoppedLegacyPids.join(",") || "-"],
|
||||
["runTaskExitCode", value.remoteApply.runTaskExitCode ?? "-"],
|
||||
];
|
||||
return [
|
||||
`hwlab nodes hwpod-preinstall ${commandAction(value.command)} (${value.status})`,
|
||||
"",
|
||||
hwpodTable(["NODE", "LANE", "OK", "RUNTIME"], [[value.node, value.lane, value.ok, value.runtimeRoot]]),
|
||||
"",
|
||||
"PROFILE",
|
||||
hwpodTable(["FIELD", "VALUE"], profileRows.map(([key, item]) => [key, short(String(item), 140)])),
|
||||
"",
|
||||
"RUNNER FILES",
|
||||
value.files.length === 0
|
||||
? " none"
|
||||
: hwpodTable(["PATH", "KIND", "BYTES", "HASH"], value.files.map((file) => [file.path, file.kind, file.bytes, `${file.sha256.slice(0, 19)}...`])),
|
||||
"",
|
||||
"REMOTE STATUS",
|
||||
hwpodTable(["FIELD", "VALUE"], remoteRows.map(([key, item]) => [key, short(String(item), 180)])),
|
||||
"",
|
||||
"REMOTE APPLY",
|
||||
hwpodTable(["FIELD", "VALUE"], applyRows.map(([key, item]) => [key, short(String(item), 180)])),
|
||||
"",
|
||||
"LEGACY RETIRE",
|
||||
...value.legacyRetire.map((item) => ` - ${item}`),
|
||||
...(value.remoteStatus !== null && value.remoteStatus.legacyProcessCommands.length > 0 ? [" legacyProcessCommands:", ...value.remoteStatus.legacyProcessCommands.map((item) => ` ${item}`)] : []),
|
||||
...(value.blockers.length === 0 ? [] : ["", "BLOCKERS", ...value.blockers.map((item) => ` - ${item}`)]),
|
||||
"",
|
||||
"NEXT",
|
||||
` plan: ${value.next.plan}`,
|
||||
` status: ${value.next.status}`,
|
||||
` apply: ${value.next.apply}`,
|
||||
"DISCLOSURE",
|
||||
" valuesRedacted=true; secret values, raw logs, full process dumps, and complete file content are not printed.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderHwpodPreinstallPlan(value: HwpodPreinstallConfigPlan): string {
|
||||
const refSummary = value.refs.length === 0
|
||||
? ["CONFIG REFS", " none (root disabled or not declared)"]
|
||||
@@ -556,6 +990,11 @@ function stringAt(value: unknown, path: string): string | null {
|
||||
return typeof found === "string" && found.length > 0 ? found : null;
|
||||
}
|
||||
|
||||
function numberAt(value: unknown, path: string): number | null {
|
||||
const found = valueAtPath(value, path);
|
||||
return typeof found === "number" && Number.isFinite(found) ? found : null;
|
||||
}
|
||||
|
||||
function textAt(value: unknown, path: string): string {
|
||||
const found = valueAtPath(value, path);
|
||||
if (typeof found === "string") return found;
|
||||
@@ -584,7 +1023,10 @@ function targetKindOf(value: unknown): "object" | "array" | "scalar" | "null" {
|
||||
}
|
||||
|
||||
function commandAction(command: string): string {
|
||||
return command.includes(" status ") ? "status" : "plan";
|
||||
for (const action of ["gateway-apply", "gateway-status", "gateway-plan", "status", "plan"]) {
|
||||
if (command.includes(` ${action} `) || command.endsWith(` ${action}`)) return action;
|
||||
}
|
||||
return "plan";
|
||||
}
|
||||
|
||||
function hwpodTable(headers: string[], rows: unknown[][]): string {
|
||||
@@ -610,3 +1052,280 @@ function lastPathSegment(value: string): string {
|
||||
const segment = normalized.split("/").filter(Boolean).pop();
|
||||
return segment ?? value;
|
||||
}
|
||||
|
||||
function sha256Fingerprint(value: string | Buffer): string {
|
||||
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
function runTrans(spec: HwlabRuntimeLaneSpec, planeOrWorkspace: string, args: string[], input: string, timeoutMs: number): CommandResult {
|
||||
const route = planeOrWorkspace === "win" ? `${spec.nodeId}:win` : `${spec.nodeId}:${planeOrWorkspace}`;
|
||||
return runCommand(["trans", route, ...args], repoRoot, { input, timeoutMs });
|
||||
}
|
||||
|
||||
function gatewaySourceBundleScript(): string {
|
||||
const evalScript = [
|
||||
"const fs=require('fs');",
|
||||
"const crypto=require('crypto');",
|
||||
"const paths=['tools/hwpod-node.ts','tools/src/hwpod-node-lib.ts','tools/src/hwpod-node-ops-contract.ts'];",
|
||||
"const files=paths.map((path)=>{const buf=fs.readFileSync(path);return {path,bytes:buf.length,sha256:'sha256:'+crypto.createHash('sha256').update(buf).digest('hex'),base64:buf.toString('base64')}});",
|
||||
"console.log(JSON.stringify({ok:true,files,valuesRedacted:true}));",
|
||||
].join("");
|
||||
return `bun --eval ${shQuote(evalScript)}`;
|
||||
}
|
||||
|
||||
function gatewayRemoteStatusScript(profile: HwpodGatewayProfileSummary): string {
|
||||
return `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = ${psQuote(profile.runtimeRoot)}
|
||||
$CloudUrl = ${psQuote(profile.cloudUrl)}
|
||||
$WsUrl = ${psQuote(profile.websocketUrl)}
|
||||
$NodeId = ${psQuote(profile.nodeId)}
|
||||
$TaskName = ${psQuote(profile.taskName)}
|
||||
$PeriodicTaskName = ${psQuote(profile.periodicTaskName)}
|
||||
$Files = @('tools\\hwpod-node.ts','tools\\src\\hwpod-node-lib.ts','tools\\src\\hwpod-node-ops-contract.ts','run-node-ws-detached.cmd','start-node-ws.cmd','status-node-ws.cmd','install-node-ws.cmd','run-node-ws.vbs','ensure-node-ws.vbs')
|
||||
function TaskState([string]$Name) {
|
||||
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
||||
if ($null -eq $task) { return 'missing' }
|
||||
return [string]$task.State
|
||||
}
|
||||
$fileStates = [ordered]@{}
|
||||
foreach ($rel in $Files) {
|
||||
$path = Join-Path $Root $rel
|
||||
if (Test-Path $path) {
|
||||
$hash = (Get-FileHash -Algorithm SHA256 $path).Hash.ToLowerInvariant()
|
||||
$fileStates[$rel] = 'sha256:' + $hash
|
||||
} else {
|
||||
$fileStates[$rel] = 'missing'
|
||||
}
|
||||
}
|
||||
$processes = @(Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { ($_.CommandLine -match 'hwpod-node\\.ts\\s+connect') -and ($_.CommandLine -match [regex]::Escape($NodeId)) })
|
||||
$expected = @($processes | Where-Object { ($_.CommandLine -match [regex]::Escape($CloudUrl)) -and ($_.CommandLine -match [regex]::Escape($WsUrl)) })
|
||||
$legacy = @($processes | Where-Object { -not (($_.CommandLine -match [regex]::Escape($CloudUrl)) -and ($_.CommandLine -match [regex]::Escape($WsUrl))) })
|
||||
$taskStates = [ordered]@{
|
||||
$TaskName = TaskState $TaskName
|
||||
$PeriodicTaskName = TaskState $PeriodicTaskName
|
||||
}
|
||||
$rootExists = Test-Path $Root
|
||||
$filesReady = -not ($fileStates.Values | Where-Object { $_ -eq 'missing' } | Select-Object -First 1)
|
||||
$tasksReady = -not ($taskStates.Values | Where-Object { $_ -eq 'missing' } | Select-Object -First 1)
|
||||
$ok = $rootExists -and $filesReady -and $tasksReady -and ($expected.Count -gt 0) -and ($legacy.Count -eq 0)
|
||||
$result = [ordered]@{
|
||||
ok = [bool]$ok
|
||||
status = $(if ($ok) { 'ready' } elseif ($legacy.Count -gt 0) { 'legacy-drift' } else { 'blocked' })
|
||||
rootExists = [bool]$rootExists
|
||||
expectedProcessCount = [int]$expected.Count
|
||||
legacyProcessCount = [int]$legacy.Count
|
||||
taskStates = $taskStates
|
||||
fileStates = $fileStates
|
||||
legacyProcessCommands = @($legacy | ForEach-Object { $_.CommandLine })
|
||||
valuesRedacted = $true
|
||||
}
|
||||
$result | ConvertTo-Json -Compress -Depth 8
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function gatewayApplyScript(payload: Record<string, unknown>): string {
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
return `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$payloadJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String(${psQuote(payloadB64)}))
|
||||
$payload = $payloadJson | ConvertFrom-Json
|
||||
$Root = [string]$payload.root
|
||||
$CloudUrl = [string]$payload.profile.cloudUrl
|
||||
$WsUrl = [string]$payload.profile.websocketUrl
|
||||
$NodeId = [string]$payload.profile.nodeId
|
||||
$TaskName = [string]$payload.profile.taskName
|
||||
$PeriodicTaskName = [string]$payload.profile.periodicTaskName
|
||||
$RunKeyName = [string]$payload.profile.runKeyName
|
||||
$RunVbs = Join-Path $Root 'run-node-ws.vbs'
|
||||
$EnsureVbs = Join-Path $Root 'ensure-node-ws.vbs'
|
||||
$RunTask = 'wscript.exe //B //Nologo "' + $RunVbs + '"'
|
||||
$EnsureTask = 'wscript.exe //B //Nologo "' + $EnsureVbs + '"'
|
||||
New-Item -ItemType Directory -Force -Path $Root, (Join-Path $Root 'logs'), (Join-Path $Root '.state'), (Join-Path $Root 'tools'), (Join-Path $Root 'tools\\src') | Out-Null
|
||||
function WritePayloadFile($item) {
|
||||
$target = Join-Path $Root ([string]$item.path)
|
||||
$parent = Split-Path -Parent $target
|
||||
if (-not [string]::IsNullOrWhiteSpace($parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
|
||||
$bytes = [System.Convert]::FromBase64String([string]$item.base64)
|
||||
[System.IO.File]::WriteAllBytes($target, $bytes)
|
||||
$actual = 'sha256:' + (Get-FileHash -Algorithm SHA256 $target).Hash.ToLowerInvariant()
|
||||
if ($actual -ne [string]$item.sha256) { throw "sha mismatch for $target expected=$($item.sha256) actual=$actual" }
|
||||
return [string]$item.path
|
||||
}
|
||||
$stopped = @()
|
||||
$processes = @(Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { ($_.CommandLine -match 'hwpod-node\\.ts\\s+connect') -and ($_.CommandLine -match [regex]::Escape($NodeId)) })
|
||||
foreach ($proc in $processes) {
|
||||
$stopped += [int]$proc.ProcessId
|
||||
Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$written = @()
|
||||
foreach ($item in @($payload.sourceFiles)) { $written += WritePayloadFile $item }
|
||||
foreach ($item in @($payload.runnerFiles)) { $written += WritePayloadFile $item }
|
||||
$runCreate = schtasks /Create /TN $TaskName /SC ONCE /ST 00:00 /TR $RunTask /F 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "run task create failed: $runCreate" }
|
||||
$periodicCreate = schtasks /Create /TN $PeriodicTaskName /SC MINUTE /MO 5 /TR $EnsureTask /F 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "periodic task create failed: $periodicCreate" }
|
||||
$runKey = reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v $RunKeyName /t REG_SZ /d $EnsureTask /f 2>&1
|
||||
if ($LASTEXITCODE -ne 0) { throw "HKCU Run update failed: $runKey" }
|
||||
$runOut = schtasks /Run /TN $TaskName 2>&1
|
||||
$runExit = $LASTEXITCODE
|
||||
Start-Sleep -Seconds 3
|
||||
$expected = @(Get-CimInstance Win32_Process -Filter "Name='bun.exe'" | Where-Object { ($_.CommandLine -match 'hwpod-node\\.ts\\s+connect') -and ($_.CommandLine -match [regex]::Escape($NodeId)) -and ($_.CommandLine -match [regex]::Escape($CloudUrl)) -and ($_.CommandLine -match [regex]::Escape($WsUrl)) })
|
||||
$result = [ordered]@{
|
||||
ok = [bool](($runExit -eq 0) -and ($expected.Count -gt 0))
|
||||
status = $(if (($runExit -eq 0) -and ($expected.Count -gt 0)) { 'applied' } else { 'started-pending' })
|
||||
writtenFiles = $written
|
||||
stoppedLegacyPids = $stopped
|
||||
taskName = $TaskName
|
||||
periodicTaskName = $PeriodicTaskName
|
||||
runTaskExitCode = $runExit
|
||||
valuesRedacted = $true
|
||||
}
|
||||
$result | ConvertTo-Json -Compress -Depth 8
|
||||
`.trim();
|
||||
}
|
||||
|
||||
function gatewayRunDetachedCmd(profile: HwpodGatewayProfileSummary): string {
|
||||
return crlf([
|
||||
"@echo off",
|
||||
"setlocal EnableExtensions",
|
||||
"chcp 65001 >nul",
|
||||
`set "ROOT=${profile.runtimeRoot}"`,
|
||||
`set "HWLAB_GATEWAY_ID=${profile.gatewayId}"`,
|
||||
`set "HWLAB_GATEWAY_SESSION_ID=${profile.sessionId}"`,
|
||||
`set "HWLAB_GATEWAY_RESOURCE_ID=${profile.resourceId}"`,
|
||||
`set "HWLAB_GATEWAY_CMD_CAPABILITY_ID=${profile.capabilityId}"`,
|
||||
`set "HWLAB_HWPOD_ID=${profile.hwpodId}"`,
|
||||
`set "HWLAB_HWPOD_NODE_ID=${profile.nodeId}"`,
|
||||
`set "HWLAB_HWPOD_NODE_WS_URL=${profile.websocketUrl}"`,
|
||||
`set "HWLAB_HWPOD_NODE_CLOUD_URL=${profile.cloudUrl}"`,
|
||||
`set "BUN_EXE=${profile.bunPath}"`,
|
||||
"cd /d \"%ROOT%\" || exit /b 2",
|
||||
"if not exist logs mkdir logs",
|
||||
"if not exist .state mkdir .state",
|
||||
"echo {\"ok\":true,\"action\":\"hwpod-node.ws.v03.runner\",\"status\":\"starting\"}>>\"%ROOT%\\logs\\hwpod-node-ws.lifecycle.log\"",
|
||||
`"${"%BUN_EXE%"}" tools\\hwpod-node.ts connect --ws-url ${profile.websocketUrl} --cloud-url ${profile.cloudUrl} --node-id ${profile.nodeId} --reconnect-base-ms ${profile.reconnectBaseMs} --reconnect-max-ms ${profile.reconnectMaxMs} 1>>"%ROOT%\\logs\\hwpod-node-ws.stdout.log" 2>>"%ROOT%\\logs\\hwpod-node-ws.stderr.log"`,
|
||||
"set \"EXIT_CODE=%ERRORLEVEL%\"",
|
||||
"echo {\"ok\":false,\"action\":\"hwpod-node.ws.v03.runner\",\"status\":\"exited\",\"exitCode\":%EXIT_CODE%}>>\"%ROOT%\\logs\\hwpod-node-ws.lifecycle.log\"",
|
||||
"exit /b %EXIT_CODE%",
|
||||
]);
|
||||
}
|
||||
|
||||
function gatewayStartCmd(profile: HwpodGatewayProfileSummary): string {
|
||||
return crlf([
|
||||
"@echo off",
|
||||
"setlocal EnableExtensions",
|
||||
"chcp 65001 >nul",
|
||||
`set "ROOT=${profile.runtimeRoot}"`,
|
||||
`set "TASK_NAME=${profile.taskName}"`,
|
||||
"cd /d \"%ROOT%\" || (",
|
||||
" echo {\"ok\":false,\"action\":\"hwpod-node.ws.v03.start\",\"status\":\"blocked\",\"message\":\"runtime root not found\"}",
|
||||
" exit /b 2",
|
||||
")",
|
||||
"schtasks /Run /TN \"%TASK_NAME%\" > .state\\hwpod-node-ws.schtasks-run.txt 2>&1",
|
||||
"set \"RUN_EXIT=%ERRORLEVEL%\"",
|
||||
"ping -n 4 127.0.0.1 >nul",
|
||||
`echo {"ok":true,"action":"hwpod-node.ws.v03.start","status":"submitted","taskName":"${jsonEscape(profile.taskName)}","cloudUrl":"${jsonEscape(profile.cloudUrl)}","nodeId":"${jsonEscape(profile.nodeId)}","runExit":%RUN_EXIT%}`,
|
||||
"exit /b %RUN_EXIT%",
|
||||
]);
|
||||
}
|
||||
|
||||
function gatewayStatusCmd(profile: HwpodGatewayProfileSummary): string {
|
||||
return crlf([
|
||||
"@echo off",
|
||||
"setlocal EnableExtensions",
|
||||
"chcp 65001 >nul",
|
||||
`echo {"ok":true,"action":"hwpod-node.ws.v03.status","root":"${jsonEscape(profile.runtimeRoot)}","cloudUrl":"${jsonEscape(profile.cloudUrl)}","websocketUrl":"${jsonEscape(profile.websocketUrl)}","nodeId":"${jsonEscape(profile.nodeId)}","statusCommand":"bun scripts/cli.ts hwlab nodes hwpod-preinstall gateway-status --node ${jsonEscape(profile.hwlabNode)} --lane ${jsonEscape(profile.lane)}","valuesRedacted":true}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function gatewayInstallCmd(profile: HwpodGatewayProfileSummary): string {
|
||||
return crlf([
|
||||
"@echo off",
|
||||
"setlocal EnableExtensions",
|
||||
"chcp 65001 >nul",
|
||||
`set "ROOT=${profile.runtimeRoot}"`,
|
||||
`set "TASK_NAME=${profile.taskName}"`,
|
||||
`set "PERIODIC_TASK_NAME=${profile.periodicTaskName}"`,
|
||||
`set "RUN_KEY_NAME=${profile.runKeyName}"`,
|
||||
"set \"RUN_TASK=wscript.exe //B //Nologo %ROOT%\\run-node-ws.vbs\"",
|
||||
"set \"ENSURE_TASK=wscript.exe //B //Nologo %ROOT%\\ensure-node-ws.vbs\"",
|
||||
"cd /d \"%ROOT%\" || exit /b 2",
|
||||
"schtasks /Create /TN \"%TASK_NAME%\" /SC ONCE /ST 00:00 /TR \"%RUN_TASK%\" /F || exit /b 2",
|
||||
"schtasks /Create /TN \"%PERIODIC_TASK_NAME%\" /SC MINUTE /MO 5 /TR \"%ENSURE_TASK%\" /F || exit /b 2",
|
||||
"reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v \"%RUN_KEY_NAME%\" /t REG_SZ /d \"%ENSURE_TASK%\" /f || exit /b 2",
|
||||
`echo {"ok":true,"action":"hwpod-node.ws.v03.install","status":"installed","taskName":"${jsonEscape(profile.taskName)}","periodicTaskName":"${jsonEscape(profile.periodicTaskName)}","valuesRedacted":true}`,
|
||||
"exit /b 0",
|
||||
]);
|
||||
}
|
||||
|
||||
function gatewayRunVbs(profile: HwpodGatewayProfileSummary): string {
|
||||
return crlf([
|
||||
"Set sh = CreateObject(\"WScript.Shell\")",
|
||||
`sh.CurrentDirectory = "${vbsEscape(profile.runtimeRoot)}"`,
|
||||
`sh.Run "cmd.exe /c ""${vbsEscape(`${profile.runtimeRoot}\\run-node-ws-detached.cmd`)}""", 0, False`,
|
||||
]);
|
||||
}
|
||||
|
||||
function gatewayEnsureVbs(profile: HwpodGatewayProfileSummary): string {
|
||||
return crlf([
|
||||
"Set sh = CreateObject(\"WScript.Shell\")",
|
||||
`sh.CurrentDirectory = "${vbsEscape(profile.runtimeRoot)}"`,
|
||||
`sh.Run "cmd.exe /c ""${vbsEscape(`${profile.runtimeRoot}\\start-node-ws.cmd`)}""", 0, False`,
|
||||
]);
|
||||
}
|
||||
|
||||
function parseJsonFromStdout(stdout: string): Record<string, unknown> | null {
|
||||
const start = stdout.indexOf("{");
|
||||
const end = stdout.lastIndexOf("}");
|
||||
if (start === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.slice(start, end + 1)) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compactResultTail(result: CommandResult): string {
|
||||
return short(`exit=${result.exitCode} timedOut=${result.timedOut} stdout=${result.stdout.slice(-400)} stderr=${result.stderr.slice(-400)}`, 900);
|
||||
}
|
||||
|
||||
function numberFromUnknown(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function numberArray(value: unknown): number[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is number => typeof item === "number" && Number.isFinite(item)) : [];
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
function stringRecord(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, item] of Object.entries(value)) result[key] = typeof item === "string" ? item : String(item);
|
||||
return result;
|
||||
}
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function psQuote(value: string): string {
|
||||
return `'${value.replace(/'/gu, "''")}'`;
|
||||
}
|
||||
|
||||
function crlf(lines: readonly string[]): string {
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
function jsonEscape(value: string): string {
|
||||
return value.replace(/\\/gu, "\\\\").replace(/"/gu, "\\\"");
|
||||
}
|
||||
|
||||
function vbsEscape(value: string): string {
|
||||
return value.replace(/"/gu, "\"\"");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user